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
## 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
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_color = Color(0.28, 0.36, 0.48)
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
add_child(envnode)
var office := Node3D.new()
+11 -18
View File
@@ -1,9 +1,8 @@
extends Node3D
## Sparse office props and bounded fire animation; Web/Compatibility safe.
const FIRE_MIN := 1.9
const FIRE_MAX := 2.8
## Sparse office props; the fire itself is a reusable rig in scripts/fire_light.gd.
const FireLight = preload("res://scripts/fire_light.gd")
var fire_light: OmniLight3D
var _time := 0.0
var fire_rig: Node3D
func _ready() -> void:
name = "OfficeAtmosphere"
@@ -150,17 +149,11 @@ func _barrel(pos: Vector3) -> void:
fire.mesh = quad
fire.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
barrel.add_child(fire)
fire_light = OmniLight3D.new()
fire_light.name = "BarrelFireLight"
fire_light.position = Vector3(0, 1.5, 0)
fire_light.light_color = Color(1, 0.36, 0.07)
fire_light.light_energy = (FIRE_MIN + FIRE_MAX) * 0.5
fire_light.omni_range = 10
fire_light.shadow_enabled = true
barrel.add_child(fire_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)
# Rig: noise-driven main light (the only shadow caster), fake-GI bounce light,
# hot core light and floating embers. The flame follows the same signal.
fire_rig = Node3D.new()
fire_rig.set_script(FireLight)
fire_rig.position = Vector3(0, 1.5, 0)
barrel.add_child(fire_rig)
fire_rig.attach_flame(fire)
fire_light = fire_rig.main_light
+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")
assert(flames is CPUParticles3D and flames.amount == 28 and flames.emitting)
assert(office.fire_light.shadow_enabled)
assert(office.fire_rig is Node3D and office.fire_rig.flame == flames)
for i in 100:
office._process(0.016)
assert(office.fire_light.light_energy >= office.FIRE_MIN and office.fire_light.light_energy <= office.FIRE_MAX)
office.fire_rig._process(0.016)
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")
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)
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():
assert(not node is OmniLight3D) # nessuna plafoniera
if node is WorldEnvironment:
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
var space = world.get_world_3d().direct_space_state