merge M03 M2 runtime rebuild classifier
This commit is contained in:
@@ -35,6 +35,7 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g
|
||||
- `src/render/wmo/wmo_scene_resource_cache_state.gd` - validated cached-WMO PackedScenes, negative cache and pending `.tscn` paths without file/I/O/Node ownership.
|
||||
- `src/render/liquid/adt_water_load_pipeline_state.gd` - ADT water pending FIFO/dedupe, active task IDs and worker-safe parsed-result mailbox without parser or Node ownership.
|
||||
- `src/render/liquid/adt_water_scene_finalizer.gd` - stateless main-thread ADT water build/attach and optional persisted Editor ownership through the existing ADTBuilder.
|
||||
- `src/render/m2/m2_runtime_mesh_rebuild_classifier.gd` - memoized billboard/UV-rotation decision for stale cached M2 runtime mesh refresh.
|
||||
- `src/scenes/streaming/eastern_kingdoms_streaming.tscn` - текущая сцена для проверки Azeroth/Eastern Kingdoms.
|
||||
- `addons/mpq_extractor/loaders/adt_builder.gd` - сборка ADT terrain, control splat материалов и MH2O liquids.
|
||||
- `addons/mpq_extractor/loaders/m2_builder.gd` - сборка статического M2 mesh/material.
|
||||
@@ -1039,6 +1040,22 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
|
||||
- Cache formats, quality profiles, batching output and visible rules are unchanged.
|
||||
Asset-backed p95/p99 and spatial-cell batching evidence remain pending.
|
||||
|
||||
## 2026-07-17 M2 Runtime Mesh Rebuild Classifier Extraction
|
||||
|
||||
- `M2RuntimeMeshRebuildClassifier` now owns the memoized decision used when a
|
||||
stale cached M2 mesh may need an in-memory rebuild from raw data.
|
||||
- Existing triggers are unchanged: any billboard flag/positive billboard vertex
|
||||
count, or a referenced non-identity UV rotation/absolute rotation speed above
|
||||
`0.000001` across at most four texture stages.
|
||||
- Invalid batch variants and combo/transform indices remain skipped. The first
|
||||
decision for a normalized path remains cached until the same two historical
|
||||
map/shutdown clear sites.
|
||||
- `StreamingWorldLoader` still loads raw M2 data, checks
|
||||
`M2_MATERIAL_REFRESH_VERSION`, calls `M2Builder`, adopts/falls back to Meshes
|
||||
and owns ResourceLoader/finalization budgets.
|
||||
- No cache version, shader, material, geometry or visible rule changed. Synthetic
|
||||
predicate timing is not asset-backed descriptor-pressure/p95/p99 evidence.
|
||||
|
||||
## 2026-07-17 WMO Placement Resolver Extraction
|
||||
|
||||
- `WmoPlacementResolver` now owns lowercase/slash cache-key normalization,
|
||||
|
||||
@@ -81,14 +81,29 @@ rotation transforms.
|
||||
|
||||
## Status
|
||||
|
||||
- State: claimed
|
||||
- Done: rebuild predicate, one query site and two cache-clear sites identified
|
||||
- Next: implement classifier, migrate loader adapter, verify and document
|
||||
- State: ready for integration
|
||||
- Done: classifier/cache implemented; loader query and both clear sites migrated;
|
||||
synthetic predicate/cache verifier and required documentation added
|
||||
- Next: integrate and run post-merge smoke on master
|
||||
- Blocked by:
|
||||
|
||||
## Handoff
|
||||
|
||||
- Commit:
|
||||
- Results:
|
||||
- Remaining risks:
|
||||
- Documentation updated:
|
||||
- Commit: `19ca8f4`
|
||||
- Results: classifier PASS (`cases=12`, `iterations=100`,
|
||||
`elapsed_ms=64.585`; initial `35.461ms`); billboard flag/count, invalid
|
||||
variants/indices, UV rotation/speed/epsilon, four-stage cap, memoization,
|
||||
detached diagnostics and clear contracts PASS; 33 M2/WMO/liquid/terrain/
|
||||
facade/scheduler/streaming/coordinate/material/shutdown regressions PASS;
|
||||
internal-access decreased to `35`; documentation and coordination gates PASS;
|
||||
checkpoint dry-run PASS `7/7`.
|
||||
- Remaining risks: raw parser data remains Dictionary-based; the first decision
|
||||
for a path remains authoritative until clear; classification covers the current
|
||||
four-stage material implementation only; private asset descriptor-pressure,
|
||||
traversal, p95/p99 and paired original-client evidence remain absent.
|
||||
- Documentation updated: inline public API; new
|
||||
`docs/modules/m2-runtime-mesh-rebuild-classifier.md`; module registry, world
|
||||
renderer and `RENDER.md`; data-flow/state/sequence/dependency diagrams,
|
||||
ownership, failure/recovery, thresholds, cache lifetime and source map included.
|
||||
|
||||
<!-- OPENWC_HANDOFF:READY:M03-RND-M2-RUNTIME-REBUILD-CLASSIFIER-001:19ca8f4 -->
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
| M2 placement transform resolver | Implemented | [`m2-placement-transform-resolver.md`](m2-placement-transform-resolver.md) |
|
||||
| M2 placement grouper | Implemented extraction | [`m2-placement-grouper.md`](m2-placement-grouper.md) |
|
||||
| M2 build batch planner | Implemented extraction | [`m2-build-batch-planner.md`](m2-build-batch-planner.md) |
|
||||
| M2 runtime mesh rebuild classifier | Implemented extraction | [`m2-runtime-mesh-rebuild-classifier.md`](m2-runtime-mesh-rebuild-classifier.md) |
|
||||
| WMO placement resolver | Implemented extraction | [`wmo-placement-resolver.md`](wmo-placement-resolver.md) |
|
||||
| WMO placement registry | Implemented extraction | [`wmo-placement-registry.md`](wmo-placement-registry.md) |
|
||||
| WMO render build step planner | Implemented extraction | [`wmo-render-build-step-planner.md`](wmo-render-build-step-planner.md) |
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# M2 Runtime Mesh Rebuild Classifier
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Status | Implemented extraction |
|
||||
| Target/work package | M03 / `M03-RND-M2-RUNTIME-REBUILD-CLASSIFIER-001` |
|
||||
| Owners | Billboard/UV-rotation rebuild decision and per-path memoization |
|
||||
| Last verified | Worktree `work/sindo-main-codex/m03-m2-runtime-rebuild-classifier`, 2026-07-17 |
|
||||
| Profiles/capabilities | Existing M2 material-refresh path; profile-independent |
|
||||
|
||||
## Purpose
|
||||
|
||||
Determine whether a stale cached M2 mesh must be rebuilt in memory from raw M2
|
||||
data to restore billboard custom attributes or UV-rotation material inputs. The
|
||||
first decision is memoized by normalized relative path for the current map/
|
||||
runtime-cache lifetime.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Read M2 files or invoke `M2Loader`/`M2Builder`.
|
||||
- Own cached Meshes, scenes, materials, Nodes or RIDs.
|
||||
- Select cache paths, perform ResourceLoader transitions or consume permits.
|
||||
- Change refresh/cache format versions or shader behavior.
|
||||
- Classify animation, particles, ribbons or general visual fidelity.
|
||||
|
||||
## Context and boundaries
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Cache[Stale cached M2 Mesh] --> Loader[StreamingWorldLoader]
|
||||
Loader --> Raw[M2Loader raw Dictionary]
|
||||
Raw --> Classifier[M2RuntimeMeshRebuildClassifier]
|
||||
Classifier --> Decision[Memoized rebuild boolean]
|
||||
Decision --> Loader
|
||||
Loader -->|true| Builder[M2Builder rebuild]
|
||||
Loader -->|false| Refresh[Mark material refresh version]
|
||||
```
|
||||
|
||||
Allowed dependencies are Dictionary/Array, `PackedInt32Array`, `Vector4` and
|
||||
scalar math. Files, ResourceLoader, parser/builder classes, SceneTree,
|
||||
WorkerThreadPool, Mesh/Node/RID ownership and other layers are forbidden.
|
||||
|
||||
## Public API
|
||||
|
||||
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|
||||
|---|---|---|---|---|
|
||||
| `needs_runtime_mesh_rebuild(normalized_relative_path, raw_m2_data)` | Memoized query | Detect billboard or referenced UV rotation requirements | Renderer main thread; cached until clear | Invalid variants/indices are skipped; first path decision wins |
|
||||
| `clear()` | Command | Drop all memoized path decisions | Main thread; map reset/shutdown | Idempotent |
|
||||
| `cached_path_count()` | Query | Return memoized path count | Main thread | None |
|
||||
| `diagnostic_snapshot()` | Query | Return detached path/decision map | Main thread; caller-owned | None |
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|
||||
|---|---|---|---|---|---|
|
||||
| Input | Normalized M2 relative path | Loader cache lookup | Classifier cache key | String value copied | Until clear |
|
||||
| Input | Raw M2 batches/transforms/combos Dictionary | Native `M2Loader` adapter | Classifier | Borrowed; not mutated | First classification call per path |
|
||||
| Output | Rebuild-required boolean | Classifier | Loader material-refresh adapter | Scalar | Memoized until clear |
|
||||
| Output | Detached decision Dictionary | Classifier | Verifier/future diagnostics | Caller-owned copy | Snapshot lifetime |
|
||||
|
||||
Side effects are limited to inserting and clearing boolean memoization entries.
|
||||
|
||||
## Data flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Query[path + raw M2 data] --> Cached{Path cached?}
|
||||
Cached -->|yes| Return[Return first decision]
|
||||
Cached -->|no| Billboard{Any billboard flag or positive vertex count?}
|
||||
Billboard -->|yes| StoreTrue[Store true]
|
||||
Billboard -->|no| Inputs{Transforms and combos present?}
|
||||
Inputs -->|no| StoreFalse[Store false]
|
||||
Inputs -->|yes| Batch[For each Dictionary batch]
|
||||
Batch --> Stage[Inspect at most four referenced stages]
|
||||
Stage --> Rotation{Non-identity rotation or speed above epsilon?}
|
||||
Rotation -->|yes| StoreTrue
|
||||
Rotation -->|no| StoreFalse
|
||||
StoreTrue --> Return
|
||||
StoreFalse --> Return
|
||||
```
|
||||
|
||||
## Lifecycle/state
|
||||
|
||||
Each path moves from unknown to one immutable cached boolean. Clear returns all
|
||||
paths to unknown; no resource lifecycle participates.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Unknown
|
||||
Unknown --> RebuildRequired: billboard or referenced UV rotation
|
||||
Unknown --> RebuildNotRequired: no relevant metadata
|
||||
RebuildRequired --> RebuildRequired: repeated query
|
||||
RebuildNotRequired --> RebuildNotRequired: repeated query
|
||||
RebuildRequired --> Unknown: clear
|
||||
RebuildNotRequired --> Unknown: clear
|
||||
```
|
||||
|
||||
## Main sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Loader as StreamingWorldLoader
|
||||
participant Native as M2Loader
|
||||
participant Classifier as M2RuntimeMeshRebuildClassifier
|
||||
participant Builder as M2Builder
|
||||
Loader->>Native: load_m2(normalized path)
|
||||
Native-->>Loader: raw Dictionary
|
||||
Loader->>Classifier: needs_runtime_mesh_rebuild(path, data)
|
||||
Classifier-->>Loader: memoized boolean
|
||||
alt rebuild required
|
||||
Loader->>Builder: rebuild mesh from raw data
|
||||
else no rebuild required
|
||||
Loader->>Loader: stamp material refresh version
|
||||
end
|
||||
```
|
||||
|
||||
## Dependency diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Loader[StreamingWorldLoader] --> Classifier[M2RuntimeMeshRebuildClassifier]
|
||||
Loader --> Native[M2Loader]
|
||||
Loader --> Builder[M2Builder]
|
||||
Loader --> Cache[M2 Mesh/Scene caches]
|
||||
Classifier -. no dependency .-> Native
|
||||
Classifier -. no dependency .-> Builder
|
||||
Classifier -. no dependency .-> Cache
|
||||
```
|
||||
|
||||
## Ownership, threading and resources
|
||||
|
||||
- The classifier owns only normalized-path boolean entries.
|
||||
- The loader owns raw loading, cache/resource selection, Mesh replacement and
|
||||
material refresh metadata.
|
||||
- Raw Dictionaries are borrowed and never retained; only the computed boolean is cached.
|
||||
- Current calls and clears are serialized on the renderer main thread.
|
||||
- No Node, Resource, Mesh, material or RID reference crosses into the classifier.
|
||||
|
||||
## Errors, cancellation and recovery
|
||||
|
||||
| Failure | Detection | Behavior | Diagnostic | Recovery |
|
||||
|---|---|---|---|---|
|
||||
| Non-Dictionary batch/transform | Type guard | Skip entry or use identity defaults | Synthetic verifier | Correct parser data on next cleared lifetime |
|
||||
| Missing transforms/combos | Empty guard | No UV-driven rebuild | Synthetic verifier | Billboard classification still applies |
|
||||
| Invalid combo/transform index | Bounds guard | Skip stage | Synthetic verifier | Correct raw data and clear |
|
||||
| Repeated path with changed data | Cache hit | Preserve first decision | Memoization fixture | Map/reset clear recomputes |
|
||||
| Load/build failure | Outside classifier | Existing mesh fallback remains | Loader diagnostics | Existing reload/fallback path |
|
||||
|
||||
## Configuration and capabilities
|
||||
|
||||
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|
||||
|---|---|---|---|---|
|
||||
| Maximum texture stages inspected | `4` | Existing M2 material path | No | Preserves historical four-stage shader boundary |
|
||||
| UV rotation-speed epsilon | `0.000001` exclusive | Existing M2 material path | No | Larger absolute speed requires rebuild |
|
||||
| `M2_MATERIAL_REFRESH_VERSION` | Loader constant `2` | All | Code version | Gates whether classifier is called |
|
||||
|
||||
## Persistence, cache and migration
|
||||
|
||||
Memoized booleans are runtime-only and are cleared at the two historical loader
|
||||
reset/shutdown sites. No persisted cache version or rebuild instruction changes.
|
||||
|
||||
## Diagnostics and observability
|
||||
|
||||
- Logs: none.
|
||||
- Metrics: `cached_path_count` is contract-level only and not added to runtime logs.
|
||||
- Debug views: detached path-to-boolean snapshot.
|
||||
- Correlation IDs: normalized M2 relative path.
|
||||
|
||||
## Verification
|
||||
|
||||
- `verify_m2_runtime_mesh_rebuild_classifier.gd`: billboard flag/count, invalid
|
||||
variants, identity/non-identity rotation, positive/negative speed, exact
|
||||
epsilon, four-stage cap, invalid indices, memoization, clear, source boundary
|
||||
and 100-by-256 timing.
|
||||
- Existing M2 builder/material verifiers cover downstream shader/cache behavior.
|
||||
- Fidelity evidence is exact predicate/cache extraction. No new asset-backed or
|
||||
original-client parity claim is made.
|
||||
- Performance budget: 25,600 classifications and clears under one second.
|
||||
|
||||
## Extension points
|
||||
|
||||
- A typed raw M2 material descriptor may replace the Dictionary only with parser
|
||||
fixtures and a compatibility adapter.
|
||||
- Mesh request/cache/finalization state can be extracted separately without
|
||||
changing this rebuild predicate.
|
||||
|
||||
## Capability status
|
||||
|
||||
| Capability | Status | Evidence | Gap/next step |
|
||||
|---|---|---|---|
|
||||
| Billboard rebuild classification | Implemented extraction | Flag/count fixtures | Asset-backed cache refresh timing pending |
|
||||
| UV-rotation rebuild classification | Implemented extraction | Combo/stage/threshold fixtures | More build-12340 material fixtures pending |
|
||||
| Runtime memoization lifetime | Implemented extraction | First-decision/clear/source fixtures | Byte/count runtime metric not exposed |
|
||||
| M2 mesh loading/finalization | Existing loader-owned | Material/shutdown regressions | Separate state extraction pending |
|
||||
|
||||
## Known gaps and risks
|
||||
|
||||
- Raw parser data remains Dictionary-based.
|
||||
- The first decision wins even if different raw data is passed for the same path;
|
||||
this intentionally preserves prior cache behavior.
|
||||
- Only four texture stages are inspected, matching current material support.
|
||||
- No private asset traversal, descriptor-pressure, p95/p99 or paired visual run is included.
|
||||
|
||||
## Source map
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `src/render/m2/m2_runtime_mesh_rebuild_classifier.gd` | Predicate, thresholds and boolean memoization |
|
||||
| `src/scenes/streaming/streaming_world_loader.gd` | Raw loading, Mesh rebuild/adoption, refresh version and clear calls |
|
||||
| `addons/mpq_extractor/loaders/m2_builder.gd` | Mesh/material rebuild implementation |
|
||||
| `src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd` | Synthetic predicate/cache/boundary/timing regression |
|
||||
|
||||
## Related decisions and references
|
||||
|
||||
- [`m2-build-batch-planner.md`](m2-build-batch-planner.md)
|
||||
- [`m2-placement-grouper.md`](m2-placement-grouper.md)
|
||||
- [`world-renderer.md`](world-renderer.md)
|
||||
- [`../../RENDER.md`](../../RENDER.md)
|
||||
- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md)
|
||||
@@ -7,7 +7,7 @@
|
||||
| Status | Partial |
|
||||
| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; M03 facade/planner/scheduler/internal-access/ground/environment/entity packages; M03 terrain packages; M03 M2 packages; M03 WMO placement package |
|
||||
| Owners | Renderer workstream / milestone integrator |
|
||||
| Last verified | Worktree `work/sindo-main-codex/m03-adt-water-scene-finalizer`, 2026-07-17 |
|
||||
| Last verified | Worktree `work/sindo-main-codex/m03-m2-runtime-rebuild-classifier`, 2026-07-17 |
|
||||
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
|
||||
|
||||
## Purpose
|
||||
@@ -132,6 +132,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state
|
||||
| `M2PlacementTransformResolver.resolve_basis/resolve_origin_offset` | Internal pure M2 service | Resolves regular and calibrated model-specific ADT placement transforms | Worker/main thread; stateless | Unknown paths use regular basis and zero offset |
|
||||
| `M2PlacementGrouper.group_placements` | Internal pure M2 service | Validates and groups ordered tile-local placement transforms by normalized path | Worker/main thread; stateless | Invalid variants/name IDs/empty paths are skipped |
|
||||
| `M2BuildBatchPlanner.plan_batch` | Internal pure M2 service | Selects static/animated batch count and next group cursor | Main/any thread; stateless | Non-positive selected limit clamps to one; empty range completes |
|
||||
| `M2RuntimeMeshRebuildClassifier` | Internal memoized M2 service | Detects billboard/UV-rotation metadata requiring stale cached-mesh rebuild | Renderer main thread; cached until reset | Invalid variants/indices skipped; first path decision wins |
|
||||
| `WmoPlacementResolver.normalize_relative_path/resolve_unique_key/resolve_world_transform` | Internal pure WMO service | Resolves cache key, registry identity and world transform | Main/any thread; stateless | Missing UID uses tile/index fallback; transform fields use historical defaults |
|
||||
| `WmoPlacementRegistry.add_reference/release_reference/contains/active_count/diagnostic_snapshot/clear` | Internal WMO service | Owns placement-key to tile/global reference sets | Renderer main thread; map session | Empty/unknown/non-owner input is rejected without mutation |
|
||||
| `WmoRenderBuildStepPlanner.plan_step` | Internal pure WMO service | Selects one mesh-first lightweight render-group operation and next cursors | Main/any thread; stateless | Raw integer comparisons are preserved without clamping |
|
||||
@@ -365,6 +366,9 @@ sequenceDiagram
|
||||
stale tile/path/root checks and `water_loaded` timing. `AdtWaterSceneFinalizer`
|
||||
performs the existing ADTBuilder call, tile attach and optional recursive
|
||||
Editor ownership on the main thread without retaining the generated subtree.
|
||||
- `M2RuntimeMeshRebuildClassifier` owns only normalized-path boolean decisions
|
||||
for billboard/UV-rotation material refresh. The loader retains raw M2 loading,
|
||||
refresh-version checks, Mesh rebuild/adoption and both historical clear sites.
|
||||
- Rendered-ground query results and diagnostic snapshots are caller-owned values;
|
||||
the facade never returns Mesh, Node, tile-state or queue references.
|
||||
- Loaded-mesh ground sampling is renderer diagnostics, not authoritative gameplay
|
||||
@@ -565,6 +569,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
|
||||
| `src/render/terrain/terrain_quality_mesh_cache.gd` | Full-quality terrain revisit Mesh retention and LRU ownership |
|
||||
| `src/render/liquid/adt_water_load_pipeline_state.gd` | ADT water request/task/result bookkeeping and worker-safe mailbox |
|
||||
| `src/render/liquid/adt_water_scene_finalizer.gd` | Main-thread ADT water build, tile attachment and optional editor ownership |
|
||||
| `src/render/m2/m2_runtime_mesh_rebuild_classifier.gd` | Billboard/UV-rotation stale cached-mesh rebuild decision and memoization |
|
||||
| `src/render/streaming/streaming_target_planner.gd` | Scene-free wanted/retained ADT target calculation |
|
||||
| `src/render/streaming/streaming_target_policy.gd` | Immutable renderer radius/prefetch policy |
|
||||
| `src/render/streaming/streaming_target_plan.gd` | Immutable planner result with read-only tile-key sets |
|
||||
@@ -579,6 +584,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
|
||||
| `src/tools/verify_terrain_quality_mesh_cache.gd` | Terrain cache admission, LRU, ownership and loader-boundary regression |
|
||||
| `src/tools/verify_adt_water_load_pipeline_state.gd` | ADT water FIFO/task/thread/boundary/timing regression |
|
||||
| `src/tools/verify_adt_water_scene_finalizer.gd` | Synthetic ADT water scene/ownership/boundary/timing regression |
|
||||
| `src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd` | M2 rebuild predicate/cache/boundary/timing regression |
|
||||
| `src/tools/verify_streaming_target_planner.gd` | Planner behavior, dependency and bounded timing regression |
|
||||
| `src/tools/verify_render_budget_scheduler.gd` | Scheduler bounds, shared-lane priority, cancellation and timing regression |
|
||||
| `src/tools/verify_renderer_internal_access.gd` | Gameplay/EditorPlugin/registered renderer-tool boundary gate derived from private streamer fields |
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
class_name M2RuntimeMeshRebuildClassifier
|
||||
extends RefCounted
|
||||
|
||||
## Memoizes whether raw M2 billboard or UV-rotation metadata requires replacing
|
||||
## a stale cached runtime mesh. Owns no parser, Mesh, material, Node or RID.
|
||||
|
||||
const UV_ROTATION_SPEED_EPSILON := 0.000001
|
||||
const MAXIMUM_TEXTURE_STAGES := 4
|
||||
|
||||
var _rebuild_required_by_normalized_path: Dictionary = {}
|
||||
|
||||
|
||||
## Returns the first memoized rebuild decision for [param normalized_relative_path].
|
||||
## [param raw_m2_data] is borrowed and not mutated. The decision remains cached
|
||||
## until [method clear], matching the loader map/shutdown reset lifetime.
|
||||
func needs_runtime_mesh_rebuild(
|
||||
normalized_relative_path: String,
|
||||
raw_m2_data: Dictionary) -> bool:
|
||||
if _rebuild_required_by_normalized_path.has(normalized_relative_path):
|
||||
return bool(_rebuild_required_by_normalized_path[normalized_relative_path])
|
||||
var rebuild_required := (
|
||||
_raw_data_has_billboards(raw_m2_data)
|
||||
or _raw_data_has_uv_rotation(raw_m2_data)
|
||||
)
|
||||
_rebuild_required_by_normalized_path[normalized_relative_path] = rebuild_required
|
||||
return rebuild_required
|
||||
|
||||
|
||||
## Clears every memoized path decision. No Mesh or resource is retained or freed.
|
||||
func clear() -> void:
|
||||
_rebuild_required_by_normalized_path.clear()
|
||||
|
||||
|
||||
## Returns the number of memoized normalized paths.
|
||||
func cached_path_count() -> int:
|
||||
return _rebuild_required_by_normalized_path.size()
|
||||
|
||||
|
||||
## Returns a detached normalized-path to boolean decision snapshot.
|
||||
func diagnostic_snapshot() -> Dictionary:
|
||||
return _rebuild_required_by_normalized_path.duplicate()
|
||||
|
||||
|
||||
func _raw_data_has_billboards(raw_m2_data: Dictionary) -> bool:
|
||||
for batch_variant in raw_m2_data.get("batches", []):
|
||||
if not (batch_variant is Dictionary):
|
||||
continue
|
||||
var batch: Dictionary = batch_variant
|
||||
if (
|
||||
bool(batch.get("has_billboard", false))
|
||||
or int(batch.get("billboard_vertex_count", 0)) > 0
|
||||
):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _raw_data_has_uv_rotation(raw_m2_data: Dictionary) -> bool:
|
||||
var texture_transforms: Array = raw_m2_data.get("texture_transforms", [])
|
||||
if texture_transforms.is_empty():
|
||||
return false
|
||||
var texture_transform_combos: PackedInt32Array = raw_m2_data.get(
|
||||
"texture_transform_combos",
|
||||
PackedInt32Array()
|
||||
)
|
||||
if texture_transform_combos.is_empty():
|
||||
return false
|
||||
for batch_variant in raw_m2_data.get("batches", []):
|
||||
if not (batch_variant is Dictionary):
|
||||
continue
|
||||
var batch: Dictionary = batch_variant
|
||||
var texture_count := maxi(1, int(batch.get("texture_count", 1)))
|
||||
var base_combo_index := int(batch.get("texture_transform_combo_index", -1))
|
||||
for texture_stage in mini(texture_count, MAXIMUM_TEXTURE_STAGES):
|
||||
var combo_index := base_combo_index + texture_stage
|
||||
if combo_index < 0 or combo_index >= texture_transform_combos.size():
|
||||
continue
|
||||
var transform_index := int(texture_transform_combos[combo_index])
|
||||
if transform_index < 0 or transform_index >= texture_transforms.size():
|
||||
continue
|
||||
var texture_transform: Dictionary = (
|
||||
texture_transforms[transform_index]
|
||||
if texture_transforms[transform_index] is Dictionary
|
||||
else {}
|
||||
)
|
||||
var rotation: Vector4 = texture_transform.get(
|
||||
"rotation",
|
||||
Vector4(1.0, 0.0, 0.0, 1.0)
|
||||
)
|
||||
if not rotation.is_equal_approx(Vector4(1.0, 0.0, 0.0, 1.0)):
|
||||
return true
|
||||
if absf(float(texture_transform.get("rotation_speed", 0.0))) > UV_ROTATION_SPEED_EPSILON:
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://breu8vpmm88en
|
||||
@@ -60,6 +60,9 @@ const M2_PLACEMENT_GROUPER_SCRIPT := preload(
|
||||
const M2_BUILD_BATCH_PLANNER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_build_batch_planner.gd"
|
||||
)
|
||||
const M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_runtime_mesh_rebuild_classifier.gd"
|
||||
)
|
||||
const STREAMING_TARGET_PLANNER_SCRIPT := preload("res://src/render/streaming/streaming_target_planner.gd")
|
||||
const STREAMING_TARGET_POLICY_SCRIPT := preload("res://src/render/streaming/streaming_target_policy.gd")
|
||||
const RENDER_BUDGET_SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd")
|
||||
@@ -247,10 +250,12 @@ var _m2_unique_placement_registry := (
|
||||
var _m2_placement_transform_resolver := M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()
|
||||
var _m2_placement_grouper := M2_PLACEMENT_GROUPER_SCRIPT.new()
|
||||
var _m2_build_batch_planner := M2_BUILD_BATCH_PLANNER_SCRIPT.new()
|
||||
var _m2_runtime_mesh_rebuild_classifier := (
|
||||
M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT.new()
|
||||
)
|
||||
var _m2_mesh_cache: Dictionary = {}
|
||||
var _m2_mesh_load_requests: Dictionary = {}
|
||||
var _m2_mesh_finalize_queue: Array = []
|
||||
var _m2_runtime_rebuild_required_cache: Dictionary = {}
|
||||
var _m2_animation_load_requests: Dictionary = {}
|
||||
var _m2_animation_finalize_queue: Array = []
|
||||
var _wmo_build_jobs: Dictionary = {}
|
||||
@@ -2342,7 +2347,7 @@ func _wait_for_tile_tasks() -> void:
|
||||
ResourceLoader.load_threaded_get(path)
|
||||
_m2_mesh_load_requests.clear()
|
||||
_m2_mesh_finalize_queue.clear()
|
||||
_m2_runtime_rebuild_required_cache.clear()
|
||||
_m2_runtime_mesh_rebuild_classifier.clear()
|
||||
|
||||
for pending in _m2_animation_load_requests.values():
|
||||
var path: String = String(pending.get("path", ""))
|
||||
@@ -2993,7 +2998,7 @@ func _clear_streamed_world() -> void:
|
||||
_m2_unique_placement_registry.clear()
|
||||
_m2_mesh_load_requests.clear()
|
||||
_m2_mesh_finalize_queue.clear()
|
||||
_m2_runtime_rebuild_required_cache.clear()
|
||||
_m2_runtime_mesh_rebuild_classifier.clear()
|
||||
_m2_animation_load_requests.clear()
|
||||
_m2_animation_finalize_queue.clear()
|
||||
_wmo_build_jobs.clear()
|
||||
@@ -4645,7 +4650,9 @@ func _prepare_m2_mesh_for_runtime(normalized_rel: String, mesh: Mesh) -> Mesh:
|
||||
if int(mesh.get_meta("wow_m2_material_refresh_version", 0)) >= M2_MATERIAL_REFRESH_VERSION:
|
||||
return mesh
|
||||
var data := _load_m2_raw_data_for_refresh(normalized_rel)
|
||||
if data.is_empty() or not _m2_raw_data_needs_runtime_mesh_rebuild(normalized_rel, data):
|
||||
if data.is_empty() or not _m2_runtime_mesh_rebuild_classifier.needs_runtime_mesh_rebuild(
|
||||
normalized_rel,
|
||||
data):
|
||||
mesh.set_meta("wow_m2_material_refresh_version", M2_MATERIAL_REFRESH_VERSION)
|
||||
return mesh
|
||||
var rebuilt := _rebuild_m2_mesh_from_data(data)
|
||||
@@ -4679,53 +4686,6 @@ func _load_m2_raw_data_for_refresh(normalized_rel: String) -> Dictionary:
|
||||
return data if data is Dictionary else {}
|
||||
|
||||
|
||||
func _m2_raw_data_needs_runtime_mesh_rebuild(normalized_rel: String, data: Dictionary) -> bool:
|
||||
if _m2_runtime_rebuild_required_cache.has(normalized_rel):
|
||||
return bool(_m2_runtime_rebuild_required_cache[normalized_rel])
|
||||
var needs_rebuild := _m2_raw_data_has_billboards(data) or _m2_raw_data_has_uv_rotation(data)
|
||||
_m2_runtime_rebuild_required_cache[normalized_rel] = needs_rebuild
|
||||
return needs_rebuild
|
||||
|
||||
|
||||
func _m2_raw_data_has_billboards(data: Dictionary) -> bool:
|
||||
for batch_variant in data.get("batches", []):
|
||||
if not (batch_variant is Dictionary):
|
||||
continue
|
||||
var batch: Dictionary = batch_variant
|
||||
if bool(batch.get("has_billboard", false)) or int(batch.get("billboard_vertex_count", 0)) > 0:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _m2_raw_data_has_uv_rotation(data: Dictionary) -> bool:
|
||||
var transforms: Array = data.get("texture_transforms", [])
|
||||
if transforms.is_empty():
|
||||
return false
|
||||
var combos: PackedInt32Array = data.get("texture_transform_combos", PackedInt32Array())
|
||||
if combos.is_empty():
|
||||
return false
|
||||
for batch_variant in data.get("batches", []):
|
||||
if not (batch_variant is Dictionary):
|
||||
continue
|
||||
var batch: Dictionary = batch_variant
|
||||
var texture_count := maxi(1, int(batch.get("texture_count", 1)))
|
||||
var base_combo := int(batch.get("texture_transform_combo_index", -1))
|
||||
for stage in mini(texture_count, 4):
|
||||
var combo_index := base_combo + stage
|
||||
if combo_index < 0 or combo_index >= combos.size():
|
||||
continue
|
||||
var transform_index := int(combos[combo_index])
|
||||
if transform_index < 0 or transform_index >= transforms.size():
|
||||
continue
|
||||
var transform: Dictionary = transforms[transform_index] if transforms[transform_index] is Dictionary else {}
|
||||
var rotation: Vector4 = transform.get("rotation", Vector4(1.0, 0.0, 0.0, 1.0))
|
||||
if not rotation.is_equal_approx(Vector4(1.0, 0.0, 0.0, 1.0)):
|
||||
return true
|
||||
if absf(float(transform.get("rotation_speed", 0.0))) > 0.000001:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _rebuild_m2_mesh_from_data(data: Dictionary) -> Mesh:
|
||||
if data.is_empty():
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
extends SceneTree
|
||||
|
||||
## Synthetic predicate, memoization, boundary and timing regression for stale
|
||||
## cached M2 runtime mesh rebuild decisions.
|
||||
|
||||
const CLASSIFIER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_runtime_mesh_rebuild_classifier.gd"
|
||||
)
|
||||
const CLASSIFIER_PATH := "res://src/render/m2/m2_runtime_mesh_rebuild_classifier.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_billboard_decisions(failures)
|
||||
_verify_uv_rotation_decisions(failures)
|
||||
_verify_stage_and_index_boundaries(failures)
|
||||
_verify_memoization_clear_and_diagnostics(failures)
|
||||
_verify_ownership_boundaries(failures)
|
||||
var elapsed_milliseconds := _verify_bounded_timing(failures)
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("M2_RUNTIME_MESH_REBUILD_CLASSIFIER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print(
|
||||
"M2_RUNTIME_MESH_REBUILD_CLASSIFIER PASS cases=12 iterations=100 elapsed_ms=%.3f"
|
||||
% elapsed_milliseconds
|
||||
)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_billboard_decisions(failures: Array[String]) -> void:
|
||||
_expect_false(_classify("empty", {}), "empty data does not rebuild", failures)
|
||||
_expect_false(
|
||||
_classify("invalid", {"batches": ["invalid", 7, null]}),
|
||||
"non-Dictionary batches skipped",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
_classify("flag", {"batches": [{"has_billboard": true}]}),
|
||||
"billboard flag rebuilds",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
_classify("vertices", {"batches": [{"billboard_vertex_count": 1}]}),
|
||||
"positive billboard vertex count rebuilds",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
_classify("zero_vertices", {"batches": [{"billboard_vertex_count": 0}]}),
|
||||
"zero billboard vertex count does not rebuild",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_uv_rotation_decisions(failures: Array[String]) -> void:
|
||||
_expect_false(
|
||||
_classify("identity", _uv_data([_rotation_transform()])),
|
||||
"identity UV rotation does not rebuild",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
_classify(
|
||||
"rotation",
|
||||
_uv_data([_rotation_transform(Vector4(0.0, 1.0, -1.0, 0.0))])
|
||||
),
|
||||
"non-identity UV rotation rebuilds",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
_classify("positive_speed", _uv_data([_rotation_transform(Vector4(1, 0, 0, 1), 0.000002)])),
|
||||
"positive UV rotation speed rebuilds",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
_classify("negative_speed", _uv_data([_rotation_transform(Vector4(1, 0, 0, 1), -0.000002)])),
|
||||
"negative UV rotation speed rebuilds",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
_classify("epsilon", _uv_data([_rotation_transform(Vector4(1, 0, 0, 1), 0.000001)])),
|
||||
"exact UV rotation epsilon does not rebuild",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_stage_and_index_boundaries(failures: Array[String]) -> void:
|
||||
var fifth_stage_transforms: Array = [
|
||||
_rotation_transform(),
|
||||
_rotation_transform(),
|
||||
_rotation_transform(),
|
||||
_rotation_transform(),
|
||||
_rotation_transform(Vector4(0.0, 1.0, -1.0, 0.0)),
|
||||
]
|
||||
_expect_false(
|
||||
_classify(
|
||||
"fifth_stage",
|
||||
_uv_data(fifth_stage_transforms, PackedInt32Array([0, 1, 2, 3, 4]), 5)
|
||||
),
|
||||
"fifth texture stage remains ignored",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
_classify(
|
||||
"invalid_indices",
|
||||
_uv_data([_rotation_transform(Vector4(0, 1, -1, 0))], PackedInt32Array([-1, 9]), 2)
|
||||
),
|
||||
"invalid transform indices skipped",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
_classify(
|
||||
"invalid_transform",
|
||||
_uv_data(["not-a-transform"], PackedInt32Array([0]), 1)
|
||||
),
|
||||
"non-Dictionary transform uses identity defaults",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_memoization_clear_and_diagnostics(failures: Array[String]) -> void:
|
||||
var classifier: RefCounted = CLASSIFIER_SCRIPT.new()
|
||||
var billboard_data := {"batches": [{"has_billboard": true}]}
|
||||
_expect_true(
|
||||
classifier.call("needs_runtime_mesh_rebuild", "world/tree.m2", billboard_data),
|
||||
"first true decision",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
classifier.call("needs_runtime_mesh_rebuild", "world/tree.m2", {}),
|
||||
"first path decision memoized",
|
||||
failures
|
||||
)
|
||||
classifier.call("needs_runtime_mesh_rebuild", "world/rock.m2", {})
|
||||
_expect_equal(int(classifier.call("cached_path_count")), 2, "cached path count", failures)
|
||||
var snapshot: Dictionary = classifier.call("diagnostic_snapshot")
|
||||
snapshot["world/tree.m2"] = false
|
||||
_expect_true(
|
||||
classifier.call("needs_runtime_mesh_rebuild", "world/tree.m2", {}),
|
||||
"diagnostics detached",
|
||||
failures
|
||||
)
|
||||
classifier.call("clear")
|
||||
_expect_equal(int(classifier.call("cached_path_count")), 0, "clear count", failures)
|
||||
_expect_false(
|
||||
classifier.call("needs_runtime_mesh_rebuild", "world/tree.m2", {}),
|
||||
"clear allows recomputation",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_ownership_boundaries(failures: Array[String]) -> void:
|
||||
var classifier_source := FileAccess.get_file_as_string(CLASSIFIER_PATH)
|
||||
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
|
||||
_expect_true(
|
||||
loader_source.contains("M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT.new()"),
|
||||
"loader composes classifier",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
loader_source.count("_m2_runtime_mesh_rebuild_classifier.clear()"),
|
||||
2,
|
||||
"loader retains two classifier clear sites",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
loader_source.contains("_m2_runtime_rebuild_required_cache"),
|
||||
"loader removes legacy rebuild cache",
|
||||
failures
|
||||
)
|
||||
for removed_function in [
|
||||
"func _m2_raw_data_needs_runtime_mesh_rebuild",
|
||||
"func _m2_raw_data_has_billboards",
|
||||
"func _m2_raw_data_has_uv_rotation",
|
||||
]:
|
||||
_expect_false(loader_source.contains(removed_function), "loader removes %s" % removed_function, failures)
|
||||
for forbidden_dependency in [
|
||||
"ResourceLoader.",
|
||||
"WorkerThreadPool.",
|
||||
"M2_BUILDER_SCRIPT",
|
||||
"ClassDB.instantiate",
|
||||
"FileAccess.",
|
||||
]:
|
||||
_expect_false(
|
||||
classifier_source.contains(forbidden_dependency),
|
||||
"classifier omits %s dependency" % forbidden_dependency,
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_bounded_timing(failures: Array[String]) -> float:
|
||||
var classifier: RefCounted = CLASSIFIER_SCRIPT.new()
|
||||
var ordinary_data := {"batches": [{"has_billboard": false}]}
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
for iteration in range(100):
|
||||
for path_index in range(256):
|
||||
classifier.call(
|
||||
"needs_runtime_mesh_rebuild",
|
||||
"world/model_%d.m2" % path_index,
|
||||
ordinary_data
|
||||
)
|
||||
classifier.call("clear")
|
||||
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
|
||||
_expect_true(elapsed_milliseconds < 1000.0, "100 by 256 classifications under 1 second", failures)
|
||||
return elapsed_milliseconds
|
||||
|
||||
|
||||
func _classify(normalized_path: String, raw_data: Dictionary) -> bool:
|
||||
var classifier: RefCounted = CLASSIFIER_SCRIPT.new()
|
||||
return bool(classifier.call("needs_runtime_mesh_rebuild", normalized_path, raw_data))
|
||||
|
||||
|
||||
func _uv_data(
|
||||
texture_transforms: Array,
|
||||
texture_transform_combos: PackedInt32Array = PackedInt32Array([0]),
|
||||
texture_count: int = 1) -> Dictionary:
|
||||
return {
|
||||
"texture_transforms": texture_transforms,
|
||||
"texture_transform_combos": texture_transform_combos,
|
||||
"batches": [{
|
||||
"texture_count": texture_count,
|
||||
"texture_transform_combo_index": 0,
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
func _rotation_transform(
|
||||
rotation: Vector4 = Vector4(1.0, 0.0, 0.0, 1.0),
|
||||
rotation_speed: float = 0.0) -> Dictionary:
|
||||
return {
|
||||
"rotation": rotation,
|
||||
"rotation_speed": rotation_speed,
|
||||
}
|
||||
|
||||
|
||||
func _expect_true(condition: bool, label: String, failures: Array[String]) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _expect_false(condition: bool, label: String, failures: Array[String]) -> void:
|
||||
_expect_true(not condition, label, failures)
|
||||
|
||||
|
||||
func _expect_equal(actual: int, expected: int, label: String, failures: Array[String]) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s expected=%d actual=%d" % [label, expected, actual])
|
||||
@@ -0,0 +1 @@
|
||||
uid://cc62iwxjrkte6
|
||||
Reference in New Issue
Block a user