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

582 lines
18 KiB
GDScript

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