Noise-driven fire light rig with bounce, core, embers and glow

This commit is contained in:
enne2
2026-10-11 02:26:08 +02:00
parent e15deef84f
commit 70c82df450
11 changed files with 440 additions and 22 deletions
+98
View File
@@ -0,0 +1,98 @@
# 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
```sh
"$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.
+10
View File
@@ -1,5 +1,15 @@
# Web build and compressed deployment # Web build and compressed deployment
## Dynamic fire release: 807d07fb6ced9fc8
Noise-driven fire rig with a bounce light, hot core, embers and a high-threshold glow,
see `docs/FIRE-LIGHT.md`. PCK 4,378,324 bytes; gzip payload total 12,825,562 bytes.
Backup `index-before-dynfire.html`.
Obsolete releases: 097e6c64b42640ad, 1194f239fcbd91d6 and cd86c85387c1c9f3 are now
deletable under the retention policy (live + previous) once approved.
## Nerd glasses and subtitle release: f77b7ba7b89364dd ## Nerd glasses and subtitle release: f77b7ba7b89364dd
The mascot now wears thick nerd glasses (third AI edit pass) and carries the subtitle The mascot now wears thick nerd glasses (third AI edit pass) and carries the subtitle
+163
View File
@@ -0,0 +1,163 @@
extends Node3D
## Dynamic fire: one noise signal drives the light rig AND the flame, so they pulse
## together. Only the main light casts shadows.
##
## Design notes (see docs/FIRE-LIGHT.md):
## - FastNoiseLite Perlin, two octaves: a slow "breathing" plus a fast flutter.
## Never per-frame randomness and never switching the light off: hard toggling is
## reported to make the lit area visibly "expand" after the switch.
## - omni_range stays FIXED: animating the range makes the light boundary breathe.
## - Frame-rate independent smoothing, so the flicker does not depend on FPS.
## - The bounce light follows the documented fake-GI recipe for positional lights
## (child of the main light, wider range, shadows off, specular 0, low energy).
const MIN_ENERGY := 1.9
const MAX_ENERGY := 2.8
const BASE_RANGE := 10.0
const SEED := 20261011
## Perlin rarely reaches its extremes, so the raw sample is gained before clamping.
const GAIN := 1.6
const SLOW_HZ := 0.8
const FAST_HZ := 7.0
const SLOW_WEIGHT := 0.75
const RESPONSE := 15.0
## Bounce light: wider and dimmer, no shadows, no specular (fake indirect lighting).
const BOUNCE_RANGE_FACTOR := 1.4
const BOUNCE_ENERGY_FACTOR := 0.2
const BOUNCE_SWING := 0.4
## Hot core near the flame.
const CORE_RANGE := 3.5
const CORE_ENERGY_FACTOR := 0.45
const CORE_SWING := 0.5
## Very small, slow sway of the shadow-casting light: this is what makes the
## shadows on the walls and floor move. Keep it subtle; large jumps look wrong.
const JITTER := 0.04
const JITTER_HZ := 1.2
## Flame height breathing, in fractions of the flame quad height.
const FLAME_SWING := 0.08
var main_light: OmniLight3D
var bounce_light: OmniLight3D
var core_light: OmniLight3D
var embers: CPUParticles3D
var flame: CPUParticles3D
var _flame_mesh: QuadMesh
var _flame_material: StandardMaterial3D
var _flame_height := 0.8
var _noise := FastNoiseLite.new()
var _time := 0.0
var _pulse := 0.0
func _ready() -> void:
name = "FireLightRig"
_noise.seed = SEED
_noise.noise_type = FastNoiseLite.TYPE_PERLIN
_noise.fractal_type = FastNoiseLite.FRACTAL_NONE
_noise.frequency = 1.0
main_light = OmniLight3D.new()
main_light.name = "FireLight"
main_light.light_color = Color(1, 0.36, 0.07)
main_light.omni_range = BASE_RANGE
main_light.omni_attenuation = 1.6
main_light.omni_shadow_mode = OmniLight3D.SHADOW_CUBE
# NOTA: in Compatibility il modo dual-paraboloid non e' supportato e l'engine
# logga un errore saltando le ombre; solo SHADOW_CUBE funziona qui.
main_light.shadow_enabled = true
main_light.light_energy = (MIN_ENERGY + MAX_ENERGY) * 0.5
add_child(main_light)
bounce_light = OmniLight3D.new()
bounce_light.name = "FireBounce"
bounce_light.light_color = Color(1, 0.42, 0.12)
bounce_light.omni_range = BASE_RANGE * BOUNCE_RANGE_FACTOR
bounce_light.omni_attenuation = 1.5
bounce_light.light_specular = 0.0
bounce_light.shadow_enabled = false
main_light.add_child(bounce_light)
core_light = OmniLight3D.new()
core_light.name = "FireCore"
core_light.light_color = Color(1, 0.52, 0.16)
core_light.omni_range = CORE_RANGE
core_light.omni_attenuation = 1.4
core_light.light_specular = 0.0
core_light.shadow_enabled = false
core_light.position = Vector3(0, -0.45, 0)
main_light.add_child(core_light)
_build_embers()
_apply()
## Links the flame emitter so its size and brightness follow the same signal.
func attach_flame(particles: CPUParticles3D) -> void:
flame = particles
if particles.mesh is QuadMesh:
_flame_mesh = particles.mesh
_flame_height = _flame_mesh.size.y
_flame_material = _flame_mesh.material if _flame_mesh else null
## Bounded, smooth, deterministic flicker signal in [-1, 1]. Pure: same t -> same
## value, which is what the tests sample without running frames.
func pulse_at(t: float) -> float:
var slow := _noise.get_noise_1d(t * SLOW_HZ)
var fast := _noise.get_noise_1d(t * FAST_HZ + 137.0)
return clampf((slow * SLOW_WEIGHT + fast * (1.0 - SLOW_WEIGHT)) * GAIN, -1.0, 1.0)
func _process(delta: float) -> void:
_time += delta
# Frame-rate independent approach: no flicker speed change with FPS.
_pulse = lerpf(_pulse, pulse_at(_time), 1.0 - exp(-RESPONSE * delta))
_apply()
func _apply() -> void:
var midpoint := (MIN_ENERGY + MAX_ENERGY) * 0.5
var span := (MAX_ENERGY - MIN_ENERGY) * 0.5
var energy := clampf(midpoint + span * _pulse, MIN_ENERGY, MAX_ENERGY)
main_light.light_energy = energy
# Colour drifts less than brightness: a bigger shift reads as a colour cycling lamp.
main_light.light_color = Color(1.0, 0.36 + 0.06 * _pulse, 0.07 + 0.05 * _pulse)
bounce_light.light_energy = energy * BOUNCE_ENERGY_FACTOR * (1.0 + BOUNCE_SWING * _pulse)
core_light.light_energy = energy * CORE_ENERGY_FACTOR * (1.0 + CORE_SWING * _pulse)
main_light.position = Vector3(
_noise.get_noise_1d(_time * JITTER_HZ + 11.0) * JITTER, 0.0,
_noise.get_noise_1d(_time * JITTER_HZ + 71.0) * JITTER)
if _flame_mesh:
_flame_mesh.size.y = _flame_height * (1.0 + FLAME_SWING * _pulse)
if _flame_material:
var brightness := 1.0 + 0.25 * _pulse
_flame_material.albedo_color = Color(brightness, brightness, brightness)
func _build_embers() -> void:
embers = CPUParticles3D.new()
embers.name = "FireEmbers"
embers.amount = 8
embers.lifetime = 1.8
embers.preprocess = 0.9
embers.emission_shape = CPUParticles3D.EMISSION_SHAPE_SPHERE
embers.emission_sphere_radius = 0.22
embers.direction = Vector3.UP
embers.spread = 22
embers.gravity = Vector3(0, 0.35, 0)
embers.initial_velocity_min = 0.5
embers.initial_velocity_max = 1.2
embers.scale_amount_min = 0.6
embers.scale_amount_max = 1.2
var ramp := Gradient.new()
ramp.set_color(0, Color(1, 0.72, 0.25, 0.95))
ramp.set_color(1, Color(0.7, 0.12, 0.02, 0))
embers.color_ramp = ramp
var spark := SphereMesh.new()
spark.radius = 0.012
spark.height = 0.024
var material := StandardMaterial3D.new()
# Unshaded on purpose: fire must not depend on scene lights (a light placed
# inside a particle system lights it inconsistently), it emits its own glow.
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
material.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
material.vertex_color_use_as_albedo = true
material.albedo_color = Color(1, 0.55, 0.2)
spark.material = material
embers.mesh = spark
embers.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
embers.position = Vector3(0, -0.42, 0)
add_child(embers)
+1
View File
@@ -0,0 +1 @@
uid://cuf3oeivpoowb
+11
View File
@@ -42,6 +42,17 @@ func _ready() -> void:
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.28, 0.36, 0.48) env.ambient_light_color = Color(0.28, 0.36, 0.48)
env.ambient_light_energy = 0.12 env.ambient_light_energy = 0.12
# Glow compensates for the missing volumetric fog (unsupported in Compatibility):
# a high threshold so only the fire core blooms, keeping the amber HUD readable.
env.glow_enabled = true
env.glow_intensity = 0.45
env.glow_strength = 1.0
env.glow_bloom = 0.05
env.glow_mix = 0.05
env.glow_hdr_threshold = 1.3
env.glow_blend_mode = Environment.GLOW_BLEND_MODE_ADDITIVE
for level in [2, 3, 4]:
env.set("glow_levels/%d" % level, 1.0 if level == 3 else 0.4)
envnode.environment = env envnode.environment = env
add_child(envnode) add_child(envnode)
var office := Node3D.new() var office := Node3D.new()
+11 -18
View File
@@ -1,9 +1,8 @@
extends Node3D extends Node3D
## Sparse office props and bounded fire animation; Web/Compatibility safe. ## Sparse office props; the fire itself is a reusable rig in scripts/fire_light.gd.
const FIRE_MIN := 1.9 const FireLight = preload("res://scripts/fire_light.gd")
const FIRE_MAX := 2.8
var fire_light: OmniLight3D var fire_light: OmniLight3D
var _time := 0.0 var fire_rig: Node3D
func _ready() -> void: func _ready() -> void:
name = "OfficeAtmosphere" name = "OfficeAtmosphere"
@@ -150,17 +149,11 @@ func _barrel(pos: Vector3) -> void:
fire.mesh = quad fire.mesh = quad
fire.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF fire.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
barrel.add_child(fire) barrel.add_child(fire)
fire_light = OmniLight3D.new() # Rig: noise-driven main light (the only shadow caster), fake-GI bounce light,
fire_light.name = "BarrelFireLight" # hot core light and floating embers. The flame follows the same signal.
fire_light.position = Vector3(0, 1.5, 0) fire_rig = Node3D.new()
fire_light.light_color = Color(1, 0.36, 0.07) fire_rig.set_script(FireLight)
fire_light.light_energy = (FIRE_MIN + FIRE_MAX) * 0.5 fire_rig.position = Vector3(0, 1.5, 0)
fire_light.omni_range = 10 barrel.add_child(fire_rig)
fire_light.shadow_enabled = true fire_rig.attach_flame(fire)
barrel.add_child(fire_light) fire_light = fire_rig.main_light
func _process(dt: float) -> void:
_time += dt
# Smooth, deterministic, bounded flicker: no per-frame random flashes.
var wave := 0.5 + 0.3 * sin(_time * 7.1) + 0.2 * sin(_time * 11.7 + 1.2)
fire_light.light_energy = lerpf(FIRE_MIN, FIRE_MAX, wave)
+36
View File
@@ -0,0 +1,36 @@
extends SceneTree
## Cattura il bidone acceso a fasi diverse di flicker e di scarto, per confrontare
## ombre, rimbalzo, nucleo, braci e glow. Lo spawn delle ondate viene bloccato e gli
## zombie congelati, altrimenti finiscono davanti all'obiettivo e falsano l'immagine.
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
var world = load("res://scenes/main.tscn").instantiate()
root.add_child(world)
world.spawning = true # impedisce l'avvio di nuove ondate durante le riprese
world.player.set_physics_process(false)
world.player.hp = 100000
var office = world.get_node("OfficeAtmosphere")
var rig = office.fire_rig
var shots := [
["basso", -1.0, 0.0, Vector3(-4, 0.35, 4)],
["alto", 1.0, 0.0, Vector3(-4, 0.35, 4)],
["alto-scarto", 1.0, 0.45, Vector3(-4, 0.35, 4)],
["insieme", 0.0, 0.2, Vector3(-4, 1.0, 4)],
]
for shot in shots:
for zombie in get_nodes_in_group("live_zombies"):
zombie.set_physics_process(false)
rig._pulse = shot[1]
rig._time = shot[2]
rig._apply()
world.player.position = Vector3(-1.6, 0.1, 2.9) if shot[0] != "insieme" else Vector3(0, 0.1, -0.5)
world.player.look_at(shot[3])
await create_timer(0.35).timeout
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("/tmp/zombie-fire-%s.png" % shot[0])
print("SHOT: 4 immagini in /tmp/zombie-fire-*.png")
world.queue_free()
await process_frame
quit()
+1
View File
@@ -0,0 +1 @@
uid://cossye8uv1oba
+99
View File
@@ -0,0 +1,99 @@
extends SceneTree
## Verifica il rig del fuoco: rumore continuo, assenza di strobing, coerenza fra le
## luci e modulazione della fiamma. Il campione di rumore e' puro, quindi il test
## simula 60 s a 60 Hz senza dipendere dal frame rate reale.
const FireLight = preload("res://scripts/fire_light.gd")
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
var world = load("res://scenes/main.tscn").instantiate()
root.add_child(world)
var rig = world.get_node("OfficeAtmosphere").fire_rig
var main_light: OmniLight3D = rig.main_light
var bounce: OmniLight3D = rig.bounce_light
var core: OmniLight3D = rig.core_light
# --- limiti, variazione e assenza di strobing -----------------------------
var previous := -1.0
var maximum_step := 0.0
var minimum := INF
var maximum := -INF
var total := 0.0
var squares := 0.0
var samples := 0
rig._pulse = 0.0
for i in 3600: # 60 s a 60 Hz
rig._process(1.0 / 60.0)
var energy := main_light.light_energy
minimum = minf(minimum, energy)
maximum = maxf(maximum, energy)
total += energy
squares += energy * energy
samples += 1
if previous >= 0.0:
maximum_step = maxf(maximum_step, absf(energy - previous))
previous = energy
var mean := total / samples
var deviation := sqrt(squares / samples - mean * mean)
assert(minimum >= rig.MIN_ENERGY and maximum <= rig.MAX_ENERGY)
assert(deviation > 0.05) # non e' una luce fissa
assert(maximum_step < 0.05) # nessun salto: niente strobing
assert(maximum - minimum > 0.4) # la gamma viene usata davvero
print("PASS: flicker entro [%.2f, %.2f], deviazione %.3f, salto massimo per frame %.3f" % [minimum, maximum, deviation, maximum_step])
# --- coerenza fra le tre luci ---------------------------------------------
assert(bounce.light_energy > 0.0 and core.light_energy > 0.0)
assert(bounce.light_energy < main_light.light_energy)
assert(core.omni_range < main_light.omni_range)
assert(is_equal_approx(main_light.omni_range, rig.BASE_RANGE)) # raggio mai animato
rig._pulse = 1.0
rig._apply()
var bright := [main_light.light_energy, bounce.light_energy, core.light_energy]
rig._pulse = -1.0
rig._apply()
var dim := [main_light.light_energy, bounce.light_energy, core.light_energy]
for i in 3:
assert(bright[i] > dim[i])
print("PASS: principale, rimbalzo e nucleo pulsano insieme e il raggio resta fisso")
# --- ricetta ufficiale del rimbalzo e unicita' delle ombre ----------------
assert(not bounce.shadow_enabled and not core.shadow_enabled)
assert(bounce.light_specular == 0.0 and core.light_specular == 0.0)
assert(is_equal_approx(bounce.omni_range, rig.BASE_RANGE * rig.BOUNCE_RANGE_FACTOR))
assert(bounce.get_parent() == main_light and core.get_parent() == main_light)
assert(main_light.omni_shadow_mode == OmniLight3D.SHADOW_CUBE) # il dual paraboloid non esiste in Compatibility
assert(main_light.shadow_enabled and not bounce.shadow_enabled)
print("PASS: rimbalzo figlio senza ombre ne' specular, raggio +40%, ombre solo sulla principale")
# --- micro-scarto e fiamma ------------------------------------------------
var maximum_offset := 0.0
var flame_heights: Array[float] = []
rig._time = 0.0
for i in 600:
rig._process(1.0 / 60.0)
maximum_offset = maxf(maximum_offset, Vector2(main_light.position.x, main_light.position.z).length())
flame_heights.append(rig._flame_mesh.size.y)
assert(maximum_offset <= rig.JITTER * 1.45) # scarto piccolo e limitato
assert(maximum_offset > 0.005) # le ombre si muovono davvero
assert(flame_heights.max() > flame_heights.min())
assert(flame_heights.max() < rig._flame_height * 1.2)
print("PASS: scarto massimo %.3f m, altezza fiamma da %.2f a %.2f" % [maximum_offset, flame_heights.min(), flame_heights.max()])
# --- braci e determinismo -------------------------------------------------
assert(rig.embers is CPUParticles3D and rig.embers.amount == 8 and rig.embers.emitting)
assert(rig.embers.mesh.material.shading_mode == BaseMaterial3D.SHADING_MODE_UNSHADED)
var same := Node3D.new()
same.set_script(FireLight)
world.add_child(same)
var different := false
for i in 200:
var t := 0.01 * i
if not is_equal_approx(rig.pulse_at(t), same.pulse_at(t)):
different = true
assert(not different) # stesso seed -> stessa sequenza, test riproducibili
print("PASS: braci presenti e flicker deterministico con seed fisso")
world.queue_free()
await process_frame
quit()
+1
View File
@@ -0,0 +1 @@
uid://c6xrj86f687kq
+9 -4
View File
@@ -21,18 +21,23 @@ func run_test() -> void:
var flames = office.get_node("FireBarrel/BarrelFlames") var flames = office.get_node("FireBarrel/BarrelFlames")
assert(flames is CPUParticles3D and flames.amount == 28 and flames.emitting) assert(flames is CPUParticles3D and flames.amount == 28 and flames.emitting)
assert(office.fire_light.shadow_enabled) assert(office.fire_light.shadow_enabled)
assert(office.fire_rig is Node3D and office.fire_rig.flame == flames)
for i in 100: for i in 100:
office._process(0.016) office.fire_rig._process(0.016)
assert(office.fire_light.light_energy >= office.FIRE_MIN and office.fire_light.light_energy <= office.FIRE_MAX) var energy: float = office.fire_light.light_energy
assert(energy >= office.fire_rig.MIN_ENERGY and energy <= office.fire_rig.MAX_ENERGY)
print("PASS: fiamme CPU leggere e flicker continuo entro limiti") print("PASS: fiamme CPU leggere e flicker continuo entro limiti")
var lights: Array[Node] = world.find_children("*", "OmniLight3D", true, false) var lights: Array[Node] = world.find_children("*", "OmniLight3D", true, false)
assert(lights.size() == 4) # due lampade, bidone, muzzle flash assert(lights.size() == 6) # due lampade, principale + rimbalzo + nucleo, muzzle flash
assert(world.find_children("DeskLampLight", "OmniLight3D", true, false).size() == 2) assert(world.find_children("DeskLampLight", "OmniLight3D", true, false).size() == 2)
var shadow_casters := lights.filter(func(light): return light.shadow_enabled)
assert(shadow_casters.size() == 1 and shadow_casters[0] == office.fire_light)
for node in world.get_children(): for node in world.get_children():
assert(not node is OmniLight3D) # nessuna plafoniera assert(not node is OmniLight3D) # nessuna plafoniera
if node is WorldEnvironment: if node is WorldEnvironment:
assert(node.environment.ambient_light_energy <= 0.15) assert(node.environment.ambient_light_energy <= 0.15)
print("PASS: plafoniera rimossa, ambient basso e quattro luci totali") assert(node.environment.glow_enabled and node.environment.glow_hdr_threshold >= 1.2)
print("PASS: plafoniera rimossa, ambient basso, una sola luce con ombre su sei totali")
await physics_frame await physics_frame
await physics_frame await physics_frame
var space = world.get_world_3d().direct_space_state var space = world.get_world_3d().direct_space_state