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:
@@ -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("")
|
||||
Reference in New Issue
Block a user