6.0 KiB
Dynamic fire light
scripts/fire_light.gd replaces the old single light with sinusoidal flicker. One
noise signal now drives the light rig and the flame, so brightness, colour, shadow
sway and flame height pulse together.
Research
Perplexity answered this time (it had been unavailable in earlier sessions), and the official documentation was checked in parallel:
- Godot renderer comparison table — Compatibility is the only method available for the web target. It supports 8 OmniLights per mesh by default, glow, depth/height fog and SSAO, and it does not support volumetric fog, light projector textures, PCSS, SSIL, SDFGI, decals or CompositorEffects post-processing. Lights with shadows are drawn in extra passes and blended in sRGB, so they differ in appearance and dominate the cost: https://docs.godotengine.org/en/stable/3d/overview_of_renderers.html
- Faking global illumination — for positional lights, add a second OmniLight3D as a child of the main one with +25/50% range, shadows off, energy 10-40% and specular 0: https://docs.godotengine.org/en/stable/tutorials/3d/global_illumination/faking_global_illumination.html
- FastNoiseLite (
get_noise_1d, Perlin, seed): https://docs.godotengine.org/en/stable/classes/class_fastnoiselite.html - Key advice from Perplexity: continuous noise instead of per-frame randomness, fixed
omni_range, frame-rate independent smoothing, amplitude 15-30%, colour variation smaller than brightness,omni_attenuation~2 for a physical falloff, one shadowed light only, and profile illumination and shadows separately. - Community: hard on/off flicker makes the lit area visibly "expand" after each switch, so the light is never toggled. Particles are lit inconsistently by a light placed inside the same particle system, which is why the flame stays unshaded and luminous on its own (the light only lights the room).
Implementation
| Element | Value | Reason |
|---|---|---|
| Main light | energy 1.9-2.8, range fixed 10, attenuation 1.6, shadows on (cube) | the only shadow caster; animating the range makes the boundary breathe |
| Noise | FastNoiseLite Perlin, seed 20261011, 0.8 Hz (weight 0.75) + 7 Hz (0.25), gain 1.6, response 15 | smooth, deterministic, frame-rate independent, no strobing |
| Colour drift | green 0.36-0.42, blue 0.07-0.12 | deliberately smaller than the brightness swing |
| Sway | ±4 cm at ~1.2 Hz, main light only | makes the shadows move; kept subtle |
| Bounce light | child, range 14 (=+40%), energy 20%, shadows off, specular 0, attenuation 1.5 | documented fake-GI recipe; no indirect lighting exists in Compatibility |
| Core light | range 3.5, energy 45%, shadows off, 45 cm below the main light | hot glow at the flame |
| Embers | 8 unshaded additive particles, 1.8 s | cheapest large realism gain |
| Flame | quad height ±8%, material brightness ±25% | visibly breathes with the light |
| Glow | enabled, intensity 0.45, blend additive, hdr_threshold 1.3, levels 2-4 |
stands in for the missing volumetric fog; a high threshold keeps the amber HUD from blooming |
Total: 6 omni lights in the room (2 desk lamps, 3 fire, 1 muzzle flash), only one with shadows, against the default limit of 8 per mesh.
Measured cost (desktop Radeon 780M, GL Compatibility, 1280x720, vsync off)
| State | FPS | Frame time |
|---|---|---|
| Fire rig active | 262.6 | 3.8 ms |
| Fire lights hidden | 250.7 | 4.0 ms |
| Shadows off | 588.4 | 1.7 ms |
The three fire lights are inside the measurement noise; the single shadow-casting light
costs about 2.1 ms, i.e. more than half the frame. That matches the documentation and is
the intended trade: the shadow is what makes the fire feel present. On a phone the same
ratio is expected to be heavier, and this workstation cannot measure it. To shed the cost
on weak devices, in this order: main_light.shadow_enabled = false, glow off, then
fire_rig.main_light.visible = false.
Pitfalls found while implementing
- Dual-paraboloid shadows do not exist in Compatibility. Setting
OmniLight3D.SHADOW_DUAL_PARABOLOIDlogsDual paraboloid shadow mode not supported in the Compatibility rendererand the light loses its shadows. OnlySHADOW_CUBEworks, even though the class reference describes the other mode as faster. Advice found online ("try dual paraboloid first") is wrong for this renderer. - Keep
omni_rangeconstant; animating it makes the light edge visibly expand. - Never switch the light off to flicker: the lit area visibly pops afterwards.
- Keep fire particles unshaded and additive; the light must only light the room.
- Raise the glow threshold before trusting it: at the default 1.0 the amber HUD panels bloom.
- When taking verification screenshots, stop wave spawning first: a zombie that reached the camera looks like a rendering bug but is just a silhouette in front of the lens.
Verification
"$G" --headless --path . --script res://tools/test_fire_light.gd
"$G" --display-driver x11 --rendering-method gl_compatibility --path . --script res://tools/shot_fire.gd
test_fire_light.gd simulates 60 s at 60 Hz on the pure noise signal and checks: energy
stays inside 1.9-2.8 (measured 1.96-2.76), deviation above 0.05 (0.135, so the fire is
alive), maximum per-frame step below 0.05 (0.036, so no strobing), the three lights move
together, the range stays fixed, bounce and core have no shadows and no specular, the
bounce is a child at +40% range, only the main light casts shadows, the sway stays inside
±4 cm (measured 3.5 cm) while actually moving, the flame height varies, embers exist,
and two rigs with the same seed produce identical signals. test_office_atmosphere.gd
was updated to the new light budget. All eleven suites pass.
Images /tmp/zombie-fire-{basso,alto,alto-scarto,insieme}.png compare the dim and bright
phases, the same brightness with a different sway phase, and a wide view: the wall and
ceiling illumination, the floor shadow and the flame halo all change together, and the
HUD stays crisp.