diff --git a/docs/FIRE-LIGHT.md b/docs/FIRE-LIGHT.md new file mode 100644 index 0000000..f022fea --- /dev/null +++ b/docs/FIRE-LIGHT.md @@ -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. diff --git a/docs/WEB-DEPLOY.md b/docs/WEB-DEPLOY.md index 7edace7..70cb822 100644 --- a/docs/WEB-DEPLOY.md +++ b/docs/WEB-DEPLOY.md @@ -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 diff --git a/scripts/fire_light.gd b/scripts/fire_light.gd new file mode 100644 index 0000000..09cc5c5 --- /dev/null +++ b/scripts/fire_light.gd @@ -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) diff --git a/scripts/fire_light.gd.uid b/scripts/fire_light.gd.uid new file mode 100644 index 0000000..e482e03 --- /dev/null +++ b/scripts/fire_light.gd.uid @@ -0,0 +1 @@ +uid://cuf3oeivpoowb diff --git a/scripts/main.gd b/scripts/main.gd index da330c9..e9b9da0 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -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() diff --git a/scripts/office_atmosphere.gd b/scripts/office_atmosphere.gd index 1cb1fe7..79d0ef4 100644 --- a/scripts/office_atmosphere.gd +++ b/scripts/office_atmosphere.gd @@ -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 diff --git a/tools/shot_fire.gd b/tools/shot_fire.gd new file mode 100644 index 0000000..eba7ac5 --- /dev/null +++ b/tools/shot_fire.gd @@ -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() diff --git a/tools/shot_fire.gd.uid b/tools/shot_fire.gd.uid new file mode 100644 index 0000000..d7df487 --- /dev/null +++ b/tools/shot_fire.gd.uid @@ -0,0 +1 @@ +uid://cossye8uv1oba diff --git a/tools/test_fire_light.gd b/tools/test_fire_light.gd new file mode 100644 index 0000000..05af1f6 --- /dev/null +++ b/tools/test_fire_light.gd @@ -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() diff --git a/tools/test_fire_light.gd.uid b/tools/test_fire_light.gd.uid new file mode 100644 index 0000000..2d35471 --- /dev/null +++ b/tools/test_fire_light.gd.uid @@ -0,0 +1 @@ +uid://c6xrj86f687kq diff --git a/tools/test_office_atmosphere.gd b/tools/test_office_atmosphere.gd index f78ea8e..caf40c8 100644 --- a/tools/test_office_atmosphere.gd +++ b/tools/test_office_atmosphere.gd @@ -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