OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / rtlight_manager.gd

208 lines · gd ref:
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()