Noise-driven fire light rig with bounce, core, embers and glow
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cuf3oeivpoowb
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cossye8uv1oba
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c6xrj86f687kq
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user