3D Lighting
Expert guidance for realistic 3D lighting with shadows and global illumination.
NEVER Do
- NEVER use VoxelGI without setting a proper extents — Unbound VoxelGI tanks performance. Always set
sizeto tightly fit your scene. - NEVER enable shadows on every light — Each shadow-casting light is expensive. Use shadows sparingly: 1-2 DirectionalLights, ~3-5 OmniLights max.
- NEVER forget directional_shadow_mode — Default is ORTHOGONAL. For large outdoor scenes, use PARALLEL_4_SPLITS for better shadow quality at distance.
- NEVER use LightmapGI for fully dynamic scenes — Lightmaps are baked. Moving geometry won't receive updated lighting. Use VoxelGI or SDFGI instead.
- NEVER set omni_range too large — Light attenuation is quadratic. A range of 500 affects 785,000 sq units. Keep range as small as visually acceptable.
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
day_night_cycle.gd
Dynamic sun position and color based on time-of-day. Handles DirectionalLight3D rotation, color temperature, and intensity curves. Use for outdoor day/night systems.
light_probe_manager.gd
VoxelGI and SDFGI management for global illumination setup.
lighting_manager.gd
Dynamic light pooling and LOD. Manages light culling and shadow toggling based on camera distance. Use for performance optimization with many lights.
volumetric_fx.gd
Volumetric fog and god ray configuration. Runtime fog density/color adjustments and light shaft setup. Use for atmospheric effects.
DirectionalLight3D (Sun/Moon)
Shadow Cascades
# For outdoor scenes with camera moving from near to far
extends DirectionalLight3D
func _ready() -> void:
shadow_enabled = true
directional_shadow_mode = SHADOW_PARALLEL_4_SPLITS
# Split distances (in meters from camera)
directional_shadow_split_1 = 10.0 # First cascade: 0-10m
directional_shadow_split_2 = 50.0 # Second: 10-50m
directional_shadow_split_3 = 200.0 # Third: 50-200m
# Fourth cascade: 200m - max shadow distance
directional_shadow_max_distance = 500.0
# Quality vs performance
directional_shadow_blend_splits = true # Smooth transitions
Day/Night Cycle
# sun_controller.gd
extends DirectionalLight3D
@export var time_of_day := 12.0 # 0-24 hours
@export var rotation_speed := 0.1 # Hours per second
func _process(delta: float) -> void:
time_of_day += rotation_speed * delta
if time_of_day >= 24.0:
time_of_day -= 24.0
# Rotate sun (0° = noon, 180° = midnight)
var angle := (time_of_day - 12.0) * 15.0 # 15° per hour
rotation_degrees.x = -angle
# Adjust intensity
if time_of_day < 6.0 or time_of_day > 18.0:
light_energy = 0.0 # Night
elif time_of_day < 7.0:
light_energy = remap(time_of_day, 6.0, 7.0, 0.0, 1.0) # Sunrise
elif time_of_day > 17.0:
light_energy = remap(time_of_day, 17.0, 18.0, 1.0, 0.0) # Sunset
else:
light_energy = 1.0 # Day
# Color shift
if time_of_day < 8.0 or time_of_day > 16.0:
light_color = Color(1.0, 0.7, 0.4) # Orange (dawn/dusk)
else:
light_color = Color(1.0, 1.0, 0.9) # Neutral white
OmniLight3D (Point Light)
Attenuation Tuning
# torch.gd
extends OmniLight3D
func _ready() -> void:
omni_range = 10.0 # Maximum reach
omni_attenuation = 2.0 # Falloff curve (1.0 = linear, 2.0 = quadratic/realistic)
# For "magical" lights, reduce attenuation
omni_attenuation = 0.5 # Flatter falloff, reaches farther
Flickering Effect
# campfire.gd
extends OmniLight3D
@export var base_energy := 1.0
@export var flicker_strength := 0.3
@export var flicker_speed := 5.0
func _process(delta: float) -> void:
var flicker := sin(Time.get_ticks_msec() * 0.001 * flicker_speed) * flicker_strength
light_energy = base_energy + flicker
SpotLight3D (Flashlight/Headlights)
Setup
# flashlight.gd
extends SpotLight3D
func _ready() -> void:
spot_range = 20.0
spot_angle = 45.0 # Cone angle (degrees)
spot_angle_attenuation = 2.0 # Edge softness
shadow_enabled = true
# Projector texture (optional - cookie/gobo)
light_projector = load("res://textures/flashlight_mask.png")
Follow Camera
# player_flashlight.gd
extends SpotLight3D
@onready var camera: Camera3D = get_viewport().get_camera_3d()
func _process(delta: float) -> void:
if camera:
global_transform = camera.global_transform
Global Illumination: VoxelGI vs SDFGI
Decision Matrix
| Feature | VoxelGI | SDFGI |
|---|---|---|
| Setup | Manual bounds per room | Automatic, scene-wide |
| Dynamic objects | Fully supported | Partially supported |
| Performance | Moderate | Higher cost |
| Use case | Indoor, small-medium scenes | Large outdoor scenes |
| Godot version | 4.0+ | 4.0+ |
VoxelGI Setup
# room_gi.gd - Place one VoxelGI per room/area
extends VoxelGI
func _ready() -> void:
# Tightly fit the room
size = Vector3(20, 10, 20)
# Quality settings
subdiv = VoxelGI.SUBDIV_128 # Higher = better quality, slower
# Bake GI data
bake()
SDFGI Setup
# world_environment.gd
extends WorldEnvironment
func _ready() -> void:
var env := environment
# Enable SDFGI
env.sdfgi_enabled = true
env.sdfgi_use_occlusion = true
env.sdfgi_read_sky_light = true
# Cascades (auto-scale based on camera)
env.sdfgi_min_cell_size = 0.2 # Detail level
env.sdfgi_max_distance = 200.0
LightmapGI (Baked Static Lighting)
When to Use
- Static architecture (buildings, dungeons)
- Mobile/low-end targets
- No dynamic geometry
Setup
# Scene structure:
# - LightmapGI node
# - StaticBody3D meshes with GeometryInstance3D.gi_mode = STATIC
# lightmap_baker.gd
extends LightmapGI
func _ready() -> void:
# Quality settings
quality = LightmapGI.BAKE_QUALITY_HIGH
bounces = 3 # Indirect light bounces
# Bake (editor only, not runtime)
# Click "Bake Lightmaps" button in editor
Environment & Sky
HDR Skybox
# world_env.gd
extends WorldEnvironment
func _ready() -> void:
var env := environment
env.background_mode = Environment.BG_SKY
var sky := Sky.new()
var sky_material := PanoramaSkyMaterial.new()
sky_material.panorama = load("res://hdri/sky.hdr")
sky.sky_material = sky_material
env.sky = sky
# Sky contribution to GI
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.ambient_light_sky_contribution = 1.0
Volumetric Fog
extends WorldEnvironment
func _ready() -> void:
var env := environment
env.volumetric_fog_enabled = true
env.volumetric_fog_density = 0.01
env.volumetric_fog_albedo = Color(0.9, 0.9, 1.0) # Blueish
env.volumetric_fog_emission = Color.BLACK
ReflectionProbe
For localized reflections (mirrors, shiny floors):
# reflection_probe.gd
extends ReflectionProbe
func _ready() -> void:
# Capture area
size = Vector3(10, 5, 10)
# Quality
resolution = ReflectionProbe.RESOLUTION_512
# Update mode
update_mode = ReflectionProbe.UPDATE_ONCE # Bake once
# or UPDATE_ALWAYS for dynamic reflections (expensive)
Performance Optimization
Light Budgets
# Recommended limits:
# - DirectionalLight3D with shadows: 1-2
# - OmniLight3D with shadows: 3-5
# - SpotLight3D with shadows: 2-4
# - OmniLight3D without shadows: 20-30
# - SpotLight3D without shadows: 15-20
# Disable shadows on minor lights
@onready var candle_lights: Array = [$Candle1, $Candle2, $Candle3]
func _ready() -> void:
for light in candle_lights:
light.shadow_enabled = false # Save performance
Per-Light Shadow Distance
# Disable shadows for distant lights
extends OmniLight3D
@export var shadow_max_distance := 50.0
func _process(delta: float) -> void:
var camera := get_viewport().get_camera_3d()
if camera:
var dist := global_position.distance_to(camera.global_position)
shadow_enabled = (dist < shadow_max_distance)
Edge Cases
Shadows Through Floors
# Problem: Thin floors let shadows through
# Solution: Increase shadow bias
extends DirectionalLight3D
func _ready() -> void:
shadow_enabled = true
shadow_bias = 0.1 # Increase if shadows bleed through
shadow_normal_bias = 2.0
Light Leaking in Indoor Scenes
# Problem: VoxelGI light bleeds through walls
# Solution: Place VoxelGI nodes per-room, don't overlap
# Also: Ensure walls have proper thickness (not paper-thin)
Reference
- Master Skill: godot-master