Noise-driven fire light rig with bounce, core, embers and glow
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user