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
+610
View File
@@ -0,0 +1,610 @@
@tool
extends SceneTree
# =============================================================================
# bake_lightmaps.gd — LightmapGI Configuration Checker
# =============================================================================
# Validates LightmapGI configuration for baking readiness in a Godot 4 scene.
# Checks:
# - LightmapGI node exists and is properly configured
# - Quality setting (Low/Medium/High/Ultra)
# - Bounce count (recommended ≥ 2)
# - Texel scale (recommended 0.54.0, default 1.0)
# - Interior flag (true for indoor maps)
# - Denoiser enabled
# - Max texture size (recommended ≤ 1024)
# - Light data presence (already baked or not)
# - Geometry with UV2 (required for baking)
#
# Note: Godot 4 LightmapGI baking is editor-only. This script validates
# configuration readiness; it does NOT perform the bake itself.
#
# Usage:
# godot --headless --script client/scripts/bake_lightmaps.gd -- res://path/to/map.tscn
#
# Exit codes:
# 0 — All config checks passed
# 1 — One or more config checks failed
# 2 — Scene file not found or cannot be loaded
# =============================================================================
const RECOMMENDED_BOUNCES := 2
const MIN_BOUNCES := 1
const MAX_BOUNCES := 8
const RECOMMENDED_TEXEL_SCALE := 1.0
const MIN_TEXEL_SCALE := 0.1
const MAX_TEXEL_SCALE := 10.0
const MAX_TEXTURE_SIZE := 1024
const RECOMMENDED_QUALITY := 2 # High
var _pass_count := 0
var _fail_count := 0
var _warning_count := 0
var _scene_path := ""
var _scene_instance: Node = null
var _lightmap_node: Node = null
# UV2 tracking
var _total_meshes := 0
var _meshes_with_uv2 := 0
var _meshes_without_uv2: Array[String] = []
var _mesh_details: Array[Dictionary] = []
func _init() -> void:
_parse_arguments()
if _scene_path.is_empty():
printerr("ERROR: No scene path provided. Usage: godot --headless --script bake_lightmaps.gd -- res://path/to/map.tscn")
quit(2)
return
print("")
print("==========================================")
print(" LIGHTMAP CONFIGURATION CHECKER")
print(" Scene: ", _scene_path)
print("==========================================")
print("")
print(" Note: Godot 4 LightmapGI baking is editor-only.")
print(" This tool validates lightmap configuration readiness only.")
print(" To bake: Open scene in editor > Scene > Bake LightmapGI")
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)
# Find LightmapGI
_lightmap_node = _find_node_by_type(_scene_instance, "LightmapGI")
# Run all checks
_run_lightmap_node_check()
if _lightmap_node != null:
_run_quality_check()
_run_bounce_check()
_run_texel_scale_check()
_run_interior_check()
_run_denoiser_check()
_run_max_texture_size_check()
_run_energy_check()
_run_baked_data_check()
_run_process_mode_check()
# Run geometry UV2 check
_run_uv2_check()
# Detailed reports
_print_lightmap_report()
_print_uv2_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
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:
return scene.instantiate()
func _find_node_by_type(node: Node, type_name: String) -> Node:
"""Recursively find first node matching a class name."""
if node.get_class() == type_name:
return node
for child in node.get_children():
var found := _find_node_by_type(child, type_name)
if found != null:
return found
return null
func _find_nodes_by_type(node: Node, type_name: String) -> Array[Node]:
"""Recursively find all nodes of a given class."""
var results: Array[Node] = []
if node.get_class() == type_name:
results.append(node)
for child in node.get_children():
results.append_array(_find_nodes_by_type(child, type_name))
return results
# ---------------------------------------------------------------------------
# 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)
func _check(condition: bool, msg_pass: String, msg_fail: String) -> bool:
if condition:
_pass(msg_pass)
else:
_fail(msg_fail)
return condition
# ---------------------------------------------------------------------------
# Individual LightmapGI checks
# ---------------------------------------------------------------------------
func _run_lightmap_node_check() -> void:
"""Verify LightmapGI node exists."""
print("--- LightmapGI Node ---")
if _lightmap_node != null:
_pass("LightmapGI node found: '" + _lightmap_node.name + "'")
else:
_fail("LightmapGI node not found in scene — required for baked lighting")
func _run_quality_check() -> void:
"""Validate LightmapGI quality setting."""
print("")
print("--- Quality ---")
if _lightmap_node == null:
return
var quality: int = _lightmap_node.get("quality") if "quality" in _lightmap_node else -1
if quality < 0:
_fail("LightmapGI.quality not available")
return
var quality_names := ["Low", "Medium", "High", "Ultra"]
var quality_name := quality_names[quality] if quality < quality_names.size() else "Unknown"
if quality >= RECOMMENDED_QUALITY:
_pass("LightmapGI.quality = " + quality_name + " (" + str(quality) + ") — High or Ultra recommended")
elif quality == 1:
_warn("LightmapGI.quality = " + quality_name + " (" + str(quality) + ") — Medium is acceptable, High recommended")
else:
_fail("LightmapGI.quality = " + quality_name + " (" + str(quality) + ") — set to at least High (2)")
func _run_bounce_check() -> void:
"""Validate light bounce count."""
print("")
print("--- Bounces ---")
if _lightmap_node == null:
return
var bounces: int = _lightmap_node.get("bounces") if "bounces" in _lightmap_node else -1
if bounces < 0:
_fail("LightmapGI.bounces not available")
return
if bounces >= RECOMMENDED_BOUNCES:
_pass("LightmapGI.bounces = " + str(bounces) + " (recommended: ≥ " + str(RECOMMENDED_BOUNCES) + ")")
elif bounces >= MIN_BOUNCES:
_warn("LightmapGI.bounces = " + str(bounces) + " — low, consider ≥ " + str(RECOMMENDED_BOUNCES))
else:
_fail("LightmapGI.bounces = " + str(bounces) + " — minimum is " + str(MIN_BOUNCES))
# Warn if too high (performance concern)
if bounces > MAX_BOUNCES - 2:
_warn("LightmapGI.bounces = " + str(bounces) + " — high bounce count may impact bake time")
func _run_texel_scale_check() -> void:
"""Validate texel scale setting."""
print("")
print("--- Texel Scale ---")
if _lightmap_node == null:
return
var texel_scale: float = _lightmap_node.get("texel_scale") if "texel_scale" in _lightmap_node else -1.0
if texel_scale < 0:
_fail("LightmapGI.texel_scale not available")
return
if abs(texel_scale - RECOMMENDED_TEXEL_SCALE) < 0.01:
_pass("LightmapGI.texel_scale = " + str(texel_scale) + " (exactly " + str(RECOMMENDED_TEXEL_SCALE) + ")")
elif texel_scale >= MIN_TEXEL_SCALE and texel_scale <= MAX_TEXEL_SCALE:
if texel_scale < 0.5:
_warn("LightmapGI.texel_scale = " + str(texel_scale) + " — below 0.5, bake resolution may be too low")
elif texel_scale > 2.0:
_warn("LightmapGI.texel_scale = " + str(texel_scale) + " — above 2.0, bake resolution may be unnecessarily high")
else:
_pass("LightmapGI.texel_scale = " + str(texel_scale) + " (within reasonable range [0.1, 10.0])")
else:
_fail("LightmapGI.texel_scale = " + str(texel_scale) + " — out of expected range [" + str(MIN_TEXEL_SCALE) + ", " + str(MAX_TEXEL_SCALE) + "]")
func _run_interior_check() -> void:
"""Validate interior flag."""
print("")
print("--- Interior ---")
if _lightmap_node == null:
return
var interior: bool = _lightmap_node.get("interior") if "interior" in _lightmap_node else false
if interior:
_pass("LightmapGI.interior = true (correct for indoor maps)")
else:
_warn("LightmapGI.interior = false — set to true for indoor scenes to avoid light leaking from outside")
func _run_denoiser_check() -> void:
"""Validate denoiser setting."""
print("")
print("--- Denoiser ---")
if _lightmap_node == null:
return
# Godot 4 renamed to use_denoiser
var use_denoiser: bool = false
if "use_denoiser" in _lightmap_node:
use_denoiser = _lightmap_node.get("use_denoiser")
elif _lightmap_node.has_method("is_using_denoiser"):
use_denoiser = _lightmap_node.is_using_denoiser()
if use_denoiser:
_pass("LightmapGI.use_denoiser = true (recommended for cleaner results)")
else:
_warn("LightmapGI.use_denoiser = false — enable denoiser for cleaner lightmap results")
func _run_max_texture_size_check() -> void:
"""Validate max texture size setting."""
print("")
print("--- Max Texture Size ---")
if _lightmap_node == null:
return
var max_tex: int = _lightmap_node.get("max_texture_size") if "max_texture_size" in _lightmap_node else -1
if max_tex < 0:
_warn("LightmapGI.max_texture_size not available (using default)")
return
if max_tex <= MAX_TEXTURE_SIZE:
_pass("LightmapGI.max_texture_size = " + str(max_tex) + " (≤ " + str(MAX_TEXTURE_SIZE) + ")")
else:
_fail("LightmapGI.max_texture_size = " + str(max_tex) + " — exceeds " + str(MAX_TEXTURE_SIZE) +
" (consider lowering for memory budget)")
func _run_energy_check() -> void:
"""Validate lightmap energy setting."""
print("")
print("--- Energy ---")
if _lightmap_node == null:
return
var energy: float = _lightmap_node.get("energy") if "energy" in _lightmap_node else 1.0
_pass("LightmapGI.energy = " + str(energy) + " (verify visually in editor)")
var indirect_energy: float = _lightmap_node.get("indirect_energy") if "indirect_energy" in _lightmap_node else 1.0
if abs(indirect_energy - 1.0) > 0.01:
_warn("LightmapGI.indirect_energy = " + str(indirect_energy) + " (default is 1.0)")
func _run_baked_data_check() -> void:
"""Check whether lightmap data has been baked."""
print("")
print("--- Baked Data ---")
if _lightmap_node == null:
return
var light_data = _lightmap_node.get("light_data") if "light_data" in _lightmap_node else null
if light_data != null:
_pass("LightmapGI has baked light data present")
# Check data size
if light_data.has_method("get_user_data"):
var user_data = light_data.get_user_data()
if user_data != null:
_warn("Lightmap data size: varies (baked data is present)")
else:
_warn("LightmapGI has NO baked data yet — needs baking in editor")
func _run_process_mode_check() -> void:
"""Validate the lightmap process mode."""
print("")
print("--- Process Mode ---")
if _lightmap_node == null:
return
# Godot 4 LightmapGI has a bake_mode or similar property for sub-surface scattering
if "bake_mode" in _lightmap_node:
var bake_mode: int = _lightmap_node.get("bake_mode")
match bake_mode:
0:
_pass("LightmapGI.bake_mode = Conservative (0) — safe default")
1:
_warn("LightmapGI.bake_mode = Aggressive (1) — may cause artifacts")
2:
_pass("LightmapGI.bake_mode = Always Lightmap (2) — good for fully baked scenes")
_:
_warn("LightmapGI.bake_mode = " + str(bake_mode) + " — unknown")
# Check for environment mode (if property exists)
if "environment_mode" in _lightmap_node:
var env_mode: int = _lightmap_node.get("environment_mode")
match env_mode:
0:
_pass("LightmapGI.environment_mode = Disabled — no environment lighting (indoor)")
1:
_pass("LightmapGI.environment_mode = Enabled — using environment for lighting")
2:
_warn("LightmapGI.environment_mode = Ambient Color — verify color is correct")
# ---------------------------------------------------------------------------
# UV2 geometry check
# ---------------------------------------------------------------------------
func _run_uv2_check() -> void:
"""Scan all MeshInstance3D nodes for UV2 channel presence."""
print("")
print("--- Geometry UV2 Check ---")
var mesh_nodes := _find_nodes_by_type(_scene_instance, "MeshInstance3D")
_total_meshes = mesh_nodes.size()
if _total_meshes == 0:
_warn("No MeshInstance3D nodes found — checking CSG nodes for UV2")
# Check CSG nodes instead
var csg_nodes := _find_nodes_by_type(_scene_instance, "CSGCombiner3D")
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGBox3D"))
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGSphere3D"))
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGCylinder3D"))
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGTorus3D"))
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGPolygon3D"))
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGMesh3D"))
# CSG nodes generate UV2 during baking automatically
if csg_nodes.size() > 0:
_pass("Scene uses CSG primitives (" + str(csg_nodes.size()) + ") — UV2 is auto-generated during bake")
return
for mi in mesh_nodes:
var has_uv2 := false
if "mesh" in mi and mi.mesh != null:
has_uv2 = _check_mesh_uv2(mi.mesh)
var info := {
"name": mi.name,
"has_uv2": has_uv2,
"material_count": 0
}
_mesh_details.append(info)
if has_uv2:
_meshes_with_uv2 += 1
else:
_meshes_without_uv2.append(mi.name)
if _meshes_without_uv2.size() == 0:
_pass("All " + str(_total_meshes) + " MeshInstance3D nodes have UV2 channel")
else:
for mesh_name in _meshes_without_uv2:
_fail("MeshInstance3D '" + mesh_name + "' lacks UV2 channel — lightmapping will not work")
_warn(str(_meshes_without_uv2.size()) + "/" + str(_total_meshes) + " meshes missing UV2")
# Check for sub-surface scattering override on LightmapGI
if _lightmap_node != null and "subdiv" in _lightmap_node:
var subdiv: int = _lightmap_node.get("subdiv")
if subdiv > 0:
_warn("LightmapGI.subdiv = " + str(subdiv) + " — non-zero subdiv enables subsurface scattering")
func _check_mesh_uv2(mesh) -> bool:
"""Check if a mesh has UV2 channel data."""
if mesh == null:
return false
if not mesh.has_method("get_surface_count"):
return false
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 uv2 := arrays[Mesh.ARRAY_TEX_UV2] as PackedVector2Array
if uv2 != null and uv2.size() > 0:
return true
return false
# ---------------------------------------------------------------------------
# Reports
# ---------------------------------------------------------------------------
func _print_lightmap_report() -> void:
"""Print a detailed lightmap configuration report."""
print("")
print("--- LightmapGI Configuration Report ---")
if _lightmap_node == null:
print(" (no LightmapGI node to report)")
return
print(" Node path: ", _get_node_path(_lightmap_node))
var quality_names := ["Low", "Medium", "High", "Ultra"]
var q: int = _lightmap_node.get("quality") if "quality" in _lightmap_node else -1
if q >= 0 and q < quality_names.size():
print(" Quality: ", quality_names[q], " (", q, ")")
else:
print(" Quality: ", q)
print(" Bounces: ", _lightmap_node.get("bounces") if "bounces" in _lightmap_node else "N/A")
print(" Texel Scale: ", _lightmap_node.get("texel_scale") if "texel_scale" in _lightmap_node else "N/A")
print(" Interior: ", _lightmap_node.get("interior") if "interior" in _lightmap_node else "N/A")
print(" Denoiser: ", _lightmap_node.get("use_denoiser") if "use_denoiser" in _lightmap_node else "N/A")
print(" Max Tex Size: ", _lightmap_node.get("max_texture_size") if "max_texture_size" in _lightmap_node else "N/A")
print(" Energy: ", _lightmap_node.get("energy") if "energy" in _lightmap_node else "N/A")
print(" Baked Data: ", "Yes" if _lightmap_node.get("light_data") != null else "No")
print("")
func _print_uv2_report() -> void:
"""Print UV2 details for meshes."""
print("--- UV2 Report ---")
print(" Total MeshInstance3D: ", _total_meshes)
print(" With UV2: ", _meshes_with_uv2)
print(" Without UV2: ", _meshes_without_uv2.size())
if _meshes_without_uv2.size() > 0:
print(" Missing UV2 list:")
for name in _meshes_without_uv2:
print(" - ", name)
print("")
func _get_node_path(node: Node) -> String:
"""Get the scene-tree path of a node."""
var parts: Array[String] = []
var current := node
while current != null and current != _scene_instance:
parts.push_front(current.name)
current = current.get_parent()
return "/".join(parts)
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
func _print_summary() -> void:
"""Print final summary."""
print("==========================================")
print(" LIGHTMAP CONFIG SUMMARY")
print("==========================================")
print(" Scene: ", _scene_path)
print(" LightmapGI: ", "Found ✓" if _lightmap_node != null else "Missing ✗")
print(" Quality OK: ", _check_quality_status())
print(" Bounces OK: ", _check_bounce_status())
print(" Texel Scale OK: ", _check_texel_status())
print(" Interior OK: ", _check_interior_status())
print(" Denoiser OK: ", _check_denoiser_status())
print(" UV2 OK: ", _check_uv2_status())
print("------------------------------------------")
print(" Passed: ", _pass_count)
print(" Failed: ", _fail_count)
print(" Warnings: ", _warning_count)
print("------------------------------------------")
if _fail_count > 0:
print(" RESULT: FAILED ❌")
printerr("Lightmap config check FAILED — ", _fail_count, " check(s) failed.")
else:
print(" RESULT: PASSED ✅")
print("==========================================")
print("")
func _check_quality_status() -> String:
if _lightmap_node == null: return "N/A"
var q = _lightmap_node.get("quality") if "quality" in _lightmap_node else -1
return "Yes ✓" if q >= RECOMMENDED_QUALITY else ("No ✗" if q >= 0 else "N/A")
func _check_bounce_status() -> String:
if _lightmap_node == null: return "N/A"
var b = _lightmap_node.get("bounces") if "bounces" in _lightmap_node else -1
return "Yes ✓" if b >= RECOMMENDED_BOUNCES else ("No ✗" if b >= 0 else "N/A")
func _check_texel_status() -> String:
if _lightmap_node == null: return "N/A"
var t = _lightmap_node.get("texel_scale") if "texel_scale" in _lightmap_node else -1.0
if t < 0: return "N/A"
return "Yes ✓" if t >= 0.5 and t <= 2.0 else ""
func _check_interior_status() -> String:
if _lightmap_node == null: return "N/A"
var interior = _lightmap_node.get("interior") if "interior" in _lightmap_node else false
return "Yes ✓" if interior else "No ✗"
func _check_denoiser_status() -> String:
if _lightmap_node == null: return "N/A"
if "use_denoiser" in _lightmap_node:
return "Yes ✓" if _lightmap_node.get("use_denoiser") else "No ✗"
if _lightmap_node.has_method("is_using_denoiser"):
return "Yes ✓" if _lightmap_node.is_using_denoiser() else "No ✗"
return "N/A"
func _check_uv2_status() -> String:
if _total_meshes == 0: return "N/A (CSG)"
if _meshes_without_uv2.size() == 0: return "Yes ✓"
return str(_meshes_without_uv2.size()) + " missing"
+443
View File
@@ -0,0 +1,443 @@
@tool
extends SceneTree
# =============================================================================
# validate_lights.gd — Dynamic Light Count / Bake Mode Validator
# =============================================================================
# Audits all light nodes in a Godot 4 scene for:
# - Dynamic light count ≤ 4 (budget for mobile/competitive FPS)
# - Each light's bake mode is set correctly (BakeMode = STATIC or DYNAMIC)
# - Light energy / range / attenuation are within reasonable bounds
# - Shadow configuration is appropriate
#
# Usage:
# godot --headless --script client/scripts/validate_lights.gd -- res://path/to/map.tscn
#
# Exit codes:
# 0 — All light checks passed
# 1 — One or more light checks failed
# 2 — Scene file not found or cannot be loaded
# =============================================================================
const MAX_DYNAMIC_LIGHTS := 4
const MAX_LIGHT_ENERGY := 50.0
const MIN_LIGHT_ENERGY := 0.1
const MAX_LIGHT_RANGE := 100.0
const MAX_LIGHT_SPOT_ANGLE := 180.0
const WARN_TOTAL_LIGHTS := 8
# BakeMode enum values as Godot defines them
const BAKE_MODE_DISABLED := 0
const BAKE_MODE_STATIC := 1
const BAKE_MODE_DYNAMIC := 2
var _pass_count := 0
var _fail_count := 0
var _warning_count := 0
var _scene_path := ""
var _scene_instance: Node = null
# Light stat tracking
var _total_lights := 0
var _dynamic_lights := 0
var _baked_lights := 0
var _disabled_lights := 0
var _shadowed_lights := 0
var _light_details: Array[Dictionary] = []
# Per-type counts
var _directional_lights := 0
var _omni_lights := 0
var _spot_lights := 0
func _init() -> void:
_parse_arguments()
if _scene_path.is_empty():
printerr("ERROR: No scene path provided. Usage: godot --headless --script validate_lights.gd -- res://path/to/map.tscn")
quit(2)
return
print("")
print("==========================================")
print(" LIGHT VALIDATOR")
print(" Scene: ", _scene_path)
print("==========================================")
print("")
print(" Light budget:")
print(" Max dynamic lights: ", MAX_DYNAMIC_LIGHTS)
print(" Recommended baking: All static geometry lights")
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)
# Collect all lights
_analyze_lights(_scene_instance)
# Run checks
_run_dynamic_light_count_check()
_run_bake_mode_checks()
_run_energy_checks()
_run_shadow_checks()
_print_light_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
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:
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)
# ---------------------------------------------------------------------------
# Light analysis
# ---------------------------------------------------------------------------
func _analyze_lights(node: Node) -> void:
"""Recursively walk scene collecting light data."""
var class_name := node.get_class()
if class_name in ["DirectionalLight3D", "OmniLight3D", "SpotLight3D"]:
_total_lights += 1
var info := _collect_light_info(node, class_name)
_light_details.append(info)
# Count by type
match class_name:
"DirectionalLight3D":
_directional_lights += 1
"OmniLight3D":
_omni_lights += 1
"SpotLight3D":
_spot_lights += 1
# Track bake mode
var bake_mode := 0
if "bake_mode" in node:
bake_mode = node.bake_mode
match bake_mode:
BAKE_MODE_DISABLED:
_disabled_lights += 1
_dynamic_lights += 1
BAKE_MODE_STATIC:
_baked_lights += 1
BAKE_MODE_DYNAMIC:
_dynamic_lights += 1
# Track shadows
if "shadow" in node and node.shadow:
_shadowed_lights += 1
for child in node.get_children():
_analyze_lights(child)
func _collect_light_info(node: Node, class_name: String) -> Dictionary:
"""Collect all relevant properties from a light node."""
var info := {
"name": node.name,
"type": class_name,
"path": _get_node_path(node),
"enabled": node.get("visible") if "visible" in node else true,
"bake_mode": node.get("bake_mode") if "bake_mode" in node else 0,
"energy": node.get("energy") if "energy" in node else 1.0,
"shadow": node.get("shadow") if "shadow" in node else false,
"shadow_bias": node.get("shadow_bias") if "shadow_bias" in node else 0.0,
"indirect_energy": node.get("indirect_energy") if "indirect_energy" in node else 1.0,
"light_specular": node.get("light_specular") if "light_specular" in node else 0.5,
}
# Type-specific properties
match class_name:
"OmniLight3D":
info["range"] = node.get("omni_range") if "omni_range" in node else 0.0
info["attenuation"] = node.get("omni_attenuation") if "omni_attenuation" in node else 1.0
"SpotLight3D":
info["range"] = node.get("spot_range") if "spot_range" in node else 0.0
info["attenuation"] = node.get("spot_attenuation") if "spot_attenuation" in node else 1.0
info["spot_angle"] = node.get("spot_angle") if "spot_angle" in node else 45.0
info["spot_angle_attenuation"] = node.get("spot_angle_attenuation") if "spot_angle_attenuation" in node else 1.0
"DirectionalLight3D":
info["range"] = -1.0
info["directional_shadow_mode"] = node.get("directional_shadow_mode") if "directional_shadow_mode" in node else 0
return info
func _get_node_path(node: Node) -> String:
"""Get the scene-tree path of a node."""
var parts: Array[String] = []
var current := node
while current != null and current != _scene_instance:
parts.push_front(current.name)
current = current.get_parent()
return "/".join(parts)
func _get_bake_mode_name(mode: int) -> String:
match mode:
BAKE_MODE_DISABLED: return "Disabled (Dynamic)"
BAKE_MODE_STATIC: return "Static (Baked)"
BAKE_MODE_DYNAMIC: return "Dynamic"
return "Unknown(" + str(mode) + ")"
# ---------------------------------------------------------------------------
# Light checks
# ---------------------------------------------------------------------------
func _run_dynamic_light_count_check() -> void:
"""Check that dynamic light count is within budget."""
print("--- Dynamic Light Count ---")
if _total_lights == 0:
_warn("No lights found in scene at all (verify map lighting)")
return
_pass("Total lights: " + str(_total_lights) + " (Dir: " + str(_directional_lights) +
", Omni: " + str(_omni_lights) + ", Spot: " + str(_spot_lights) + ")")
if _dynamic_lights <= MAX_DYNAMIC_LIGHTS:
_pass("Dynamic lights: " + str(_dynamic_lights) + " (budget: ≤ " + str(MAX_DYNAMIC_LIGHTS) + ")")
else:
_fail("Too many dynamic lights: " + str(_dynamic_lights) +
" (budget: ≤ " + str(MAX_DYNAMIC_LIGHTS) + ")" +
" — consider baking " + str(_dynamic_lights - MAX_DYNAMIC_LIGHTS) + " light(s)")
# Warn if total lights (including baked) is very high
if _total_lights > WARN_TOTAL_LIGHTS:
_warn("Total light count (" + str(_total_lights) + ") exceeds " + str(WARN_TOTAL_LIGHTS) +
" — verify all lights are necessary")
func _run_bake_mode_checks() -> void:
"""Check that each light has an appropriate bake mode."""
print("")
print("--- Bake Mode Validation ---")
var proper_bake := 0
var improper_bake := 0
for info in _light_details:
var bake_mode := info.bake_mode as int
var light_name := info.name as String
var light_type := info.type as String
var bake_name := _get_bake_mode_name(bake_mode)
if bake_mode == BAKE_MODE_STATIC:
proper_bake += 1
_pass(light_name + " (" + light_type + "): bake_mode = Static ✓")
elif bake_mode == BAKE_MODE_DISABLED:
# Dynamic lights are OK if count is within budget
improper_bake += 1
_warn(light_name + " (" + light_type + "): bake_mode = Disabled (fully dynamic)")
elif bake_mode == BAKE_MODE_DYNAMIC:
improper_bake += 1
_warn(light_name + " (" + light_type + "): bake_mode = Dynamic")
if improper_bake > 0:
_warn(str(improper_bake) + " light(s) are not set to Static bake mode" +
" — use Static for baked geometry lights")
# DirectionalLight3D should generally be Baked (Static) for main sun
for info in _light_details:
if info.type == "DirectionalLight3D" and info.bake_mode == BAKE_MODE_DISABLED:
_warn("DirectionalLight3D '" + info.name + "' is set to Disabled bake mode" +
" — set to Static (Baked) for performance")
func _run_energy_checks() -> void:
"""Check that light energy values are within reasonable ranges."""
print("")
print("--- Light Energy / Range Checks ---")
for info in _light_details:
var energy := info.energy as float
var light_name := info.name as String
var light_type := info.type as String
if energy > MAX_LIGHT_ENERGY:
_warn(light_name + " (" + light_type + "): energy = " + str(energy) +
" — very high (max recommended: " + str(MAX_LIGHT_ENERGY) + ")")
elif energy < MIN_LIGHT_ENERGY:
_warn(light_name + " (" + light_type + "): energy = " + str(energy) +
" — very low (min recommended: " + str(MIN_LIGHT_ENERGY) + ")")
else:
_pass(light_name + " (" + light_type + "): energy = " + str(energy) + " (within [0.1, 50.0])")
# Range check (not applicable to DirectionalLight3D)
if info.has("range") and info.range > 0:
var range_val := info.range as float
if range_val > MAX_LIGHT_RANGE:
_warn(light_name + " (" + light_type + "): range = " + str(range_val) +
" — very large (max recommended: " + str(MAX_LIGHT_RANGE) + ")")
# Spot angle check
if info.has("spot_angle"):
var angle := info.spot_angle as float
if angle > MAX_LIGHT_SPOT_ANGLE:
_warn(light_name + " (SpotLight3D): spot_angle = " + str(angle) +
" — exceeds 180° (will project backwards)")
func _run_shadow_checks() -> void:
"""Validate shadow configuration on lights."""
print("")
print("--- Shadow Configuration ---")
for info in _light_details:
var has_shadow := info.shadow as bool
var light_name := info.name as String
if has_shadow:
# Shadowed lights are more expensive
if info.bake_mode == BAKE_MODE_STATIC:
_pass(light_name + ": shadows enabled, bake_mode = Static (good — shadows will be baked)")
else:
_warn(light_name + ": dynamic shadows enabled — expensive for real-time rendering")
# Count shadowed dynamic lights
var dynamic_shadow_count := 0
for info in _light_details:
if info.shadow and info.bake_mode != BAKE_MODE_STATIC:
dynamic_shadow_count += 1
if dynamic_shadow_count > 2:
_warn(str(dynamic_shadow_count) + " lights have dynamic shadows — recommended max 2 for performance")
# ---------------------------------------------------------------------------
# Light report
# ---------------------------------------------------------------------------
func _print_light_report() -> void:
"""Print a comprehensive light report."""
print("")
print("--- Light Report ---")
print(" Total lights: ", _total_lights)
print(" DirectionalLight3D: ", _directional_lights)
print(" OmniLight3D: ", _omni_lights)
print(" SpotLight3D: ", _spot_lights)
print(" Dynamic (non-baked): ", _dynamic_lights)
print(" Baked (Static): ", _baked_lights)
print(" Disabled: ", _disabled_lights)
print(" Shadowed: ", _shadowed_lights)
print("")
# Light detail table
if _light_details.size() > 0:
print(" Light Details:")
print(" " + "-".repeat(90))
print(" %-25s %-18s %-12s %-8s %-6s %-8s" % ["Name", "Type", "Bake Mode", "Energy", "Shadow", "Range"])
print(" " + "-".repeat(90))
for info in _light_details:
var bake_mode_name := _get_bake_mode_name(info.bake_mode)
var range_str := str(info.range) if info.has("range") and info.range > 0 else "N/A"
var shadow_str := "Yes" if info.shadow else "No"
print(" %-25s %-18s %-12s %-8.1f %-6s %-8s" % [
info.name, info.type, bake_mode_name, info.energy, shadow_str, range_str
])
print(" " + "-".repeat(90))
print("")
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
func _print_summary() -> void:
"""Print final summary of all light checks."""
print("==========================================")
print(" LIGHT VALIDATION SUMMARY")
print("==========================================")
print(" Scene: ", _scene_path)
print(" Total lights: ", _total_lights)
print(" Dynamic lights: ", _dynamic_lights)
print(" Baked (Static): ", _baked_lights)
print(" Dynamic shadowed: ", _shadowed_lights)
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, " light check(s) failed.")
else:
print(" RESULT: PASSED ✅")
print("==========================================")
print("")
+511
View File
@@ -0,0 +1,511 @@
@tool
extends SceneTree
# =============================================================================
# validate_map.gd — Map Scene Structure Validator
# =============================================================================
# Validates that a competitive FPS map scene follows the required structure:
# - CSG Combiner as root node
# - Spawn groups (T Spawn / CT Spawn)
# - Bomb sites (A / B)
# - Buy zones (T / CT)
# - Map bounds (invisible walls / kill plane)
# - LightmapGI configuration
# - Lighting setup (WorldEnvironment, directional/ambient lights)
#
# Usage:
# godot --headless --script client/scripts/validate_map.gd -- res://path/to/map.tscn
#
# Exit codes:
# 0 — All checks passed
# 1 — One or more checks failed
# 2 — Scene file not found at the given path
# =============================================================================
const EXPECTED_MIN_NODES := 15
const EXPECTED_MAX_NODES := 500
# Separator used to mark user arguments after --
const ARG_SEPARATOR := "--"
var _pass_count := 0
var _fail_count := 0
var _warning_count := 0
var _scene_path := ""
var _scene_instance: Node = null
var _root_node: Node = null
func _init() -> void:
_parse_arguments()
if _scene_path.is_empty():
printerr("ERROR: No scene path provided. Usage: godot --headless --script validate_map.gd -- res://path/to/map.tscn")
quit(2)
return
print("")
print("==========================================")
print(" MAP SCENE STRUCTURE VALIDATOR")
print(" Scene: ", _scene_path)
print("==========================================")
print("")
# Step 1 — Load and instantiate the scene
var scene: PackedScene = _load_scene(_scene_path)
if scene == null:
printerr("ERROR: Scene not found or failed to load: ", _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)
_root_node = _scene_instance
# Step 2 — Run all validation checks
_run_all_checks()
# Step 3 — Print summary
_print_summary()
# Step 4 — Exit with appropriate code
if _fail_count > 0:
quit(1)
else:
quit(0)
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
func _parse_arguments() -> void:
"""
Parse command-line arguments to extract the scene path.
Args after '--' are user arguments: godot ... --script validate_map.gd -- res://map.tscn
"""
var args: PackedStringArray = OS.get_cmdline_user_args()
if args.is_empty():
# Fallback: look for '--' in full cmdline
var full_args: PackedStringArray = OS.get_cmdline_args()
var found_sep := false
for i in range(full_args.size()):
if full_args[i] == ARG_SEPARATOR:
found_sep = true
continue
if found_sep:
_scene_path = full_args[i].strip_edges()
break
else:
# First user arg is the scene path
_scene_path = args[0].strip_edges()
# Basic validation
if _scene_path.is_empty():
return
# Ensure it's a res:// path or convert relative to res://
if not _scene_path.begins_with("res://"):
# Could be a relative filesystem path — keep as-is, ResourceLoader handles it
pass
# ---------------------------------------------------------------------------
# Scene loading helpers
# ---------------------------------------------------------------------------
func _load_scene(path: String) -> PackedScene:
"""
Load a .tscn or .scn file and return the PackedScene, or null on failure.
"""
if not ResourceLoader.exists(path):
# Try adding extension
if not path.ends_with(".tscn") and not path.ends_with(".scn"):
path = path + ".tscn"
if not ResourceLoader.exists(path):
return null
var scene: PackedScene = ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_REUSE)
return scene
func _instantiate_scene(scene: PackedScene) -> Node:
"""
Safely instantiate a packed scene, returning the root node or null.
"""
var instance: Node = null
var ok := false
instance = scene.instantiate()
if instance != null:
ok = true
return instance if ok else null
# ---------------------------------------------------------------------------
# Check registration helpers
# ---------------------------------------------------------------------------
func _pass(msg: String) -> void:
"""Record a passed check."""
_pass_count += 1
print("", msg)
func _fail(msg: String) -> void:
"""Record a failed check."""
_fail_count += 1
print("", msg)
func _warn(msg: String) -> void:
"""Record a non-blocking warning."""
_warning_count += 1
print("", msg)
func _check(condition: bool, msg_pass: String, msg_fail: String) -> bool:
"""Convenience: pass or fail based on condition."""
if condition:
_pass(msg_pass)
else:
_fail(msg_fail)
return condition
# ---------------------------------------------------------------------------
# Node search helpers
# ---------------------------------------------------------------------------
func _find_node(parent: Node, name: String) -> Node:
"""Recursively find a node by exact name."""
for child in parent.get_children():
if child.name == name:
return child
var found := _find_node(child, name)
if found != null:
return found
return null
func _find_node_by_type(parent: Node, type_name: String) -> Node:
"""Recursively find a node by its script or class name."""
for child in parent.get_children():
var class_str := str(child.get_class())
if class_str == type_name:
return child
var found := _find_node_by_type(child, type_name)
if found != null:
return found
return null
func _find_nodes_by_type(parent: Node, type_name: String) -> Array[Node]:
"""Recursively find all nodes of a given type."""
var results: Array[Node] = []
if str(parent.get_class()) == type_name:
results.append(parent)
for child in parent.get_children():
results.append_array(_find_nodes_by_type(child, type_name))
return results
func _find_children_by_name_prefix(parent: Node, prefix: String) -> Array[Node]:
"""Find all immediate children whose name starts with prefix."""
var results: Array[Node] = []
for child in parent.get_children():
if child.name.begins_with(prefix):
results.append(child)
return results
func _count_nodes(node: Node) -> int:
"""Count total nodes in the subtree, including node itself."""
var count := 1
for child in node.get_children():
count += _count_nodes(child)
return count
# ---------------------------------------------------------------------------
# Main validation runner
# ---------------------------------------------------------------------------
func _run_all_checks() -> void:
"""Execute all validation categories."""
print("--- Root Node ---")
_check_root_node()
print("")
print("--- Spawn Groups ---")
_check_spawn_groups()
print("")
print("--- Bomb Sites ---")
_check_bomb_sites()
print("")
print("--- Buy Zones ---")
_check_buy_zones()
print("")
print("--- Map Bounds ---")
_check_map_bounds()
print("")
print("--- LightmapGI Configuration ---")
_check_lightmapgi()
print("")
print("--- Lighting Setup ---")
_check_lighting()
print("")
print("--- Node Count Sanity ---")
_check_node_sanity()
print("")
print("--- CSG Combiner Hierarchy ---")
_check_csg_hierarchy()
# ---------------------------------------------------------------------------
# Individual check groups
# ---------------------------------------------------------------------------
func _check_root_node() -> void:
"""Verify root node is a CSG Combiner or suitable root."""
if _root_node == null:
_fail("Root node is null")
return
var class_name := _root_node.get_class()
# Accept CSGCombiner3D as root, or any reasonable Spatial/Node3D
var is_csg := class_name in ["CSGCombiner3D", "CSGSphere3D", "CSGBox3D", "CSGCylinder3D", "CSGTorus3D"]
var is_node3d := class_name in ["Node3D", "Spatial"]
if is_csg:
_pass("Root node is CSGCombiner3D ('" + _root_node.name + "')")
elif is_node3d:
_warn("Root node is Node3D ('" + _root_node.name + "') — expected CSGCombiner3D for map geometry")
else:
_warn("Root node is '" + class_name + "' ('" + _root_node.name + "') — expected CSGCombiner3D")
func _check_spawn_groups() -> void:
"""Verify spawn point groups exist (T Spawn, CT Spawn)."""
var found_t_spawn := false
var found_ct_spawn := false
for child in _root_node.get_children():
var name_lower := child.name.to_lower()
if "t_spawn" in name_lower or "tspawn" in name_lower or "terrorist" in name_lower:
found_t_spawn = true
_pass("T Spawn group found: '" + child.name + "'")
if "ct_spawn" in name_lower or "ctspawn" in name_lower or "counter" in name_lower:
found_ct_spawn = true
_pass("CT Spawn group found: '" + child.name + "'")
if not found_t_spawn:
_fail("T Spawn group not found (expected node containing 'T_Spawn' or 'Terrorist')")
if not found_ct_spawn:
_fail("CT Spawn group not found (expected node containing 'CT_Spawn' or 'Counter')")
func _check_bomb_sites() -> void:
"""Verify bomb site marker nodes exist (A, B)."""
var found_a := false
var found_b := false
for child in _root_node.get_children():
var name_lower := child.name.to_lower()
if "bombsite_a" in name_lower or "bomb_site_a" in name_lower or "site_a" in name_lower or "a_site" in name_lower:
found_a = true
_pass("Bomb Site A found: '" + child.name + "'")
if "bombsite_b" in name_lower or "bomb_site_b" in name_lower or "site_b" in name_lower or "b_site" in name_lower:
found_b = true
_pass("Bomb Site B found: '" + child.name + "'")
if not found_a:
_fail("Bomb Site A not found (expected node containing 'Bombsite_A' or 'Site_A')")
if not found_b:
_fail("Bomb Site B not found (expected node containing 'Bombsite_B' or 'Site_B')")
func _check_buy_zones() -> void:
"""Verify buy zone areas exist for T and CT spawns."""
var found_t_buy := false
var found_ct_buy := false
for child in _root_node.get_children():
var name_lower := child.name.to_lower()
if ("t_buy" in name_lower or "buy_t" in name_lower or "buyzone_t" in name_lower) and "zone" in name_lower:
found_t_buy = true
_pass("T Buy Zone found: '" + child.name + "'")
if ("ct_buy" in name_lower or "buy_ct" in name_lower or "buyzone_ct" in name_lower) and "zone" in name_lower:
found_ct_buy = true
_pass("CT Buy Zone found: '" + child.name + "'")
if not found_t_buy or not found_ct_buy:
_warn("Buy zones not found — expected Area3D nodes named 'T_BuyZone' and 'CT_BuyZone'")
func _check_map_bounds() -> void:
"""Verify map bounds exist (invisible walls, kill plane)."""
var found_bounds := false
var found_killplane := false
for child in _root_node.get_children():
var name_lower := child.name.to_lower()
if "bounds" in name_lower or "boundary" in name_lower or "wall" in name_lower:
if "invisible" in name_lower or "clip" in name_lower or "world" in name_lower:
found_bounds = true
_pass("Map bounds found: '" + child.name + "'")
if "kill" in name_lower or "death" in name_lower or "fall" in name_lower:
found_killplane = true
_pass("Kill plane / death zone found: '" + child.name + "'")
if not found_bounds:
_warn("No map boundary/invisible wall nodes detected (optional but recommended)")
if not found_killplane:
_warn("No kill plane / death zone found (optional but recommended)")
func _check_lightmapgi() -> void:
"""Validate LightmapGI node configuration."""
var lightmap := _find_node_by_type(_root_node, "LightmapGI")
if lightmap == null:
_fail("LightmapGI node not found in scene")
return
_pass("LightmapGI node found: '" + lightmap.name + "'")
# Check quality
if lightmap.has_method("get_quality"):
var q := lightmap.get_quality()
_check(q >= 1, "LightmapGI quality set (≥ Medium)", "LightmapGI quality is Low (0) — set to at least Medium")
# Check bounces
var bounces: int = lightmap.get("bounces") if "bounces" in lightmap else -1
if bounces >= 0:
_check(bounces >= 2, "LightmapGI.bounces >= 2", "LightmapGI.bounces is " + str(bounces) + " — expected ≥ 2")
# Check interior
var interior: bool = lightmap.get("interior") if "interior" in lightmap else false
_check(interior == true, "LightmapGI.interior = true (indoor map)", "LightmapGI.interior is false — set to true for indoor scenes")
# Check texel scale
var texel_scale: float = lightmap.get("texel_scale") if "texel_scale" in lightmap else 0.0
_check(abs(texel_scale - 1.0) < 0.01, "LightmapGI.texel_scale ≈ 1.0", "LightmapGI.texel_scale is " + str(texel_scale) + " — expected 1.0")
# Check denoiser
var denoiser: bool = lightmap.get("use_denoiser") if "use_denoiser" in lightmap else false
_check(denoiser == true, "LightmapGI.use_denoiser = true", "LightmapGI.use_denoiser is false — recommended true")
# Check max texture size
var max_tex: int = lightmap.get("max_texture_size") if "max_texture_size" in lightmap else 0
if max_tex > 0:
_check(max_tex <= 1024, "LightmapGI.max_texture_size ≤ 1024", "LightmapGI.max_texture_size is " + str(max_tex) + " — consider ≤ 1024")
# Check energy
var energy: float = lightmap.get("energy") if "energy" in lightmap else 1.0
_warn("LightmapGI energy = " + str(energy) + " (verify visually)")
func _check_lighting() -> void:
"""Verify lighting setup: WorldEnvironment, directional light, etc."""
var world_env := _find_node_by_type(_root_node, "WorldEnvironment")
if world_env != null:
_pass("WorldEnvironment node found")
# Check environment resource
if world_env.has_method("get_environment"):
var env := world_env.get_environment()
if env != null:
_pass("WorldEnvironment has Environment resource")
else:
_fail("WorldEnvironment has no Environment resource assigned")
else:
_warn("WorldEnvironment — check environment assignment manually")
else:
_fail("WorldEnvironment node not found — required for map lighting")
# Directional light (sun)
var sun := _find_node_by_type(_root_node, "DirectionalLight3D")
if sun != null:
_pass("DirectionalLight3D (sun) found: '" + sun.name + "'")
else:
_fail("DirectionalLight3D not found — map needs a sun light")
# Check for too many dynamic lights
var omni_lights := _find_nodes_by_type(_root_node, "OmniLight3D")
var spot_lights := _find_nodes_by_type(_root_node, "SpotLight3D")
if omni_lights.size() + spot_lights.size() > 4:
_warn("Scene has " + str(omni_lights.size() + spot_lights.size()) + " dynamic lights — consider baking (recommended ≤ 4)")
else:
_pass("Dynamic light count (" + str(omni_lights.size() + spot_lights.size()) + ") within budget (≤ 4)")
# Reflection probes
var ref_probes := _find_nodes_by_type(_root_node, "ReflectionProbe")
if ref_probes.size() > 0:
_pass("ReflectionProbe(s) found: " + str(ref_probes.size()))
else:
_warn("No ReflectionProbe found — consider adding for visual quality")
func _check_node_sanity() -> void:
"""Verify total node count is within expected range."""
var total := _count_nodes(_root_node)
if total < EXPECTED_MIN_NODES:
_warn("Scene has only " + str(total) + " nodes — very minimal (< " + str(EXPECTED_MIN_NODES) + ")")
elif total > EXPECTED_MAX_NODES:
_warn("Scene has " + str(total) + " nodes — consider optimization (> " + str(EXPECTED_MAX_NODES) + ")")
else:
_pass("Node count " + str(total) + " within expected range [" + str(EXPECTED_MIN_NODES) + ", " + str(EXPECTED_MAX_NODES) + "]")
func _check_csg_hierarchy() -> void:
"""Check that CSG nodes are properly parented under a CSGCombiner3D."""
var csg_nodes := _find_nodes_by_type(_root_node, "CSGCombiner3D")
if csg_nodes.size() > 1:
_warn("Multiple CSGCombiner3D nodes found (" + str(csg_nodes.size()) + ") — expected single root combiner")
elif csg_nodes.size() == 0:
_warn("No CSGCombiner3D found at root (map may use mesh instances instead)")
var csg_shape_nodes := 0
csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGBox3D").size()
csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGSphere3D").size()
csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGCylinder3D").size()
csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGTorus3D").size()
csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGPolygon3D").size()
if csg_shape_nodes > 0:
_pass("CSG primitive nodes found: " + str(csg_shape_nodes))
else:
_warn("No CSG primitive nodes found — map may use imported meshes instead")
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
func _print_summary() -> void:
"""Print a final summary of all checks."""
print("")
print("==========================================")
print(" VALIDATION SUMMARY")
print("==========================================")
print(" Scene: ", _scene_path)
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, " check(s) failed.")
else:
print(" RESULT: PASSED ✅")
print("==========================================")
print("")
+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("")
+581
View File
@@ -0,0 +1,581 @@
@tool
extends SceneTree
# =============================================================================
# validate_textures.gd — Texture Size / Mipmaps / UV2 Checker
# =============================================================================
# Scans all material and texture resources used in a scene and validates:
# - Texture dimensions ≤ 1024 (1K)
# - Mipmaps are enabled on all textures
# - UV2 channel is present on meshes (for lightmapping)
# - Texture format is appropriate
#
# Usage:
# godot --headless --script client/scripts/validate_textures.gd -- res://path/to/map.tscn
#
# Exit codes:
# 0 — All texture checks passed
# 1 — One or more texture checks failed
# 2 — Scene file not found or cannot be loaded
# =============================================================================
const MAX_TEXTURE_SIZE := 1024
const MAX_TEXTURE_SIZE_LABEL := "1K (1024x1024)"
var _pass_count := 0
var _fail_count := 0
var _warning_count := 0
var _scene_path := ""
var _scene_instance: Node = null
# Collected data
var _textures_checked: Dictionary = {} # resource_path -> info dict
var _overbudget_textures: Array[Dictionary] = []
var _no_mipmap_textures: Array[Dictionary] = []
var _meshes_without_uv2: Array[String] = []
var _meshes_with_uv2: Array[String] = []
var _mesh_count := 0
var _total_textures := 0
var _total_materials := 0
var _texture_memory_estimate := 0 # approximate total texture memory in bytes
# Unique set to avoid re-checking the same resource
var _checked_resources: Dictionary = {}
func _init() -> void:
_parse_arguments()
if _scene_path.is_empty():
printerr("ERROR: No scene path provided. Usage: godot --headless --script validate_textures.gd -- res://path/to/map.tscn")
quit(2)
return
print("")
print("==========================================")
print(" TEXTURE VALIDATOR")
print(" Scene: ", _scene_path)
print("==========================================")
print("")
print(" Texture budget:")
print(" Max dimension: ", MAX_TEXTURE_SIZE_LABEL)
print(" Mipmaps: Required")
print(" UV2 channel: Required for lightmapping")
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)
# Analyze
_analyze_node(_scene_instance)
# Run checks
_run_texture_checks()
_run_mipmap_checks()
_run_uv2_checks()
_print_texture_report()
# Summary
_print_summary()
if _fail_count > 0:
quit(1)
else:
quit(0)
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
func _parse_arguments() -> void:
"""Extract scene path from user args (after --)."""
var args: PackedStringArray = OS.get_cmdline_user_args()
if not args.is_empty():
_scene_path = args[0].strip_edges()
return
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:
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)
# ---------------------------------------------------------------------------
# Analysis engine
# ---------------------------------------------------------------------------
func _analyze_node(node: Node) -> void:
"""Recursively walk scene, check materials on meshes."""
var class_name := node.get_class()
# MeshInstance3D — inspect its material overrides
if class_name == "MeshInstance3D" or node.is_class("MeshInstance3D"):
_mesh_count += 1
_analyze_mesh_instance(node)
# CSG nodes can also have materials / meshes
if class_name in ["CSGCombiner3D", "CSGSphere3D", "CSGBox3D", "CSGCylinder3D",
"CSGTorus3D", "CSGPolygon3D", "CSGMesh3D"]:
_analyze_csg_node(node)
# GeometryInstance3D base class
if node.is_class("GeometryInstance3D"):
var mat_override := node.get("material_override") if "material_override" in node else null
if mat_override != null:
_analyze_material(mat_override)
for child in node.get_children():
_analyze_node(child)
func _analyze_mesh_instance(mi: Node) -> void:
"""Inspect a MeshInstance3D node for materials and UV2."""
# Check mesh
if "mesh" in mi and mi.mesh != null:
var mesh = mi.mesh
_analyze_mesh(mesh, mi.name)
# Surface material overrides
if "material_override" in mi and mi.material_override != null:
_analyze_material(mi.material_override)
# Per-surface materials
if mi.has_method("get_surface_override_material"):
var surface_count := 0
if "mesh" in mi and mi.mesh != null and mi.mesh.has_method("get_surface_count"):
surface_count = mi.mesh.get_surface_count()
for s in range(surface_count):
var mat := mi.get_surface_override_material(s)
if mat != null:
_analyze_material(mat)
func _analyze_csg_node(node: Node) -> void:
"""Analyze materials on CSG nodes."""
# CSG nodes have operation and material properties
if "material" in node and node.material != null:
_analyze_material(node.material)
# Check CSGMesh3D which references a mesh
if node.get_class() == "CSGMesh3D" and "mesh" in node and node.mesh != null:
_analyze_mesh(node.mesh, node.name)
func _analyze_mesh(mesh, node_name: String) -> void:
"""Analyze a mesh resource for UV2 and material slots."""
if mesh == null:
return
var mesh_id := mesh.resource_path if "resource_path" in mesh and mesh.resource_path != "" else str(mesh.get_instance_id())
if _checked_resources.has(mesh_id):
return
_checked_resources[mesh_id] = true
# Check for UV2 channel
var has_uv2 := false
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 uv2 := arrays[Mesh.ARRAY_TEX_UV2] as PackedVector2Array
if uv2 != null and uv2.size() > 0:
has_uv2 = true
if has_uv2:
_meshes_with_uv2.append(node_name)
else:
_meshes_without_uv2.append(node_name)
# Check materials on the mesh
if mesh.has_method("surface_get_material"):
var surface_count := mesh.get_surface_count()
for s in range(surface_count):
var mat := mesh.surface_get_material(s)
if mat != null:
_analyze_material(mat)
func _analyze_material(mat) -> void:
"""Recursively analyze a material and its textures."""
if mat == null:
return
var mat_id := mat.resource_path if "resource_path" in mat and mat.resource_path != "" else str(mat.get_instance_id())
if _checked_resources.has(mat_id):
return
_checked_resources[mat_id] = true
_total_materials += 1
var mat_class := mat.get_class()
# StandardMaterial3D — check texture properties
if mat_class == "StandardMaterial3D" or mat_class == "ORMMaterial3D":
var tex_properties := [
"albedo_texture", "metallic_texture", "roughness_texture",
"normal_texture", "ambient_occlusion_texture", "emission_texture",
"clearcoat_texture", "rim_texture", "transparency_texture",
"heightmap_texture", "refraction_texture", "detail_albedo_texture",
"detail_normal_texture", "detail_mask_texture", "subsurface_scattering_texture",
"backlight_texture"
]
for prop in tex_properties:
if prop in mat:
var tex = mat.get(prop)
if tex != null:
_analyze_texture(tex)
# ShaderMaterial — inspect shader parameters for textures
elif mat_class == "ShaderMaterial":
if mat.has_method("get_shader_parameter_list"):
var param_list := mat.get_shader_parameter_list()
for param in param_list:
var param_name := param.name if "name" in param else str(param)
var val = mat.get_shader_parameter(param_name)
if val != null and (val is Texture2D or val is Texture3D or val is TextureLayered):
_analyze_texture(val)
# BaseMaterial3D (Godot 4 fallback)
elif mat.is_class("BaseMaterial3D"):
if "albedo_texture" in mat and mat.albedo_texture != null:
_analyze_texture(mat.albedo_texture)
if "metallic_texture" in mat and mat.metallic_texture != null:
_analyze_texture(mat.metallic_texture)
if "roughness_texture" in mat and mat.roughness_texture != null:
_analyze_texture(mat.roughness_texture)
if "normal_texture" in mat and mat.normal_texture != null:
_analyze_texture(mat.normal_texture)
func _analyze_texture(tex) -> void:
"""Analyze a single texture resource for size, mipmaps, and format."""
if tex == null:
return
# Use resource path as unique key
var tex_path := ""
if "resource_path" in tex and tex.resource_path != "":
tex_path = tex.resource_path
else:
tex_path = str(tex.get_instance_id())
if _textures_checked.has(tex_path):
return
var tex_class := tex.get_class()
var width := 0
var height := 0
var has_mipmaps := false
var format := -1
var format_name := "Unknown"
# Texture2D
if tex is Texture2D:
width = tex.get_width()
height = tex.get_height()
has_mipmaps = tex.has_mipmaps() if tex.has_method("has_mipmaps") else false
if tex.has_method("get_format"):
format = tex.get_format()
format_name = _get_format_name(format)
_total_textures += 1
# Texture3D
elif tex is Texture3D:
width = tex.get_width()
height = tex.get_height()
if tex.has_method("has_mipmaps"):
has_mipmaps = tex.has_mipmaps()
_total_textures += 1
# TextureLayered
elif tex is TextureLayered:
if tex.has_method("get_width"):
width = tex.get_width()
if tex.has_method("get_height"):
height = tex.get_height()
_total_textures += 1
var info := {
"path": tex_path,
"width": width,
"height": height,
"has_mipmaps": has_mipmaps,
"format": format_name,
"class": tex_class
}
_textures_checked[tex_path] = info
# Estimate memory
var bpp := _bits_per_pixel(format)
_texture_memory_estimate += (width * height * bpp) / 8
# Check over-budget
var max_dim := maxi(width, height)
if max_dim > MAX_TEXTURE_SIZE:
_overbudget_textures.append(info)
# Check mipmaps
if not has_mipmaps:
_no_mipmap_textures.append(info)
# ---------------------------------------------------------------------------
# Texture checks
# ---------------------------------------------------------------------------
func _run_texture_checks() -> void:
"""Validate texture size budget."""
print("--- Texture Size Checks ---")
if _total_textures == 0:
_warn("No textures found in scene (may be expected for procedural materials)")
return
_pass("Total textures found: " + str(_total_textures))
if _overbudget_textures.size() == 0:
_pass("All textures within " + MAX_TEXTURE_SIZE_LABEL + " limit")
else:
for tex_info in _overbudget_textures:
var dim := str(tex_info.width) + "x" + str(tex_info.height)
_fail("Texture over budget: " + _truncate_path(tex_info.path) + " (" + dim + ", max: " + MAX_TEXTURE_SIZE_LABEL + ")")
func _run_mipmap_checks() -> void:
"""Validate that all textures have mipmaps enabled."""
print("")
print("--- Mipmap Checks ---")
if _no_mipmap_textures.size() == 0:
_pass("All textures have mipmaps enabled")
else:
for tex_info in _no_mipmap_textures:
var dim := str(tex_info.width) + "x" + str(tex_info.height)
_fail("Texture missing mipmaps: " + _truncate_path(tex_info.path) + " (" + dim + ", class: " + tex_info.class + ")")
func _run_uv2_checks() -> void:
"""Validate UV2 channel presence on meshes."""
print("")
print("--- UV2 Channel Checks ---")
if _mesh_count == 0:
_warn("No MeshInstance3D nodes found")
return
_pass("Meshes found: " + str(_mesh_count))
if _meshes_without_uv2.size() == 0:
_pass("All " + str(_mesh_count) + " meshes have UV2 channel (good for lightmapping)")
else:
for mesh_name in _meshes_without_uv2:
_fail("Mesh missing UV2 channel: " + mesh_name + " lightmapping will not work correctly")
# ---------------------------------------------------------------------------
# Report
# ---------------------------------------------------------------------------
func _print_texture_report() -> void:
"""Print detailed texture report."""
print("")
print("--- Texture Report ---")
print(" Total materials: ", _total_materials)
print(" Total textures: ", _total_textures)
print(" Over-budget textures: ", _overbudget_textures.size())
print(" Missing mipmaps: ", _no_mipmap_textures.size())
print(" Total meshes: ", _mesh_count)
print(" Meshes with UV2: ", _meshes_with_uv2.size())
print(" Meshes without UV2: ", _meshes_without_uv2.size())
print(" Est. texture memory: ", _format_bytes(_texture_memory_estimate))
print("")
# Print texture format distribution
var format_counts: Dictionary = {}
for tex_path in _textures_checked.keys():
var info := _textures_checked[tex_path] as Dictionary
var fmt := info.format
if not format_counts.has(fmt):
format_counts[fmt] = 0
format_counts[fmt] += 1
if format_counts.size() > 0:
print(" Texture format distribution:")
for fmt in format_counts.keys():
print(" " + fmt + ": " + str(format_counts[fmt]))
print("")
func _truncate_path(path: String) -> String:
"""Truncate a resource path to fit in a single line."""
if path.length() > 60:
return "..." + path.substr(path.length() - 57)
return path
func _get_format_name(format_id: int) -> String:
"""Map Godot image format enum to a human-readable name."""
match format_id:
0: return "L8"
1: return "LA8"
2: return "R8"
3: return "RG8"
4: return "RGB8"
5: return "RGBA8"
6: return "RGBA4444"
7: return "RGBA5551"
8: return "RF"
9: return "RGF"
10: return "RGBF"
11: return "RGBAF"
12: return "RH"
13: return "RGH"
14: return "RGBH"
15: return "RGBAH"
16: return "RGBE9995"
17: return "DXT1 (BC1)"
18: return "DXT3 (BC2)"
19: return "DXT5 (BC3)"
20: return "RGTC (BC4)"
21: return "RGTC (BC5)"
22: return "BPTC (BC6H)"
23: return "BPTC (BC7)"
24: return "ETC"
25: return "ETC2 R11"
26: return "ETC2 RG11"
27: return "ETC2 RGB8"
28: return "ETC2 RGBA8"
29: return "PVRTC2"
30: return "PVRTC4"
31: return "ASTC 4x4"
32: return "ASTC 8x8"
33: return "Max"
return "Unknown(" + str(format_id) + ")"
func _bits_per_pixel(format_id: int) -> int:
"""Estimate bits per pixel for a given format."""
match format_id:
0, 2: return 8 # L8, R8
1, 3: return 16 # LA8, RG8
4: return 24 # RGB8
5, 6, 7: return 32 # RGBA8, RGBA4444, RGBA5551
8: return 32 # RF
9: return 64 # RGF
10: return 96 # RGBF
11: return 128 # RGBAF
12: return 16 # RH (half)
13: return 32 # RGH
14: return 48 # RGBH
15: return 64 # RGBAH
16: return 32 # RGBE9995
17, 18, 19: return 4 # DXT/BC compressed — 4bpp
20, 21: return 4 # BC4/BC5 — 4bpp (BC5 is 8bpp but rare)
22: return 8 # BC6H — 8bpp
23: return 8 # BC7 — 8bpp
24, 25, 26, 27, 28: return 4 # ETC/ETC2 — 4bpp
29, 30: return 2 # PVRTC — 2bpp
31: return 8 # ASTC 4x4 — 8bpp
32: return 2 # ASTC 8x8 — 2bpp
return 32
func _format_bytes(bytes: int) -> String:
"""Format bytes into human-readable string."""
if bytes < 1024:
return str(bytes) + " B"
elif bytes < 1024 * 1024:
return str(stepify(float(bytes) / 1024.0, 0.1)) + " KB"
else:
return str(stepify(float(bytes) / (1024.0 * 1024.0), 0.1)) + " MB"
func maxi(a: int, b: int) -> int:
return a if a > b else b
func mini(a: int, b: int) -> int:
return a if a < b else b
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
func _print_summary() -> void:
"""Print final summary."""
print("==========================================")
print(" TEXTURE VALIDATION SUMMARY")
print("==========================================")
print(" Scene: ", _scene_path)
print(" Textures checked: ", _total_textures)
print(" Materials checked: ", _total_materials)
print(" Over-budget textures: ", _overbudget_textures.size())
print(" Missing mipmaps: ", _no_mipmap_textures.size())
print(" Meshes w/o UV2: ", _meshes_without_uv2.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, " texture check(s) failed.")
else:
print(" RESULT: PASSED ")
print("==========================================")
print("")