@tool extends RefCounted # Validator: Light Count & Lightmap Status # # Checks: # - Dynamic (non-baked) OmniLight3D + SpotLight3D count ≤ 4 # - DirectionalLight3D is allowed (counts as 1 toward dynamic budget if not baked) # - WorldEnvironment has tonemap/ambient configured # - LightmapGI present and baked (light_data != null) # - ReflectionProbe count and settings # - Light bake modes should be 2 (Static) for all static geometry lights const MAX_DYNAMIC_LIGHTS := 4 # Omni + Spot + unbaked Directional func validate(scene_root: Node, scene_path: String) -> Dictionary: var errors: Array[String] = [] var warnings: Array[String] = [] var all_lights: Array[Node] = [] _collect_light_nodes(scene_root, all_lights) var dynamic_count := 0 var baked_count := 0 var directional_count := 0 var omni_count := 0 var spot_count := 0 for light in all_lights: var bake_mode := -1 if light.has_method("get_light_bake_mode"): bake_mode = light.get("light_bake_mode") as int var is_dynamic := bake_mode == 0 # BAKE_DISABLED if light is DirectionalLight3D: directional_count += 1 if is_dynamic: dynamic_count += 1 else: baked_count += 1 elif light is OmniLight3D: omni_count += 1 if is_dynamic: dynamic_count += 1 else: baked_count += 1 elif light is SpotLight3D: spot_count += 1 if is_dynamic: dynamic_count += 1 else: baked_count += 1 print(" Light breakdown:") print(" DirectionalLight3D: ", directional_count) print(" OmniLight3D: ", omni_count) print(" SpotLight3D: ", spot_count) print(" Dynamic (non-baked): ", dynamic_count) print(" Baked: ", baked_count) print("") # Check dynamic light budget if dynamic_count > MAX_DYNAMIC_LIGHTS: errors.append("Too many dynamic lights: %d (max %d) — set light_bake_mode = 2 (Static) on static lights to reduce" % [dynamic_count, MAX_DYNAMIC_LIGHTS]) else: print(" ✓ Dynamic light count: ", dynamic_count, " (limit: ", MAX_DYNAMIC_LIGHTS, ")") # Check LightmapGI var lightmaps: Array[LightmapGI] = [] _find_nodes_by_type(scene_root, "LightmapGI", lightmaps) if lightmaps.is_empty(): errors.append("No LightmapGI node found — maps should include a LightmapGI for baked global illumination") else: var lm := lightmaps[0] if lightmaps.size() > 1: warnings.append("Multiple LightmapGI nodes found (%d) — only one recommended" % lightmaps.size()) if lm.light_data != null: print(" ✓ LightmapGI baked (light_data present)") else: warnings.append("LightmapGI node found but NOT BAKED — light_data is null. Select LightmapGI → 'Bake Lightmap' button in editor") # Quality settings check print(" Quality: ", lm.quality, " (0=Low, 1=Med, 2=High, 3=Ultra)") print(" Bounces: ", lm.bounces) print(" Texel Scale: ", lm.texel_scale) print(" Max Texture Size: ", lm.max_texture_size) if lm.bounces < 2: warnings.append("LightmapGI bounces set to %d — recommend a minimum of 2 for accurate indirect lighting" % lm.bounces) if lm.max_texture_size < 512: warnings.append("LightmapGI max_texture_size is %d — may cause visible seams, recommend ≥ 512" % lm.max_texture_size) # Check WorldEnvironment var envs: Array[WorldEnvironment] = [] _find_nodes_by_type(scene_root, "WorldEnvironment", envs) if envs.is_empty(): errors.append("No WorldEnvironment node found — maps need a WorldEnvironment for ambient lighting and sky") else: var env := envs[0].environment if env == null: warnings.append("WorldEnvironment node has no Environment resource assigned") else: print(" ✓ WorldEnvironment configured") # ReflectionProbe check var probes: Array[ReflectionProbe] = [] _find_nodes_by_type(scene_root, "ReflectionProbe", probes) if probes.is_empty(): warnings.append("No ReflectionProbe found — interior maps benefit from at least one ReflectionProbe for specular reflections") else: print(" ReflectionProbes: ", probes.size()) for p in probes: if p.max_distance < 5.0: warnings.append("ReflectionProbe \"%s\" max_distance is %.1f — may be too short for interior spaces" % [p.name, p.max_distance]) # Warn on lights with shadows enabled that aren't baked for light in all_lights: if light.has_method("is_shadow_enabled") and light.get("shadow_enabled") == true: var bake_mode := light.get("light_bake_mode") as int if bake_mode == 0: var light_name := light.name var light_type := light.get_class() warnings.append("Dynamic light has shadows enabled: \"%s\" (%s) — shadows on dynamic lights cost performance" % [light_name, light_type]) return { "pass": errors.is_empty(), "errors": errors, "warnings": warnings, } func _collect_light_nodes(node: Node, result: Array) -> void: if node is DirectionalLight3D or node is OmniLight3D or node is SpotLight3D: result.append(node) for child in node.get_children(): _collect_light_nodes(child, result) func _find_nodes_by_type(root: Node, expected_type: String, output) -> void: if root.get_class() == expected_type: output.append(root) for child in root.get_children(): _find_nodes_by_type(child, expected_type, output)