extends Node
## BENCHMARK (not a gate) — what creatures cost the peer that simulates them
## (hyper-games #114 §7). For each count N: spawn N creatures of one type the way
## the retired CreatureSpawner._spawn_creature did (new, add_child, initialize), warm up, then for
## SAMPLE frames read PerfMetrics.sim_snapshot() (per-tick CPU + SphereSweep queries,
## accumulated, never sampled) and the engine's physics/process frame time.
##
## Every creature stays at FULL LOD (its default — no spawner here moves it), so the
## numbers are the UPPER bound per creature: the real spawner drops far ones to
## MEDIUM/LOW, which do no sweeps.
##
## Env: BENCH_TYPE crawler | hopper | serpent (default crawler)
## BENCH_COUNTS "0,10,50,100"
## BENCH_MAP a map .tscn to stand them on (default: one flat 2 km box collider)
## BENCH_SAMPLE frames per point (default 600)
## Run (windowed, like the game): DISPLAY=:0 CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 \
## "$HYPER_TPS_GODOT" --path . res://scenes/test/bench_creatures.tscn
## Output: one "BENCH ..." line per count.
var _type := "crawler"
var _counts: Array = [0, 10, 50, 100]
var _sample := 600
var _live: Array[Node3D] = []
## BENCH_SPREAD (m): creatures spread evenly over a disk of that radius around the origin
## (0 = the dense golden spiral, ~4 m apart). BENCH_TIERS=1: a real CreatureScheduler
## with ONE observer at the origin ticks them (near/mid/far/sleep), as the spawner does.
var _spread := 0.0
var _n_now := 0
var _sched: CreatureScheduler = null
var _frame := 0
func _physics_process(delta: float) -> void:
if _sched == null:
return
if _frame % 30 == 0:
_sched.retier(PackedVector3Array([Vector3.ZERO]))
_sched.step(delta, _frame)
_frame += 1
func _ready() -> void:
_type = OS.get_environment("BENCH_TYPE") if OS.get_environment("BENCH_TYPE") != "" else "crawler"
_spread = float(OS.get_environment("BENCH_SPREAD")) if OS.get_environment("BENCH_SPREAD") != "" else 0.0
if OS.get_environment("BENCH_TIERS") == "1":
_sched = CreatureScheduler.new()
if OS.get_environment("BENCH_NEAR_EVERY") != "":
_sched.near_every = int(OS.get_environment("BENCH_NEAR_EVERY"))
if OS.get_environment("BENCH_COUNTS") != "":
_counts = []
for s: String in OS.get_environment("BENCH_COUNTS").split(","):
_counts.append(int(s))
if OS.get_environment("BENCH_SAMPLE") != "":
_sample = int(OS.get_environment("BENCH_SAMPLE"))
var map_path := OS.get_environment("BENCH_MAP")
if map_path == "":
var floor_body := StaticBody3D.new()
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(2000, 1, 2000)
cs.shape = box
floor_body.add_child(cs)
add_child(floor_body)
floor_body.position = Vector3(0, -0.5, 0)
else:
add_child((load(map_path) as PackedScene).instantiate())
var cam := Camera3D.new()
add_child(cam)
cam.current = true
cam.position = Vector3(0, 30, 40)
cam.look_at(Vector3.ZERO, Vector3.UP)
await _frames(300 if map_path == "" else 900) # a map's terrain streams first
print("BENCH start type=%s map=%s sample=%d frames (FULL LOD = upper bound)" % [_type,
map_path if map_path != "" else "flat", _sample])
for n: int in _counts:
await _point(n)
get_tree().quit(0)
func _frames(n: int) -> void:
for i in n:
await get_tree().process_frame
func _ground(space: PhysicsDirectSpaceState3D, x: float, z: float) -> float:
var r: Dictionary = SphereSweep.sweep(space, Vector3(x, 2000, z), Vector3(x, -2000, z), 0.3)
return float((r["point"] as Vector3).y) if not r.is_empty() else NAN
func _spawn(i: int) -> void:
var rng := RandomNumberGenerator.new()
rng.seed = 1000 + i
var a := float(i) * 2.399963 # golden angle: spread evenly, deterministic
var rad := 4.0 * sqrt(float(i) + 1.0)
if _spread > 0.0:
rad = _spread * sqrt((float(i) + 0.5) / float(maxi(_n_now, 1))) # uniform over the disk
var x := cos(a) * rad
var z := sin(a) * rad
var gy := _ground(get_viewport().get_world_3d().direct_space_state, x, z)
if is_nan(gy):
return
var c: Node3D
match _type:
"hopper":
c = Hopper.new()
"serpent":
c = FlyingSerpent.new()
_:
c = GroundCrawler.new()
add_child(c)
c.global_position = Vector3(x, gy + (20.0 if _type == "serpent" else 0.1), z)
c.call("initialize", rng)
_live.append(c)
if _sched != null:
_sched.add(c, PackedVector3Array([Vector3.ZERO]))
func _point(n: int) -> void:
for c in _live:
c.queue_free()
_live.clear()
if _sched != null:
_sched.clear()
await _frames(10)
_n_now = n
for i in n:
_spawn(i)
await _frames(120) # warm-up: creatures settle onto the ground
SimStats.reset()
var phys_us := 0.0
var proc_us := 0.0
var t0 := Time.get_ticks_usec()
for i in _sample:
await get_tree().process_frame
phys_us += Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1e6
proc_us += Performance.get_monitor(Performance.TIME_PROCESS) * 1e6
var wall_s := float(Time.get_ticks_usec() - t0) / 1e6
var snap: Dictionary = SimStats.snapshot()
var key := "creature.%s" % _type
var k: Dictionary = snap.get(key, {})
print("BENCH type=%s n=%d spawned=%d ticks=%d us_per_tick=%.1f creature_ms_per_s=%.2f sweeps_per_s=%.0f physics_frame_ms=%.3f process_frame_ms=%.3f fps=%.1f" % [
_type, n, _live.size(), int(k.get("calls", 0)), float(k.get("us_per_call", 0.0)),
float(k.get("ms_per_s", 0.0)), float(k.get("queries_per_s", 0.0)),
phys_us / float(_sample) / 1000.0, proc_us / float(_sample) / 1000.0, float(_sample) / wall_s])
var dk: Dictionary = snap.get("creature.%s.draw" % _type, {})
if not dk.is_empty():
print("BENCH render-frame draw (interpolation): %.1f us per call, %.2f ms/s; sim+draw = %.2f ms/s" % [
float(dk.get("us_per_call", 0.0)), float(dk.get("ms_per_s", 0.0)),
float(k.get("ms_per_s", 0.0)) + float(dk.get("ms_per_s", 0.0))])
var vk: Dictionary = snap.get("creature.%s.visuals" % _type, {})
if not vk.is_empty():
print("BENCH visuals: %.1f us per call, %.2f ms/s (%.0f %% of the creature CPU)" % [
float(vk.get("us_per_call", 0.0)), float(vk.get("ms_per_s", 0.0)),
100.0 * float(vk.get("ms_per_s", 0.0)) / maxf(float(k.get("ms_per_s", 0.0)), 1e-6)])
if _sched != null:
var tiers := [0, 0, 0, 0]
for c in _live:
tiers[_sched.tier_of(c)] += 1
print("BENCH tiers near/mid/far/sleep = %d/%d/%d/%d" % tiers)