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