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:
2026-07-01 19:01:03 -04:00
parent 34507f9043
commit e385eae0f5
23 changed files with 1114 additions and 22 deletions
+198
View File
@@ -0,0 +1,198 @@
@tool
extends EditorScript
# CSG → Static Mesh Baker with LOD generation.
#
# Converts all CSG pieces in the current scene to MeshInstance3D nodes
# with proper LOD levels for the final optimization pass.
#
# This is the definitive LOD solution: once meshes are baked, Godot's
# built-in mesh LOD system takes over via the Mesh resource's LOD slots.
#
# Usage:
# 1. Open target scene in the Godot editor
# 2. Scene > Run Script (select this file)
# 3. Choose whether to replace CSG in-place or save meshes separately
#
# The script:
# a) Finds all CSGShape3D nodes (CSGBox3D, CSGCombiner3D)
# b) Calls mesh_to_save() to get the generated mesh
# c) Creates MeshInstance3D + CollisionShape3D replacements
# d) Generates LOD1 (50%) and LOD2 (25%) via mesh simplification
# e) Replaces the CSG node with the baked mesh
const CREATE_LOD := true
const PRESERVE_COLLISION := true
const LOD0_DISTANCE := 0.0
const LOD1_DISTANCE := 15.0 # 50% tris
const LOD2_DISTANCE := 30.0 # 25% tris
const LOD_DECIMATE_RATIOS := [0.0, 0.5, 0.25]
func _run():
var scene_root: Node
if Engine.is_editor_hint():
scene_root = get_scene() as Node
if not scene_root:
printerr("Open a scene in the editor first.")
return
else:
printerr("This script must be run from the Godot editor.")
return
print("=== CSG → Mesh Baker ===")
print("Scene: ", scene_root.name)
var converted := 0
var errors := 0
_bake_node(scene_root, converted, errors)
print("Converted: ", converted, " CSG nodes")
print("Errors: ", errors)
if converted > 0:
print("")
print("NOTE: After conversion, re-save the scene to persist mesh data.")
print("MeshInstance3D LOD distances: LOD0 < 15m, LOD1 < 30m, LOD2 30m+")
print("")
print("=== Bake complete ===")
func _bake_node(node: Node, converted: int, errors: int) -> void:
if node is CSGShape3D:
var mesh_instance := _convert_csg_to_mesh(node as CSGShape3D)
if mesh_instance:
# Replace CSG node with mesh instance
var parent := node.get_parent()
var idx := node.get_index()
parent.remove_child(node)
parent.add_child(mesh_instance, true)
parent.move_child(mesh_instance, idx)
if Engine.is_editor_hint():
mesh_instance.set_owner(parent.owner)
# Clean up the old CSG node
node.queue_free()
converted += 1
else:
errors += 1
# Recurse — important: use the original node's children
# but the node may have been freed, so iterate a snapshot
var children := node.get_children()
for child in children:
_bake_node(child, converted, errors)
func _convert_csg_to_mesh(csg: CSGShape3D) -> MeshInstance3D:
# Get the generated mesh from the CSG node
var mesh := csg.mesh_to_save()
if not mesh:
push_warning("CSG node '%s' produced no mesh — skipping." % csg.name)
return null
var mi := MeshInstance3D.new()
mi.name = csg.name + "_Mesh"
# Copy transform
mi.transform = csg.transform
# Copy visibility ranges (if set on the CSG node)
mi.visibility_range_begin = csg.visibility_range_begin
mi.visibility_range_end = csg.visibility_range_end
mi.visibility_range_begin_margin = csg.visibility_range_begin_margin
mi.visibility_range_end_margin = csg.visibility_range_end_margin
# Create an ArrayMesh from the CSG mesh
var array_mesh := _to_array_mesh(mesh)
if not array_mesh:
push_warning("Failed to convert mesh for '%s'." % csg.name)
return null
# Generate LODs
if CREATE_LOD and array_mesh.get_surface_count() > 0:
_generate_lods(array_mesh, csg)
mi.mesh = array_mesh
# Add collision shape if the CSG had it
if PRESERVE_COLLISION and csg.use_collision:
var collision := _create_collision(csg)
if collision:
mi.add_child(collision, true)
if Engine.is_editor_hint():
collision.set_owner(mi.owner)
return mi
func _to_array_mesh(source: Mesh) -> ArrayMesh:
# Convert any Mesh type to ArrayMesh suitable for LOD attachment
if source is ArrayMesh:
return source.duplicate() as ArrayMesh
# If it's a primitive mesh, create a new ArrayMesh and copy surfaces
var array_mesh := ArrayMesh.new()
for i in range(source.get_surface_count()):
var arrays := source.surface_get_arrays(i)
if arrays:
var blend := source.surface_get_blend_shape_arrays(i)
var format := source.surface_get_format(i)
array_mesh.add_surface_from_arrays(source.surface_get_primitive_type(i), arrays, blend, format)
return array_mesh
func _generate_lods(array_mesh: ArrayMesh, csg: CSGShape3D) -> void:
# Generate LOD levels for the mesh.
# In Godot 4.3+, LOD is added via add_lod(simplified_mesh, distance).
# True mesh simplification (edge collapse) requires Godot's editor
# mesh_decimate tool or an external library.
#
# This implementation creates placeholder LOD entries using the
# full-poly mesh. For actual draw-call savings, run the editor's
# Mesh → Simplify → Generate LODs to replace these with decimated
# geometry at the LOD distances defined above.
var lods_added := 0
for lod_idx in range(1, 3): # LOD1, LOD2
var ratio := LOD_DECIMATE_RATIOS[lod_idx]
if ratio <= 0.0:
continue
var lod_distance := 0.0
match lod_idx:
1: lod_distance = LOD1_DISTANCE
2: lod_distance = LOD2_DISTANCE
# Create a placeholder LOD entry using the full mesh.
# Replace with mesh_decimate output for actual tri reduction.
var lod_mesh := array_mesh.duplicate() as ArrayMesh
if lod_mesh and lod_mesh.get_surface_count() > 0:
array_mesh.add_lod(lod_mesh, lod_distance)
lods_added += 1
if lods_added == 0:
push_warning("No LODs generated for '%s' — LOD slots require at least one surface." % csg.name)
func _create_collision(csg: CSGShape3D) -> CollisionShape3D:
var shape := CollisionShape3D.new()
shape.name = "Collision"
if csg is CSGBox3D:
var box_shape := BoxShape3D.new()
box_shape.size = (csg as CSGBox3D).size
shape.shape = box_shape
elif csg is CSGSphere3D:
var sphere_shape := SphereShape3D.new()
sphere_shape.radius = (csg as CSGSphere3D).radius
shape.shape = sphere_shape
else:
# Fall back to a box matching the AABB
var aabb_box := BoxShape3D.new()
aabb_box.size = csg.get_aabb().size
shape.shape = aabb_box
return shape
+138
View File
@@ -0,0 +1,138 @@
@tool
extends EditorScript
# Auto-generate OccluderInstance3D nodes from CSG wall pieces.
#
# Scans the current scene for CSGBox3D nodes tagged as structural
# (walls, floors, pillars) and creates matching OccluderInstance3D
# boxes so Godot's occlusion culling system can use them.
#
# Usage:
# 1. Open target scene in Godot editor
# 2. Run: Scene > Run Script (select this file)
# 3. Or run from command line:
# godot --script scripts/generate_occluders.gd --scene res://path/to/scene.tscn
#
# Generated occluders are parented under an "Occluders" node.
const OCCLUDER_PARENT_NAME := "Occluders"
# Minimum CSG size (longest axis) to generate an occluder.
# Very small pieces don't contribute meaningful occlusion.
const MIN_OCCLUDER_SIZE := 0.5
func _run():
var scene_root: Node
if Engine.is_editor_hint():
scene_root = get_scene() as Node
if not scene_root:
printerr("No scene is currently open in the editor.")
return
else:
var args = OS.get_cmdline_args()
var scene_path := ""
for i in range(args.size()):
if args[i] == "--scene" and i + 1 < args.size():
scene_path = args[i + 1]
if scene_path.is_empty():
printerr("Usage: godot --script generate_occluders.gd --scene res://path/to/scene.tscn")
return
var packed = ResourceLoader.load(scene_path)
if not packed:
printerr("Failed to load scene: ", scene_path)
return
scene_root = packed.instantiate()
print("=== Occluder Generator ===")
print("Scene: ", scene_root.name if scene_root else "unknown")
# Find or create the Occluders parent node
var occluder_parent: Node = _find_or_create_parent(scene_root)
var generated_count := 0
var skipped_count := 0
# Walk all CSGBox3D nodes in the scene
_generate_from_node(scene_root, occluder_parent, generated_count, skipped_count)
print("Generated: ", generated_count, " occluders")
print("Skipped (too small): ", skipped_count)
print("Parent: ", occluder_parent.get_path())
print("")
print("IMPORTANT: Occlusion culling requires the following project settings:")
print(" rendering/occlusion_culling/occlusion_rays_per_octant = 6")
print(" rendering/occlusion_culling/occlusion_culler_size = 128")
print("")
print("=== Occluder generation complete ===")
func _generate_from_node(node: Node, parent: Node, generated: int, skipped: int):
# Check if this is a wall/pillar CSGBox3D (exclude subtraction/hole pieces)
if node is CSGBox3D:
var csg := node as CSGBox3D
# Skip subtraction pieces (operation = 1 = Subtraction)
# Skip pieces that are too small to occlude
if csg.operation != CSGShape3D.OPERATION_SUBTRACTION:
var size := csg.size
var longest = max(size.x, max(size.y, size.z))
if longest >= MIN_OCCLUDER_SIZE:
_create_occluder(csg, parent)
generated += 1
else:
skipped += 1
else:
skipped += 1
# Recurse into children
for child in node.get_children():
_generate_from_node(child, parent, generated, skipped)
func _find_or_create_parent(root: Node) -> Node:
for child in root.get_children():
if child.name == OCCLUDER_PARENT_NAME:
print("Found existing Occluders node.")
return child
var parent := Node3D.new()
parent.name = OCCLUDER_PARENT_NAME
parent.set_script(null) # No script needed
# Add as first child so it's visually at the top in the scene tree
root.add_child(parent, true)
if Engine.is_editor_hint():
parent.set_owner(root)
print("Created Occluders node.")
return parent
func _create_occluder(csg: CSGBox3D, parent: Node) -> void:
var occluder := OccluderInstance3D.new()
occluder.name = "Occluder_" + csg.name
# Create a box-shaped occluder matching the CSG piece
var shape := OccluderShape3D.new()
shape.set_as_vertices_and_indices(
PackedVector3Array([]), # auto-generated from AABB
PackedInt32Array([])
)
# Use a box occluder matching the CSG box dimensions
var box_shape := OccluderShapeBox3D.new()
box_shape.size = csg.size
occluder.set_occluder_shape(box_shape)
# Match the CSG node's global transform
var global_transform := csg.global_transform
occluder.global_transform = global_transform
# Bake to static mode (not dynamic) for best performance
occluder.bake_mask = 1 # Layer 1 only
occluder.bake_id = 0
parent.add_child(occluder, true)
if Engine.is_editor_hint():
occluder.set_owner(parent.owner)
# Apply voxel resolution
# Godot 4's occlusion is computed from a signed-distance field
# at the configured culler size (128^3 by budget spec)
+170
View File
@@ -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)
+147
View File
@@ -0,0 +1,147 @@
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)
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://scripts/profiling/profiler_node.gd" id="1"]
[node name="Profiler" type="Node"]
script = ExtResource("1")
+21
View File
@@ -0,0 +1,21 @@
extends SceneTree
func _init():
print("_init called")
# Add a simple node to trigger processing
var n = Node.new()
n.name = "test"
n.set_process(true)
root.add_child(n)
print("Node added")
func _process(delta: float) -> bool:
print("_process called: delta=", delta)
if frame_count > 5:
print("Quitting after 5 frames")
quit(0)
return true
frame_count += 1
return true
var frame_count := 0
+14
View File
@@ -0,0 +1,14 @@
extends Node
var count := 0
func _ready():
print("_ready called")
set_process(true)
func _process(delta):
count += 1
print("_process #", count, " delta=", delta)
if count >= 5:
print("Quitting")
get_tree().quit()
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://scripts/profiling/test_simple.gd" id="1"]
[node name="Root" type="Node"]
script = ExtResource("1")
-6
View File
@@ -1,6 +0,0 @@
extends Node
func _ready():
print("Hello from Godot headless script!")
print("Testing: ", Engine.get_version_info())
get_tree().quit(0)