@tool extends SceneTree # ============================================================================= # validate_textures.gd — Texture Size / Mipmaps / UV2 Checker # ============================================================================= # Scans all material and texture resources used in a scene and validates: # - Texture dimensions ≤ 1024 (1K) # - Mipmaps are enabled on all textures # - UV2 channel is present on meshes (for lightmapping) # - Texture format is appropriate # # Usage: # godot --headless --script client/scripts/validate_textures.gd -- res://path/to/map.tscn # # Exit codes: # 0 — All texture checks passed # 1 — One or more texture checks failed # 2 — Scene file not found or cannot be loaded # ============================================================================= const MAX_TEXTURE_SIZE := 1024 const MAX_TEXTURE_SIZE_LABEL := "1K (1024x1024)" var _pass_count := 0 var _fail_count := 0 var _warning_count := 0 var _scene_path := "" var _scene_instance: Node = null # Collected data var _textures_checked: Dictionary = {} # resource_path -> info dict var _overbudget_textures: Array[Dictionary] = [] var _no_mipmap_textures: Array[Dictionary] = [] var _meshes_without_uv2: Array[String] = [] var _meshes_with_uv2: Array[String] = [] var _mesh_count := 0 var _total_textures := 0 var _total_materials := 0 var _texture_memory_estimate := 0 # approximate total texture memory in bytes # Unique set to avoid re-checking the same resource var _checked_resources: Dictionary = {} func _init() -> void: _parse_arguments() if _scene_path.is_empty(): printerr("ERROR: No scene path provided. Usage: godot --headless --script validate_textures.gd -- res://path/to/map.tscn") quit(2) return print("") print("==========================================") print(" TEXTURE VALIDATOR") print(" Scene: ", _scene_path) print("==========================================") print("") print(" Texture budget:") print(" Max dimension: ", MAX_TEXTURE_SIZE_LABEL) print(" Mipmaps: Required") print(" UV2 channel: Required for lightmapping") 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) # Analyze _analyze_node(_scene_instance) # Run checks _run_texture_checks() _run_mipmap_checks() _run_uv2_checks() _print_texture_report() # Summary _print_summary() if _fail_count > 0: quit(1) else: quit(0) # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- func _parse_arguments() -> void: """Extract scene path from user args (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) # --------------------------------------------------------------------------- # Analysis engine # --------------------------------------------------------------------------- func _analyze_node(node: Node) -> void: """Recursively walk scene, check materials on meshes.""" var class_name := node.get_class() # MeshInstance3D — inspect its material overrides if class_name == "MeshInstance3D" or node.is_class("MeshInstance3D"): _mesh_count += 1 _analyze_mesh_instance(node) # CSG nodes can also have materials / meshes if class_name in ["CSGCombiner3D", "CSGSphere3D", "CSGBox3D", "CSGCylinder3D", "CSGTorus3D", "CSGPolygon3D", "CSGMesh3D"]: _analyze_csg_node(node) # GeometryInstance3D base class if node.is_class("GeometryInstance3D"): var mat_override := node.get("material_override") if "material_override" in node else null if mat_override != null: _analyze_material(mat_override) for child in node.get_children(): _analyze_node(child) func _analyze_mesh_instance(mi: Node) -> void: """Inspect a MeshInstance3D node for materials and UV2.""" # Check mesh if "mesh" in mi and mi.mesh != null: var mesh = mi.mesh _analyze_mesh(mesh, mi.name) # Surface material overrides if "material_override" in mi and mi.material_override != null: _analyze_material(mi.material_override) # Per-surface materials if mi.has_method("get_surface_override_material"): var surface_count := 0 if "mesh" in mi and mi.mesh != null and mi.mesh.has_method("get_surface_count"): surface_count = mi.mesh.get_surface_count() for s in range(surface_count): var mat := mi.get_surface_override_material(s) if mat != null: _analyze_material(mat) func _analyze_csg_node(node: Node) -> void: """Analyze materials on CSG nodes.""" # CSG nodes have operation and material properties if "material" in node and node.material != null: _analyze_material(node.material) # Check CSGMesh3D which references a mesh if node.get_class() == "CSGMesh3D" and "mesh" in node and node.mesh != null: _analyze_mesh(node.mesh, node.name) func _analyze_mesh(mesh, node_name: String) -> void: """Analyze a mesh resource for UV2 and material slots.""" if mesh == null: return var mesh_id := mesh.resource_path if "resource_path" in mesh and mesh.resource_path != "" else str(mesh.get_instance_id()) if _checked_resources.has(mesh_id): return _checked_resources[mesh_id] = true # Check for UV2 channel var has_uv2 := false if mesh.has_method("get_surface_count"): var surface_count := mesh.get_surface_count() for s in range(surface_count): var arrays := mesh.surface_get_arrays(s) if arrays is Array and arrays.size() > 0: var uv2 := arrays[Mesh.ARRAY_TEX_UV2] as PackedVector2Array if uv2 != null and uv2.size() > 0: has_uv2 = true if has_uv2: _meshes_with_uv2.append(node_name) else: _meshes_without_uv2.append(node_name) # Check materials on the mesh if mesh.has_method("surface_get_material"): var surface_count := mesh.get_surface_count() for s in range(surface_count): var mat := mesh.surface_get_material(s) if mat != null: _analyze_material(mat) func _analyze_material(mat) -> void: """Recursively analyze a material and its textures.""" if mat == null: return var mat_id := mat.resource_path if "resource_path" in mat and mat.resource_path != "" else str(mat.get_instance_id()) if _checked_resources.has(mat_id): return _checked_resources[mat_id] = true _total_materials += 1 var mat_class := mat.get_class() # StandardMaterial3D — check texture properties if mat_class == "StandardMaterial3D" or mat_class == "ORMMaterial3D": var tex_properties := [ "albedo_texture", "metallic_texture", "roughness_texture", "normal_texture", "ambient_occlusion_texture", "emission_texture", "clearcoat_texture", "rim_texture", "transparency_texture", "heightmap_texture", "refraction_texture", "detail_albedo_texture", "detail_normal_texture", "detail_mask_texture", "subsurface_scattering_texture", "backlight_texture" ] for prop in tex_properties: if prop in mat: var tex = mat.get(prop) if tex != null: _analyze_texture(tex) # ShaderMaterial — inspect shader parameters for textures elif mat_class == "ShaderMaterial": if mat.has_method("get_shader_parameter_list"): var param_list := mat.get_shader_parameter_list() for param in param_list: var param_name := param.name if "name" in param else str(param) var val = mat.get_shader_parameter(param_name) if val != null and (val is Texture2D or val is Texture3D or val is TextureLayered): _analyze_texture(val) # BaseMaterial3D (Godot 4 fallback) elif mat.is_class("BaseMaterial3D"): if "albedo_texture" in mat and mat.albedo_texture != null: _analyze_texture(mat.albedo_texture) if "metallic_texture" in mat and mat.metallic_texture != null: _analyze_texture(mat.metallic_texture) if "roughness_texture" in mat and mat.roughness_texture != null: _analyze_texture(mat.roughness_texture) if "normal_texture" in mat and mat.normal_texture != null: _analyze_texture(mat.normal_texture) func _analyze_texture(tex) -> void: """Analyze a single texture resource for size, mipmaps, and format.""" if tex == null: return # Use resource path as unique key var tex_path := "" if "resource_path" in tex and tex.resource_path != "": tex_path = tex.resource_path else: tex_path = str(tex.get_instance_id()) if _textures_checked.has(tex_path): return var tex_class := tex.get_class() var width := 0 var height := 0 var has_mipmaps := false var format := -1 var format_name := "Unknown" # Texture2D if tex is Texture2D: width = tex.get_width() height = tex.get_height() has_mipmaps = tex.has_mipmaps() if tex.has_method("has_mipmaps") else false if tex.has_method("get_format"): format = tex.get_format() format_name = _get_format_name(format) _total_textures += 1 # Texture3D elif tex is Texture3D: width = tex.get_width() height = tex.get_height() if tex.has_method("has_mipmaps"): has_mipmaps = tex.has_mipmaps() _total_textures += 1 # TextureLayered elif tex is TextureLayered: if tex.has_method("get_width"): width = tex.get_width() if tex.has_method("get_height"): height = tex.get_height() _total_textures += 1 var info := { "path": tex_path, "width": width, "height": height, "has_mipmaps": has_mipmaps, "format": format_name, "class": tex_class } _textures_checked[tex_path] = info # Estimate memory var bpp := _bits_per_pixel(format) _texture_memory_estimate += (width * height * bpp) / 8 # Check over-budget var max_dim := maxi(width, height) if max_dim > MAX_TEXTURE_SIZE: _overbudget_textures.append(info) # Check mipmaps if not has_mipmaps: _no_mipmap_textures.append(info) # --------------------------------------------------------------------------- # Texture checks # --------------------------------------------------------------------------- func _run_texture_checks() -> void: """Validate texture size budget.""" print("--- Texture Size Checks ---") if _total_textures == 0: _warn("No textures found in scene (may be expected for procedural materials)") return _pass("Total textures found: " + str(_total_textures)) if _overbudget_textures.size() == 0: _pass("All textures within " + MAX_TEXTURE_SIZE_LABEL + " limit") else: for tex_info in _overbudget_textures: var dim := str(tex_info.width) + "x" + str(tex_info.height) _fail("Texture over budget: " + _truncate_path(tex_info.path) + " (" + dim + ", max: " + MAX_TEXTURE_SIZE_LABEL + ")") func _run_mipmap_checks() -> void: """Validate that all textures have mipmaps enabled.""" print("") print("--- Mipmap Checks ---") if _no_mipmap_textures.size() == 0: _pass("All textures have mipmaps enabled") else: for tex_info in _no_mipmap_textures: var dim := str(tex_info.width) + "x" + str(tex_info.height) _fail("Texture missing mipmaps: " + _truncate_path(tex_info.path) + " (" + dim + ", class: " + tex_info.class + ")") func _run_uv2_checks() -> void: """Validate UV2 channel presence on meshes.""" print("") print("--- UV2 Channel Checks ---") if _mesh_count == 0: _warn("No MeshInstance3D nodes found") return _pass("Meshes found: " + str(_mesh_count)) if _meshes_without_uv2.size() == 0: _pass("All " + str(_mesh_count) + " meshes have UV2 channel (good for lightmapping)") else: for mesh_name in _meshes_without_uv2: _fail("Mesh missing UV2 channel: " + mesh_name + " — lightmapping will not work correctly") # --------------------------------------------------------------------------- # Report # --------------------------------------------------------------------------- func _print_texture_report() -> void: """Print detailed texture report.""" print("") print("--- Texture Report ---") print(" Total materials: ", _total_materials) print(" Total textures: ", _total_textures) print(" Over-budget textures: ", _overbudget_textures.size()) print(" Missing mipmaps: ", _no_mipmap_textures.size()) print(" Total meshes: ", _mesh_count) print(" Meshes with UV2: ", _meshes_with_uv2.size()) print(" Meshes without UV2: ", _meshes_without_uv2.size()) print(" Est. texture memory: ", _format_bytes(_texture_memory_estimate)) print("") # Print texture format distribution var format_counts: Dictionary = {} for tex_path in _textures_checked.keys(): var info := _textures_checked[tex_path] as Dictionary var fmt := info.format if not format_counts.has(fmt): format_counts[fmt] = 0 format_counts[fmt] += 1 if format_counts.size() > 0: print(" Texture format distribution:") for fmt in format_counts.keys(): print(" " + fmt + ": " + str(format_counts[fmt])) print("") func _truncate_path(path: String) -> String: """Truncate a resource path to fit in a single line.""" if path.length() > 60: return "..." + path.substr(path.length() - 57) return path func _get_format_name(format_id: int) -> String: """Map Godot image format enum to a human-readable name.""" match format_id: 0: return "L8" 1: return "LA8" 2: return "R8" 3: return "RG8" 4: return "RGB8" 5: return "RGBA8" 6: return "RGBA4444" 7: return "RGBA5551" 8: return "RF" 9: return "RGF" 10: return "RGBF" 11: return "RGBAF" 12: return "RH" 13: return "RGH" 14: return "RGBH" 15: return "RGBAH" 16: return "RGBE9995" 17: return "DXT1 (BC1)" 18: return "DXT3 (BC2)" 19: return "DXT5 (BC3)" 20: return "RGTC (BC4)" 21: return "RGTC (BC5)" 22: return "BPTC (BC6H)" 23: return "BPTC (BC7)" 24: return "ETC" 25: return "ETC2 R11" 26: return "ETC2 RG11" 27: return "ETC2 RGB8" 28: return "ETC2 RGBA8" 29: return "PVRTC2" 30: return "PVRTC4" 31: return "ASTC 4x4" 32: return "ASTC 8x8" 33: return "Max" return "Unknown(" + str(format_id) + ")" func _bits_per_pixel(format_id: int) -> int: """Estimate bits per pixel for a given format.""" match format_id: 0, 2: return 8 # L8, R8 1, 3: return 16 # LA8, RG8 4: return 24 # RGB8 5, 6, 7: return 32 # RGBA8, RGBA4444, RGBA5551 8: return 32 # RF 9: return 64 # RGF 10: return 96 # RGBF 11: return 128 # RGBAF 12: return 16 # RH (half) 13: return 32 # RGH 14: return 48 # RGBH 15: return 64 # RGBAH 16: return 32 # RGBE9995 17, 18, 19: return 4 # DXT/BC compressed — 4bpp 20, 21: return 4 # BC4/BC5 — 4bpp (BC5 is 8bpp but rare) 22: return 8 # BC6H — 8bpp 23: return 8 # BC7 — 8bpp 24, 25, 26, 27, 28: return 4 # ETC/ETC2 — 4bpp 29, 30: return 2 # PVRTC — 2bpp 31: return 8 # ASTC 4x4 — 8bpp 32: return 2 # ASTC 8x8 — 2bpp return 32 func _format_bytes(bytes: int) -> String: """Format bytes into human-readable string.""" if bytes < 1024: return str(bytes) + " B" elif bytes < 1024 * 1024: return str(stepify(float(bytes) / 1024.0, 0.1)) + " KB" else: return str(stepify(float(bytes) / (1024.0 * 1024.0), 0.1)) + " MB" func maxi(a: int, b: int) -> int: return a if a > b else b func mini(a: int, b: int) -> int: return a if a < b else b # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- func _print_summary() -> void: """Print final summary.""" print("==========================================") print(" TEXTURE VALIDATION SUMMARY") print("==========================================") print(" Scene: ", _scene_path) print(" Textures checked: ", _total_textures) print(" Materials checked: ", _total_materials) print(" Over-budget textures: ", _overbudget_textures.size()) print(" Missing mipmaps: ", _no_mipmap_textures.size()) print(" Meshes w/o UV2: ", _meshes_without_uv2.size()) 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, " texture check(s) failed.") else: print(" RESULT: PASSED ✅") print("==========================================") print("")