Files

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

  1. Dual-paraboloid shadows do not exist in Compatibility. Setting OmniLight3D.SHADOW_DUAL_PARABOLOID logs Dual paraboloid shadow mode not supported in the Compatibility renderer and the light loses its shadows. Only SHADOW_CUBE works, even though the class reference describes the other mode as faster. Advice found online ("try dual paraboloid first") is wrong for this renderer.
  2. Keep omni_range constant; animating it makes the light edge visibly expand.
  3. Never switch the light off to flicker: the lit area visibly pops afterwards.
  4. Keep fire particles unshaded and additive; the light must only light the room.
  5. Raise the glow threshold before trusting it: at the default 1.0 the amber HUD panels bloom.
  6. 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.