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)
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
extends SceneTree
|
||||
|
||||
# Profile scene runner — manually drives the frame loop.
|
||||
# In --script --headless mode the automatic main loop doesn't
|
||||
# iterate, so we drive it ourselves via a manual loop.
|
||||
|
||||
const DEFAULT_SCENE := "res://assets/scenes/modular/kit_demo.tscn"
|
||||
const DEFAULT_CAPTURE_FRAMES := 600
|
||||
const WARMUP_FRAMES := 60
|
||||
|
||||
var capture_frames := DEFAULT_CAPTURE_FRAMES
|
||||
var scene_path := ""
|
||||
var frame_count := 0
|
||||
var recording: Array[Dictionary] = []
|
||||
var profiling_complete := false
|
||||
|
||||
func _init():
|
||||
# Parse CLI args
|
||||
var args = OS.get_cmdline_args()
|
||||
for i in range(args.size()):
|
||||
if args[i] == "--scene" and i + 1 < args.size():
|
||||
scene_path = args[i + 1]
|
||||
if args[i] == "--capture-frames" and i + 1 < args.size():
|
||||
capture_frames = args[i + 1].to_int()
|
||||
|
||||
if scene_path.is_empty():
|
||||
scene_path = DEFAULT_SCENE
|
||||
|
||||
print("=== Profile Scene Tool ===")
|
||||
print("Engine: ", Engine.get_version_info().string)
|
||||
print("Scene: ", scene_path)
|
||||
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 target scene
|
||||
var scene = ResourceLoader.load(scene_path)
|
||||
if not scene:
|
||||
printerr("ERROR: Failed to load scene: ", scene_path)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var instance = scene.instantiate()
|
||||
root.add_child(instance)
|
||||
|
||||
# Manual frame loop: Godot doesn't auto-iterate the main loop
|
||||
# in --script --headless mode. We iterate it ourselves.
|
||||
profiling_complete = false
|
||||
_manual_loop()
|
||||
|
||||
func _manual_loop() -> void:
|
||||
var start_time := Time.get_ticks_usec()
|
||||
var frame_delta := 0.016 # Target 60fps step
|
||||
|
||||
while not profiling_complete:
|
||||
var frame_start := Time.get_ticks_usec()
|
||||
|
||||
# Call idle (process) on the scene tree — this triggers
|
||||
# _process() on all nodes including the instanced scene
|
||||
#idle(frame_delta)
|
||||
|
||||
# Manually iterate — this processes input, idle, and physics
|
||||
iteration(frame_delta)
|
||||
|
||||
_on_frame(frame_delta)
|
||||
|
||||
# Cap frame rate to avoid 100% CPU in headless mode
|
||||
var elapsed := (Time.get_ticks_usec() - frame_start) / 1000.0
|
||||
var target_ms := 16.0 # ~60fps
|
||||
if elapsed < target_ms:
|
||||
OS.delay_msec(int(target_ms - elapsed))
|
||||
|
||||
# Use elapsed wall time as delta for next frame
|
||||
frame_delta = (Time.get_ticks_usec() - start_time) / 1000000.0
|
||||
start_time = Time.get_ticks_usec()
|
||||
|
||||
_finish_report()
|
||||
|
||||
func _on_frame(delta: float) -> void:
|
||||
frame_count += 1
|
||||
|
||||
# Skip warmup frames
|
||||
if frame_count <= WARMUP_FRAMES:
|
||||
return
|
||||
|
||||
if frame_count > WARMUP_FRAMES + capture_frames:
|
||||
profiling_complete = true
|
||||
return
|
||||
|
||||
var p := Performance
|
||||
|
||||
var frame_delta := 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_report() -> void:
|
||||
print("")
|
||||
print("=== Profile Summary ===")
|
||||
if recording.is_empty():
|
||||
print("No frames recorded!")
|
||||
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 ===")
|
||||
quit(0 if pass_fail == "PASS" else 1)
|
||||
Reference in New Issue
Block a user