@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"