Files

164 lines
6.4 KiB
GDScript

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)