Files
tactical-shooter/client/scripts/validate_lights.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

444 lines
14 KiB
GDScript

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