Files
tactical-shooter/client/scripts/bake_lightmaps.gd
T
shawn ffa72a8f24 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.
2026-07-01 18:35:52 -04:00

611 lines
20 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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"