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.
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
@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("")
|
||||
Reference in New Issue
Block a user