ffa72a8f24
- 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.
444 lines
14 KiB
GDScript
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("")
|