Files
tactical-shooter/client/scripts/profiling/profiler_node.gd
T
shawn e385eae0f5 feat: performance budget profiling setup
- docs/performance-budget.md: 60fps/2GB VRAM budget with CPU, GPU,
  memory, and draw-call targets (P95<16.6ms, P99<50ms)
- scripts/profiling/: SceneTree + Node profilers, test scenes
- scripts/generate_occluders.gd: auto-generate OccluderInstance3D
  from CSG wall pieces (128^3 voxel occlusion culling)
- scripts/convert_csg_to_mesh.gd: CSG->StaticMesh baker with LOD
  slots (LOD1@15m, LOD2@30m)
- project.godot: occlusion culling + LOD settings enabled
- modular scene UV2 data from lightmapping pass
- rcon_command_handler.gd conflict resolved (kept upstream plugin cmd)
- Fixed profiler_node.gd warmup frame bug (60 frames, not 3)
- Fixed convert_csg_to_mesh.gd LOD API (add_lod + distance)
2026-07-01 19:01:03 -04:00

148 lines
4.1 KiB
GDScript

extends Node
# Per-frame profiling node for tactical-shooter.
const WARMUP_FRAMES := 60
const DEFAULT_CAPTURE_FRAMES := 600
var capture_frames := DEFAULT_CAPTURE_FRAMES
var frame_count := 0
var recording: Array[Dictionary] = []
var profiling_complete := false
var last_tick := 0
func _ready():
# Try to read capture_frames from environment or metadata
if OS.has_environment("PROFILE_FRAMES"):
capture_frames = OS.get_environment("PROFILE_FRAMES").to_int()
capture_frames = get_meta("capture_frames", capture_frames)
print("=== Profiler Node ===")
print("Capture frames: ", capture_frames)
print("Warmup frames: ", WARMUP_FRAMES)
print("")
print("Frame,Delta(ms),GPU(ms),Physics(ms),DrawCalls,Tris,Vertices,VRAM(MB),RAM(MB),Objects")
# Load the target scene from metadata or environment
var target_scene = get_meta("target_scene", "")
if target_scene.is_empty() and OS.has_environment("PROFILE_SCENE"):
target_scene = OS.get_environment("PROFILE_SCENE")
if not target_scene.is_empty():
var packed = ResourceLoader.load(target_scene)
if packed:
var instance = packed.instantiate()
add_child(instance)
print("Loaded target scene: ", target_scene)
else:
printerr("Failed to load target scene: ", target_scene)
last_tick = Time.get_ticks_usec()
print("Ready complete, process() should fire now")
set_process(true)
func _process(delta: float) -> void:
print("_process called, frame=", frame_count)
if profiling_complete:
return
frame_count += 1
# Calculate real delta
var now := Time.get_ticks_usec()
var real_delta := (now - last_tick) / 1000000.0
last_tick = now
# Skip warmup frames
if frame_count <= WARMUP_FRAMES:
return
# Check if done
if frame_count > WARMUP_FRAMES + capture_frames:
profiling_complete = true
_finish()
return
var p := Performance
var frame_delta := real_delta * 1000.0
var gpu_time := float(RenderingServer.get_frame_setup_time_cpu()) * 1000.0
var physics_time := p.get_monitor(p.TIME_PHYSICS_PROCESS) * 1000.0
var draw_calls := int(p.get_monitor(p.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
var tris := int(p.get_monitor(p.RENDER_TOTAL_PRIMITIVES_IN_FRAME))
var verts := int(p.get_monitor(p.RENDER_TOTAL_VERTICES_IN_FRAME))
var vram_mb := int(p.get_monitor(p.RENDER_VIDEO_MEM_USED)) / (1024 * 1024)
var ram_mb := int(OS.get_static_memory_usage()) / (1024 * 1024)
var objects := int(p.get_monitor(p.OBJECT_NODE_COUNT))
recording.append({
frame = frame_count - WARMUP_FRAMES,
delta_ms = snapped(frame_delta, 0.01),
gpu_ms = snapped(gpu_time, 0.01),
physics_ms = snapped(physics_time, 0.01),
draw_calls = draw_calls,
tris = tris,
verts = verts,
vram_mb = vram_mb,
ram_mb = ram_mb,
objects = objects
})
print("%d,%.2f,%.2f,%.2f,%d,%d,%d,%d,%d,%d" % [
frame_count - WARMUP_FRAMES,
frame_delta, gpu_time, physics_time,
draw_calls, tris, verts, vram_mb, ram_mb, objects
])
func _finish() -> void:
print("")
print("=== Profile Summary ===")
if recording.is_empty():
print("No frames recorded!")
get_tree().quit(1)
return
var deltas: Array[float] = []
for r in recording:
deltas.append(r.delta_ms)
deltas.sort()
var total := float(recording.size())
var n := deltas.size()
var p50: float = deltas[n * 50 / 100]
var p95: float = deltas[n * 95 / 100]
var p99: float = deltas[n * 99 / 100]
var sum: float = 0.0
for d in deltas:
sum += d
var avg: float = sum / total
var max_dc := 0
var max_tris := 0
var max_vram := 0
for r in recording:
if r.draw_calls > max_dc: max_dc = r.draw_calls
if r.tris > max_tris: max_tris = r.tris
if r.vram_mb > max_vram: max_vram = r.vram_mb
print("Frames captured: ", recording.size())
print("Avg frame time: %.2f ms" % avg)
print("P50 frame time: %.2f ms" % p50)
print("P95 frame time: %.2f ms" % p95)
print("P99 frame time: %.2f ms" % p99)
print("Max draw calls: %d" % max_dc)
print("Max triangles: %d" % max_tris)
print("Peak VRAM: %d MB" % max_vram)
var pass_fail := "PASS"
if p95 > 16.6:
pass_fail = "FAIL (P95 > 16.6 ms)"
elif p99 > 50.0:
pass_fail = "FAIL (P99 spike > 50 ms)"
print("Result: ", pass_fail)
print("")
print("=== Profile complete ===")
get_tree().quit(0 if pass_fail == "PASS" else 1)