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)