ffa72a8f24
- validate_map.gd — scene structure validator (CSG combiner root, spawn groups, bomb sites, buy zones, map bounds, LightmapGI config, lighting setup) - validate_polycount.gd — CSG triangle budget checker (<50K faces, <5K per node, <200 CSG nodes) - validate_textures.gd — texture size ≤1K, mipmaps, UV2 checker - validate_lights.gd — dynamic lights ≤4, bake mode validator - bake_lightmaps.gd — LightmapGI config checker (quality, bounces, texel_scale, interior, denoiser) All scripts: @tool SceneTree, accept scene path via -- separator, exit 0=pass / 1=fail / 2=scene-not-found. 443-610 lines each.
466 lines
14 KiB
GDScript
466 lines
14 KiB
GDScript
@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("")
|