class_name RtlightManager
extends Node3D
## Loads a DarkPlaces .rtlights file (per-map, hand-curated realtime lights) and spawns an
## OmniLight3D per entry, transformed into the map's world space. CANONICAL to DarkPlaces
## `r_shadow.c` — see spec/quake-darkplaces-transfer.md §3 (values DERIVED from source, not tuned).
##
## rtlights line (R_Shadow_LoadWorldLights, r_shadow.c:4884), 18 progressively-optional fields:
## [!]org.x org.y org.z radius col.r col.g col.b style "cubemap"
## corona ang.p ang.y ang.r coronasizescale ambientscale diffusescale specularscale flags
## leading '!' -> shadow OFF for that light.
## defaults: ambient 0, diffuse 1, specular 1, flags REALTIMEMODE(2), corona 0, csizescale 0.25.
## intensity = color * (ambient+diffuse+specular) * lightintensityscale(1) (r_shadow.c:1697,2037)
## falloff = (1-x)*2/(1+x*x) clamped, x=dist/radius, 0 at radius (r_shadow.c:930,1227)
##
## Godot Forward+ already clusters light CONTRIBUTION; only shadow-atlas SLOT selection is ours.
## Godot can't afford a shadowed cubemap per light, so only SHADOW_BUDGET casters cast shadows at
## once. Selection is a TEMPORALLY-STABLE importance model (EMA + dead-band + amortized occlusion
## probe) — NOT a per-frame distance re-rank, which popped lights in/out at the rank-N boundary.
## (Principle: temporal convergence + hysteresis — LadyHavoc's visibility design / ReSTIR, Bitterli 2020.)
# --- per-light values (DERIVED — do not tune without a DP A/B, see §4) ---------
const OMNI_ATTENUATION := 0.5 # closest single-exponent to DP's flat-core (1-x)*2/(1+x*x); leave it
const INTENSITY_SCALE := 1.0 # r_shadow_lightintensityscale (DP default 1)
const LIGHTFLAG_REALTIMEMODE := 2 # client.h:35 — flags without this bit are baked-only → skipped
# --- shadow-caster selection tunables (spec §3.4 — verify against DP render) ---
const SHADOW_BUDGET := 16 # reconcile with positional_shadow atlas_size+quadrants (task #4)
const SHADOW_REFRESH := 0.1 # s between selection ticks (safe to lower now selection is stable)
const ALPHA := 0.2 # EMA weight on the importance score (↑ snappier, ↓ steadier)
const HYSTERESIS := 0.25 # outsider must beat weakest insider by this fraction to swap
const MIN_DWELL := 0.5 # s a light holds a shadow slot after gaining it
const PROBES_PER_REFRESH := 4 # amortized camera→light occlusion raycasts per tick
const PROBE_ALPHA := 0.35 # EMA weight on the occlusion hit signal
const FRUSTUM_MISS_WEIGHT := 0.15 # behind-frustum lights can still cast in → discount, don't zero
const OCCLUDED_WEIGHT := 0.1 # fully-occluded lights lose their slot to visible ones
# Each caster: {light, base, ema, occl, dwell, shadowed}
# base = luminance(color) * energy (static per light)
var _casters: Array = []
var _refresh_accum := 0.0
var _probe_cursor := 0
var _initialised := false
## Parse + spawn. Returns the number of lights spawned (0 = file missing/empty → caller keeps
## whatever lights it had). world = ((_q2g(quake_xyz) - map_center) * world_scale) + aligned_offset.
func setup(rtlights_path: String, map_center: Vector3, world_scale: float,
aligned_offset: Vector3) -> int:
var f := FileAccess.open(rtlights_path, FileAccess.READ)
if f == null:
push_warning("[Rtlights] cannot open %s" % rtlights_path)
return 0
var count := 0
var shadowed := 0
var skipped_baked := 0
var skipped_dark := 0
while not f.eof_reached():
var line := f.get_line().strip_edges()
if line.is_empty():
continue
var casts_shadow := true
if line.begins_with("!"):
casts_shadow = false # DarkPlaces: leading '!' disables this light's shadow
line = line.substr(1)
var t := line.split(" ", false)
if t.size() < 7: # need at least origin(3)+radius(1)+color(3)
continue
# Progressive-optional fields with DP defaults (r_shadow.c field-count fallbacks).
var flags := int(float(t[17])) if t.size() > 17 else LIGHTFLAG_REALTIMEMODE
if (flags & LIGHTFLAG_REALTIMEMODE) == 0:
skipped_baked += 1 # normal-mode-only light: applies only in the baked path
continue
var ambient := float(t[14]) if t.size() > 14 else 0.0
var diffuse := float(t[15]) if t.size() > 15 else 1.0
var specular := float(t[16]) if t.size() > 16 else 1.0
var energy := (ambient + diffuse + specular) * INTENSITY_SCALE
var color := Color(float(t[4]), float(t[5]), float(t[6]))
var lum := color.get_luminance()
if energy * lum < 1.0e-4: # corona-only / dark light (r_shadow.c:3436) → don't spawn
skipped_dark += 1
continue
var q := QuakeMapImporter._q2g(float(t[0]), float(t[1]), float(t[2]))
var radius := float(t[3])
var lamp := OmniLight3D.new()
lamp.position = ((q - map_center) * world_scale) + aligned_offset
lamp.omni_range = maxf(radius * world_scale, 0.5) # DP reaches 0 exactly at radius (no ×1.25)
lamp.omni_attenuation = OMNI_ATTENUATION
lamp.light_color = color
lamp.light_energy = energy # DP total intensity = (amb+diff+spec)
lamp.shadow_enabled = false # selector turns these on for the current caster set
add_child(lamp)
count += 1
if casts_shadow:
_casters.append({
"light": lamp, "base": lum * energy,
"ema": lum * energy, "occl": 0.0, "dwell": 0.0, "shadowed": false,
})
shadowed += 1
f.close()
print("[Rtlights] %d lights (%d shadow-eligible, budget %d; skipped %d baked-only, %d dark) from %s" % [
count, shadowed, SHADOW_BUDGET, skipped_baked, skipped_dark, rtlights_path.get_file()])
return count
func _process(delta: float) -> void:
# Fewer casters than the budget — enable them all once, then idle.
if _casters.size() <= SHADOW_BUDGET:
if not _initialised:
for c in _casters:
c.light.shadow_enabled = true
c.shadowed = true
_initialised = true
return
_refresh_accum += delta
if _refresh_accum < SHADOW_REFRESH:
return
var elapsed := _refresh_accum
_refresh_accum = 0.0
var cam := get_viewport().get_camera_3d()
if cam == null:
return
var cam_pos := cam.global_position
# Exclude the local player capsule so it doesn't self-occlude every probe.
var player_rid: RID = _local_player_rid(cam)
# 1. Amortized occlusion probes (round-robin K casters, one camera→light ray each).
var space := get_world_3d().direct_space_state
var n := _casters.size()
for _i in mini(PROBES_PER_REFRESH, n):
var c: Dictionary = _casters[_probe_cursor]
_probe_cursor = (_probe_cursor + 1) % n
var q := PhysicsRayQueryParameters3D.create(cam_pos, c.light.global_position)
if player_rid.is_valid():
q.exclude = [player_rid]
var hit := space.intersect_ray(q) # light has no collider → any hit = occluder in between
var occluded := 1.0 if hit.size() > 0 else 0.0
c.occl = lerpf(c.occl, occluded, PROBE_ALPHA)
# 2. Update each caster's EMA importance (visibility-weighted, NOT raw distance).
for c in _casters:
var pos: Vector3 = c.light.global_position
var dist := maxf(cam_pos.distance_to(pos), 0.001)
var rng: float = c.light.omni_range
var cov := clampf(rng / maxf(dist, rng), 0.0, 1.0)
cov = cov * cov # ≈ on-screen shadow coverage
var vis := (1.0 if cam.is_position_in_frustum(pos) else FRUSTUM_MISS_WEIGHT)
vis *= lerpf(1.0, OCCLUDED_WEIGHT, c.occl) # demote walled-off lights
var raw: float = c.base * cov * vis
c.ema = lerpf(c.ema, raw, ALPHA)
if c.dwell > 0.0:
c.dwell -= elapsed
# 3. Dead-band selection: fill to budget, then swap only past the hysteresis margin.
_select_with_deadband()
for c in _casters:
c.light.shadow_enabled = c.shadowed
## Fill the shadow set to SHADOW_BUDGET by EMA, then let an outsider replace the weakest insider
## ONLY if it beats it by (1+HYSTERESIS) and that insider is off its MIN_DWELL hold. No boundary flicker.
func _select_with_deadband() -> void:
var shadowed := _casters.filter(func(c): return c.shadowed)
var dark := _casters.filter(func(c): return not c.shadowed)
# Initial / under-filled: promote strongest non-shadowed to reach the budget.
while shadowed.size() < SHADOW_BUDGET and dark.size() > 0:
var best := _argmax_ema(dark)
best.shadowed = true
best.dwell = MIN_DWELL
shadowed.append(best)
dark.erase(best)
# Swap loop: strongest outsider vs weakest (dwell-free) insider, gated by the dead-band.
while dark.size() > 0:
var outs := _argmax_ema(dark)
var ins: Variant = _argmin_ema_swappable(shadowed)
if ins == null:
break # every insider is still on its dwell hold
if outs.ema <= ins.ema * (1.0 + HYSTERESIS):
break # nobody clears the margin → stable, stop
ins.shadowed = false
outs.shadowed = true
outs.dwell = MIN_DWELL
shadowed.erase(ins); shadowed.append(outs)
dark.erase(outs); dark.append(ins)
func _argmax_ema(arr: Array) -> Dictionary:
var best: Dictionary = arr[0]
for c in arr:
if c.ema > best.ema:
best = c
return best
## Weakest insider that is NOT on its dwell hold (returns {} if all are held).
func _argmin_ema_swappable(arr: Array) -> Variant:
var worst: Variant = null
for c in arr:
if c.dwell > 0.0:
continue
if worst == null or c.ema < worst.ema:
worst = c
return worst
## The local player's CharacterBody3D rid (the camera lives in the player rig), or an invalid RID.
func _local_player_rid(cam: Camera3D) -> RID:
var n: Node = cam
while n != null and not (n is CharacterBody3D):
n = n.get_parent()
if n is CollisionObject3D:
return (n as CollisionObject3D).get_rid()
return RID()