diff --git a/RENDER.md b/RENDER.md index 5d9d717..eaa405e 100644 --- a/RENDER.md +++ b/RENDER.md @@ -39,6 +39,7 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g - `src/render/m2/m2_animated_scene_finalizer.gd` - animated PackedScene candidate ownership, historical material repair and AnimationPlayer validation. - `src/render/m2/m2_animation_playback_controller.gd` - deterministic per-instance phase, default animation selection and native/imported playback mutation. - `src/render/m2/m2_animated_instance_materializer.gd` - main-thread animated instance duplication, render settings, playback startup and non-empty batch attachment. +- `src/render/m2/m2_static_batch_materializer.gd` - main-thread static M2 MultiMesh construction, render settings and attachment. - `src/render/m2/m2_animation_load_pipeline_state.gd` - animated M2 threaded-load request records and completion-order finalize FIFO without I/O or Node ownership. - `src/render/m2/m2_mesh_load_pipeline_state.gd` - static M2 threaded-load request records, terminal statuses and completion-order finalize FIFO without I/O or Mesh ownership. - `src/render/m2/m2_mesh_resource_cache_state.gd` - normalized-path prepared static M2 Mesh references with final-shutdown lifetime. diff --git a/docs/modules/README.md b/docs/modules/README.md index 3efaf78..760aee7 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -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 static batch materializer | Implemented extraction | [`m2-static-batch-materializer.md`](m2-static-batch-materializer.md) | | M2 runtime mesh rebuild classifier | Implemented extraction | [`m2-runtime-mesh-rebuild-classifier.md`](m2-runtime-mesh-rebuild-classifier.md) | | M2 animated scene finalizer | Implemented extraction | [`m2-animated-scene-finalizer.md`](m2-animated-scene-finalizer.md) | | M2 animation playback controller | Implemented extraction | [`m2-animation-playback-controller.md`](m2-animation-playback-controller.md) | diff --git a/docs/modules/m2-build-batch-planner.md b/docs/modules/m2-build-batch-planner.md index 87ce0eb..2f39c79 100644 --- a/docs/modules/m2-build-batch-planner.md +++ b/docs/modules/m2-build-batch-planner.md @@ -174,7 +174,8 @@ queue depth, build activity and hitch observability. | Path | Responsibility | |---|---| | `src/render/m2/m2_build_batch_planner.gd` | Pure limit/count/cursor planning | -| `src/scenes/streaming/streaming_world_loader.gd` | Build job, queue, resources, instance materialization and budgets | +| `src/scenes/streaming/streaming_world_loader.gd` | Build job, queue, resource readiness, materializer adapters and budgets | +| `src/render/m2/m2_static_batch_materializer.gd` | Planned static-slice MultiMesh construction and attachment | | `src/render/m2/m2_animation_playback_controller.gd` | Animated-instance phase/selection/native/imported playback | | `src/render/m2/m2_animated_instance_materializer.gd` | Planned animated-slice duplication, playback startup and attachment | | `src/render/m2/m2_placement_grouper.gd` | Produces grouped transform arrays | @@ -184,6 +185,7 @@ queue depth, build activity and hitch observability. - [`m2-placement-grouper.md`](m2-placement-grouper.md) - [`m2-placement-transform-resolver.md`](m2-placement-transform-resolver.md) +- [`m2-static-batch-materializer.md`](m2-static-batch-materializer.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/m2-mesh-resource-cache-state.md b/docs/modules/m2-mesh-resource-cache-state.md index 19ad994..c89b659 100644 --- a/docs/modules/m2-mesh-resource-cache-state.md +++ b/docs/modules/m2-mesh-resource-cache-state.md @@ -36,7 +36,7 @@ flowchart LR IO --> Extract[M2MeshResourceExtractor] Extract --> Prepare[M2RuntimeMeshFinalizer] Prepare --> Cache - Loader --> Build[M2 MultiMesh materialization] + Loader --> Build[M2StaticBatchMaterializer] ``` The cache may retain `Mesh` resources and copied normalized-path Strings. It has @@ -118,7 +118,8 @@ flowchart TB Loader[StreamingWorldLoader] --> Cache[M2MeshResourceCacheState] Loader --> Pipeline[M2MeshLoadPipelineState] Loader --> Finalizer[M2RuntimeMeshFinalizer] - Loader --> Builder[M2Builder and MultiMesh] + Loader --> Builder[M2Builder] + Loader --> Materializer[M2StaticBatchMaterializer] Cache --> Mesh[Godot Mesh Resource] Cache -. no dependency .-> Pipeline Cache -. no dependency .-> Classifier @@ -132,7 +133,8 @@ flowchart TB - Borrowed Mesh lookups do not transfer ownership or duplicate resources. - `M2MeshResourceExtractor` owns first-Mesh selection and temporary PackedScene instances. `M2PrototypeCacheState` owns missing/prototype/animated state; the - loader owns MultiMeshes and adoption decisions. + static materializer owns MultiMesh construction/attachment; the loader owns + resource adoption and build-job decisions. - The loader drains asynchronous work before the final cache clear. ## Errors, cancellation and recovery diff --git a/docs/modules/m2-static-batch-materializer.md b/docs/modules/m2-static-batch-materializer.md new file mode 100644 index 0000000..f6531fb --- /dev/null +++ b/docs/modules/m2-static-batch-materializer.md @@ -0,0 +1,224 @@ +# M2 Static Batch Materializer + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented | +| Target/work package | M03 / `M03-RND-M2-STATIC-BATCH-MATERIALIZER-001` | +| Owners | Static M2 MultiMesh construction, render settings and attachment | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-static-batch-materializer`, 2026-07-18 | +| Profiles/capabilities | Existing static M2 placement path | + +## Purpose + +Build and attach one static M2 `MultiMeshInstance3D` from an already prepared +Mesh and an ordered transform slice. The service preserves the existing +transform format, Mesh identity, batch name, visibility and shadow behavior. + +## Non-goals + +- Group placements, size batches or advance build-job cursors. +- Load, refresh, build or cache Mesh resources. +- Consume render permits, rotate queues or decide retries/cancellation. +- Assign Editor owners or release tile roots. +- Materialize animated instances or introduce spatial-cell batching. + +## Context and boundaries + +```mermaid +flowchart LR + Groups[Ordered transform group] --> Loader[StreamingWorldLoader build adapter] + MeshCache[Prepared Mesh cache] --> Loader + Planner[M2BuildBatchPlanner] --> Slice[Start and count] + Slice --> Loader + Loader --> Materializer[M2StaticBatchMaterializer] + Materializer --> Batch[Attached MultiMeshInstance3D] + Loader --> EditorOwner[Editor owner adapter] +``` + +Allowed dependencies are Godot `Mesh`, `MultiMesh`, `MultiMeshInstance3D`, +`GeometryInstance3D` and main-thread SceneTree APIs. Files, ResourceLoader, +workers, RenderingServer calls, caches, build jobs, scheduler policy, gameplay, +network and Editor ownership are forbidden. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `materialize_batch(parent, path, mesh, transforms, start, count, serial, visibility_end, visibility_margin, cast_shadows)` | Command/query | Create, configure and attach one static MultiMesh batch | Main thread; borrowed inputs, parent owns result | Invalid/empty input returns null; transform bounds are a caller precondition | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | M2 parent root | Loader build job | Batch attachment | Borrowed Node3D | Tile/job lifetime | +| Input | Relative model path and serial | Loader build adapter | Batch node name | Copied values | One call | +| Input | Prepared static Mesh | M2 Mesh resource cache/finalizer | MultiMesh | Borrowed Resource; exact reference retained by MultiMesh | Cache and batch lifetimes | +| Input | Ordered transforms, start and count | Placement group and batch plan | MultiMesh transform buffer | Borrowed Array/scalars | One call | +| Input | Visibility end/margin and shadow flag | Renderer configuration | MultiMeshInstance3D properties | Copied scalars | One call | +| Output | Attached `MultiMeshInstance3D` | Materializer | Parent SceneTree and loader Editor-owner adapter | Parent-owned node/resource | Tile lifetime | + +Side effects are MultiMesh allocation, transform-buffer mutation, +MultiMeshInstance3D allocation/configuration and SceneTree attachment. The +service retains no direct reference after return. + +## Data flow + +```mermaid +flowchart TD + Validate[Validate parent, Mesh, transforms and count] --> MultiMesh[Allocate TRANSFORM_3D MultiMesh] + MultiMesh --> MeshIdentity[Assign exact prepared Mesh] + MeshIdentity --> Count[Set instance count] + Count --> Upload[Upload transforms start plus ordered batch offset] + Upload --> Node[Create named MultiMeshInstance3D] + Node --> Render[Apply shadow and optional visibility end/margin] + Render --> Attach[Attach once to supplied parent] + Attach --> Return[Return attached node] + Validate -->|invalid| Null[Return null without attachment] +``` + +## Lifecycle/state + +```mermaid +stateDiagram-v2 + [*] --> Validating + Validating --> Rejected: invalid or empty input + Validating --> Building: valid slice + Building --> Configured: transforms and render properties set + Configured --> Attached: parent add_child + Rejected --> [*] + Attached --> [*]: parent/tile owner releases subtree +``` + +The `RefCounted` service is stateless; states describe a single call. + +## Main sequence + +```mermaid +sequenceDiagram + participant L as StreamingWorldLoader + participant P as M2BuildBatchPlanner + participant M as M2StaticBatchMaterializer + participant R as M2 parent root + L->>P: plan_batch(transform count, offset, static limits) + P-->>L: start/count and cursor transition + L->>L: resolve prepared Mesh or rotate pending job + L->>M: materialize_batch(parent, Mesh, transform slice, render settings) + M->>M: allocate MultiMesh and upload ordered transforms + M->>R: add_child(MultiMeshInstance3D) + M-->>L: attached node + L->>L: assign optional Editor owner +``` + +## Dependency diagram + +```mermaid +flowchart TB + Loader[StreamingWorldLoader] --> Materializer[M2StaticBatchMaterializer] + Planner[M2BuildBatchPlanner] --> Loader + MeshState[M2MeshResourceCacheState] --> Loader + Materializer --> Engine[Mesh / MultiMesh / MultiMeshInstance3D] + Loader --> EditorOwner[Editor ownership] + Loader --> Scheduler[RenderBudgetScheduler] + Materializer -. no dependency .-> ResourceIO[ResourceLoader / files] + Materializer -. no dependency .-> BuildState[Build jobs / queues / caches] +``` + +## Ownership, threading and resources + +- The caller borrows a prepared Mesh and transform Array for the synchronous call. +- The new MultiMesh retains the exact Mesh Resource reference. +- The new MultiMeshInstance3D retains the MultiMesh and transfers to the parent. +- Parent/tile teardown owns node and Resource release through the SceneTree. +- Loader assigns an Editor owner after successful attachment when configured. +- All Resource and SceneTree mutation is main-thread-only. +- No RID is acquired or released directly by this service. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Null parent or Mesh | Entry guard | Return null without allocation/attachment | Synthetic contract fixture | Correct build composition/cache state | +| Empty transforms or non-positive count | Entry guard | Return null | Synthetic contract fixture | Planner supplies non-empty slice | +| Invalid transform bounds | Caller precondition | Historical indexed access behavior remains | Build planner/source regression | Repair job cursor/group state | +| Mesh pending | Outside service | Loader rotates job without calling service | Existing build/cache diagnostics | Async completion retries job | +| Tile cancelled | Outside service | Loader cancels before materialization or releases parent later | Shutdown regression | Eligible tile may requeue | + +The service does not swallow invalid bounds or silently clamp/reorder transforms, +because doing so would change the accepted build-planner contract. + +## Configuration and capabilities + +| Setting/capability | Default | Profile | Runtime mutable | Effect | +|---|---|---|---|---| +| Transform format | `MultiMesh.TRANSFORM_3D` | All | No | Preserves full 3D M2 placements | +| Static batch count | Loader `m2_multimesh_batch_size` through planner | Quality preset | Yes, between plans | Controls per-batch transform count | +| Visibility end/margin | Loader M2 range / chunk size | Quality preset | Yes, between calls | Applied only when end is positive | +| Shadow mode | Loader `m2_cast_shadows` | Quality preset | Yes, between calls | Selects ON/OFF casting setting | + +## Persistence, cache and migration + +No state is serialized and no cache/schema version changes. The service consumes +the accepted prepared Mesh cache contract; no rebake or migration is required. + +## Diagnostics and observability + +- The service emits no logs, metrics or debug views. +- Existing loader queue/hitch metrics continue to account for `m2build` work. +- Path/serial remain visible through the unchanged batch node name. +- Editor ownership remains observable through the existing loader adapter. + +## Verification + +- `verify_m2_static_batch_materializer.gd` covers invalid inputs, node naming, + exact Mesh identity, transform format/count, ordered slice source contract, + visibility, shadows, single attachment, ownership boundaries and timing. +- Build planner, Mesh cache/extractor/finalizer, prototype, animated materializer, + shutdown, materials, facade and internal-access regressions protect neighbors. +- The headless dummy renderer does not round-trip per-instance transforms through + `MultiMesh.get_instance_transform`; exact ordered upload is protected by the + source contract and render checkpoints remain the observable regression gate. +- Fidelity evidence is exact extraction only; no new build-12340 parity claim. +- Performance budget: 10,000 transform API writes under one second in headless. + +## Extension points + +Measured spatial-cell batching may later provide smaller transform slices while +reusing this materializer. A generic static/animated base class, async mutation +and callback framework are intentionally excluded. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Static M2 MultiMesh materialization | Implemented extraction | Synthetic node/resource/render/source/timing verifier | Asset-backed traversal pending | +| Transform ordering | Implemented extraction | Exact source contract and existing checkpoints | Headless per-instance getter unavailable | +| Editor persistence | Existing loader-owned | Source boundary and editor helper | Editor integration fixture remains separate | +| Spatial-cell batching | Planned | Renderer roadmap | Culling/performance design and evidence pending | + +## Known gaps and risks + +- Materialization remains synchronous main-thread work under the existing permit. +- MultiMesh transform upload timing in a headless dummy renderer does not measure GPU cost. +- No private asset traversal, visual comparison, descriptor-pressure, leak or p95/p99 run exists. +- Current model-path batching is not culling-driven spatial-cell batching. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/m2/m2_static_batch_materializer.gd` | Static MultiMesh allocation, transform upload, render setup and attachment | +| `src/render/m2/m2_build_batch_planner.gd` | Static/animated batch count and cursor planning | +| `src/render/m2/m2_mesh_resource_cache_state.gd` | Prepared static Mesh reference ownership | +| `src/scenes/streaming/streaming_world_loader.gd` | Resource readiness, job/budget adapter and Editor ownership | +| `src/tools/verify_m2_static_batch_materializer.gd` | Node/resource/render/source/timing regression | + +## Related decisions and references + +- [`m2-build-batch-planner.md`](m2-build-batch-planner.md) +- [`m2-mesh-resource-cache-state.md`](m2-mesh-resource-cache-state.md) +- [`m2-animated-instance-materializer.md`](m2-animated-instance-materializer.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 7f79f97..24d4bae 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -54,6 +54,8 @@ flowchart LR M2Transform --> Loader Loader --> M2Batch[M2BuildBatchPlanner] M2Batch --> Loader + Loader --> M2Static[M2StaticBatchMaterializer] + M2Static --> Scene Loader --> WmoPlacement[WmoPlacementResolver] WmoPlacement --> Loader Loader --> WmoRegistry[WmoPlacementRegistry] @@ -132,6 +134,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 | +| `M2StaticBatchMaterializer.materialize_batch` | Internal M2 scene-materialization service | Builds and attaches one prepared-Mesh MultiMesh transform slice | Renderer main thread; stateless after each call | Invalid/empty input returns null; bounds are caller precondition | | `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 | | `M2AnimationLoadPipelineState` | Internal M2 async-state service | Owns animated scene pending request records and terminal finalize FIFO | Renderer main thread; map/session | Empty/duplicate/unknown transitions rejected | | `M2AnimatedSceneFinalizer` | Internal M2 scene-finalization service | Instantiates candidates, repairs materials and requires AnimationPlayer descendants | Renderer main thread; stateless after each call | Invalid/rejected detached roots are freed | @@ -179,6 +182,7 @@ loader configuration remains transitional composition data, not a caller API. | Internal transform | Rotation/path/scale | Loader or grouper / `M2PlacementTransformResolver` | Group/placeholder/instance transforms | Value-only Basis/Vector3 | One placement | | Internal grouping | Tile origin, M2 names and placements | Loader / `M2PlacementGrouper` | Loader worker result/build job | Fresh Dictionary/Transform3D arrays | One grouping task | | Internal batch plan | Transform count/offset, path kind and limits | Loader / `M2BuildBatchPlanner` | Loader materialization/cursor adapter | Fresh scalar Dictionary | One build operation | +| Internal static M2 materialization | Parent, prepared Mesh, ordered transform slice and render settings | Loader / `M2StaticBatchMaterializer` | Attached MultiMeshInstance3D | Parent owns node/MultiMesh; exact Mesh reference retained | One main-thread build batch | | Internal WMO placement | Path, MODF placement, tile/index | Loader / `WmoPlacementResolver` | WMO caches, registry and three instance adapters | Value-only String/Transform3D | Lookup/placement lifetime | | Internal WMO ownership | Resolved placement key and tile/global reference key | Loader / `WmoPlacementRegistry` | Loader create/retain/final-free decisions | Registry-owned String sets; detached diagnostics | Map session or final release | | Internal WMO build step | Mesh/MultiMesh counts and job cursors | Loader / `WmoRenderBuildStepPlanner` | Loader materialization/cursor adapter | Fresh scalar Dictionary | One group operation | @@ -239,7 +243,8 @@ flowchart TD M2Registry --> M2Grouper[M2PlacementGrouper] M2Transform[M2PlacementTransformResolver] --> M2Grouper M2Grouper --> M2Batch[M2BuildBatchPlanner] - M2Batch --> M2 + M2Batch --> M2Static[M2StaticBatchMaterializer] + M2Static --> M2 R --> WmoPlacement[WmoPlacementResolver] WmoPlacement --> WmoRegistry[WmoPlacementRegistry] WmoRegistry --> WmoBuildQueue[WmoRenderBuildQueue] @@ -364,8 +369,10 @@ sequenceDiagram transforms and every build/render side effect remain loader-owned. - `M2PlacementGrouper` is stateless and owns only call-local grouped transforms. The loader retains tasks, mutex/result queues, stale checks and build state. -- `M2BuildBatchPlanner` is stateless and owns only call-local scalar plans. The - loader retains queue/resource transitions, cursor adoption and materialization. +- `M2BuildBatchPlanner` is stateless and owns only call-local scalar plans. + `M2StaticBatchMaterializer` owns static MultiMesh construction/attachment; + the loader retains queue/resource transitions, cursor adoption, animated/static + dispatch, budgets and Editor ownership. - `WmoPlacementResolver` is stateless and owns only call-local cache-key, identity and transform values. `WmoPlacementRegistry` owns only placement-key reference sets. `WmoRenderBuildStepPlanner` owns only a call-local operation @@ -542,6 +549,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 static batch materializer | Implemented extraction | Synthetic node/Mesh/render/source/timing contract | Asset-backed traversal/visual/GPU p95/p99 pending | | 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 | | M2 placement grouper | Implemented extraction | Scene-free validation/order/transform/source/timing contract | Worker/build state, spatial cells and asset-backed p95/p99 remain pending | @@ -615,6 +623,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/render/m2/m2_animated_scene_finalizer.gd` | Animated scene candidate ownership, material repair and player validation | | `src/render/m2/m2_animation_playback_controller.gd` | Per-instance phase, selection, native copy/start and imported playback | | `src/render/m2/m2_animated_instance_materializer.gd` | Animated duplicate/render/playback startup and non-empty batch attachment | +| `src/render/m2/m2_static_batch_materializer.gd` | Static MultiMesh construction, render setup and attachment | | `src/render/m2/m2_mesh_load_pipeline_state.gd` | Static M2 pending Resource paths, terminal statuses and finalize FIFO | | `src/render/m2/m2_mesh_resource_cache_state.gd` | Prepared static M2 Mesh references and final-shutdown release | | `src/render/m2/m2_mesh_resource_extractor.gd` | First-Mesh selection and temporary PackedScene instance lifetime | @@ -640,6 +649,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/tools/verify_m2_animated_scene_finalizer.gd` | Animated M2 candidate/material/player/lifetime/boundary/timing regression | | `src/tools/verify_m2_animation_playback_controller.gd` | Animated M2 phase/selection/playback/native/boundary/timing regression | | `src/tools/verify_m2_animated_instance_materializer.gd` | Animated M2 order/render/playback/attachment/boundary/timing regression | +| `src/tools/verify_m2_static_batch_materializer.gd` | Static M2 node/Mesh/render/attachment/boundary/timing regression | | `src/tools/verify_m2_mesh_load_pipeline_state.gd` | Static M2 request/terminal/FIFO/boundary/timing regression | | `src/tools/verify_m2_mesh_resource_cache_state.gd` | Static M2 Mesh cache ownership/lifetime/boundary/timing regression | | `src/tools/verify_m2_mesh_resource_extractor.gd` | M2 direct/PackedScene/subtree order/lifetime/boundary/timing regression | diff --git a/src/render/m2/m2_static_batch_materializer.gd b/src/render/m2/m2_static_batch_materializer.gd new file mode 100644 index 0000000..749aa9c --- /dev/null +++ b/src/render/m2/m2_static_batch_materializer.gd @@ -0,0 +1,56 @@ +class_name M2StaticBatchMaterializer +extends RefCounted + +## Builds and attaches one static M2 MultiMesh batch from an ordered transform +## slice. All Resource and SceneTree mutation belongs on the main thread. + + +## Returns the attached MultiMeshInstance3D, or null for invalid/empty input. +## The supplied parent owns the returned node and its MultiMesh Resource. +## Transform bounds are a build-planner precondition and are not clamped. +func materialize_batch( + m2_parent_root: Node3D, + relative_path: String, + mesh: Mesh, + transforms: Array, + start_index: int, + instance_count: int, + batch_serial: int, + visibility_range_end: float, + visibility_range_margin: float, + cast_shadows: bool +) -> MultiMeshInstance3D: + if ( + m2_parent_root == null + or mesh == null + or transforms.is_empty() + or instance_count <= 0 + ): + return null + + var multimesh := MultiMesh.new() + multimesh.transform_format = MultiMesh.TRANSFORM_3D + multimesh.mesh = mesh + multimesh.instance_count = instance_count + for batch_offset in instance_count: + multimesh.set_instance_transform( + batch_offset, + transforms[start_index + batch_offset] + ) + + var multimesh_instance := MultiMeshInstance3D.new() + multimesh_instance.name = "%s_%d" % [ + relative_path.get_file().get_basename(), + batch_serial, + ] + multimesh_instance.multimesh = multimesh + multimesh_instance.cast_shadow = ( + GeometryInstance3D.SHADOW_CASTING_SETTING_ON + if cast_shadows + else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + ) + if visibility_range_end > 0.0: + multimesh_instance.visibility_range_end = visibility_range_end + multimesh_instance.visibility_range_end_margin = visibility_range_margin + m2_parent_root.add_child(multimesh_instance) + return multimesh_instance diff --git a/src/render/m2/m2_static_batch_materializer.gd.uid b/src/render/m2/m2_static_batch_materializer.gd.uid new file mode 100644 index 0000000..e25a447 --- /dev/null +++ b/src/render/m2/m2_static_batch_materializer.gd.uid @@ -0,0 +1 @@ +uid://bgiw27rkh8fbn diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 510535b..57ddede 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -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_STATIC_BATCH_MATERIALIZER_SCRIPT := preload( + "res://src/render/m2/m2_static_batch_materializer.gd" +) const M2_RUNTIME_MESH_FINALIZER_SCRIPT := preload( "res://src/render/m2/m2_runtime_mesh_finalizer.gd" ) @@ -273,6 +276,7 @@ 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_static_batch_materializer := M2_STATIC_BATCH_MATERIALIZER_SCRIPT.new() var _m2_runtime_mesh_finalizer := M2_RUNTIME_MESH_FINALIZER_SCRIPT.new() var _m2_raw_model_repository := M2_RAW_MODEL_REPOSITORY_SCRIPT.new() var _m2_prototype_cache_state := M2_PROTOTYPE_CACHE_STATE_SCRIPT.new() @@ -4396,29 +4400,20 @@ func _materialize_m2_group_batch( start: int, count: int, serial: int) -> void: - if transforms.is_empty() or count <= 0 or mesh == null: - return - - var mm := MultiMesh.new() - mm.transform_format = MultiMesh.TRANSFORM_3D - mm.mesh = mesh - mm.instance_count = count - for i in count: - mm.set_instance_transform(i, transforms[start + i]) - - var mmi := MultiMeshInstance3D.new() - mmi.name = "%s_%d" % [rel_path.get_file().get_basename(), serial] - mmi.multimesh = mm - mmi.cast_shadow = ( - GeometryInstance3D.SHADOW_CASTING_SETTING_ON - if m2_cast_shadows - else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + var multimesh_instance := _m2_static_batch_materializer.materialize_batch( + m2_root, + rel_path, + mesh, + transforms, + start, + count, + serial, + m2_visibility_range, + CHUNK_SIZE, + m2_cast_shadows ) - if m2_visibility_range > 0.0: - mmi.visibility_range_end = m2_visibility_range - mmi.visibility_range_end_margin = CHUNK_SIZE - m2_root.add_child(mmi) - _set_editor_owner_recursive(mmi) + if multimesh_instance != null: + _set_editor_owner_recursive(multimesh_instance) func _materialize_m2_animated_batch( diff --git a/src/tools/verify_m2_static_batch_materializer.gd b/src/tools/verify_m2_static_batch_materializer.gd new file mode 100644 index 0000000..bfca661 --- /dev/null +++ b/src/tools/verify_m2_static_batch_materializer.gd @@ -0,0 +1,255 @@ +extends SceneTree + +## Synthetic MultiMesh identity, transform order, render settings, ownership +## boundary and bounded-timing regression for static M2 batch materialization. + +const MATERIALIZER_SCRIPT := preload( + "res://src/render/m2/m2_static_batch_materializer.gd" +) +const MATERIALIZER_PATH := "res://src/render/m2/m2_static_batch_materializer.gd" +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_batch_contract(failures) + _verify_zero_visibility_contract(failures) + _verify_invalid_inputs(failures) + _verify_ownership_boundaries(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("M2_STATIC_BATCH_MATERIALIZER: %s" % failure) + quit(1) + return + print( + "M2_STATIC_BATCH_MATERIALIZER PASS cases=11 instances=10000 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_batch_contract(failures: Array[String]) -> void: + var materializer = MATERIALIZER_SCRIPT.new() + var parent_root := Node3D.new() + get_root().add_child(parent_root) + var mesh := ArrayMesh.new() + var transforms: Array = [ + Transform3D(Basis.IDENTITY, Vector3.ZERO), + Transform3D(Basis.IDENTITY, Vector3(1.0, 2.0, 3.0)), + Transform3D(Basis.IDENTITY, Vector3(4.0, 5.0, 6.0)), + ] + var multimesh_instance: MultiMeshInstance3D = materializer.materialize_batch( + parent_root, + "World\\Doodads\\StaticTree.m2", + mesh, + transforms, + 1, + 2, + 7, + 345.0, + 16.0, + false + ) + _expect_true(multimesh_instance != null, "valid batch returned", failures) + _expect_same(multimesh_instance.get_parent(), parent_root, "batch attached once", failures) + _expect_equal(parent_root.get_child_count(), 1, "one parent child", failures) + _expect_string_equal(multimesh_instance.name, "StaticTree_7", "batch name", failures) + _expect_equal(multimesh_instance.multimesh.transform_format, MultiMesh.TRANSFORM_3D, "3D transform format", failures) + _expect_same(multimesh_instance.multimesh.mesh, mesh, "exact Mesh identity", failures) + _expect_equal(multimesh_instance.multimesh.instance_count, 2, "instance count", failures) + _expect_equal( + multimesh_instance.cast_shadow, + GeometryInstance3D.SHADOW_CASTING_SETTING_OFF, + "shadow mode", + failures + ) + _expect_float_equal(multimesh_instance.visibility_range_end, 345.0, "visibility end", failures) + _expect_float_equal(multimesh_instance.visibility_range_end_margin, 16.0, "visibility margin", failures) + parent_root.free() + + +func _verify_zero_visibility_contract(failures: Array[String]) -> void: + var materializer = MATERIALIZER_SCRIPT.new() + var parent_root := Node3D.new() + var multimesh_instance: MultiMeshInstance3D = materializer.materialize_batch( + parent_root, + "world/static.m2", + ArrayMesh.new(), + [Transform3D.IDENTITY], + 0, + 1, + 0, + 0.0, + 99.0, + true + ) + _expect_float_equal(multimesh_instance.visibility_range_end, 0.0, "disabled visibility unchanged", failures) + _expect_equal( + multimesh_instance.cast_shadow, + GeometryInstance3D.SHADOW_CASTING_SETTING_ON, + "enabled shadow mode", + failures + ) + parent_root.free() + + +func _verify_invalid_inputs(failures: Array[String]) -> void: + var materializer = MATERIALIZER_SCRIPT.new() + var parent_root := Node3D.new() + var mesh := ArrayMesh.new() + var transforms: Array = [Transform3D.IDENTITY] + _expect_true( + materializer.materialize_batch( + null, "a.m2", mesh, transforms, 0, 1, 0, 0.0, 0.0, true + ) == null, + "null parent rejected", + failures + ) + _expect_true( + materializer.materialize_batch( + parent_root, "a.m2", null, transforms, 0, 1, 0, 0.0, 0.0, true + ) == null, + "null Mesh rejected", + failures + ) + _expect_true( + materializer.materialize_batch( + parent_root, "a.m2", mesh, [], 0, 1, 0, 0.0, 0.0, true + ) == null, + "empty transforms rejected", + failures + ) + _expect_true( + materializer.materialize_batch( + parent_root, "a.m2", mesh, transforms, 0, 0, 0, 0.0, 0.0, true + ) == null, + "zero count rejected", + failures + ) + _expect_equal(parent_root.get_child_count(), 0, "invalid input has no attachment", failures) + parent_root.free() + + +func _verify_ownership_boundaries(failures: Array[String]) -> void: + var materializer_source := FileAccess.get_file_as_string(MATERIALIZER_PATH) + var loader_source := FileAccess.get_file_as_string(LOADER_PATH) + var loader_adapter_source := _source_between( + loader_source, + "func _materialize_m2_group_batch(", + "func _materialize_m2_animated_batch(", + failures + ) + _expect_true( + loader_source.contains("M2_STATIC_BATCH_MATERIALIZER_SCRIPT.new()"), + "loader composes materializer", + failures + ) + _expect_equal( + loader_adapter_source.count("_m2_static_batch_materializer.materialize_batch("), + 1, + "loader delegates once", + failures + ) + _expect_false(loader_adapter_source.contains("MultiMesh.new()"), "loader adapter omits MultiMesh construction", failures) + _expect_false(loader_adapter_source.contains("set_instance_transform("), "loader adapter omits transform upload", failures) + _expect_true(loader_adapter_source.contains("_set_editor_owner_recursive(multimesh_instance)"), "loader retains Editor ownership", failures) + for required_materializer_rule in [ + "MultiMesh.TRANSFORM_3D", + "multimesh.mesh = mesh", + "multimesh.instance_count = instance_count", + "multimesh.set_instance_transform(", + "transforms[start_index + batch_offset]", + "m2_parent_root.add_child(multimesh_instance)", + ]: + _expect_true( + materializer_source.contains(required_materializer_rule), + "materializer owns %s" % required_materializer_rule, + failures + ) + for forbidden_dependency in [ + "ResourceLoader.", + "FileAccess.", + "WorkerThreadPool.", + "RenderingServer.", + ".owner =", + "_m2_build_jobs", + "_render_budget_scheduler", + ]: + _expect_false( + materializer_source.contains(forbidden_dependency), + "materializer omits %s ownership" % forbidden_dependency, + failures + ) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var materializer = MATERIALIZER_SCRIPT.new() + var parent_root := Node3D.new() + get_root().add_child(parent_root) + var transforms: Array = [] + for instance_index in 10000: + transforms.append( + Transform3D(Basis.IDENTITY, Vector3(float(instance_index), 0.0, 0.0)) + ) + var started_microseconds := Time.get_ticks_usec() + var multimesh_instance: MultiMeshInstance3D = materializer.materialize_batch( + parent_root, + "world/timing.m2", + ArrayMesh.new(), + transforms, + 0, + 10000, + 1, + 0.0, + 0.0, + true + ) + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_equal(multimesh_instance.multimesh.instance_count, 10000, "timing batch complete", failures) + _expect_true(elapsed_milliseconds < 1000.0, "10000 transforms under one second", failures) + parent_root.free() + return elapsed_milliseconds + + +func _source_between( + source: String, + start_marker: String, + end_marker: String, + failures: Array[String] +) -> String: + var start_offset := source.find(start_marker) + var end_offset := source.find(end_marker, start_offset + start_marker.length()) + if start_offset < 0 or end_offset <= start_offset: + failures.append("cannot isolate source between %s and %s" % [start_marker, end_marker]) + return "" + return source.substr(start_offset, end_offset - start_offset) + + +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]) + + +func _expect_float_equal(actual: float, expected: float, label: String, failures: Array[String]) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s expected=%.6f actual=%.6f" % [label, expected, actual]) + + +func _expect_string_equal(actual: String, expected: String, label: String, failures: Array[String]) -> void: + if actual != expected: + failures.append("%s expected=%s actual=%s" % [label, expected, actual]) + + +func _expect_same(actual: Variant, expected: Variant, label: String, failures: Array[String]) -> void: + if not is_same(actual, expected): + failures.append(label) diff --git a/src/tools/verify_m2_static_batch_materializer.gd.uid b/src/tools/verify_m2_static_batch_materializer.gd.uid new file mode 100644 index 0000000..a1f5dde --- /dev/null +++ b/src/tools/verify_m2_static_batch_materializer.gd.uid @@ -0,0 +1 @@ +uid://hdpp6alfpaa3 diff --git a/targets/03-renderer-facade.md b/targets/03-renderer-facade.md index 6946bc4..81e9a38 100644 --- a/targets/03-renderer-facade.md +++ b/targets/03-renderer-facade.md @@ -27,7 +27,7 @@ Runtime и Editor используют facade; planner/scheduler тестиру ## Evidence - Date: 2026-07-18 -- Revision/worktree: master merges `7ece2ab`, `7e35de7`, `80cb084`, `d6e5b53`, `c69abd6`, `606770c`, `630a0c1`, `f36fabb`, `24aef13`, `2342430`, `396be5e`, `f88bf97`, `3f84717`, `d2cb52f`, `f9e5a4f`, `c06aed5`, `a878e7c`, `60603e1`, `b4955d6`, `ada0fd9`, `f470775`, `874fd0f`, `38f831e`, `83b3618`, `3094561`, `fa85985`, `1fafdde`, `7cd07dd`, `1de50f9`, `3dccd3e`, `e7a7c67`, `c0fc191`; +- Revision/worktree: master merges `7ece2ab`, `7e35de7`, `80cb084`, `d6e5b53`, `c69abd6`, `606770c`, `630a0c1`, `f36fabb`, `24aef13`, `2342430`, `396be5e`, `f88bf97`, `3f84717`, `d2cb52f`, `f9e5a4f`, `c06aed5`, `a878e7c`, `60603e1`, `b4955d6`, `ada0fd9`, `f470775`, `874fd0f`, `38f831e`, `83b3618`, `3094561`, `fa85985`, `1fafdde`, `7cd07dd`, `1de50f9`, `3dccd3e`, `e7a7c67`, `c0fc191`, `c251985`; packages `M03-RND-SCHEDULER-001`, `M03-RND-INTERNAL-ACCESS-GATE-001`, `M03-RND-FACADE-GROUND-QUERY-001`, `M03-RND-FACADE-ENVIRONMENT-001`, `M03-RND-FACADE-ENTITY-001`, `M03-RND-TERRAIN-CACHE-SERVICE-001`, @@ -53,7 +53,9 @@ Runtime и Editor используют facade; planner/scheduler тестиру `M03-RND-M2-PROTOTYPE-CACHE-001`, `M03-RND-M2-ANIMATION-LOAD-PIPELINE-001`, `M03-RND-M2-ANIMATED-SCENE-FINALIZER-001`, - `M03-RND-M2-ANIMATION-PLAYBACK-001` + `M03-RND-M2-ANIMATION-PLAYBACK-001`, + `M03-RND-M2-ANIMATED-INSTANCE-MATERIALIZER-001`, + `M03-RND-M2-STATIC-BATCH-MATERIALIZER-001` - Commands: dedicated scheduler/planner/facade/focus/coordinate/manifest/shutdown headless verifiers; Godot cold editor parse; coordination, documentation and diff gates. - Results: scheduler `cases=6 iterations=20000 elapsed_ms=10.216`; planner @@ -227,6 +229,14 @@ Runtime и Editor используют facade; planner/scheduler тестиру Post-merge materializer (`13.719ms`), playback/finalizer, shutdown, facade, internal-access `30`, checkpoint dry-run `7/7`, documentation and coordination gates remained green. + M2 static batch materializer passed `cases=11 instances=10000 + elapsed_ms=0.552` with invalid-input guards, exact Mesh identity, 3D transform + format/count, ordered slice source contract, visibility, shadows, single + attachment and loader ownership boundaries. All 51 autonomous headless + regressions and checkpoint dry-run `7/7` passed; the proprietary ADT probe + remained unavailable. Documentation and coordination gates passed; internal- + access inventory remains `30` and static resource readiness/cursors/budgets/ + Editor ownership remain in the loader adapter. M02 terrain-query regression remained green (13 pre-existing expired M00 claim warnings). - Fidelity comparison: all 16 historical operation limits and drain sites were @@ -368,6 +378,7 @@ Runtime и Editor используют facade; planner/scheduler тестиру M2 animated scene finalizer and loader candidate/material/player traversal adapters, M2 animation playback controller and loader native/imported playback adapters, M2 animated instance materializer and loader log/Editor-owner adapter, + M2 static batch materializer and loader readiness/cursor/Editor-owner adapter, expanded facade/internal-access/coordinate verifiers; work-package claims and this evidence. - Remaining risks: worker concurrency and stale-result validation remain streamer-owned; cancellation stops new permits but does not interrupt @@ -388,6 +399,9 @@ Runtime и Editor используют facade; planner/scheduler тестиру animation request state and Mesh adoption remain in the loader call path; raw animated-instance materialization is now separated but remains synchronous main-thread work; asset-backed traversal/visual/leak/p95/p99 evidence is pending; + static MultiMesh materialization is also separated but remains synchronous; + headless timing does not measure transform upload GPU cost and spatial-cell + batching still needs culling/performance evidence; WMO ResourceLoader/FileAccess I/O, live fallback and group materialization remain in the loader; asset-backed WMO placement/portal/material/leak/p95/p99 evidence remains pending;