t_p5_validator: add 5 Godot 4 map validator scripts

- validate_map.gd — scene structure validator (CSG combiner root, spawn groups,
  bomb sites, buy zones, map bounds, LightmapGI config, lighting setup)
- validate_polycount.gd — CSG triangle budget checker
  (<50K faces, <5K per node, <200 CSG nodes)
- validate_textures.gd — texture size ≤1K, mipmaps, UV2 checker
- validate_lights.gd — dynamic lights ≤4, bake mode validator
- bake_lightmaps.gd — LightmapGI config checker
  (quality, bounces, texel_scale, interior, denoiser)

All scripts: @tool SceneTree, accept scene path via -- separator,
exit 0=pass / 1=fail / 2=scene-not-found. 443-610 lines each.
This commit is contained in:
2026-07-01 18:35:52 -04:00
parent 589b90d886
commit ffa72a8f24
5 changed files with 2610 additions and 0 deletions
+610
View File
@@ -0,0 +1,610 @@
@tool
extends SceneTree
# =============================================================================
# bake_lightmaps.gd — LightmapGI Configuration Checker
# =============================================================================
# Validates LightmapGI configuration for baking readiness in a Godot 4 scene.
# Checks:
# - LightmapGI node exists and is properly configured
# - Quality setting (Low/Medium/High/Ultra)
# - Bounce count (recommended ≥ 2)
# - Texel scale (recommended 0.54.0, default 1.0)
# - Interior flag (true for indoor maps)
# - Denoiser enabled
# - Max texture size (recommended ≤ 1024)
# - Light data presence (already baked or not)
# - Geometry with UV2 (required for baking)
#
# Note: Godot 4 LightmapGI baking is editor-only. This script validates
# configuration readiness; it does NOT perform the bake itself.
#
# Usage:
# godot --headless --script client/scripts/bake_lightmaps.gd -- res://path/to/map.tscn
#
# Exit codes:
# 0 — All config checks passed
# 1 — One or more config checks failed
# 2 — Scene file not found or cannot be loaded
# =============================================================================
const RECOMMENDED_BOUNCES := 2
const MIN_BOUNCES := 1
const MAX_BOUNCES := 8
const RECOMMENDED_TEXEL_SCALE := 1.0
const MIN_TEXEL_SCALE := 0.1
const MAX_TEXEL_SCALE := 10.0
const MAX_TEXTURE_SIZE := 1024
const RECOMMENDED_QUALITY := 2 # High
var _pass_count := 0
var _fail_count := 0
var _warning_count := 0
var _scene_path := ""
var _scene_instance: Node = null
var _lightmap_node: Node = null
# UV2 tracking
var _total_meshes := 0
var _meshes_with_uv2 := 0
var _meshes_without_uv2: Array[String] = []
var _mesh_details: Array[Dictionary] = []
func _init() -> void:
_parse_arguments()
if _scene_path.is_empty():
printerr("ERROR: No scene path provided. Usage: godot --headless --script bake_lightmaps.gd -- res://path/to/map.tscn")
quit(2)
return
print("")
print("==========================================")
print(" LIGHTMAP CONFIGURATION CHECKER")
print(" Scene: ", _scene_path)
print("==========================================")
print("")
print(" Note: Godot 4 LightmapGI baking is editor-only.")
print(" This tool validates lightmap configuration readiness only.")
print(" To bake: Open scene in editor > Scene > Bake LightmapGI")
print("")
var scene: PackedScene = _load_scene(_scene_path)
if scene == null:
printerr("ERROR: Scene not found: ", _scene_path)
quit(2)
return
_scene_instance = _instantiate_scene(scene)
if _scene_instance == null:
printerr("ERROR: Failed to instantiate scene: ", _scene_path)
quit(2)
return
root.add_child(_scene_instance)
# Find LightmapGI
_lightmap_node = _find_node_by_type(_scene_instance, "LightmapGI")
# Run all checks
_run_lightmap_node_check()
if _lightmap_node != null:
_run_quality_check()
_run_bounce_check()
_run_texel_scale_check()
_run_interior_check()
_run_denoiser_check()
_run_max_texture_size_check()
_run_energy_check()
_run_baked_data_check()
_run_process_mode_check()
# Run geometry UV2 check
_run_uv2_check()
# Detailed reports
_print_lightmap_report()
_print_uv2_report()
# Summary
_print_summary()
if _fail_count > 0:
quit(1)
else:
quit(0)
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
func _parse_arguments() -> void:
"""Extract scene path from user arguments (after --)."""
var args: PackedStringArray = OS.get_cmdline_user_args()
if not args.is_empty():
_scene_path = args[0].strip_edges()
return
var full_args: PackedStringArray = OS.get_cmdline_args()
var found_sep := false
for i in range(full_args.size()):
if full_args[i] == "--":
found_sep = true
continue
if found_sep:
_scene_path = full_args[i].strip_edges()
break
# ---------------------------------------------------------------------------
# Scene helpers
# ---------------------------------------------------------------------------
func _load_scene(path: String) -> PackedScene:
"""Load a .tscn/.scn file."""
if not ResourceLoader.exists(path):
if not path.ends_with(".tscn") and not path.ends_with(".scn"):
path = path + ".tscn"
if not ResourceLoader.exists(path):
return null
return ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_REUSE)
func _instantiate_scene(scene: PackedScene) -> Node:
return scene.instantiate()
func _find_node_by_type(node: Node, type_name: String) -> Node:
"""Recursively find first node matching a class name."""
if node.get_class() == type_name:
return node
for child in node.get_children():
var found := _find_node_by_type(child, type_name)
if found != null:
return found
return null
func _find_nodes_by_type(node: Node, type_name: String) -> Array[Node]:
"""Recursively find all nodes of a given class."""
var results: Array[Node] = []
if node.get_class() == type_name:
results.append(node)
for child in node.get_children():
results.append_array(_find_nodes_by_type(child, type_name))
return results
# ---------------------------------------------------------------------------
# Check helpers
# ---------------------------------------------------------------------------
func _pass(msg: String) -> void:
_pass_count += 1
print("", msg)
func _fail(msg: String) -> void:
_fail_count += 1
print("", msg)
func _warn(msg: String) -> void:
_warning_count += 1
print("", msg)
func _check(condition: bool, msg_pass: String, msg_fail: String) -> bool:
if condition:
_pass(msg_pass)
else:
_fail(msg_fail)
return condition
# ---------------------------------------------------------------------------
# Individual LightmapGI checks
# ---------------------------------------------------------------------------
func _run_lightmap_node_check() -> void:
"""Verify LightmapGI node exists."""
print("--- LightmapGI Node ---")
if _lightmap_node != null:
_pass("LightmapGI node found: '" + _lightmap_node.name + "'")
else:
_fail("LightmapGI node not found in scene — required for baked lighting")
func _run_quality_check() -> void:
"""Validate LightmapGI quality setting."""
print("")
print("--- Quality ---")
if _lightmap_node == null:
return
var quality: int = _lightmap_node.get("quality") if "quality" in _lightmap_node else -1
if quality < 0:
_fail("LightmapGI.quality not available")
return
var quality_names := ["Low", "Medium", "High", "Ultra"]
var quality_name := quality_names[quality] if quality < quality_names.size() else "Unknown"
if quality >= RECOMMENDED_QUALITY:
_pass("LightmapGI.quality = " + quality_name + " (" + str(quality) + ") — High or Ultra recommended")
elif quality == 1:
_warn("LightmapGI.quality = " + quality_name + " (" + str(quality) + ") — Medium is acceptable, High recommended")
else:
_fail("LightmapGI.quality = " + quality_name + " (" + str(quality) + ") — set to at least High (2)")
func _run_bounce_check() -> void:
"""Validate light bounce count."""
print("")
print("--- Bounces ---")
if _lightmap_node == null:
return
var bounces: int = _lightmap_node.get("bounces") if "bounces" in _lightmap_node else -1
if bounces < 0:
_fail("LightmapGI.bounces not available")
return
if bounces >= RECOMMENDED_BOUNCES:
_pass("LightmapGI.bounces = " + str(bounces) + " (recommended: ≥ " + str(RECOMMENDED_BOUNCES) + ")")
elif bounces >= MIN_BOUNCES:
_warn("LightmapGI.bounces = " + str(bounces) + " — low, consider ≥ " + str(RECOMMENDED_BOUNCES))
else:
_fail("LightmapGI.bounces = " + str(bounces) + " — minimum is " + str(MIN_BOUNCES))
# Warn if too high (performance concern)
if bounces > MAX_BOUNCES - 2:
_warn("LightmapGI.bounces = " + str(bounces) + " — high bounce count may impact bake time")
func _run_texel_scale_check() -> void:
"""Validate texel scale setting."""
print("")
print("--- Texel Scale ---")
if _lightmap_node == null:
return
var texel_scale: float = _lightmap_node.get("texel_scale") if "texel_scale" in _lightmap_node else -1.0
if texel_scale < 0:
_fail("LightmapGI.texel_scale not available")
return
if abs(texel_scale - RECOMMENDED_TEXEL_SCALE) < 0.01:
_pass("LightmapGI.texel_scale = " + str(texel_scale) + " (exactly " + str(RECOMMENDED_TEXEL_SCALE) + ")")
elif texel_scale >= MIN_TEXEL_SCALE and texel_scale <= MAX_TEXEL_SCALE:
if texel_scale < 0.5:
_warn("LightmapGI.texel_scale = " + str(texel_scale) + " — below 0.5, bake resolution may be too low")
elif texel_scale > 2.0:
_warn("LightmapGI.texel_scale = " + str(texel_scale) + " — above 2.0, bake resolution may be unnecessarily high")
else:
_pass("LightmapGI.texel_scale = " + str(texel_scale) + " (within reasonable range [0.1, 10.0])")
else:
_fail("LightmapGI.texel_scale = " + str(texel_scale) + " — out of expected range [" + str(MIN_TEXEL_SCALE) + ", " + str(MAX_TEXEL_SCALE) + "]")
func _run_interior_check() -> void:
"""Validate interior flag."""
print("")
print("--- Interior ---")
if _lightmap_node == null:
return
var interior: bool = _lightmap_node.get("interior") if "interior" in _lightmap_node else false
if interior:
_pass("LightmapGI.interior = true (correct for indoor maps)")
else:
_warn("LightmapGI.interior = false — set to true for indoor scenes to avoid light leaking from outside")
func _run_denoiser_check() -> void:
"""Validate denoiser setting."""
print("")
print("--- Denoiser ---")
if _lightmap_node == null:
return
# Godot 4 renamed to use_denoiser
var use_denoiser: bool = false
if "use_denoiser" in _lightmap_node:
use_denoiser = _lightmap_node.get("use_denoiser")
elif _lightmap_node.has_method("is_using_denoiser"):
use_denoiser = _lightmap_node.is_using_denoiser()
if use_denoiser:
_pass("LightmapGI.use_denoiser = true (recommended for cleaner results)")
else:
_warn("LightmapGI.use_denoiser = false — enable denoiser for cleaner lightmap results")
func _run_max_texture_size_check() -> void:
"""Validate max texture size setting."""
print("")
print("--- Max Texture Size ---")
if _lightmap_node == null:
return
var max_tex: int = _lightmap_node.get("max_texture_size") if "max_texture_size" in _lightmap_node else -1
if max_tex < 0:
_warn("LightmapGI.max_texture_size not available (using default)")
return
if max_tex <= MAX_TEXTURE_SIZE:
_pass("LightmapGI.max_texture_size = " + str(max_tex) + " (≤ " + str(MAX_TEXTURE_SIZE) + ")")
else:
_fail("LightmapGI.max_texture_size = " + str(max_tex) + " — exceeds " + str(MAX_TEXTURE_SIZE) +
" (consider lowering for memory budget)")
func _run_energy_check() -> void:
"""Validate lightmap energy setting."""
print("")
print("--- Energy ---")
if _lightmap_node == null:
return
var energy: float = _lightmap_node.get("energy") if "energy" in _lightmap_node else 1.0
_pass("LightmapGI.energy = " + str(energy) + " (verify visually in editor)")
var indirect_energy: float = _lightmap_node.get("indirect_energy") if "indirect_energy" in _lightmap_node else 1.0
if abs(indirect_energy - 1.0) > 0.01:
_warn("LightmapGI.indirect_energy = " + str(indirect_energy) + " (default is 1.0)")
func _run_baked_data_check() -> void:
"""Check whether lightmap data has been baked."""
print("")
print("--- Baked Data ---")
if _lightmap_node == null:
return
var light_data = _lightmap_node.get("light_data") if "light_data" in _lightmap_node else null
if light_data != null:
_pass("LightmapGI has baked light data present")
# Check data size
if light_data.has_method("get_user_data"):
var user_data = light_data.get_user_data()
if user_data != null:
_warn("Lightmap data size: varies (baked data is present)")
else:
_warn("LightmapGI has NO baked data yet — needs baking in editor")
func _run_process_mode_check() -> void:
"""Validate the lightmap process mode."""
print("")
print("--- Process Mode ---")
if _lightmap_node == null:
return
# Godot 4 LightmapGI has a bake_mode or similar property for sub-surface scattering
if "bake_mode" in _lightmap_node:
var bake_mode: int = _lightmap_node.get("bake_mode")
match bake_mode:
0:
_pass("LightmapGI.bake_mode = Conservative (0) — safe default")
1:
_warn("LightmapGI.bake_mode = Aggressive (1) — may cause artifacts")
2:
_pass("LightmapGI.bake_mode = Always Lightmap (2) — good for fully baked scenes")
_:
_warn("LightmapGI.bake_mode = " + str(bake_mode) + " — unknown")
# Check for environment mode (if property exists)
if "environment_mode" in _lightmap_node:
var env_mode: int = _lightmap_node.get("environment_mode")
match env_mode:
0:
_pass("LightmapGI.environment_mode = Disabled — no environment lighting (indoor)")
1:
_pass("LightmapGI.environment_mode = Enabled — using environment for lighting")
2:
_warn("LightmapGI.environment_mode = Ambient Color — verify color is correct")
# ---------------------------------------------------------------------------
# UV2 geometry check
# ---------------------------------------------------------------------------
func _run_uv2_check() -> void:
"""Scan all MeshInstance3D nodes for UV2 channel presence."""
print("")
print("--- Geometry UV2 Check ---")
var mesh_nodes := _find_nodes_by_type(_scene_instance, "MeshInstance3D")
_total_meshes = mesh_nodes.size()
if _total_meshes == 0:
_warn("No MeshInstance3D nodes found — checking CSG nodes for UV2")
# Check CSG nodes instead
var csg_nodes := _find_nodes_by_type(_scene_instance, "CSGCombiner3D")
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGBox3D"))
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGSphere3D"))
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGCylinder3D"))
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGTorus3D"))
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGPolygon3D"))
csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGMesh3D"))
# CSG nodes generate UV2 during baking automatically
if csg_nodes.size() > 0:
_pass("Scene uses CSG primitives (" + str(csg_nodes.size()) + ") — UV2 is auto-generated during bake")
return
for mi in mesh_nodes:
var has_uv2 := false
if "mesh" in mi and mi.mesh != null:
has_uv2 = _check_mesh_uv2(mi.mesh)
var info := {
"name": mi.name,
"has_uv2": has_uv2,
"material_count": 0
}
_mesh_details.append(info)
if has_uv2:
_meshes_with_uv2 += 1
else:
_meshes_without_uv2.append(mi.name)
if _meshes_without_uv2.size() == 0:
_pass("All " + str(_total_meshes) + " MeshInstance3D nodes have UV2 channel")
else:
for mesh_name in _meshes_without_uv2:
_fail("MeshInstance3D '" + mesh_name + "' lacks UV2 channel — lightmapping will not work")
_warn(str(_meshes_without_uv2.size()) + "/" + str(_total_meshes) + " meshes missing UV2")
# Check for sub-surface scattering override on LightmapGI
if _lightmap_node != null and "subdiv" in _lightmap_node:
var subdiv: int = _lightmap_node.get("subdiv")
if subdiv > 0:
_warn("LightmapGI.subdiv = " + str(subdiv) + " — non-zero subdiv enables subsurface scattering")
func _check_mesh_uv2(mesh) -> bool:
"""Check if a mesh has UV2 channel data."""
if mesh == null:
return false
if not mesh.has_method("get_surface_count"):
return false
var surface_count := mesh.get_surface_count()
for s in range(surface_count):
var arrays := mesh.surface_get_arrays(s)
if arrays is Array and arrays.size() > 0:
var uv2 := arrays[Mesh.ARRAY_TEX_UV2] as PackedVector2Array
if uv2 != null and uv2.size() > 0:
return true
return false
# ---------------------------------------------------------------------------
# Reports
# ---------------------------------------------------------------------------
func _print_lightmap_report() -> void:
"""Print a detailed lightmap configuration report."""
print("")
print("--- LightmapGI Configuration Report ---")
if _lightmap_node == null:
print(" (no LightmapGI node to report)")
return
print(" Node path: ", _get_node_path(_lightmap_node))
var quality_names := ["Low", "Medium", "High", "Ultra"]
var q: int = _lightmap_node.get("quality") if "quality" in _lightmap_node else -1
if q >= 0 and q < quality_names.size():
print(" Quality: ", quality_names[q], " (", q, ")")
else:
print(" Quality: ", q)
print(" Bounces: ", _lightmap_node.get("bounces") if "bounces" in _lightmap_node else "N/A")
print(" Texel Scale: ", _lightmap_node.get("texel_scale") if "texel_scale" in _lightmap_node else "N/A")
print(" Interior: ", _lightmap_node.get("interior") if "interior" in _lightmap_node else "N/A")
print(" Denoiser: ", _lightmap_node.get("use_denoiser") if "use_denoiser" in _lightmap_node else "N/A")
print(" Max Tex Size: ", _lightmap_node.get("max_texture_size") if "max_texture_size" in _lightmap_node else "N/A")
print(" Energy: ", _lightmap_node.get("energy") if "energy" in _lightmap_node else "N/A")
print(" Baked Data: ", "Yes" if _lightmap_node.get("light_data") != null else "No")
print("")
func _print_uv2_report() -> void:
"""Print UV2 details for meshes."""
print("--- UV2 Report ---")
print(" Total MeshInstance3D: ", _total_meshes)
print(" With UV2: ", _meshes_with_uv2)
print(" Without UV2: ", _meshes_without_uv2.size())
if _meshes_without_uv2.size() > 0:
print(" Missing UV2 list:")
for name in _meshes_without_uv2:
print(" - ", name)
print("")
func _get_node_path(node: Node) -> String:
"""Get the scene-tree path of a node."""
var parts: Array[String] = []
var current := node
while current != null and current != _scene_instance:
parts.push_front(current.name)
current = current.get_parent()
return "/".join(parts)
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
func _print_summary() -> void:
"""Print final summary."""
print("==========================================")
print(" LIGHTMAP CONFIG SUMMARY")
print("==========================================")
print(" Scene: ", _scene_path)
print(" LightmapGI: ", "Found ✓" if _lightmap_node != null else "Missing ✗")
print(" Quality OK: ", _check_quality_status())
print(" Bounces OK: ", _check_bounce_status())
print(" Texel Scale OK: ", _check_texel_status())
print(" Interior OK: ", _check_interior_status())
print(" Denoiser OK: ", _check_denoiser_status())
print(" UV2 OK: ", _check_uv2_status())
print("------------------------------------------")
print(" Passed: ", _pass_count)
print(" Failed: ", _fail_count)
print(" Warnings: ", _warning_count)
print("------------------------------------------")
if _fail_count > 0:
print(" RESULT: FAILED ❌")
printerr("Lightmap config check FAILED — ", _fail_count, " check(s) failed.")
else:
print(" RESULT: PASSED ✅")
print("==========================================")
print("")
func _check_quality_status() -> String:
if _lightmap_node == null: return "N/A"
var q = _lightmap_node.get("quality") if "quality" in _lightmap_node else -1
return "Yes ✓" if q >= RECOMMENDED_QUALITY else ("No ✗" if q >= 0 else "N/A")
func _check_bounce_status() -> String:
if _lightmap_node == null: return "N/A"
var b = _lightmap_node.get("bounces") if "bounces" in _lightmap_node else -1
return "Yes ✓" if b >= RECOMMENDED_BOUNCES else ("No ✗" if b >= 0 else "N/A")
func _check_texel_status() -> String:
if _lightmap_node == null: return "N/A"
var t = _lightmap_node.get("texel_scale") if "texel_scale" in _lightmap_node else -1.0
if t < 0: return "N/A"
return "Yes ✓" if t >= 0.5 and t <= 2.0 else ""
func _check_interior_status() -> String:
if _lightmap_node == null: return "N/A"
var interior = _lightmap_node.get("interior") if "interior" in _lightmap_node else false
return "Yes ✓" if interior else "No ✗"
func _check_denoiser_status() -> String:
if _lightmap_node == null: return "N/A"
if "use_denoiser" in _lightmap_node:
return "Yes ✓" if _lightmap_node.get("use_denoiser") else "No ✗"
if _lightmap_node.has_method("is_using_denoiser"):
return "Yes ✓" if _lightmap_node.is_using_denoiser() else "No ✗"
return "N/A"
func _check_uv2_status() -> String:
if _total_meshes == 0: return "N/A (CSG)"
if _meshes_without_uv2.size() == 0: return "Yes ✓"
return str(_meshes_without_uv2.size()) + " missing"