@tool extends SceneTree # ============================================================================= # validate_polycount.gd — CSG Triangle Budget Checker # ============================================================================= # Audits a Godot 4 map scene for CSG polygon / triangle budget compliance. # Budget limits (competitive FPS): # - Total faces: < 50,000 # - Per node: < 5,000 # - Total CSG nodes: < 200 # # Usage: # godot --headless --script client/scripts/validate_polycount.gd -- res://path/to/map.tscn # # Exit codes: # 0 — All poly budgets met # 1 — One or more budgets exceeded # 2 — Scene file not found or cannot be loaded # ============================================================================= # Budget constants (adjust for target platform) const MAX_TOTAL_FACES := 50_000 const MAX_FACES_PER_NODE := 5_000 const MAX_CSG_NODES := 200 const MAX_MESH_INSTANCES := 150 const WARN_TOTAL_FACES := 40_000 const WARN_FACES_PER_NODE := 3_000 var _pass_count := 0 var _fail_count := 0 var _warning_count := 0 var _scene_path := "" var _scene_instance: Node = null # Accumulated stats var _total_faces := 0 var _total_vertices := 0 var _total_csg_nodes := 0 var _total_mesh_instances := 0 var _node_face_counts: Dictionary = {} # node_path -> face_count var _highest_face_node := "" var _highest_face_count := 0 var _csg_node_list: Array[String] = [] var _overbudget_nodes: Array[String] = [] func _init() -> void: _parse_arguments() if _scene_path.is_empty(): printerr("ERROR: No scene path provided. Usage: godot --headless --script validate_polycount.gd -- res://path/to/map.tscn") quit(2) return print("") print("==========================================") print(" CSG POLYGON BUDGET VALIDATOR") print(" Scene: ", _scene_path) print("==========================================") print("") # Budget header print(" Budget limits:") print(" Max total faces: ", _format_num(MAX_TOTAL_FACES)) print(" Max faces per node: ", _format_num(MAX_FACES_PER_NODE)) print(" Max CSG nodes: ", _format_num(MAX_CSG_NODES)) print(" Max MeshInstance3D: ", _format_num(MAX_MESH_INSTANCES)) print("") var scene: PackedScene = _load_scene(_scene_path) if scene == null: printerr("ERROR: Scene not found: ", _scene_path) quit(2) return _scene_instance = _instantiate_scene(scene) if _scene_instance == null: printerr("ERROR: Failed to instantiate scene: ", _scene_path) quit(2) return root.add_child(_scene_instance) # Perform analysis _analyze_node(_scene_instance, "") # Run all checks _run_checks() # Print detailed report _print_detailed_report() # Summary _print_summary() if _fail_count > 0: quit(1) else: quit(0) # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- func _parse_arguments() -> void: """Extract scene path from user arguments (after --).""" var args: PackedStringArray = OS.get_cmdline_user_args() if not args.is_empty(): _scene_path = args[0].strip_edges() return # Fallback: scan full cmdline for -- var full_args: PackedStringArray = OS.get_cmdline_args() var found_sep := false for i in range(full_args.size()): if full_args[i] == "--": found_sep = true continue if found_sep: _scene_path = full_args[i].strip_edges() break # --------------------------------------------------------------------------- # Scene helpers # --------------------------------------------------------------------------- func _load_scene(path: String) -> PackedScene: """Load a .tscn/.scn file.""" if not ResourceLoader.exists(path): if not path.ends_with(".tscn") and not path.ends_with(".scn"): path = path + ".tscn" if not ResourceLoader.exists(path): return null return ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_REUSE) func _instantiate_scene(scene: PackedScene) -> Node: """Instantiate a packed scene.""" return scene.instantiate() # --------------------------------------------------------------------------- # Check helpers # --------------------------------------------------------------------------- func _pass(msg: String) -> void: _pass_count += 1 print(" ✓ ", msg) func _fail(msg: String) -> void: _fail_count += 1 print(" ✗ ", msg) func _warn(msg: String) -> void: _warning_count += 1 print(" ⚠ ", msg) # --------------------------------------------------------------------------- # Polygon analysis engine # --------------------------------------------------------------------------- func _analyze_node(node: Node, path: String) -> void: """ Recursively walk the scene tree and collect polygon statistics for all CSG and MeshInstance nodes. """ var current_path := path + "/" + node.name if path != "" else node.name # Handle CSG nodes var class_name := node.get_class() var is_csg := class_name in [ "CSGCombiner3D", "CSGSphere3D", "CSGBox3D", "CSGCylinder3D", "CSGTorus3D", "CSGPolygon3D", "CSGMesh3D" ] if is_csg: _total_csg_nodes += 1 _csg_node_list.append(current_path) # Try to get mesh / face count var face_count := _estimate_faces(node, class_name) var vert_count := _estimate_vertices(node, class_name) if face_count > 0: _total_faces += face_count _total_vertices += vert_count _node_face_counts[current_path] = face_count if face_count > _highest_face_count: _highest_face_count = face_count _highest_face_node = current_path if face_count > MAX_FACES_PER_NODE: _overbudget_nodes.append(current_path) # Count MeshInstance3D if class_name == "MeshInstance3D" or node.is_class("MeshInstance3D"): _total_mesh_instances += 1 # Recurse children for child in node.get_children(): _analyze_node(child, current_path) func _estimate_faces(node: Node, class_name: String) -> int: """ Attempt to estimate the number of triangle faces for a given node. For CSG nodes, Godot exposes get_meshes() which returns arrays. For MeshInstance3D, we inspect the mesh resource. """ var faces := 0 # CSG nodes can provide their collision / bake mesh if class_name in ["CSGCombiner3D", "CSGSphere3D", "CSGBox3D", "CSGCylinder3D", "CSGTorus3D", "CSGPolygon3D", "CSGMesh3D"]: # Try to get the bake mesh (the triangulated mesh used for lightmapping) if node.has_method("get_bake_mesh"): var bake_mesh := node.get_bake_mesh() if bake_mesh != null: faces = _count_mesh_faces(bake_mesh) # If that fails, try the collision/debug mesh if faces == 0 and node.has_method("get_meshes"): var mesh_arrays := node.get_meshes() if mesh_arrays is Array and mesh_arrays.size() > 1: var mesh := mesh_arrays[1] as ArrayMesh if mesh != null: faces = _count_mesh_faces(mesh) # Fallback estimate: for simple primitives if faces == 0: match class_name: "CSGBox3D": faces = 12 # 6 faces * 2 triangles "CSGSphere3D": faces = 320 # typical sphere LOD "CSGCylinder3D": faces = 128 # typical cylinder "CSGTorus3D": faces = 512 # typical torus "CSGPolygon3D": faces = 64 # default polygon "CSGMesh3D": faces = 100 # fallback "CSGCombiner3D": faces = 0 # combiner itself has no geometry # MeshInstance3D: count faces from mesh resource if class_name == "MeshInstance3D" or node.is_class("MeshInstance3D"): if "mesh" in node and node.mesh != null: faces = _count_mesh_faces(node.mesh) return faces func _estimate_vertices(node: Node, class_name: String) -> int: """Estimate vertices from the node's mesh if available.""" var verts := 0 if class_name in ["CSGCombiner3D", "CSGSphere3D", "CSGBox3D", "CSGCylinder3D", "CSGTorus3D", "CSGPolygon3D", "CSGMesh3D"]: if node.has_method("get_bake_mesh"): var bake_mesh := node.get_bake_mesh() if bake_mesh != null: verts = _count_mesh_vertices(bake_mesh) if verts == 0 and (class_name == "MeshInstance3D" or node.is_class("MeshInstance3D")): if "mesh" in node and node.mesh != null: verts = _count_mesh_vertices(node.mesh) return verts func _count_mesh_faces(mesh) -> int: """Count triangle faces in any Mesh type.""" if mesh == null: return 0 var count := 0 # ArrayMesh and similar if mesh.has_method("get_surface_count"): var surface_count := mesh.get_surface_count() for s in range(surface_count): var arrays := mesh.surface_get_arrays(s) if arrays is Array and arrays.size() > 0: var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array if indices != null and indices.size() > 0: count += indices.size() / 3 else: # Non-indexed: count vertices / 3 var verts := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array if verts != null: count += verts.size() / 3 return count func _count_mesh_vertices(mesh) -> int: """Count vertices in any Mesh type.""" if mesh == null: return 0 var count := 0 if mesh.has_method("get_surface_count"): var surface_count := mesh.get_surface_count() for s in range(surface_count): var arrays := mesh.surface_get_arrays(s) if arrays is Array and arrays.size() > 0: var verts := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array if verts != null: count += verts.size() return count # --------------------------------------------------------------------------- # Budget checks # --------------------------------------------------------------------------- func _run_checks() -> void: """Run all poly budget validation checks.""" print("--- Budget Checks ---") # Total faces check if _total_faces < WARN_TOTAL_FACES: _pass("Total faces: " + _format_num(_total_faces) + " (budget: < " + _format_num(MAX_TOTAL_FACES) + ")") elif _total_faces < MAX_TOTAL_FACES: _warn("Total faces approaching limit: " + _format_num(_total_faces) + " (budget: < " + _format_num(MAX_TOTAL_FACES) + ")") else: _fail("Total faces EXCEEDED: " + _format_num(_total_faces) + " (budget: < " + _format_num(MAX_TOTAL_FACES) + ")") # Per-node face check if _overbudget_nodes.size() == 0: _pass("No nodes exceed " + _format_num(MAX_FACES_PER_NODE) + " faces") else: for node_path in _overbudget_nodes: _fail("Node exceeds face budget: " + node_path + " (" + _format_num(_node_face_counts[node_path]) + " faces, max: " + _format_num(MAX_FACES_PER_NODE) + ")") # CSG node count check if _total_csg_nodes < MAX_CSG_NODES: _pass("CSG node count: " + str(_total_csg_nodes) + " (budget: < " + str(MAX_CSG_NODES) + ")") else: _fail("CSG node count EXCEEDED: " + str(_total_csg_nodes) + " (budget: < " + str(MAX_CSG_NODES) + ")") # Mesh instance count check if _total_mesh_instances < MAX_MESH_INSTANCES: _pass("MeshInstance3D count: " + str(_total_mesh_instances) + " (budget: < " + str(MAX_MESH_INSTANCES) + ")") else: _fail("MeshInstance3D count EXCEEDED: " + str(_total_mesh_instances) + " (budget: < " + str(MAX_MESH_INSTANCES) + ")") # Highest face node info print("") print("--- Hot Spots ---") if _highest_face_count > 0: if _highest_face_count > WARN_FACES_PER_NODE: _warn("Highest face node: " + _highest_face_node + " (" + _format_num(_highest_face_count) + " faces)") else: _pass("Highest face node: " + _highest_face_node + " (" + _format_num(_highest_face_count) + " faces)") else: _warn("No face data collected (scene may use non-CSG geometry)") # --------------------------------------------------------------------------- # Detailed report # --------------------------------------------------------------------------- func _print_detailed_report() -> void: """Print a detailed per-node breakdown if there are few enough nodes.""" print("") print("--- Detailed Breakdown ---") print(" Total triangles: ", _format_num(_total_faces)) print(" Total vertices: ", _format_num(_total_vertices)) print(" CSG nodes: ", _total_csg_nodes) print(" MeshInstance3D: ", _total_mesh_instances) print(" Nodes over budget: ", _overbudget_nodes.size()) print("") # Top 10 most expensive nodes var sorted_nodes: Array[Dictionary] = [] for node_path in _node_face_counts.keys(): sorted_nodes.append({"path": node_path, "faces": _node_face_counts[node_path]}) sorted_nodes.sort_custom(_sort_by_faces_desc) var top_n := mini(sorted_nodes.size(), 10) if top_n > 0: print(" Top " + str(top_n) + " most expensive nodes:") for i in range(top_n): var entry := sorted_nodes[i] as Dictionary var bar := _make_bar(entry.faces, _highest_face_count) print(" " + str(i + 1) + ". " + entry.path + " — " + _format_num(entry.faces) + " faces " + bar) print("") func _sort_by_faces_desc(a: Dictionary, b: Dictionary) -> bool: return a.faces > b.faces func _make_bar(value: int, max_value: int) -> String: """Create a simple ASCII bar visualization.""" if max_value == 0: return "" var ratio := float(value) / float(max_value) var bar_len := int(ratio * 20) if bar_len < 1 and value > 0: bar_len = 1 var bar := "" for i in range(bar_len): bar += "█" return bar # --------------------------------------------------------------------------- # Formatting helpers # --------------------------------------------------------------------------- func _format_num(n: int) -> String: """Format a number with thousand separators.""" var s := str(n) var result := "" var count := 0 for i in range(s.length() - 1, -1, -1): if count > 0 and count % 3 == 0: result = "," + result result = s[i] + result count += 1 return result func mini(a: int, b: int) -> int: return a if a < b else b # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- func _print_summary() -> void: """Print final summary.""" print("==========================================") print(" POLYGON BUDGET SUMMARY") print("==========================================") print(" Scene: ", _scene_path) print(" Total faces: ", _format_num(_total_faces)) print(" Total vertices: ", _format_num(_total_vertices)) print(" CSG nodes: ", _total_csg_nodes) print(" MeshInstance3D: ", _total_mesh_instances) print(" Over-budget: ", _overbudget_nodes.size()) print("------------------------------------------") print(" Passed: ", _pass_count) print(" Failed: ", _fail_count) print(" Warnings: ", _warning_count) print("------------------------------------------") if _fail_count > 0: print(" RESULT: FAILED ❌") printerr("Validation FAILED — ", _fail_count, " budget(s) exceeded.") else: print(" RESULT: PASSED ✅") print("==========================================") print("")