From 0a09d7bc1ea08904d855ef2619c89bbc4fa05b35 Mon Sep 17 00:00:00 2001 From: sindoring Date: Thu, 16 Jul 2026 23:51:59 +0400 Subject: [PATCH] refactor(M03): extract M2 placement transform resolver --- RENDER.md | 13 ++ docs/modules/README.md | 1 + .../m2-placement-transform-resolver.md | 184 +++++++++++++++ docs/modules/world-renderer.md | 14 +- .../m2/m2_placement_transform_resolver.gd | 110 +++++++++ .../m2/m2_placement_transform_resolver.gd.uid | 1 + .../streaming/streaming_world_loader.gd | 120 +++------- .../verify_m2_placement_transform_resolver.gd | 219 ++++++++++++++++++ ...ify_m2_placement_transform_resolver.gd.uid | 1 + 9 files changed, 573 insertions(+), 90 deletions(-) create mode 100644 docs/modules/m2-placement-transform-resolver.md create mode 100644 src/render/m2/m2_placement_transform_resolver.gd create mode 100644 src/render/m2/m2_placement_transform_resolver.gd.uid create mode 100644 src/tools/verify_m2_placement_transform_resolver.gd create mode 100644 src/tools/verify_m2_placement_transform_resolver.gd.uid diff --git a/RENDER.md b/RENDER.md index a3ff8c6..ae5ad8e 100644 --- a/RENDER.md +++ b/RENDER.md @@ -990,6 +990,19 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - M2/ADT cache formats, transforms, profiles and visible rules are unchanged. Asset-backed cross-boundary p95/p99 and visual acceptance remain required. +## 2026-07-16 M2 Placement Transform Resolver Extraction + +- `M2PlacementTransformResolver` now owns the pure ADT M2 basis and local + origin-compensation formulas used by worker grouping, placeholders and direct + instance creation. +- Regular models retain `Basis.from_euler`; `ElwynnCliffRock01/02` retain their + narrow shell correction; `NewWaterfall` and `ElwynnTallWaterfall01` retain + calibrated world yaw, local fall-axis twist and tall-sheet anchor compensation. +- Path separator/case/MDX handling, constants and caller-specific final scale + clamps are unchanged. The resolver owns no Node, task, cache or RID. +- Cache formats, placement positions and visible rules are unchanged by the + extraction. Asset-backed visual recheck and general placement parity remain gaps. + ## 2026-07-16 World Environment Snapshot Facade - `WorldEnvironmentSnapshot` carries one immutable finite time-of-day value, diff --git a/docs/modules/README.md b/docs/modules/README.md index a6e5903..69ff605 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -16,6 +16,7 @@ | Terrain chunk LOD planner | Implemented extraction | [`terrain-chunk-lod-planner.md`](terrain-chunk-lod-planner.md) | | Terrain chunk geometry queue planner | Implemented extraction | [`terrain-chunk-geometry-queue-planner.md`](terrain-chunk-geometry-queue-planner.md) | | M2 unique placement registry | Implemented extraction | [`m2-unique-placement-registry.md`](m2-unique-placement-registry.md) | +| M2 placement transform resolver | Implemented | [`m2-placement-transform-resolver.md`](m2-placement-transform-resolver.md) | | Third-person camera | Implemented | [`third-person-camera.md`](third-person-camera.md) | | Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.md) | | Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) | diff --git a/docs/modules/m2-placement-transform-resolver.md b/docs/modules/m2-placement-transform-resolver.md new file mode 100644 index 0000000..6e4f5e5 --- /dev/null +++ b/docs/modules/m2-placement-transform-resolver.md @@ -0,0 +1,184 @@ +# M2 Placement Transform Resolver + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented | +| Target/work package | M03 / `M03-RND-M2-PLACEMENT-TRANSFORM-001` | +| Owners | Pure ADT M2 placement basis and model-specific origin compensation | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-placement-transform`, 2026-07-16 | +| Profiles/capabilities | Existing static M2 paths; four calibrated model exceptions | + +## Purpose + +Resolve the unscaled Godot-space orientation and optional local origin offset +used by ADT M2 worker grouping, placeholder rendering and instance creation. + +## Non-goals + +- Convert WoW/server/ADT coordinates or own placement positions. +- Apply final scale clamps, build transforms, group placements or create Nodes. +- Add, generalize or configure model-specific corrections. +- Change M2 materials, animation, caches, visibility or quality profiles. +- Claim general build-12340 placement parity. + +## Context and boundaries + +```mermaid +flowchart LR + ADT[ADT placement rotation/path/scale] --> Resolver[M2PlacementTransformResolver] + Resolver --> Basis[Unscaled Basis] + Resolver --> Offset[Local origin offset] + Basis --> Group[Worker grouping] + Basis --> Placeholder[Placeholder MultiMesh path] + Basis --> Instance[Instance path] + Offset --> Group + Offset --> Placeholder + Offset --> Instance + Group --> Loader[StreamingWorldLoader-owned queues/build] +``` + +Allowed dependencies are `Vector3`, `Basis`, scalar math and path-string +normalization. Node, SceneTree, RenderingServer, ResourceLoader, tasks, caches, +files, gameplay, network and editor UI are forbidden. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `resolve_basis(rotation_radians, relative_m2_path)` | Pure query | Return unscaled regular or calibrated placement orientation | Worker/main thread; call-local result | Unknown paths use `Basis.from_euler` | +| `resolve_origin_offset(rotation_radians, relative_m2_path, scale_value)` | Pure query | Compensate the measured tall-waterfall twist anchor | Worker/main thread; call-local result | Regular/unanchored paths return zero; scale clamps to `0.0001` for compensation | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Godot Euler rotation in radians | ADT loader placement record | Resolver | Scalar copy | One call | +| Input | Relative M2 path | ADT MMDX/name table | Resolver allowlist | Caller string | One call | +| Input | Raw placement scale | ADT placement record | Offset compensation | Scalar copy | One call | +| Output | Unscaled `Basis` | Resolver | Group/placeholder/instance adapter | Value copy | One call | +| Output | Local `Vector3` offset | Resolver | Transform origin adapter | Value copy | One call | + +Callers preserve their historical basis scale minima: grouping/instance use +`0.0001`, while placeholders use `0.01`. + +## Data flow + +```mermaid +flowchart TD + BasisCall[resolve_basis] --> ModelName[Normalize separators/case/basename] + ModelName --> Waterfall{Waterfall sheet?} + Waterfall -->|yes| WaterfallBasis[+90 degree world yaw * Euler * local-axis twist] + Waterfall -->|no| Cliff{Elwynn cliff rock?} + Cliff -->|yes| CliffBasis[Corrected Y/BACK/RIGHT composition] + Cliff -->|no| Regular[Basis.from_euler] + OffsetCall[resolve_origin_offset] --> Anchored{Tall waterfall anchor?} + Anchored -->|no| Zero[Vector3.ZERO] + Anchored -->|yes| Compensation[(base anchor - twisted anchor) * clamped scale] +``` + +## Lifecycle/state + +The resolver is stateless. It may be shared between the main thread and worker +calls because each result is computed solely from value inputs. Construction, +world reset and shutdown require no clear, cancellation or resource release. + +## Main sequence + +```mermaid +sequenceDiagram + participant Loader as StreamingWorldLoader + participant Resolver as M2PlacementTransformResolver + participant Consumer as Group/placeholder/instance path + Loader->>Resolver: resolve_basis(rotation, path) + Resolver-->>Loader: unscaled Basis + Loader->>Resolver: resolve_origin_offset(rotation, path, scale) + Resolver-->>Loader: local offset + Loader->>Consumer: compose historical scaled Transform3D +``` + +## Ownership, threading and resources + +- The resolver retains no mutable state or engine resources. +- The loader owns placement records, final transforms, tasks, queues, caches, + MultiMesh/Node/Mesh/material/RID creation and cleanup. +- Pure methods are safe for current worker grouping and main-thread consumers. +- Returned `Basis`/`Vector3` values have no lifetime coupling to the resolver. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Empty/unknown path | Allowlist miss | Regular Euler basis and zero offset | Contract fixture | Expected fallback | +| Mixed case/backslashes/MDX | Normalization | Same calibrated model match | Contract fixture | None required | +| Zero/negative scale for anchored offset | Numeric clamp | Use `0.0001` compensation scale | Contract fixture | Caller may supply valid ADT scale later | +| World reset/shutdown | No retained state | No action | Existing shutdown verifier | New calls remain independent | + +There is no asynchronous operation or cancellation inside the resolver. + +## Configuration and capabilities + +The resolver introduces no settings. The preserved constants and allowlists are: + +- world yaw `PI` for `ElwynnCliffRock01/02`; +- world yaw `PI/2` for `NewWaterfall` and `ElwynnTallWaterfall01`; +- measured local axes/twist signs for both waterfall sheets; +- measured tall-waterfall anchor `(-2.667799, 89.62273, 0.00129)`. + +## Persistence, cache and migration + +No persistence, schema or cache format participates. Existing ADT/M2 cache +versions and rebuild instructions remain unchanged. + +## Diagnostics and observability + +The resolver emits no logs. The contract verifier compares formulas/constants, +checks all three loader consumers and records bounded synthetic timing. Visual +and asset-backed evidence remains in the renderer checkpoint workflow. + +## Verification + +- `verify_m2_placement_transform_resolver.gd`: regular fallback, path + normalization, both cliff rocks, both waterfalls, twist anchor, negative-scale + compensation, three loader adapters, source boundary and 10,000-pair timing. +- M2 unique/dedupe, terrain, facade, internal-access, manifest, shutdown, + scheduler, streaming and coordinate regressions remain required. +- Fidelity evidence is exact extraction of existing calibrated formulas. The + private asset/camera comparison that motivated them is not reproduced here. + +## Extension points + +- The next package may compose this resolver into a pure placement grouper. +- New model corrections require separate measured fidelity evidence; this module + is not a generic override registry. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Consistent transform rules across three M2 paths | Implemented | Formula/source/timing contract | Asset-backed visual recheck pending | +| General ADT M2 placement parity | Partial | Existing checkpoint notes | Broader fixture coverage required | + +## Known gaps and risks + +- Four model-specific exceptions are empirical compatibility rules. +- Most placements use raw Godot Euler behavior; independent client-wide + transform validation is incomplete. +- Callers intentionally retain different final scale clamps. +- Grouping, build, cache and render ownership remain in the streamer. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/m2/m2_placement_transform_resolver.gd` | Pure basis/offset rules and calibrated allowlists | +| `src/scenes/streaming/streaming_world_loader.gd` | Three adapters and final Transform3D/resource ownership | +| `src/tools/verify_m2_placement_transform_resolver.gd` | Formula, source and performance regression | + +## Related decisions and references + +- [`m2-unique-placement-registry.md`](m2-unique-placement-registry.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) diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 8f37cf5..de18a6e 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -5,9 +5,9 @@ | Field | Value | |---|---| | 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-RND-M2-UNIQUE-REGISTRY-001` | +| 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 unique/transform packages | | Owners | Renderer workstream / milestone integrator | -| Last verified | Worktree `work/sindo-main-codex/m03-m2-unique-registry`, 2026-07-16 | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-placement-transform`, 2026-07-16 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -48,6 +48,8 @@ flowchart LR ChunkQueue --> Loader Loader --> M2Registry[M2UniquePlacementRegistry] M2Registry --> Loader + Loader --> M2Transform[M2PlacementTransformResolver] + M2Transform --> Loader Native --> Parsed[Parsed tile/model data] Parsed --> Loader Loader --> Scene[SceneTree nodes] @@ -115,6 +117,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `TerrainChunkLodPlanner.plan` | Internal pure terrain query | Maps populated parsed chunk indices to desired LOD 0/1/2 | Synchronous per target refresh | Invalid/disabled input returns empty for existing tile-LOD fallback | | `TerrainChunkGeometryQueuePlanner.plan` | Internal pure terrain query | Produces chunk create/remove requests from current and desired state | Synchronous per queue rebuild | Invalid focus returns empty arrays | | `M2UniquePlacementRegistry.reserve/release/clear` | Internal M2 service | Owns positive cross-tile ADT placement-ID reservations | Renderer thread/map session | Invalid/unkeyed/non-owner inputs preserve documented fallback | +| `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 | Публичным contract не считаются `StreamingWorldLoader` methods/properties, внутренние dictionaries, queues, job records и generated node names. Scene-owned @@ -140,6 +143,7 @@ loader configuration remains transitional composition data, not a caller API. | Internal plan | Parsed chunks, typed focus and immutable chunk-LOD policy | Loader / `TerrainChunkLodPlanner` | Loader desired state | Fresh dictionary; no retained resources | One target refresh | | Internal plan | Current/desired chunk state and typed focus | Loader / `TerrainChunkGeometryQueuePlanner` | Loader chunk queues | Fresh request arrays; no retained resources | One queue rebuild | | Internal registry | Tile key and M2 placement unique IDs | Loader / `M2UniquePlacementRegistry` | Filtered grouping input and tile retry state | Registry-owned strings; fresh result arrays | Map session | +| Internal transform | Rotation/path/scale | Loader / `M2PlacementTransformResolver` | Group/placeholder/instance transforms | Value-only Basis/Vector3 | One placement | | Output | Desired tile/detail operations | Streamer plan application | Finalize queues | Loader-owned | Cross-frame | | Output | Terrain/M2/WMO/liquid instances | Loader/builders | Godot world/renderer | Loader/world owner | Main-thread attach | | Output | Detached renderer metrics | `WorldRenderFacade` | Capture/baseline tools | Caller-owned deep copy | On demand | @@ -185,6 +189,7 @@ flowchart TD ChunkQueue --> B R --> M2Registry[M2UniquePlacementRegistry reserve] M2Registry --> M2 + M2Transform[M2PlacementTransformResolver] --> M2 R --> B[RenderBudgetScheduler permits] B --> Terrain[Terrain attach/upgrade] B --> M2[M2 group/MultiMesh attach] @@ -299,6 +304,8 @@ sequenceDiagram - `M2UniquePlacementRegistry` owns only positive unique-key to tile-key strings. The loader retains owned/skipped tile arrays, candidate retry, grouping/build tasks, caches and all MultiMesh/Node/Mesh/material/RID side effects. +- `M2PlacementTransformResolver` is stateless and retains no resources. The + loader owns final transforms and every grouping/build/render side effect. - 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 @@ -384,6 +391,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m - M2 unique placement contract: unkeyed pass-through, within-call/cross-tile dedupe, owner release/retry, clear, detached diagnostics, loader boundary and bounded reserve/release timing without a world scene. +- M2 placement transform contract: regular/cliffrock/waterfall formulas, path + normalization, scale compensation, all three consumers and bounded timing. - Budget scheduler contract: exact lane exhaustion, shared chunk removal/create priority, independent lanes, frame reset, invalid limits, terminal cancellation, dependency boundaries and bounded permit timing without loading a world scene. @@ -414,6 +423,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | ADT streaming/terrain | Partial | M00 checkpoints and current scenes | Fidelity/performance gaps remain | | Static M2 placement | Partial | MultiMesh/dedupe probes | Full materials/animation/effects | | M2 unique placement registry | Implemented extraction | Scene-free ownership/lifecycle/timing contract and historical `uid:11785` smoke | Group/build/tasks/finalization and asset-backed p95/p99 remain pending | +| M2 placement transform resolver | Implemented extraction | Scene-free formula/source/timing contract across three consumers | Asset-backed visual recheck and general placement parity pending | | WMO rendering | Partial | Cached group rendering | Portals/rooms/material parity | | Liquids | Partial | MH2O/MLIQ paths | LiquidType/depth/shore fidelity | | Sky/light | Partial | DBC controller/logs | Zone transition paired validation | diff --git a/src/render/m2/m2_placement_transform_resolver.gd b/src/render/m2/m2_placement_transform_resolver.gd new file mode 100644 index 0000000..fe54e46 --- /dev/null +++ b/src/render/m2/m2_placement_transform_resolver.gd @@ -0,0 +1,110 @@ +class_name M2PlacementTransformResolver +extends RefCounted + +## Stateless ADT M2 placement orientation and origin-correction rules. + +const CLIFFROCK_WORLD_YAW_OFFSET := PI +const WATERFALL_WORLD_YAW_OFFSET := PI * 0.5 + + +## Resolves the unscaled Godot-space basis for one ADT M2 placement. +func resolve_basis(rotation_radians: Vector3, relative_m2_path: String = "") -> Basis: + if not _is_waterfall_path(relative_m2_path): + return _resolve_regular_basis(rotation_radians, relative_m2_path) + + var placement_basis := Basis.from_euler(rotation_radians) + return ( + Basis(Vector3.UP, WATERFALL_WORLD_YAW_OFFSET) + * placement_basis + * Basis( + _resolve_waterfall_local_fall_axis(relative_m2_path), + _resolve_waterfall_sheet_twist(relative_m2_path) + ) + ) + + +## Resolves local origin compensation for a twisted waterfall sheet. Regular +## models and waterfall models without a measured anchor return zero. +func resolve_origin_offset( + rotation_radians: Vector3, + relative_m2_path: String, + scale_value: float +) -> Vector3: + if not _is_waterfall_path(relative_m2_path): + return Vector3.ZERO + var twist_radians := _resolve_waterfall_sheet_twist(relative_m2_path) + if is_zero_approx(twist_radians): + return Vector3.ZERO + var anchor_local_position := _resolve_waterfall_sheet_twist_anchor(relative_m2_path) + if anchor_local_position == Vector3.ZERO: + return Vector3.ZERO + var placement_basis := ( + Basis(Vector3.UP, WATERFALL_WORLD_YAW_OFFSET) + * Basis.from_euler(rotation_radians) + ) + var twisted_basis := placement_basis * Basis( + _resolve_waterfall_local_fall_axis(relative_m2_path), + twist_radians + ) + return ( + placement_basis * anchor_local_position + - twisted_basis * anchor_local_position + ) * maxf(scale_value, 0.0001) + + +func _resolve_regular_basis(rotation_radians: Vector3, relative_m2_path: String) -> Basis: + if not _is_elwynn_cliffrock_path(relative_m2_path): + return Basis.from_euler(rotation_radians) + return ( + Basis(Vector3.UP, rotation_radians.y + CLIFFROCK_WORLD_YAW_OFFSET) + * Basis(Vector3.BACK, rotation_radians.x) + * Basis(Vector3.RIGHT, -rotation_radians.z) + ) + + +func _is_waterfall_path(relative_m2_path: String) -> bool: + var model_name := _normalized_model_name(relative_m2_path) + return model_name == "newwaterfall" or model_name == "elwynntallwaterfall01" + + +func _is_elwynn_cliffrock_path(relative_m2_path: String) -> bool: + var model_name := _normalized_model_name(relative_m2_path) + return model_name == "elwynncliffrock01" or model_name == "elwynncliffrock02" + + +func _normalized_model_name(relative_m2_path: String) -> String: + return relative_m2_path.replace("\\", "/").to_lower().get_file().get_basename() + + +func _resolve_waterfall_local_fall_axis(relative_m2_path: String) -> Vector3: + var normalized_path := relative_m2_path.replace("\\", "/").to_lower() + if normalized_path.ends_with("newwaterfall.m2") or normalized_path.ends_with("newwaterfall.mdx"): + return Vector3(-0.138742, 0.990329, 0.0).normalized() + if ( + normalized_path.ends_with("elwynntallwaterfall01.m2") + or normalized_path.ends_with("elwynntallwaterfall01.mdx") + ): + return Vector3(-0.068962, 0.997619, 0.0).normalized() + return Vector3.UP + + +func _resolve_waterfall_sheet_twist(relative_m2_path: String) -> float: + var normalized_path := relative_m2_path.replace("\\", "/").to_lower() + if normalized_path.ends_with("newwaterfall.m2") or normalized_path.ends_with("newwaterfall.mdx"): + return PI * 0.5 + if ( + normalized_path.ends_with("elwynntallwaterfall01.m2") + or normalized_path.ends_with("elwynntallwaterfall01.mdx") + ): + return -PI * 0.5 + return 0.0 + + +func _resolve_waterfall_sheet_twist_anchor(relative_m2_path: String) -> Vector3: + var normalized_path := relative_m2_path.replace("\\", "/").to_lower() + if ( + normalized_path.ends_with("elwynntallwaterfall01.m2") + or normalized_path.ends_with("elwynntallwaterfall01.mdx") + ): + return Vector3(-2.667799, 89.62273, 0.00129) + return Vector3.ZERO diff --git a/src/render/m2/m2_placement_transform_resolver.gd.uid b/src/render/m2/m2_placement_transform_resolver.gd.uid new file mode 100644 index 0000000..717870a --- /dev/null +++ b/src/render/m2/m2_placement_transform_resolver.gd.uid @@ -0,0 +1 @@ +uid://bcnmp7y8ph3qa diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 7e4478f..2fa0ed1 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -27,6 +27,9 @@ const TERRAIN_CHUNK_GEOMETRY_QUEUE_PLANNER_SCRIPT := preload( const M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT := preload( "res://src/render/m2/m2_unique_placement_registry.gd" ) +const M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT := preload( + "res://src/render/m2/m2_placement_transform_resolver.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") @@ -39,8 +42,6 @@ const RENDER_GROUND_QUERY_RAY_HEIGHT_UNITS := 5000.0 const TILE_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS -const ADT_CLIFFROCK_WORLD_YAW_OFFSET := PI -const ADT_WATERFALL_WORLD_YAW_OFFSET := PI * 0.5 const QUALITY_CUSTOM := "Custom" const QUALITY_PERFORMANCE := "Performance" const QUALITY_BALANCED := "Balanced" @@ -213,6 +214,7 @@ var _m2_build_queue: Array = [] var _m2_unique_placement_registry := ( M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT.new() ) +var _m2_placement_transform_resolver := M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new() var _m2_mesh_cache: Dictionary = {} var _m2_mesh_load_requests: Dictionary = {} var _m2_mesh_finalize_queue: Array = [] @@ -3978,86 +3980,6 @@ func _rebuild_cached_wmo_material(material: Material) -> Material: return WMO_BUILDER_SCRIPT._build_material(mat_def, textures, extracted_dir) -func _adt_m2_placement_basis(rot: Vector3, rel_path: String = "") -> Basis: - if not _is_waterfall_m2_path(rel_path): - return _adt_m2_regular_placement_basis(rot, rel_path) - - # ADTLoader stores MDDF yaw as raw_yaw - 90 degrees for historical Godot - # facing alignment. In our tile-positive ADT world, waterfall models need a - # world-space +90 degree yaw to anchor their top to the source pool. Twist - # the sheet around its own local fall axis afterwards, so the anchor stays in - # place but the alpha plane is not rendered edge-on. - var basis := Basis.from_euler(rot) - return ( - Basis(Vector3.UP, ADT_WATERFALL_WORLD_YAW_OFFSET) - * basis - * Basis(_waterfall_local_fall_axis(rel_path), _waterfall_sheet_twist(rel_path)) - ) - - -func _adt_m2_regular_placement_basis(rot: Vector3, rel_path: String = "") -> Basis: - if not _is_elwynn_cliffrock_m2_path(rel_path): - return Basis.from_euler(rot) - - # Elwynn cliff rock M2s are open-backed shell meshes. Plain Godot Euler - # points their hollow side out of the cliff at waterfall placements. Keep the - # correction model-specific so normal props retain the native ADT rotation. - return ( - Basis(Vector3.UP, rot.y + ADT_CLIFFROCK_WORLD_YAW_OFFSET) - * Basis(Vector3.BACK, rot.x) - * Basis(Vector3.RIGHT, -rot.z) - ) - - -func _adt_m2_placement_origin_offset(rot: Vector3, rel_path: String, scale_value: float) -> Vector3: - if not _is_waterfall_m2_path(rel_path): - return Vector3.ZERO - var twist := _waterfall_sheet_twist(rel_path) - if is_zero_approx(twist): - return Vector3.ZERO - var anchor := _waterfall_sheet_twist_anchor(rel_path) - if anchor == Vector3.ZERO: - return Vector3.ZERO - var base := Basis(Vector3.UP, ADT_WATERFALL_WORLD_YAW_OFFSET) * Basis.from_euler(rot) - var twisted := base * Basis(_waterfall_local_fall_axis(rel_path), twist) - return (base * anchor - twisted * anchor) * maxf(scale_value, 0.0001) - - -func _is_waterfall_m2_path(rel_path: String) -> bool: - var name := rel_path.replace("\\", "/").to_lower().get_file().get_basename() - return name == "newwaterfall" or name == "elwynntallwaterfall01" - - -func _is_elwynn_cliffrock_m2_path(rel_path: String) -> bool: - var name := rel_path.replace("\\", "/").to_lower().get_file().get_basename() - return name == "elwynncliffrock01" or name == "elwynncliffrock02" - - -func _waterfall_local_fall_axis(rel_path: String) -> Vector3: - var normalized := rel_path.replace("\\", "/").to_lower() - if normalized.ends_with("newwaterfall.m2") or normalized.ends_with("newwaterfall.mdx"): - return Vector3(-0.138742, 0.990329, 0.0).normalized() - if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"): - return Vector3(-0.068962, 0.997619, 0.0).normalized() - return Vector3.UP - - -func _waterfall_sheet_twist(rel_path: String) -> float: - var normalized := rel_path.replace("\\", "/").to_lower() - if normalized.ends_with("newwaterfall.m2") or normalized.ends_with("newwaterfall.mdx"): - return PI * 0.5 - if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"): - return -PI * 0.5 - return 0.0 - - -func _waterfall_sheet_twist_anchor(rel_path: String) -> Vector3: - var normalized := rel_path.replace("\\", "/").to_lower() - if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"): - return Vector3(-2.667799, 89.62273, 0.00129) - return Vector3.ZERO - - func _cancel_wmo_build_job(tile_key: String) -> void: if _wmo_build_jobs.has(tile_key): _wmo_build_jobs.erase(tile_key) @@ -4216,8 +4138,12 @@ func _group_tile_m2_task( var pos: Vector3 = world_pos - tile_origin var rot: Vector3 = placement.get("rot", Vector3.ZERO) var scale_value: float = float(placement.get("scale", 1.0)) - var basis := _adt_m2_placement_basis(rot, normalized) - var offset := _adt_m2_placement_origin_offset(rot, normalized, scale_value) + var basis: Basis = _m2_placement_transform_resolver.resolve_basis(rot, normalized) + var offset: Vector3 = _m2_placement_transform_resolver.resolve_origin_offset( + rot, + normalized, + scale_value + ) var xform := Transform3D(basis.scaled(Vector3.ONE * maxf(scale_value, 0.0001)), pos + offset) if not groups.has(normalized): @@ -4905,8 +4831,18 @@ func _build_tile_m2_placeholders(tile_root: Node3D, tile_origin: Vector3, m2_nam var local_pos: Vector3 = placement.get("pos", Vector3.ZERO) - tile_origin var rot: Vector3 = placement.get("rot", Vector3.ZERO) var scale_value: float = float(placement.get("scale", 1.0)) - var xform_basis := _adt_m2_placement_basis(rot, rel_path).scaled(Vector3.ONE * maxf(scale_value, 0.01)) - var xf := Transform3D(xform_basis, local_pos + _adt_m2_placement_origin_offset(rot, rel_path, scale_value)) + var xform_basis := _m2_placement_transform_resolver.resolve_basis( + rot, + rel_path + ).scaled(Vector3.ONE * maxf(scale_value, 0.01)) + var xf := Transform3D( + xform_basis, + local_pos + _m2_placement_transform_resolver.resolve_origin_offset( + rot, + rel_path, + scale_value + ) + ) if is_tree: tree_transforms.append(xf) else: @@ -4956,8 +4892,16 @@ func _instantiate_m2(rel_path: String, placement: Dictionary, tile_origin: Vecto var rot: Vector3 = placement.get("rot", Vector3.ZERO) var scale_value: float = float(placement.get("scale", 1.0)) node.transform = Transform3D( - _adt_m2_placement_basis(rot, rel_path).scaled(Vector3.ONE * maxf(scale_value, 0.0001)), - local_pos + _adt_m2_placement_origin_offset(rot, rel_path, scale_value)) + _m2_placement_transform_resolver.resolve_basis( + rot, + rel_path + ).scaled(Vector3.ONE * maxf(scale_value, 0.0001)), + local_pos + _m2_placement_transform_resolver.resolve_origin_offset( + rot, + rel_path, + scale_value + ) + ) return node diff --git a/src/tools/verify_m2_placement_transform_resolver.gd b/src/tools/verify_m2_placement_transform_resolver.gd new file mode 100644 index 0000000..6fff9cb --- /dev/null +++ b/src/tools/verify_m2_placement_transform_resolver.gd @@ -0,0 +1,219 @@ +extends SceneTree + +## Asset-free formula, dependency and timing regression for ADT M2 transforms. + +const RESOLVER_SCRIPT := preload("res://src/render/m2/m2_placement_transform_resolver.gd") +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" +const WATERFALL_WORLD_YAW_OFFSET := PI * 0.5 +const CLIFFROCK_WORLD_YAW_OFFSET := PI + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_regular_basis(failures) + _verify_cliffrock_basis(failures) + _verify_new_waterfall_basis(failures) + _verify_tall_waterfall_basis_and_offset(failures) + _verify_path_normalization_and_scale_clamp(failures) + _verify_loader_boundary(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("M2_PLACEMENT_TRANSFORM_RESOLVER: %s" % failure) + quit(1) + return + print( + "M2_PLACEMENT_TRANSFORM_RESOLVER PASS cases=9 iterations=10000 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_regular_basis(failures: Array[String]) -> void: + var resolver: RefCounted = RESOLVER_SCRIPT.new() + var rotation := Vector3(0.2, -0.7, 1.1) + _expect_basis_near( + resolver.call("resolve_basis", rotation, "World/Generic/Tree.m2"), + Basis.from_euler(rotation), + "regular basis", + failures + ) + _expect_vector_near( + resolver.call("resolve_origin_offset", rotation, "World/Generic/Tree.m2", 3.0), + Vector3.ZERO, + "regular offset", + failures + ) + + +func _verify_cliffrock_basis(failures: Array[String]) -> void: + var resolver: RefCounted = RESOLVER_SCRIPT.new() + var rotation := Vector3(0.3, 0.4, -0.2) + var expected_basis := ( + Basis(Vector3.UP, rotation.y + CLIFFROCK_WORLD_YAW_OFFSET) + * Basis(Vector3.BACK, rotation.x) + * Basis(Vector3.RIGHT, -rotation.z) + ) + _expect_basis_near( + resolver.call("resolve_basis", rotation, "WORLD\\AZEROTH\\ElwynnCliffRock01.MDX"), + expected_basis, + "cliffrock basis", + failures + ) + + +func _verify_new_waterfall_basis(failures: Array[String]) -> void: + var resolver: RefCounted = RESOLVER_SCRIPT.new() + var rotation := Vector3(-0.1, 0.8, 0.2) + var fall_axis := Vector3(-0.138742, 0.990329, 0.0).normalized() + var expected_basis := ( + Basis(Vector3.UP, WATERFALL_WORLD_YAW_OFFSET) + * Basis.from_euler(rotation) + * Basis(fall_axis, PI * 0.5) + ) + _expect_basis_near( + resolver.call("resolve_basis", rotation, "world/x/NewWaterfall.m2"), + expected_basis, + "new waterfall basis", + failures + ) + _expect_vector_near( + resolver.call("resolve_origin_offset", rotation, "world/x/NewWaterfall.m2", 1.0), + Vector3.ZERO, + "new waterfall offset", + failures + ) + + +func _verify_tall_waterfall_basis_and_offset(failures: Array[String]) -> void: + var resolver: RefCounted = RESOLVER_SCRIPT.new() + var rotation := Vector3(0.05, -0.2, 0.15) + var path := "world/x/ElwynnTallWaterfall01.m2" + var fall_axis := Vector3(-0.068962, 0.997619, 0.0).normalized() + var placement_basis := ( + Basis(Vector3.UP, WATERFALL_WORLD_YAW_OFFSET) * Basis.from_euler(rotation) + ) + var expected_basis := placement_basis * Basis(fall_axis, -PI * 0.5) + var anchor := Vector3(-2.667799, 89.62273, 0.00129) + var expected_offset := (placement_basis * anchor - expected_basis * anchor) * 1.5 + _expect_basis_near( + resolver.call("resolve_basis", rotation, path), + expected_basis, + "tall waterfall basis", + failures + ) + _expect_vector_near( + resolver.call("resolve_origin_offset", rotation, path, 1.5), + expected_offset, + "tall waterfall offset", + failures + ) + + +func _verify_path_normalization_and_scale_clamp(failures: Array[String]) -> void: + var resolver: RefCounted = RESOLVER_SCRIPT.new() + var path := "WORLD\\X\\ELWYNNTALLWATERFALL01.MDX" + var minimum_offset: Vector3 = resolver.call("resolve_origin_offset", Vector3.ZERO, path, 0.0001) + _expect_vector_near( + resolver.call("resolve_origin_offset", Vector3.ZERO, path, -9.0), + minimum_offset, + "negative scale clamp", + failures + ) + _expect_basis_near( + resolver.call("resolve_basis", Vector3.ZERO, "ElwynnCliffRock02.m2"), + Basis(Vector3.UP, PI), + "second cliffrock", + failures + ) + + +func _verify_loader_boundary(failures: Array[String]) -> void: + var source := _read_text(LOADER_PATH, failures) + _expect_true( + source.contains("M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()"), + "loader composes resolver", + failures + ) + _expect_equal( + source.count("_m2_placement_transform_resolver.resolve_basis("), + 3, + "three basis adapters", + failures + ) + _expect_equal( + source.count("_m2_placement_transform_resolver.resolve_origin_offset("), + 3, + "three offset adapters", + failures + ) + _expect_true( + not source.contains("func _adt_m2_placement_basis("), + "loader omits basis formula", + failures + ) + _expect_true( + not source.contains("func _waterfall_sheet_twist("), + "loader omits waterfall formula", + failures + ) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var resolver: RefCounted = RESOLVER_SCRIPT.new() + var rotation := Vector3(0.1, 0.2, 0.3) + var started_microseconds := Time.get_ticks_usec() + for iteration in range(10000): + var path := ( + "world/x/ElwynnTallWaterfall01.m2" + if iteration % 4 == 0 + else "world/generic/Tree.m2" + ) + resolver.call("resolve_basis", rotation, path) + resolver.call("resolve_origin_offset", rotation, path, 1.0) + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_true(elapsed_milliseconds < 1000.0, "10000 transform pairs bounded", failures) + return elapsed_milliseconds + + +func _read_text(path: String, failures: Array[String]) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("cannot open %s" % path) + return "" + return file.get_as_text() + + +func _expect_basis_near( + actual_basis: Basis, + expected_basis: Basis, + label: String, + failures: Array[String] +) -> void: + if not actual_basis.is_equal_approx(expected_basis): + failures.append("%s basis mismatch" % label) + + +func _expect_vector_near( + actual_value: Vector3, + expected_value: Vector3, + label: String, + failures: Array[String] +) -> void: + if not actual_value.is_equal_approx(expected_value): + failures.append("%s expected %s, got %s" % [label, expected_value, actual_value]) + + +func _expect_equal( + actual_value: int, + expected_value: int, + label: String, + failures: Array[String] +) -> void: + if actual_value != expected_value: + failures.append("%s expected %d, got %d" % [label, expected_value, actual_value]) + + +func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void: + if not actual_value: + failures.append("%s expected true" % label) diff --git a/src/tools/verify_m2_placement_transform_resolver.gd.uid b/src/tools/verify_m2_placement_transform_resolver.gd.uid new file mode 100644 index 0000000..bceb8b4 --- /dev/null +++ b/src/tools/verify_m2_placement_transform_resolver.gd.uid @@ -0,0 +1 @@ +uid://b4a7gtg46jrdn