t_p5_validator: add 5 Godot 4 map validator scripts

- 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.
This commit is contained in:
2026-07-01 18:35:52 -04:00
parent 589b90d886
commit ffa72a8f24
5 changed files with 2610 additions and 0 deletions
+465
View File
@@ -0,0 +1,465 @@
@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("")