Skill v1.0.1
Automated scan100/1001 files
version: "1.0.1" 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
Quaternionfor 3D rotation interpolation or theBasismatrix 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
_processto follow a body moving in_physics_processcauses jitter. UseNode3D.get_global_transform_interpolated()for smooth transforms.
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.
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
# 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: Playercharacter_3d.collision_mask = 0b0110 # Detect Enemies + World
Step 2: Camera Conversion
# ❌ BAD: Direct 2D follow logicextends 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.gdextends CharacterBody3D@onready var spring_arm: SpringArm3D = $SpringArm3D@onready var camera: Camera3D = $SpringArm3D/Camera3Dfunc _ready() -> void:spring_arm.spring_length = 10.0 # Distance from playerspring_arm.position = Vector3(0, 2, 0) # Above playerfunc _unhandled_input(event: InputEvent) -> void:if event is InputEventMouseMotion:spring_arm.rotate_y(-event.relative.x * 0.005) # Horizontal rotationspring_arm.rotate_object_local(Vector3.RIGHT, -event.relative.y * 0.005) # Vertical# Clamp vertical rotationspring_arm.rotation.x = clamp(spring_arm.rotation.x, -PI/3, PI/6)
Step 3: Movement Conversion
# 2D platformer movementextends CharacterBody2Dconst SPEED = 300.0const JUMP_VELOCITY = -400.0func _physics_process(delta: float) -> void:if not is_on_floor():velocity.y += gravity * deltaif Input.is_action_just_pressed("jump") and is_on_floor():velocity.y = JUMP_VELOCITYvar direction := Input.get_axis("left", "right")velocity.x = direction * SPEEDmove_and_slide()# ✅ 3D equivalent (third-person platformer)extends CharacterBody3Dconst SPEED = 5.0const JUMP_VELOCITY = 4.5const GRAVITY = 9.8@onready var spring_arm: SpringArm3D = $SpringArm3Dfunc _physics_process(delta: float) -> void:if not is_on_floor():velocity.y -= GRAVITY * deltaif Input.is_action_just_pressed("jump") and is_on_floor():velocity.y = JUMP_VELOCITY# Movement relative to camera directionvar input_dir := Input.get_vector("left", "right", "forward", "back")var camera_basis := spring_arm.global_transform.basisvar direction := (camera_basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()if direction:velocity.x = direction.x * SPEEDvelocity.z = direction.z * SPEED# Rotate player to face movement directionrotation.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 conversionextends Sprite3Dfunc _ready() -> void:texture = load("res://sprites/character.png")billboard = BaseMaterial3D.BILLBOARD_ENABLED # Always face camerapixel_size = 0.01 # Scale sprite in 3D space
Option 2: Quad Meshes (Floating Sprites)
# Create textured quadsvar mesh_instance := MeshInstance3D.new()var quad := QuadMesh.new()quad.size = Vector2(1, 1)mesh_instance.mesh = quadvar material := StandardMaterial3D.new()material.albedo_texture = load("res://sprites/character.png")material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHAmaterial.cull_mode = BaseMaterial3D.CULL_DISABLED # Show both sidesmesh_instance.material_override = material
Option 3: Full 3D Models (Blender/Asset Library)
# Import .glb, .fbx modelsvar character := load("res://models/character.glb").instantiate()add_child(character)# Access animationsvar anim_player := character.get_node("AnimationPlayer")anim_player.play("idle")
Lighting Considerations
Minimum Lighting Setup
# Add to main scenevar sun := DirectionalLight3D.new()sun.rotation_degrees = Vector3(-45, 30, 0)sun.light_energy = 1.0sun.shadow_enabled = trueadd_child(sun)# Ambient lightvar env := WorldEnvironment.new()var environment := Environment.new()environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLORenvironment.ambient_light_color = Color(0.3, 0.3, 0.4) # Subtle blueenvironment.ambient_light_energy = 0.5env.environment = environmentadd_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 objectsvar 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 distancevar sun := DirectionalLight3D.new()sun.directional_shadow_max_distance = 50.0 # Don't render far shadows# 4. Use unlit materials for distant objectsvar material := StandardMaterial3D.new()material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
Input Scheme Changes
2D → 3D Input Mapping
# 2D: left/right for horizontal movementInput.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_CAPTUREDfunc _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 layersvar 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 obstructedspring_arm.spring_length = 10.0spring_arm.collision_mask = 0b0100 # Layer 3: World
Player Falling Through Floor
# Problem: StaticBody3D floor has no CollisionShape3D# Solution: Add collisionvar floor_collision := CollisionShape3D.new()var box_shape := BoxShape3D.new()box_shape.size = Vector3(100, 1, 100)floor_collision.shape = box_shapefloor.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.
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.
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_3Dmultimesh.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.
@toolclass_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.colorl3d.light_energy = node.energy# Approximate 3D range from 2D texture radius * scalevar radius := 128.0 # Default fallbackif node.texture: radius = node.texture.get_width() / 2.0l3d.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