--- name: godot-adapt-2d-to-3d description: "Expert patterns for migrating 2D games to 3D including node type conversions, camera systems (third-person, first-person, orbit), physics layer migration, sprite-to-model art pipeline, and control scheme adaptations. Use when porting 2D projects to 3D or adding 3D elements. Trigger keywords: CharacterBody2D to CharacterBody3D, Area2D to Area3D, Camera2D to Camera3D, Vector2 to Vector3, collision_layer migration, sprite to MeshInstance3D, 2D to 3D conversion." --- # 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. - **NEVER use the rotation property for complex 3D logic** — 3D rotation uses Euler angles. Interpolating Euler angles causes unpredictable paths and Gimbal Lock. Always use `Quaternion` for 3D rotation interpolation or the `Basis` matrix for directional vectors. - **NEVER ignore metric scaling** — 3D physics and lighting assume 1 unit = 1 meter. Scaling models inside the engine introduces precision errors. Export assets from DCCs at the correct metric scale. - **NEVER disable physics interpolation when using custom camera follow scripts** — Updating camera position in `_process` to follow a body moving in `_physics_process` causes jitter. Use `Node3D.get_global_transform_interpolated()` for smooth transforms. --- ## Available Scripts > **MANDATORY**: Read the appropriate script before implementing the corresponding pattern. ### [sprite_plane.gd](scripts/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](scripts/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. ### [crisp_projected_ui.gd](scripts/crisp_projected_ui.gd) Projected 2D UI for 3D Objects mapping snippet. Replaces blurry text elements with true 2D Canvas space positioning projected from 3D space. --- ## 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 ```gdscript # 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 ```gdscript # ❌ 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 ```gdscript # 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) ```gdscript # 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) ```gdscript # 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) ```gdscript # 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 ```gdscript # 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 ```gdscript # ✅ 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 ```gdscript # 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 ```gdscript # 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 ```gdscript # 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 ```gdscript # 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 ```gdscript # 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 | --- ## Expert Techniques & Optimizations ### 1. Vector Math over Euler Angles When moving a 3D character, rely heavily on `Transform3D` basis vectors rather than calculating trigonometric angles. To move forward locally, extract the negative Z-axis of your transform's basis: `velocity = transform.basis.z * speed`. ### 2. Understanding Coordinate Discrepancies In 2D, the Y-axis points down. In 3D, Godot uses a right-handed system where Y-axis points UP, and forward is -Z. Translating 2D jumps to 3D requires inverting the Y velocity logic (e.g., `velocity.y = JUMP_SPEED` instead of `-JUMP_SPEED`). ### 3. 2.5D Navigation (Camera-Projected Paths) For 2.5D games where actors move on a 3D floor but are displayed as 2D sprites, query the `NavigationServer3D` directly and project the resulting `PackedVector3Array` into 2D screen space (or a flattened gameplay plane) using `Camera3D.unproject_position`. ```gdscript class_name NavigationBridge2D5D extends Node ## Projects 3D NavigationServer paths to 2D screenspace for 2.5D movement. static func query_2_5d_path(camera: Camera3D, map_rid: RID, start_2d: Vector2, target_2d: Vector2) -> PackedVector2Array: # 1. Project 2D screen points to the 3D ground plane (Y=0). var start_3d := camera.project_position(start_2d, 0.0) var target_3d := camera.project_position(target_2d, 0.0) # 2. Query optimized 3D path. var path_3d := NavigationServer3D.map_get_path(map_rid, start_3d, target_3d, true) # 3. Project 3D world points back to 2D screenspace coordinates for the sprite. var path_2d := PackedVector2Array() for point in path_3d: path_2d.append(camera.unproject_position(point)) return path_2d ``` ### 4. Shader-Based Billboarding (Massive Crowd Rendering) To render millions of instances, use `MultiMeshInstance3D` paired with a custom Visual Shader. Use `VisualShaderNodeBillboard` with `BILLBOARD_TYPE_FIXED_Y` to ensure sprites stay upright on flat terrain. ```gdscript class_name MassiveCrowdManager extends MultiMeshInstance3D ## Efficiently manages millions of camera-facing instances via GPU hardware. func _ready() -> void: # 1. Configure the MultiMesh for 3D transforms. multimesh = MultiMesh.new() multimesh.transform_format = MultiMesh.TRANSFORM_3D multimesh.instance_count = 10000 # 2. Build a ShaderMaterial using VisualShaderNodeBillboard. var material := ShaderMaterial.new() # Note: Logic assumes billboard_type=BILLBOARD_TYPE_FIXED_Y and keep_scale=true. multimesh.mesh = QuadMesh.new() multimesh.mesh.surface_set_material(0, material) # 3. Populate transforms. The GPU handles orientation. for i in range(multimesh.instance_count): var pos := Vector3(randf() * 100, 0, randf() * 100) multimesh.set_instance_transform(i, Transform3D(Basis(), pos)) ``` ### 5. Lighting Migration Tool (2D to 3D Converter) A robust `EditorScript` for mapping `PointLight2D` properties to `OmniLight3D`. Uses `EditorInterface.get_edited_scene_root()` to ensure changes are tracked by the editor. ```gdscript @tool class_name LightMigrationTool extends EditorScript ## Converts PointLight2D nodes in the active scene to OmniLight3D. func _run() -> void: var root := EditorInterface.get_edited_scene_root() if not root: return _migrate_node(root) func _migrate_node(node: Node) -> void: if node is PointLight2D: var l3d := OmniLight3D.new() l3d.light_color = node.color l3d.light_energy = node.energy # Approximate 3D range from 2D texture radius * scale var radius := 128.0 # Default fallback if node.texture: radius = node.texture.get_width() / 2.0 l3d.omni_range = radius * node.texture_scale # Mapping 2D (x, y) to 3D (x, y, height) l3d.position = Vector3(node.position.x, node.position.y, node.height) node.get_parent().add_child(l3d) l3d.owner = EditorInterface.get_edited_scene_root() l3d.name = node.name + "_3D" for child in node.get_children(): _migrate_node(child) ``` ## Reference - Master Skill: [godot-master](../godot-master/SKILL.md)