extends SceneTree ## Micro-benchmark for the on-hit ragdoll spike. ## ## On mobile the profiler reads SCRIPT-bound (main-thread GDScript, one thread) exactly when the ## bull's attack connects and a matador is hit. This times the pieces of that synchronous path so ## we can see which op eats the milliseconds instead of guessing: ## • spawn — _matador.instantiate() + add_child (_ready builds the 21-bone ragdoll rig) ## • sim start — PhysicalBoneSimulator3D.physical_bones_start_simulation() (Jolt makes bodies) ## • blood burst — the CPUParticles3D one-shot fired on death ## • full hit — apply_ability_hit() end to end (what the player actually triggers) ## • MASS hit — hitting N matadors in ONE frame (what a slam / roll does — the real spike) ## ## Run: godot --headless --script res://tests/ragdoll_perf_test.gd ## It PRINTS per-op avg/worst ms and names the dominant cost; it also fails (exit 1) if a single ## hit or a mass-hit frame blows a generous budget, so it doubles as a regression guard. # Loaded at runtime (not preload): a preload here would compile matador.gd at this entry # script's parse time, before the autoloads (DP / Controls) register as global identifiers, # so matador.gd's `Controls.rigid_skin_enabled()` would fail to resolve. load() in _run runs # after the tree — and its autoloads — are up. var _matador: PackedScene = null const N := 8 # matadors sampled per op const MASS := 6 # matadors hit in one frame (a slam catching a cluster) const SINGLE_HIT_BUDGET_MS := 12.0 const MASS_HIT_BUDGET_MS := 33.0 # two 60fps frames — a slam may cost a hitch, not a freeze var _fail := 0 func _init() -> void: _run.call_deferred() func _us() -> int: return Time.get_ticks_usec() func _stats(us: Array) -> Dictionary: var total := 0 var worst := 0 for v: int in us: total += v worst = maxi(worst, v) var avg := (total / us.size()) if us.size() > 0 else 0 return {"avg_ms": avg / 1000.0, "max_ms": worst / 1000.0} func _sample(frames: int) -> Dictionary: var proc_sum := 0.0 var phys_sum := 0.0 for f: int in frames: await process_frame proc_sum += Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0 phys_sum += Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0 return {"proc_ms": proc_sum / frames, "phys_ms": phys_sum / frames} func _spawn_one() -> Node3D: var m: Node3D = _matador.instantiate() m.position = Vector3(randf_range(-20.0, 20.0), 0.0, randf_range(-20.0, 20.0)) root.add_child(m) return m func _run() -> void: # Warm up: first instance pays one-time import/JIT/shader costs that would skew sample 1. _matador = load("res://Matador.tscn") as PackedScene var warm := _spawn_one() await process_frame warm.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0) await process_frame warm.free() await process_frame print("[stage] warmup done") # ── spawn: instantiate() vs add_child(_ready = ragdoll build) ─────────────── var inst_us: Array = [] var ready_us: Array = [] for i: int in N: var t0 := _us() var m: Node3D = _matador.instantiate() var t1 := _us() root.add_child(m) # _ready runs synchronously → MatadorRagdoll.build (21 bodies + shapes) var t2 := _us() inst_us.append(t1 - t0) ready_us.append(t2 - t1) m.free() await process_frame print("[stage] spawn done") # ── component: sim start (Jolt body creation) and blood burst, in isolation ── var simstart_us: Array = [] var burst_us: Array = [] for i: int in N: var m := _spawn_one() await process_frame var sim: Node = m.get(&"_sim") if sim != null: sim.set("active", true) var t0 := _us() sim.call(&"physical_bones_start_simulation") simstart_us.append(_us() - t0) var blood: Node = m.get(&"_blood_burst") if blood != null: var t2 := _us() blood.call(&"burst", m.global_position + Vector3(0, 0.9, 0), Vector3(1, 0, 0)) burst_us.append(_us() - t2) m.free() await process_frame print("[stage] components done") # ── full hit: apply_ability_hit end to end (state WANDER → RAGDOLL) ────────── var hit_us: Array = [] for i: int in N: var m := _spawn_one() await process_frame var t0 := _us() m.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0) hit_us.append(_us() - t0) await process_frame m.free() await process_frame print("[stage] full-hit done") # ── MASS hit: MASS matadors ragdolled in ONE frame (a slam catching a cluster) ─ var cluster: Array = [] for i: int in MASS: cluster.append(_spawn_one()) await process_frame await process_frame var mt0 := _us() for m: Node3D in cluster: m.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0) var mass_ms := (_us() - mt0) / 1000.0 for m: Node3D in cluster: m.free() await process_frame print("[stage] mass done") # ── STEADY load: per-frame cost of live_n ragdolls ALIVE at once (they live ~4 s each) ─ # The hit is instantaneous; the drag is every ragdoll still simulating afterward. Sample # the frame cost with live_n matadors idle, then with all live_n ragdolling, and report the delta — # split process (idle-frame GDScript = the mobile "SCRIPT" bucket) vs physics (the Jolt step). var live_n := 10 var steady: Array = [] for i: int in live_n: steady.append(_spawn_one()) for f: int in 15: await process_frame var idle: Dictionary = await _sample(20) for m: Node3D in steady: m.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0) for f: int in 3: await process_frame var active: Dictionary = await _sample(20) for m: Node3D in steady: if is_instance_valid(m): m.free() # ── report ────────────────────────────────────────────────────────────────── var inst := _stats(inst_us) var rdy := _stats(ready_us) var ss := _stats(simstart_us) var bu := _stats(burst_us) var hit := _stats(hit_us) print("\n==== on-hit ragdoll cost (per matador, avg / worst) ====") print(" instantiate() %6.2f / %6.2f ms" % [inst["avg_ms"], inst["max_ms"]]) print(" add_child (_ready build)%6.2f / %6.2f ms" % [rdy["avg_ms"], rdy["max_ms"]]) print(" sim start (Jolt bodies) %6.2f / %6.2f ms" % [ss["avg_ms"], ss["max_ms"]]) print(" blood burst %6.2f / %6.2f ms" % [bu["avg_ms"], bu["max_ms"]]) print(" FULL apply_ability_hit %6.2f / %6.2f ms" % [hit["avg_ms"], hit["max_ms"]]) print(" MASS hit (%d in 1 frame) %6.2f ms total" % [MASS, mass_ms]) print("---- steady per-frame cost, %d matadors idle vs ragdolling ----" % live_n) print(" idle: process %5.2f ms physics %5.2f ms" % [idle["proc_ms"], idle["phys_ms"]]) print(" ragdolling: process %5.2f ms physics %5.2f ms" % [active["proc_ms"], active["phys_ms"]]) print(" delta/%d ragdolls: process +%5.2f ms physics +%5.2f ms (per ragdoll ~%.2f / %.2f ms)" % [ live_n, active["proc_ms"] - idle["proc_ms"], active["phys_ms"] - idle["phys_ms"], (active["proc_ms"] - idle["proc_ms"]) / live_n, (active["phys_ms"] - idle["phys_ms"]) / live_n]) # Name the dominant component of the full hit so the fix target is obvious. var parts := {"sim start": ss["avg_ms"], "blood burst": bu["avg_ms"]} var worst_name := "sim start" var worst_val := -1.0 for k: String in parts: if parts[k] > worst_val: worst_val = parts[k] worst_name = k print(" >> dominant hit cost: %s (%.2f ms of the %.2f ms hit)" % [ worst_name, worst_val, hit["avg_ms"]]) if hit["max_ms"] > SINGLE_HIT_BUDGET_MS: _fail += 1 print(" FAIL: worst single hit %.2f ms > %.1f ms budget" % [hit["max_ms"], SINGLE_HIT_BUDGET_MS]) if mass_ms > MASS_HIT_BUDGET_MS: _fail += 1 print(" FAIL: mass hit %.2f ms > %.1f ms budget" % [mass_ms, MASS_HIT_BUDGET_MS]) print("Results: %s" % ("FAIL (%d)" % _fail if _fail > 0 else "PASS")) quit(1 if _fail > 0 else 0)