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,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.5–4.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"
|
||||
@@ -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("")
|
||||
@@ -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("")
|
||||
@@ -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("")
|
||||
@@ -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("")
|
||||
Reference in New Issue
Block a user