OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / creatures / creature_scheduler.gd

159 lines · gd ref:
class_name CreatureScheduler extends RefCounted
## SIMULATION TIERS + SLEEP for the creatures one spawner simulates (#109 / hyper-games
## #114, user 2026-10-13: "start on simulation tiers and sleep for creatures").
##
## Measured first (#114 benchmark): a crawler costs 90–270 µs a tick, mostly its own
## GDScript rather than its ground sweeps, so the old LOD — which only skipped the
## sweeps — saved little. The fix every shipped crowd system uses (Assassin's Creed
## Unity's LOD tiers, Project Highrise, Minecraft simulation distance, Valheim's active
## zone): tick near things at full rate, far things rarely, and unobserved things NOT AT
## ALL, so cost follows what someone can see rather than how many creatures exist.
##
## Tier by the NEAREST OBSERVER (every player in the mesh, not just this peer's own):
##   NEAR  < near_radius   every physics tick, ground sweeps on (the old FULL LOD)
##   MID   < mid_radius    every MID_EVERY-th tick (10 Hz), no sweeps
##   FAR   < sleep_radius  every FAR_EVERY-th tick (2 Hz), no sweeps, last ground held
##   SLEEP beyond          no tick at all: frozen; its time does not pass
## A ticking creature gets the time it missed (its accumulated dt, capped at MAX_DT —
## the coarsest step its steering lerp stays stable at), so a 2 Hz creature walks as far
## per second as a 60 Hz one. Each tier is staggered by a per-creature SLOT, so no frame
## takes every far creature at once.
##
## Creatures stop ticking themselves while scheduled (`scheduled = true`); a creature
## outside any spawner (a bot's body) still ticks on its own.

enum Tier { NEAR, MID, FAR, SLEEP }
const MID_EVERY := 6         # 60 Hz / 6  = 10 Hz
const FAR_EVERY := 30        # 60 Hz / 30 = 2 Hz
const MAX_DT := 0.5          # the crawler's head lerp (2·dt) saturates at 0.5 s

var near_radius := 20.0
var mid_radius := 50.0
var sleep_radius := 100.0
## NEAR creatures that can INTERPOLATE (an `interpolating` property: the crawler) tick
## every near_every physics frames — 1 = 60 Hz, 2 = 30 Hz, 3 = 20 Hz (user 2026-10-13:
## "20 Hz near tier and interpolation") — and blend their last two poses every render
## frame. The rest stay at 60 Hz near until they can.
var near_every := 3

## creature -> {tier, acc, slot, dirty}
var _e: Dictionary = {}
var _next_slot := 0
## Ticks GIVEN per tier since reset_stats() — what gate_creature_tiers counts.
var ticks := PackedInt64Array([0, 0, 0, 0])
var simulated := {}           # creature -> seconds of dt handed to it (gate)
var tick_count := {}          # creature -> ticks handed to it (gate)


## Tiered AT ONCE from `observers` — not left at NEAR until the next retier, which ran a
## creature spawned far away at full rate for up to RETIER_INTERVAL (gate_creature_tiers).
func add(c: Node3D, observers := PackedVector3Array()) -> void:
	var e := {"tier": Tier.NEAR, "acc": 0.0, "slot": _next_slot, "dirty": true}
	_e[c] = e
	_next_slot += 1
	c.set("scheduled", true)
	if not observers.is_empty():
		_apply(c, e, _tier_for(c.global_position, observers))


func remove(c: Node3D) -> void:
	_e.erase(c)
	simulated.erase(c)
	tick_count.erase(c)


func clear() -> void:
	_e.clear()
	simulated.clear()
	tick_count.clear()


func size() -> int:
	return _e.size()


func tier_of(c: Node3D) -> int:
	return int((_e.get(c, {"tier": Tier.SLEEP}) as Dictionary)["tier"])


func reset_stats() -> void:
	ticks = PackedInt64Array([0, 0, 0, 0])
	simulated.clear()
	tick_count.clear()


## Re-tier everything by its distance to the NEAREST observer. Cheap enough at 2 Hz:
## one distance per creature per observer.
func retier(observers: PackedVector3Array) -> void:
	for c: Node3D in _e.keys():
		if not is_instance_valid(c):
			_e.erase(c)
			continue
		_apply(c, _e[c], _tier_for(c.global_position, observers))


func _tier_for(p: Vector3, observers: PackedVector3Array) -> int:
	var best := INF
	for o: Vector3 in observers:
		best = minf(best, p.distance_squared_to(o))
	if best < near_radius * near_radius:
		return Tier.NEAR
	if best < mid_radius * mid_radius:
		return Tier.MID
	if best < sleep_radius * sleep_radius:
		return Tier.FAR
	return Tier.SLEEP


func _apply(c: Node3D, e: Dictionary, t: int) -> void:
	if t == Tier.SLEEP and int(e["tier"]) != Tier.SLEEP:
		e["acc"] = 0.0               # falling asleep: the time it would have had is dropped
	e["tier"] = t
	# Sweeps only up close (the creatures' own FULL/LOW switch).
	if "_lod" in c:
		c.set("_lod", 0 if t == Tier.NEAR else 2)
	# Near at a reduced rate only where the creature blends its poses in between.
	var interp: bool = t == Tier.NEAR and near_every > 1 and "interpolating" in c
	e["every"] = near_every if interp else 1
	if "interpolating" in c:
		c.set("interpolating", interp)
		c.set("_tick_period", near_every if interp else 1)


## One physics frame: tick whoever's turn it is, with the time each has missed.
func step(delta: float, frame: int) -> void:
	for c: Node3D in _e.keys():
		if not is_instance_valid(c):
			_e.erase(c)
			continue
		var e: Dictionary = _e[c]
		var t: int = e["tier"]
		if t == Tier.SLEEP:
			continue
		e["acc"] = float(e["acc"]) + delta
		var every: int = int(e.get("every", 1))       # NEAR: 1, or near_every if interpolating
		if t == Tier.MID:
			every = MID_EVERY
		elif t == Tier.FAR:
			every = FAR_EVERY
		if (frame + int(e["slot"])) % every != 0:
			continue
		var dt := minf(float(e["acc"]), MAX_DT)
		e["acc"] = 0.0
		e["dirty"] = true
		c.call("sim_tick", dt)
		ticks[t] += 1
		simulated[c] = float(simulated.get(c, 0.0)) + dt
		tick_count[c] = int(tick_count.get(c, 0)) + 1


## The creatures that moved since the last call — the only ones worth a position row.
func take_dirty() -> Array[Node3D]:
	var out: Array[Node3D] = []
	for c: Node3D in _e.keys():
		var e: Dictionary = _e[c]
		if bool(e["dirty"]) and is_instance_valid(c):
			out.append(c)
			e["dirty"] = false
	return out