OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / bench_creatures.gd

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