Phase 7: netfox + godot-jolt stack upgrade
Stack installed: - netfox v1.35.3 (core + extras + noray + internals) - godot-jolt v0.16.0-stable Architecture: - Server: ENet transport (works headless, no netfox deps) - Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator) New/modified: - docs/migration-netfox-plan.md — migration architecture - scripts/network/network_manager.gd — netfox-aware ENet fallback - scripts/network/player.gd — clean base player - client/characters/player_netfox.gd — rollback player w/ WeaponManager - client/characters/input/player_net_input.gd — BaseNetInput subclass - client/characters/character/fps_character_controller.gd — netfox input feed - client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager - client/scripts/round_replicator.gd — client-side round state bridge - server/scripts/round_manager.gd — improved state machine - server/scripts/plugin_api/plugin_manager.gd — refined plugin system - config: enemy_tag, ally_tag for meatball targeting Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# Map Validator Scripts
|
||||
|
||||
Validator scripts for Tactical Shooter map scenes. Run against any `.tscn` file to
|
||||
check structural integrity, performance budget, and lighting correctness before
|
||||
packaging.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# From the project root or anywhere with --path pointing at the Godot project
|
||||
godot --path client --script tools/validate_map.gd -- res://maps/mymap.tscn
|
||||
```
|
||||
|
||||
The `--` separates Godot engine args from user args. The scene path must be a
|
||||
`res://`-prefixed Godot resource path.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning | Description |
|
||||
|------|----------------|-------------------------------------------|
|
||||
| 0 | PASS | All checks passed, no warnings |
|
||||
| 1 | WARNINGS | Passed with non-blocking suggestions |
|
||||
| 2 | ERRORS | Failed — must-fix items found |
|
||||
|
||||
Use exit codes in CI/CD pipelines to gate map merges:
|
||||
```bash
|
||||
godot --path client --script tools/validate_map.gd -- res://maps/mymap.tscn
|
||||
if [ $? -eq 2 ]; then
|
||||
echo "Map validation FAILED — fix errors before submitting"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Validator Modules
|
||||
|
||||
### 1. Scene Structure (`validate_scene.gd`)
|
||||
- Scene root is a `Node3D`
|
||||
- Required node types present: `WorldEnvironment`, `LightmapGI`
|
||||
- Required Godot groups assigned to one node each: `ct_spawn`, `t_spawn`, `buy_zone`, `bomb_site`, `cubemap_origin`
|
||||
- Node naming follows PascalCase convention
|
||||
|
||||
### 2. Polygon Count (`validate_polycount.gd`)
|
||||
- Total mesh triangle count across all `MeshInstance3D` nodes ≤ **50,000**
|
||||
- Per-mesh breakdown printed for debugging
|
||||
- Warns on individual meshes exceeding 5K triangles
|
||||
- Warns if no `MeshInstance3D` nodes found (empty map)
|
||||
|
||||
### 3. Texture Sizes (`validate_textures.gd`)
|
||||
- All material textures (albedo, normal, roughness, metallic, ORM, emission, AO) ≤ **1024×1024**
|
||||
- Checks `StandardMaterial3D`, `ORMMaterial3D`, and `ShaderMaterial` (Texture2D params)
|
||||
- Warns on textures > 512×512 (suggested for secondary surfaces)
|
||||
- LightmapGI baked textures checked separately
|
||||
|
||||
### 4. Lights & Lightmap (`validate_lights.gd`)
|
||||
- Dynamic (non-baked) `OmniLight3D` + `SpotLight3D` ≤ **4**
|
||||
- `DirectionalLight3D` counts toward dynamic budget if light_bake_mode ≠ Static
|
||||
- `LightmapGI` node present and baked (light_data ≠ null)
|
||||
- Lightmap quality, bounces, texel scale, and max texture size checked
|
||||
- `WorldEnvironment` configured with Environment resource
|
||||
- `ReflectionProbe` presence and settings
|
||||
- Warns on dynamic lights with shadows enabled (performance cost)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
client/tools/
|
||||
├── validate_map.gd # Main entry point: parses args, loads scene, runs modules
|
||||
└── validate_map/
|
||||
├── validate_scene.gd # Scene structure validator
|
||||
├── validate_polycount.gd # Polygon count validator
|
||||
├── validate_textures.gd # Texture size validator
|
||||
└── validate_lights.gd # Light/lightmap validator
|
||||
```
|
||||
|
||||
All modules are `@tool` `RefCounted` scripts exporting a single `validate(scene_root: Node, scene_path: String) -> Dictionary` function.
|
||||
|
||||
## Adding a New Validator
|
||||
|
||||
1. Create `tools/validate_map/validate_<name>.gd` extending `RefCounted`
|
||||
2. Implement `func validate(scene_root: Node, scene_path: String) -> Dictionary`
|
||||
3. Return `{"pass": bool, "errors": [String], "warnings": [String]}`
|
||||
4. Add a `_run_module("<name>", instance, scene_path)` call in `validate_map.gd:_ready()`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Godot 4.x
|
||||
- The map scene must be self-contained (all resources accessible via `res://` paths)
|
||||
- Run from a terminal (not embedded in a running game)
|
||||
@@ -0,0 +1,7 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
func _ready() -> void:
|
||||
print("HELLO FROM VALIDATOR")
|
||||
print("Args: ", OS.get_cmdline_user_args())
|
||||
get_tree().quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c6gr7qvmxxb2q
|
||||
@@ -0,0 +1,10 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tools/validate_map.gd" id="1"]
|
||||
|
||||
[node name="TestRoot" type="Node3D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
|
||||
[node name="LightmapGI" type="LightmapGI" parent="."]
|
||||
@@ -0,0 +1,151 @@
|
||||
@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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6v02wqbuawg8
|
||||
@@ -0,0 +1,114 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# Validator: Polygon Count
|
||||
#
|
||||
# Checks:
|
||||
# - Total mesh triangle count across all MeshInstance3D nodes ≤ 50K
|
||||
# - Per-mesh breakdown printed for debugging
|
||||
# - Warns on individual meshes > 5K triangles
|
||||
|
||||
const MAX_TOTAL_TRIANGLES := 50000
|
||||
const PER_MESH_WARN_THRESHOLD := 5000
|
||||
|
||||
|
||||
func validate(scene_root: Node, scene_path: String) -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
var warnings: Array[String] = []
|
||||
|
||||
var mesh_instances: Array[MeshInstance3D] = []
|
||||
_collect_mesh_instances(scene_root, mesh_instances)
|
||||
|
||||
var total_triangles := 0
|
||||
var mesh_breakdown: Array[Dictionary] = []
|
||||
|
||||
for mi in mesh_instances:
|
||||
var mesh: Mesh = mi.mesh
|
||||
if mesh == null:
|
||||
continue
|
||||
|
||||
var tri_count := _count_triangles(mesh)
|
||||
total_triangles += tri_count
|
||||
|
||||
var entry := {
|
||||
"node": mi.name,
|
||||
"path": _node_path_to_string(scene_root, mi),
|
||||
"triangles": tri_count,
|
||||
"mesh_type": mesh.get_class(),
|
||||
}
|
||||
mesh_breakdown.append(entry)
|
||||
|
||||
if tri_count > PER_MESH_WARN_THRESHOLD:
|
||||
warnings.append("High-poly mesh: \"%s\" (%s) — %d triangles (limit: %d per mesh recommend)" % [
|
||||
mi.name, entry["path"], tri_count, PER_MESH_WARN_THRESHOLD])
|
||||
|
||||
# Print breakdown
|
||||
print(" Meshes scanned: ", mesh_instances.size())
|
||||
print(" Total triangles: ", total_triangles)
|
||||
print(" Budget: ", MAX_TOTAL_TRIANGLES)
|
||||
print("")
|
||||
|
||||
# Sort and show top contributors
|
||||
mesh_breakdown.sort_custom(func(a, b): return a["triangles"] > b["triangles"])
|
||||
var top := mesh_breakdown.slice(0, min(10, mesh_breakdown.size()))
|
||||
if not top.is_empty():
|
||||
print(" Top meshes by triangle count:")
|
||||
for m in top:
|
||||
print(" %6d %s (%s)" % [m["triangles"], m["node"], m["mesh_type"]])
|
||||
if mesh_breakdown.size() > 10:
|
||||
print(" ... and ", mesh_breakdown.size() - 10, " more")
|
||||
|
||||
if total_triangles > MAX_TOTAL_TRIANGLES:
|
||||
errors.append("Map exceeds %d triangle budget: %d total (%.1f%% over budget)" % [
|
||||
MAX_TOTAL_TRIANGLES, total_triangles,
|
||||
float(total_triangles - MAX_TOTAL_TRIANGLES) / MAX_TOTAL_TRIANGLES * 100.0])
|
||||
else:
|
||||
var pct := float(total_triangles) / MAX_TOTAL_TRIANGLES * 100.0
|
||||
print(" ✓ Within budget (%.1f%% of %d)" % [pct, MAX_TOTAL_TRIANGLES])
|
||||
|
||||
# Warn if mesh_instances is empty (suspicious map with no geometry)
|
||||
if mesh_instances.is_empty():
|
||||
warnings.append("No MeshInstance3D nodes found in scene — map may be empty or using unsupported geometry type")
|
||||
|
||||
return {
|
||||
"pass": errors.is_empty(),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
func _collect_mesh_instances(node: Node, result: Array) -> void:
|
||||
if node is MeshInstance3D:
|
||||
result.append(node)
|
||||
for child in node.get_children():
|
||||
_collect_mesh_instances(child, result)
|
||||
|
||||
|
||||
func _count_triangles(mesh: Mesh) -> int:
|
||||
var total := 0
|
||||
for i in mesh.get_surface_count():
|
||||
var arrays := mesh.surface_get_arrays(i)
|
||||
if arrays.is_empty():
|
||||
continue
|
||||
var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array
|
||||
if indices and indices.size() > 0:
|
||||
total += indices.size() / 3
|
||||
else:
|
||||
# Non-indexed mesh — count vertices / 3 as approximation
|
||||
var verts := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
|
||||
if verts:
|
||||
total += verts.size() / 3
|
||||
return total
|
||||
|
||||
|
||||
func _node_path_to_string(root: Node, node: Node) -> String:
|
||||
var full_path: String = node.get_path_to(root)
|
||||
# full_path is a NodePath string like "../NodeA/NodeB" — split and drop the root
|
||||
var parts := full_path.split("/")
|
||||
# Remove the first segment (the "../" back-reference) and last if empty
|
||||
var effective: Array[String] = []
|
||||
for p in parts:
|
||||
if p.is_empty() or p == "." or p.begins_with(".."):
|
||||
continue
|
||||
effective.append(p)
|
||||
return "/".join(effective)
|
||||
@@ -0,0 +1 @@
|
||||
uid://hefbh8r7ujn
|
||||
@@ -0,0 +1,122 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# Validator: Scene Structure
|
||||
#
|
||||
# Checks:
|
||||
# - Scene root is a Node3D (or subtype)
|
||||
# - Required nodes present: WorldEnvironment, LightmapGI
|
||||
# - Required groups present: ct_spawn, t_spawn, buy_zone, bomb_site, cubemap_origin
|
||||
# - No duplicate groups on different nodes
|
||||
# - Node naming follows convention (PascalCase, no spaces)
|
||||
|
||||
const REQUIRED_GROUPS := [
|
||||
"ct_spawn",
|
||||
"t_spawn",
|
||||
"buy_zone",
|
||||
"bomb_site",
|
||||
"cubemap_origin",
|
||||
]
|
||||
|
||||
const DYNAMIC_BAKE_MODE := 0 # light_bake_mode = 0 means dynamic
|
||||
|
||||
|
||||
func validate(scene_root: Node, scene_path: String) -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
var warnings: Array[String] = []
|
||||
|
||||
# 1. Root node type
|
||||
if not scene_root is Node3D:
|
||||
errors.append("Scene root must inherit Node3D, got: " + scene_root.get_class())
|
||||
else:
|
||||
print(" ✓ Root node: ", scene_root.name, " (", scene_root.get_class(), ")")
|
||||
|
||||
# 2. Required nodes
|
||||
_check_required_node(scene_root, "WorldEnvironment", errors, "WorldEnvironment")
|
||||
_check_required_node(scene_root, "LightmapGI", errors, "LightmapGI")
|
||||
|
||||
# 3. Node naming convention
|
||||
_check_naming(scene_root, warnings, [])
|
||||
|
||||
# 4. Required groups
|
||||
var found_groups: Dictionary = {} # group → [node names]
|
||||
_collect_groups(scene_root, found_groups)
|
||||
|
||||
for group in REQUIRED_GROUPS:
|
||||
if found_groups.has(group):
|
||||
var nodes := found_groups[group] as Array
|
||||
print(" ✓ Group \"", group, "\" → ", nodes[0], " (", nodes.size(), " node(s))")
|
||||
if nodes.size() > 1:
|
||||
warnings.append("Group \"%s\" assigned to %d nodes — only one expected" % [group, nodes.size()])
|
||||
else:
|
||||
errors.append("Missing required group: \"" + group + "\" — no node in the map has this group")
|
||||
|
||||
# 5. Check for orphan / stray groups (as hint)
|
||||
var all_groups := _get_all_group_names(found_groups)
|
||||
for g in all_groups:
|
||||
if g not in REQUIRED_GROUPS and g.begins_with("res://"):
|
||||
warnings.append("Unexpected group with resource path pattern: \"" + g + "\"")
|
||||
|
||||
return {
|
||||
"pass": errors.is_empty(),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
func _check_required_node(root: Node, node_name: String, errors: Array, type_hint: String) -> void:
|
||||
var found: Array[Node] = []
|
||||
_find_nodes_by_type(root, type_hint, found)
|
||||
|
||||
if found.is_empty():
|
||||
errors.append("Missing required %s — map needs a %s node named \"%s\"" % [type_hint, type_hint, node_name])
|
||||
return
|
||||
|
||||
# Check naming
|
||||
if found[0].name != node_name:
|
||||
errors.append("%s node found but not named \"%s\" — got \"%s\", rename to match convention" % [type_hint, node_name, found[0].name])
|
||||
else:
|
||||
print(" ✓ ", node_name, " (", type_hint, ")")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
func _collect_groups(root: Node, groups: Dictionary) -> void:
|
||||
for g in root.get_groups():
|
||||
# Godot auto-adds "__builtins" and similar — skip those
|
||||
if g.begins_with("__"):
|
||||
continue
|
||||
if not groups.has(g):
|
||||
groups[g] = []
|
||||
(groups[g] as Array).append(root.name)
|
||||
for child in root.get_children():
|
||||
_collect_groups(child, groups)
|
||||
|
||||
|
||||
func _get_all_group_names(groups: Dictionary) -> Array:
|
||||
var names: Array = []
|
||||
for key in groups:
|
||||
names.append(key)
|
||||
return names
|
||||
|
||||
|
||||
func _check_naming(node: Node, warnings: Array, path: Array) -> void:
|
||||
var path_str: String = "/".join(path + [node.name]) if not path.is_empty() else node.name
|
||||
|
||||
# Check for spaces in name
|
||||
if " " in node.name:
|
||||
warnings.append("Node name contains spaces: \"" + path_str + "\" — use PascalCase instead")
|
||||
|
||||
# Check for lowercase start on non-leaf nodes (leaf instances may have instance names)
|
||||
if node.get_child_count() > 0 and node.name.length() > 0:
|
||||
var first: String = String(node.name)[0]
|
||||
if first == first.to_lower() and first != first.to_upper():
|
||||
warnings.append("Node name should start uppercase (PascalCase): \"" + path_str + "\"")
|
||||
|
||||
for child in node.get_children():
|
||||
_check_naming(child, warnings, path + [node.name])
|
||||
@@ -0,0 +1 @@
|
||||
uid://dt8vumbcs0bs
|
||||
@@ -0,0 +1,157 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# Validator: Texture Size
|
||||
#
|
||||
# Checks:
|
||||
# - All textures referenced by materials in the scene are ≤ 1024×1024
|
||||
# - Checks StandardMaterial3D, ORMMaterial3D, and ShaderMaterial textures
|
||||
# - Warns on textures > 512×512 (suggested for secondary surfaces)
|
||||
# - Skips LightmapGI textures (baked lightmaps are handled separately)
|
||||
|
||||
const MAX_TEXTURE_SIZE := 1024
|
||||
const WARN_TEXTURE_SIZE := 512
|
||||
|
||||
|
||||
func validate(scene_root: Node, scene_path: String) -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
var warnings: Array[String] = []
|
||||
|
||||
var inspected := 0
|
||||
var oversized: Array[String] = []
|
||||
|
||||
# Collect all MeshInstance3D nodes and their materials
|
||||
var mesh_instances: Array[MeshInstance3D] = []
|
||||
_collect_mesh_instances(scene_root, mesh_instances)
|
||||
|
||||
# Track visited textures to avoid duplicate reports
|
||||
var visited_textures: Dictionary = {}
|
||||
|
||||
for mi in mesh_instances:
|
||||
var mesh: Mesh = mi.mesh
|
||||
if mesh == null:
|
||||
continue
|
||||
|
||||
for surf_idx in mesh.get_surface_count():
|
||||
var mat := mesh.surface_get_material(surf_idx)
|
||||
if mat == null:
|
||||
# Check override material on MeshInstance3D
|
||||
mat = mi.get_surface_override_material(surf_idx)
|
||||
if mat == null:
|
||||
# Check material_override on MeshInstance3D
|
||||
mat = mi.material_override
|
||||
if mat == null:
|
||||
continue
|
||||
|
||||
_check_material_textures(mat, visited_textures, errors, warnings, inspected)
|
||||
|
||||
# Check material_override if no per-surface materials
|
||||
if mesh_instances.is_empty():
|
||||
_find_materials_on_node(scene_root, visited_textures, errors, warnings, inspected)
|
||||
|
||||
# Check LightmapGI light_data texture sizes separately
|
||||
var lightmaps: Array[LightmapGI] = []
|
||||
_find_nodes_by_type(scene_root, "LightmapGI", lightmaps)
|
||||
for lm in lightmaps:
|
||||
if lm.light_data != null:
|
||||
var lightmap_tex = lm.light_data.get_texture()
|
||||
if lightmap_tex != null and not visited_textures.has(lightmap_tex.resource_path):
|
||||
visited_textures[lightmap_tex.resource_path] = true
|
||||
var size := _get_texture_size(lightmap_tex)
|
||||
if size > 0:
|
||||
print(" Lightmap texture: ", lightmap_tex.resource_path, " — ", size, "×")
|
||||
if size > MAX_TEXTURE_SIZE:
|
||||
warnings.append("Lightmap texture exceeds %d: %s (%d×)" % [MAX_TEXTURE_SIZE, lightmap_tex.resource_path, size])
|
||||
|
||||
print("")
|
||||
print(" Textures inspected: ", inspected)
|
||||
if oversized.is_empty():
|
||||
print(" ✓ All textures within ", MAX_TEXTURE_SIZE, "×", MAX_TEXTURE_SIZE, " limit")
|
||||
else:
|
||||
for o in oversized:
|
||||
print(" ✖ ", o)
|
||||
|
||||
return {
|
||||
"pass": errors.is_empty(),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
func _check_material_textures(mat: Material, visited: Dictionary, errors: Array, warnings: Array, inspected: int) -> void:
|
||||
if mat is StandardMaterial3D:
|
||||
var smat := mat as StandardMaterial3D
|
||||
_check_single_texture(smat.albedo_texture, "albedo", smat.resource_path, visited, errors, warnings, inspected)
|
||||
_check_single_texture(smat.normal_texture, "normal", smat.resource_path, visited, errors, warnings, inspected)
|
||||
_check_single_texture(smat.roughness_texture, "roughness", smat.resource_path, visited, errors, warnings, inspected)
|
||||
_check_single_texture(smat.metallic_texture, "metallic", smat.resource_path, visited, errors, warnings, inspected)
|
||||
_check_single_texture(smat.emission_texture, "emission", smat.resource_path, visited, errors, warnings, inspected)
|
||||
_check_single_texture(smat.ao_texture, "ambient_occlusion", smat.resource_path, visited, errors, warnings, inspected)
|
||||
|
||||
elif mat is ORMMaterial3D:
|
||||
var orm := mat as ORMMaterial3D
|
||||
_check_single_texture(orm.albedo_texture, "albedo", orm.resource_path, visited, errors, warnings, inspected)
|
||||
_check_single_texture(orm.normal_texture, "normal", orm.resource_path, visited, errors, warnings, inspected)
|
||||
_check_single_texture(orm.orm_texture, "ORM", orm.resource_path, visited, errors, warnings, inspected)
|
||||
_check_single_texture(orm.emission_texture, "emission", orm.resource_path, visited, errors, warnings, inspected)
|
||||
|
||||
elif mat is ShaderMaterial:
|
||||
var sm := mat as ShaderMaterial
|
||||
# ShaderMaterial textures are set via shader params — check all Texture2D params
|
||||
var param_list = sm.get_shader_parameter_list() if sm.shader else []
|
||||
for param in param_list:
|
||||
var val = sm.get_shader_parameter(param)
|
||||
if val is Texture2D:
|
||||
_check_single_texture(val as Texture2D, param, sm.resource_path, visited, errors, warnings, inspected)
|
||||
|
||||
|
||||
func _check_single_texture(tex: Texture2D, slot_name: String, material_path: String, visited: Dictionary, errors: Array, warnings: Array, inspected: int) -> void:
|
||||
if tex == null:
|
||||
return
|
||||
var path := tex.resource_path
|
||||
if path.is_empty():
|
||||
path = tex.name # built-in/placeholder texture
|
||||
if visited.has(path):
|
||||
return
|
||||
visited[path] = true
|
||||
inspected += 1
|
||||
|
||||
var size := _get_texture_size(tex)
|
||||
if size <= 0:
|
||||
return
|
||||
|
||||
var label := "%s/%s" % [material_path.get_file(), slot_name]
|
||||
|
||||
if size > MAX_TEXTURE_SIZE:
|
||||
errors.append("Texture exceeds %d×%d: %s (%s) — %d× (path: %s)" % [MAX_TEXTURE_SIZE, MAX_TEXTURE_SIZE, label, path, size, path])
|
||||
elif size > WARN_TEXTURE_SIZE:
|
||||
warnings.append("Texture > %d×%d: %s (%s) — %d× (consider downscaling)" % [WARN_TEXTURE_SIZE, WARN_TEXTURE_SIZE, label, path, size])
|
||||
|
||||
|
||||
func _get_texture_size(tex: Texture2D) -> int:
|
||||
# Return the larger dimension
|
||||
return max(tex.get_width(), tex.get_height())
|
||||
|
||||
|
||||
func _collect_mesh_instances(node: Node, result: Array) -> void:
|
||||
if node is MeshInstance3D:
|
||||
result.append(node)
|
||||
for child in node.get_children():
|
||||
_collect_mesh_instances(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)
|
||||
|
||||
|
||||
func _find_materials_on_node(node: Node, visited: Dictionary, errors: Array, warnings: Array, inspected: int) -> void:
|
||||
# Fallback: walk all nodes and check their material_override
|
||||
for child in node.get_children():
|
||||
if child.has_method("get_material_override"):
|
||||
var mat = child.get("material_override")
|
||||
if mat != null and mat is Material:
|
||||
_check_material_textures(mat, visited, errors, warnings, inspected)
|
||||
_find_materials_on_node(child, visited, errors, warnings, inspected)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgpcwrlic4dww
|
||||
@@ -0,0 +1,31 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
# Bootstrap: loads and runs validate_map.gd from the main scene.
|
||||
# Usage:
|
||||
# 1. Set project.godot run/main_scene to this scene
|
||||
# 2. godot --path client [-- <scene_path>]
|
||||
# 3. Then restore the original main_scene
|
||||
|
||||
func _ready() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
|
||||
if args.is_empty():
|
||||
print("Usage: godot --path client [-- <scene_path>]")
|
||||
print("")
|
||||
print("If no scene_path given, validates the default scene.")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
var scene_path := args[0]
|
||||
|
||||
# Load and run the validate_map.gd script
|
||||
var validator_script := load("res://tools/validate_map.gd")
|
||||
if validator_script == null:
|
||||
push_error("Failed to load validator script")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
var validator := Node.new()
|
||||
validator.set_script(validator_script)
|
||||
add_child(validator)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cyql0huyj6r5y
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tools/validate_map.gd" id="1"]
|
||||
|
||||
[node name="Validator" type="Node"]
|
||||
script = ExtResource("1")
|
||||
Reference in New Issue
Block a user