- Adapt: 2D to 3D
- Expert guidance for migrating 2D games into the third dimension.
- NEVER Do
- NEVER directly replace Vector2 with Vector3(x, y, 0)
- — This creates a "flat 3D" game with no depth gameplay. Add Z-axis movement or camera rotation to justify 3D.
- NEVER keep 2D collision layers
- — 2D and 3D physics use separate layer systems. You must reconfigure collision_layer/collision_mask for 3D nodes.
- NEVER forget to add lighting
- — 3D without lights is pitch black (unless using unlit materials). Add at least one DirectionalLight3D.
- NEVER use Camera2D follow logic in 3D
- — Camera3D needs spring arm or look-at logic. Direct position copying causes clipping and disorientation.
- NEVER assume same performance
- — 3D is 5-10x more demanding. Budget for lower draw calls, smaller viewport resolution on mobile.
- Available Scripts
- MANDATORY
- Read the appropriate script before implementing the corresponding pattern. sprite_plane.gd Sprite3D billboard configuration and world-to-screen projection for placing 2D UI over 3D objects. Handles behind-camera detection. vector_mapping.gd Static utility for 2D→3D vector translation. The Y-to-Z rule: 2D Y (down) maps to 3D Z (forward). Essential for movement code. Node Conversion Matrix 2D Node 3D Equivalent Notes CharacterBody2D CharacterBody3D Add Z-axis movement, rotate with mouse RigidBody2D RigidBody3D Gravity now Vector3(0, -9.8, 0) StaticBody2D StaticBody3D Collision shapes use Shape3D Area2D Area3D Triggers work the same way Sprite2D MeshInstance3D + QuadMesh Or use Sprite3D (billboarded) AnimatedSprite2D AnimatedSprite3D Billboard mode available TileMapLayer GridMap Requires MeshLibrary creation Camera2D Camera3D Requires repositioning logic CollisionShape2D CollisionShape3D BoxShape2D → BoxShape3D, etc. RayCast2D RayCast3D target_position is now Vector3 Migration Steps Step 1: Physics Layer Reconfiguration
2D collision layers are SEPARATE from 3D
You must reconfigure in Project Settings → Layer Names → 3D Physics
Before (2D):
Layer 1: Player
Layer 2: Enemies
Layer 3: World
After (3D) - same names, but different system
In code, update all collision layer references:
2D version:
collision_layer = 0b0001
3D version (same logic, different node):
var character_3d := CharacterBody3D . new ( ) character_3d . collision_layer = 0b0001
Layer 1: Player
character_3d . collision_mask = 0b0110
Detect Enemies + World
Step 2: Camera Conversion
❌ BAD: Direct 2D follow logic
extends Camera3D @ onready var player : Node3D = $ "../Player" func _process ( delta : float ) -> void : global_position = player . global_position
Clipping, disorienting!
✅ GOOD: Third-person camera with SpringArm3D
Scene structure:
Player (CharacterBody3D)
└─ SpringArm3D
└─ Camera3D
player.gd
extends CharacterBody3D @ onready var spring_arm : SpringArm3D = $SpringArm3D @ onready var camera : Camera3D = $SpringArm3D / Camera3D func _ready ( ) -> void : spring_arm . spring_length = 10.0
Distance from player
spring_arm . position = Vector3 ( 0 , 2 , 0 )
Above player
func _unhandled_input ( event : InputEvent ) -> void : if event is InputEventMouseMotion : spring_arm . rotate_y ( - event . relative . x * 0.005 )
Horizontal rotation
spring_arm . rotate_object_local ( Vector3 . RIGHT , - event . relative . y * 0.005 )
Vertical
Clamp vertical rotation
spring_arm . rotation . x = clamp ( spring_arm . rotation . x , - PI / 3 , PI / 6 ) Step 3: Movement Conversion
2D platformer movement
extends CharacterBody2D const SPEED = 300.0 const JUMP_VELOCITY = - 400.0 func _physics_process ( delta : float ) -> void : if not is_on_floor ( ) : velocity . y += gravity * delta if Input . is_action_just_pressed ( "jump" ) and is_on_floor ( ) : velocity . y = JUMP_VELOCITY var direction := Input . get_axis ( "left" , "right" ) velocity . x = direction * SPEED move_and_slide ( )
✅ 3D equivalent (third-person platformer)
extends CharacterBody3D const SPEED = 5.0 const JUMP_VELOCITY = 4.5 const GRAVITY = 9.8 @ onready var spring_arm : SpringArm3D = $SpringArm3D func _physics_process ( delta : float ) -> void : if not is_on_floor ( ) : velocity . y -= GRAVITY * delta if Input . is_action_just_pressed ( "jump" ) and is_on_floor ( ) : velocity . y = JUMP_VELOCITY
Movement relative to camera direction
var input_dir := Input . get_vector ( "left" , "right" , "forward" , "back" ) var camera_basis := spring_arm . global_transform . basis var direction := ( camera_basis * Vector3 ( input_dir . x , 0 , input_dir . y ) ) . normalized ( ) if direction : velocity . x = direction . x * SPEED velocity . z = direction . z * SPEED
Rotate player to face movement direction
rotation . y = lerp_angle ( rotation . y , atan2 ( - direction . x , - direction . z ) , 0.1 ) else : velocity . x = move_toward ( velocity . x , 0 , SPEED ) velocity . z = move_toward ( velocity . z , 0 , SPEED ) move_and_slide ( ) Art Pipeline: Sprites → 3D Models Option 1: Billboard Sprites (2.5D)
Use Sprite3D for quick conversion
extends Sprite3D func _ready ( ) -> void : texture = load ( "res://sprites/character.png" ) billboard = BaseMaterial3D . BILLBOARD_ENABLED
Always face camera
pixel_size
0.01
Scale sprite in 3D space
Option 2: Quad Meshes (Floating Sprites)
Create textured quads
var mesh_instance := MeshInstance3D . new ( ) var quad := QuadMesh . new ( ) quad . size = Vector2 ( 1 , 1 ) mesh_instance . mesh = quad var material := StandardMaterial3D . new ( ) material . albedo_texture = load ( "res://sprites/character.png" ) material . transparency = BaseMaterial3D . TRANSPARENCY_ALPHA material . cull_mode = BaseMaterial3D . CULL_DISABLED
Show both sides
mesh_instance . material_override = material Option 3: Full 3D Models (Blender/Asset Library)
Import .glb, .fbx models
var character := load ( "res://models/character.glb" ) . instantiate ( ) add_child ( character )
Access animations
var anim_player := character . get_node ( "AnimationPlayer" ) anim_player . play ( "idle" ) Lighting Considerations Minimum Lighting Setup
Add to main scene
var sun := DirectionalLight3D . new ( ) sun . rotation_degrees = Vector3 ( - 45 , 30 , 0 ) sun . light_energy = 1.0 sun . shadow_enabled = true add_child ( sun )
Ambient light
var env := WorldEnvironment . new ( ) var environment := Environment . new ( ) environment . ambient_light_source = Environment . AMBIENT_SOURCE_COLOR environment . ambient_light_color = Color ( 0.3 , 0.3 , 0.4 )
Subtle blue
environment . ambient_light_energy = 0.5 env . environment = environment add_child ( env ) UI Adaptation
✅ GOOD: Keep 2D UI overlay
Scene structure:
Main (Node3D)
├─ WorldEnvironment
├─ DirectionalLight3D
├─ Player (CharacterBody3D)
└─ CanvasLayer # 2D UI on top of 3D world
└─ Control (HUD)
UI remains 2D (Control nodes, Sprite2D for HUD elements)
Performance Budgeting 2D vs 3D Performance Metric 2D Budget 3D Budget Notes Draw calls 100-200 50-100 Use fewer meshes Vertices Unlimited 100K-500K LOD important Lights N/A 3-5 shadowed Expensive Transparent objects Many <10 Sorting overhead Particle systems Many 2-3 max GPU godot-particles only Optimization Checklist
1. Use LOD for distant objects
var mesh_instance := MeshInstance3D . new ( ) mesh_instance . lod_bias = 1.0
Lower detail sooner
2. Occlusion culling
Use OccluderInstance3D for large walls/buildings
3. Reduce shadow distance
var sun := DirectionalLight3D . new ( ) sun . directional_shadow_max_distance = 50.0
Don't render far shadows
4. Use unlit materials for distant objects
var material := StandardMaterial3D . new ( ) material . shading_mode = BaseMaterial3D . SHADING_MODE_UNSHADED Input Scheme Changes 2D → 3D Input Mapping
2D: left/right for horizontal movement
Input . get_axis ( "left" , "right" )
3D: Add forward/back, use get_vector()
var input := Input . get_vector ( "left" , "right" , "forward" , "back" )
Returns Vector2(horizontal, vertical) for 3D movement
Configure in Project Settings → Input Map:
forward: W, Up Arrow
back: S, Down Arrow
left: A, Left Arrow
right: D, Right Arrow
Mouse look (lock cursor)
func _ready ( ) -> void : Input . mouse_mode = Input . MOUSE_MODE_CAPTURED func _input ( event : InputEvent ) -> void : if event is InputEventMouseMotion and Input . mouse_mode == Input . MOUSE_MODE_CAPTURED : rotate_camera ( event . relative ) Edge Cases Physics Not Working
Problem: Forgot to set collision layers for 3D
Solution: Reconfigure layers
var body := CharacterBody3D . new ( ) body . collision_layer = 0b0001
What AM I?
body . collision_mask = 0b0110
What do I DETECT?
Camera Clipping Through Walls
SpringArm3D automatically pulls camera forward when obstructed
spring_arm . spring_length = 10.0 spring_arm . collision_mask = 0b0100
Layer 3: World
Player Falling Through Floor
Problem: StaticBody3D floor has no CollisionShape3D
Solution: Add collision
var floor_collision := CollisionShape3D . new ( ) var box_shape := BoxShape3D . new ( ) box_shape . size = Vector3 ( 100 , 1 , 100 ) floor_collision . shape = box_shape floor . add_child ( floor_collision ) Decision Tree: When to Go 3D Factor Stay 2D Go 3D Gameplay Platformer, top-down, no depth needed Exploration, first-person, 3D space combat Art budget Pixel art, limited resources 3D models available or necessary Performance target Mobile, web, low-end Desktop, console, high-end mobile Development time Limited Have time for 3D learning curve Team skills 2D artists only 3D artists or asset library Reference Master Skill: godot-master