@forgeax/engine-render 0.0.0-dev.8d955ade1c79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +647 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/authoring-path.unit.test.d.ts +2 -0
- package/dist/__tests__/authoring-path.unit.test.d.ts.map +1 -0
- package/dist/__tests__/backend-selection-rhi-capture.test-d.d.ts +2 -0
- package/dist/__tests__/backend-selection-rhi-capture.test-d.d.ts.map +1 -0
- package/dist/__tests__/callback-inference.test-d.d.ts +2 -0
- package/dist/__tests__/callback-inference.test-d.d.ts.map +1 -0
- package/dist/__tests__/cluster-binner.test.d.ts +2 -0
- package/dist/__tests__/cluster-binner.test.d.ts.map +1 -0
- package/dist/__tests__/cluster-grid-default-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/cluster-grid-default-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/construct-renderer-identity.unit.test.d.ts +2 -0
- package/dist/__tests__/construct-renderer-identity.unit.test.d.ts.map +1 -0
- package/dist/__tests__/device-scope-lifecycle.unit.test.d.ts +2 -0
- package/dist/__tests__/device-scope-lifecycle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/device-scope-stale-matrix.unit.test.d.ts +2 -0
- package/dist/__tests__/device-scope-stale-matrix.unit.test.d.ts.map +1 -0
- package/dist/__tests__/direct-extract-consumer-contract.test-d.d.ts +2 -0
- package/dist/__tests__/direct-extract-consumer-contract.test-d.d.ts.map +1 -0
- package/dist/__tests__/direct-light-snapshot.unit.test.d.ts +2 -0
- package/dist/__tests__/direct-light-snapshot.unit.test.d.ts.map +1 -0
- package/dist/__tests__/factory-contract.integration.test.d.ts +2 -0
- package/dist/__tests__/factory-contract.integration.test.d.ts.map +1 -0
- package/dist/__tests__/fallback-row-stride-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/fallback-row-stride-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/frame-observation.contract.test.d.ts +2 -0
- package/dist/__tests__/frame-observation.contract.test.d.ts.map +1 -0
- package/dist/__tests__/fullscreen-feature-plan.unit.test.d.ts +2 -0
- package/dist/__tests__/fullscreen-feature-plan.unit.test.d.ts.map +1 -0
- package/dist/__tests__/glyph-text-layout-recovery.unit.test.d.ts +2 -0
- package/dist/__tests__/glyph-text-layout-recovery.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-driven-indirect-raster-evidence.d.ts +6 -0
- package/dist/__tests__/gpu-driven-indirect-raster-evidence.d.ts.map +1 -0
- package/dist/__tests__/gpu-driven-indirect-raster.browser.test.d.ts +2 -0
- package/dist/__tests__/gpu-driven-indirect-raster.browser.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-driven-indirect-raster.dawn.test.d.ts +2 -0
- package/dist/__tests__/gpu-driven-indirect-raster.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-driven-production.integration.test.d.ts +2 -0
- package/dist/__tests__/gpu-driven-production.integration.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-driven-scaling.unit.test.d.ts +2 -0
- package/dist/__tests__/gpu-driven-scaling.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-driven-view-gpu-evidence.d.ts +18 -0
- package/dist/__tests__/gpu-driven-view-gpu-evidence.d.ts.map +1 -0
- package/dist/__tests__/gpu-driven-view-graph.integration.test.d.ts +2 -0
- package/dist/__tests__/gpu-driven-view-graph.integration.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-driven-view-reference.d.ts +15 -0
- package/dist/__tests__/gpu-driven-view-reference.d.ts.map +1 -0
- package/dist/__tests__/gpu-driven-view.browser.test.d.ts +2 -0
- package/dist/__tests__/gpu-driven-view.browser.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-driven-view.dawn.test.d.ts +2 -0
- package/dist/__tests__/gpu-driven-view.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-driven-view.unit.test.d.ts +2 -0
- package/dist/__tests__/gpu-driven-view.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-resource-store-stride.unit.test.d.ts +2 -0
- package/dist/__tests__/gpu-resource-store-stride.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-scene-render-graph-gpu.d.ts +7 -0
- package/dist/__tests__/gpu-scene-render-graph-gpu.d.ts.map +1 -0
- package/dist/__tests__/gpu-scene-render-graph.browser.test.d.ts +2 -0
- package/dist/__tests__/gpu-scene-render-graph.browser.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-scene-render-graph.dawn.test.d.ts +2 -0
- package/dist/__tests__/gpu-scene-render-graph.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-scene-schema.unit.test.d.ts +2 -0
- package/dist/__tests__/gpu-scene-schema.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-scene.dawn.test.d.ts +2 -0
- package/dist/__tests__/gpu-scene.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-scene.unit.test.d.ts +2 -0
- package/dist/__tests__/gpu-scene.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ibl/face-uniforms.unit.test.d.ts +2 -0
- package/dist/__tests__/ibl/face-uniforms.unit.test.d.ts.map +1 -0
- package/dist/__tests__/infinite-grid-no-vertex-probe.unit.test.d.ts +2 -0
- package/dist/__tests__/infinite-grid-no-vertex-probe.unit.test.d.ts.map +1 -0
- package/dist/__tests__/injection-kind-owner.test-d.d.ts +2 -0
- package/dist/__tests__/injection-kind-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/ktx2-basis-gpu-consumer.dawn.test.d.ts +2 -0
- package/dist/__tests__/ktx2-basis-gpu-consumer.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/light-buffer-layout.unit.test.d.ts +2 -0
- package/dist/__tests__/light-buffer-layout.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-contract-inventory.unit.test.d.ts +2 -0
- package/dist/__tests__/material-contract-inventory.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-cooked-projection.unit.test.d.ts +2 -0
- package/dist/__tests__/material-cooked-projection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-snapshot-mutation.integration.test.d.ts +2 -0
- package/dist/__tests__/material-snapshot-mutation.integration.test.d.ts.map +1 -0
- package/dist/__tests__/material-static-dynamic-route.unit.test.d.ts +2 -0
- package/dist/__tests__/material-static-dynamic-route.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-stride-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/material-stride-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-buffer-usage-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/mesh-buffer-usage-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-material-bindings.unit.test.d.ts +2 -0
- package/dist/__tests__/mesh-material-bindings.unit.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-stride-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/mesh-stride-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/morph-culling-reentry.dawn.test.d.ts +2 -0
- package/dist/__tests__/morph-culling-reentry.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/morph-feature-gpu-work.integration.test.d.ts +2 -0
- package/dist/__tests__/morph-feature-gpu-work.integration.test.d.ts.map +1 -0
- package/dist/__tests__/morph-feature-plan.unit.test.d.ts +2 -0
- package/dist/__tests__/morph-feature-plan.unit.test.d.ts.map +1 -0
- package/dist/__tests__/morph-visual-evidence.integration.test.d.ts +2 -0
- package/dist/__tests__/morph-visual-evidence.integration.test.d.ts.map +1 -0
- package/dist/__tests__/nineslice-handle-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/nineslice-handle-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/post-process-error-policy-owner.unit.test.d.ts +2 -0
- package/dist/__tests__/post-process-error-policy-owner.unit.test.d.ts.map +1 -0
- package/dist/__tests__/prepared-buffer-kind-owner.test-d.d.ts +2 -0
- package/dist/__tests__/prepared-buffer-kind-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/prepared-graphics-capability.unit.test.d.ts +2 -0
- package/dist/__tests__/prepared-graphics-capability.unit.test.d.ts.map +1 -0
- package/dist/__tests__/prepared-graphics-generation.unit.test.d.ts +2 -0
- package/dist/__tests__/prepared-graphics-generation.unit.test.d.ts.map +1 -0
- package/dist/__tests__/prepared-graphics-lifecycle.unit.test.d.ts +2 -0
- package/dist/__tests__/prepared-graphics-lifecycle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/prepared-graphics-pipeline-warmup.unit.test.d.ts +2 -0
- package/dist/__tests__/prepared-graphics-pipeline-warmup.unit.test.d.ts.map +1 -0
- package/dist/__tests__/prepared-graphics-resolver.unit.test.d.ts +2 -0
- package/dist/__tests__/prepared-graphics-resolver.unit.test.d.ts.map +1 -0
- package/dist/__tests__/prepared-graphics-snapshot.unit.test.d.ts +2 -0
- package/dist/__tests__/prepared-graphics-snapshot.unit.test.d.ts.map +1 -0
- package/dist/__tests__/prepared-graphics-store.unit.test.d.ts +2 -0
- package/dist/__tests__/prepared-graphics-store.unit.test.d.ts.map +1 -0
- package/dist/__tests__/prepared-graphics-validation.unit.test.d.ts +2 -0
- package/dist/__tests__/prepared-graphics-validation.unit.test.d.ts.map +1 -0
- package/dist/__tests__/profiler-default-off.test.d.ts +2 -0
- package/dist/__tests__/profiler-default-off.test.d.ts.map +1 -0
- package/dist/__tests__/profiler-phase-catalog.test.d.ts +2 -0
- package/dist/__tests__/profiler-phase-catalog.test.d.ts.map +1 -0
- package/dist/__tests__/profiler-phase-integration.test.d.ts +2 -0
- package/dist/__tests__/profiler-phase-integration.test.d.ts.map +1 -0
- package/dist/__tests__/profiler-phase-token.test.d.ts +2 -0
- package/dist/__tests__/profiler-phase-token.test.d.ts.map +1 -0
- package/dist/__tests__/public-surface.test-d.d.ts +2 -0
- package/dist/__tests__/public-surface.test-d.d.ts.map +1 -0
- package/dist/__tests__/recover-error-policy-owner.unit.test.d.ts +2 -0
- package/dist/__tests__/recover-error-policy-owner.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-error-code-owner.test-d.d.ts +2 -0
- package/dist/__tests__/render-error-code-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-error-detail-ssot.test-d.d.ts +2 -0
- package/dist/__tests__/render-error-detail-ssot.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-error-exhaustive.test-d.d.ts +2 -0
- package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-feature-assembly.test-d.d.ts +2 -0
- package/dist/__tests__/render-feature-assembly.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-feature-context.test-d.d.ts +2 -0
- package/dist/__tests__/render-feature-context.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-feature-contract.test-d.d.ts +2 -0
- package/dist/__tests__/render-feature-contract.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-feature-diagnostics.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-diagnostics.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-docs.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-docs.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-errors.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-gpu-work.integration.test.d.ts +2 -0
- package/dist/__tests__/render-feature-gpu-work.integration.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-plan.test-d.d.ts +2 -0
- package/dist/__tests__/render-feature-plan.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-feature-plan.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-plan.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-prepared-diagnostics.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-prepared-diagnostics.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-prepared-errors.test-d.d.ts +2 -0
- package/dist/__tests__/render-feature-prepared-errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-feature-prepared-graphics-docs.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-prepared-graphics-docs.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-prepared-graphics-isolation.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-prepared-graphics-isolation.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-prepared-graphics-negative.test-d.d.ts +2 -0
- package/dist/__tests__/render-feature-prepared-graphics-negative.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-feature-prepared-graphics.test-d.d.ts +2 -0
- package/dist/__tests__/render-feature-prepared-graphics.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-feature-prepared-graphics.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-prepared-graphics.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-public-surface.test-d.d.ts +2 -0
- package/dist/__tests__/render-feature-public-surface.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-feature-retry.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-retry.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-scheduling.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-scheduling.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-stage-failed-policy-owner.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-stage-failed-policy-owner.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-stage-fault-recovery.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-stage-fault-recovery.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-visibility-context.test-d.d.ts +2 -0
- package/dist/__tests__/render-feature-visibility-context.test-d.d.ts.map +1 -0
- package/dist/__tests__/render-feature-visibility-context.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-visibility-context.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-feature-zero-work.unit.test.d.ts +2 -0
- package/dist/__tests__/render-feature-zero-work.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-compute-raster-gpu.d.ts +7 -0
- package/dist/__tests__/render-graph-compute-raster-gpu.d.ts.map +1 -0
- package/dist/__tests__/render-graph-compute-raster.browser.test.d.ts +2 -0
- package/dist/__tests__/render-graph-compute-raster.browser.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-compute-raster.dawn.test.d.ts +2 -0
- package/dist/__tests__/render-graph-compute-raster.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-hzb-gpu.d.ts +7 -0
- package/dist/__tests__/render-graph-hzb-gpu.d.ts.map +1 -0
- package/dist/__tests__/render-graph-hzb.browser.test.d.ts +2 -0
- package/dist/__tests__/render-graph-hzb.browser.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-hzb.dawn.test.d.ts +2 -0
- package/dist/__tests__/render-graph-hzb.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-primitives.unit.test.d.ts +2 -0
- package/dist/__tests__/render-graph-primitives.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-storage-indirect-gpu.d.ts +12 -0
- package/dist/__tests__/render-graph-storage-indirect-gpu.d.ts.map +1 -0
- package/dist/__tests__/render-graph-storage-indirect.browser.test.d.ts +2 -0
- package/dist/__tests__/render-graph-storage-indirect.browser.test.d.ts.map +1 -0
- package/dist/__tests__/render-graph-storage-indirect.dawn.test.d.ts +2 -0
- package/dist/__tests__/render-graph-storage-indirect.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/render-scene-projection.unit.test.d.ts +2 -0
- package/dist/__tests__/render-scene-projection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/renderer-factory-material-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/renderer-factory-material-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/renderer-lifecycle.integration.test.d.ts +2 -0
- package/dist/__tests__/renderer-lifecycle.integration.test.d.ts.map +1 -0
- package/dist/__tests__/rhi-backend-package-boundary.unit.test.d.ts +2 -0
- package/dist/__tests__/rhi-backend-package-boundary.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
- package/dist/__tests__/shader-stage-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/shader-stage-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ssao-config.unit.test.d.ts +2 -0
- package/dist/__tests__/ssao-config.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ssao-kernel-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/ssao-kernel-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/standard-clustered-pipeline.unit.test.d.ts +2 -0
- package/dist/__tests__/standard-clustered-pipeline.unit.test.d.ts.map +1 -0
- package/dist/__tests__/standard-descriptors.test-d.d.ts +2 -0
- package/dist/__tests__/standard-descriptors.test-d.d.ts.map +1 -0
- package/dist/__tests__/standard-pipeline.dawn.test.d.ts +2 -0
- package/dist/__tests__/standard-pipeline.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/standard-pipeline.integration.test.d.ts +2 -0
- package/dist/__tests__/standard-pipeline.integration.test.d.ts.map +1 -0
- package/dist/__tests__/standard-profile-oracle.unit.test.d.ts +2 -0
- package/dist/__tests__/standard-profile-oracle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/surface-configuration.unit.test.d.ts +2 -0
- package/dist/__tests__/surface-configuration.unit.test.d.ts.map +1 -0
- package/dist/__tests__/texture-usage-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/texture-usage-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/typed-pipeline-topology.unit.test.d.ts +2 -0
- package/dist/__tests__/typed-pipeline-topology.unit.test.d.ts.map +1 -0
- package/dist/__tests__/urp-pipeline-targets.unit.test.d.ts +2 -0
- package/dist/__tests__/urp-pipeline-targets.unit.test.d.ts.map +1 -0
- package/dist/__tests__/urp-pipeline.unit.test.d.ts +2 -0
- package/dist/__tests__/urp-pipeline.unit.test.d.ts.map +1 -0
- package/dist/__tests__/vertex-color-pipeline.unit.test.d.ts +2 -0
- package/dist/__tests__/vertex-color-pipeline.unit.test.d.ts.map +1 -0
- package/dist/__tests__/vertex-layout-consumer.unit.test.d.ts +2 -0
- package/dist/__tests__/vertex-layout-consumer.unit.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-diagnostics.integration.test.d.ts +2 -0
- package/dist/__tests__/visibility-diagnostics.integration.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-extract.integration.test.d.ts +2 -0
- package/dist/__tests__/visibility-extract.integration.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-extract.perf.test.d.ts +2 -0
- package/dist/__tests__/visibility-extract.perf.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-feature-dedup.integration.test.d.ts +2 -0
- package/dist/__tests__/visibility-feature-dedup.integration.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-hidden-report.unit.test.d.ts +2 -0
- package/dist/__tests__/visibility-hidden-report.unit.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-instances.integration.test.d.ts +2 -0
- package/dist/__tests__/visibility-instances.integration.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-multi-world.integration.test.d.ts +2 -0
- package/dist/__tests__/visibility-multi-world.integration.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-producer-matrix.integration.test.d.ts +2 -0
- package/dist/__tests__/visibility-producer-matrix.integration.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-producer-matrix.unit.test.d.ts +2 -0
- package/dist/__tests__/visibility-producer-matrix.unit.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-public-surface.test-d.d.ts +2 -0
- package/dist/__tests__/visibility-public-surface.test-d.d.ts.map +1 -0
- package/dist/__tests__/visibility-resolver.unit.test.d.ts +2 -0
- package/dist/__tests__/visibility-resolver.unit.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-resource-short-circuit.unit.test.d.ts +2 -0
- package/dist/__tests__/visibility-resource-short-circuit.unit.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-snapshot-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/visibility-snapshot-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/vocabulary.unit.test.d.ts +2 -0
- package/dist/__tests__/vocabulary.unit.test.d.ts.map +1 -0
- package/dist/assembly/backend-contract.d.ts +37 -0
- package/dist/assembly/backend-contract.d.ts.map +1 -0
- package/dist/assembly/factory.d.ts +207 -0
- package/dist/assembly/factory.d.ts.map +1 -0
- package/dist/assembly/host-contract.d.ts +84 -0
- package/dist/assembly/host-contract.d.ts.map +1 -0
- package/dist/assembly/material/assembly.d.ts +21 -0
- package/dist/assembly/material/assembly.d.ts.map +1 -0
- package/dist/assembly/material/pipeline-projection.d.ts +30 -0
- package/dist/assembly/material/pipeline-projection.d.ts.map +1 -0
- package/dist/assets/asset-decoders.d.ts +5 -0
- package/dist/assets/asset-decoders.d.ts.map +1 -0
- package/dist/assets/render-asset-port.d.ts +17 -0
- package/dist/assets/render-asset-port.d.ts.map +1 -0
- package/dist/assets/resolve-render-asset.d.ts +6 -0
- package/dist/assets/resolve-render-asset.d.ts.map +1 -0
- package/dist/authoring.d.ts +12 -0
- package/dist/authoring.d.ts.map +1 -0
- package/dist/authoring.mjs +27 -0
- package/dist/authoring.mjs.map +1 -0
- package/dist/chunk-CQXZC5XI.mjs +182 -0
- package/dist/chunk-CQXZC5XI.mjs.map +1 -0
- package/dist/chunk-HCOGPHKS.mjs +93 -0
- package/dist/chunk-HCOGPHKS.mjs.map +1 -0
- package/dist/chunk-YEJIUW7D.mjs +1152 -0
- package/dist/chunk-YEJIUW7D.mjs.map +1 -0
- package/dist/cluster-binner.d.ts +152 -0
- package/dist/cluster-binner.d.ts.map +1 -0
- package/dist/components/__tests__/points-lines-admission.unit.test.d.ts +2 -0
- package/dist/components/__tests__/points-lines-admission.unit.test.d.ts.map +1 -0
- package/dist/components/__tests__/points-lines-schema.unit.test.d.ts +2 -0
- package/dist/components/__tests__/points-lines-schema.unit.test.d.ts.map +1 -0
- package/dist/components/__tests__/schema-contract.unit.test.d.ts +2 -0
- package/dist/components/__tests__/schema-contract.unit.test.d.ts.map +1 -0
- package/dist/components/__tests__/schema-parity.unit.test.d.ts +2 -0
- package/dist/components/__tests__/schema-parity.unit.test.d.ts.map +1 -0
- package/dist/components/__tests__/visibility-schema.unit.test.d.ts +2 -0
- package/dist/components/__tests__/visibility-schema.unit.test.d.ts.map +1 -0
- package/dist/components/camera.d.ts +311 -0
- package/dist/components/camera.d.ts.map +1 -0
- package/dist/components/directional-light.d.ts +76 -0
- package/dist/components/directional-light.d.ts.map +1 -0
- package/dist/components/glyph-text.d.ts +26 -0
- package/dist/components/glyph-text.d.ts.map +1 -0
- package/dist/components/index.d.ts +24 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/instances.d.ts +41 -0
- package/dist/components/instances.d.ts.map +1 -0
- package/dist/components/layer.d.ts +34 -0
- package/dist/components/layer.d.ts.map +1 -0
- package/dist/components/light-helpers.d.ts +53 -0
- package/dist/components/light-helpers.d.ts.map +1 -0
- package/dist/components/lines.d.ts +5 -0
- package/dist/components/lines.d.ts.map +1 -0
- package/dist/components/mesh-filter.d.ts +24 -0
- package/dist/components/mesh-filter.d.ts.map +1 -0
- package/dist/components/mesh-renderer.d.ts +33 -0
- package/dist/components/mesh-renderer.d.ts.map +1 -0
- package/dist/components/point-light-shadow.d.ts +27 -0
- package/dist/components/point-light-shadow.d.ts.map +1 -0
- package/dist/components/point-light.d.ts +35 -0
- package/dist/components/point-light.d.ts.map +1 -0
- package/dist/components/points.d.ts +15 -0
- package/dist/components/points.d.ts.map +1 -0
- package/dist/components/post-process-params.d.ts +5 -0
- package/dist/components/post-process-params.d.ts.map +1 -0
- package/dist/components/scene-instance.d.ts +140 -0
- package/dist/components/scene-instance.d.ts.map +1 -0
- package/dist/components/skybox-background.d.ts +56 -0
- package/dist/components/skybox-background.d.ts.map +1 -0
- package/dist/components/skylight.d.ts +55 -0
- package/dist/components/skylight.d.ts.map +1 -0
- package/dist/components/sort-key.d.ts +44 -0
- package/dist/components/sort-key.d.ts.map +1 -0
- package/dist/components/spot-light.d.ts +81 -0
- package/dist/components/spot-light.d.ts.map +1 -0
- package/dist/components/sprite-animation.d.ts +74 -0
- package/dist/components/sprite-animation.d.ts.map +1 -0
- package/dist/components/sprite-instances.d.ts +51 -0
- package/dist/components/sprite-instances.d.ts.map +1 -0
- package/dist/components/sprite-playback-mode.d.ts +42 -0
- package/dist/components/sprite-playback-mode.d.ts.map +1 -0
- package/dist/components/sprite-region-override.d.ts +46 -0
- package/dist/components/sprite-region-override.d.ts.map +1 -0
- package/dist/components/tile-layer.d.ts +106 -0
- package/dist/components/tile-layer.d.ts.map +1 -0
- package/dist/components/tilemap.d.ts +25 -0
- package/dist/components/tilemap.d.ts.map +1 -0
- package/dist/components/visibility.d.ts +22 -0
- package/dist/components/visibility.d.ts.map +1 -0
- package/dist/construct-renderer.d.ts +19 -0
- package/dist/construct-renderer.d.ts.map +1 -0
- package/dist/construct-renderer.mjs +27462 -0
- package/dist/construct-renderer.mjs.map +1 -0
- package/dist/debug-draw-glue.d.ts +5 -0
- package/dist/debug-draw-glue.d.ts.map +1 -0
- package/dist/device/device-scope.d.ts +83 -0
- package/dist/device/device-scope.d.ts.map +1 -0
- package/dist/device/gpu-residency.d.ts +337 -0
- package/dist/device/gpu-residency.d.ts.map +1 -0
- package/dist/device/resource-types.d.ts +8 -0
- package/dist/device/resource-types.d.ts.map +1 -0
- package/dist/engine-metrics.d.ts +23 -0
- package/dist/engine-metrics.d.ts.map +1 -0
- package/dist/errors/ecs-validation.d.ts +3 -0
- package/dist/errors/ecs-validation.d.ts.map +1 -0
- package/dist/errors/index.d.ts +4 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/recover.d.ts +105 -0
- package/dist/errors/recover.d.ts.map +1 -0
- package/dist/errors/render.d.ts +705 -0
- package/dist/errors/render.d.ts.map +1 -0
- package/dist/extract/visibility.d.ts +26 -0
- package/dist/extract/visibility.d.ts.map +1 -0
- package/dist/features/fullscreen.d.ts +19 -0
- package/dist/features/fullscreen.d.ts.map +1 -0
- package/dist/features/host.d.ts +106 -0
- package/dist/features/host.d.ts.map +1 -0
- package/dist/features/morph/morph-feature.d.ts +79 -0
- package/dist/features/morph/morph-feature.d.ts.map +1 -0
- package/dist/features/plan.d.ts +182 -0
- package/dist/features/plan.d.ts.map +1 -0
- package/dist/features/prepared-gpu-work.d.ts +117 -0
- package/dist/features/prepared-gpu-work.d.ts.map +1 -0
- package/dist/features/prepared-graphics-store.d.ts +66 -0
- package/dist/features/prepared-graphics-store.d.ts.map +1 -0
- package/dist/features/prepared-graphics.d.ts +215 -0
- package/dist/features/prepared-graphics.d.ts.map +1 -0
- package/dist/features/render-graph-compute.d.ts +13 -0
- package/dist/features/render-graph-compute.d.ts.map +1 -0
- package/dist/features/render-graph-contribution.d.ts +12 -0
- package/dist/features/render-graph-contribution.d.ts.map +1 -0
- package/dist/features/render-graph-raster.d.ts +38 -0
- package/dist/features/render-graph-raster.d.ts.map +1 -0
- package/dist/features/render-graph-resources.d.ts +11 -0
- package/dist/features/render-graph-resources.d.ts.map +1 -0
- package/dist/features/targets.d.ts +32 -0
- package/dist/features/targets.d.ts.map +1 -0
- package/dist/features/types.d.ts +68 -0
- package/dist/features/types.d.ts.map +1 -0
- package/dist/features/vocabulary.d.ts +14 -0
- package/dist/features/vocabulary.d.ts.map +1 -0
- package/dist/fullscreen-post-process-pass.d.ts +157 -0
- package/dist/fullscreen-post-process-pass.d.ts.map +1 -0
- package/dist/glyph-text-layout-system.d.ts +19 -0
- package/dist/glyph-text-layout-system.d.ts.map +1 -0
- package/dist/gpu-driven/batch-topology.d.ts +55 -0
- package/dist/gpu-driven/batch-topology.d.ts.map +1 -0
- package/dist/gpu-driven/production-raster.d.ts +51 -0
- package/dist/gpu-driven/production-raster.d.ts.map +1 -0
- package/dist/gpu-driven/view-gpu.d.ts +85 -0
- package/dist/gpu-driven/view-gpu.d.ts.map +1 -0
- package/dist/gpu-resource.d.ts +73 -0
- package/dist/gpu-resource.d.ts.map +1 -0
- package/dist/gpu-scene-schema.d.ts +78 -0
- package/dist/gpu-scene-schema.d.ts.map +1 -0
- package/dist/gpu-scene.d.ts +55 -0
- package/dist/gpu-scene.d.ts.map +1 -0
- package/dist/gpu-stage.d.ts +4 -0
- package/dist/gpu-stage.d.ts.map +1 -0
- package/dist/gpu-texture-usage.d.ts +6 -0
- package/dist/gpu-texture-usage.d.ts.map +1 -0
- package/dist/gpu-usage.d.ts +9 -0
- package/dist/gpu-usage.d.ts.map +1 -0
- package/dist/hdrp-buffers.d.ts +163 -0
- package/dist/hdrp-buffers.d.ts.map +1 -0
- package/dist/ibl/IblPipelineCache.d.ts +168 -0
- package/dist/ibl/IblPipelineCache.d.ts.map +1 -0
- package/dist/ibl/skylight-bind-group.d.ts +130 -0
- package/dist/ibl/skylight-bind-group.d.ts.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +169 -0
- package/dist/index.mjs.map +1 -0
- package/dist/inspection-types.d.ts +72 -0
- package/dist/inspection-types.d.ts.map +1 -0
- package/dist/instance-buffer-cache.d.ts +56 -0
- package/dist/instance-buffer-cache.d.ts.map +1 -0
- package/dist/internal.d.ts +2 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.mjs +3 -0
- package/dist/internal.mjs.map +1 -0
- package/dist/lifecycle.d.ts +62 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/light-buffer-layout.d.ts +164 -0
- package/dist/light-buffer-layout.d.ts.map +1 -0
- package/dist/materials.d.ts +47 -0
- package/dist/materials.d.ts.map +1 -0
- package/dist/mesh-material-bindings.d.ts +43 -0
- package/dist/mesh-material-bindings.d.ts.map +1 -0
- package/dist/pbr-pipeline.d.ts +301 -0
- package/dist/pbr-pipeline.d.ts.map +1 -0
- package/dist/pipeline/standard-forward-lane.d.ts +5 -0
- package/dist/pipeline/standard-forward-lane.d.ts.map +1 -0
- package/dist/pipeline/standard-pipeline.d.ts +31 -0
- package/dist/pipeline/standard-pipeline.d.ts.map +1 -0
- package/dist/pipeline/standard-profile.d.ts +40 -0
- package/dist/pipeline/standard-profile.d.ts.map +1 -0
- package/dist/pipeline-builder.d.ts +122 -0
- package/dist/pipeline-builder.d.ts.map +1 -0
- package/dist/pipeline-spec-types.d.ts +24 -0
- package/dist/pipeline-spec-types.d.ts.map +1 -0
- package/dist/pipeline-spec.d.ts +367 -0
- package/dist/pipeline-spec.d.ts.map +1 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/points-lines/__tests__/bounds.unit.test.d.ts +2 -0
- package/dist/points-lines/__tests__/bounds.unit.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/cache-recovery.unit.test.d.ts +2 -0
- package/dist/points-lines/__tests__/cache-recovery.unit.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/inspection-lanes.integration.test.d.ts +2 -0
- package/dist/points-lines/__tests__/inspection-lanes.integration.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/inspection.unit.test.d.ts +2 -0
- package/dist/points-lines/__tests__/inspection.unit.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/lane-structural.integration.test.d.ts +2 -0
- package/dist/points-lines/__tests__/lane-structural.integration.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/points-lines.bench.d.ts +2 -0
- package/dist/points-lines/__tests__/points-lines.bench.d.ts.map +1 -0
- package/dist/points-lines/__tests__/raster-lane.integration.test.d.ts +2 -0
- package/dist/points-lines/__tests__/raster-lane.integration.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/snapshot.unit.test.d.ts +2 -0
- package/dist/points-lines/__tests__/snapshot.unit.test.d.ts.map +1 -0
- package/dist/points-lines/__tests__/view-ubo.unit.test.d.ts +2 -0
- package/dist/points-lines/__tests__/view-ubo.unit.test.d.ts.map +1 -0
- package/dist/points-lines/admission.d.ts +36 -0
- package/dist/points-lines/admission.d.ts.map +1 -0
- package/dist/points-lines/bounds.d.ts +8 -0
- package/dist/points-lines/bounds.d.ts.map +1 -0
- package/dist/points-lines/expansion-cache.d.ts +28 -0
- package/dist/points-lines/expansion-cache.d.ts.map +1 -0
- package/dist/points-lines/inspection.d.ts +53 -0
- package/dist/points-lines/inspection.d.ts.map +1 -0
- package/dist/points-lines/prepare.d.ts +67 -0
- package/dist/points-lines/prepare.d.ts.map +1 -0
- package/dist/points-lines/record.d.ts +60 -0
- package/dist/points-lines/record.d.ts.map +1 -0
- package/dist/points-lines/snapshot.d.ts +49 -0
- package/dist/points-lines/snapshot.d.ts.map +1 -0
- package/dist/post-process-errors.d.ts +182 -0
- package/dist/post-process-errors.d.ts.map +1 -0
- package/dist/prepare/prepared-graphics-resolver.d.ts +59 -0
- package/dist/prepare/prepared-graphics-resolver.d.ts.map +1 -0
- package/dist/record/__tests__/material-bg-cache.unit.test.d.ts +2 -0
- package/dist/record/__tests__/material-bg-cache.unit.test.d.ts.map +1 -0
- package/dist/record/__tests__/material-slot-plan.unit.test.d.ts +2 -0
- package/dist/record/__tests__/material-slot-plan.unit.test.d.ts.map +1 -0
- package/dist/record/__tests__/typed-frame-graph-plan.unit.test.d.ts +2 -0
- package/dist/record/__tests__/typed-frame-graph-plan.unit.test.d.ts.map +1 -0
- package/dist/record/__tests__/video-texture-view.unit.test.d.ts +2 -0
- package/dist/record/__tests__/video-texture-view.unit.test.d.ts.map +1 -0
- package/dist/record/frame-lighting.d.ts +121 -0
- package/dist/record/frame-lighting.d.ts.map +1 -0
- package/dist/record/frame-snapshot.d.ts +429 -0
- package/dist/record/frame-snapshot.d.ts.map +1 -0
- package/dist/record/frame-targets.d.ts +20 -0
- package/dist/record/frame-targets.d.ts.map +1 -0
- package/dist/record/frame.d.ts +71 -0
- package/dist/record/frame.d.ts.map +1 -0
- package/dist/record/helpers.d.ts +102 -0
- package/dist/record/helpers.d.ts.map +1 -0
- package/dist/record/main-pass-geometry.d.ts +28 -0
- package/dist/record/main-pass-geometry.d.ts.map +1 -0
- package/dist/record/main-pass-material.d.ts +204 -0
- package/dist/record/main-pass-material.d.ts.map +1 -0
- package/dist/record/main-pass-sprite-draws.d.ts +43 -0
- package/dist/record/main-pass-sprite-draws.d.ts.map +1 -0
- package/dist/record/main-pass.d.ts +20 -0
- package/dist/record/main-pass.d.ts.map +1 -0
- package/dist/record/mesh-ssbo.d.ts +446 -0
- package/dist/record/mesh-ssbo.d.ts.map +1 -0
- package/dist/record/render-context.d.ts +252 -0
- package/dist/record/render-context.d.ts.map +1 -0
- package/dist/record/shadow-pass.d.ts +52 -0
- package/dist/record/shadow-pass.d.ts.map +1 -0
- package/dist/record/skybox-post-pass.d.ts +33 -0
- package/dist/record/skybox-post-pass.d.ts.map +1 -0
- package/dist/record/typed-frame-graph.d.ts +35 -0
- package/dist/record/typed-frame-graph.d.ts.map +1 -0
- package/dist/record/view-ubo.d.ts +45 -0
- package/dist/record/view-ubo.d.ts.map +1 -0
- package/dist/render-contract.d.ts +424 -0
- package/dist/render-contract.d.ts.map +1 -0
- package/dist/render-data.d.ts +143 -0
- package/dist/render-data.d.ts.map +1 -0
- package/dist/render-graph-primitives.d.ts +76 -0
- package/dist/render-graph-primitives.d.ts.map +1 -0
- package/dist/render-pipeline.d.ts +110 -0
- package/dist/render-pipeline.d.ts.map +1 -0
- package/dist/render-system-extract.d.ts +868 -0
- package/dist/render-system-extract.d.ts.map +1 -0
- package/dist/render-system.d.ts +125 -0
- package/dist/render-system.d.ts.map +1 -0
- package/dist/scene/render-scene-types.d.ts +61 -0
- package/dist/scene/render-scene-types.d.ts.map +1 -0
- package/dist/scene/render-scene.d.ts +112 -0
- package/dist/scene/render-scene.d.ts.map +1 -0
- package/dist/scene-instances/post-spawn-resolve-joints.d.ts +30 -0
- package/dist/scene-instances/post-spawn-resolve-joints.d.ts.map +1 -0
- package/dist/shadow-atlas.d.ts +117 -0
- package/dist/shadow-atlas.d.ts.map +1 -0
- package/dist/ssao-buffers.d.ts +50 -0
- package/dist/ssao-buffers.d.ts.map +1 -0
- package/dist/ssao-config.d.ts +28 -0
- package/dist/ssao-config.d.ts.map +1 -0
- package/dist/systems/active-camera.d.ts +53 -0
- package/dist/systems/active-camera.d.ts.map +1 -0
- package/dist/systems/pass-selector.d.ts +24 -0
- package/dist/systems/pass-selector.d.ts.map +1 -0
- package/dist/systems/skin-palette-allocator.d.ts +49 -0
- package/dist/systems/skin-palette-allocator.d.ts.map +1 -0
- package/dist/systems/skin-palette-types.d.ts +14 -0
- package/dist/systems/skin-palette-types.d.ts.map +1 -0
- package/dist/systems/transparent-sort-config.d.ts +112 -0
- package/dist/systems/transparent-sort-config.d.ts.map +1 -0
- package/dist/tilemap-chunk-extract-system.d.ts +143 -0
- package/dist/tilemap-chunk-extract-system.d.ts.map +1 -0
- package/dist/typed-render-graph-primitives.d.ts +57 -0
- package/dist/typed-render-graph-primitives.d.ts.map +1 -0
- package/dist/typed-shadow-passes.d.ts +11 -0
- package/dist/typed-shadow-passes.d.ts.map +1 -0
- package/package.json +94 -0
- package/src/__tests__/authoring-path.unit.test.ts +23 -0
- package/src/__tests__/backend-selection-rhi-capture.test-d.ts +21 -0
- package/src/__tests__/callback-inference.test-d.ts +8 -0
- package/src/__tests__/cluster-binner.test.ts +272 -0
- package/src/__tests__/cluster-grid-default-surface.unit.test.ts +20 -0
- package/src/__tests__/construct-renderer-identity.unit.test.ts +23 -0
- package/src/__tests__/device-scope-lifecycle.unit.test.ts +151 -0
- package/src/__tests__/device-scope-stale-matrix.unit.test.ts +42 -0
- package/src/__tests__/direct-extract-consumer-contract.test-d.ts +23 -0
- package/src/__tests__/direct-light-snapshot.unit.test.ts +61 -0
- package/src/__tests__/factory-contract.integration.test.ts +280 -0
- package/src/__tests__/fallback-row-stride-surface.unit.test.ts +15 -0
- package/src/__tests__/frame-observation.contract.test.ts +112 -0
- package/src/__tests__/fullscreen-feature-plan.unit.test.ts +32 -0
- package/src/__tests__/glyph-text-layout-recovery.unit.test.ts +167 -0
- package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +350 -0
- package/src/__tests__/gpu-driven-indirect-raster.browser.test.ts +12 -0
- package/src/__tests__/gpu-driven-indirect-raster.dawn.test.ts +16 -0
- package/src/__tests__/gpu-driven-production.integration.test.ts +436 -0
- package/src/__tests__/gpu-driven-scaling.unit.test.ts +87 -0
- package/src/__tests__/gpu-driven-view-gpu-evidence.ts +271 -0
- package/src/__tests__/gpu-driven-view-graph.integration.test.ts +290 -0
- package/src/__tests__/gpu-driven-view-reference.ts +111 -0
- package/src/__tests__/gpu-driven-view.browser.test.ts +30 -0
- package/src/__tests__/gpu-driven-view.dawn.test.ts +28 -0
- package/src/__tests__/gpu-driven-view.unit.test.ts +199 -0
- package/src/__tests__/gpu-resource-store-stride.unit.test.ts +59 -0
- package/src/__tests__/gpu-scene-render-graph-gpu.ts +308 -0
- package/src/__tests__/gpu-scene-render-graph.browser.test.ts +19 -0
- package/src/__tests__/gpu-scene-render-graph.dawn.test.ts +19 -0
- package/src/__tests__/gpu-scene-schema.unit.test.ts +16 -0
- package/src/__tests__/gpu-scene.dawn.test.ts +205 -0
- package/src/__tests__/gpu-scene.unit.test.ts +147 -0
- package/src/__tests__/ibl/face-uniforms.unit.test.ts +46 -0
- package/src/__tests__/infinite-grid-no-vertex-probe.unit.test.ts +92 -0
- package/src/__tests__/injection-kind-owner.test-d.ts +14 -0
- package/src/__tests__/ktx2-basis-gpu-consumer.dawn.test.ts +728 -0
- package/src/__tests__/light-buffer-layout.unit.test.ts +40 -0
- package/src/__tests__/material-contract-inventory.unit.test.ts +24 -0
- package/src/__tests__/material-cooked-projection.unit.test.ts +150 -0
- package/src/__tests__/material-snapshot-mutation.integration.test.ts +53 -0
- package/src/__tests__/material-static-dynamic-route.unit.test.ts +94 -0
- package/src/__tests__/material-stride-surface.unit.test.ts +32 -0
- package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +106 -0
- package/src/__tests__/mesh-material-bindings.unit.test.ts +89 -0
- package/src/__tests__/mesh-stride-surface.unit.test.ts +16 -0
- package/src/__tests__/morph-culling-reentry.dawn.test.ts +157 -0
- package/src/__tests__/morph-feature-gpu-work.integration.test.ts +80 -0
- package/src/__tests__/morph-feature-plan.unit.test.ts +53 -0
- package/src/__tests__/morph-visual-evidence.integration.test.ts +80 -0
- package/src/__tests__/nineslice-handle-surface.unit.test.ts +19 -0
- package/src/__tests__/post-process-error-policy-owner.unit.test.ts +201 -0
- package/src/__tests__/prepared-buffer-kind-owner.test-d.ts +29 -0
- package/src/__tests__/prepared-graphics-capability.unit.test.ts +65 -0
- package/src/__tests__/prepared-graphics-generation.unit.test.ts +161 -0
- package/src/__tests__/prepared-graphics-lifecycle.unit.test.ts +138 -0
- package/src/__tests__/prepared-graphics-pipeline-warmup.unit.test.ts +29 -0
- package/src/__tests__/prepared-graphics-resolver.unit.test.ts +285 -0
- package/src/__tests__/prepared-graphics-snapshot.unit.test.ts +105 -0
- package/src/__tests__/prepared-graphics-store.unit.test.ts +154 -0
- package/src/__tests__/prepared-graphics-validation.unit.test.ts +176 -0
- package/src/__tests__/profiler-default-off.test.ts +22 -0
- package/src/__tests__/profiler-phase-catalog.test.ts +66 -0
- package/src/__tests__/profiler-phase-integration.test.ts +62 -0
- package/src/__tests__/profiler-phase-token.test.ts +79 -0
- package/src/__tests__/public-surface.test-d.ts +94 -0
- package/src/__tests__/recover-error-policy-owner.unit.test.ts +107 -0
- package/src/__tests__/render-error-code-owner.test-d.ts +116 -0
- package/src/__tests__/render-error-detail-ssot.test-d.ts +139 -0
- package/src/__tests__/render-error-exhaustive.test-d.ts +110 -0
- package/src/__tests__/render-feature-assembly.test-d.ts +43 -0
- package/src/__tests__/render-feature-context.test-d.ts +57 -0
- package/src/__tests__/render-feature-contract.test-d.ts +49 -0
- package/src/__tests__/render-feature-diagnostics.unit.test.ts +149 -0
- package/src/__tests__/render-feature-docs.unit.test.ts +89 -0
- package/src/__tests__/render-feature-errors.unit.test.ts +47 -0
- package/src/__tests__/render-feature-gpu-work.integration.test.ts +316 -0
- package/src/__tests__/render-feature-plan.test-d.ts +161 -0
- package/src/__tests__/render-feature-plan.unit.test.ts +170 -0
- package/src/__tests__/render-feature-prepared-diagnostics.unit.test.ts +90 -0
- package/src/__tests__/render-feature-prepared-errors.test-d.ts +79 -0
- package/src/__tests__/render-feature-prepared-graphics-docs.unit.test.ts +80 -0
- package/src/__tests__/render-feature-prepared-graphics-isolation.unit.test.ts +72 -0
- package/src/__tests__/render-feature-prepared-graphics-negative.test-d.ts +36 -0
- package/src/__tests__/render-feature-prepared-graphics.test-d.ts +74 -0
- package/src/__tests__/render-feature-prepared-graphics.unit.test.ts +121 -0
- package/src/__tests__/render-feature-public-surface.test-d.ts +57 -0
- package/src/__tests__/render-feature-retry.unit.test.ts +87 -0
- package/src/__tests__/render-feature-scheduling.unit.test.ts +110 -0
- package/src/__tests__/render-feature-stage-failed-policy-owner.unit.test.ts +137 -0
- package/src/__tests__/render-feature-stage-fault-recovery.unit.test.ts +146 -0
- package/src/__tests__/render-feature-visibility-context.test-d.ts +33 -0
- package/src/__tests__/render-feature-visibility-context.unit.test.ts +37 -0
- package/src/__tests__/render-feature-zero-work.unit.test.ts +76 -0
- package/src/__tests__/render-graph-compute-raster-gpu.ts +342 -0
- package/src/__tests__/render-graph-compute-raster.browser.test.ts +16 -0
- package/src/__tests__/render-graph-compute-raster.dawn.test.ts +22 -0
- package/src/__tests__/render-graph-hzb-gpu.ts +239 -0
- package/src/__tests__/render-graph-hzb.browser.test.ts +16 -0
- package/src/__tests__/render-graph-hzb.dawn.test.ts +23 -0
- package/src/__tests__/render-graph-primitives.unit.test.ts +22 -0
- package/src/__tests__/render-graph-storage-indirect-gpu.ts +162 -0
- package/src/__tests__/render-graph-storage-indirect.browser.test.ts +21 -0
- package/src/__tests__/render-graph-storage-indirect.dawn.test.ts +28 -0
- package/src/__tests__/render-scene-projection.unit.test.ts +168 -0
- package/src/__tests__/renderer-factory-material-contract.unit.test.ts +121 -0
- package/src/__tests__/renderer-lifecycle.integration.test.ts +58 -0
- package/src/__tests__/rhi-backend-package-boundary.unit.test.ts +11 -0
- package/src/__tests__/scriptable-pack-consumer.test-d.ts +14 -0
- package/src/__tests__/shader-stage-surface.unit.test.ts +43 -0
- package/src/__tests__/ssao-config.unit.test.ts +36 -0
- package/src/__tests__/ssao-kernel-surface.unit.test.ts +14 -0
- package/src/__tests__/standard-clustered-pipeline.unit.test.ts +28 -0
- package/src/__tests__/standard-descriptors.test-d.ts +22 -0
- package/src/__tests__/standard-pipeline.dawn.test.ts +61 -0
- package/src/__tests__/standard-pipeline.integration.test.ts +124 -0
- package/src/__tests__/standard-profile-oracle.unit.test.ts +62 -0
- package/src/__tests__/surface-configuration.unit.test.ts +91 -0
- package/src/__tests__/texture-usage-surface.unit.test.ts +58 -0
- package/src/__tests__/typed-pipeline-topology.unit.test.ts +223 -0
- package/src/__tests__/urp-pipeline-targets.unit.test.ts +42 -0
- package/src/__tests__/urp-pipeline.unit.test.ts +14 -0
- package/src/__tests__/vertex-color-pipeline.unit.test.ts +132 -0
- package/src/__tests__/vertex-layout-consumer.unit.test.ts +25 -0
- package/src/__tests__/visibility-diagnostics.integration.test.ts +62 -0
- package/src/__tests__/visibility-extract.integration.test.ts +98 -0
- package/src/__tests__/visibility-extract.perf.test.ts +130 -0
- package/src/__tests__/visibility-feature-dedup.integration.test.ts +55 -0
- package/src/__tests__/visibility-hidden-report.unit.test.ts +36 -0
- package/src/__tests__/visibility-instances.integration.test.ts +46 -0
- package/src/__tests__/visibility-multi-world.integration.test.ts +86 -0
- package/src/__tests__/visibility-producer-matrix.integration.test.ts +168 -0
- package/src/__tests__/visibility-producer-matrix.unit.test.ts +96 -0
- package/src/__tests__/visibility-public-surface.test-d.ts +27 -0
- package/src/__tests__/visibility-resolver.unit.test.ts +85 -0
- package/src/__tests__/visibility-resource-short-circuit.unit.test.ts +80 -0
- package/src/__tests__/visibility-snapshot-contract.unit.test.ts +60 -0
- package/src/__tests__/vocabulary.unit.test.ts +21 -0
- package/src/assembly/backend-contract.ts +57 -0
- package/src/assembly/factory.ts +8130 -0
- package/src/assembly/host-contract.ts +101 -0
- package/src/assembly/material/assembly.ts +48 -0
- package/src/assembly/material/pipeline-projection.ts +63 -0
- package/src/assets/asset-decoders.ts +158 -0
- package/src/assets/render-asset-port.ts +44 -0
- package/src/assets/resolve-render-asset.ts +13 -0
- package/src/authoring.ts +17 -0
- package/src/cluster-binner.ts +700 -0
- package/src/components/__tests__/points-lines-admission.unit.test.ts +219 -0
- package/src/components/__tests__/points-lines-schema.unit.test.ts +56 -0
- package/src/components/__tests__/schema-contract.unit.test.ts +10 -0
- package/src/components/__tests__/schema-parity.unit.test.ts +25 -0
- package/src/components/__tests__/visibility-schema.unit.test.ts +35 -0
- package/src/components/camera.ts +512 -0
- package/src/components/directional-light.ts +120 -0
- package/src/components/glyph-text.ts +64 -0
- package/src/components/index.ts +54 -0
- package/src/components/instances.ts +206 -0
- package/src/components/layer.ts +69 -0
- package/src/components/light-helpers.ts +188 -0
- package/src/components/lines.ts +18 -0
- package/src/components/mesh-filter.ts +60 -0
- package/src/components/mesh-renderer.ts +61 -0
- package/src/components/point-light-shadow.ts +43 -0
- package/src/components/point-light.ts +65 -0
- package/src/components/points.ts +40 -0
- package/src/components/post-process-params.ts +35 -0
- package/src/components/scene-instance.ts +193 -0
- package/src/components/skybox-background.ts +103 -0
- package/src/components/skylight.ts +97 -0
- package/src/components/sort-key.ts +75 -0
- package/src/components/spot-light.ts +124 -0
- package/src/components/sprite-animation.ts +168 -0
- package/src/components/sprite-instances.ts +179 -0
- package/src/components/sprite-playback-mode.ts +79 -0
- package/src/components/sprite-region-override.ts +105 -0
- package/src/components/tile-layer.ts +167 -0
- package/src/components/tilemap.ts +48 -0
- package/src/components/visibility.ts +53 -0
- package/src/construct-renderer.ts +67 -0
- package/src/debug-draw-glue.ts +48 -0
- package/src/device/device-scope.ts +306 -0
- package/src/device/gpu-residency.ts +1822 -0
- package/src/device/resource-types.ts +20 -0
- package/src/engine-metrics.ts +82 -0
- package/src/errors/ecs-validation.ts +16 -0
- package/src/errors/index.ts +3 -0
- package/src/errors/recover.ts +161 -0
- package/src/errors/render.ts +1284 -0
- package/src/extract/visibility.ts +120 -0
- package/src/features/fullscreen.ts +46 -0
- package/src/features/host.ts +1363 -0
- package/src/features/morph/morph-feature.ts +377 -0
- package/src/features/plan.ts +447 -0
- package/src/features/prepared-gpu-work.ts +959 -0
- package/src/features/prepared-graphics-store.ts +449 -0
- package/src/features/prepared-graphics.ts +420 -0
- package/src/features/render-graph-compute.ts +69 -0
- package/src/features/render-graph-contribution.ts +75 -0
- package/src/features/render-graph-raster.ts +332 -0
- package/src/features/render-graph-resources.ts +38 -0
- package/src/features/targets.ts +70 -0
- package/src/features/types.ts +103 -0
- package/src/features/vocabulary.ts +24 -0
- package/src/fullscreen-post-process-pass.ts +363 -0
- package/src/glyph-text-layout-system.ts +421 -0
- package/src/gpu-driven/batch-topology.ts +245 -0
- package/src/gpu-driven/production-raster.ts +695 -0
- package/src/gpu-driven/view-gpu.ts +703 -0
- package/src/gpu-resource.ts +159 -0
- package/src/gpu-scene-schema.ts +136 -0
- package/src/gpu-scene.ts +722 -0
- package/src/gpu-stage.ts +9 -0
- package/src/gpu-texture-usage.ts +12 -0
- package/src/gpu-usage.ts +14 -0
- package/src/hdrp-buffers.ts +480 -0
- package/src/ibl/IblPipelineCache.ts +1006 -0
- package/src/ibl/skylight-bind-group.ts +513 -0
- package/src/index.ts +129 -0
- package/src/inspection-types.ts +80 -0
- package/src/instance-buffer-cache.ts +95 -0
- package/src/internal.ts +9 -0
- package/src/lifecycle.ts +168 -0
- package/src/light-buffer-layout.ts +346 -0
- package/src/materials.ts +214 -0
- package/src/mesh-material-bindings.ts +103 -0
- package/src/pbr-pipeline.ts +1137 -0
- package/src/pipeline/standard-forward-lane.ts +229 -0
- package/src/pipeline/standard-pipeline.ts +555 -0
- package/src/pipeline/standard-profile.ts +65 -0
- package/src/pipeline-builder.ts +382 -0
- package/src/pipeline-spec-types.ts +28 -0
- package/src/pipeline-spec.ts +1231 -0
- package/src/plugin.ts +69 -0
- package/src/points-lines/__tests__/bounds.unit.test.ts +21 -0
- package/src/points-lines/__tests__/cache-recovery.unit.test.ts +157 -0
- package/src/points-lines/__tests__/inspection-lanes.integration.test.ts +146 -0
- package/src/points-lines/__tests__/inspection.unit.test.ts +173 -0
- package/src/points-lines/__tests__/lane-structural.integration.test.ts +122 -0
- package/src/points-lines/__tests__/points-lines.bench.ts +400 -0
- package/src/points-lines/__tests__/raster-lane.integration.test.ts +44 -0
- package/src/points-lines/__tests__/snapshot.unit.test.ts +101 -0
- package/src/points-lines/__tests__/view-ubo.unit.test.ts +73 -0
- package/src/points-lines/admission.ts +238 -0
- package/src/points-lines/bounds.ts +23 -0
- package/src/points-lines/expansion-cache.ts +215 -0
- package/src/points-lines/inspection.ts +97 -0
- package/src/points-lines/prepare.ts +177 -0
- package/src/points-lines/record.ts +125 -0
- package/src/points-lines/snapshot.ts +130 -0
- package/src/post-process-errors.ts +302 -0
- package/src/prepare/prepared-graphics-resolver.ts +456 -0
- package/src/record/__tests__/material-bg-cache.unit.test.ts +41 -0
- package/src/record/__tests__/material-slot-plan.unit.test.ts +44 -0
- package/src/record/__tests__/typed-frame-graph-plan.unit.test.ts +41 -0
- package/src/record/__tests__/video-texture-view.unit.test.ts +90 -0
- package/src/record/frame-lighting.ts +1074 -0
- package/src/record/frame-snapshot.ts +500 -0
- package/src/record/frame-targets.ts +137 -0
- package/src/record/frame.ts +1598 -0
- package/src/record/helpers.ts +284 -0
- package/src/record/main-pass-geometry.ts +1065 -0
- package/src/record/main-pass-material.ts +1043 -0
- package/src/record/main-pass-sprite-draws.ts +1137 -0
- package/src/record/main-pass.ts +610 -0
- package/src/record/mesh-ssbo.ts +885 -0
- package/src/record/render-context.ts +384 -0
- package/src/record/shadow-pass.ts +698 -0
- package/src/record/skybox-post-pass.ts +593 -0
- package/src/record/typed-frame-graph.ts +461 -0
- package/src/record/view-ubo.ts +222 -0
- package/src/render-contract.ts +582 -0
- package/src/render-data.ts +385 -0
- package/src/render-graph-primitives.ts +811 -0
- package/src/render-pipeline.ts +259 -0
- package/src/render-system-extract.ts +4585 -0
- package/src/render-system.ts +1933 -0
- package/src/scene/render-scene-types.ts +67 -0
- package/src/scene/render-scene.ts +1087 -0
- package/src/scene-instances/post-spawn-resolve-joints.ts +165 -0
- package/src/shadow-atlas.ts +244 -0
- package/src/ssao-buffers.ts +334 -0
- package/src/ssao-config.ts +61 -0
- package/src/systems/active-camera.ts +116 -0
- package/src/systems/pass-selector.ts +49 -0
- package/src/systems/skin-palette-allocator.ts +251 -0
- package/src/systems/skin-palette-types.ts +14 -0
- package/src/systems/transparent-sort-config.ts +233 -0
- package/src/tilemap-chunk-extract-system.ts +1290 -0
- package/src/typed-render-graph-primitives.ts +541 -0
- package/src/typed-shadow-passes.ts +165 -0
|
@@ -0,0 +1,4585 @@
|
|
|
1
|
+
// @forgeax/engine-runtime - RenderSystem Extract stage (D-S2 + plan-strategy R-15
|
|
2
|
+
// fallback split). Pure ECS query phase: walks Camera / DirectionalLight /
|
|
3
|
+
// the merged-MeshRenderer renderable archetype and produces SoA-free snapshot
|
|
4
|
+
// arrays consumed by the Record stage.
|
|
5
|
+
//
|
|
6
|
+
// Carve-out from render-system.ts (review round 1 finding #3 - 505 line cap
|
|
7
|
+
// fallback split into 3 files; main + extract + record). The public API remains
|
|
8
|
+
// limited to the renderer contract; this module is an owner-local
|
|
9
|
+
// implementation seam consumed through relative imports.
|
|
10
|
+
//
|
|
11
|
+
// w15 (feat-20260511-asset-system-v1 M5 / plan-strategy D-P4) +
|
|
12
|
+
// feat-20260513-component-naming-bevy-align M3 / D-2 (merged) +
|
|
13
|
+
// feat-20260517-merge-mesh-renderer-material-renderer M2 / w5 (rename) +
|
|
14
|
+
// M3 / w9 (single-query convergence; this commit):
|
|
15
|
+
// The previous 4-query alpha split (one full-archetype query gating on
|
|
16
|
+
// Transform + MeshFilter + MeshRenderer without Instances; one direct
|
|
17
|
+
// archetype-graph walk for the instanced variant; two fallback queries
|
|
18
|
+
// on Transform+MeshFilter without MeshRenderer / MeshFilter without
|
|
19
|
+
// Transform; one trailing dispatch query gating on MeshRenderer alone)
|
|
20
|
+
// collapses into ONE archetype-graph walk gated on `MeshRenderer`
|
|
21
|
+
// component presence (plan-strategy section 2.2 case A archetype-
|
|
22
|
+
// natural-absence + section 3.2 sequence diagram "with: [MeshRenderer]
|
|
23
|
+
// (sole)"). Inside the loop
|
|
24
|
+
// the four D-Q7 dispositions land:
|
|
25
|
+
// - case A (no MeshRenderer) : not in domain; archetype absent. No
|
|
26
|
+
// fire; no renderable; no dispatch.
|
|
27
|
+
// - case B (material === 0) : missing-spec sentinel; defaultMaterial
|
|
28
|
+
// Snapshot fills the slot. No fire.
|
|
29
|
+
// - case C (handle unresolved): assets.get(handle).err with the entity
|
|
30
|
+
// carrying the full T+MF+MR renderable
|
|
31
|
+
// archetype -> structured RhiError(
|
|
32
|
+
// `asset-not-registered`) routed through
|
|
33
|
+
// the World Layer-3 ErrorHandler + entity
|
|
34
|
+
// skipped from RenderableSnapshot[] +
|
|
35
|
+
// from MaterialDispatchSnapshot[]; with a
|
|
36
|
+
// non-renderable archetype (MeshRenderer
|
|
37
|
+
// only / lacking T or MF) the dangling
|
|
38
|
+
// handle stays a silent skip (charter F1
|
|
39
|
+
// surface minimization: AI users without
|
|
40
|
+
// render intent should not see render
|
|
41
|
+
// errors).
|
|
42
|
+
// - resolved : populated MaterialSnapshot (5 fields)
|
|
43
|
+
// + MaterialDispatchSnapshot dispatch
|
|
44
|
+
// entry (regardless of T+MF presence,
|
|
45
|
+
// preserving the dispatch-only counter
|
|
46
|
+
// semantics).
|
|
47
|
+
//
|
|
48
|
+
// D-Q7 three-tier subtable (SSOT mirrored in `packages/runtime/README.md`
|
|
49
|
+
// §ECS render bridge D-Q7 `MeshRenderer` three-tier subtable +
|
|
50
|
+
// `components/mesh-renderer.ts` head JSDoc; AGENTS.md §Component naming
|
|
51
|
+
// + §Breaking changes 2026-05-17 row references this surface):
|
|
52
|
+
// - case A archetype absent : silent skip; no onError; not in
|
|
53
|
+
// RenderableSnapshot[]; hint literal N/A
|
|
54
|
+
// (charter "ergonomic omission, not a
|
|
55
|
+
// misuse"; AC-09).
|
|
56
|
+
// - case B missing-spec : `material === undefined` ->
|
|
57
|
+
// `defaultMaterialSnapshot()` mid-grey
|
|
58
|
+
// unlit; no onError; entity present in
|
|
59
|
+
// RenderableSnapshot[] with default
|
|
60
|
+
// material; hint literal: `'pass
|
|
61
|
+
// undefined or omit field to request
|
|
62
|
+
// default material'` (AC-10).
|
|
63
|
+
// - case C dangling-ref : `assets.get(handle).err` (strict path,
|
|
64
|
+
// isRenderable === true) ->
|
|
65
|
+
// `RhiError({ code: 'asset-not-
|
|
66
|
+
// registered', detail: { assetHandle },
|
|
67
|
+
// hint: 'register material via
|
|
68
|
+
// assetRegistry.register(asset) before
|
|
69
|
+
// spawn, or remove the material field
|
|
70
|
+
// to fall back to default' })` routed
|
|
71
|
+
// through World Layer-3 ErrorHandler;
|
|
72
|
+
// entity skipped from
|
|
73
|
+
// RenderableSnapshot[] +
|
|
74
|
+
// MaterialDispatchSnapshot[]; mirrors
|
|
75
|
+
// `MeshFilter.assetHandle` dangling path
|
|
76
|
+
// (charter proposition 5; AC-11).
|
|
77
|
+
//
|
|
78
|
+
// feat-20260514-ecs-children-instances-managed-buffer-array M3 / w15:
|
|
79
|
+
// The legacy `Instances { buffer, count }` form (cross-coupled with the
|
|
80
|
+
// deleted `AssetRegistry.createInstancedBuffer` triplet + the retired
|
|
81
|
+
// `InstancedBufferAsset` POD) is gone. Per-entity instance transforms now
|
|
82
|
+
// live inside the ECS `Instances { transforms: 'array<f32>' }` column;
|
|
83
|
+
// extract materialises a `Float32Array` snapshot per Instances-bearing
|
|
84
|
+
// entity (via `world.get(e, Instances).transforms`) so the record stage
|
|
85
|
+
// can upload to a per-entity GPU storage buffer cached by entity packed
|
|
86
|
+
// u32.
|
|
87
|
+
//
|
|
88
|
+
// feat-20260515-buffer-array-vocab-collapse M3 / w15: the component-level
|
|
89
|
+
// `arrayStride` hook on `Instances.transforms` was retired (decision section
|
|
90
|
+
// 2.3 stride responsibility migration -- AI users gate at the set / push
|
|
91
|
+
// call sites + RenderSystem extract entry holds a defensive fail-fast).
|
|
92
|
+
// The extract entry checks `transforms.length % 16 === 0` directly after
|
|
93
|
+
// the `world.get(entity, Instances)` snapshot is taken; violations route
|
|
94
|
+
// `InstanceTransformsStrideMismatchError`
|
|
95
|
+
// (`code: 'instance-transforms-stride-mismatch'`,
|
|
96
|
+
// `detail: { actualLength, expectedStride: 16 }`) through the World
|
|
97
|
+
// Layer-3 ErrorHandler and the renderable is skipped. The record stage
|
|
98
|
+
// trusts the invariant and does not re-check.
|
|
99
|
+
|
|
100
|
+
import type { AssetRegistry } from '@forgeax/engine-assets-runtime';
|
|
101
|
+
import {
|
|
102
|
+
resolveAssetHandle,
|
|
103
|
+
walkMaterialPassesOverSharedRefs,
|
|
104
|
+
} from '@forgeax/engine-assets-runtime';
|
|
105
|
+
import type { Component, ComponentSchema, EntityHandle, World } from '@forgeax/engine-ecs';
|
|
106
|
+
import {
|
|
107
|
+
InstanceTransformsStrideMismatchError,
|
|
108
|
+
readRenderArrayView,
|
|
109
|
+
routeWorldError,
|
|
110
|
+
SpawnLightInvalidBoundsError,
|
|
111
|
+
SpriteInstancesCountMismatchError,
|
|
112
|
+
SpriteInstancesMutuallyExclusiveWithInstancesError,
|
|
113
|
+
SpriteInstancesRequiresSpriteShaderError,
|
|
114
|
+
} from '@forgeax/engine-ecs/projection';
|
|
115
|
+
import { box3, frustum, type Mat4, mat4, type Vec3, vec3 } from '@forgeax/engine-math';
|
|
116
|
+
import { AssetGuid, type AssetGuid as AssetGuidBytes } from '@forgeax/engine-pack/guid';
|
|
117
|
+
import { RhiError } from '@forgeax/engine-rhi';
|
|
118
|
+
import {
|
|
119
|
+
MorphWeights,
|
|
120
|
+
projectHierarchy,
|
|
121
|
+
type SceneHierarchySnapshot,
|
|
122
|
+
Transform,
|
|
123
|
+
} from '@forgeax/engine-scene';
|
|
124
|
+
import {
|
|
125
|
+
JointCountMismatchError,
|
|
126
|
+
JointEntityDanglingError,
|
|
127
|
+
SkeletonResolveFailedError,
|
|
128
|
+
Skin,
|
|
129
|
+
SkinInstancesCoexistForbiddenError,
|
|
130
|
+
} from '@forgeax/engine-skinning';
|
|
131
|
+
import type {
|
|
132
|
+
Asset,
|
|
133
|
+
Handle,
|
|
134
|
+
MaterialAsset,
|
|
135
|
+
MaterialColorParameterSchema,
|
|
136
|
+
MaterialParameter,
|
|
137
|
+
MaterialPass,
|
|
138
|
+
MaterialRenderState,
|
|
139
|
+
MaterialTextureCoordinates,
|
|
140
|
+
MaterialTextureValue,
|
|
141
|
+
MeshAsset,
|
|
142
|
+
ParamSchemaEntry,
|
|
143
|
+
PrimitiveTopology,
|
|
144
|
+
SkeletonAsset,
|
|
145
|
+
} from '@forgeax/engine-types';
|
|
146
|
+
import {
|
|
147
|
+
ASSET_ERROR_HINTS,
|
|
148
|
+
AssetError,
|
|
149
|
+
derive,
|
|
150
|
+
materialGuidText,
|
|
151
|
+
materialValuesToLinearRuntime,
|
|
152
|
+
toShared,
|
|
153
|
+
} from '@forgeax/engine-types';
|
|
154
|
+
import {
|
|
155
|
+
antialiasFromF32,
|
|
156
|
+
bloomEnabledFromF32,
|
|
157
|
+
Camera,
|
|
158
|
+
cameraProjectionFromF32,
|
|
159
|
+
DirectionalLight,
|
|
160
|
+
Instances,
|
|
161
|
+
Layer,
|
|
162
|
+
Lines,
|
|
163
|
+
MeshFilter,
|
|
164
|
+
MeshRenderer,
|
|
165
|
+
PointLight,
|
|
166
|
+
PointLightShadow,
|
|
167
|
+
Points,
|
|
168
|
+
PostProcessParams,
|
|
169
|
+
pointShapeFromU32,
|
|
170
|
+
SkyboxBackground,
|
|
171
|
+
Skylight,
|
|
172
|
+
SortKey,
|
|
173
|
+
SpotLight,
|
|
174
|
+
SpriteInstances,
|
|
175
|
+
SpriteRegionOverride,
|
|
176
|
+
tonemapFromF32,
|
|
177
|
+
tonemapToU32,
|
|
178
|
+
} from './components';
|
|
179
|
+
import { GlyphText } from './components/glyph-text';
|
|
180
|
+
import { computeInvRangeSquared, degToCos } from './components/light-helpers';
|
|
181
|
+
import {
|
|
182
|
+
MaterialSkinAttrMissingError,
|
|
183
|
+
ShadowInvalidConfigError,
|
|
184
|
+
SkinMaterialMismatchError,
|
|
185
|
+
} from './errors/render';
|
|
186
|
+
import { resolveVisibility, type VisibilitySnapshot } from './extract/visibility';
|
|
187
|
+
import type {
|
|
188
|
+
RenderFeatureHiddenEntityReport,
|
|
189
|
+
RenderFeatureWorldVisibilitySnapshot,
|
|
190
|
+
} from './features/types';
|
|
191
|
+
import { ensureGlyphMeshMaterialSlots } from './glyph-text-layout-system';
|
|
192
|
+
import {
|
|
193
|
+
type MeshMaterialBindingDiagnostic,
|
|
194
|
+
type MeshMaterialBindingSource,
|
|
195
|
+
resolveMeshMaterialBindings,
|
|
196
|
+
} from './mesh-material-bindings';
|
|
197
|
+
import { isStandardPbrMaterialShader } from './pbr-pipeline';
|
|
198
|
+
import { expandPointsLinesBounds } from './points-lines/bounds';
|
|
199
|
+
import type { PointsLinesRetainedSnapshot, PointsLinesStyle } from './points-lines/snapshot';
|
|
200
|
+
import { type CameraSnapshot, STANDARD_TONEMAP_FEATURE_ID } from './render-contract';
|
|
201
|
+
import { getActiveCamera, selectActiveCameraIndex } from './systems/active-camera';
|
|
202
|
+
import { selectPasses } from './systems/pass-selector';
|
|
203
|
+
import type { SkinPaletteAllocator } from './systems/skin-palette-allocator';
|
|
204
|
+
import type { SkinPaletteSlice } from './systems/skin-palette-types';
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* DirectionalLightSnapshot — sun-like infinite light variant of the
|
|
208
|
+
* `LightSnapshot` discriminated union (M2 / w16 / AC-03). Host pre-multiplies
|
|
209
|
+
* `color * intensity` so the shader sees the radiance term directly (charter
|
|
210
|
+
* P4 host-side parity); `direction` stays in raw outgoing-vector form so the
|
|
211
|
+
* shader can negate it once for BRDF (`let l = normalize(-light.direction)`).
|
|
212
|
+
*
|
|
213
|
+
* Plan-strategy D-S1 (3) (LightSnapshot to GPU buffer bucket 1:1 mapping).
|
|
214
|
+
*/
|
|
215
|
+
export interface DirectionalLightSnapshot {
|
|
216
|
+
readonly kind: 'directional';
|
|
217
|
+
readonly direction: Vec3;
|
|
218
|
+
readonly color: Vec3;
|
|
219
|
+
readonly intensity: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* PointLightSnapshot — omnidirectional point variant. `position` from the
|
|
224
|
+
* companion Transform; `invRangeSquared = 1 / range^2` host-folded via
|
|
225
|
+
* `computeInvRangeSquared` (range = +Infinity -> 0; range = 0 -> 1e8 NaN
|
|
226
|
+
* protection per D-S5).
|
|
227
|
+
*/
|
|
228
|
+
export interface PointLightSnapshot {
|
|
229
|
+
readonly kind: 'point';
|
|
230
|
+
readonly position: Vec3;
|
|
231
|
+
readonly color: Vec3;
|
|
232
|
+
readonly intensity: number;
|
|
233
|
+
readonly invRangeSquared: number;
|
|
234
|
+
/**
|
|
235
|
+
* feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-8 + M4 / T-M4-4:
|
|
236
|
+
* cube_array atlas layer index for this light's shadow map (0..3 for shadow
|
|
237
|
+
* casters; sentinel `-1` for non-shadow lights). Default `-1` per
|
|
238
|
+
* plan-strategy §D-2 — record stage / shader skips shadow sampling when the
|
|
239
|
+
* lane equals the sentinel. Joined with `pointShadow[]` by entity at the
|
|
240
|
+
* end of extract; HDRP record stage threads `shadowAtlasLayer + shadowNear +
|
|
241
|
+
* shadowFar` through `packLightSlot` as the §D-8 pad-lane payload.
|
|
242
|
+
*/
|
|
243
|
+
readonly shadowAtlasLayer?: number;
|
|
244
|
+
/**
|
|
245
|
+
* Per-face perspective near plane (matches `PointLightShadow.nearPlane`).
|
|
246
|
+
* Used by HDRP `evalPointShadowed` for depth-ref reconstruction; rides
|
|
247
|
+
* `LightSlot.kind_and_pad.z` (byte 56..60) on the std430 std layout.
|
|
248
|
+
*/
|
|
249
|
+
readonly shadowNear?: number;
|
|
250
|
+
/**
|
|
251
|
+
* Per-face perspective far plane (matches `PointLightShadow.farPlane`).
|
|
252
|
+
* Rides `LightSlot.kind_and_pad.w` (byte 60..64).
|
|
253
|
+
*/
|
|
254
|
+
readonly shadowFar?: number;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* PointShadowSnapshot — extract-stage view of one shadow-casting point light
|
|
259
|
+
* (feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-7).
|
|
260
|
+
*
|
|
261
|
+
* `entity` is the spawning entity (joins PointLight + PointLightShadow on the
|
|
262
|
+
* same archetype); `position` is the light world-space position resolved from
|
|
263
|
+
* the companion Transform (mirrors PointLightSnapshot.position).
|
|
264
|
+
* `shadowMatrices` is the 96-float packed mat4[6] table (column-major,
|
|
265
|
+
* 6 mat4 * 16 floats = 384 bytes) ready for UBO upload.
|
|
266
|
+
*
|
|
267
|
+
* Plan-strategy §D-3: the record stage allocates a dynamic-offset uniform
|
|
268
|
+
* buffer (384 B per-light, aligned to minUniformBufferOffsetAlignment, WebGPU
|
|
269
|
+
* min 256 -> 512 B stride). Extract owns the host-side `Float32Array(96)`;
|
|
270
|
+
* record owns the GPU `RhiBuffer` lifetime.
|
|
271
|
+
*
|
|
272
|
+
* `mapSize` / `nearPlane` / `farPlane` ride here so record stage can size the
|
|
273
|
+
* cube atlas faces and feed shader the proj constants without re-querying ECS.
|
|
274
|
+
* `shadowAtlasLayer` is assigned by extract in spawn order (0 .. cap-1) so the
|
|
275
|
+
* downstream LightSlot / packPointLight packers carry the layer index in
|
|
276
|
+
* pointPadW (T-M1-8 will rename to shadowAtlasLayer).
|
|
277
|
+
*/
|
|
278
|
+
export interface PointShadowSnapshot {
|
|
279
|
+
/** Spawning entity index (joins PointLight + PointLightShadow + Transform). */
|
|
280
|
+
readonly entity: number;
|
|
281
|
+
/** World-space light position (sourced from companion Transform). */
|
|
282
|
+
readonly position: Vec3;
|
|
283
|
+
/** PointLightShadow.mapSize (per-face cube square dimension; 512 default). */
|
|
284
|
+
readonly mapSize: number;
|
|
285
|
+
readonly nearPlane: number;
|
|
286
|
+
readonly farPlane: number;
|
|
287
|
+
/**
|
|
288
|
+
* Atlas layer assigned by extract in spawn order (0..cap-1, where cap=4
|
|
289
|
+
* matches PointLightShadow cardinality). Sentinel -1 is reserved by the
|
|
290
|
+
* shader-side LightSlot for no-shadow lights (T-M1-8).
|
|
291
|
+
*/
|
|
292
|
+
readonly shadowAtlasLayer: number;
|
|
293
|
+
/**
|
|
294
|
+
* 6 view-proj mat4 in face order [+X, -X, +Y, -Y, +Z, -Z] packed into one
|
|
295
|
+
* Float32Array(96). 16 floats per face = 64 B; 6 * 64 = 384 B raw. The
|
|
296
|
+
* record stage pads each slot to 512 B (256 alignment) when packing into
|
|
297
|
+
* the per-light dynamic-offset UBO (plan-strategy §D-3).
|
|
298
|
+
*/
|
|
299
|
+
readonly shadowMatrices: Float32Array;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* SpotLightSnapshot — cone-restricted variant. `position` from companion
|
|
304
|
+
* Transform; `direction` raw outgoing-vector; `cosInner` / `cosOuter` host
|
|
305
|
+
* pre-converted via `degToCos` so the shader sees only cosines (D-S2).
|
|
306
|
+
*
|
|
307
|
+
* feat-20260625-spot-light-shadow-mapping M1 w5: added shadow fields.
|
|
308
|
+
* castShadow (bool) gates shadow projection; lightViewProj is the perspective
|
|
309
|
+
* light-view-projection matrix computed in extract (undefined when castShadow
|
|
310
|
+
* is false, dir degenerates, or the light is clipped). shadowAtlasTile
|
|
311
|
+
* (i32 sentinel -1) is the allocated tile index 0..3 or -1 for unassigned
|
|
312
|
+
* (plan-strategy D-4). mapSize / nearPlane / farPlane are the source
|
|
313
|
+
* component shadow parameters carried through to the record stage.
|
|
314
|
+
*/
|
|
315
|
+
export interface SpotLightSnapshot {
|
|
316
|
+
readonly kind: 'spot';
|
|
317
|
+
readonly position: Vec3;
|
|
318
|
+
readonly direction: Vec3;
|
|
319
|
+
readonly color: Vec3;
|
|
320
|
+
readonly intensity: number;
|
|
321
|
+
readonly invRangeSquared: number;
|
|
322
|
+
readonly cosInner: number;
|
|
323
|
+
readonly cosOuter: number;
|
|
324
|
+
// ── shadow fields (feat-20260625-spot-light-shadow-mapping M1) ──
|
|
325
|
+
readonly castShadow: boolean;
|
|
326
|
+
readonly lightViewProj: Float32Array | undefined;
|
|
327
|
+
readonly mapSize: number;
|
|
328
|
+
readonly nearPlane: number;
|
|
329
|
+
readonly farPlane: number;
|
|
330
|
+
readonly shadowAtlasTile: number;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* LightSnapshot — discriminated union of the three KHR_lights_punctual
|
|
335
|
+
* variants. AI users + the record stage perform an exhaustive switch on
|
|
336
|
+
* `kind`; missing arms are caught at compile time (no `default`,
|
|
337
|
+
* `assertNever`-style guards). Plan-strategy R-10.
|
|
338
|
+
*/
|
|
339
|
+
export type LightSnapshot = DirectionalLightSnapshot | PointLightSnapshot | SpotLightSnapshot;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* ExtractedLights — three-bucket output of the extractFrame three-query
|
|
343
|
+
* union (M2 / w16 / AC-03). Plan-strategy section 3.1 EXT node:
|
|
344
|
+
* - `directional` — at most one (record-stage N>1 fail-fast lands in M3)
|
|
345
|
+
* - `point[]` — first-slice cap of 4 enforced at record stage (M3)
|
|
346
|
+
* - `spot[]` — first-slice cap of 4 enforced at record stage (M3)
|
|
347
|
+
* `directionalCount` exposes the raw count of DirectionalLight entities the
|
|
348
|
+
* extract observed (so the record stage can fire `render-system-multi-light`
|
|
349
|
+
* when N>1 without re-running the query). M3 / w19 will widen this with
|
|
350
|
+
* pointCount / spotCount fields once the matching record-time fail-fast
|
|
351
|
+
* lands; for M2 the field stays directional-only to keep AC-06 (a) intact.
|
|
352
|
+
*/
|
|
353
|
+
export interface ExtractedLights {
|
|
354
|
+
readonly directional: DirectionalLightSnapshot | undefined;
|
|
355
|
+
readonly directionalCount: number;
|
|
356
|
+
readonly point: readonly PointLightSnapshot[];
|
|
357
|
+
readonly spot: readonly SpotLightSnapshot[];
|
|
358
|
+
/**
|
|
359
|
+
* feat-20260613-csm-cascaded-shadow-maps M2 / w9: per-cascade light-view-
|
|
360
|
+
* projection matrices (one per cascade, length 4 pre-allocated). Each matrix
|
|
361
|
+
* is a column-major 16-float mat4 with atlas tile UV inset baked in
|
|
362
|
+
* (plan-strategy D-3). cascadeCount < 4: unused slots are zero matrices.
|
|
363
|
+
* Undefined when castShadow=false or no directional light.
|
|
364
|
+
*/
|
|
365
|
+
readonly lightViewProj: readonly Float32Array[] | undefined;
|
|
366
|
+
/**
|
|
367
|
+
* feat-20260613-csm-cascaded-shadow-maps M2 / w9: view-space z depths
|
|
368
|
+
* of the PSSM split planes (length 4, Float32Array). cascadeCount < 4:
|
|
369
|
+
* unused slots are 0.0f. Undefined when castShadow=false or no directional
|
|
370
|
+
* light.
|
|
371
|
+
*/
|
|
372
|
+
readonly splitPlanes: Float32Array | undefined;
|
|
373
|
+
/**
|
|
374
|
+
* feat-20260613-csm-cascaded-shadow-maps M2 / w9: effective cascade count
|
|
375
|
+
* from the DirectionalLight component (1..4). Undefined when castShadow=false
|
|
376
|
+
* or no directional light.
|
|
377
|
+
*/
|
|
378
|
+
readonly cascadeCount: number | undefined;
|
|
379
|
+
/**
|
|
380
|
+
* feat-20260613-csm-cascaded-shadow-maps M2 / w9: cascade blend width
|
|
381
|
+
* from the DirectionalLight component (0..0.5). Undefined when castShadow=false
|
|
382
|
+
* or no directional light.
|
|
383
|
+
*/
|
|
384
|
+
readonly cascadeBlend: number | undefined;
|
|
385
|
+
/**
|
|
386
|
+
* feat-20260520-directional-light-shadow-mapping M1c / w8:
|
|
387
|
+
* shadowMapSize from DirectionalLight.mapSize. Drives shadow RT
|
|
388
|
+
* lazy-allocate (idempotency: same size -> no rebuild). Undefined when
|
|
389
|
+
* castShadow=false or no directional light.
|
|
390
|
+
*/
|
|
391
|
+
readonly shadowMapSize: number | undefined;
|
|
392
|
+
/**
|
|
393
|
+
* feat-20260621-merge-directionallightshadow-into-directionallight M2:
|
|
394
|
+
* depthBias from the merged DirectionalLight (constant shadow-bias floor).
|
|
395
|
+
* Populated when castShadow=true on the first-hit directional light;
|
|
396
|
+
* undefined otherwise.
|
|
397
|
+
*/
|
|
398
|
+
readonly depthBias: number | undefined;
|
|
399
|
+
/**
|
|
400
|
+
* feat-20260621-merge-directionallightshadow-into-directionallight M2:
|
|
401
|
+
* normalBias from the merged DirectionalLight (slope-based shadow-bias
|
|
402
|
+
* coefficient).
|
|
403
|
+
* Populated when castShadow=true on the first-hit directional light;
|
|
404
|
+
* undefined otherwise.
|
|
405
|
+
*/
|
|
406
|
+
readonly normalBias: number | undefined;
|
|
407
|
+
/**
|
|
408
|
+
* feat-20260621-merge-directionallightshadow-into-directionallight M2:
|
|
409
|
+
* pcfKernelSize from the merged DirectionalLight (PCF kernel width, odd>=1).
|
|
410
|
+
* Populated when castShadow=true on the first-hit directional light;
|
|
411
|
+
* undefined otherwise.
|
|
412
|
+
*/
|
|
413
|
+
readonly pcfKernelSize: number | undefined;
|
|
414
|
+
/**
|
|
415
|
+
* feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-7:
|
|
416
|
+
* shadow-casting point lights (PointLight + PointLightShadow + Transform
|
|
417
|
+
* archetype join). Each entry carries the per-light 6-face VP matrices and
|
|
418
|
+
* the assigned cube_array atlas layer (0..3 in spawn order; sentinel -1 is
|
|
419
|
+
* reserved by the shader-side LightSlot for no-shadow point lights).
|
|
420
|
+
*
|
|
421
|
+
* Empty array when no PointLightShadow components exist (zero-cost gate per
|
|
422
|
+
* AC-09; record stage skips atlas allocation + shadow pass dispatch).
|
|
423
|
+
*/
|
|
424
|
+
readonly pointShadow: readonly PointShadowSnapshot[];
|
|
425
|
+
/**
|
|
426
|
+
* bug-20260710-editor-cross-world-shadow: the RAW directional-shadow config
|
|
427
|
+
* (cascadeCount / splitLambda / cascadeBlend / mapSize / shadowDistance) of
|
|
428
|
+
* the first-hit castShadow DirectionalLight, carried unconditionally of
|
|
429
|
+
* whether this world had a camera. The {@link extractFrames} merge layer
|
|
430
|
+
* re-runs {@link computeDirectionalCsm} with this config + the surfaced
|
|
431
|
+
* camera so directional shadows work when the light and camera live in
|
|
432
|
+
* DIFFERENT worlds (editor editorWorld/sceneWorld super-composite). Undefined
|
|
433
|
+
* when castShadow=false or no directional light. The `lightViewProj` /
|
|
434
|
+
* `splitPlanes` fields above remain the *computed* per-world outputs (correct
|
|
435
|
+
* for single-world; recomputed at merge for cross-world).
|
|
436
|
+
*/
|
|
437
|
+
readonly directionalCsmConfig: DirectionalCsmConfig | undefined;
|
|
438
|
+
/**
|
|
439
|
+
* bug-20260710-editor-cross-world-shadow: the light-direction of the
|
|
440
|
+
* first-hit castShadow DirectionalLight, carried so the merge layer can
|
|
441
|
+
* rebuild the light-view matrix. Redundant with `directional.direction` but
|
|
442
|
+
* kept explicit so the CSM recompute reads a single config bundle. Undefined
|
|
443
|
+
* when castShadow=false or no directional light.
|
|
444
|
+
*/
|
|
445
|
+
readonly directionalCsmDirection: Vec3 | undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* SkylightSnapshot -- extract-stage view of one Skylight entity
|
|
450
|
+
* (feat-20260520-skylight-ibl-cubemap M4 / t26).
|
|
451
|
+
*
|
|
452
|
+
* `equirectHandle` carries the packed u32 handle for
|
|
453
|
+
* `Handle<EquirectAsset, 'shared'>`; the record stage drives the internal
|
|
454
|
+
* lazy cubemap projection from it and resolves the projected GPU cubemap via
|
|
455
|
+
* `GpuResidencyCache.getCubemapGpuView(...)` and related helpers
|
|
456
|
+
* (feat-20260630 M3 / w16-w18). The snapshot carries NO projection status
|
|
457
|
+
* field: the status truth lives in the store's CubemapGpuEntry (D-3 SSOT);
|
|
458
|
+
* record queries it once per frame.
|
|
459
|
+
*
|
|
460
|
+
* `intensity` defaults to 1.0 via the Skylight component token defaults
|
|
461
|
+
* (plan-strategy D-6: Skylight data flows through existing extract->record
|
|
462
|
+
* pipeline; no independent ECS system).
|
|
463
|
+
*/
|
|
464
|
+
export interface SkylightSnapshot {
|
|
465
|
+
// 0 = no equirect supplied -> solid-color ambient via the white fallback
|
|
466
|
+
// cube (record falls to fallback resources when no IBL views are cached).
|
|
467
|
+
readonly equirectHandle: number;
|
|
468
|
+
readonly color: readonly [number, number, number];
|
|
469
|
+
readonly intensity: number;
|
|
470
|
+
/** Environment-space quaternion [x, y, z, w], identity by default. */
|
|
471
|
+
readonly rotation: readonly [number, number, number, number];
|
|
472
|
+
// feat-20260630 M3 / w19: the WINNING Skylight entity's packed handle (first
|
|
473
|
+
// archetype hit). Carried so the multi-Skylight once-warn can name which
|
|
474
|
+
// entity is used and which is ignored (F-8: warn carries conflicting entity
|
|
475
|
+
// info). 0 only if the bundle had no live entity (never happens for a hit).
|
|
476
|
+
readonly entityHandle: number;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* SkyboxSnapshot -- extract-stage view of one SkyboxBackground entity
|
|
481
|
+
* (feat-20260531-skybox-env-background M2 / w5).
|
|
482
|
+
*
|
|
483
|
+
* `equirectHandle` carries the packed u32 handle for
|
|
484
|
+
* `Handle<EquirectAsset, 'shared'>`; the record stage resolves the projected
|
|
485
|
+
* GPU cubemap via `GpuResidencyCache.getCubemapGpuView(...)`.
|
|
486
|
+
*
|
|
487
|
+
* `mode` carries the raw `f32` column value (`SKYBOX_MODE_CUBEMAP = 0`).
|
|
488
|
+
* First hit wins per plan-strategy D-6; multi-entity once-warn in record stage.
|
|
489
|
+
*/
|
|
490
|
+
export interface SkyboxSnapshot {
|
|
491
|
+
readonly equirectHandle: number;
|
|
492
|
+
readonly mode: number;
|
|
493
|
+
/** Environment-space quaternion [x, y, z, w], identity by default. */
|
|
494
|
+
readonly rotation: readonly [number, number, number, number];
|
|
495
|
+
// feat-20260630 M3 / w19: the WINNING SkyboxBackground entity's packed handle
|
|
496
|
+
// (first archetype hit), so the multi-SkyboxBackground once-warn can name the
|
|
497
|
+
// used entity (F-8 parity with the Skylight warn).
|
|
498
|
+
readonly entityHandle: number;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export interface RenderableSnapshot {
|
|
502
|
+
readonly assetHandle: number;
|
|
503
|
+
readonly transform: TransformSnapshot;
|
|
504
|
+
/** Finite producer-owned local bounds retained for persistent CPU view work. */
|
|
505
|
+
readonly localAabb?: Float32Array;
|
|
506
|
+
/** Indexed single-submesh facts consumed by the first GPU-driven rigid lane. */
|
|
507
|
+
readonly gpuDrivenDraws?: readonly GpuDrivenDrawSnapshot[];
|
|
508
|
+
/**
|
|
509
|
+
* feat-20260608 M5 amend / w11-a: the entity's representative (first)
|
|
510
|
+
* material snapshot, kept as a same-name shorthand for `materials[0]`.
|
|
511
|
+
* Per-entity dispatch (shading model routing, sprite-vs-mesh split, the
|
|
512
|
+
* D-1/D-3 pipeline-tag pick) is uniform across the entity's submeshes
|
|
513
|
+
* today, so the existing 20+ `entry.source.material.X` consumers in the
|
|
514
|
+
* record stage stay unchanged. Per-submesh material data (baseColor /
|
|
515
|
+
* UBO payload / paramSnapshot) lives in `materials[i]`.
|
|
516
|
+
*/
|
|
517
|
+
readonly material: MaterialSnapshot;
|
|
518
|
+
/**
|
|
519
|
+
* feat-20260608 M5 amend / w11-a: per-submesh MaterialSnapshot[]
|
|
520
|
+
* positionally aligned with `MeshAsset.submeshes[]` (plan §3.2 sequence
|
|
521
|
+
* step 6 + AC-08). Length always equals the entity's submesh count once
|
|
522
|
+
* the extract stage's count-mismatch validator (M2 / w12) has filtered
|
|
523
|
+
* misaligned spawns. The record stage reads `materials[i]` to upload the
|
|
524
|
+
* i-th material UBO slot before drawing the i-th submesh.
|
|
525
|
+
*
|
|
526
|
+
* Backward-compat: `materials[0] === material` (the legacy mid-grey
|
|
527
|
+
* default-material case-B path also routes through `materials = [default]`,
|
|
528
|
+
* keeping the legacy single-mesh-no-material spawn shape working without
|
|
529
|
+
* a special branch in record).
|
|
530
|
+
*/
|
|
531
|
+
readonly materials: readonly MaterialSnapshot[];
|
|
532
|
+
/** Per-slot provenance trace from the shared mesh-material resolver. */
|
|
533
|
+
readonly materialBindingSources: readonly MeshMaterialBindingSource[];
|
|
534
|
+
/** Active structured override diagnostics for this renderable. */
|
|
535
|
+
readonly materialBindingDiagnostics?: readonly MeshMaterialBindingDiagnostic[];
|
|
536
|
+
/**
|
|
537
|
+
* feat-20260708-composited-multi-world-rendering M1 / D-1: the worldId
|
|
538
|
+
* of the world this renderable was extracted from. Defaults to 0 in
|
|
539
|
+
* single-world path (extractFrame always assigns 0). The merge layer
|
|
540
|
+
* (extractFrames in M2) stamps the correct worldId per world before
|
|
541
|
+
* the record stage consumes it. Combined with `entityKey` via
|
|
542
|
+
* `worldEntityKey(worldId, entityKey)` to form per-entity cache keys.
|
|
543
|
+
*
|
|
544
|
+
* Never rewrite `entityKey` itself — consumers that need the real
|
|
545
|
+
* entity handle (video provider, etc.) read the bare `entityKey`.
|
|
546
|
+
*/
|
|
547
|
+
readonly worldId: number;
|
|
548
|
+
/**
|
|
549
|
+
* feat-20260531-per-frame-bind-group-cache M1 / w3: packed Entity u32
|
|
550
|
+
* (encodeEntity(indexSlot, generation)) surface'd from the extract
|
|
551
|
+
* stage. Stable per-entity identity for the record stage cache keys
|
|
552
|
+
* (material / instances / per-frame clean-up) without re-querying the
|
|
553
|
+
* World (charter P5 Pipeline Isolation: record stage only consumes
|
|
554
|
+
* snapshot POD). Reuses the encodeEntity calculation already performed
|
|
555
|
+
* at :1293 for the Instances path, and now also computed for plain
|
|
556
|
+
* (non-Instances) renderables.
|
|
557
|
+
*
|
|
558
|
+
* Never rewrite this field — cache keys use `worldEntityKey(worldId, entityKey)`,
|
|
559
|
+
* not the bare entityKey alone (D-1).
|
|
560
|
+
*/
|
|
561
|
+
readonly entityKey: number;
|
|
562
|
+
/**
|
|
563
|
+
* feat-20260514 M3 (w15): when the entity carries an `Instances` component
|
|
564
|
+
* the extract stage materialises a fresh `Float32Array` snapshot of the
|
|
565
|
+
* packed mat4 transforms (16 f32 per instance, stride 16) plus a stable
|
|
566
|
+
* `cacheKey` (the entity's packed u32) and the `archVersion` (used by the
|
|
567
|
+
* record stage to invalidate its per-entity GPU buffer cache when the
|
|
568
|
+
* archetype storage grew). Absent (`undefined`) means the record stage
|
|
569
|
+
* falls back to the shared 1-element identity-mat4 storage buffer +
|
|
570
|
+
* `drawIndexed(.., 1, ..)`.
|
|
571
|
+
*/
|
|
572
|
+
readonly instances?: InstancesSnapshot;
|
|
573
|
+
/**
|
|
574
|
+
* feat-20260625-sprite-instances-and-tilemap-terrain-static-batch M3 / w10
|
|
575
|
+
* (plan-strategy D-1 + D-9): when the entity carries a `SpriteInstances`
|
|
576
|
+
* component the extract stage materialises a paired snapshot — packed mat4
|
|
577
|
+
* `transforms` (16 f32, stride 16) + per-instance UV `regions` (4 f32,
|
|
578
|
+
* stride 4) — plus the same cache fingerprint pair (`cacheKey` = entity
|
|
579
|
+
* packed u32, `archVersion` = archetype version stamp at snapshot time).
|
|
580
|
+
* The record stage interleaves the two arrays into an 80B-per-instance
|
|
581
|
+
* single GPU buffer routed through `@group(3) @binding(0)` (BGL unchanged,
|
|
582
|
+
* D-1). Absent (`undefined`) means the entity is not a `SpriteInstances`
|
|
583
|
+
* carrier; the record stage falls back to its existing sprite path
|
|
584
|
+
* (material UBO region + identity-instance buffer).
|
|
585
|
+
*
|
|
586
|
+
* Three structured `EcsError` codes fire at extract entry and skip the
|
|
587
|
+
* renderable on violation (charter P3 explicit failure):
|
|
588
|
+
* - `'sprite-instances-mutually-exclusive-with-instances'`
|
|
589
|
+
* - `'sprite-instances-requires-sprite-shading-model'`
|
|
590
|
+
* - `'sprite-instances-count-mismatch'`
|
|
591
|
+
*/
|
|
592
|
+
readonly spriteInstances?: SpriteInstancesSnapshot;
|
|
593
|
+
/**
|
|
594
|
+
* feat-20260523-skin-skeleton-animation M2 / T-21: when the entity carries
|
|
595
|
+
* a `Skin` component the extract stage populates this field with the
|
|
596
|
+
* skin palette slice metadata. The record stage uses this to route the
|
|
597
|
+
* draw to `forgeax::pbr-skin` pipeline + set the palette dynamic offset.
|
|
598
|
+
* Absent (`undefined`) means the entity is not skinned.
|
|
599
|
+
*/
|
|
600
|
+
readonly skin?: SkinPaletteSlice;
|
|
601
|
+
/**
|
|
602
|
+
* ECS-owned morph weights. Presence selects the Standard Pipeline CPU
|
|
603
|
+
* specialized deformation lane; the asset remains the source of target
|
|
604
|
+
* deltas and the record stage only receives this frozen POD snapshot.
|
|
605
|
+
*/
|
|
606
|
+
readonly morph?: MorphSnapshot;
|
|
607
|
+
/** Detached M2 authoring facts for the optional Points/Lines projection. */
|
|
608
|
+
readonly pointsLines?: PointsLinesRetainedSnapshot;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export interface MorphSnapshot {
|
|
612
|
+
readonly weights: Float32Array;
|
|
613
|
+
readonly targetCount: number;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export interface GpuDrivenDrawSnapshot {
|
|
617
|
+
readonly kind: 'indexed' | 'non-indexed';
|
|
618
|
+
readonly first: number;
|
|
619
|
+
readonly count: number;
|
|
620
|
+
readonly baseVertex: number;
|
|
621
|
+
readonly materialSlot: number;
|
|
622
|
+
readonly topology: PrimitiveTopology;
|
|
623
|
+
readonly pipelineClass: string;
|
|
624
|
+
readonly materialResourceClass: string;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export interface InstancesSnapshot {
|
|
628
|
+
/** Packed column-major mat4 transforms (16 f32 per instance). */
|
|
629
|
+
readonly transforms: Float32Array;
|
|
630
|
+
/** Number of instances (transforms.length / 16). */
|
|
631
|
+
readonly instanceCount: number;
|
|
632
|
+
/** Stable per-entity GPU buffer cache key (the packed Entity u32). */
|
|
633
|
+
readonly cacheKey: number;
|
|
634
|
+
/**
|
|
635
|
+
* Archetype version stamp at snapshot time. The record stage compares this
|
|
636
|
+
* against its cached version per cacheKey; a bump means the underlying
|
|
637
|
+
* BufferPool slot may have grown / been reallocated, forcing a fresh
|
|
638
|
+
* `device.createBuffer + queue.writeBuffer` round.
|
|
639
|
+
*/
|
|
640
|
+
readonly archVersion: number;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* feat-20260625-sprite-instances-and-tilemap-terrain-static-batch M3 / w10:
|
|
645
|
+
* extract-stage view of a `SpriteInstances` carrier (2D peer of
|
|
646
|
+
* `InstancesSnapshot`).
|
|
647
|
+
*
|
|
648
|
+
* Field shapes mirror `InstancesSnapshot` so the record-stage cache protocol
|
|
649
|
+
* (`(cacheKey, archVersion, byteLength)` fingerprint triple) is reused
|
|
650
|
+
* verbatim. The byte length consumed at upload time is the sum
|
|
651
|
+
* `transforms.byteLength + regions.byteLength` (= 80*N for N instances,
|
|
652
|
+
* plan-strategy D-1 interleaved single-buffer); the record stage builds the
|
|
653
|
+
* 80B/instance interleaved buffer once per (entity, archVersion, byteLength)
|
|
654
|
+
* fingerprint change.
|
|
655
|
+
*/
|
|
656
|
+
export interface SpriteInstancesSnapshot {
|
|
657
|
+
/** Packed column-major mat4 transforms (16 f32 per instance, stride 16). */
|
|
658
|
+
readonly transforms: Float32Array;
|
|
659
|
+
/** Per-instance UV vec4 regions (4 f32 per instance, stride 4). */
|
|
660
|
+
readonly regions: Float32Array;
|
|
661
|
+
/**
|
|
662
|
+
* Number of instances. Derived from `transforms.length / 16` (equivalently
|
|
663
|
+
* `regions.length / 4`); the extract-entry validator guarantees the two
|
|
664
|
+
* derivations agree, otherwise it fires
|
|
665
|
+
* `'sprite-instances-count-mismatch'` and skips the renderable.
|
|
666
|
+
*/
|
|
667
|
+
readonly instanceCount: number;
|
|
668
|
+
/** Stable per-entity GPU buffer cache key (the packed Entity u32, D-9). */
|
|
669
|
+
readonly cacheKey: number;
|
|
670
|
+
/** Archetype version stamp at snapshot time (cache invalidation fingerprint). */
|
|
671
|
+
readonly archVersion: number;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* TransformSnapshot: extract-stage view of one entity's resolved world
|
|
676
|
+
* transform (feat-20260601 D-3). Holds the single `world` mat4 (column-major
|
|
677
|
+
* 16 floats, copied from the entity's `Transform.world` view written by
|
|
678
|
+
* propagateTransforms). The record stage copies these 16 floats straight into
|
|
679
|
+
* the mesh SSBO slot (zero `mat4.compose`); position / scale consumers derive
|
|
680
|
+
* from the mat4 via `mat4.getTranslation` / basis-column lengths.
|
|
681
|
+
*/
|
|
682
|
+
export interface TransformSnapshot {
|
|
683
|
+
readonly world: Float32Array;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* MaterialSnapshot: extract-stage view of one entity's material asset (M2 / w6
|
|
688
|
+
* of feat-20260517-merge-mesh-renderer-material-renderer; plan-strategy section 2.3).
|
|
689
|
+
*
|
|
690
|
+
* Adds optional `baseColorTexture` + `sampler` slots (consumed by the record
|
|
691
|
+
* stage's textured-material code path; M3 / w10 dropped the cast-over-firstMaterial
|
|
692
|
+
* pattern in favour of direct snapshot field reads).
|
|
693
|
+
*
|
|
694
|
+
* feat-20260522-learn-render-3-1-sponza-model-loading-with-multi-l M4 extends
|
|
695
|
+
* the snapshot with `metallicRoughnessTexture` and `normalTexture`
|
|
696
|
+
* so the record stage can wire PBR texture bindings 4 and 6
|
|
697
|
+
* from real GPU views instead of placeholder 1x1 white / flat-normal views.
|
|
698
|
+
*
|
|
699
|
+
* Bounded scope: the snapshot tracks only what the record stage actually
|
|
700
|
+
* consumes today (charter proposition 5 consistent abstraction; YAGNI for
|
|
701
|
+
* future-proof field bloat). Future MaterialAsset extensions (emissive / etc)
|
|
702
|
+
* drive snapshot extensions when the record stage starts consuming them, not
|
|
703
|
+
* vice-versa.
|
|
704
|
+
*
|
|
705
|
+
* tweak-20260701 M1: `shadingModel` field removed — shader identity via
|
|
706
|
+
* {@link materialShaderId} is the single source of truth for material dispatch.
|
|
707
|
+
*/
|
|
708
|
+
export interface MaterialSnapshot {
|
|
709
|
+
readonly baseColor: Vec3;
|
|
710
|
+
readonly metallic: number;
|
|
711
|
+
readonly roughness: number;
|
|
712
|
+
readonly clearcoat?: number | undefined;
|
|
713
|
+
readonly clearcoatRoughness?: number | undefined;
|
|
714
|
+
/** Authored Standard PBR specular tint in linear runtime color space. */
|
|
715
|
+
readonly specularTint?: readonly [number, number, number] | undefined;
|
|
716
|
+
/**
|
|
717
|
+
* Schema-driven material shader identifier (feat-20260523 M4-T05).
|
|
718
|
+
* Populated when the material asset uses the schema-driven path
|
|
719
|
+
* (payload.materialShader set). Undefined for unlit/sprite legacy
|
|
720
|
+
* materials and for case-B defaultMaterialSnapshot.
|
|
721
|
+
*
|
|
722
|
+
* The record stage uses this as the pipeline cache key first-level
|
|
723
|
+
* discriminator (M4-T06).
|
|
724
|
+
*/
|
|
725
|
+
readonly materialShaderId?: string | undefined;
|
|
726
|
+
/**
|
|
727
|
+
* Stable source handle for the material asset. Record uses this only for
|
|
728
|
+
* same-frame assembly reuse; GPU bind-group validity still comes from the
|
|
729
|
+
* resource-identity cache.
|
|
730
|
+
*/
|
|
731
|
+
readonly materialHandle?: number | undefined;
|
|
732
|
+
/**
|
|
733
|
+
* The selected forward-pass state for this exact material. A mesh can bind
|
|
734
|
+
* different materials to its submeshes, so pipeline selection must read the
|
|
735
|
+
* state from the submesh snapshot rather than the entity's first material.
|
|
736
|
+
*/
|
|
737
|
+
readonly renderState?: MaterialRenderState | undefined;
|
|
738
|
+
/**
|
|
739
|
+
* Schema-driven parameter snapshot (feat-20260523 M4-T05).
|
|
740
|
+
* Populated alongside materialShaderId. Maps param name to its
|
|
741
|
+
* runtime-resolved value: number for scalar params, number[] for
|
|
742
|
+
* vec/color params, string (GUID) for texture2d/sampler params.
|
|
743
|
+
*/
|
|
744
|
+
readonly paramSnapshot?: Readonly<Record<string, number | number[] | string>> | undefined;
|
|
745
|
+
/** Effective MaterialAsset parameter contract used to lay out this snapshot. */
|
|
746
|
+
readonly materialParamSchema?: readonly ParamSchemaEntry[] | undefined;
|
|
747
|
+
/** Authored per-slot UV set and KHR texture transform metadata. */
|
|
748
|
+
readonly textureCoordinates?: ReadonlyMap<string, MaterialTextureCoordinates> | undefined;
|
|
749
|
+
/**
|
|
750
|
+
* User-region texture handles keyed by paramSchema field name
|
|
751
|
+
* (feat-20260621-learn-render-5-5-parallax M2 / w7). The SSOT carrier for
|
|
752
|
+
* EVERY texture the shader's `derive(paramSchema).textureFieldNames`
|
|
753
|
+
* declares — `baseColorTexture` / `metallicRoughnessTexture` /
|
|
754
|
+
* `normalTexture` for built-in standard-PBR, plus each custom field such as
|
|
755
|
+
* `heightTexture` (LO 5.5 parallax). The record stage iterates this map to
|
|
756
|
+
* assemble the user-region bind group per the per-shader BGL (w8), so a 4th
|
|
757
|
+
* (or Nth) texture flows end-to-end without a hardcoded field list.
|
|
758
|
+
*
|
|
759
|
+
* Populated by iterating `textureFieldNames`; absent keys mean the
|
|
760
|
+
* paramValue was missing / mis-typed (record falls back to default white).
|
|
761
|
+
* `emissiveTexture` / `occlusionTexture` are NOT here — they live in the
|
|
762
|
+
* engine-injection lightmap region (their named fields below feed
|
|
763
|
+
* `appendInjection('lightmap')`, not the user-region).
|
|
764
|
+
*/
|
|
765
|
+
readonly textureHandles?: ReadonlyMap<string, Handle<'TextureAsset', 'shared'>> | undefined;
|
|
766
|
+
/**
|
|
767
|
+
* User-region texture field names whose paramValue resolved to a VideoAsset
|
|
768
|
+
* (kind `'video'`) rather than a static TextureAsset
|
|
769
|
+
* (feat-20260623-world-space-video-asset M4 / w14, D-5). The video GUID
|
|
770
|
+
* occupies the same texture2d values slot a static texture would (P4:
|
|
771
|
+
* one binding shape), but extract routes it here instead of `textureHandles`
|
|
772
|
+
* so the record stage pulls the current-frame view from the transient
|
|
773
|
+
* DynamicTextureStore (D-3) instead of `GpuResidencyCache.ensureResident`
|
|
774
|
+
* (which has no `video` arm; AC-08). Each entry also carries the resolved
|
|
775
|
+
* clip handle so the record stage can key the per-frame upload.
|
|
776
|
+
*
|
|
777
|
+
* Producer/consumer split (charter P5 / AC-07 gate): extract owns the
|
|
778
|
+
* asset->snapshot translation; record consumes this POD field only — it never
|
|
779
|
+
* reaches back into the MaterialAsset to learn a field is video-sourced.
|
|
780
|
+
*/
|
|
781
|
+
readonly videoTextureFields?: ReadonlyMap<string, Handle<'VideoAsset', 'shared'>> | undefined;
|
|
782
|
+
/** Authored sampler handles keyed by their matching texture parameter. */
|
|
783
|
+
readonly samplerHandles?: ReadonlyMap<string, Handle<'SamplerAsset', 'shared'>> | undefined;
|
|
784
|
+
readonly baseColorTexture?: Handle<'TextureAsset', 'shared'> | undefined;
|
|
785
|
+
/**
|
|
786
|
+
* PBR metallic-roughness texture handle (present for PBR/sprite/skin
|
|
787
|
+
* shaders). Undefined for the default-unlit shader
|
|
788
|
+
* (materialShaderId === 'forgeax::default-unlit'). The record stage reads
|
|
789
|
+
* this to write GPU view at material bind-group binding 4, falling
|
|
790
|
+
* back to a 1x1 white placeholder when undefined.
|
|
791
|
+
*
|
|
792
|
+
* feat-20260522-learn-render-3-1-sponza-model-loading-with-multi-l M4:
|
|
793
|
+
* field added so the record stage can wire real metallic-roughness
|
|
794
|
+
* textures from Sponza glTF materials (M3 writes the handle into
|
|
795
|
+
* SchemaDrivenMaterialAsset values; M4 extract carries it through to the snapshot).
|
|
796
|
+
*/
|
|
797
|
+
readonly metallicRoughnessTexture?: Handle<'TextureAsset', 'shared'> | undefined;
|
|
798
|
+
/**
|
|
799
|
+
* PBR tangent-space normal texture handle (present for PBR/sprite/skin
|
|
800
|
+
* shaders). Undefined for the default-unlit shader
|
|
801
|
+
* (materialShaderId === 'forgeax::default-unlit'). The record stage reads
|
|
802
|
+
* this to write GPU view at material bind-group binding 6, falling
|
|
803
|
+
* back to a (0.5,0.5,1.0) flat-normal placeholder when undefined.
|
|
804
|
+
*
|
|
805
|
+
* feat-20260522-learn-render-3-1-sponza-model-loading-with-multi-l M4:
|
|
806
|
+
* field added symmetrically with metallicRoughnessTexture so the record
|
|
807
|
+
* stage can wire real normal textures from Sponza glTF materials.
|
|
808
|
+
*/
|
|
809
|
+
readonly normalTexture?: Handle<'TextureAsset', 'shared'> | undefined;
|
|
810
|
+
/** Scalar applied to tangent-space normal XY after the normal texture sample. */
|
|
811
|
+
readonly normalScale?: number | undefined;
|
|
812
|
+
readonly emissive?: readonly [number, number, number] | undefined;
|
|
813
|
+
readonly emissiveIntensity?: number | undefined;
|
|
814
|
+
readonly emissiveTexture?: Handle<'TextureAsset', 'shared'> | undefined;
|
|
815
|
+
readonly occlusionTexture?: Handle<'TextureAsset', 'shared'> | undefined;
|
|
816
|
+
readonly occlusionStrength?: number | undefined;
|
|
817
|
+
/**
|
|
818
|
+
* Transparent composition flag derived from the first pass's
|
|
819
|
+
* `renderState.blend` presence on the underlying
|
|
820
|
+
* {@link MaterialPass} (feat-20260626-collapse M2: blend
|
|
821
|
+
* presence is the SSOT after `MaterialPass.transparent` was
|
|
822
|
+
* dropped in M1).
|
|
823
|
+
*
|
|
824
|
+
* The record stage reads this to drive both the LDR split-pass
|
|
825
|
+
* decision and the premultiplied-alpha blend resolution on the
|
|
826
|
+
* generic materialShaderId pipeline path — shader-agnostic, decoupled
|
|
827
|
+
* from the legacy `shadingModel` discriminant (feat-20260625 M2 D-3,
|
|
828
|
+
* finalised in w15; `shadingModel` field removed in tweak-20260701 M1).
|
|
829
|
+
*
|
|
830
|
+
* Extract derives this from the first pass's `renderState.blend !==
|
|
831
|
+
* undefined` (post-feat-20260626-collapse: blend presence is the
|
|
832
|
+
* single SSOT for "this material is transparent on the geometry
|
|
833
|
+
* pipeline cache key"). Multi-pass materials whose mix of opaque +
|
|
834
|
+
* transparent passes need finer routing should split into separate
|
|
835
|
+
* MaterialAsset entries (the normal forgeax pattern).
|
|
836
|
+
*
|
|
837
|
+
* Type is `boolean | undefined` (derived): `undefined` means "no
|
|
838
|
+
* passes / unknown"; consumers must read `=== true` / `!== true` to
|
|
839
|
+
* stay correct under both populated and absent cases.
|
|
840
|
+
*/
|
|
841
|
+
readonly transparent?: boolean | undefined;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
interface MaterialCacheChainIdentity {
|
|
845
|
+
readonly asset: MaterialAsset;
|
|
846
|
+
readonly parentGuid: string | undefined;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
export interface MaterialSnapshotCacheEntry {
|
|
850
|
+
readonly snapshot: MaterialSnapshot;
|
|
851
|
+
readonly passes: readonly MaterialPass[];
|
|
852
|
+
/** Root/chain identities are present only on cross-frame-safe entries. */
|
|
853
|
+
readonly chain?: readonly MaterialCacheChainIdentity[];
|
|
854
|
+
/** AssetRegistry epoch at which the root/parent chain was last validated. */
|
|
855
|
+
readonly catalogEpoch?: number;
|
|
856
|
+
/** True only when every object reachable from the authored chain is frozen. */
|
|
857
|
+
readonly crossFrameSafe: boolean;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export type MaterialSnapshotCache = Map<number, MaterialSnapshotCacheEntry>;
|
|
861
|
+
export type MaterialSnapshotCachesByWorld = WeakMap<World, MaterialSnapshotCache>;
|
|
862
|
+
|
|
863
|
+
function gpuDrivenMaterialResourceClass(material: MaterialSnapshot): string {
|
|
864
|
+
const textures = [...(material.textureHandles?.entries() ?? [])]
|
|
865
|
+
.map(([name, handle]) => [name, Number(handle)] as const)
|
|
866
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
867
|
+
const samplers = [...(material.samplerHandles?.entries() ?? [])]
|
|
868
|
+
.map(([name, handle]) => [name, Number(handle)] as const)
|
|
869
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
870
|
+
return JSON.stringify({
|
|
871
|
+
textures,
|
|
872
|
+
samplers,
|
|
873
|
+
video: [...(material.videoTextureFields?.keys() ?? [])].sort(),
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// === DispatchEntry — M3 / w26 single dispatch list (feat-20260526-material-asset-multipass-renderstate) ===
|
|
878
|
+
//
|
|
879
|
+
// Plan-strategy D-3: single dispatch list sorted by queue value,
|
|
880
|
+
// replacing the old three-bucket opaque/transparent/overlay dispatch.
|
|
881
|
+
// Each entry carries the per-pass render-state and entry-point
|
|
882
|
+
// data from the resolved MaterialPass so the record stage
|
|
883
|
+
// reads them without re-resolving the material.
|
|
884
|
+
|
|
885
|
+
export interface DispatchEntry {
|
|
886
|
+
readonly entityIndex: number;
|
|
887
|
+
readonly materialHandle: number;
|
|
888
|
+
readonly renderableIndex: number;
|
|
889
|
+
readonly passIndex: number;
|
|
890
|
+
readonly queue: number;
|
|
891
|
+
/**
|
|
892
|
+
* Signed i32 from the entity's {@link Layer} component value (default 0 for
|
|
893
|
+
* entities without a Layer). Primary sort key for the transparent-dispatch
|
|
894
|
+
* sort in {@link render-system.ts}: lower value = drawn first (behind).
|
|
895
|
+
*/
|
|
896
|
+
readonly layer: number;
|
|
897
|
+
readonly tags: Record<string, string>;
|
|
898
|
+
readonly renderState: MaterialRenderState | undefined;
|
|
899
|
+
readonly defines: Record<string, string> | undefined;
|
|
900
|
+
readonly vertexEntry: string | undefined;
|
|
901
|
+
readonly fragmentEntry: string | undefined;
|
|
902
|
+
readonly materialShaderId: string | undefined;
|
|
903
|
+
readonly paramSnapshot: Readonly<Record<string, number | number[] | string>> | undefined;
|
|
904
|
+
/**
|
|
905
|
+
* Stencil reference value from {@link MaterialPass.stencilReference}
|
|
906
|
+
* (draw-call dynamic state). Folded during extract for per-draw consumption
|
|
907
|
+
* in the record stage. `undefined` when the pass does not set a reference
|
|
908
|
+
* value (record stage falls back to WebGPU default 0).
|
|
909
|
+
*/
|
|
910
|
+
readonly stencilReference?: number;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Stable-sort dispatch entries by `queue` value ascending.
|
|
915
|
+
* Returns a new sorted array (does not mutate the input).
|
|
916
|
+
* Same-queue entries preserve insertion order (AC-11 stable-order guarantee).
|
|
917
|
+
*/
|
|
918
|
+
export function sortDispatchByQueue<E extends { readonly queue: number }>(
|
|
919
|
+
entries: readonly E[],
|
|
920
|
+
): E[] {
|
|
921
|
+
// Array.prototype.sort is stable per ES2019 spec (V8 7.0+ / Node 12+).
|
|
922
|
+
return entries.slice().sort((a, b) => a.queue - b.queue);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const DEFAULT_FORWARD_PASS: MaterialPass = {
|
|
926
|
+
name: 'forward',
|
|
927
|
+
program: { module: 'forgeax::default-unlit', vertexEntry: 'vs_main', fragmentEntry: 'fs_main' },
|
|
928
|
+
renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
function appendMaterialDispatchEntries(
|
|
932
|
+
pendingDispatch: DispatchEntry[],
|
|
933
|
+
passes: readonly MaterialPass[],
|
|
934
|
+
entity: EntityHandle,
|
|
935
|
+
materialHandle: number,
|
|
936
|
+
renderableIndex: number,
|
|
937
|
+
layer: number,
|
|
938
|
+
paramSnapshot: Readonly<Record<string, number | number[] | string>> | undefined,
|
|
939
|
+
passIndexOffset = 0,
|
|
940
|
+
): void {
|
|
941
|
+
const matchedPasses = selectPasses(passes, {});
|
|
942
|
+
for (let pIdx = 0; pIdx < matchedPasses.length; pIdx++) {
|
|
943
|
+
const pass = matchedPasses[pIdx];
|
|
944
|
+
if (pass === undefined) continue;
|
|
945
|
+
const passState = (pass.renderState ?? {}) as MaterialRenderState & {
|
|
946
|
+
readonly tags?: Record<string, string>;
|
|
947
|
+
readonly queue?: number;
|
|
948
|
+
readonly stencilReference?: number;
|
|
949
|
+
};
|
|
950
|
+
pendingDispatch.push({
|
|
951
|
+
entityIndex: entity,
|
|
952
|
+
materialHandle,
|
|
953
|
+
renderableIndex,
|
|
954
|
+
passIndex: passIndexOffset + pIdx,
|
|
955
|
+
queue: passState.queue ?? 2000,
|
|
956
|
+
layer,
|
|
957
|
+
tags: passState.tags ?? {},
|
|
958
|
+
renderState: pipelineRenderState(passState),
|
|
959
|
+
// Material module identity is already closed in the cooked program.
|
|
960
|
+
// It must never become a draw-time define map.
|
|
961
|
+
defines: undefined,
|
|
962
|
+
vertexEntry: pass.program.vertexEntry,
|
|
963
|
+
fragmentEntry: pass.program.fragmentEntry,
|
|
964
|
+
materialShaderId: runtimeMaterialShaderId(pass.program.module, pass.name),
|
|
965
|
+
paramSnapshot,
|
|
966
|
+
...(passState.stencilReference !== undefined && {
|
|
967
|
+
stencilReference: passState.stencilReference,
|
|
968
|
+
}),
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export interface ExtractedFrame {
|
|
974
|
+
readonly cameras: CameraSnapshot[];
|
|
975
|
+
readonly lights: ExtractedLights;
|
|
976
|
+
readonly renderables: RenderableSnapshot[];
|
|
977
|
+
/**
|
|
978
|
+
* Single dispatch list sorted by queue value (ascending, stable sort).
|
|
979
|
+
* Replaces the old opaqueDispatch / transparentDispatch / overlayDispatch
|
|
980
|
+
* three-bucket model per plan-strategy D-3.
|
|
981
|
+
*/
|
|
982
|
+
readonly dispatch: DispatchEntry[];
|
|
983
|
+
readonly skylight: SkylightSnapshot | undefined;
|
|
984
|
+
readonly skylightCount: number;
|
|
985
|
+
readonly skybox: SkyboxSnapshot | undefined;
|
|
986
|
+
readonly skyboxCount: number;
|
|
987
|
+
/**
|
|
988
|
+
* feat-20260528-frustum-culling M3 / w11: frustum culling statistics
|
|
989
|
+
* collected during the extract phase. `total` is the count of entities
|
|
990
|
+
* that reached the culling decision point; `culled` is the count of
|
|
991
|
+
* those that were removed from renderables by frustum culling.
|
|
992
|
+
*/
|
|
993
|
+
readonly frustumStats: { readonly culled: number; readonly total: number };
|
|
994
|
+
/** Candidate entities rejected by author visibility before resource parsing. */
|
|
995
|
+
readonly visibilityStats: { readonly explicitlyHidden: number };
|
|
996
|
+
/**
|
|
997
|
+
* Per-frame post-process params snapshot collected from PostProcessParams
|
|
998
|
+
* entities (D-1: data-driven params channel). Maps shader id to the
|
|
999
|
+
* raw data bytes (Uint8Array = FieldValueType<'buffer'>). Last-one-wins
|
|
1000
|
+
* when multiple entities bear the same shader id.
|
|
1001
|
+
* Empty map when no PostProcessParams entities exist.
|
|
1002
|
+
*/
|
|
1003
|
+
readonly postProcessParams: ReadonlyMap<string, Uint8Array>;
|
|
1004
|
+
/** Visibility snapshots prepared for each contributing World. */
|
|
1005
|
+
readonly visibilitySnapshots: readonly VisibilitySnapshot[];
|
|
1006
|
+
/** World-labelled snapshots passed to generic RenderFeature extraction. */
|
|
1007
|
+
readonly featureVisibilitySnapshots: readonly RenderFeatureWorldVisibilitySnapshot[];
|
|
1008
|
+
/** Built-in hidden candidates, retained for host-side producer deduplication. */
|
|
1009
|
+
readonly hiddenEntityReports: readonly RenderFeatureHiddenEntityReport[];
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Internal world surface used by extract for archetype-graph traversal and
|
|
1014
|
+
* error routing. The packed `Entity` handle for a row is read directly from
|
|
1015
|
+
* the essential id=0 `Entity` column (`arch.columns.get(Entity.id).get('self')`)
|
|
1016
|
+
* -- no generation lookup / encodeEntity rebuild (feat-20260602 M2).
|
|
1017
|
+
*/
|
|
1018
|
+
type RenderErrorContext = {
|
|
1019
|
+
readonly systemName: string;
|
|
1020
|
+
/** Retained as diagnostic metadata for legacy render call sites. */
|
|
1021
|
+
readonly severity?: 'error' | 'warning';
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
type ArrayFieldView = ArrayLike<number>;
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Render's old zero-copy ECS calls now use the explicit public projection
|
|
1028
|
+
* boundary. The optimized ECS owner keeps storage behind `World`; render
|
|
1029
|
+
* receives a detached array snapshot and routes failures through the same
|
|
1030
|
+
* projection owner.
|
|
1031
|
+
*/
|
|
1032
|
+
type WorldInternalView = {
|
|
1033
|
+
_routeError(error: unknown, ctx: RenderErrorContext): void;
|
|
1034
|
+
_getArrayView<N extends string, S extends ComponentSchema>(
|
|
1035
|
+
entity: EntityHandle,
|
|
1036
|
+
component: Component<N, S>,
|
|
1037
|
+
fieldName: string,
|
|
1038
|
+
): ArrayFieldView | undefined;
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
const Severity = Object.freeze({ Error: 'error', Warning: 'warning' } as const);
|
|
1042
|
+
|
|
1043
|
+
function createWorldInternalView(world: World): WorldInternalView {
|
|
1044
|
+
return {
|
|
1045
|
+
_routeError(error, ctx) {
|
|
1046
|
+
routeWorldError(world, error, { systemName: ctx.systemName });
|
|
1047
|
+
},
|
|
1048
|
+
_getArrayView(entity, component, fieldName) {
|
|
1049
|
+
return readRenderArrayView(world, entity, component, fieldName) as ArrayFieldView | undefined;
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* The user-region texture fields the built-in standard-PBR material declares.
|
|
1056
|
+
* Used as the fallback texture-field set when the shader id is not registered
|
|
1057
|
+
* (cross-worktree shader-late-register, plan R-4) so a built-in material still
|
|
1058
|
+
* resolves its user-region textures. Mirrors `derive(default-standard-pbr).textureFieldNames`.
|
|
1059
|
+
*/
|
|
1060
|
+
const BUILTIN_USER_REGION_TEXTURE_FIELDS: readonly string[] = [
|
|
1061
|
+
'baseColorTexture',
|
|
1062
|
+
'metallicRoughnessTexture',
|
|
1063
|
+
'normalTexture',
|
|
1064
|
+
'specularTintTexture',
|
|
1065
|
+
];
|
|
1066
|
+
const BUILTIN_USER_REGION_TEXTURE_FIELD_SET = new Set(BUILTIN_USER_REGION_TEXTURE_FIELDS);
|
|
1067
|
+
const BUILTIN_BASE_COLOR_TEXTURE_FIELD_SET = new Set(['baseColorTexture']);
|
|
1068
|
+
|
|
1069
|
+
function materialTextureFields(
|
|
1070
|
+
shaderId: string | undefined,
|
|
1071
|
+
fields: ReadonlySet<string> | undefined,
|
|
1072
|
+
): ReadonlySet<string> | undefined {
|
|
1073
|
+
if (fields !== undefined && fields.size > 0) return fields;
|
|
1074
|
+
if (
|
|
1075
|
+
shaderId === 'forgeax::default-standard-pbr' ||
|
|
1076
|
+
shaderId === 'forgeax::default-standard-pbr-skin' ||
|
|
1077
|
+
shaderId === 'forgeax::pbr-skin'
|
|
1078
|
+
) {
|
|
1079
|
+
return BUILTIN_USER_REGION_TEXTURE_FIELD_SET;
|
|
1080
|
+
}
|
|
1081
|
+
if (
|
|
1082
|
+
shaderId === 'forgeax::default-unlit' ||
|
|
1083
|
+
shaderId === 'forgeax::sprite' ||
|
|
1084
|
+
shaderId === 'forgeax::sprite-lit'
|
|
1085
|
+
) {
|
|
1086
|
+
return BUILTIN_BASE_COLOR_TEXTURE_FIELD_SET;
|
|
1087
|
+
}
|
|
1088
|
+
return fields;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* The authored material surface uses the module IDs declared by built-in WGSL
|
|
1093
|
+
* sources. Runtime pipeline caches still use the existing engine registration
|
|
1094
|
+
* IDs, so this small projection keeps authored identity and renderer lookup
|
|
1095
|
+
* identity in one place during the migration.
|
|
1096
|
+
*/
|
|
1097
|
+
function runtimeMaterialShaderId(
|
|
1098
|
+
module: string | undefined,
|
|
1099
|
+
passName?: string,
|
|
1100
|
+
): string | undefined {
|
|
1101
|
+
if (
|
|
1102
|
+
passName === 'shadow-caster' &&
|
|
1103
|
+
(module === 'forgeax_material::standard' ||
|
|
1104
|
+
module === 'forgeax_material::unlit' ||
|
|
1105
|
+
module === 'forgeax::default-standard-pbr' ||
|
|
1106
|
+
module === 'forgeax::default-unlit')
|
|
1107
|
+
) {
|
|
1108
|
+
return 'forgeax::default-shadow-caster';
|
|
1109
|
+
}
|
|
1110
|
+
switch (module) {
|
|
1111
|
+
case 'forgeax_material::standard':
|
|
1112
|
+
return 'forgeax::default-standard-pbr';
|
|
1113
|
+
case 'forgeax_material::unlit':
|
|
1114
|
+
return 'forgeax::default-unlit';
|
|
1115
|
+
case 'forgeax_material::sprite':
|
|
1116
|
+
return 'forgeax::sprite';
|
|
1117
|
+
case 'forgeax_material::sprite-lit':
|
|
1118
|
+
return 'forgeax::sprite-lit';
|
|
1119
|
+
default:
|
|
1120
|
+
return module;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function pipelineRenderState(
|
|
1125
|
+
renderState:
|
|
1126
|
+
| (MaterialRenderState & {
|
|
1127
|
+
readonly tags?: Readonly<Record<string, string>>;
|
|
1128
|
+
readonly queue?: number;
|
|
1129
|
+
readonly stencilReference?: number;
|
|
1130
|
+
})
|
|
1131
|
+
| undefined,
|
|
1132
|
+
): MaterialRenderState | undefined {
|
|
1133
|
+
if (renderState === undefined) return undefined;
|
|
1134
|
+
const {
|
|
1135
|
+
cullMode,
|
|
1136
|
+
depthCompare,
|
|
1137
|
+
depthWriteEnabled,
|
|
1138
|
+
blend,
|
|
1139
|
+
alphaToCoverageEnabled,
|
|
1140
|
+
stencil,
|
|
1141
|
+
stencilReadMask,
|
|
1142
|
+
stencilWriteMask,
|
|
1143
|
+
frontFace,
|
|
1144
|
+
} = renderState;
|
|
1145
|
+
if (
|
|
1146
|
+
cullMode === undefined &&
|
|
1147
|
+
depthCompare === undefined &&
|
|
1148
|
+
depthWriteEnabled === undefined &&
|
|
1149
|
+
blend === undefined &&
|
|
1150
|
+
alphaToCoverageEnabled === undefined &&
|
|
1151
|
+
stencil === undefined &&
|
|
1152
|
+
stencilReadMask === undefined &&
|
|
1153
|
+
stencilWriteMask === undefined &&
|
|
1154
|
+
frontFace === undefined
|
|
1155
|
+
) {
|
|
1156
|
+
return undefined;
|
|
1157
|
+
}
|
|
1158
|
+
return {
|
|
1159
|
+
...(cullMode !== undefined && { cullMode }),
|
|
1160
|
+
...(depthCompare !== undefined && { depthCompare }),
|
|
1161
|
+
...(depthWriteEnabled !== undefined && { depthWriteEnabled }),
|
|
1162
|
+
...(blend !== undefined && { blend }),
|
|
1163
|
+
...(alphaToCoverageEnabled !== undefined && { alphaToCoverageEnabled }),
|
|
1164
|
+
...(stencil !== undefined && { stencil }),
|
|
1165
|
+
...(stencilReadMask !== undefined && { stencilReadMask }),
|
|
1166
|
+
...(stencilWriteMask !== undefined && { stencilWriteMask }),
|
|
1167
|
+
...(frontFace !== undefined && { frontFace }),
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* tweak-20260627-model-loading-smoke-build-perf M4: per-World intern cache for
|
|
1173
|
+
* the loadByGuid texture/sampler resolution path. A MaterialAsset's
|
|
1174
|
+
* texture/sampler values remain embedded GUID strings (dash-form) after
|
|
1175
|
+
* loadByGuid; the extract stage re-resolves each GUID to a column handle every
|
|
1176
|
+
* frame. Before this cache each resolution called `world.allocSharedRef`, which
|
|
1177
|
+
* mints a NEW monotonically-increasing slot id per call. Because the GPU
|
|
1178
|
+
* residency cache (`GpuResidencyCache.textureGpuHandles`) is keyed on
|
|
1179
|
+
* `handleSlot(handle)`, a fresh slot every frame meant the residency check
|
|
1180
|
+
* ALWAYS missed -> all textures re-uploaded to the GPU every frame, old GPU
|
|
1181
|
+
* textures never freed (refcount never hits 0). Unbounded GPU memory +
|
|
1182
|
+
* unbounded per-frame upload cost (628ms -> 2764ms over 12 frames, SIGKILL).
|
|
1183
|
+
*
|
|
1184
|
+
* The fix interns the GUID-string -> column-handle resolution: each unique
|
|
1185
|
+
* `(guid, brand)` pair mints EXACTLY ONE stable shared handle per World, reused
|
|
1186
|
+
* across frames (architecture-principle §6 idempotency: same GUID resolved N
|
|
1187
|
+
* times yields the same handle). The handle is intentionally long-lived -- it
|
|
1188
|
+
* lives as long as the World references the material, which is exactly the
|
|
1189
|
+
* desired lifetime; the `onLastRelease` -> `gpuStore.evictTexture` wiring stays
|
|
1190
|
+
* coherent because the handle is no longer churned per frame.
|
|
1191
|
+
*
|
|
1192
|
+
* Invariant boundary (asset-registry.ts:1958-1962): the AssetRegistry is a
|
|
1193
|
+
* GUID -> payload catalogue with NO handle/World concept -- it cannot mint a
|
|
1194
|
+
* column handle. So this intern cache lives in the extract/render layer, keyed
|
|
1195
|
+
* per-World via a WeakMap (the World owns the SharedRefStore that mints slots).
|
|
1196
|
+
*
|
|
1197
|
+
* Inner key is `${lowercasedGuid}\u0000${brand}` -- a GUID catalogues to a
|
|
1198
|
+
* single asset kind in practice, but the brand keeps the key correct if the
|
|
1199
|
+
* same GUID is ever resolved under two brands.
|
|
1200
|
+
*
|
|
1201
|
+
* Cache hits are validated against the registry's current payload identity.
|
|
1202
|
+
* Re-cataloguing the same GUID therefore retires the cache's allocation grant
|
|
1203
|
+
* and mints a handle for the replacement payload. An invalidated GUID retires
|
|
1204
|
+
* the entry immediately instead of keeping a stale payload alive.
|
|
1205
|
+
*/
|
|
1206
|
+
interface GuidHandleInternEntry {
|
|
1207
|
+
readonly handle: number;
|
|
1208
|
+
readonly payload: Asset;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const guidHandleInternByWorld = new WeakMap<World, Map<string, GuidHandleInternEntry>>();
|
|
1212
|
+
|
|
1213
|
+
function internSharedRefFromGuid<B extends string>(
|
|
1214
|
+
world: World,
|
|
1215
|
+
assetsRef: AssetRegistry,
|
|
1216
|
+
guid: string,
|
|
1217
|
+
brand: B,
|
|
1218
|
+
): Handle<B, 'shared'> | undefined {
|
|
1219
|
+
let perWorld = guidHandleInternByWorld.get(world);
|
|
1220
|
+
if (perWorld === undefined) {
|
|
1221
|
+
perWorld = new Map<string, GuidHandleInternEntry>();
|
|
1222
|
+
guidHandleInternByWorld.set(world, perWorld);
|
|
1223
|
+
}
|
|
1224
|
+
const key = `${guid.toLowerCase()}\u0000${brand}`;
|
|
1225
|
+
const cached = perWorld.get(key);
|
|
1226
|
+
const payload = assetsRef.lookup(guid);
|
|
1227
|
+
if (cached !== undefined && cached.payload === payload) {
|
|
1228
|
+
const cachedHandle = toShared<B>(cached.handle);
|
|
1229
|
+
if (world.sharedRefs.resolve(cachedHandle).ok) return cachedHandle;
|
|
1230
|
+
}
|
|
1231
|
+
if (cached !== undefined) {
|
|
1232
|
+
world.sharedRefs.release(toShared<B>(cached.handle));
|
|
1233
|
+
perWorld.delete(key);
|
|
1234
|
+
}
|
|
1235
|
+
if (payload === undefined) return undefined;
|
|
1236
|
+
// Mint exactly once per (world, guid, brand). ECS owns the allocation grant
|
|
1237
|
+
// and publishes release evidence through SharedRefStore; render does not
|
|
1238
|
+
// attach a second deleter lifecycle to the World.
|
|
1239
|
+
const handle = world.internSharedRef(brand, payload);
|
|
1240
|
+
perWorld.set(key, { handle, payload });
|
|
1241
|
+
return handle;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* feat-20260623-world-space-video-asset M4 / w14 (D-5): if a user-region
|
|
1246
|
+
* texture field's paramValue is an embedded GUID string that catalogues to a
|
|
1247
|
+
* VideoAsset (`kind === 'video'`), mint a `VideoAsset`-branded column handle for
|
|
1248
|
+
* it and return that handle; otherwise return undefined (the field is a static
|
|
1249
|
+
* texture / sampler / scalar and flows through the normal TextureAsset path).
|
|
1250
|
+
*
|
|
1251
|
+
* The video GUID occupies the same texture2d values slot a static texture
|
|
1252
|
+
* would (P4: identical binding shape) — extract just routes it to a different
|
|
1253
|
+
* GPU lifecycle (the transient DynamicTextureStore, D-3) instead of the static
|
|
1254
|
+
* `ensureResident` cache, whose switch has no `video` arm (AC-08). Minting a
|
|
1255
|
+
* brand-`VideoAsset` handle keeps the snapshot self-describing: the record stage
|
|
1256
|
+
* sees a video handle and resolves the per-frame view without reaching back into
|
|
1257
|
+
* the asset (charter P5).
|
|
1258
|
+
*
|
|
1259
|
+
* A `number` paramValue (already a minted column handle) is not a GUID string so
|
|
1260
|
+
* it cannot be a freshly-catalogued video; it passes through as undefined here.
|
|
1261
|
+
*/
|
|
1262
|
+
function resolveVideoFieldHandle(
|
|
1263
|
+
value: unknown,
|
|
1264
|
+
world: World,
|
|
1265
|
+
assetsRef: AssetRegistry,
|
|
1266
|
+
): Handle<'VideoAsset', 'shared'> | undefined {
|
|
1267
|
+
const texture = materialTextureValue(value);
|
|
1268
|
+
const textureGuid = assetReferenceText(texture?.texture ?? value);
|
|
1269
|
+
if (textureGuid === undefined) return undefined;
|
|
1270
|
+
const payload = assetsRef.lookup(textureGuid);
|
|
1271
|
+
if (payload === undefined || payload.kind !== 'video') return undefined;
|
|
1272
|
+
// M4: intern so a video GUID mints one stable VideoAsset handle per World
|
|
1273
|
+
// instead of a fresh slot every frame. The transient per-frame view is
|
|
1274
|
+
// resolved downstream by this handle (DynamicTextureStore); minting the
|
|
1275
|
+
// handle once does not freeze the view (P5: handle != frame data).
|
|
1276
|
+
return internSharedRefFromGuid(world, assetsRef, textureGuid, 'VideoAsset');
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function materialTextureValue(value: unknown): MaterialTextureValue | undefined {
|
|
1280
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
1281
|
+
const textureValue = value as Partial<MaterialTextureValue>;
|
|
1282
|
+
return typeof textureValue.texture === 'number' ||
|
|
1283
|
+
typeof textureValue.texture === 'string' ||
|
|
1284
|
+
textureValue.texture instanceof Uint8Array
|
|
1285
|
+
? (textureValue as MaterialTextureValue)
|
|
1286
|
+
: undefined;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function assetReferenceText(value: unknown): string | undefined {
|
|
1290
|
+
if (typeof value === 'string') return value;
|
|
1291
|
+
if (value instanceof Uint8Array && isAssetGuidBytes(value)) return AssetGuid.format(value);
|
|
1292
|
+
return undefined;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function isAssetGuidBytes(value: Uint8Array): value is AssetGuidBytes {
|
|
1296
|
+
return value.byteLength === 16;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function materialNormalScale(values: Readonly<Record<string, unknown>>): number {
|
|
1300
|
+
for (const [field, value] of Object.entries(values)) {
|
|
1301
|
+
if (field !== 'normalTexture') continue;
|
|
1302
|
+
const scale = materialTextureValue(value)?.normalScale;
|
|
1303
|
+
return typeof scale === 'number' && Number.isFinite(scale) ? scale : 1;
|
|
1304
|
+
}
|
|
1305
|
+
return 1;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function materialTextureRef(value: unknown): unknown {
|
|
1309
|
+
return materialTextureValue(value)?.texture ?? value;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function collectMaterialTextureCoordinates(
|
|
1313
|
+
values: Readonly<Record<string, unknown>>,
|
|
1314
|
+
): Map<string, MaterialTextureCoordinates> {
|
|
1315
|
+
const out = new Map<string, MaterialTextureCoordinates>();
|
|
1316
|
+
for (const field of [
|
|
1317
|
+
'baseColorTexture',
|
|
1318
|
+
'metallicRoughnessTexture',
|
|
1319
|
+
'normalTexture',
|
|
1320
|
+
'specularTintTexture',
|
|
1321
|
+
'emissiveTexture',
|
|
1322
|
+
'occlusionTexture',
|
|
1323
|
+
]) {
|
|
1324
|
+
const coordinates = materialTextureValue(values[field])?.coordinates;
|
|
1325
|
+
if (coordinates !== undefined) out.set(field, coordinates);
|
|
1326
|
+
}
|
|
1327
|
+
return out;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function collectMaterialTextureSamplers(
|
|
1331
|
+
values: Readonly<Record<string, unknown>>,
|
|
1332
|
+
resolveSampler: (value: unknown) => Handle<'SamplerAsset', 'shared'> | undefined,
|
|
1333
|
+
): Map<string, Handle<'SamplerAsset', 'shared'>> {
|
|
1334
|
+
const out = new Map<string, Handle<'SamplerAsset', 'shared'>>();
|
|
1335
|
+
for (const [field, value] of Object.entries(values)) {
|
|
1336
|
+
const sampler = resolveSampler(materialTextureValue(value)?.sampler);
|
|
1337
|
+
if (sampler !== undefined) out.set(field, sampler);
|
|
1338
|
+
}
|
|
1339
|
+
const legacySampler = resolveSampler(values.sampler);
|
|
1340
|
+
if (legacySampler !== undefined && !out.has('baseColorTexture')) {
|
|
1341
|
+
out.set('baseColorTexture', legacySampler);
|
|
1342
|
+
}
|
|
1343
|
+
return out;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* feat-20260621-learn-render-5-5-parallax M2 / w7 (D-3): collect the
|
|
1348
|
+
* user-region texture handles for a material by iterating the shader's
|
|
1349
|
+
* `derive(paramSchema).textureFieldNames` SSOT (via
|
|
1350
|
+
* `AssetRegistry.materialShaderTextureFieldNames`). Each declared texture
|
|
1351
|
+
* field whose paramValue resolves to a handle lands in the returned map keyed
|
|
1352
|
+
* by field name; this is the single path through which an arbitrary number of user-region
|
|
1353
|
+
* textures (4 standard or 5+ custom, e.g. parallax `heightTexture`) flow.
|
|
1354
|
+
*
|
|
1355
|
+
* When the shader is not registered the built-in 4-field set is used so
|
|
1356
|
+
* standard materials still resolve. `emissiveTexture` / `occlusionTexture`
|
|
1357
|
+
* are engine-injection (lightmap) textures and are NOT part of this set.
|
|
1358
|
+
*
|
|
1359
|
+
* feat-20260623-world-space-video-asset M4 / w14 (D-5): a field whose paramValue
|
|
1360
|
+
* catalogues to a VideoAsset is routed into `videoOut` (a VideoAsset-branded
|
|
1361
|
+
* handle) instead of the TextureAsset map, so the record stage pulls its view
|
|
1362
|
+
* from the transient DynamicTextureStore (D-3) rather than the static
|
|
1363
|
+
* ensureResident cache (AC-08). The video GUID still has to be in this
|
|
1364
|
+
* `textureFieldNames` traversal set or it is never inspected (R-7) — that
|
|
1365
|
+
* membership is asserted in w13.
|
|
1366
|
+
*/
|
|
1367
|
+
function collectUserRegionTextureHandles(
|
|
1368
|
+
pv: Readonly<Record<string, unknown>>,
|
|
1369
|
+
shaderId: string | undefined,
|
|
1370
|
+
paramSchema: readonly ParamSchemaEntry[] | undefined,
|
|
1371
|
+
assetsRef: AssetRegistry,
|
|
1372
|
+
world: World,
|
|
1373
|
+
resolveTex: (
|
|
1374
|
+
value: unknown,
|
|
1375
|
+
brand: 'TextureAsset',
|
|
1376
|
+
) => Handle<'TextureAsset', 'shared'> | undefined,
|
|
1377
|
+
videoOut: Map<string, Handle<'VideoAsset', 'shared'>>,
|
|
1378
|
+
): Map<string, Handle<'TextureAsset', 'shared'>> {
|
|
1379
|
+
const fields =
|
|
1380
|
+
materialTextureFields(
|
|
1381
|
+
shaderId,
|
|
1382
|
+
paramSchema !== undefined
|
|
1383
|
+
? derive(paramSchema).textureFieldNames
|
|
1384
|
+
: shaderId !== undefined
|
|
1385
|
+
? assetsRef.materialShaderTextureFieldNames(shaderId)
|
|
1386
|
+
: undefined,
|
|
1387
|
+
) ?? BUILTIN_USER_REGION_TEXTURE_FIELDS;
|
|
1388
|
+
const out = new Map<string, Handle<'TextureAsset', 'shared'>>();
|
|
1389
|
+
for (const field of fields) {
|
|
1390
|
+
// D-5: a video-kind paramValue is routed to the transient path (videoOut),
|
|
1391
|
+
// NOT minted as a TextureAsset (which would crash the record-stage
|
|
1392
|
+
// ensureResident, AC-08). A static field falls through to resolveTex.
|
|
1393
|
+
const videoHandle = resolveVideoFieldHandle(pv[field], world, assetsRef);
|
|
1394
|
+
if (videoHandle !== undefined) {
|
|
1395
|
+
videoOut.set(field, videoHandle);
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
const handle = resolveTex(materialTextureRef(pv[field]), 'TextureAsset');
|
|
1399
|
+
if (handle !== undefined) out.set(field, handle);
|
|
1400
|
+
}
|
|
1401
|
+
return out;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const ENGINE_INJECTED_TEXTURE_FIELDS = new Set(['emissiveTexture', 'occlusionTexture']);
|
|
1405
|
+
|
|
1406
|
+
function isEngineInjectedTextureField(shaderId: string | undefined, fieldName: string): boolean {
|
|
1407
|
+
return isStandardPbrMaterialShader(shaderId) && ENGINE_INJECTED_TEXTURE_FIELDS.has(fieldName);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function materialParametersToParamSchema(
|
|
1411
|
+
parameters: readonly MaterialParameter[],
|
|
1412
|
+
shaderId: string | undefined,
|
|
1413
|
+
): readonly ParamSchemaEntry[] {
|
|
1414
|
+
const engineInjectedTextureFields = isStandardPbrMaterialShader(shaderId)
|
|
1415
|
+
? ENGINE_INJECTED_TEXTURE_FIELDS
|
|
1416
|
+
: undefined;
|
|
1417
|
+
return parameters.flatMap((parameter): ParamSchemaEntry[] => {
|
|
1418
|
+
// Boolean material values are runtime-only and have no UBO representation.
|
|
1419
|
+
if (parameter.type === 'bool') return [];
|
|
1420
|
+
if (parameter.type === 'texture') {
|
|
1421
|
+
if (engineInjectedTextureFields?.has(parameter.name) === true) return [];
|
|
1422
|
+
return [{ name: parameter.name, type: 'texture2d' }];
|
|
1423
|
+
}
|
|
1424
|
+
return [
|
|
1425
|
+
{
|
|
1426
|
+
name: parameter.name,
|
|
1427
|
+
type: parameter.type,
|
|
1428
|
+
...(parameter.colorSpace === undefined ? {} : { colorSpace: parameter.colorSpace }),
|
|
1429
|
+
},
|
|
1430
|
+
];
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Resolve color semantics from the material asset first and the shader schema
|
|
1436
|
+
* for fields the asset does not redeclare. Generated and historical pack
|
|
1437
|
+
* materials commonly omit `parameters`, while the shader registry still owns
|
|
1438
|
+
* the complete parameter contract.
|
|
1439
|
+
*/
|
|
1440
|
+
function materialColorParameterSchema(
|
|
1441
|
+
parameters: readonly MaterialParameter[],
|
|
1442
|
+
shaderId: string | undefined,
|
|
1443
|
+
assets: AssetRegistry,
|
|
1444
|
+
): readonly MaterialColorParameterSchema[] {
|
|
1445
|
+
if (shaderId === undefined) return parameters;
|
|
1446
|
+
const shader = assets.shaderRegistry.findMaterialArtifact(shaderId);
|
|
1447
|
+
if (!shader.ok) return parameters;
|
|
1448
|
+
|
|
1449
|
+
const byName = new Map<string, MaterialColorParameterSchema>(
|
|
1450
|
+
shader.value.paramSchema.map((parameter) => [parameter.name, parameter]),
|
|
1451
|
+
);
|
|
1452
|
+
for (const parameter of parameters) byName.set(parameter.name, parameter);
|
|
1453
|
+
return [...byName.values()];
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const MATERIAL_PARENT_CHAIN_LIMIT = 128;
|
|
1457
|
+
|
|
1458
|
+
function isDeepFrozen(value: unknown, seen = new Set<object>()): boolean {
|
|
1459
|
+
if (typeof value !== 'object' || value === null) return true;
|
|
1460
|
+
if (seen.has(value)) return true;
|
|
1461
|
+
if (!Object.isFrozen(value)) return false;
|
|
1462
|
+
seen.add(value);
|
|
1463
|
+
for (const child of Object.values(value)) {
|
|
1464
|
+
if (!isDeepFrozen(child, seen)) return false;
|
|
1465
|
+
}
|
|
1466
|
+
return true;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Capture the object identities that make a resolved material stable. The
|
|
1471
|
+
* renderer owns this cache, but the AssetRegistry owns parent GUID lookup, so
|
|
1472
|
+
* validation must walk the same chain on every cross-frame read. A malformed
|
|
1473
|
+
* or cyclic chain is deliberately not cacheable.
|
|
1474
|
+
*/
|
|
1475
|
+
function captureMaterialCacheChain(
|
|
1476
|
+
root: MaterialAsset,
|
|
1477
|
+
assets: AssetRegistry,
|
|
1478
|
+
): readonly MaterialCacheChainIdentity[] | undefined {
|
|
1479
|
+
const chain: MaterialCacheChainIdentity[] = [];
|
|
1480
|
+
const visitedParentGuids = new Set<string>();
|
|
1481
|
+
let current = root;
|
|
1482
|
+
for (let depth = 0; depth < MATERIAL_PARENT_CHAIN_LIMIT; depth += 1) {
|
|
1483
|
+
const parentRef = current.parent;
|
|
1484
|
+
const parentGuid = parentRef === undefined ? undefined : materialGuidText(parentRef);
|
|
1485
|
+
chain.push({ asset: current, parentGuid });
|
|
1486
|
+
if (parentGuid === undefined) return chain;
|
|
1487
|
+
if (parentRef === undefined) return undefined;
|
|
1488
|
+
if (visitedParentGuids.has(parentGuid)) return undefined;
|
|
1489
|
+
visitedParentGuids.add(parentGuid);
|
|
1490
|
+
const parent = assets.lookup<Asset>(parentRef);
|
|
1491
|
+
if (parent?.kind !== 'material') return undefined;
|
|
1492
|
+
current = parent;
|
|
1493
|
+
}
|
|
1494
|
+
return undefined;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function readPersistentMaterialSnapshot(
|
|
1498
|
+
cache: MaterialSnapshotCache | undefined,
|
|
1499
|
+
handleRaw: number,
|
|
1500
|
+
assets: AssetRegistry,
|
|
1501
|
+
): MaterialSnapshotCacheEntry | undefined {
|
|
1502
|
+
const entry = cache?.get(handleRaw);
|
|
1503
|
+
if (entry === undefined) return undefined;
|
|
1504
|
+
if (
|
|
1505
|
+
entry.crossFrameSafe &&
|
|
1506
|
+
entry.chain !== undefined &&
|
|
1507
|
+
entry.catalogEpoch === assets.catalogEpoch
|
|
1508
|
+
)
|
|
1509
|
+
return entry;
|
|
1510
|
+
// The snapshot contains resolved texture and sampler handles as well as the
|
|
1511
|
+
// material parent chain. Any registry mutation can replace one of those
|
|
1512
|
+
// referenced payloads, so an epoch mismatch is a hard miss even when the
|
|
1513
|
+
// material objects themselves are unchanged.
|
|
1514
|
+
cache?.delete(handleRaw);
|
|
1515
|
+
return undefined;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Fast path for a cache entry whose chain was already validated at the
|
|
1520
|
+
* current registry epoch. The full reader needs the root asset only when an
|
|
1521
|
+
* epoch changed; asking ECS to resolve that root before checking the epoch
|
|
1522
|
+
* defeats the cross-frame cache on the ordinary stable frame.
|
|
1523
|
+
*/
|
|
1524
|
+
function readStablePersistentMaterialSnapshot(
|
|
1525
|
+
cache: MaterialSnapshotCache | undefined,
|
|
1526
|
+
handleRaw: number,
|
|
1527
|
+
assets: AssetRegistry,
|
|
1528
|
+
): MaterialSnapshotCacheEntry | undefined {
|
|
1529
|
+
const entry = cache?.get(handleRaw);
|
|
1530
|
+
return entry?.crossFrameSafe === true &&
|
|
1531
|
+
entry.chain !== undefined &&
|
|
1532
|
+
entry.catalogEpoch === assets.catalogEpoch
|
|
1533
|
+
? entry
|
|
1534
|
+
: undefined;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function storeMaterialSnapshot(
|
|
1538
|
+
cache: MaterialSnapshotCache,
|
|
1539
|
+
handleRaw: number,
|
|
1540
|
+
snapshot: MaterialSnapshot,
|
|
1541
|
+
passes: readonly MaterialPass[],
|
|
1542
|
+
root: MaterialAsset,
|
|
1543
|
+
assets: AssetRegistry,
|
|
1544
|
+
): MaterialSnapshotCacheEntry {
|
|
1545
|
+
const chain = captureMaterialCacheChain(root, assets);
|
|
1546
|
+
const entry: MaterialSnapshotCacheEntry = {
|
|
1547
|
+
snapshot,
|
|
1548
|
+
passes,
|
|
1549
|
+
crossFrameSafe: chain?.every(({ asset }) => isDeepFrozen(asset)) === true,
|
|
1550
|
+
...(chain === undefined ? {} : { chain }),
|
|
1551
|
+
...(chain === undefined ? {} : { catalogEpoch: assets.catalogEpoch }),
|
|
1552
|
+
};
|
|
1553
|
+
cache.set(handleRaw, entry);
|
|
1554
|
+
return entry;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* feat-20260608 M5 amend / w11-a: resolve a single MaterialAsset handle into
|
|
1559
|
+
* a per-submesh MaterialSnapshot. Used by the extractFrame archetype loop to
|
|
1560
|
+
* build `RenderableSnapshot.materials[]` for indices >= 1 (the entity-level
|
|
1561
|
+
* snapshot at index 0 is built inline because it also drives multi-pass
|
|
1562
|
+
* DispatchEntry creation + sprite-region overrides; per-submesh secondary
|
|
1563
|
+
* materials are non-sprite + single-pass-equivalent for the record stage's
|
|
1564
|
+
* UBO upload, so this helper produces a plain MaterialSnapshot only).
|
|
1565
|
+
*
|
|
1566
|
+
* Returns `defaultMaterialSnapshot()` (mid-grey unlit) for handle=0 (case-B
|
|
1567
|
+
* sentinel, mirroring the inline path) and on each unresolved / non-material
|
|
1568
|
+
* asset (so a partially-mis-registered multi-material entity still renders
|
|
1569
|
+
* the resolvable submeshes; the count-mismatch validator already filtered
|
|
1570
|
+
* the count-disagreement case earlier).
|
|
1571
|
+
*/
|
|
1572
|
+
export function resolveMaterialSnapshot(
|
|
1573
|
+
handleRaw: number,
|
|
1574
|
+
world: World,
|
|
1575
|
+
assetsRef: AssetRegistry,
|
|
1576
|
+
materialSnapshotCache?: MaterialSnapshotCache,
|
|
1577
|
+
persistentMaterialSnapshotCache?: MaterialSnapshotCache,
|
|
1578
|
+
): MaterialSnapshot {
|
|
1579
|
+
if (handleRaw === 0) return defaultMaterialSnapshot(handleRaw);
|
|
1580
|
+
const cached = materialSnapshotCache?.get(handleRaw);
|
|
1581
|
+
if (cached !== undefined) return cached.snapshot;
|
|
1582
|
+
const stablePersistentCached = readStablePersistentMaterialSnapshot(
|
|
1583
|
+
persistentMaterialSnapshotCache,
|
|
1584
|
+
handleRaw,
|
|
1585
|
+
assetsRef,
|
|
1586
|
+
);
|
|
1587
|
+
if (stablePersistentCached !== undefined) {
|
|
1588
|
+
materialSnapshotCache?.set(handleRaw, stablePersistentCached);
|
|
1589
|
+
return stablePersistentCached.snapshot;
|
|
1590
|
+
}
|
|
1591
|
+
const tagged = toShared<'MaterialAsset'>(handleRaw);
|
|
1592
|
+
const res = resolveAssetHandle(world, tagged);
|
|
1593
|
+
if (!res.ok) return defaultMaterialSnapshot(handleRaw);
|
|
1594
|
+
const asset = res.value;
|
|
1595
|
+
if (asset.kind !== 'material') return defaultMaterialSnapshot(handleRaw);
|
|
1596
|
+
const persistentCached = readPersistentMaterialSnapshot(
|
|
1597
|
+
persistentMaterialSnapshotCache,
|
|
1598
|
+
handleRaw,
|
|
1599
|
+
assetsRef,
|
|
1600
|
+
);
|
|
1601
|
+
if (persistentCached !== undefined) {
|
|
1602
|
+
materialSnapshotCache?.set(handleRaw, persistentCached);
|
|
1603
|
+
return persistentCached.snapshot;
|
|
1604
|
+
}
|
|
1605
|
+
const resolvedResult = walkMaterialPassesOverSharedRefs(world, tagged, assetsRef);
|
|
1606
|
+
if (!resolvedResult.ok) return defaultMaterialSnapshot(handleRaw);
|
|
1607
|
+
const resolved = resolvedResult.value;
|
|
1608
|
+
const allPasses = resolved.passes;
|
|
1609
|
+
const firstPassShader =
|
|
1610
|
+
allPasses.length > 0
|
|
1611
|
+
? runtimeMaterialShaderId(allPasses[0]?.program.module, allPasses[0]?.name)
|
|
1612
|
+
: undefined;
|
|
1613
|
+
const pv = materialValuesToLinearRuntime(
|
|
1614
|
+
resolved.values,
|
|
1615
|
+
materialColorParameterSchema(resolved.parameters ?? [], firstPassShader, assetsRef),
|
|
1616
|
+
resolved.colorSpace,
|
|
1617
|
+
) as Readonly<Record<string, unknown>>;
|
|
1618
|
+
const baseColorPv = pv.baseColor as readonly number[] | undefined;
|
|
1619
|
+
const baseColor = vec3.create(
|
|
1620
|
+
baseColorPv?.[0] ?? 1,
|
|
1621
|
+
baseColorPv?.[1] ?? 1,
|
|
1622
|
+
baseColorPv?.[2] ?? 1,
|
|
1623
|
+
);
|
|
1624
|
+
const metallicPv = typeof pv.metallic === 'number' ? pv.metallic : 0;
|
|
1625
|
+
const roughnessPv = typeof pv.roughness === 'number' ? pv.roughness : 0.5;
|
|
1626
|
+
const clearcoatPv = typeof pv.clearcoat === 'number' ? pv.clearcoat : 0;
|
|
1627
|
+
const clearcoatRoughnessPv =
|
|
1628
|
+
typeof pv.clearcoatRoughness === 'number' ? pv.clearcoatRoughness : 0.5;
|
|
1629
|
+
const specularTintPv = pv.specularTint as readonly number[] | undefined;
|
|
1630
|
+
const normalScalePv = materialNormalScale(pv);
|
|
1631
|
+
|
|
1632
|
+
const paramSnap: Record<string, number | number[] | string> = {};
|
|
1633
|
+
for (const [k, v] of Object.entries(pv)) {
|
|
1634
|
+
if (typeof v === 'number') paramSnap[k] = v;
|
|
1635
|
+
else if (typeof v === 'string') paramSnap[k] = v;
|
|
1636
|
+
else if (Array.isArray(v) && v.every((x) => typeof x === 'number')) {
|
|
1637
|
+
paramSnap[k] = v as number[];
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
const materialParamSchema = materialParametersToParamSchema(
|
|
1641
|
+
resolved.parameters ?? [],
|
|
1642
|
+
firstPassShader,
|
|
1643
|
+
);
|
|
1644
|
+
// feat-20260614 M8 (D-19): texture / sampler values are embedded GUIDs
|
|
1645
|
+
// (dash-form strings) after loadByGuid. Resolve each to a user-tier column
|
|
1646
|
+
// handle by looking up the catalogued payload and minting via
|
|
1647
|
+
// world.allocSharedRef; a numeric value (already a column handle from a
|
|
1648
|
+
// directly-minted material) passes through unchanged.
|
|
1649
|
+
const resolveTexLike = <B extends string>(
|
|
1650
|
+
value: unknown,
|
|
1651
|
+
brand: B,
|
|
1652
|
+
): Handle<B, 'shared'> | undefined => {
|
|
1653
|
+
if (typeof value === 'number') return toShared<B>(value);
|
|
1654
|
+
if (typeof value === 'string') {
|
|
1655
|
+
// M4: intern the GUID -> column-handle resolution so each unique
|
|
1656
|
+
// (world, guid, brand) mints exactly ONE stable handle reused across
|
|
1657
|
+
// frames (stops the per-frame slot churn that defeated the GPU
|
|
1658
|
+
// residency cache). feat-20260619 M2 / w8: TextureAsset brand wires
|
|
1659
|
+
// onLastRelease -> gpuStore.evictTexture; other brands (SamplerAsset)
|
|
1660
|
+
// bypass (releaseUnreferenced-fallback lifecycle).
|
|
1661
|
+
return internSharedRefFromGuid(world, assetsRef, value, brand);
|
|
1662
|
+
}
|
|
1663
|
+
return undefined;
|
|
1664
|
+
};
|
|
1665
|
+
// feat-20260621-learn-render-5-5-parallax M2 / w7 (D-3): iterate the
|
|
1666
|
+
// shader's derive(paramSchema).textureFieldNames SSOT instead of a hardcoded
|
|
1667
|
+
// user-region field list, so an Nth texture (e.g. parallax heightTexture)
|
|
1668
|
+
// resolves through the same path. emissive/occlusion are engine-injection
|
|
1669
|
+
// (lightmap) textures, NOT in textureFieldNames, so they keep named reads.
|
|
1670
|
+
const videoTextureFields = new Map<string, Handle<'VideoAsset', 'shared'>>();
|
|
1671
|
+
const textureHandles = collectUserRegionTextureHandles(
|
|
1672
|
+
pv,
|
|
1673
|
+
firstPassShader,
|
|
1674
|
+
materialParamSchema.length > 0 ? materialParamSchema : undefined,
|
|
1675
|
+
assetsRef,
|
|
1676
|
+
world,
|
|
1677
|
+
resolveTexLike,
|
|
1678
|
+
videoTextureFields,
|
|
1679
|
+
);
|
|
1680
|
+
const samplerHandles = collectMaterialTextureSamplers(pv, (value) =>
|
|
1681
|
+
resolveTexLike(materialTextureRef(value), 'SamplerAsset'),
|
|
1682
|
+
);
|
|
1683
|
+
const emissiveTextureHandle = resolveTexLike(
|
|
1684
|
+
materialTextureRef(pv.emissiveTexture),
|
|
1685
|
+
'TextureAsset',
|
|
1686
|
+
);
|
|
1687
|
+
const occlusionTextureHandle = resolveTexLike(
|
|
1688
|
+
materialTextureRef(pv.occlusionTexture),
|
|
1689
|
+
'TextureAsset',
|
|
1690
|
+
);
|
|
1691
|
+
const textureCoordinates = collectMaterialTextureCoordinates(pv);
|
|
1692
|
+
// Named user-region fields are derived from the map (sprite + legacy reads).
|
|
1693
|
+
const baseColorTextureHandle = textureHandles.get('baseColorTexture');
|
|
1694
|
+
const metallicRoughnessTextureHandle = textureHandles.get('metallicRoughnessTexture');
|
|
1695
|
+
const normalTextureHandle = textureHandles.get('normalTexture');
|
|
1696
|
+
const emissivePv = pv.emissive as readonly number[] | undefined;
|
|
1697
|
+
const snapshot: MaterialSnapshot = {
|
|
1698
|
+
baseColor,
|
|
1699
|
+
metallic: metallicPv,
|
|
1700
|
+
roughness: roughnessPv,
|
|
1701
|
+
clearcoat: clearcoatPv,
|
|
1702
|
+
clearcoatRoughness: clearcoatRoughnessPv,
|
|
1703
|
+
...(specularTintPv !== undefined && {
|
|
1704
|
+
specularTint: [
|
|
1705
|
+
specularTintPv[0] ?? 1,
|
|
1706
|
+
specularTintPv[1] ?? 1,
|
|
1707
|
+
specularTintPv[2] ?? 1,
|
|
1708
|
+
] as readonly [number, number, number],
|
|
1709
|
+
}),
|
|
1710
|
+
normalScale: normalScalePv,
|
|
1711
|
+
materialShaderId: firstPassShader,
|
|
1712
|
+
materialHandle: handleRaw,
|
|
1713
|
+
renderState: pipelineRenderState(allPasses[0]?.renderState),
|
|
1714
|
+
paramSnapshot: paramSnap,
|
|
1715
|
+
...(materialParamSchema.length > 0 && { materialParamSchema }),
|
|
1716
|
+
...(textureCoordinates.size > 0 && { textureCoordinates }),
|
|
1717
|
+
...(textureHandles.size > 0 && { textureHandles }),
|
|
1718
|
+
...(videoTextureFields.size > 0 && { videoTextureFields }),
|
|
1719
|
+
...(samplerHandles.size > 0 && { samplerHandles }),
|
|
1720
|
+
...(baseColorTextureHandle !== undefined && { baseColorTexture: baseColorTextureHandle }),
|
|
1721
|
+
...(metallicRoughnessTextureHandle !== undefined && {
|
|
1722
|
+
metallicRoughnessTexture: metallicRoughnessTextureHandle,
|
|
1723
|
+
}),
|
|
1724
|
+
...(normalTextureHandle !== undefined && { normalTexture: normalTextureHandle }),
|
|
1725
|
+
...(emissivePv !== undefined && {
|
|
1726
|
+
emissive: [emissivePv[0] ?? 0, emissivePv[1] ?? 0, emissivePv[2] ?? 0] as readonly [
|
|
1727
|
+
number,
|
|
1728
|
+
number,
|
|
1729
|
+
number,
|
|
1730
|
+
],
|
|
1731
|
+
}),
|
|
1732
|
+
...(typeof pv.emissiveIntensity === 'number' && { emissiveIntensity: pv.emissiveIntensity }),
|
|
1733
|
+
...(emissiveTextureHandle !== undefined && { emissiveTexture: emissiveTextureHandle }),
|
|
1734
|
+
...(occlusionTextureHandle !== undefined && { occlusionTexture: occlusionTextureHandle }),
|
|
1735
|
+
...(typeof pv.occlusionStrength === 'number' && { occlusionStrength: pv.occlusionStrength }),
|
|
1736
|
+
// feat-city-glb Bug 5 (per-submesh transparency): derive `transparent`
|
|
1737
|
+
// from the first pass's `renderState.blend` presence, identical to the
|
|
1738
|
+
// entity-level snapshot builder (extractFrame archetype loop). Without
|
|
1739
|
+
// this, per-submesh materials (materials[i>=1], e.g. a glTF BLEND decal
|
|
1740
|
+
// submesh on a multi-material mesh) never carry the transparent flag, so
|
|
1741
|
+
// the record stage's LDR split + blend routing treats them as opaque and
|
|
1742
|
+
// their alpha=0 texels composite as black.
|
|
1743
|
+
transparent: allPasses[0]?.renderState?.blend !== undefined,
|
|
1744
|
+
};
|
|
1745
|
+
const stored =
|
|
1746
|
+
materialSnapshotCache !== undefined
|
|
1747
|
+
? storeMaterialSnapshot(
|
|
1748
|
+
materialSnapshotCache,
|
|
1749
|
+
handleRaw,
|
|
1750
|
+
snapshot,
|
|
1751
|
+
allPasses,
|
|
1752
|
+
asset,
|
|
1753
|
+
assetsRef,
|
|
1754
|
+
)
|
|
1755
|
+
: undefined;
|
|
1756
|
+
const cacheEntry =
|
|
1757
|
+
stored ??
|
|
1758
|
+
(persistentMaterialSnapshotCache !== undefined
|
|
1759
|
+
? storeMaterialSnapshot(
|
|
1760
|
+
persistentMaterialSnapshotCache,
|
|
1761
|
+
handleRaw,
|
|
1762
|
+
snapshot,
|
|
1763
|
+
allPasses,
|
|
1764
|
+
asset,
|
|
1765
|
+
assetsRef,
|
|
1766
|
+
)
|
|
1767
|
+
: undefined);
|
|
1768
|
+
if (cacheEntry?.crossFrameSafe === true) {
|
|
1769
|
+
persistentMaterialSnapshotCache?.set(handleRaw, cacheEntry);
|
|
1770
|
+
}
|
|
1771
|
+
return snapshot;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/**
|
|
1775
|
+
* feat-20260612-skin-palette-per-frame-upload M2 / m2-6: narrow per-frame
|
|
1776
|
+
* pipeline surface consumed by `extractFrame`. Today only `skinPaletteAllocator`
|
|
1777
|
+
* crosses the seam (the allocator owns the per-frame palette buffer + cursor;
|
|
1778
|
+
* extract calls `resetForFrame` at frame entry then `allocateSlice` +
|
|
1779
|
+
* `writeJointPalette` per skinned entity). Kept narrow on purpose — extract
|
|
1780
|
+
* does NOT see the rest of `PipelineState` to avoid a circular import via
|
|
1781
|
+
* `render-system.ts`.
|
|
1782
|
+
*/
|
|
1783
|
+
export interface ExtractPipelineSurface {
|
|
1784
|
+
readonly skinPaletteAllocator: SkinPaletteAllocator | null;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
/**
|
|
1788
|
+
* PSSM (Parallel-Split Shadow Maps) split plane computation.
|
|
1789
|
+
*
|
|
1790
|
+
* Formula (plan-strategy D-8, research F1):
|
|
1791
|
+
* C_i = λ·n·(f/n)^(i/m) + (1-λ)·(n + i/m·(f-n))
|
|
1792
|
+
* where i = 1..m, n = near, f = far, m = cascadeCount
|
|
1793
|
+
*
|
|
1794
|
+
* The coverage range is [camera near, DirectionalLight.shadowDistance] — the
|
|
1795
|
+
* near end derives from the active camera, the far end is the shadowDistance
|
|
1796
|
+
* knob. Returns strictly monotonic view-space z depths (positive).
|
|
1797
|
+
*
|
|
1798
|
+
* When `far <= near + ε` (ε=1e-6), throws ShadowInvalidConfigError
|
|
1799
|
+
* (charter P3 explicit failure — research F2 guarantees formula stability
|
|
1800
|
+
* but degenerate configuration must be surfaced to the caller).
|
|
1801
|
+
*
|
|
1802
|
+
* @param nearPlane - near of the shadow casting range (from the camera near)
|
|
1803
|
+
* @param farPlane - far of the shadow casting range (DirectionalLight.shadowDistance)
|
|
1804
|
+
* @param cascadeCount - number of cascades (1..4)
|
|
1805
|
+
* @param splitLambda - PSSM split weight (0 = pure uniform, 1 = pure log)
|
|
1806
|
+
* @returns Array of m split depths (view-space z); length = cascadeCount,
|
|
1807
|
+
* guaranteed strictly monotonic, last element = far.
|
|
1808
|
+
*
|
|
1809
|
+
* @internal exported for w6/w7 testing only; production callers route through
|
|
1810
|
+
* extractFrame which validates cascadeCount/splitLambda via component schema.
|
|
1811
|
+
*/
|
|
1812
|
+
/**
|
|
1813
|
+
* Compute the 8 world-space corner points of a camera frustum slice.
|
|
1814
|
+
*
|
|
1815
|
+
* Uses mat4.unproject to map NDC cube corners back to world space.
|
|
1816
|
+
* The slice is defined by view-space nearZ and farZ depths.
|
|
1817
|
+
*
|
|
1818
|
+
* For a WebGPU perspective matrix, ndcZ(viewZ) = camFar * (viewZ - camNear) /
|
|
1819
|
+
* (viewZ * (camFar - camNear)). We compute the ndcZ for each slice boundary
|
|
1820
|
+
* and unproject directly.
|
|
1821
|
+
*
|
|
1822
|
+
* @param vp - camera view-projection matrix (column-major 16 floats)
|
|
1823
|
+
* @param camNear - camera near plane (used to derive NDC mapping)
|
|
1824
|
+
* @param camFar - camera far plane (used to derive NDC mapping)
|
|
1825
|
+
* @param nearZ - near depth of the frustum slice (view-space, positive)
|
|
1826
|
+
* @param farZ - far depth of the frustum slice (view-space, positive)
|
|
1827
|
+
* @returns Array of 8 Vec3 world-space corner positions.
|
|
1828
|
+
*/
|
|
1829
|
+
function computeFrustumCorners(
|
|
1830
|
+
vp: Mat4,
|
|
1831
|
+
camNear: number,
|
|
1832
|
+
camFar: number,
|
|
1833
|
+
nearZ: number,
|
|
1834
|
+
farZ: number,
|
|
1835
|
+
projection: 'perspective' | 'orthographic',
|
|
1836
|
+
): Vec3[] {
|
|
1837
|
+
const invVP = mat4.create();
|
|
1838
|
+
mat4.invert(invVP, vp);
|
|
1839
|
+
const corners: Vec3[] = [];
|
|
1840
|
+
|
|
1841
|
+
// NDC z mapping is projection-dependent (WebGPU clip-space z in [0,1]):
|
|
1842
|
+
// perspective: ndc(z) = camFar * (z - camNear) / (z * (camFar - camNear))
|
|
1843
|
+
// orthographic: ndc(z) = (z - camNear) / (camFar - camNear)
|
|
1844
|
+
// feat-20260613-csm M6 / w22: orthographic cameras silently produced
|
|
1845
|
+
// garbage NDC z (perspective formula divides by viewZ, but ortho NDC is
|
|
1846
|
+
// linear), which mapped the cascade slab back to a degenerate world-space
|
|
1847
|
+
// corner set and the AABB-fit collapsed to near zero -- shadow_caster
|
|
1848
|
+
// wrote its triangles outside the [-1,1] clip volume so the depth
|
|
1849
|
+
// attachment stayed at clear=1.0 (root cause for shadow-m2 / shadow-m3 /
|
|
1850
|
+
// shadow-opt-out dawn red surfaced after w20's host-side ortho fix).
|
|
1851
|
+
const span = camFar - camNear;
|
|
1852
|
+
const ndcNear =
|
|
1853
|
+
projection === 'orthographic'
|
|
1854
|
+
? (nearZ - camNear) / span
|
|
1855
|
+
: (camFar * (nearZ - camNear)) / (nearZ * span);
|
|
1856
|
+
const ndcFar =
|
|
1857
|
+
projection === 'orthographic'
|
|
1858
|
+
? (farZ - camNear) / span
|
|
1859
|
+
: (camFar * (farZ - camNear)) / (farZ * span);
|
|
1860
|
+
|
|
1861
|
+
const signs = [-1, 1];
|
|
1862
|
+
for (const sx of signs) {
|
|
1863
|
+
for (const sy of signs) {
|
|
1864
|
+
corners.push(unprojectNDC(invVP, sx, sy, ndcNear));
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
for (const sx of signs) {
|
|
1868
|
+
for (const sy of signs) {
|
|
1869
|
+
corners.push(unprojectNDC(invVP, sx, sy, ndcFar));
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
return corners;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/**
|
|
1877
|
+
* Unproject a single NDC point to world space.
|
|
1878
|
+
*/
|
|
1879
|
+
function unprojectNDC(invVP: Mat4, ndcX: number, ndcY: number, ndcZ: number): Vec3 {
|
|
1880
|
+
const ndc = vec3.create(ndcX, ndcY, ndcZ);
|
|
1881
|
+
const ws = vec3.create();
|
|
1882
|
+
mat4.unproject(ws, ndc, invVP);
|
|
1883
|
+
return ws;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
export function pssmSplit(
|
|
1887
|
+
nearPlane: number,
|
|
1888
|
+
farPlane: number,
|
|
1889
|
+
cascadeCount: number,
|
|
1890
|
+
splitLambda: number,
|
|
1891
|
+
): Float32Array {
|
|
1892
|
+
const EPS = 1e-6;
|
|
1893
|
+
if (farPlane <= nearPlane + EPS) {
|
|
1894
|
+
throw new ShadowInvalidConfigError('shadowDistance', farPlane, nearPlane + EPS);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
const result = new Float32Array(cascadeCount);
|
|
1898
|
+
const m = cascadeCount;
|
|
1899
|
+
const n = nearPlane;
|
|
1900
|
+
const f = farPlane;
|
|
1901
|
+
const ratio = f / n;
|
|
1902
|
+
|
|
1903
|
+
for (let i = 1; i <= m; i++) {
|
|
1904
|
+
const t = i / m;
|
|
1905
|
+
const logPart = n * ratio ** t;
|
|
1906
|
+
const uniformPart = n + t * (f - n);
|
|
1907
|
+
result[i - 1] = splitLambda * logPart + (1 - splitLambda) * uniformPart;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
return result;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-5 (plan-strategy §D-3,
|
|
1914
|
+
// requirements §5.3): 6-face view-proj matrix table for one omnidirectional
|
|
1915
|
+
// shadow caster.
|
|
1916
|
+
//
|
|
1917
|
+
// Face order matches WebGPU cube layer convention (also LearnOpenGL 5.3.2):
|
|
1918
|
+
// 0 (+X) | look=(+1, 0, 0) | up=(0, -1, 0)
|
|
1919
|
+
// 1 (-X) | look=(-1, 0, 0) | up=(0, -1, 0)
|
|
1920
|
+
// 2 (+Y) | look=(0, +1, 0) | up=(0, 0, +1)
|
|
1921
|
+
// 3 (-Y) | look=(0, -1, 0) | up=(0, 0, -1)
|
|
1922
|
+
// 4 (+Z) | look=(0, 0, +1) | up=(0, -1, 0)
|
|
1923
|
+
// 5 (-Z) | look=(0, 0, -1) | up=(0, -1, 0)
|
|
1924
|
+
//
|
|
1925
|
+
// Projection: WebGPU [0, 1] NDC perspective (mat4.perspective short name).
|
|
1926
|
+
// fov=90deg, aspect=1, near/far from PointLightShadow component.
|
|
1927
|
+
const POINT_SHADOW_FACE_LOOK: readonly Vec3[] = [
|
|
1928
|
+
vec3.create(1, 0, 0),
|
|
1929
|
+
vec3.create(-1, 0, 0),
|
|
1930
|
+
vec3.create(0, 1, 0),
|
|
1931
|
+
vec3.create(0, -1, 0),
|
|
1932
|
+
vec3.create(0, 0, 1),
|
|
1933
|
+
vec3.create(0, 0, -1),
|
|
1934
|
+
];
|
|
1935
|
+
const POINT_SHADOW_FACE_UP: readonly Vec3[] = [
|
|
1936
|
+
vec3.create(0, -1, 0),
|
|
1937
|
+
vec3.create(0, -1, 0),
|
|
1938
|
+
vec3.create(0, 0, 1),
|
|
1939
|
+
vec3.create(0, 0, -1),
|
|
1940
|
+
vec3.create(0, -1, 0),
|
|
1941
|
+
vec3.create(0, -1, 0),
|
|
1942
|
+
];
|
|
1943
|
+
|
|
1944
|
+
/**
|
|
1945
|
+
* Build 6 face view-proj matrices for an omnidirectional shadow caster
|
|
1946
|
+
* (point-light cube map). Returns 6 mat4 (column-major 16-float each) in face
|
|
1947
|
+
* order [+X, -X, +Y, -Y, +Z, -Z] matching WebGPU cube layer convention.
|
|
1948
|
+
*
|
|
1949
|
+
* Each matrix is `proj * view` where:
|
|
1950
|
+
* - `view = lookAt(lightPos, lightPos + faceLook[i], faceUp[i])`
|
|
1951
|
+
* - `proj = perspective(PI/2, 1, near, far)` (WebGPU [0, 1] NDC short name)
|
|
1952
|
+
*
|
|
1953
|
+
* The returned `Mat4[]` length is always exactly 6. Caller can flatten into a
|
|
1954
|
+
* 96-float (6 mat4) Float32Array for UBO upload (T-M1-7).
|
|
1955
|
+
*
|
|
1956
|
+
* @param lightPos world-space position of the point light (from companion Transform)
|
|
1957
|
+
* @param near near plane distance (PointLightShadow.nearPlane)
|
|
1958
|
+
* @param far far plane distance (PointLightShadow.farPlane)
|
|
1959
|
+
*/
|
|
1960
|
+
export function buildPointShadowMatrices(lightPos: Vec3, near: number, far: number): Mat4[] {
|
|
1961
|
+
const fovY = Math.PI / 2; // 90 deg
|
|
1962
|
+
const aspect = 1;
|
|
1963
|
+
const proj = mat4.create();
|
|
1964
|
+
mat4.perspective(proj, fovY, aspect, near, far);
|
|
1965
|
+
|
|
1966
|
+
const out: Mat4[] = [];
|
|
1967
|
+
for (let i = 0; i < 6; i++) {
|
|
1968
|
+
// biome-ignore lint/style/noNonNullAssertion: i in [0..6) and arrays are length-6 const
|
|
1969
|
+
const look = POINT_SHADOW_FACE_LOOK[i]!;
|
|
1970
|
+
// biome-ignore lint/style/noNonNullAssertion: i in [0..6) and arrays are length-6 const
|
|
1971
|
+
const up = POINT_SHADOW_FACE_UP[i]!;
|
|
1972
|
+
const target = vec3.create(
|
|
1973
|
+
(lightPos[0] ?? 0) + (look[0] ?? 0),
|
|
1974
|
+
(lightPos[1] ?? 0) + (look[1] ?? 0),
|
|
1975
|
+
(lightPos[2] ?? 0) + (look[2] ?? 0),
|
|
1976
|
+
);
|
|
1977
|
+
const view = mat4.create();
|
|
1978
|
+
mat4.lookAt(view, lightPos, target, up);
|
|
1979
|
+
const vp = mat4.create();
|
|
1980
|
+
mat4.multiply(vp, proj, view);
|
|
1981
|
+
out.push(vp);
|
|
1982
|
+
}
|
|
1983
|
+
return out;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
/**
|
|
1987
|
+
* bug-20260710-editor-cross-world-shadow: the camera-frustum subset of
|
|
1988
|
+
* {@link CameraSnapshot} that the directional-CSM matrix builder consumes.
|
|
1989
|
+
* Extracted so the builder can run both inside {@link extractFrame} (single
|
|
1990
|
+
* world) AND at the {@link extractFrames} merge layer, where the camera and the
|
|
1991
|
+
* directional light may originate in DIFFERENT worlds (the editor
|
|
1992
|
+
* editorWorld/sceneWorld super-composite). A cameraless world produces zero
|
|
1993
|
+
* lightViewProj matrices, so the merge layer MUST recompute using the surfaced
|
|
1994
|
+
* camera — see {@link computeDirectionalCsm}.
|
|
1995
|
+
*/
|
|
1996
|
+
export interface CsmCameraData {
|
|
1997
|
+
readonly world: Float32Array;
|
|
1998
|
+
readonly fov: number;
|
|
1999
|
+
readonly aspect: number;
|
|
2000
|
+
readonly near: number;
|
|
2001
|
+
readonly far: number;
|
|
2002
|
+
readonly projection: 'perspective' | 'orthographic';
|
|
2003
|
+
readonly orthoLeft: number;
|
|
2004
|
+
readonly orthoRight: number;
|
|
2005
|
+
readonly orthoBottom: number;
|
|
2006
|
+
readonly orthoTop: number;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* bug-20260710-editor-cross-world-shadow: the raw directional-shadow config a
|
|
2011
|
+
* DirectionalLight carries, threaded onto {@link ExtractedLights} so the merge
|
|
2012
|
+
* layer can recompute CSM matrices against the surfaced camera. Distinct from
|
|
2013
|
+
* the *computed* outputs (lightViewProj / splitPlanes) also on ExtractedLights.
|
|
2014
|
+
*/
|
|
2015
|
+
export interface DirectionalCsmConfig {
|
|
2016
|
+
readonly cascadeCount: number;
|
|
2017
|
+
readonly splitLambda: number;
|
|
2018
|
+
readonly cascadeBlend: number;
|
|
2019
|
+
readonly mapSize: number;
|
|
2020
|
+
readonly shadowDistance: number;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
/**
|
|
2024
|
+
* bug-20260710-editor-cross-world-shadow: computed directional-CSM output —
|
|
2025
|
+
* the per-cascade light-view-projection matrices plus the derived cascade
|
|
2026
|
+
* metadata. Returned by {@link computeDirectionalCsm}; `undefined` when the
|
|
2027
|
+
* config or camera is missing (caller leaves the fields undefined).
|
|
2028
|
+
*/
|
|
2029
|
+
export interface DirectionalCsmResult {
|
|
2030
|
+
readonly lightViewProj: Float32Array[];
|
|
2031
|
+
readonly splitPlanes: Float32Array;
|
|
2032
|
+
readonly cascadeCount: number;
|
|
2033
|
+
readonly cascadeBlend: number;
|
|
2034
|
+
readonly shadowMapSize: number;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
/**
|
|
2038
|
+
* bug-20260710-editor-cross-world-shadow: pure directional-CSM matrix builder,
|
|
2039
|
+
* extracted verbatim from the former inline block in {@link extractFrame} (the
|
|
2040
|
+
* PSSM split + per-cascade frustum-slice AABB fit + orthographic light
|
|
2041
|
+
* projection). It is now a free function so BOTH the per-world extract and the
|
|
2042
|
+
* cross-world merge layer can call it — the merge layer is the only place that
|
|
2043
|
+
* pairs the first-hit directional light of one world with the surfaced camera
|
|
2044
|
+
* of another (editor super-composite). Returns `null` when there is no camera
|
|
2045
|
+
* to fit against (a cameraless world's directional light yields no matrices —
|
|
2046
|
+
* the caller leaves lightViewProj undefined rather than emitting zero matrices
|
|
2047
|
+
* that the WGSL reader would sample as NaN → "fully lit").
|
|
2048
|
+
*
|
|
2049
|
+
* Byte-identical to the prior inline computation for the single-world case
|
|
2050
|
+
* (same PSSM lambda, same toward-light Z reach RC-2 fix, same clip-space
|
|
2051
|
+
* matrix / tile-placement-in-shader split).
|
|
2052
|
+
*/
|
|
2053
|
+
export function computeDirectionalCsm(
|
|
2054
|
+
direction: Vec3,
|
|
2055
|
+
config: DirectionalCsmConfig,
|
|
2056
|
+
cameraData: CsmCameraData | undefined,
|
|
2057
|
+
): DirectionalCsmResult | null {
|
|
2058
|
+
const cascadeCount = Math.round(config.cascadeCount);
|
|
2059
|
+
// Coverage range: near from the active camera near (no separate near knob);
|
|
2060
|
+
// far is the component's shadowDistance. Fallback near (0.1) only when no
|
|
2061
|
+
// camera exists — but matrices are gated on cameraData below anyway.
|
|
2062
|
+
const sNear = cameraData?.near ?? 0.1;
|
|
2063
|
+
const sFar = config.shadowDistance;
|
|
2064
|
+
|
|
2065
|
+
// PSSM split planes: [camera near, shadowDistance], not the camera far.
|
|
2066
|
+
const splits = pssmSplit(sNear, sFar, cascadeCount, config.splitLambda);
|
|
2067
|
+
const paddedSplits = new Float32Array(4);
|
|
2068
|
+
for (let i = 0; i < splits.length; i++) paddedSplits[i] = splits[i] ?? 0;
|
|
2069
|
+
|
|
2070
|
+
// Light view matrix: camera at origin, looking along the light direction.
|
|
2071
|
+
const lightDirN = vec3.normalize(vec3.create(), direction);
|
|
2072
|
+
const lightTarget = vec3.create(lightDirN[0] ?? 0, lightDirN[1] ?? 0, lightDirN[2] ?? 0);
|
|
2073
|
+
const lightView = mat4.create();
|
|
2074
|
+
mat4.lookAt(lightView, vec3.create(0, 0, 0), lightTarget, vec3.create(0, 1, 0));
|
|
2075
|
+
|
|
2076
|
+
// Camera view-projection for frustum-corner computation. Without a camera the
|
|
2077
|
+
// frustum cannot be fit — bail so the caller emits no matrices (undefined),
|
|
2078
|
+
// NOT zero matrices (which read as NaN in the shadow shader → no shadow).
|
|
2079
|
+
if (cameraData === undefined) return null;
|
|
2080
|
+
const camProj = mat4.create();
|
|
2081
|
+
if (cameraData.projection === 'orthographic') {
|
|
2082
|
+
mat4.orthographic(
|
|
2083
|
+
camProj,
|
|
2084
|
+
cameraData.orthoLeft,
|
|
2085
|
+
cameraData.orthoRight,
|
|
2086
|
+
cameraData.orthoBottom,
|
|
2087
|
+
cameraData.orthoTop,
|
|
2088
|
+
cameraData.near,
|
|
2089
|
+
cameraData.far,
|
|
2090
|
+
);
|
|
2091
|
+
} else {
|
|
2092
|
+
mat4.perspective(camProj, cameraData.fov, cameraData.aspect, cameraData.near, cameraData.far);
|
|
2093
|
+
}
|
|
2094
|
+
const camView = mat4.create();
|
|
2095
|
+
mat4.invert(camView, cameraData.world);
|
|
2096
|
+
const cameraVP = mat4.create();
|
|
2097
|
+
mat4.multiply(cameraVP, camProj, camView);
|
|
2098
|
+
|
|
2099
|
+
const resultLightViewProjs: Float32Array[] = [];
|
|
2100
|
+
|
|
2101
|
+
// bug-20260619 RC-2 (AC-05): toward-light Z reach. Extend the near (toward-
|
|
2102
|
+
// light) bound of EVERY cascade to the toward-light extreme of the WHOLE
|
|
2103
|
+
// shadow frustum (sNear..sFar) so casters between the light and a slice are
|
|
2104
|
+
// admitted; X/Y stays per-cascade tight. Larger light-space z == closer to
|
|
2105
|
+
// the light (lookAt forward = eye-target), so the full-frustum max-z is the
|
|
2106
|
+
// toward-light reach used as -maxZ (the ortho near plane) per cascade.
|
|
2107
|
+
let lightSpaceMaxZFull = -Infinity;
|
|
2108
|
+
const fullCorners = computeFrustumCorners(
|
|
2109
|
+
cameraVP,
|
|
2110
|
+
cameraData.near,
|
|
2111
|
+
cameraData.far,
|
|
2112
|
+
sNear,
|
|
2113
|
+
sFar,
|
|
2114
|
+
cameraData.projection,
|
|
2115
|
+
);
|
|
2116
|
+
for (const ws of fullCorners) {
|
|
2117
|
+
const ls = vec3.create();
|
|
2118
|
+
mat4.transformVec3(ls, lightView, ws);
|
|
2119
|
+
if ((ls[2] ?? 0) > lightSpaceMaxZFull) lightSpaceMaxZFull = ls[2] ?? 0;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// Pre-allocate 4-cascade array; fill in the effective cascades.
|
|
2123
|
+
for (let cIdx = 0; cIdx < 4; cIdx++) {
|
|
2124
|
+
if (cIdx >= cascadeCount) {
|
|
2125
|
+
resultLightViewProjs.push(new Float32Array(16));
|
|
2126
|
+
continue;
|
|
2127
|
+
}
|
|
2128
|
+
const cascadeNear = cIdx === 0 ? sNear : (splits[cIdx - 1] ?? sFar);
|
|
2129
|
+
const cascadeFar = splits[cIdx] ?? sFar;
|
|
2130
|
+
const corners = computeFrustumCorners(
|
|
2131
|
+
cameraVP,
|
|
2132
|
+
cameraData.near,
|
|
2133
|
+
cameraData.far,
|
|
2134
|
+
cascadeNear,
|
|
2135
|
+
cascadeFar,
|
|
2136
|
+
cameraData.projection,
|
|
2137
|
+
);
|
|
2138
|
+
const lightMVP = mat4.clone(lightView);
|
|
2139
|
+
let minX = Infinity;
|
|
2140
|
+
let maxX = -Infinity;
|
|
2141
|
+
let minY = Infinity;
|
|
2142
|
+
let maxY = -Infinity;
|
|
2143
|
+
let minZ = Infinity;
|
|
2144
|
+
let maxZ = -Infinity;
|
|
2145
|
+
for (const ws of corners) {
|
|
2146
|
+
const ls = vec3.create();
|
|
2147
|
+
mat4.transformVec3(ls, lightMVP, ws);
|
|
2148
|
+
if ((ls[0] ?? 0) < minX) minX = ls[0] ?? 0;
|
|
2149
|
+
if ((ls[0] ?? 0) > maxX) maxX = ls[0] ?? 0;
|
|
2150
|
+
if ((ls[1] ?? 0) < minY) minY = ls[1] ?? 0;
|
|
2151
|
+
if ((ls[1] ?? 0) > maxY) maxY = ls[1] ?? 0;
|
|
2152
|
+
if ((ls[2] ?? 0) < minZ) minZ = ls[2] ?? 0;
|
|
2153
|
+
if ((ls[2] ?? 0) > maxZ) maxZ = ls[2] ?? 0;
|
|
2154
|
+
}
|
|
2155
|
+
// Orthographic projection from light-space AABB. RC-2: whole-frustum
|
|
2156
|
+
// toward-light extreme for the near bound; per-cascade far (minZ) + X/Y.
|
|
2157
|
+
const nearZ = Math.max(maxZ, lightSpaceMaxZFull);
|
|
2158
|
+
const orthoProj = mat4.create();
|
|
2159
|
+
mat4.orthographic(orthoProj, minX, maxX, minY, maxY, -nearZ, -minZ);
|
|
2160
|
+
// lightViewProj = orthoProj * lightView — pure clip-space [-1,1]. Atlas
|
|
2161
|
+
// tile placement is handled by the per-cascade viewport + fragment-side UV
|
|
2162
|
+
// math (evalDirectional), so shadow_caster.gl_Position stays clip-space.
|
|
2163
|
+
void cIdx;
|
|
2164
|
+
resultLightViewProjs.push(new Float32Array(mat4.multiply(mat4.create(), orthoProj, lightView)));
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
return {
|
|
2168
|
+
lightViewProj: resultLightViewProjs,
|
|
2169
|
+
splitPlanes: paddedSplits,
|
|
2170
|
+
cascadeCount,
|
|
2171
|
+
cascadeBlend: config.cascadeBlend,
|
|
2172
|
+
shadowMapSize: config.mapSize,
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/**
|
|
2177
|
+
* feat-20260709-editor-world-partition M1 / w4 (AC-08, plan-strategy §2 D-3):
|
|
2178
|
+
* the owner index that previously served BOTH cameras and singleton render
|
|
2179
|
+
* resources (skylight / skybox / postProcessParams) is split into two
|
|
2180
|
+
* independent indices. `cameraOwner` selects the world whose cameras are
|
|
2181
|
+
* surfaced; `resourceOwner` selects the world whose singleton resources are
|
|
2182
|
+
* surfaced. A single number is accepted as the backward-compatible legacy form
|
|
2183
|
+
* where `cameraOwner === resourceOwner === owner` (single-world callers +
|
|
2184
|
+
* frame-loop stay byte-identical; the hard `{ owner }` cutover lands in M2).
|
|
2185
|
+
*/
|
|
2186
|
+
export interface ExtractFramesOwner {
|
|
2187
|
+
readonly cameraOwner: number;
|
|
2188
|
+
readonly resourceOwner: number;
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
export interface PreparedExtractContext {
|
|
2192
|
+
readonly assets: AssetRegistry | null | undefined;
|
|
2193
|
+
readonly pipelineState: ExtractPipelineSurface | null | undefined;
|
|
2194
|
+
readonly materialSnapshotCache: MaterialSnapshotCache | undefined;
|
|
2195
|
+
readonly cull: 'self' | 'none' | 'external';
|
|
2196
|
+
readonly cullCameras: readonly CameraSnapshot[] | undefined;
|
|
2197
|
+
readonly hierarchy: SceneHierarchySnapshot;
|
|
2198
|
+
readonly visibility: VisibilitySnapshot;
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
export function extractCameraSnapshots(world: World): CameraSnapshot[] {
|
|
2202
|
+
const worldInternal = createWorldInternalView(world);
|
|
2203
|
+
const cameras: CameraSnapshot[] = [];
|
|
2204
|
+
const cameraEntities: number[] = [];
|
|
2205
|
+
const cameraQuery = world.query({ read: [Camera], with: [Transform] }).unwrap();
|
|
2206
|
+
for (const row of cameraQuery) {
|
|
2207
|
+
const cam = row.get(Camera);
|
|
2208
|
+
const entity = row.entity;
|
|
2209
|
+
const view = worldInternal._getArrayView(entity, Transform, 'world');
|
|
2210
|
+
if (view === undefined) continue;
|
|
2211
|
+
const worldMat = new Float32Array(view);
|
|
2212
|
+
cameras.push({
|
|
2213
|
+
entityKey: entity as number,
|
|
2214
|
+
position: mat4.getTranslation(vec3.create(), worldMat),
|
|
2215
|
+
world: worldMat,
|
|
2216
|
+
fov: cam.fov,
|
|
2217
|
+
aspect: cam.aspect,
|
|
2218
|
+
near: cam.near,
|
|
2219
|
+
far: cam.far,
|
|
2220
|
+
projection: cameraProjectionFromF32(cam.projection),
|
|
2221
|
+
orthoLeft: cam.left,
|
|
2222
|
+
orthoRight: cam.right,
|
|
2223
|
+
orthoBottom: cam.bottom,
|
|
2224
|
+
orthoTop: cam.top,
|
|
2225
|
+
tonemap: tonemapFromF32(cam.tonemap),
|
|
2226
|
+
exposure: cam.exposure,
|
|
2227
|
+
whitePoint: cam.whitePoint,
|
|
2228
|
+
antialias: antialiasFromF32(cam.antialias),
|
|
2229
|
+
bloom: bloomEnabledFromF32(cam.bloom),
|
|
2230
|
+
bloomThreshold: cam.bloomThreshold,
|
|
2231
|
+
bloomIntensity: cam.bloomIntensity,
|
|
2232
|
+
bloomBlurRadius: cam.bloomBlurRadius,
|
|
2233
|
+
clearColor: [
|
|
2234
|
+
cam.clearColor[0] ?? 0,
|
|
2235
|
+
cam.clearColor[1] ?? 0,
|
|
2236
|
+
cam.clearColor[2] ?? 0,
|
|
2237
|
+
cam.clearColor[3] ?? 1,
|
|
2238
|
+
],
|
|
2239
|
+
});
|
|
2240
|
+
cameraEntities.push(entity as number);
|
|
2241
|
+
}
|
|
2242
|
+
const activeCameraIndex = selectActiveCameraIndex(cameraEntities, getActiveCamera(world)?.entity);
|
|
2243
|
+
if (activeCameraIndex < 0) return cameras;
|
|
2244
|
+
const selected = cameras[activeCameraIndex];
|
|
2245
|
+
return selected === undefined ? cameras : [selected];
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
function tonemapParams(camera: CameraSnapshot): Uint8Array {
|
|
2249
|
+
const bytes = new ArrayBuffer(16);
|
|
2250
|
+
const floats = new Float32Array(bytes);
|
|
2251
|
+
const integers = new Uint32Array(bytes);
|
|
2252
|
+
floats[0] = camera.exposure;
|
|
2253
|
+
floats[1] = camera.whitePoint;
|
|
2254
|
+
integers[2] = tonemapToU32(camera.tonemap);
|
|
2255
|
+
return new Uint8Array(bytes);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
export function prepareExtractContext(
|
|
2259
|
+
world: World,
|
|
2260
|
+
options: {
|
|
2261
|
+
readonly assets?: AssetRegistry | null;
|
|
2262
|
+
readonly pipelineState?: ExtractPipelineSurface | null;
|
|
2263
|
+
readonly materialSnapshotCache?: MaterialSnapshotCache;
|
|
2264
|
+
readonly cull?: 'self' | 'none' | 'external';
|
|
2265
|
+
readonly cullCameras?: readonly CameraSnapshot[];
|
|
2266
|
+
} = {},
|
|
2267
|
+
): PreparedExtractContext {
|
|
2268
|
+
const hierarchy = projectHierarchy(world);
|
|
2269
|
+
return {
|
|
2270
|
+
assets: options.assets,
|
|
2271
|
+
pipelineState: options.pipelineState,
|
|
2272
|
+
materialSnapshotCache: options.materialSnapshotCache,
|
|
2273
|
+
cull: options.cull ?? 'self',
|
|
2274
|
+
cullCameras: options.cullCameras,
|
|
2275
|
+
hierarchy,
|
|
2276
|
+
visibility: resolveVisibility(world, hierarchy),
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
export function extractFrames(
|
|
2281
|
+
worlds: readonly World[],
|
|
2282
|
+
owner: number | ExtractFramesOwner,
|
|
2283
|
+
assets?: AssetRegistry | null,
|
|
2284
|
+
pipelineState?: ExtractPipelineSurface | null,
|
|
2285
|
+
materialSnapshotCachesByWorld?: MaterialSnapshotCachesByWorld,
|
|
2286
|
+
options: { readonly cull?: 'normal' | 'none' } = {},
|
|
2287
|
+
): ExtractedFrame {
|
|
2288
|
+
// w4: normalize the owner argument. A bare number is the legacy single-owner
|
|
2289
|
+
// form (cameraOwner === resourceOwner); an object carries the two split
|
|
2290
|
+
// indices. When they coincide the code path is byte-identical to the pre-w4
|
|
2291
|
+
// single-owner behaviour (w1 contract combination 2).
|
|
2292
|
+
const cameraOwner = typeof owner === 'number' ? owner : owner.cameraOwner;
|
|
2293
|
+
const resourceOwner = typeof owner === 'number' ? owner : owner.resourceOwner;
|
|
2294
|
+
|
|
2295
|
+
// ── D-2: frame-level side effects live here ────────────────────────────
|
|
2296
|
+
//
|
|
2297
|
+
// resetForFrame is called exactly once per frame, at the extractFrames
|
|
2298
|
+
// entry. The skinPaletteAllocator cursor is reset before each per-world
|
|
2299
|
+
// extract runs, so sequential per-world allocation yields non-overlapping
|
|
2300
|
+
// palette slices (AC-08).
|
|
2301
|
+
const skinPaletteAllocator = pipelineState?.skinPaletteAllocator ?? null;
|
|
2302
|
+
if (skinPaletteAllocator !== null) {
|
|
2303
|
+
skinPaletteAllocator.resetForFrame();
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
const failedWorlds = new Set<World>();
|
|
2307
|
+
// ── D-2: per-world extract with error isolation ────────────────────────
|
|
2308
|
+
//
|
|
2309
|
+
// Each world runs extractFrame over the final state published by
|
|
2310
|
+
// world.update(). Failure in one world is caught, routed to that world's
|
|
2311
|
+
// _routeError (systemName carries worldId for source identification),
|
|
2312
|
+
// and the world's contribution is skipped (AC-09 graceful degradation).
|
|
2313
|
+
|
|
2314
|
+
// The camera-owner frame must be extracted first: non-owner worlds do not
|
|
2315
|
+
// carry the surfaced camera, but their renderables still need to be tested
|
|
2316
|
+
// against that camera's frustum. Frames are placed back into worlds[] order
|
|
2317
|
+
// before merge so queue / directional-light / worldId semantics stay stable.
|
|
2318
|
+
const succeededFrames: ExtractedFrame[] = [];
|
|
2319
|
+
const succeededIndices: number[] = [];
|
|
2320
|
+
|
|
2321
|
+
const extractionOrder = Array.from({ length: worlds.length }, (_, wi) => wi).sort((a, b) => {
|
|
2322
|
+
if (a === cameraOwner) return -1;
|
|
2323
|
+
if (b === cameraOwner) return 1;
|
|
2324
|
+
return a - b;
|
|
2325
|
+
});
|
|
2326
|
+
const framesByWorld = new Map<number, ExtractedFrame>();
|
|
2327
|
+
|
|
2328
|
+
for (const wi of extractionOrder) {
|
|
2329
|
+
const world = worlds[wi];
|
|
2330
|
+
if (world === undefined || failedWorlds.has(world)) continue;
|
|
2331
|
+
try {
|
|
2332
|
+
const isCameraOwner = wi === cameraOwner;
|
|
2333
|
+
const cameraOwnerFrame = framesByWorld.get(cameraOwner);
|
|
2334
|
+
const prepared = prepareExtractContext(world, {
|
|
2335
|
+
...(assets !== undefined ? { assets } : {}),
|
|
2336
|
+
...(pipelineState !== undefined ? { pipelineState } : {}),
|
|
2337
|
+
...(materialSnapshotCachesByWorld === undefined
|
|
2338
|
+
? {}
|
|
2339
|
+
: {
|
|
2340
|
+
materialSnapshotCache:
|
|
2341
|
+
materialSnapshotCachesByWorld.get(world) ??
|
|
2342
|
+
(() => {
|
|
2343
|
+
const cache: MaterialSnapshotCache = new Map();
|
|
2344
|
+
materialSnapshotCachesByWorld.set(world, cache);
|
|
2345
|
+
return cache;
|
|
2346
|
+
})(),
|
|
2347
|
+
}),
|
|
2348
|
+
cull: options.cull === 'none' ? 'none' : isCameraOwner ? 'self' : 'external',
|
|
2349
|
+
...(cameraOwnerFrame === undefined ? {} : { cullCameras: cameraOwnerFrame.cameras }),
|
|
2350
|
+
});
|
|
2351
|
+
const frame = extractFrame(world, prepared);
|
|
2352
|
+
|
|
2353
|
+
framesByWorld.set(wi, frame);
|
|
2354
|
+
} catch (err) {
|
|
2355
|
+
// Per-world failure: route to world's own error handler, skip contribution.
|
|
2356
|
+
try {
|
|
2357
|
+
createWorldInternalView(world)._routeError(err, {
|
|
2358
|
+
severity: Severity.Error,
|
|
2359
|
+
systemName: `RenderSystem.extractFrames(world[${wi}])`,
|
|
2360
|
+
});
|
|
2361
|
+
} catch {
|
|
2362
|
+
// If _routeError itself throws, the world already failed — skip silently.
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
// Restore the caller's world order after the camera-owner-first extraction.
|
|
2368
|
+
for (let wi = 0; wi < worlds.length; wi++) {
|
|
2369
|
+
const frame = framesByWorld.get(wi);
|
|
2370
|
+
if (frame === undefined) continue;
|
|
2371
|
+
succeededFrames.push(frame);
|
|
2372
|
+
succeededIndices.push(wi);
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// ── D-3: merge semantics ───────────────────────────────────────────────
|
|
2376
|
+
|
|
2377
|
+
// AC-04: renderables — concat by worlds[] order, stamp worldId.
|
|
2378
|
+
const renderables: RenderableSnapshot[] = [];
|
|
2379
|
+
const dispatchEntries: DispatchEntry[] = [];
|
|
2380
|
+
const visibilitySnapshots: VisibilitySnapshot[] = [];
|
|
2381
|
+
const featureVisibilitySnapshots: RenderFeatureWorldVisibilitySnapshot[] = [];
|
|
2382
|
+
const hiddenEntityReports: RenderFeatureHiddenEntityReport[] = [];
|
|
2383
|
+
|
|
2384
|
+
for (let fi = 0; fi < succeededFrames.length; fi++) {
|
|
2385
|
+
const f = succeededFrames[fi];
|
|
2386
|
+
const wId = succeededIndices[fi];
|
|
2387
|
+
if (f === undefined || wId === undefined) continue;
|
|
2388
|
+
|
|
2389
|
+
const base = renderables.length;
|
|
2390
|
+
const visibilitySnapshot = f.visibilitySnapshots[0];
|
|
2391
|
+
const world = worlds[wId];
|
|
2392
|
+
if (visibilitySnapshot !== undefined) {
|
|
2393
|
+
visibilitySnapshots.push(visibilitySnapshot);
|
|
2394
|
+
if (world !== undefined)
|
|
2395
|
+
featureVisibilitySnapshots.push({ world, snapshot: visibilitySnapshot });
|
|
2396
|
+
}
|
|
2397
|
+
hiddenEntityReports.push(...f.hiddenEntityReports);
|
|
2398
|
+
for (const r of f.renderables) {
|
|
2399
|
+
renderables.push({
|
|
2400
|
+
...r,
|
|
2401
|
+
worldId: wId,
|
|
2402
|
+
...(r.pointsLines === undefined ? {} : { pointsLines: { ...r.pointsLines, worldId: wId } }),
|
|
2403
|
+
});
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
// D-3: dispatch — per-world renderableIndex rebased by base offset.
|
|
2407
|
+
for (const d of f.dispatch) {
|
|
2408
|
+
dispatchEntries.push({ ...d, renderableIndex: (d.renderableIndex ?? 0) + base });
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// Stable sort dispatch by queue value.
|
|
2413
|
+
dispatchEntries.sort((a, b) => (a.queue ?? 0) - (b.queue ?? 0));
|
|
2414
|
+
|
|
2415
|
+
// AC-04: lights — point[]/spot[] concat; directional first-hit in
|
|
2416
|
+
// succeededFrames order (which preserves worlds[] order for successful
|
|
2417
|
+
// frames); directionalCount sum.
|
|
2418
|
+
const point: PointLightSnapshot[] = [];
|
|
2419
|
+
const spot: SpotLightSnapshot[] = [];
|
|
2420
|
+
let directional: DirectionalLightSnapshot | undefined;
|
|
2421
|
+
let directionalCount = 0;
|
|
2422
|
+
let lightViewProj: readonly Float32Array[] | undefined;
|
|
2423
|
+
let splitPlanes: Float32Array | undefined;
|
|
2424
|
+
let cascadeCount: number | undefined;
|
|
2425
|
+
let cascadeBlend: number | undefined;
|
|
2426
|
+
let shadowMapSize: number | undefined;
|
|
2427
|
+
let depthBias: number | undefined;
|
|
2428
|
+
let normalBias: number | undefined;
|
|
2429
|
+
let pcfKernelSize: number | undefined;
|
|
2430
|
+
const pointShadow: PointShadowSnapshot[] = [];
|
|
2431
|
+
// bug-20260710-editor-cross-world-shadow: carry the raw CSM config +
|
|
2432
|
+
// direction of the first-hit directional so the merge layer can recompute
|
|
2433
|
+
// matrices against the SURFACED (cameraOwner) camera — the light and the
|
|
2434
|
+
// camera may live in different worlds (editor super-composite).
|
|
2435
|
+
let directionalCsmConfig: DirectionalCsmConfig | undefined;
|
|
2436
|
+
let directionalCsmDirection: Vec3 | undefined;
|
|
2437
|
+
for (const f of succeededFrames) {
|
|
2438
|
+
for (const p of f.lights.point) point.push(p);
|
|
2439
|
+
for (const s of f.lights.spot) spot.push(s);
|
|
2440
|
+
for (const ps of f.lights.pointShadow) pointShadow.push(ps);
|
|
2441
|
+
if (directional === undefined && f.lights.directional !== undefined) {
|
|
2442
|
+
directional = f.lights.directional;
|
|
2443
|
+
// Carry CSM shadow fields from the first-hit directional's world.
|
|
2444
|
+
lightViewProj = f.lights.lightViewProj;
|
|
2445
|
+
splitPlanes = f.lights.splitPlanes;
|
|
2446
|
+
cascadeCount = f.lights.cascadeCount;
|
|
2447
|
+
cascadeBlend = f.lights.cascadeBlend;
|
|
2448
|
+
shadowMapSize = f.lights.shadowMapSize;
|
|
2449
|
+
depthBias = f.lights.depthBias;
|
|
2450
|
+
normalBias = f.lights.normalBias;
|
|
2451
|
+
pcfKernelSize = f.lights.pcfKernelSize;
|
|
2452
|
+
directionalCsmConfig = f.lights.directionalCsmConfig;
|
|
2453
|
+
directionalCsmDirection = f.lights.directionalCsmDirection;
|
|
2454
|
+
}
|
|
2455
|
+
directionalCount += f.lights.directionalCount;
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
// AC-05/06 + w4 owner split (D-3 / R-6): cameras come from the cameraOwner
|
|
2459
|
+
// world; skylight / skybox / postProcessParams come from the resourceOwner
|
|
2460
|
+
// world (holistic snapshot selection). Scan succeededIndices once to locate
|
|
2461
|
+
// each owner's surviving frame. When cameraOwner === resourceOwner both
|
|
2462
|
+
// resolve to the same frame — byte-identical to the pre-w4 single-owner path.
|
|
2463
|
+
let cameraOwnerFrame: ExtractedFrame | undefined;
|
|
2464
|
+
let resourceOwnerFrame: ExtractedFrame | undefined;
|
|
2465
|
+
for (let fi = 0; fi < succeededFrames.length; fi++) {
|
|
2466
|
+
if (succeededIndices[fi] === cameraOwner) cameraOwnerFrame = succeededFrames[fi];
|
|
2467
|
+
if (succeededIndices[fi] === resourceOwner) resourceOwnerFrame = succeededFrames[fi];
|
|
2468
|
+
}
|
|
2469
|
+
const cameras = cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.cameras] : [];
|
|
2470
|
+
|
|
2471
|
+
// bug-20260710-editor-cross-world-shadow: RECOMPUTE directional CSM matrices
|
|
2472
|
+
// against the surfaced camera. In a single-world app the per-world extract
|
|
2473
|
+
// already produced correct matrices (light+camera share the world), and this
|
|
2474
|
+
// recompute reproduces them byte-identically (same config, same camera). In
|
|
2475
|
+
// the editor super-composite the directional light's world has NO camera, so
|
|
2476
|
+
// its per-world `lightViewProj` is undefined/degenerate; the surfaced camera
|
|
2477
|
+
// lives in the cameraOwner world. Pairing them here is the ONLY place both
|
|
2478
|
+
// are visible. Point/spot shadows are camera-independent (light-space only)
|
|
2479
|
+
// and need no merge-layer fix-up.
|
|
2480
|
+
const mergeCam = cameras[0];
|
|
2481
|
+
if (directionalCsmConfig !== undefined && directionalCsmDirection !== undefined) {
|
|
2482
|
+
const mergeCameraData: CsmCameraData | undefined =
|
|
2483
|
+
mergeCam !== undefined
|
|
2484
|
+
? {
|
|
2485
|
+
world: mergeCam.world,
|
|
2486
|
+
fov: mergeCam.fov,
|
|
2487
|
+
aspect: mergeCam.aspect,
|
|
2488
|
+
near: mergeCam.near,
|
|
2489
|
+
far: mergeCam.far,
|
|
2490
|
+
projection: mergeCam.projection,
|
|
2491
|
+
orthoLeft: mergeCam.orthoLeft,
|
|
2492
|
+
orthoRight: mergeCam.orthoRight,
|
|
2493
|
+
orthoBottom: mergeCam.orthoBottom,
|
|
2494
|
+
orthoTop: mergeCam.orthoTop,
|
|
2495
|
+
}
|
|
2496
|
+
: undefined;
|
|
2497
|
+
const csm = computeDirectionalCsm(
|
|
2498
|
+
directionalCsmDirection,
|
|
2499
|
+
directionalCsmConfig,
|
|
2500
|
+
mergeCameraData,
|
|
2501
|
+
);
|
|
2502
|
+
if (csm !== null) {
|
|
2503
|
+
lightViewProj = csm.lightViewProj;
|
|
2504
|
+
splitPlanes = csm.splitPlanes;
|
|
2505
|
+
cascadeCount = csm.cascadeCount;
|
|
2506
|
+
cascadeBlend = csm.cascadeBlend;
|
|
2507
|
+
shadowMapSize = csm.shadowMapSize;
|
|
2508
|
+
}
|
|
2509
|
+
// csm === null (no surfaced camera at all): keep the per-world carry —
|
|
2510
|
+
// there is no better data, and a cameraless frame renders nothing anyway.
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
const lights: ExtractedLights = {
|
|
2514
|
+
directional,
|
|
2515
|
+
directionalCount,
|
|
2516
|
+
point,
|
|
2517
|
+
spot,
|
|
2518
|
+
lightViewProj,
|
|
2519
|
+
splitPlanes,
|
|
2520
|
+
cascadeCount,
|
|
2521
|
+
cascadeBlend,
|
|
2522
|
+
shadowMapSize,
|
|
2523
|
+
depthBias,
|
|
2524
|
+
normalBias,
|
|
2525
|
+
pcfKernelSize,
|
|
2526
|
+
pointShadow,
|
|
2527
|
+
directionalCsmConfig,
|
|
2528
|
+
directionalCsmDirection,
|
|
2529
|
+
};
|
|
2530
|
+
const skylight = resourceOwnerFrame?.skylight;
|
|
2531
|
+
const skylightCount = resourceOwnerFrame?.skylightCount ?? 0;
|
|
2532
|
+
const skybox = resourceOwnerFrame?.skybox;
|
|
2533
|
+
const skyboxCount = resourceOwnerFrame?.skyboxCount ?? 0;
|
|
2534
|
+
// User-authored PostProcessParams entities are singleton scene resources, so
|
|
2535
|
+
// they come from the resourceOwner world (holistic snapshot selection).
|
|
2536
|
+
const postProcessParams = new Map(resourceOwnerFrame?.postProcessParams);
|
|
2537
|
+
// feat-20260709-editor-world-partition ENGINE-fix-round2 (defect 1): the
|
|
2538
|
+
// engine built-in Standard tonemap param is NOT a scene resource — the
|
|
2539
|
+
// per-world extractFrame bridges it from that world's own `cameras[0]`
|
|
2540
|
+
// (Camera.exposure / whitePoint / tonemap is the SSOT). It therefore lives on
|
|
2541
|
+
// the CAMERA-owner frame, not the resource-owner frame. In the split-owner
|
|
2542
|
+
// editor topology the resourceOwner world has no Camera, so its frame carries
|
|
2543
|
+
// no Standard tonemap entry; taking postProcessParams from resourceOwner
|
|
2544
|
+
// alone drops it, the tonemap pass's params UBO stays zero-filled
|
|
2545
|
+
// (exposure=0 => tonemapped output is uniformly black), and the whole frame
|
|
2546
|
+
// reads black even though geometry drew into the HDR target. Overlay the
|
|
2547
|
+
// camera-owner frame's tonemap param (its SSOT source) so the surfaced
|
|
2548
|
+
// camera's exposure/whitePoint/mode reach the tonemap pass. When
|
|
2549
|
+
// cameraOwner === resourceOwner this is a no-op (identical entry). The 'na'
|
|
2550
|
+
// Standard tonemap identity mirrors the engine provider key set at the
|
|
2551
|
+
// bottom of extractFrame (SSOT: same identity, same 16B layout).
|
|
2552
|
+
const TONEMAP_PARAM_KEY = STANDARD_TONEMAP_FEATURE_ID;
|
|
2553
|
+
const cameraTonemapParam = cameraOwnerFrame?.postProcessParams.get(TONEMAP_PARAM_KEY);
|
|
2554
|
+
if (cameraTonemapParam !== undefined) {
|
|
2555
|
+
postProcessParams.set(TONEMAP_PARAM_KEY, cameraTonemapParam);
|
|
2556
|
+
} else {
|
|
2557
|
+
// The camera-owner world produced no tonemap param (no Camera surfaced
|
|
2558
|
+
// there this frame); do not leave a stale resource-owner entry that would
|
|
2559
|
+
// apply a foreign camera's exposure. Removing it lets the tonemap pass fall
|
|
2560
|
+
// back to its param-less zero path only when genuinely no camera exists.
|
|
2561
|
+
postProcessParams.delete(TONEMAP_PARAM_KEY);
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// D-3: frustumStats — culled/total summed across worlds.
|
|
2565
|
+
const frustumStats = {
|
|
2566
|
+
culled: succeededFrames.reduce((s, f) => s + f.frustumStats.culled, 0),
|
|
2567
|
+
total: succeededFrames.reduce((s, f) => s + f.frustumStats.total, 0),
|
|
2568
|
+
};
|
|
2569
|
+
const visibilityStats = {
|
|
2570
|
+
explicitlyHidden: succeededFrames.reduce((s, f) => s + f.visibilityStats.explicitlyHidden, 0),
|
|
2571
|
+
};
|
|
2572
|
+
|
|
2573
|
+
return {
|
|
2574
|
+
cameras,
|
|
2575
|
+
lights,
|
|
2576
|
+
renderables,
|
|
2577
|
+
dispatch: dispatchEntries,
|
|
2578
|
+
skylight,
|
|
2579
|
+
skylightCount,
|
|
2580
|
+
skybox,
|
|
2581
|
+
skyboxCount,
|
|
2582
|
+
frustumStats,
|
|
2583
|
+
visibilityStats,
|
|
2584
|
+
postProcessParams,
|
|
2585
|
+
visibilitySnapshots,
|
|
2586
|
+
featureVisibilitySnapshots,
|
|
2587
|
+
hiddenEntityReports,
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
/**
|
|
2592
|
+
* A MeshAsset participates in frustum culling only when its producer supplied
|
|
2593
|
+
* the complete finite local-space bounds promised by the asset contract.
|
|
2594
|
+
* Missing, malformed, and inverted-infinity empty bounds remain conservative
|
|
2595
|
+
* (always visible) so a bad asset cannot turn into a false-negative render;
|
|
2596
|
+
* they are not cull candidates and therefore do not inflate frustumStats.
|
|
2597
|
+
*/
|
|
2598
|
+
function hasFiniteOrderedLocalAabb(aabb: Float32Array | undefined): aabb is Float32Array {
|
|
2599
|
+
if (aabb === undefined || aabb.length !== 6) return false;
|
|
2600
|
+
const minX = aabb[0];
|
|
2601
|
+
const minY = aabb[1];
|
|
2602
|
+
const minZ = aabb[2];
|
|
2603
|
+
const maxX = aabb[3];
|
|
2604
|
+
const maxY = aabb[4];
|
|
2605
|
+
const maxZ = aabb[5];
|
|
2606
|
+
if (
|
|
2607
|
+
minX === undefined ||
|
|
2608
|
+
minY === undefined ||
|
|
2609
|
+
minZ === undefined ||
|
|
2610
|
+
maxX === undefined ||
|
|
2611
|
+
maxY === undefined ||
|
|
2612
|
+
maxZ === undefined
|
|
2613
|
+
) {
|
|
2614
|
+
return false;
|
|
2615
|
+
}
|
|
2616
|
+
if (
|
|
2617
|
+
!Number.isFinite(minX) ||
|
|
2618
|
+
!Number.isFinite(minY) ||
|
|
2619
|
+
!Number.isFinite(minZ) ||
|
|
2620
|
+
!Number.isFinite(maxX) ||
|
|
2621
|
+
!Number.isFinite(maxY) ||
|
|
2622
|
+
!Number.isFinite(maxZ)
|
|
2623
|
+
) {
|
|
2624
|
+
return false;
|
|
2625
|
+
}
|
|
2626
|
+
return minX <= maxX && minY <= maxY && minZ <= maxZ;
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
function morphSnapshotFor(
|
|
2630
|
+
mesh: MeshAsset,
|
|
2631
|
+
weights: ArrayLike<number> | undefined,
|
|
2632
|
+
): MorphSnapshot | undefined {
|
|
2633
|
+
const targets = mesh.morphTargets;
|
|
2634
|
+
if (targets === undefined || targets.length === 0 || weights === undefined) return undefined;
|
|
2635
|
+
if (weights.length !== targets.length) return undefined;
|
|
2636
|
+
const firstPositions = targets[0]?.position;
|
|
2637
|
+
if (
|
|
2638
|
+
firstPositions === undefined ||
|
|
2639
|
+
firstPositions.length === 0 ||
|
|
2640
|
+
firstPositions.length % 3 !== 0
|
|
2641
|
+
) {
|
|
2642
|
+
return undefined;
|
|
2643
|
+
}
|
|
2644
|
+
const vertexCount = firstPositions.length / 3;
|
|
2645
|
+
if (mesh.vertices.length % vertexCount !== 0) return undefined;
|
|
2646
|
+
for (let targetIndex = 0; targetIndex < targets.length; targetIndex += 1) {
|
|
2647
|
+
const target = targets[targetIndex];
|
|
2648
|
+
if (target?.position?.length !== firstPositions.length) return undefined;
|
|
2649
|
+
}
|
|
2650
|
+
const copied = new Float32Array(weights.length);
|
|
2651
|
+
for (let index = 0; index < weights.length; index += 1) {
|
|
2652
|
+
const weight = weights[index] ?? Number.NaN;
|
|
2653
|
+
if (!Number.isFinite(weight)) return undefined;
|
|
2654
|
+
copied[index] = weight;
|
|
2655
|
+
}
|
|
2656
|
+
return { weights: copied, targetCount: targets.length };
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
export function extractFrame(world: World, context: PreparedExtractContext): ExtractedFrame {
|
|
2660
|
+
// feat-20260708-composited-multi-world-rendering M2 / D-2: resetForFrame
|
|
2661
|
+
// has been lifted to extractFrames (the frame-level entry point).
|
|
2662
|
+
// extractFrame is now a pure world->snapshot function with no frame-level
|
|
2663
|
+
// side effects. See plan-decisions PD2 for the reviewer ruling.
|
|
2664
|
+
const {
|
|
2665
|
+
assets,
|
|
2666
|
+
pipelineState,
|
|
2667
|
+
materialSnapshotCache: persistentMaterialSnapshotCache,
|
|
2668
|
+
cull: cullMode,
|
|
2669
|
+
} = context;
|
|
2670
|
+
const visibility = context.visibility.hasAnyHiddenIntent ? context.visibility : undefined;
|
|
2671
|
+
const skinPaletteAllocator = pipelineState?.skinPaletteAllocator ?? null;
|
|
2672
|
+
|
|
2673
|
+
const directionalLightQuery = world.query({ read: [DirectionalLight] }).unwrap();
|
|
2674
|
+
|
|
2675
|
+
// feat-20260601 D-3: camera / point / spot light world transforms are read
|
|
2676
|
+
// through the single resolved `Transform.world` mat4 (written by
|
|
2677
|
+
// propagateTransforms), not the retired GlobalTransform-column-switch.
|
|
2678
|
+
//
|
|
2679
|
+
// Each segment routes through a World-owned Query with explicit read and
|
|
2680
|
+
// optional roles. The packed entity handle for `readWorldMat4Copy` /
|
|
2681
|
+
// `_getArrayView` reads comes from `row.entity` -- the
|
|
2682
|
+
// archetype-graph back-door (`graph.archetypes` / `arch.components.some`) is gone.
|
|
2683
|
+
// Plan-decisions K-2 sniffing scheme B (archetype-edge sniff once via
|
|
2684
|
+
// `row.get(X) !== undefined`); K-3 invariant preserved (`_getArrayView`
|
|
2685
|
+
// calls survive untouched, only the entity source changes).
|
|
2686
|
+
const worldInternal = createWorldInternalView(world);
|
|
2687
|
+
|
|
2688
|
+
const cameras = extractCameraSnapshots(world);
|
|
2689
|
+
|
|
2690
|
+
// Three-query union (M2 / w16 / AC-03): directional has no Transform
|
|
2691
|
+
// dependency (sun-like infinite-source semantics); point + spot pull
|
|
2692
|
+
// position from the companion Transform via the joined queries.
|
|
2693
|
+
// Host-side pre-multiplication: color *= intensity (charter P4); cone
|
|
2694
|
+
// deg -> cos (D-S2); range -> 1/range^2 (D-S5).
|
|
2695
|
+
let directional: DirectionalLightSnapshot | undefined;
|
|
2696
|
+
let directionalCount = 0;
|
|
2697
|
+
// feat-20260621 M2: capture shadow fields from the first-hit DirectionalLight.
|
|
2698
|
+
// castShadow defaults to true; the CSM path is gated on firstHitCastShadow !== false.
|
|
2699
|
+
let firstHitCastShadow: boolean | undefined;
|
|
2700
|
+
let firstHitShadowFields:
|
|
2701
|
+
| {
|
|
2702
|
+
cascadeCount: number;
|
|
2703
|
+
splitLambda: number;
|
|
2704
|
+
cascadeBlend: number;
|
|
2705
|
+
mapSize: number;
|
|
2706
|
+
depthBias: number;
|
|
2707
|
+
normalBias: number;
|
|
2708
|
+
shadowDistance: number;
|
|
2709
|
+
pcfKernelSize: number;
|
|
2710
|
+
}
|
|
2711
|
+
| undefined;
|
|
2712
|
+
for (const row of directionalLightQuery) {
|
|
2713
|
+
const l = row.get(DirectionalLight);
|
|
2714
|
+
directionalCount += 1;
|
|
2715
|
+
const intensity = l.intensity;
|
|
2716
|
+
const snapshot: DirectionalLightSnapshot = {
|
|
2717
|
+
kind: 'directional',
|
|
2718
|
+
direction: vec3.create(l.direction[0] ?? 0, l.direction[1] ?? -1, l.direction[2] ?? 0),
|
|
2719
|
+
color: vec3.create(
|
|
2720
|
+
(l.color[0] ?? 1) * intensity,
|
|
2721
|
+
(l.color[1] ?? 1) * intensity,
|
|
2722
|
+
(l.color[2] ?? 1) * intensity,
|
|
2723
|
+
),
|
|
2724
|
+
intensity,
|
|
2725
|
+
};
|
|
2726
|
+
if (directional === undefined) {
|
|
2727
|
+
// First hit wins; record-stage N>1 fail-fast (M3 / w19) flags duplicates.
|
|
2728
|
+
directional = snapshot;
|
|
2729
|
+
firstHitCastShadow = l.castShadow;
|
|
2730
|
+
firstHitShadowFields = {
|
|
2731
|
+
cascadeCount: l.cascadeCount,
|
|
2732
|
+
splitLambda: l.splitLambda,
|
|
2733
|
+
cascadeBlend: l.cascadeBlend,
|
|
2734
|
+
mapSize: l.mapSize,
|
|
2735
|
+
depthBias: l.depthBias,
|
|
2736
|
+
normalBias: l.normalBias,
|
|
2737
|
+
shadowDistance: l.shadowDistance,
|
|
2738
|
+
pcfKernelSize: l.pcfKernelSize,
|
|
2739
|
+
};
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
const pointSnapshots: PointLightSnapshot[] = [];
|
|
2744
|
+
// feat-20260612-point-light-shadows-urp-hdrp M4 / T-M4-4: track entity per
|
|
2745
|
+
// pointSnapshots index so the post-extract pointShadow join can stamp
|
|
2746
|
+
// `shadowAtlasLayer + shadowNear + shadowFar` onto the matching PointLight.
|
|
2747
|
+
const pointSnapshotEntities: number[] = [];
|
|
2748
|
+
const pointLightQuery = world
|
|
2749
|
+
.query({
|
|
2750
|
+
read: [PointLight],
|
|
2751
|
+
optional: [Transform],
|
|
2752
|
+
})
|
|
2753
|
+
.unwrap();
|
|
2754
|
+
for (const row of pointLightQuery) {
|
|
2755
|
+
const p = row.get(PointLight);
|
|
2756
|
+
// K-2 scheme B: archetype-edge sniff -- `bundle.Transform` key is absent
|
|
2757
|
+
// when the archetype does not carry the Transform column.
|
|
2758
|
+
const hasTransform = row.get(Transform) !== undefined;
|
|
2759
|
+
const intensity = p.intensity;
|
|
2760
|
+
const range = p.range;
|
|
2761
|
+
const entityId = row.entity;
|
|
2762
|
+
// Position = world-space translation extracted from Transform.world.
|
|
2763
|
+
// A point light archetype without a Transform column sits at the origin.
|
|
2764
|
+
let worldMat: Float32Array | undefined;
|
|
2765
|
+
if (hasTransform) {
|
|
2766
|
+
const view = worldInternal._getArrayView(entityId, Transform, 'world');
|
|
2767
|
+
if (view !== undefined) worldMat = new Float32Array(view);
|
|
2768
|
+
}
|
|
2769
|
+
const position =
|
|
2770
|
+
worldMat !== undefined ? mat4.getTranslation(vec3.create(), worldMat) : vec3.create(0, 0, 0);
|
|
2771
|
+
pointSnapshots.push({
|
|
2772
|
+
kind: 'point',
|
|
2773
|
+
position,
|
|
2774
|
+
color: vec3.create(
|
|
2775
|
+
(p.color[0] ?? 1) * intensity,
|
|
2776
|
+
(p.color[1] ?? 1) * intensity,
|
|
2777
|
+
(p.color[2] ?? 1) * intensity,
|
|
2778
|
+
),
|
|
2779
|
+
intensity,
|
|
2780
|
+
invRangeSquared: computeInvRangeSquared(range),
|
|
2781
|
+
});
|
|
2782
|
+
pointSnapshotEntities.push(entityId);
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
const spotSnapshots: SpotLightSnapshot[] = [];
|
|
2786
|
+
const spotLightQuery = world
|
|
2787
|
+
.query({
|
|
2788
|
+
read: [SpotLight],
|
|
2789
|
+
optional: [Transform],
|
|
2790
|
+
})
|
|
2791
|
+
.unwrap();
|
|
2792
|
+
// feat-20260625-spot-light-shadow-mapping M1 w5: tile allocation for castShadow spots.
|
|
2793
|
+
// Cap = 4 (OOS-5), sentinel -1 = unassigned (plan-strategy D-4).
|
|
2794
|
+
// Direction degeneration (near-zero) also skips shadow (requirements $112).
|
|
2795
|
+
let spotTileNext = 0;
|
|
2796
|
+
for (const row of spotLightQuery) {
|
|
2797
|
+
const s = row.get(SpotLight);
|
|
2798
|
+
const hasTransform = row.get(Transform) !== undefined;
|
|
2799
|
+
const intensity = s.intensity;
|
|
2800
|
+
const range = s.range;
|
|
2801
|
+
const innerConeDeg = s.innerConeDeg;
|
|
2802
|
+
const outerConeDeg = s.outerConeDeg;
|
|
2803
|
+
let worldMat: Float32Array | undefined;
|
|
2804
|
+
if (hasTransform) {
|
|
2805
|
+
const entity = row.entity;
|
|
2806
|
+
const view = worldInternal._getArrayView(entity, Transform, 'world');
|
|
2807
|
+
if (view !== undefined) worldMat = new Float32Array(view);
|
|
2808
|
+
}
|
|
2809
|
+
const position =
|
|
2810
|
+
worldMat !== undefined ? mat4.getTranslation(vec3.create(), worldMat) : vec3.create(0, 0, 0);
|
|
2811
|
+
const dir = vec3.create(s.direction[0] ?? 0, s.direction[1] ?? -1, s.direction[2] ?? 0);
|
|
2812
|
+
|
|
2813
|
+
// Extract is the single direction-normalization owner for direct-light
|
|
2814
|
+
// snapshots. URP and HDRP preserve this value downstream.
|
|
2815
|
+
const dirLen = Math.sqrt(
|
|
2816
|
+
(dir[0] ?? 0) * (dir[0] ?? 0) + (dir[1] ?? 0) * (dir[1] ?? 0) + (dir[2] ?? 0) * (dir[2] ?? 0),
|
|
2817
|
+
);
|
|
2818
|
+
const EPSILON = 1e-6;
|
|
2819
|
+
const hasValidDirection = dirLen > EPSILON;
|
|
2820
|
+
if (!hasValidDirection) {
|
|
2821
|
+
worldInternal._routeError(
|
|
2822
|
+
new SpawnLightInvalidBoundsError('SpotLight', 'direction', [
|
|
2823
|
+
dir[0] ?? 0,
|
|
2824
|
+
dir[1] ?? 0,
|
|
2825
|
+
dir[2] ?? 0,
|
|
2826
|
+
]),
|
|
2827
|
+
{
|
|
2828
|
+
severity: Severity.Error,
|
|
2829
|
+
systemName: 'RenderSystem.extract (spot-direction)',
|
|
2830
|
+
},
|
|
2831
|
+
);
|
|
2832
|
+
}
|
|
2833
|
+
const dirN = vec3.create(
|
|
2834
|
+
hasValidDirection ? (dir[0] ?? 0) / dirLen : (dir[0] ?? 0),
|
|
2835
|
+
hasValidDirection ? (dir[1] ?? 0) / dirLen : (dir[1] ?? 0),
|
|
2836
|
+
hasValidDirection ? (dir[2] ?? 0) / dirLen : (dir[2] ?? 0),
|
|
2837
|
+
);
|
|
2838
|
+
|
|
2839
|
+
// ── shadow fields (feat-20260625-spot-light-shadow-mapping M1) ──
|
|
2840
|
+
const castShadow = s.castShadow;
|
|
2841
|
+
const sMapSize = s.mapSize;
|
|
2842
|
+
const sNearPlane = s.nearPlane;
|
|
2843
|
+
const sFarPlane = s.farPlane;
|
|
2844
|
+
|
|
2845
|
+
let lightViewProj: Float32Array | undefined;
|
|
2846
|
+
let shadowAtlasTile = -1;
|
|
2847
|
+
|
|
2848
|
+
if (castShadow && hasValidDirection) {
|
|
2849
|
+
const target = vec3.create(
|
|
2850
|
+
(position[0] ?? 0) + (dirN[0] ?? 0),
|
|
2851
|
+
(position[1] ?? 0) + (dirN[1] ?? 0),
|
|
2852
|
+
(position[2] ?? 0) + (dirN[2] ?? 0),
|
|
2853
|
+
);
|
|
2854
|
+
// D-1: perspective(outerConeDeg*2, aspect=1, near, far) x lookAt(pos, pos+dir).
|
|
2855
|
+
// FOV = outerConeDeg * 2 in degrees; mat4.perspective takes fov in radians.
|
|
2856
|
+
const fov = outerConeDeg * 2 * (Math.PI / 180);
|
|
2857
|
+
const proj = mat4.create();
|
|
2858
|
+
mat4.perspective(proj, fov, 1, sNearPlane, sFarPlane);
|
|
2859
|
+
const view = mat4.create();
|
|
2860
|
+
mat4.lookAt(view, position, target, vec3.create(0, 1, 0));
|
|
2861
|
+
lightViewProj = new Float32Array(16);
|
|
2862
|
+
// Reinterpret the Float32Array surface field as a Mat4 out-param; a
|
|
2863
|
+
// factory would force a needless alloc+copy. brand-cast-ok
|
|
2864
|
+
mat4.multiply(lightViewProj as Mat4, proj, view);
|
|
2865
|
+
|
|
2866
|
+
// D-4: allocate tile 0..3; 5th+ = -1 sentinel.
|
|
2867
|
+
if (spotTileNext < 4) {
|
|
2868
|
+
shadowAtlasTile = spotTileNext;
|
|
2869
|
+
spotTileNext += 1;
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
spotSnapshots.push({
|
|
2874
|
+
kind: 'spot',
|
|
2875
|
+
// D-6: position reflects world transform; direction stays sourced
|
|
2876
|
+
// from SpotLight.direction (NOT rotated by the parent).
|
|
2877
|
+
position,
|
|
2878
|
+
direction: dirN,
|
|
2879
|
+
color: vec3.create(
|
|
2880
|
+
(s.color[0] ?? 1) * intensity,
|
|
2881
|
+
(s.color[1] ?? 1) * intensity,
|
|
2882
|
+
(s.color[2] ?? 1) * intensity,
|
|
2883
|
+
),
|
|
2884
|
+
intensity,
|
|
2885
|
+
invRangeSquared: computeInvRangeSquared(range),
|
|
2886
|
+
cosInner: degToCos(innerConeDeg),
|
|
2887
|
+
cosOuter: degToCos(outerConeDeg),
|
|
2888
|
+
// ── shadow fields ──
|
|
2889
|
+
castShadow,
|
|
2890
|
+
lightViewProj,
|
|
2891
|
+
mapSize: sMapSize,
|
|
2892
|
+
nearPlane: sNearPlane,
|
|
2893
|
+
farPlane: sFarPlane,
|
|
2894
|
+
shadowAtlasTile,
|
|
2895
|
+
});
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
// bug-20260710-editor-cross-world-shadow: CSM matrices are computed by the
|
|
2899
|
+
// shared pure {@link computeDirectionalCsm}, called here per-world with THIS
|
|
2900
|
+
// world's own `cameras[0]`. In a single-world app the light and camera share
|
|
2901
|
+
// the world, so this per-world result is final (byte-identical to the prior
|
|
2902
|
+
// inline block). In the editor super-composite the light's world may have no
|
|
2903
|
+
// camera → this yields no matrices; {@link extractFrames} then RECOMPUTES at
|
|
2904
|
+
// the merge layer using the surfaced (cameraOwner) camera + the raw config
|
|
2905
|
+
// carried on ExtractedLights. The raw config + direction are surfaced
|
|
2906
|
+
// unconditionally so the merge layer can re-run the builder.
|
|
2907
|
+
let lightViewProj: Float32Array[] | undefined;
|
|
2908
|
+
let splitPlanes: Float32Array | undefined;
|
|
2909
|
+
let cascadeCount: number | undefined;
|
|
2910
|
+
let cascadeBlend: number | undefined;
|
|
2911
|
+
let shadowMapSize: number | undefined;
|
|
2912
|
+
let directionalCsmConfig: DirectionalCsmConfig | undefined;
|
|
2913
|
+
let directionalCsmDirection: Vec3 | undefined;
|
|
2914
|
+
|
|
2915
|
+
// Camera data needed for frustum corner computation (first camera only;
|
|
2916
|
+
// multi-camera CSM is OOS-1). Undefined in a cameraless world.
|
|
2917
|
+
const cam0 = cameras[0];
|
|
2918
|
+
const cameraData: CsmCameraData | undefined =
|
|
2919
|
+
cam0 !== undefined
|
|
2920
|
+
? {
|
|
2921
|
+
world: cam0.world,
|
|
2922
|
+
fov: cam0.fov,
|
|
2923
|
+
aspect: cam0.aspect,
|
|
2924
|
+
near: cam0.near,
|
|
2925
|
+
far: cam0.far,
|
|
2926
|
+
projection: cam0.projection,
|
|
2927
|
+
orthoLeft: cam0.orthoLeft,
|
|
2928
|
+
orthoRight: cam0.orthoRight,
|
|
2929
|
+
orthoBottom: cam0.orthoBottom,
|
|
2930
|
+
orthoTop: cam0.orthoTop,
|
|
2931
|
+
}
|
|
2932
|
+
: undefined;
|
|
2933
|
+
|
|
2934
|
+
// feat-20260621 M2: CSM computation gated on castShadow from the
|
|
2935
|
+
// merged DirectionalLight. castShadow defaults to true (first-hit-wins
|
|
2936
|
+
// semantics, D-6 no cardinality cap).
|
|
2937
|
+
if (directional !== undefined && firstHitCastShadow !== false) {
|
|
2938
|
+
const dirSnapshot = directional;
|
|
2939
|
+
const sf = firstHitShadowFields;
|
|
2940
|
+
if (sf !== undefined) {
|
|
2941
|
+
directionalCsmConfig = {
|
|
2942
|
+
cascadeCount: sf.cascadeCount,
|
|
2943
|
+
splitLambda: sf.splitLambda,
|
|
2944
|
+
cascadeBlend: sf.cascadeBlend,
|
|
2945
|
+
mapSize: sf.mapSize,
|
|
2946
|
+
shadowDistance: sf.shadowDistance,
|
|
2947
|
+
};
|
|
2948
|
+
directionalCsmDirection = dirSnapshot.direction;
|
|
2949
|
+
const csm = computeDirectionalCsm(dirSnapshot.direction, directionalCsmConfig, cameraData);
|
|
2950
|
+
// Cascade metadata (splitPlanes / count / blend / mapSize) is available
|
|
2951
|
+
// even without a camera (splitPlanes needs only near/far); the matrices
|
|
2952
|
+
// need the camera. When csm is null (no camera) leave lightViewProj
|
|
2953
|
+
// undefined — the merge layer recomputes. Still surface the split/count
|
|
2954
|
+
// metadata so a single-world path keeps its prior fields.
|
|
2955
|
+
cascadeCount = Math.round(sf.cascadeCount);
|
|
2956
|
+
cascadeBlend = sf.cascadeBlend;
|
|
2957
|
+
shadowMapSize = sf.mapSize;
|
|
2958
|
+
if (csm !== null) {
|
|
2959
|
+
lightViewProj = csm.lightViewProj;
|
|
2960
|
+
splitPlanes = csm.splitPlanes;
|
|
2961
|
+
} else {
|
|
2962
|
+
const sNear = cameraData?.near ?? 0.1;
|
|
2963
|
+
const splits = pssmSplit(sNear, sf.shadowDistance, cascadeCount, sf.splitLambda);
|
|
2964
|
+
const padded = new Float32Array(4);
|
|
2965
|
+
for (let i = 0; i < splits.length; i++) padded[i] = splits[i] ?? 0;
|
|
2966
|
+
splitPlanes = padded;
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
// feat-20260613-csm M3 / w14 (plan-strategy §D-7): pad the up-to-4
|
|
2972
|
+
// splitPlanes into a fixed length-4 Float32Array (unused slots = 0) so
|
|
2973
|
+
// the View UBO tail keeps a stable layout regardless of the runtime
|
|
2974
|
+
// cascadeCount. Host-side correctness invariant: only the first
|
|
2975
|
+
// cascadeCount slots are ever read by the WGSL kernel.
|
|
2976
|
+
const paddedSplitPlanes = new Float32Array(4);
|
|
2977
|
+
if (splitPlanes !== undefined) {
|
|
2978
|
+
for (let i = 0; i < splitPlanes.length; i++) {
|
|
2979
|
+
paddedSplitPlanes[i] = splitPlanes[i] ?? 0;
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
// feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-7 (plan-strategy §D-3,
|
|
2984
|
+
// requirements §5.3): query (PointLight + PointLightShadow + Transform)
|
|
2985
|
+
// archetype join. For each shadow caster, build 6 face VP matrices and pack
|
|
2986
|
+
// into Float32Array(96). Atlas layer assigned in spawn order (0..3); the
|
|
2987
|
+
// sentinel -1 is shader-side and applies to non-shadow PointLights only.
|
|
2988
|
+
// Cap of 4 is enforced by ECS cardinality on PointLightShadow.
|
|
2989
|
+
const pointShadowSnapshots: PointShadowSnapshot[] = [];
|
|
2990
|
+
{
|
|
2991
|
+
const pointShadowQuery = world
|
|
2992
|
+
.query({ read: [Transform, PointLightShadow], with: [PointLight] })
|
|
2993
|
+
.unwrap();
|
|
2994
|
+
for (const row of pointShadowQuery) {
|
|
2995
|
+
const t = row.get(Transform);
|
|
2996
|
+
const ps = row.get(PointLightShadow);
|
|
2997
|
+
// Read world-space position from Transform.world (mat4 column-major;
|
|
2998
|
+
// translation lives at indices 12..14, mirroring CameraSnapshot.world
|
|
2999
|
+
// semantics in this file).
|
|
3000
|
+
// feat-20260614 M4 / w13: TypedArrayFor for `array<f32, 16>` now
|
|
3001
|
+
// resolves to a concrete `Float32Array` (was `never` pre-w11), which
|
|
3002
|
+
// surfaces the row-window slicing -- `t.world` is the stride-16 flat
|
|
3003
|
+
// column view; row i lives at `[i*16, (i+1)*16)`. The prior
|
|
3004
|
+
// `t.world?.[i]` form silently returned a single element under the
|
|
3005
|
+
// `never`-typed bundle path and `wRow[12]` widened to `undefined ?? 0`
|
|
3006
|
+
// so light positions clamped to the origin.
|
|
3007
|
+
const wRow = t.world;
|
|
3008
|
+
if (wRow === undefined) continue;
|
|
3009
|
+
const px = wRow[12] ?? 0;
|
|
3010
|
+
const py = wRow[13] ?? 0;
|
|
3011
|
+
const pz = wRow[14] ?? 0;
|
|
3012
|
+
const lightPos = vec3.create(px, py, pz);
|
|
3013
|
+
const mapSize = ps.mapSize;
|
|
3014
|
+
const nearPlane = ps.nearPlane;
|
|
3015
|
+
const farPlane = ps.farPlane;
|
|
3016
|
+
const layer = pointShadowSnapshots.length; // 0, 1, 2, 3 in spawn order
|
|
3017
|
+
|
|
3018
|
+
const matrices = buildPointShadowMatrices(lightPos, nearPlane, farPlane);
|
|
3019
|
+
const packed = new Float32Array(96);
|
|
3020
|
+
for (let f = 0; f < 6; f++) {
|
|
3021
|
+
const m = matrices[f];
|
|
3022
|
+
if (m === undefined) continue;
|
|
3023
|
+
for (let k = 0; k < 16; k++) {
|
|
3024
|
+
packed[f * 16 + k] = m[k] ?? 0;
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
pointShadowSnapshots.push({
|
|
3028
|
+
entity: row.entity,
|
|
3029
|
+
position: lightPos,
|
|
3030
|
+
mapSize,
|
|
3031
|
+
nearPlane,
|
|
3032
|
+
farPlane,
|
|
3033
|
+
shadowAtlasLayer: layer,
|
|
3034
|
+
shadowMatrices: packed,
|
|
3035
|
+
});
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
// feat-20260612-point-light-shadows-urp-hdrp M4 / T-M4-4 (plan-strategy §D-8):
|
|
3040
|
+
// join pointShadow snapshots into the matching PointLightSnapshot so the
|
|
3041
|
+
// record stage threads `shadowAtlasLayer + shadowNear + shadowFar` through
|
|
3042
|
+
// `packLightSlot` for the HDRP std430 LightSlot pad lanes (byte 52..64).
|
|
3043
|
+
// Mutates the freshly-built PointLightSnapshot in place; the snapshot is
|
|
3044
|
+
// not exposed elsewhere this frame yet (consumed only by lights.point[]).
|
|
3045
|
+
if (pointShadowSnapshots.length > 0) {
|
|
3046
|
+
const shadowByEntity = new Map<number, PointShadowSnapshot>();
|
|
3047
|
+
for (const ps of pointShadowSnapshots) shadowByEntity.set(ps.entity, ps);
|
|
3048
|
+
for (let i = 0; i < pointSnapshots.length; i++) {
|
|
3049
|
+
const entityId = pointSnapshotEntities[i] ?? 0;
|
|
3050
|
+
const ps = shadowByEntity.get(entityId);
|
|
3051
|
+
if (ps !== undefined) {
|
|
3052
|
+
pointSnapshots[i] = {
|
|
3053
|
+
...(pointSnapshots[i] as PointLightSnapshot),
|
|
3054
|
+
shadowAtlasLayer: ps.shadowAtlasLayer,
|
|
3055
|
+
shadowNear: ps.nearPlane,
|
|
3056
|
+
shadowFar: ps.farPlane,
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
const lights: ExtractedLights = {
|
|
3063
|
+
directional,
|
|
3064
|
+
directionalCount,
|
|
3065
|
+
point: pointSnapshots,
|
|
3066
|
+
spot: spotSnapshots,
|
|
3067
|
+
lightViewProj,
|
|
3068
|
+
splitPlanes: splitPlanes !== undefined ? paddedSplitPlanes : undefined,
|
|
3069
|
+
cascadeCount,
|
|
3070
|
+
cascadeBlend,
|
|
3071
|
+
shadowMapSize,
|
|
3072
|
+
depthBias: firstHitCastShadow !== false ? firstHitShadowFields?.depthBias : undefined,
|
|
3073
|
+
normalBias: firstHitCastShadow !== false ? firstHitShadowFields?.normalBias : undefined,
|
|
3074
|
+
pcfKernelSize: firstHitCastShadow !== false ? firstHitShadowFields?.pcfKernelSize : undefined,
|
|
3075
|
+
pointShadow: pointShadowSnapshots,
|
|
3076
|
+
// bug-20260710-editor-cross-world-shadow: raw CSM config + light direction
|
|
3077
|
+
// so the merge layer can recompute matrices against the surfaced camera.
|
|
3078
|
+
directionalCsmConfig,
|
|
3079
|
+
directionalCsmDirection,
|
|
3080
|
+
};
|
|
3081
|
+
|
|
3082
|
+
// feat-20260520-skylight-ibl-cubemap M4 / t26+t27: query Skylight entities.
|
|
3083
|
+
// First archetype hit wins (mirrors DirectionalLight pattern); multi-Skylight
|
|
3084
|
+
// warn in record stage (t27) uses skylightCount.
|
|
3085
|
+
const skylightQuery = world.query({ read: [Skylight] }).unwrap();
|
|
3086
|
+
let skylight: SkylightSnapshot | undefined;
|
|
3087
|
+
let skylightCount = 0;
|
|
3088
|
+
for (const row of skylightQuery) {
|
|
3089
|
+
const s = row.get(Skylight);
|
|
3090
|
+
// equirect is OPTIONAL: an omitted field zero-inits to handle 0, which
|
|
3091
|
+
// record treats as "no equirect" -> solid-color ambient via the white
|
|
3092
|
+
// fallback cube. A Skylight WITHOUT an equirect is still a valid snapshot
|
|
3093
|
+
// (the prior `equirectRaw !== undefined` gate dropped color-only
|
|
3094
|
+
// skylights, leaving the scene black -- the downstream gap #4).
|
|
3095
|
+
const equirectRaw = s.equirect;
|
|
3096
|
+
const intensity = s.intensity;
|
|
3097
|
+
const colorR = s.color[0] ?? 1.0;
|
|
3098
|
+
const colorG = s.color[1] ?? 1.0;
|
|
3099
|
+
const colorB = s.color[2] ?? 1.0;
|
|
3100
|
+
const rotation: [number, number, number, number] = [
|
|
3101
|
+
s.rotation[0] ?? 0,
|
|
3102
|
+
s.rotation[1] ?? 0,
|
|
3103
|
+
s.rotation[2] ?? 0,
|
|
3104
|
+
s.rotation[3] ?? 1,
|
|
3105
|
+
];
|
|
3106
|
+
skylightCount += 1;
|
|
3107
|
+
if (skylight === undefined) {
|
|
3108
|
+
skylight = {
|
|
3109
|
+
equirectHandle: equirectRaw !== undefined ? Math.round(equirectRaw) : 0,
|
|
3110
|
+
color: [colorR, colorG, colorB],
|
|
3111
|
+
intensity,
|
|
3112
|
+
rotation,
|
|
3113
|
+
// w19: winning entity handle for the multi-Skylight once-warn (F-8).
|
|
3114
|
+
entityHandle: row.entity,
|
|
3115
|
+
};
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
// feat-20260531-skybox-env-background M2 / w5: query SkyboxBackground entities.
|
|
3120
|
+
// First archetype hit wins (mirrors Skylight pattern); multi-entity
|
|
3121
|
+
// once-warn in record stage uses skyboxCount.
|
|
3122
|
+
const skyboxQuery = world.query({ read: [SkyboxBackground] }).unwrap();
|
|
3123
|
+
let skybox: SkyboxSnapshot | undefined;
|
|
3124
|
+
let skyboxCount = 0;
|
|
3125
|
+
for (const row of skyboxQuery) {
|
|
3126
|
+
const s = row.get(SkyboxBackground);
|
|
3127
|
+
const equirectRaw = s.equirect;
|
|
3128
|
+
const modeRaw = s.mode;
|
|
3129
|
+
const rotation: [number, number, number, number] = [
|
|
3130
|
+
s.rotation[0] ?? 0,
|
|
3131
|
+
s.rotation[1] ?? 0,
|
|
3132
|
+
s.rotation[2] ?? 0,
|
|
3133
|
+
s.rotation[3] ?? 1,
|
|
3134
|
+
];
|
|
3135
|
+
skyboxCount += 1;
|
|
3136
|
+
if (skybox === undefined && equirectRaw !== undefined) {
|
|
3137
|
+
skybox = {
|
|
3138
|
+
equirectHandle: Math.round(equirectRaw),
|
|
3139
|
+
mode: modeRaw,
|
|
3140
|
+
rotation,
|
|
3141
|
+
// w19: winning entity handle for the multi-SkyboxBackground warn (F-8).
|
|
3142
|
+
entityHandle: row.entity,
|
|
3143
|
+
};
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
// feat-20260528-frustum-culling M3 / w10: precompute per-camera frustum
|
|
3148
|
+
// planes so entities can be tested against all cameras in the inner loop.
|
|
3149
|
+
// Cameras with degenerate projection parameters (e.g. zero fov, zero aspect)
|
|
3150
|
+
// are skipped — entities are always-visible for those. Frustum plane cache
|
|
3151
|
+
// stored as Float32Array[] parallel to the cameras[] array.
|
|
3152
|
+
//
|
|
3153
|
+
// feat-20260708-composited-multi-world-rendering M2 / D-4: a composite
|
|
3154
|
+
// non-owner world uses the camera-owner snapshots, not its own cameras.
|
|
3155
|
+
// This keeps culling correct when the surfaced camera and renderables live
|
|
3156
|
+
// in different worlds. The explicit 'none' mode remains the always-visible
|
|
3157
|
+
// escape hatch for callers that genuinely need it.
|
|
3158
|
+
const frustumPlanes: Float32Array[] = [];
|
|
3159
|
+
const cullingCameras = cullMode === 'external' ? (context.cullCameras ?? []) : cameras;
|
|
3160
|
+
if (cullMode !== 'none') {
|
|
3161
|
+
for (const cam of cullingCameras) {
|
|
3162
|
+
// feat-20260613 M6 / w20: orthographic cameras have fov=0 by design;
|
|
3163
|
+
// the previous degeneracy guard (`fov <= 0`) was rejecting valid ortho
|
|
3164
|
+
// cameras and returning the always-visible escape hatch. Only the
|
|
3165
|
+
// perspective path needs the fov check.
|
|
3166
|
+
if (cam.projection === 'perspective' && (cam.fov <= 0 || cam.aspect <= 0)) {
|
|
3167
|
+
frustumPlanes.push(new Float32Array(0)); // degenerate → always-visible
|
|
3168
|
+
continue;
|
|
3169
|
+
}
|
|
3170
|
+
if (cam.near >= cam.far) {
|
|
3171
|
+
frustumPlanes.push(new Float32Array(0));
|
|
3172
|
+
continue;
|
|
3173
|
+
}
|
|
3174
|
+
const proj = mat4.create();
|
|
3175
|
+
if (cam.projection === 'orthographic') {
|
|
3176
|
+
mat4.orthographic(
|
|
3177
|
+
proj,
|
|
3178
|
+
cam.orthoLeft,
|
|
3179
|
+
cam.orthoRight,
|
|
3180
|
+
cam.orthoBottom,
|
|
3181
|
+
cam.orthoTop,
|
|
3182
|
+
cam.near,
|
|
3183
|
+
cam.far,
|
|
3184
|
+
);
|
|
3185
|
+
} else {
|
|
3186
|
+
mat4.perspective(proj, cam.fov, cam.aspect, cam.near, cam.far);
|
|
3187
|
+
}
|
|
3188
|
+
// feat-20260601 D-3: view = invert(camera world mat4). The camera scale is
|
|
3189
|
+
// carried in the world basis columns; the cull frustum uses the same view
|
|
3190
|
+
// the record stage derives, so cull stays same-source with render (AC-05).
|
|
3191
|
+
const view = mat4.create();
|
|
3192
|
+
mat4.invert(view, cam.world);
|
|
3193
|
+
const vp = mat4.create();
|
|
3194
|
+
mat4.multiply(vp, proj, view);
|
|
3195
|
+
const f = frustum.create();
|
|
3196
|
+
frustum.fromViewProjection(f, vp);
|
|
3197
|
+
frustumPlanes.push(f);
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
const renderables: RenderableSnapshot[] = [];
|
|
3202
|
+
// feat-20260528-frustum-culling M3 / w11: frustum culling counters.
|
|
3203
|
+
let frustumCulled = 0;
|
|
3204
|
+
let frustumTotal = 0;
|
|
3205
|
+
const explicitlyHidden = new Set<EntityHandle>();
|
|
3206
|
+
// feat-20260520-2d-sprite-layer-mvp M-3 / w22 (@new-surface): three-
|
|
3207
|
+
// bucket dispatch arrays. The legacy `materialDispatch` field stays as
|
|
3208
|
+
// a back-compat union (opaque + transparent + overlay back-compat
|
|
3209
|
+
// entries) so the pre-w25 RenderSystem.draw consumer loop keeps
|
|
3210
|
+
// working until M-3 / w25 lands the bucket-aware record. Plan-strategy
|
|
3211
|
+
// §6.1 (back-compat field stays until M-4 acceptance round green).
|
|
3212
|
+
// M3 / w26: single dispatch list replaces old three-bucket model
|
|
3213
|
+
// (plan-strategy D-3). Entries built per-entity per-pass inside the
|
|
3214
|
+
// archetype walk, then sorted by queue at the end.
|
|
3215
|
+
let dispatch: DispatchEntry[] = [];
|
|
3216
|
+
// Keep the derived material snapshot and its resolved passes local to this
|
|
3217
|
+
// extraction. Shared handles are immutable inputs for the frame, while
|
|
3218
|
+
// dispatch entries still need to be rebuilt for each entity.
|
|
3219
|
+
const materialSnapshotCache: MaterialSnapshotCache = new Map();
|
|
3220
|
+
// tweak-20260611 M1: MeshRenderer renderable archetype walk routes
|
|
3221
|
+
// through one World-owned Query. K-2 sniffing scheme B
|
|
3222
|
+
// (`row.get(X) !== undefined` edge sniff) replaces the prior
|
|
3223
|
+
// `arch.components.some` row-internal back-door. K-3 invariant: the
|
|
3224
|
+
// variable-length array reads (`MeshRenderer.materials`,
|
|
3225
|
+
// `Instances.transforms`) still flow through `_getArrayView` /
|
|
3226
|
+
// `world.get(e, Instances)` -- only the `entity` source switches to
|
|
3227
|
+
// `row.entity`.
|
|
3228
|
+
//
|
|
3229
|
+
// archVersion plumbing exception: the `RenderableSnapshot.instances
|
|
3230
|
+
// .archVersion` cache key is keyed off the live archetype's mutation
|
|
3231
|
+
// counter (record stage `instanceBuffers` cache invalidation). The
|
|
3232
|
+
// QueryRow does not surface this number, so a single archetype
|
|
3233
|
+
// graph access is retained for instance-bearing rows -- match by entity
|
|
3234
|
+
// packed handle. AC-01 grep ≤ current - 1 still holds: the stale
|
|
3235
|
+
// archetype-graph traversal commentary at the prior call site is gone.
|
|
3236
|
+
// feat-20260521-sprite-atlas-animation M3 / T-16: SpriteRegionOverride
|
|
3237
|
+
// column id for the sprite-bucket per-entity region override read
|
|
3238
|
+
void SpriteRegionOverride;
|
|
3239
|
+
|
|
3240
|
+
const meshRendererQuery = world
|
|
3241
|
+
.query({
|
|
3242
|
+
read: [MeshRenderer],
|
|
3243
|
+
optional: [
|
|
3244
|
+
Transform,
|
|
3245
|
+
MeshFilter,
|
|
3246
|
+
Instances,
|
|
3247
|
+
Skin,
|
|
3248
|
+
Layer,
|
|
3249
|
+
MorphWeights,
|
|
3250
|
+
SpriteRegionOverride,
|
|
3251
|
+
SpriteInstances,
|
|
3252
|
+
Points,
|
|
3253
|
+
Lines,
|
|
3254
|
+
SortKey,
|
|
3255
|
+
],
|
|
3256
|
+
})
|
|
3257
|
+
.unwrap();
|
|
3258
|
+
// QueryRow deliberately exposes component data, not an archetype version.
|
|
3259
|
+
// The optimized ECS owner keeps table storage private, so instance-buffer
|
|
3260
|
+
// invalidation uses a content fingerprint of the managed arrays instead.
|
|
3261
|
+
const resolveArchVersion = (entity: EntityHandle): number => {
|
|
3262
|
+
const values = [
|
|
3263
|
+
worldInternal._getArrayView(entity, Instances, 'transforms'),
|
|
3264
|
+
worldInternal._getArrayView(entity, SpriteInstances, 'transforms'),
|
|
3265
|
+
worldInternal._getArrayView(entity, SpriteInstances, 'regions'),
|
|
3266
|
+
];
|
|
3267
|
+
let hash = 2166136261;
|
|
3268
|
+
for (const value of values) {
|
|
3269
|
+
hash = Math.imul(hash ^ (value?.length ?? 0), 16777619) >>> 0;
|
|
3270
|
+
if (value === undefined) continue;
|
|
3271
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
3272
|
+
hash = Math.imul(hash ^ Math.fround(value[index] ?? 0), 16777619) >>> 0;
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
return hash;
|
|
3276
|
+
};
|
|
3277
|
+
// Pending entries are created with the current renderables.length, which is
|
|
3278
|
+
// exactly the slot this entity receives if it survives culling. No other
|
|
3279
|
+
// renderable can be pushed between staging and this entity's push, so publish
|
|
3280
|
+
// the original fresh entries instead of cloning every submesh descriptor.
|
|
3281
|
+
// A culled entity still discards its private pending array unchanged.
|
|
3282
|
+
const flushPendingDispatch = (pending: readonly DispatchEntry[]): void => {
|
|
3283
|
+
for (const entry of pending) dispatch.push(entry);
|
|
3284
|
+
};
|
|
3285
|
+
for (const row of meshRendererQuery) {
|
|
3286
|
+
// K-2 archetype-edge sniff (scheme B): a missing optional component
|
|
3287
|
+
// surfaces as an absent bundle key, not a row-internal optional chain.
|
|
3288
|
+
// Presence-only checks use QueryRow.has so large array-bearing components
|
|
3289
|
+
// are not materialised merely to answer a boolean question.
|
|
3290
|
+
const hasTransform = row.has(Transform);
|
|
3291
|
+
const meshFilter = row.get(MeshFilter);
|
|
3292
|
+
const hasInstances = row.has(Instances);
|
|
3293
|
+
const skin = row.get(Skin);
|
|
3294
|
+
const morphWeightsView = worldInternal._getArrayView(row.entity, MorphWeights, 'weights');
|
|
3295
|
+
const hasMeshFilter = meshFilter !== undefined;
|
|
3296
|
+
const hasSkin = skin !== undefined;
|
|
3297
|
+
// feat-20260625-sprite-instances-and-tilemap-terrain-static-batch M3 / w10:
|
|
3298
|
+
// SpriteInstances optional component archetype-edge sniff. Three structured
|
|
3299
|
+
// EcsError codes fire at the row-loop entry (D-6 fail-fast at extract):
|
|
3300
|
+
// - 'sprite-instances-mutually-exclusive-with-instances'
|
|
3301
|
+
// (hasInstances && hasSpriteInstances) — Instances + SpriteInstances peers.
|
|
3302
|
+
// - 'sprite-instances-requires-sprite-shading-model'
|
|
3303
|
+
// (materialSnap.materialShaderId !== 'forgeax::sprite') — non-sprite material.
|
|
3304
|
+
// - 'sprite-instances-count-mismatch'
|
|
3305
|
+
// (transforms.length / 16 !== regions.length / 4) — stride pair desync.
|
|
3306
|
+
const hasSpriteInstances = row.has(SpriteInstances);
|
|
3307
|
+
const points = row.get(Points);
|
|
3308
|
+
const lines = row.get(Lines);
|
|
3309
|
+
const sortKey = row.get(SortKey)?.value;
|
|
3310
|
+
const pointsLinesComponent =
|
|
3311
|
+
points !== undefined ? 'Points' : lines !== undefined ? 'Lines' : undefined;
|
|
3312
|
+
const isRenderable = hasTransform && hasMeshFilter;
|
|
3313
|
+
|
|
3314
|
+
// feat-20260601 D-3: the resolved world transform is read per-entity from
|
|
3315
|
+
// the single `Transform.world` mat4 (propagateTransforms output) inside the
|
|
3316
|
+
// row loop below. The retired GlobalTransform-column-switch + the
|
|
3317
|
+
// ChildOf-without-GlobalTransform misconfig signal are gone: the world
|
|
3318
|
+
// column always exists on a Transform-bearing entity, so the
|
|
3319
|
+
// "ChildOf but forgot GlobalTransform" misconfiguration cannot occur.
|
|
3320
|
+
const fAssetHandle = meshFilter?.assetHandle;
|
|
3321
|
+
// feat-20260520-2d-sprite-layer-mvp M-3 / w22: Layer column read here;
|
|
3322
|
+
// value folded into each DispatchEntry so the render-system sort can use
|
|
3323
|
+
// it as the primary transparent-sort key without a second ECS round-trip.
|
|
3324
|
+
const fLayerValue = row.get(Layer)?.value;
|
|
3325
|
+
// feat-20260608-tilemap-object-layer-rendering M3 / m3-t5: tilemap-spawned
|
|
3326
|
+
// per-cell render entities (the ones `tilemap-chunk-extract-system`
|
|
3327
|
+
// pushes via `spawnDerivedRenderEntities`) reach this loop via the same
|
|
3328
|
+
// archetype edge that carries a sprite entity -- they all wear
|
|
3329
|
+
// `MeshFilter.assetHandle === HANDLE_QUAD` + a `forgeax::sprite`-shaded
|
|
3330
|
+
// material asset + the sprite-bucket `values.region` rectangle.
|
|
3331
|
+
// For the per-entity Y-sort path (requirements §AC-12 / §AC-13):
|
|
3332
|
+
//
|
|
3333
|
+
// sortKey = -(Transform.posY - effectivePivotY * |Transform.scaleY|)
|
|
3334
|
+
//
|
|
3335
|
+
// with `effectivePivotY = effectivePivotYForTilemapFlip(pivotY, pivotX,
|
|
3336
|
+
// flipV, flipDiagonal)` from `tilemap-chunk-extract-system` (the SAME
|
|
3337
|
+
// helper drives `spawnDerivedRenderEntities`, so the value the sort
|
|
3338
|
+
// uses matches the value baked into `Transform.posY` -- charter P4
|
|
3339
|
+
// single SSOT for the post-flip pivot). Sprite entities reuse the same
|
|
3340
|
+
// formula but skip the flip composition (their pivot stays raw); both
|
|
3341
|
+
// bucket types therefore feed one `transparentSortEntries` argsort
|
|
3342
|
+
// step + share the `argsortInPlace` radix LSD primitive (plan-strategy
|
|
3343
|
+
// §D-1 / §D-3). The detection lives on the material side -- detect a
|
|
3344
|
+
// tilemap-spawned entity by `MeshFilter.assetHandle === HANDLE_QUAD`
|
|
3345
|
+
// plus the `forgeax::sprite` shader id on `MeshRenderer.material`'s
|
|
3346
|
+
// first pass + non-empty `values.region`; no new public ECS
|
|
3347
|
+
// marker component lands (charter F1 minimum surface).
|
|
3348
|
+
//
|
|
3349
|
+
// Layer.value is now folded into each DispatchEntry.layer (fLayerValue
|
|
3350
|
+
// column, read once per archetype pass above). render-system.ts
|
|
3351
|
+
// `sortTransparentDispatch` applies (layer ASC, sortValue ASC) for all
|
|
3352
|
+
// transparent-sort modes (0/1/2) using posY/pivotY/sizeY from the
|
|
3353
|
+
// parallel renderables[] snapshot -- no second ECS round-trip needed.
|
|
3354
|
+
// feat-20260527-sprite-nineslice M4 / w17 (AC-14): SpriteRegionOverride
|
|
3355
|
+
// per-entity UV sub-rectangle. When the entity carries this component the
|
|
3356
|
+
// 4-float `[uMin, vMin, uW, vH]` override displaces the asset-side
|
|
3357
|
+
// `values.region` for this entity only — downstream 9-slice logic
|
|
3358
|
+
// measures slices against this effective region.zw, so a half-width sub-
|
|
3359
|
+
// sprite reduces the anchor budget to 0.5 rather than the asset's 1.0.
|
|
3360
|
+
//
|
|
3361
|
+
// SpriteRegionOverride.region is a fixed stride-4 value. Per-row reads
|
|
3362
|
+
// route through `_getArrayView` for the zero-copy row window
|
|
3363
|
+
// row-window slice (consistent with the variable-length array column
|
|
3364
|
+
// reads -- K-3 carve-out keeps `_getArrayView` as the row-accessor of
|
|
3365
|
+
// record for each non-scalar column).
|
|
3366
|
+
const hasSpriteRegionOverride = row.has(SpriteRegionOverride);
|
|
3367
|
+
// feat-20260523-skin-skeleton-animation M2 / T-21: Skin component
|
|
3368
|
+
// column views for coexistence check + joint despawn fail-fast.
|
|
3369
|
+
// `skeleton` holds the packed Handle<SkeletonAsset>; `joints` holds
|
|
3370
|
+
// the packed Entity u32 array (N x one u32 each).
|
|
3371
|
+
const skinSkeletonView = skin?.skeleton;
|
|
3372
|
+
|
|
3373
|
+
let archVersion = 0;
|
|
3374
|
+
if (hasInstances || hasSpriteInstances) {
|
|
3375
|
+
// All rows in this callback share one archetype. Only these two
|
|
3376
|
+
// instance-bearing paths consume the version in their cache key.
|
|
3377
|
+
archVersion = resolveArchVersion(row.entity);
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
{
|
|
3381
|
+
// feat-20260608 M2 / w11: read materials array via _getArrayView
|
|
3382
|
+
const entity = row.entity;
|
|
3383
|
+
if (isRenderable && visibility?.effective(entity) === 'hidden') {
|
|
3384
|
+
explicitlyHidden.add(entity);
|
|
3385
|
+
continue;
|
|
3386
|
+
}
|
|
3387
|
+
const layerVal = fLayerValue ?? 0;
|
|
3388
|
+
// bug-20260709-builtin-quad-withoutaabb-disables-sprite-frustum-cu M2.5
|
|
3389
|
+
// (carries PR #598 feat-20260703 D-7): dispatch entries for this entity
|
|
3390
|
+
// are staged locally and flushed into the shared `dispatch[]` array
|
|
3391
|
+
// ONLY when the entity survives the frustum-cull `continue` below —
|
|
3392
|
+
// same cull-passed branch as the paired `renderables.push`. Prior to
|
|
3393
|
+
// this fix the three `dispatch.push` sites ran before the cull check,
|
|
3394
|
+
// so a culled entity left dangling entries whose `renderableIndex`
|
|
3395
|
+
// aliased the slot a LATER visible entity occupied — surfacing as the
|
|
3396
|
+
// pbr-mesh-array-bgl vs hdrp-unified-bgl-group2 BGL/PL mismatch on
|
|
3397
|
+
// the deferred-shading smoke (PR #598 CI). Pure ordering fix; cull
|
|
3398
|
+
// logic and MeshRenderer contract unchanged.
|
|
3399
|
+
const pendingDispatch: DispatchEntry[] = [];
|
|
3400
|
+
const materialsView = worldInternal._getArrayView(entity, MeshRenderer, 'materials') as
|
|
3401
|
+
| Uint32Array
|
|
3402
|
+
| undefined;
|
|
3403
|
+
const materialCount = materialsView?.length ?? 0;
|
|
3404
|
+
let materialHandles = Array.from(materialsView ?? []);
|
|
3405
|
+
let materialBindingSources: MeshMaterialBindingSource[] = materialHandles.map(
|
|
3406
|
+
() => 'renderer-override',
|
|
3407
|
+
);
|
|
3408
|
+
let materialBindingDiagnostics: MeshMaterialBindingDiagnostic[] = [];
|
|
3409
|
+
let gpuDrivenSubmeshes: readonly MeshAsset['submeshes'][number][] = [];
|
|
3410
|
+
let gpuDrivenIndexed = false;
|
|
3411
|
+
|
|
3412
|
+
// Resolve instance overrides against mesh-owned slot defaults once.
|
|
3413
|
+
const fAssetHandleVal = fAssetHandle;
|
|
3414
|
+
if (
|
|
3415
|
+
fAssetHandleVal !== undefined &&
|
|
3416
|
+
fAssetHandleVal !== 0 &&
|
|
3417
|
+
assets !== undefined &&
|
|
3418
|
+
assets !== null
|
|
3419
|
+
) {
|
|
3420
|
+
const meshHandle = toShared<'MeshAsset'>(fAssetHandleVal);
|
|
3421
|
+
const meshRes = resolveAssetHandle<Asset>(world, meshHandle);
|
|
3422
|
+
if (meshRes.ok && meshRes.value.kind === 'mesh') {
|
|
3423
|
+
const meshAsset = meshRes.value as MeshAsset;
|
|
3424
|
+
gpuDrivenSubmeshes = meshAsset.submeshes;
|
|
3425
|
+
gpuDrivenIndexed = meshAsset.indices !== undefined;
|
|
3426
|
+
// extractFrames is also a public read path and can run before the
|
|
3427
|
+
// Renderer.draw pre-render stage. GlyphText proves the derived mesh
|
|
3428
|
+
// owns exactly one Default slot; arbitrary meshes still fail closed
|
|
3429
|
+
// and never infer slot topology from their submeshes.
|
|
3430
|
+
if (!Array.isArray(meshAsset.materialSlots) && world.get(entity, GlyphText).ok) {
|
|
3431
|
+
ensureGlyphMeshMaterialSlots(world, meshHandle);
|
|
3432
|
+
}
|
|
3433
|
+
const guid = (meshRes.value as { guid?: string }).guid ?? '<no-guid>';
|
|
3434
|
+
const resolvedBindings = resolveMeshMaterialBindings(meshAsset, materialsView ?? [], {
|
|
3435
|
+
isValidOverride(handle) {
|
|
3436
|
+
const resolved = resolveAssetHandle(world, toShared<'MaterialAsset'>(handle));
|
|
3437
|
+
return resolved.ok && resolved.value.kind === 'material';
|
|
3438
|
+
},
|
|
3439
|
+
resolveMeshDefault(defaultGuid) {
|
|
3440
|
+
const guidText = AssetGuid.format(defaultGuid);
|
|
3441
|
+
if (assets.lookup<Asset>(guidText)?.kind !== 'material') return undefined;
|
|
3442
|
+
return internSharedRefFromGuid(world, assets, guidText, 'MaterialAsset') as
|
|
3443
|
+
| number
|
|
3444
|
+
| undefined;
|
|
3445
|
+
},
|
|
3446
|
+
});
|
|
3447
|
+
if (!resolvedBindings.ok) {
|
|
3448
|
+
if (resolvedBindings.code === 'mesh-material-slots-missing') {
|
|
3449
|
+
worldInternal._routeError(
|
|
3450
|
+
new AssetError({
|
|
3451
|
+
code: 'load-failed',
|
|
3452
|
+
expected: 'every MeshAsset producer supplies materialSlots[]',
|
|
3453
|
+
hint: `fix the MeshAsset producer; renderer inheritance never guesses slot topology (mesh=${guid}, entity=${entity}, vertices=${meshAsset.vertices.length}, indices=${meshAsset.indices?.length ?? 0})`,
|
|
3454
|
+
detail: {
|
|
3455
|
+
referencedByGuid: guid,
|
|
3456
|
+
referencedByKind: 'mesh',
|
|
3457
|
+
subAssetGuid: '<material-slots-missing>',
|
|
3458
|
+
sourceField: { fieldName: 'materialSlots' },
|
|
3459
|
+
},
|
|
3460
|
+
}),
|
|
3461
|
+
{
|
|
3462
|
+
severity: Severity.Error,
|
|
3463
|
+
systemName: 'RenderSystem.extract (mesh-material-slots-missing)',
|
|
3464
|
+
},
|
|
3465
|
+
);
|
|
3466
|
+
continue;
|
|
3467
|
+
}
|
|
3468
|
+
const materialGuid = AssetGuid.format(resolvedBindings.defaultMaterial);
|
|
3469
|
+
worldInternal._routeError(
|
|
3470
|
+
new AssetError({
|
|
3471
|
+
code: 'load-failed',
|
|
3472
|
+
expected: `MeshAsset materialSlots[${resolvedBindings.slotIndex}] default ${materialGuid} is ready and a MaterialAsset`,
|
|
3473
|
+
hint: `loadByGuid(meshGuid) must recursively load the declared default material; mesh=${guid}, slot=${resolvedBindings.slotIndex}, material=${materialGuid}`,
|
|
3474
|
+
detail: {
|
|
3475
|
+
referencedByGuid: guid,
|
|
3476
|
+
referencedByKind: 'mesh',
|
|
3477
|
+
subAssetGuid: materialGuid,
|
|
3478
|
+
sourceField: {
|
|
3479
|
+
fieldName: 'materialSlots',
|
|
3480
|
+
arrayIndex: resolvedBindings.slotIndex,
|
|
3481
|
+
},
|
|
3482
|
+
},
|
|
3483
|
+
}),
|
|
3484
|
+
{
|
|
3485
|
+
severity: Severity.Error,
|
|
3486
|
+
systemName: 'RenderSystem.extract (mesh-default-not-ready)',
|
|
3487
|
+
},
|
|
3488
|
+
);
|
|
3489
|
+
continue;
|
|
3490
|
+
}
|
|
3491
|
+
materialHandles = resolvedBindings.bindings.map((binding) => binding.handle);
|
|
3492
|
+
materialBindingSources = resolvedBindings.bindings.map((binding) => binding.source);
|
|
3493
|
+
materialBindingDiagnostics = resolvedBindings.diagnostics.map((diagnostic) => ({
|
|
3494
|
+
...diagnostic,
|
|
3495
|
+
detail:
|
|
3496
|
+
diagnostic.code === 'mesh-renderer-material-override-overflow'
|
|
3497
|
+
? {
|
|
3498
|
+
expectedCount: meshAsset.materialSlots.length,
|
|
3499
|
+
actualCount: materialCount,
|
|
3500
|
+
meshAssetGuid: guid,
|
|
3501
|
+
}
|
|
3502
|
+
: {
|
|
3503
|
+
meshAssetGuid: guid,
|
|
3504
|
+
slotIndex: diagnostic.slotIndex,
|
|
3505
|
+
handle: diagnostic.handle ?? 0,
|
|
3506
|
+
},
|
|
3507
|
+
}));
|
|
3508
|
+
for (const diagnostic of resolvedBindings.diagnostics) {
|
|
3509
|
+
worldInternal._routeError(
|
|
3510
|
+
new AssetError({
|
|
3511
|
+
code: diagnostic.code,
|
|
3512
|
+
expected:
|
|
3513
|
+
diagnostic.code === 'mesh-renderer-material-override-overflow'
|
|
3514
|
+
? `materials.length <= materialSlots.length (${meshAsset.materialSlots.length})`
|
|
3515
|
+
: `materials[${diagnostic.slotIndex}] resolves to a live MaterialAsset`,
|
|
3516
|
+
hint: ASSET_ERROR_HINTS[diagnostic.code],
|
|
3517
|
+
detail:
|
|
3518
|
+
diagnostic.code === 'mesh-renderer-material-override-overflow'
|
|
3519
|
+
? {
|
|
3520
|
+
expectedCount: meshAsset.materialSlots.length,
|
|
3521
|
+
actualCount: materialCount,
|
|
3522
|
+
meshAssetGuid: guid,
|
|
3523
|
+
}
|
|
3524
|
+
: {
|
|
3525
|
+
meshAssetGuid: guid,
|
|
3526
|
+
slotIndex: diagnostic.slotIndex,
|
|
3527
|
+
handle: diagnostic.handle ?? 0,
|
|
3528
|
+
},
|
|
3529
|
+
}),
|
|
3530
|
+
{
|
|
3531
|
+
severity: Severity.Warning,
|
|
3532
|
+
systemName: `RenderSystem.extract (${diagnostic.code})`,
|
|
3533
|
+
},
|
|
3534
|
+
);
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
if (materialHandles.length === 0) {
|
|
3540
|
+
materialHandles = [0];
|
|
3541
|
+
materialBindingSources = ['engine-default'];
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3544
|
+
// Use the first material handle for the entity-level snapshot
|
|
3545
|
+
// (shading-model dispatch routing + multi-pass DispatchEntry. Per-
|
|
3546
|
+
// submesh materials[i>=1] are resolved by `resolveMaterialSnapshot`
|
|
3547
|
+
// below into the `materials[]` array, used by the record stage to
|
|
3548
|
+
// upload N material UBO slots and bind the i-th slot before the
|
|
3549
|
+
// i-th submesh draw.) -- feat-20260608 M5 amend / w11-a.
|
|
3550
|
+
const handleRaw = materialHandles[0] ?? 0;
|
|
3551
|
+
|
|
3552
|
+
const cachedMaterial =
|
|
3553
|
+
handleRaw !== 0 && !hasSpriteRegionOverride && !hasSkin
|
|
3554
|
+
? materialSnapshotCache.get(handleRaw)
|
|
3555
|
+
: undefined;
|
|
3556
|
+
|
|
3557
|
+
let materialSnap: MaterialSnapshot;
|
|
3558
|
+
|
|
3559
|
+
if (cachedMaterial !== undefined) {
|
|
3560
|
+
materialSnap = cachedMaterial.snapshot;
|
|
3561
|
+
if (isRenderable) {
|
|
3562
|
+
appendMaterialDispatchEntries(
|
|
3563
|
+
pendingDispatch,
|
|
3564
|
+
cachedMaterial.passes,
|
|
3565
|
+
entity,
|
|
3566
|
+
handleRaw,
|
|
3567
|
+
renderables.length,
|
|
3568
|
+
layerVal,
|
|
3569
|
+
materialSnap.paramSnapshot,
|
|
3570
|
+
);
|
|
3571
|
+
}
|
|
3572
|
+
} else if (handleRaw === 0 || assets === undefined || assets === null) {
|
|
3573
|
+
// case B: missing-spec sentinel -> mid-grey defaultMaterialSnapshot.
|
|
3574
|
+
materialSnap = defaultMaterialSnapshot(handleRaw);
|
|
3575
|
+
} else {
|
|
3576
|
+
const tagged = toShared<'MaterialAsset'>(handleRaw);
|
|
3577
|
+
const stablePersistentCached =
|
|
3578
|
+
!hasSpriteRegionOverride && !hasSkin
|
|
3579
|
+
? readStablePersistentMaterialSnapshot(
|
|
3580
|
+
persistentMaterialSnapshotCache,
|
|
3581
|
+
handleRaw,
|
|
3582
|
+
assets,
|
|
3583
|
+
)
|
|
3584
|
+
: undefined;
|
|
3585
|
+
if (stablePersistentCached !== undefined) {
|
|
3586
|
+
materialSnapshotCache.set(handleRaw, stablePersistentCached);
|
|
3587
|
+
materialSnap = stablePersistentCached.snapshot;
|
|
3588
|
+
if (isRenderable) {
|
|
3589
|
+
appendMaterialDispatchEntries(
|
|
3590
|
+
pendingDispatch,
|
|
3591
|
+
stablePersistentCached.passes,
|
|
3592
|
+
entity,
|
|
3593
|
+
handleRaw,
|
|
3594
|
+
renderables.length,
|
|
3595
|
+
layerVal,
|
|
3596
|
+
materialSnap.paramSnapshot,
|
|
3597
|
+
);
|
|
3598
|
+
}
|
|
3599
|
+
} else {
|
|
3600
|
+
const res = resolveAssetHandle(world, tagged);
|
|
3601
|
+
if (!res.ok) {
|
|
3602
|
+
if (isRenderable) {
|
|
3603
|
+
const rhiErr = new RhiError({
|
|
3604
|
+
code: 'asset-not-registered',
|
|
3605
|
+
expected: 'MeshRenderer.material in AssetRegistry',
|
|
3606
|
+
hint: 'catalog the material via assetRegistry.catalog(guid, asset) + world.allocSharedRef before spawn, or remove the material field to fall back to default',
|
|
3607
|
+
detail: { assetHandle: handleRaw },
|
|
3608
|
+
});
|
|
3609
|
+
worldInternal._routeError(rhiErr, {
|
|
3610
|
+
severity: Severity.Error,
|
|
3611
|
+
systemName: 'RenderSystem.extract (material asset-not-registered)',
|
|
3612
|
+
});
|
|
3613
|
+
}
|
|
3614
|
+
continue;
|
|
3615
|
+
}
|
|
3616
|
+
const asset = res.value;
|
|
3617
|
+
if (asset.kind !== 'material') {
|
|
3618
|
+
materialSnap = defaultMaterialSnapshot(handleRaw);
|
|
3619
|
+
} else {
|
|
3620
|
+
// feat-20260529 M3 / w11: material parent chain inheritance via
|
|
3621
|
+
// read-through _materialWalk accessor (plan-strategy D-6).
|
|
3622
|
+
// The old direct asset.passes / asset.values read never
|
|
3623
|
+
// walked the parent chain, causing broken-inheritance (root cause).
|
|
3624
|
+
const resolvedResult = walkMaterialPassesOverSharedRefs(world, tagged, assets);
|
|
3625
|
+
if (!resolvedResult.ok) {
|
|
3626
|
+
// AC-09 / S-7 / q8=A: passes-empty or cycle must fire structured
|
|
3627
|
+
// error through _routeError (same routing as asset-not-registered
|
|
3628
|
+
// branch above). Silent continue is forbidden because it produces
|
|
3629
|
+
// a black screen indistinguishable from a content bug.
|
|
3630
|
+
const err = resolvedResult.error;
|
|
3631
|
+
switch (err.code) {
|
|
3632
|
+
case 'material-parent-not-found':
|
|
3633
|
+
case 'material-no-effective-pass':
|
|
3634
|
+
case 'material-value-unknown':
|
|
3635
|
+
case 'material-value-type-mismatch':
|
|
3636
|
+
case 'material-contract-program-mismatch':
|
|
3637
|
+
worldInternal._routeError(err, {
|
|
3638
|
+
severity: Severity.Error,
|
|
3639
|
+
systemName: `RenderSystem.extract (${err.code})`,
|
|
3640
|
+
});
|
|
3641
|
+
break;
|
|
3642
|
+
case 'material-circular-inheritance':
|
|
3643
|
+
worldInternal._routeError(err, {
|
|
3644
|
+
severity: Severity.Error,
|
|
3645
|
+
systemName: 'RenderSystem.extract (material-circular-inheritance)',
|
|
3646
|
+
});
|
|
3647
|
+
break;
|
|
3648
|
+
default:
|
|
3649
|
+
// Exhaustive guard: unhandled error codes from _materialWalk
|
|
3650
|
+
// surface an internal assertion to avoid silent continuation.
|
|
3651
|
+
worldInternal._routeError(err, {
|
|
3652
|
+
severity: Severity.Error,
|
|
3653
|
+
systemName: `RenderSystem.extract (_materialWalk: ${err.code})`,
|
|
3654
|
+
});
|
|
3655
|
+
}
|
|
3656
|
+
continue;
|
|
3657
|
+
}
|
|
3658
|
+
const resolved = resolvedResult.value;
|
|
3659
|
+
const allPasses = resolved.passes;
|
|
3660
|
+
const firstPassShader =
|
|
3661
|
+
allPasses.length > 0
|
|
3662
|
+
? runtimeMaterialShaderId(allPasses[0]?.program.module, allPasses[0]?.name)
|
|
3663
|
+
: undefined;
|
|
3664
|
+
const pv = materialValuesToLinearRuntime(
|
|
3665
|
+
resolved.values,
|
|
3666
|
+
materialColorParameterSchema(resolved.parameters ?? [], firstPassShader, assets),
|
|
3667
|
+
resolved.colorSpace,
|
|
3668
|
+
) as Readonly<Record<string, unknown>>;
|
|
3669
|
+
|
|
3670
|
+
const baseColorPv = pv.baseColor as readonly number[] | undefined;
|
|
3671
|
+
const baseColor = vec3.create(
|
|
3672
|
+
baseColorPv?.[0] ?? 1,
|
|
3673
|
+
baseColorPv?.[1] ?? 1,
|
|
3674
|
+
baseColorPv?.[2] ?? 1,
|
|
3675
|
+
);
|
|
3676
|
+
const metallicPv = typeof pv.metallic === 'number' ? pv.metallic : 0;
|
|
3677
|
+
const roughnessPv = typeof pv.roughness === 'number' ? pv.roughness : 0.5;
|
|
3678
|
+
const clearcoatPv = typeof pv.clearcoat === 'number' ? pv.clearcoat : 0;
|
|
3679
|
+
const clearcoatRoughnessPv =
|
|
3680
|
+
typeof pv.clearcoatRoughness === 'number' ? pv.clearcoatRoughness : 0.5;
|
|
3681
|
+
const specularTintPv = pv.specularTint as readonly number[] | undefined;
|
|
3682
|
+
const normalScalePv = materialNormalScale(pv);
|
|
3683
|
+
|
|
3684
|
+
const paramSnap: Record<string, number | number[] | string> = {};
|
|
3685
|
+
for (const [k, v] of Object.entries(pv)) {
|
|
3686
|
+
if (typeof v === 'number') paramSnap[k] = v;
|
|
3687
|
+
else if (typeof v === 'string') paramSnap[k] = v;
|
|
3688
|
+
else if (Array.isArray(v) && v.every((x) => typeof x === 'number')) {
|
|
3689
|
+
paramSnap[k] = v as number[];
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
const materialParamSchema = materialParametersToParamSchema(
|
|
3694
|
+
resolved.parameters ?? [],
|
|
3695
|
+
firstPassShader,
|
|
3696
|
+
);
|
|
3697
|
+
// feat-20260611-fox-skinning-vertex-attribute-chain M4 / w17 (D-5):
|
|
3698
|
+
// bidirectional Skin <-> pbr-skin material fail-fast at extract.
|
|
3699
|
+
// Skin component without a forgeax::pbr-skin first-pass material
|
|
3700
|
+
// would draw with a non-skin shader against the 18-float vertex
|
|
3701
|
+
// buffer (joints/weights bytes interpreted as garbage). Conversely
|
|
3702
|
+
// a forgeax::pbr-skin material against a 12-float (unskinned) mesh
|
|
3703
|
+
// would have @location(4)/@location(5) read uninitialized memory.
|
|
3704
|
+
// Both cases route through `_routeError` + `continue` so a single
|
|
3705
|
+
// misconfigured entity does NOT abort the whole frame's draw list
|
|
3706
|
+
// (charter P3 explicit failure + plan-decisions D-5 over `return err`).
|
|
3707
|
+
{
|
|
3708
|
+
const hasSkinSkel =
|
|
3709
|
+
hasSkin &&
|
|
3710
|
+
skinSkeletonView !== undefined &&
|
|
3711
|
+
skinSkeletonView !== undefined &&
|
|
3712
|
+
skinSkeletonView !== 0;
|
|
3713
|
+
const isPbrSkinMaterial = firstPassShader === 'forgeax::pbr-skin';
|
|
3714
|
+
if (hasSkinSkel && !isPbrSkinMaterial) {
|
|
3715
|
+
worldInternal._routeError(new SkinMaterialMismatchError(entity, firstPassShader), {
|
|
3716
|
+
severity: Severity.Error,
|
|
3717
|
+
systemName: 'RenderSystem.extract (skin-material-mismatch)',
|
|
3718
|
+
});
|
|
3719
|
+
continue;
|
|
3720
|
+
}
|
|
3721
|
+
if (isPbrSkinMaterial && fAssetHandleVal !== undefined && fAssetHandleVal !== 0) {
|
|
3722
|
+
const meshHandleForSkinCheck = toShared<'MeshAsset'>(fAssetHandleVal);
|
|
3723
|
+
const meshResForSkinCheck = resolveAssetHandle<MeshAsset>(
|
|
3724
|
+
world,
|
|
3725
|
+
meshHandleForSkinCheck,
|
|
3726
|
+
);
|
|
3727
|
+
if (meshResForSkinCheck.ok) {
|
|
3728
|
+
const meshAttrs = meshResForSkinCheck.value.attributes;
|
|
3729
|
+
const hasSkinIdx = meshAttrs.skinIndex !== undefined;
|
|
3730
|
+
const hasSkinWt = meshAttrs.skinWeight !== undefined;
|
|
3731
|
+
if (!hasSkinIdx || !hasSkinWt) {
|
|
3732
|
+
const missing: 'skinIndex' | 'skinWeight' | 'both' =
|
|
3733
|
+
!hasSkinIdx && !hasSkinWt ? 'both' : !hasSkinIdx ? 'skinIndex' : 'skinWeight';
|
|
3734
|
+
worldInternal._routeError(new MaterialSkinAttrMissingError(entity, missing), {
|
|
3735
|
+
severity: Severity.Error,
|
|
3736
|
+
systemName: 'RenderSystem.extract (material-skin-attr-missing)',
|
|
3737
|
+
});
|
|
3738
|
+
continue;
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3743
|
+
// feat-20260625-refactor-sprite-as-transparent-mesh M3 / w12 (D-3):
|
|
3744
|
+
// sprite materials now flow through the same generic paramSchema-
|
|
3745
|
+
// driven extract path PBR / unlit use. The narrow `forgeax::sprite`
|
|
3746
|
+
// exception block below covers exactly 2 plan-authorised cases:
|
|
3747
|
+
// 1. SpriteRegionOverride per-entity region displacement (Q4=a)
|
|
3748
|
+
// 2. flipX / flipY -> region fold (plan-strategy D-8)
|
|
3749
|
+
// No legacy values field-name shim; demos and SpriteParamValues
|
|
3750
|
+
// are UBO-aligned (no `texture` / `baseColor` / `pivot` / `slices`
|
|
3751
|
+
// / `sliceMode` keys reaching this code path). AGENTS.md §Change
|
|
3752
|
+
// stance: "no shim layer, no v1/v2 dual-path".
|
|
3753
|
+
//
|
|
3754
|
+
// feat-20260624 M1' / t6: `'forgeax::sprite-lit'` walks the same
|
|
3755
|
+
// sprite-family vertex path (VsOut byte-identical, paramSchema
|
|
3756
|
+
// mirror) so the SAME 2 folds apply — extending `isSprite` to
|
|
3757
|
+
// cover both shader ids keeps the narrowing-point count at 1
|
|
3758
|
+
// (plan-strategy §1.6 + D-1: mirror sprite, no new branch).
|
|
3759
|
+
const isSprite =
|
|
3760
|
+
firstPassShader === 'forgeax::sprite' || firstPassShader === 'forgeax::sprite-lit';
|
|
3761
|
+
|
|
3762
|
+
// feat-20260613-material-paramschema-driven-binding M4 / w23
|
|
3763
|
+
// (D-5 graceful): paramSchema-driven texture-field validation.
|
|
3764
|
+
// For each handle-shaped paramValue (typeof === 'number'),
|
|
3765
|
+
// verify it actually points at a registered texture asset
|
|
3766
|
+
// when the field is declared as a texture in the shader's
|
|
3767
|
+
// paramSchema; mis-typed handles (e.g. a scalar f32 stored as
|
|
3768
|
+
// int 0 the M4 / w22 graceful fallback resolved to a wrong
|
|
3769
|
+
// sub-asset) are dropped here so the record stage falls back
|
|
3770
|
+
// to MISSING_TEXTURE_HANDLE (default white) without raising.
|
|
3771
|
+
const validateTextureHandle = (
|
|
3772
|
+
fieldName: string,
|
|
3773
|
+
raw: unknown,
|
|
3774
|
+
): Handle<'TextureAsset', 'shared'> | undefined => {
|
|
3775
|
+
// feat-20260614 M8 (D-19): a string value is an embedded texture
|
|
3776
|
+
// GUID; resolve it to a column handle via catalog + allocSharedRef
|
|
3777
|
+
// before validation. A number is an already-minted column handle.
|
|
3778
|
+
let handle: Handle<'TextureAsset', 'shared'>;
|
|
3779
|
+
const textureRef = materialTextureRef(raw);
|
|
3780
|
+
const textureGuid = assetReferenceText(textureRef);
|
|
3781
|
+
if (textureGuid !== undefined) {
|
|
3782
|
+
if (assets === null || assets === undefined) return undefined;
|
|
3783
|
+
// M4: intern so the GUID mints one stable handle per World
|
|
3784
|
+
// instead of a fresh slot every frame (GPU residency relies on
|
|
3785
|
+
// a stable handleSlot). onLastRelease -> gpuStore.evictTexture.
|
|
3786
|
+
const interned = internSharedRefFromGuid(
|
|
3787
|
+
world,
|
|
3788
|
+
assets,
|
|
3789
|
+
textureGuid,
|
|
3790
|
+
'TextureAsset',
|
|
3791
|
+
);
|
|
3792
|
+
if (interned === undefined) return undefined;
|
|
3793
|
+
handle = interned;
|
|
3794
|
+
} else if (typeof textureRef === 'number') {
|
|
3795
|
+
handle = toShared<'TextureAsset'>(textureRef);
|
|
3796
|
+
} else {
|
|
3797
|
+
return undefined;
|
|
3798
|
+
}
|
|
3799
|
+
if (assets === null || assets === undefined) return handle;
|
|
3800
|
+
const declaredFields = materialTextureFields(
|
|
3801
|
+
firstPassShader,
|
|
3802
|
+
materialParamSchema.length > 0
|
|
3803
|
+
? derive(materialParamSchema).textureFieldNames
|
|
3804
|
+
: firstPassShader !== undefined
|
|
3805
|
+
? assets.materialShaderTextureFieldNames(firstPassShader)
|
|
3806
|
+
: undefined,
|
|
3807
|
+
);
|
|
3808
|
+
// Shader not registered (R-4 cross-worktree path) -> trust the
|
|
3809
|
+
// raw handle and let the record stage / GPU layer surface each
|
|
3810
|
+
// mismatch via MISSING_TEXTURE_HANDLE.
|
|
3811
|
+
if (declaredFields === undefined) return handle;
|
|
3812
|
+
// Field is not declared as a texture by the shader -> the
|
|
3813
|
+
// loader's "try every int" fallback misclassified a scalar;
|
|
3814
|
+
// drop the slot so the record stage uses the default white.
|
|
3815
|
+
if (
|
|
3816
|
+
!declaredFields.has(fieldName) &&
|
|
3817
|
+
!isEngineInjectedTextureField(firstPassShader, fieldName)
|
|
3818
|
+
) {
|
|
3819
|
+
return undefined;
|
|
3820
|
+
}
|
|
3821
|
+
// Field declared as texture: verify the handle's asset kind.
|
|
3822
|
+
const assetRes = resolveAssetHandle(world, handle);
|
|
3823
|
+
if (!assetRes.ok) return undefined;
|
|
3824
|
+
const kind = (assetRes.value as { kind?: string }).kind;
|
|
3825
|
+
if (kind !== 'texture') return undefined;
|
|
3826
|
+
return handle;
|
|
3827
|
+
};
|
|
3828
|
+
// feat-20260614 M8 (D-19): resolve a sampler / texture paramValue
|
|
3829
|
+
// that may be an embedded GUID string (catalog + allocSharedRef) or
|
|
3830
|
+
// an already-minted column handle (number passthrough).
|
|
3831
|
+
const resolveParamHandle = <B extends string>(
|
|
3832
|
+
raw: unknown,
|
|
3833
|
+
brand: B,
|
|
3834
|
+
): Handle<B, 'shared'> | undefined => {
|
|
3835
|
+
const value = materialTextureRef(raw);
|
|
3836
|
+
if (typeof value === 'number') return toShared<B>(value);
|
|
3837
|
+
const guid = assetReferenceText(value);
|
|
3838
|
+
if (guid !== undefined) {
|
|
3839
|
+
if (assets === null || assets === undefined) return undefined;
|
|
3840
|
+
// M4: intern the GUID -> column-handle resolution (one stable
|
|
3841
|
+
// handle per (world, guid, brand), reused across frames).
|
|
3842
|
+
return internSharedRefFromGuid(world, assets, guid, brand);
|
|
3843
|
+
}
|
|
3844
|
+
return undefined;
|
|
3845
|
+
};
|
|
3846
|
+
// feat-20260621-learn-render-5-5-parallax M2 / w7 (D-3): iterate the
|
|
3847
|
+
// shader's derive(paramSchema).textureFieldNames SSOT so the Nth
|
|
3848
|
+
// user-region texture (e.g. parallax heightTexture) is validated +
|
|
3849
|
+
// carried, replacing the hardcoded 3-field list. validateTextureHandle
|
|
3850
|
+
// already drops fields a shader doesn't declare as a texture.
|
|
3851
|
+
const userRegionFields =
|
|
3852
|
+
materialTextureFields(
|
|
3853
|
+
firstPassShader,
|
|
3854
|
+
materialParamSchema.length > 0
|
|
3855
|
+
? derive(materialParamSchema).textureFieldNames
|
|
3856
|
+
: firstPassShader !== undefined && assets !== null && assets !== undefined
|
|
3857
|
+
? assets.materialShaderTextureFieldNames(firstPassShader)
|
|
3858
|
+
: undefined,
|
|
3859
|
+
) ?? BUILTIN_USER_REGION_TEXTURE_FIELDS;
|
|
3860
|
+
const textureHandles = new Map<string, Handle<'TextureAsset', 'shared'>>();
|
|
3861
|
+
const videoTextureFields = new Map<string, Handle<'VideoAsset', 'shared'>>();
|
|
3862
|
+
for (const field of userRegionFields) {
|
|
3863
|
+
// D-5: a video-kind paramValue routes to the transient path
|
|
3864
|
+
// (videoTextureFields), NOT validateTextureHandle (which drops
|
|
3865
|
+
// kind!=='texture', the R-7 silent-fail path). Static fields fall
|
|
3866
|
+
// through to validateTextureHandle unchanged.
|
|
3867
|
+
const videoHandle =
|
|
3868
|
+
assets !== null && assets !== undefined
|
|
3869
|
+
? resolveVideoFieldHandle(pv[field], world, assets)
|
|
3870
|
+
: undefined;
|
|
3871
|
+
if (videoHandle !== undefined) {
|
|
3872
|
+
videoTextureFields.set(field, videoHandle);
|
|
3873
|
+
continue;
|
|
3874
|
+
}
|
|
3875
|
+
const handle = validateTextureHandle(field, pv[field]);
|
|
3876
|
+
if (handle !== undefined) textureHandles.set(field, handle);
|
|
3877
|
+
}
|
|
3878
|
+
const baseColorTextureHandle = textureHandles.get('baseColorTexture');
|
|
3879
|
+
const metallicRoughnessTextureHandle = textureHandles.get('metallicRoughnessTexture');
|
|
3880
|
+
const normalTextureHandle = textureHandles.get('normalTexture');
|
|
3881
|
+
const samplerHandles = collectMaterialTextureSamplers(pv, (value) =>
|
|
3882
|
+
resolveParamHandle(materialTextureRef(value), 'SamplerAsset'),
|
|
3883
|
+
);
|
|
3884
|
+
const emissiveTextureHandle = validateTextureHandle(
|
|
3885
|
+
'emissiveTexture',
|
|
3886
|
+
pv.emissiveTexture,
|
|
3887
|
+
);
|
|
3888
|
+
const occlusionTextureHandle = validateTextureHandle(
|
|
3889
|
+
'occlusionTexture',
|
|
3890
|
+
pv.occlusionTexture,
|
|
3891
|
+
);
|
|
3892
|
+
const textureCoordinates = collectMaterialTextureCoordinates(pv);
|
|
3893
|
+
const emissivePv = pv.emissive as readonly number[] | undefined;
|
|
3894
|
+
// feat-20260625 M2 / w6: first-pass transparency flag folds into
|
|
3895
|
+
// MaterialSnapshot.transparent so the record stage can drive the
|
|
3896
|
+
// LDR split + premultiplied-alpha blend decision without
|
|
3897
|
+
// re-reading passes[]. feat-20260626-collapse M2: derive from
|
|
3898
|
+
// `passes[0].renderState.blend !== undefined` (blend presence is
|
|
3899
|
+
// the SSOT after MaterialPass.transparent was dropped).
|
|
3900
|
+
// Result is plain boolean (always defined here) — written as-is
|
|
3901
|
+
// into the snapshot (`boolean | undefined` field, see L759).
|
|
3902
|
+
const firstPassTransparent: boolean = allPasses[0]?.renderState?.blend !== undefined;
|
|
3903
|
+
|
|
3904
|
+
// feat-20260625-refactor-sprite-as-transparent-mesh M3 / w12 (D-8):
|
|
3905
|
+
// narrow `forgeax::sprite` extract block --- folds the legacy user
|
|
3906
|
+
// values format (flipX / flipY / slices / sliceMode + free
|
|
3907
|
+
// region / pivot) into the UBO-aligned paramSnapshot vec4 fields
|
|
3908
|
+
// (region / pivotAndSize / slicesAndMode + colorTint). Also folds
|
|
3909
|
+
// per-entity SpriteRegionOverride (Q4=a). After this block the
|
|
3910
|
+
// generic else branch picks up the snapshot via the same writer
|
|
3911
|
+
// path PBR / unlit use; no more shadingModel='sprite' arm, no
|
|
3912
|
+
// spriteFields POD (AC-02 / AC-07: extract has exactly 2 hard
|
|
3913
|
+
// `forgeax::sprite` checks --- this fold + the slices mesh swap on
|
|
3914
|
+
// the record side).
|
|
3915
|
+
if (isSprite) {
|
|
3916
|
+
// SpriteRegionOverride: per-entity per-frame region displacement.
|
|
3917
|
+
let overrideRegion: readonly [number, number, number, number] | undefined;
|
|
3918
|
+
if (hasSpriteRegionOverride) {
|
|
3919
|
+
const overrideView = worldInternal._getArrayView(
|
|
3920
|
+
entity,
|
|
3921
|
+
SpriteRegionOverride,
|
|
3922
|
+
'region',
|
|
3923
|
+
) as Float32Array | undefined;
|
|
3924
|
+
if (overrideView !== undefined && overrideView.length >= 4) {
|
|
3925
|
+
overrideRegion = [
|
|
3926
|
+
overrideView[0] ?? 0,
|
|
3927
|
+
overrideView[1] ?? 0,
|
|
3928
|
+
overrideView[2] ?? 1,
|
|
3929
|
+
overrideView[3] ?? 1,
|
|
3930
|
+
];
|
|
3931
|
+
}
|
|
3932
|
+
}
|
|
3933
|
+
// Region resolution priority: SpriteRegionOverride > paramSnapshot.
|
|
3934
|
+
// region (UBO-aligned user input) > [0,0,1,1] identity.
|
|
3935
|
+
const regionPv = paramSnap.region as readonly number[] | undefined;
|
|
3936
|
+
let regionX = overrideRegion?.[0] ?? regionPv?.[0] ?? 0;
|
|
3937
|
+
let regionY = overrideRegion?.[1] ?? regionPv?.[1] ?? 0;
|
|
3938
|
+
let regionZ = overrideRegion?.[2] ?? regionPv?.[2] ?? 1;
|
|
3939
|
+
let regionW = overrideRegion?.[3] ?? regionPv?.[3] ?? 1;
|
|
3940
|
+
// flipX / flipY fold into region (D-8): the shader does
|
|
3941
|
+
// `uv * region.zw + region.xy`, so flipping along U is a sign
|
|
3942
|
+
// negation of region.z plus an origin offset.
|
|
3943
|
+
const flipXPv = typeof pv.flipX === 'number' ? pv.flipX : 0;
|
|
3944
|
+
const flipYPv = typeof pv.flipY === 'number' ? pv.flipY : 0;
|
|
3945
|
+
if (flipXPv !== 0) {
|
|
3946
|
+
regionX += regionZ;
|
|
3947
|
+
regionZ = -regionZ;
|
|
3948
|
+
}
|
|
3949
|
+
if (flipYPv !== 0) {
|
|
3950
|
+
regionY += regionW;
|
|
3951
|
+
regionW = -regionW;
|
|
3952
|
+
}
|
|
3953
|
+
paramSnap.region = [regionX, regionY, regionZ, regionW];
|
|
3954
|
+
// Guard: slicesAndMode must be present and zero for non-9-slice
|
|
3955
|
+
// sprites so the record-stage UBO writer (applyParamSnapshotToUbo)
|
|
3956
|
+
// writes [0,0,0,0] at offset 48 instead of leaving the
|
|
3957
|
+
// buildPbrMaterialUboPayload PBR baseline (e.g. occlusionStrength=1
|
|
3958
|
+
// at that slot). A non-zero slicesAndMode trips `useSlices=true`
|
|
3959
|
+
// in sprite.wgsl, which degenerates HANDLE_QUAD geometry → invisible.
|
|
3960
|
+
if (!('slicesAndMode' in paramSnap)) {
|
|
3961
|
+
(paramSnap as Record<string, unknown>).slicesAndMode = [0, 0, 0, 0];
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3965
|
+
// Generic materialShaderId snapshot --- sprite included now flows
|
|
3966
|
+
// through this single branch (plan-strategy D-3 / AC-01 / AC-02 /
|
|
3967
|
+
// AC-07). The sprite block above only writes paramSnap.region (D-8
|
|
3968
|
+
// SpriteRegionOverride + flip fold); the rest of the UBO is filled
|
|
3969
|
+
// by the same paramSchema-driven path PBR / unlit use.
|
|
3970
|
+
materialSnap = {
|
|
3971
|
+
baseColor,
|
|
3972
|
+
metallic: metallicPv,
|
|
3973
|
+
roughness: roughnessPv,
|
|
3974
|
+
clearcoat: clearcoatPv,
|
|
3975
|
+
clearcoatRoughness: clearcoatRoughnessPv,
|
|
3976
|
+
...(specularTintPv !== undefined && {
|
|
3977
|
+
specularTint: [
|
|
3978
|
+
specularTintPv[0] ?? 1,
|
|
3979
|
+
specularTintPv[1] ?? 1,
|
|
3980
|
+
specularTintPv[2] ?? 1,
|
|
3981
|
+
] as readonly [number, number, number],
|
|
3982
|
+
}),
|
|
3983
|
+
normalScale: normalScalePv,
|
|
3984
|
+
materialShaderId: firstPassShader,
|
|
3985
|
+
materialHandle: handleRaw,
|
|
3986
|
+
renderState: pipelineRenderState(allPasses[0]?.renderState),
|
|
3987
|
+
paramSnapshot: paramSnap,
|
|
3988
|
+
...(materialParamSchema.length > 0 && { materialParamSchema }),
|
|
3989
|
+
...(textureCoordinates.size > 0 && { textureCoordinates }),
|
|
3990
|
+
...(samplerHandles.size > 0 && { samplerHandles }),
|
|
3991
|
+
...(textureHandles.size > 0 && { textureHandles }),
|
|
3992
|
+
...(videoTextureFields.size > 0 && { videoTextureFields }),
|
|
3993
|
+
...(baseColorTextureHandle !== undefined && {
|
|
3994
|
+
baseColorTexture: baseColorTextureHandle,
|
|
3995
|
+
}),
|
|
3996
|
+
...(metallicRoughnessTextureHandle !== undefined && {
|
|
3997
|
+
metallicRoughnessTexture: metallicRoughnessTextureHandle,
|
|
3998
|
+
}),
|
|
3999
|
+
...(normalTextureHandle !== undefined && { normalTexture: normalTextureHandle }),
|
|
4000
|
+
...(emissivePv !== undefined && {
|
|
4001
|
+
emissive: [emissivePv[0] ?? 0, emissivePv[1] ?? 0, emissivePv[2] ?? 0] as readonly [
|
|
4002
|
+
number,
|
|
4003
|
+
number,
|
|
4004
|
+
number,
|
|
4005
|
+
],
|
|
4006
|
+
}),
|
|
4007
|
+
...(typeof pv.emissiveIntensity === 'number' && {
|
|
4008
|
+
emissiveIntensity: pv.emissiveIntensity,
|
|
4009
|
+
}),
|
|
4010
|
+
...(emissiveTextureHandle !== undefined && {
|
|
4011
|
+
emissiveTexture: emissiveTextureHandle,
|
|
4012
|
+
}),
|
|
4013
|
+
...(occlusionTextureHandle !== undefined && {
|
|
4014
|
+
occlusionTexture: occlusionTextureHandle,
|
|
4015
|
+
}),
|
|
4016
|
+
...(typeof pv.occlusionStrength === 'number' && {
|
|
4017
|
+
occlusionStrength: pv.occlusionStrength,
|
|
4018
|
+
}),
|
|
4019
|
+
transparent: firstPassTransparent,
|
|
4020
|
+
};
|
|
4021
|
+
|
|
4022
|
+
if (!hasSpriteRegionOverride && !hasSkin) {
|
|
4023
|
+
const stored = storeMaterialSnapshot(
|
|
4024
|
+
materialSnapshotCache,
|
|
4025
|
+
handleRaw,
|
|
4026
|
+
materialSnap,
|
|
4027
|
+
allPasses,
|
|
4028
|
+
asset,
|
|
4029
|
+
assets,
|
|
4030
|
+
);
|
|
4031
|
+
if (stored.crossFrameSafe) persistentMaterialSnapshotCache?.set(handleRaw, stored);
|
|
4032
|
+
}
|
|
4033
|
+
|
|
4034
|
+
// Build dispatch entries from resolved passes.
|
|
4035
|
+
if (isRenderable) {
|
|
4036
|
+
appendMaterialDispatchEntries(
|
|
4037
|
+
pendingDispatch,
|
|
4038
|
+
allPasses,
|
|
4039
|
+
entity,
|
|
4040
|
+
handleRaw,
|
|
4041
|
+
renderables.length,
|
|
4042
|
+
layerVal,
|
|
4043
|
+
paramSnap,
|
|
4044
|
+
);
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
|
|
4050
|
+
// feat-20260609 M2/M5 corrective fixup: default-material entities
|
|
4051
|
+
// (handleRaw===0 / case-B MeshRenderer{data:{}}) must produce
|
|
4052
|
+
// ShadowCaster dispatch entries so the shadow pass includes them.
|
|
4053
|
+
// The pre-existing logic only builds dispatch entries from
|
|
4054
|
+
// resolved material assets; defaultMaterialSnapshot() (mid-grey unlit)
|
|
4055
|
+
// left dispatch empty, causing shadow-m2/m3 test failures.
|
|
4056
|
+
// Requirements §10.5: shadow-casting is default behaviour, opt-out
|
|
4057
|
+
// via castShadow:false. The default material has no opt-out, so
|
|
4058
|
+
// it casts shadows.
|
|
4059
|
+
//
|
|
4060
|
+
// CHARTER NOTE (feat-20260609 T-005-a): the URP literals
|
|
4061
|
+
// `LightMode: 'ShadowCaster'` / `LightMode: 'Forward'` below are a
|
|
4062
|
+
// local URP-bridge — they mirror what `Materials.unlit({ castShadow:
|
|
4063
|
+
// true })` produces at the asset layer. The default-material
|
|
4064
|
+
// handle=0 path bypasses asset registration, so we synthesize the
|
|
4065
|
+
// same dispatch shape inline. Follow-up cleanup (F-1 from
|
|
4066
|
+
// implement-review R1): thread default materials through the
|
|
4067
|
+
// Materials factory so this block can call into the shared
|
|
4068
|
+
// passes[] producer.
|
|
4069
|
+
// tweak-20260701 M1: `materialSnap.shadingModel === 'unlit'` removed —
|
|
4070
|
+
// for handleRaw===0, defaultMaterialSnapshot() was always unlit
|
|
4071
|
+
// (the shadingModel check was a tautology); the isRenderable &&
|
|
4072
|
+
// handleRaw===0 guard alone preserves the exact same dispatch shape.
|
|
4073
|
+
if (isRenderable && handleRaw === 0) {
|
|
4074
|
+
const shadowCasterTags: Record<string, string> = { LightMode: 'ShadowCaster' };
|
|
4075
|
+
const nextRenderableIndex = renderables.length;
|
|
4076
|
+
// M2.5: stage into pendingDispatch; flushed at the renderable push
|
|
4077
|
+
// site below only when the entity survives cull.
|
|
4078
|
+
pendingDispatch.push({
|
|
4079
|
+
entityIndex: entity,
|
|
4080
|
+
materialHandle: 0,
|
|
4081
|
+
renderableIndex: nextRenderableIndex,
|
|
4082
|
+
passIndex: 0,
|
|
4083
|
+
queue: 2000,
|
|
4084
|
+
layer: layerVal,
|
|
4085
|
+
tags: shadowCasterTags,
|
|
4086
|
+
renderState: undefined,
|
|
4087
|
+
defines: undefined,
|
|
4088
|
+
vertexEntry: 'vs_main',
|
|
4089
|
+
fragmentEntry: undefined,
|
|
4090
|
+
materialShaderId: 'forgeax::default-shadow-caster',
|
|
4091
|
+
paramSnapshot: {},
|
|
4092
|
+
});
|
|
4093
|
+
// Also add a Forward pass entry so the entity renders in the
|
|
4094
|
+
// main scene pass (mirrors Materials.unlit default).
|
|
4095
|
+
appendMaterialDispatchEntries(
|
|
4096
|
+
pendingDispatch,
|
|
4097
|
+
[DEFAULT_FORWARD_PASS],
|
|
4098
|
+
entity,
|
|
4099
|
+
0,
|
|
4100
|
+
nextRenderableIndex,
|
|
4101
|
+
layerVal,
|
|
4102
|
+
{},
|
|
4103
|
+
1,
|
|
4104
|
+
);
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
if (isRenderable) {
|
|
4108
|
+
// feat-20260612 M2 / m2-6: Skin + Instances coexistence + per-joint
|
|
4109
|
+
// dangling fail-fast + real palette slice allocation. Replaces the
|
|
4110
|
+
// T-21 placeholder ({0,0} discriminator-only sentinel) with full
|
|
4111
|
+
// resolve / validate / write chain (D-9 reset already fired at
|
|
4112
|
+
// extractFrame entry; per-entity allocate + writeJointPalette here).
|
|
4113
|
+
let skinSlice: SkinPaletteSlice | undefined;
|
|
4114
|
+
if (hasSkin) {
|
|
4115
|
+
const skeletonHandleRaw = skinSkeletonView;
|
|
4116
|
+
if (
|
|
4117
|
+
skeletonHandleRaw !== undefined &&
|
|
4118
|
+
skeletonHandleRaw !== 0 &&
|
|
4119
|
+
assets !== undefined &&
|
|
4120
|
+
assets !== null
|
|
4121
|
+
) {
|
|
4122
|
+
// Skin + Instances coexistence is forbidden (D-10).
|
|
4123
|
+
if (hasInstances) {
|
|
4124
|
+
worldInternal._routeError(new SkinInstancesCoexistForbiddenError(entity), {
|
|
4125
|
+
severity: Severity.Error,
|
|
4126
|
+
systemName: 'RenderSystem.extract (skin-instances-coexist)',
|
|
4127
|
+
});
|
|
4128
|
+
|
|
4129
|
+
continue;
|
|
4130
|
+
}
|
|
4131
|
+
// (a) Resolve skeleton asset; on failure -> skeleton-resolve-failed.
|
|
4132
|
+
const skeletonHandle = toShared<'SkeletonAsset'>(skeletonHandleRaw);
|
|
4133
|
+
const skeletonRes = resolveAssetHandle<SkeletonAsset>(world, skeletonHandle);
|
|
4134
|
+
if (!skeletonRes.ok || skeletonRes.value.kind !== 'skeleton') {
|
|
4135
|
+
worldInternal._routeError(new SkeletonResolveFailedError(entity, skeletonHandleRaw), {
|
|
4136
|
+
severity: Severity.Error,
|
|
4137
|
+
systemName: 'RenderSystem.extract (skeleton-resolve-failed)',
|
|
4138
|
+
});
|
|
4139
|
+
continue;
|
|
4140
|
+
}
|
|
4141
|
+
const skeleton = skeletonRes.value;
|
|
4142
|
+
// (b) Reuse the Skin row already read for its skeleton handle;
|
|
4143
|
+
// the old path repeated a second whole-row world.get here.
|
|
4144
|
+
const skinJoints = skin?.joints;
|
|
4145
|
+
if (skinJoints === undefined) continue;
|
|
4146
|
+
const jointsLength = skinJoints.length;
|
|
4147
|
+
if (jointsLength !== skeleton.jointCount) {
|
|
4148
|
+
worldInternal._routeError(
|
|
4149
|
+
new JointCountMismatchError(entity, skeleton.jointCount, jointsLength),
|
|
4150
|
+
{
|
|
4151
|
+
severity: Severity.Error,
|
|
4152
|
+
systemName: 'RenderSystem.extract (joint-count-mismatch)',
|
|
4153
|
+
},
|
|
4154
|
+
);
|
|
4155
|
+
continue;
|
|
4156
|
+
}
|
|
4157
|
+
// (c) Resolve only each joint's Transform.world column. The
|
|
4158
|
+
// transient view is consumed before a structural mutation and
|
|
4159
|
+
// therefore preserves the same dangling-joint behavior without
|
|
4160
|
+
// constructing every other Transform field.
|
|
4161
|
+
// Build mat4 list eagerly so write happens once per entity (no
|
|
4162
|
+
// half-written slice on dangling).
|
|
4163
|
+
const jointWorlds = new Array<Mat4>(skeleton.jointCount);
|
|
4164
|
+
let jointDangling = -1;
|
|
4165
|
+
for (let jIdx = 0; jIdx < skeleton.jointCount; jIdx++) {
|
|
4166
|
+
const jointEntityRaw = skinJoints[jIdx] ?? 0;
|
|
4167
|
+
const jointEntity = jointEntityRaw as EntityHandle;
|
|
4168
|
+
const jointWorld = worldInternal._getArrayView(jointEntity, Transform, 'world');
|
|
4169
|
+
if (jointWorld === undefined) {
|
|
4170
|
+
jointDangling = jIdx;
|
|
4171
|
+
break;
|
|
4172
|
+
}
|
|
4173
|
+
// The allocator consumes the canonical Mat4 brand. Copy the
|
|
4174
|
+
// column view into that owner-created value so the ECS storage
|
|
4175
|
+
// view never crosses the math brand boundary by assertion.
|
|
4176
|
+
const jointWorldMat = mat4.create();
|
|
4177
|
+
jointWorldMat.set(jointWorld);
|
|
4178
|
+
jointWorlds[jIdx] = jointWorldMat;
|
|
4179
|
+
}
|
|
4180
|
+
if (jointDangling >= 0) {
|
|
4181
|
+
worldInternal._routeError(new JointEntityDanglingError(entity, jointDangling), {
|
|
4182
|
+
severity: Severity.Error,
|
|
4183
|
+
systemName: 'RenderSystem.extract (joint-entity-dangling)',
|
|
4184
|
+
});
|
|
4185
|
+
continue;
|
|
4186
|
+
}
|
|
4187
|
+
// (d) Slice the IBM flat Float32Array into per-joint Float32Arrays.
|
|
4188
|
+
// skeleton.inverseBindMatrices length === jointCount * 16.
|
|
4189
|
+
const ibmFlat = skeleton.inverseBindMatrices;
|
|
4190
|
+
const ibms: Float32Array[] = new Array<Float32Array>(skeleton.jointCount);
|
|
4191
|
+
for (let jIdx = 0; jIdx < skeleton.jointCount; jIdx++) {
|
|
4192
|
+
ibms[jIdx] = ibmFlat.subarray(jIdx * 16, jIdx * 16 + 16);
|
|
4193
|
+
}
|
|
4194
|
+
// (e) Allocate slice + write palette via the allocator (D-9 reset
|
|
4195
|
+
// already fired at extractFrame entry). When the pipelineState
|
|
4196
|
+
// surface is absent (test fixtures that pass undefined) the
|
|
4197
|
+
// hasSkin segment is skipped silently — bind-pose equivalent.
|
|
4198
|
+
if (skinPaletteAllocator !== null) {
|
|
4199
|
+
const slice = skinPaletteAllocator.allocateSlice(skeleton.jointCount);
|
|
4200
|
+
skinPaletteAllocator.writeJointPalette(slice, ibms, jointWorlds);
|
|
4201
|
+
skinSlice = {
|
|
4202
|
+
jointCount: slice.jointCount,
|
|
4203
|
+
byteOffset: slice.byteOffset,
|
|
4204
|
+
buffer: slice.buffer,
|
|
4205
|
+
};
|
|
4206
|
+
}
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
|
|
4210
|
+
// feat-20260601 D-3: read the resolved world mat4 (propagateTransforms
|
|
4211
|
+
// output) straight from the Transform.world column array view. The
|
|
4212
|
+
// record stage copies these 16 floats into the mesh SSBO with zero
|
|
4213
|
+
// per-snapshot `mat4.compose` (AC-07). A stale slot (generation gone)
|
|
4214
|
+
// skips the renderable, mirroring the Instances dangling-row sweep.
|
|
4215
|
+
// tweak-20260611 M1 / K-3: `_getArrayView` call survives untouched;
|
|
4216
|
+
// only the `entity` source switched to `bundle.Entity.self[i]`.
|
|
4217
|
+
const worldView = worldInternal._getArrayView(entity, Transform, 'world');
|
|
4218
|
+
if (worldView === undefined) continue;
|
|
4219
|
+
const worldMat = new Float32Array(worldView);
|
|
4220
|
+
const transformSnap: TransformSnapshot = { world: worldMat };
|
|
4221
|
+
// feat-20260608 M5 amend / w11-a: per-submesh `materials[]` array
|
|
4222
|
+
// aligned 1-1 with `MeshAsset.submeshes[]`. materials[0] === the
|
|
4223
|
+
// representative entity-level snapshot already built; materials[i>=1]
|
|
4224
|
+
// are resolved via `resolveMaterialSnapshot` (a non-sprite, single-
|
|
4225
|
+
// pass-equivalent resolver — sprite per-submesh is OOS-1). When the
|
|
4226
|
+
// entity has no materialsView (case-B sentinel) the array is a single
|
|
4227
|
+
// mid-grey default mirroring the legacy single-material path so the
|
|
4228
|
+
// record stage's per-submesh UBO upload loop trivially writes one
|
|
4229
|
+
// slot, no special branch.
|
|
4230
|
+
const materialsArr: MaterialSnapshot[] = [materialSnap];
|
|
4231
|
+
if (assets !== undefined && assets !== null) {
|
|
4232
|
+
for (let mi = 1; mi < materialHandles.length; mi++) {
|
|
4233
|
+
const subHandle = materialHandles[mi] ?? 0;
|
|
4234
|
+
const cachedSubmaterial = materialSnapshotCache.get(subHandle);
|
|
4235
|
+
materialsArr.push(
|
|
4236
|
+
cachedSubmaterial?.snapshot ??
|
|
4237
|
+
resolveMaterialSnapshot(
|
|
4238
|
+
subHandle,
|
|
4239
|
+
world,
|
|
4240
|
+
assets,
|
|
4241
|
+
materialSnapshotCache,
|
|
4242
|
+
persistentMaterialSnapshotCache,
|
|
4243
|
+
),
|
|
4244
|
+
);
|
|
4245
|
+
}
|
|
4246
|
+
}
|
|
4247
|
+
if (isRenderable && assets !== undefined && assets !== null) {
|
|
4248
|
+
for (let mi = 1; mi < materialHandles.length; mi++) {
|
|
4249
|
+
const subHandle = materialHandles[mi] ?? 0;
|
|
4250
|
+
if (subHandle === handleRaw) continue;
|
|
4251
|
+
const subEntry =
|
|
4252
|
+
materialSnapshotCache.get(subHandle) ??
|
|
4253
|
+
readPersistentMaterialSnapshot(persistentMaterialSnapshotCache, subHandle, assets);
|
|
4254
|
+
appendMaterialDispatchEntries(
|
|
4255
|
+
pendingDispatch,
|
|
4256
|
+
subEntry?.passes ?? (subHandle === 0 ? [DEFAULT_FORWARD_PASS] : []),
|
|
4257
|
+
entity,
|
|
4258
|
+
subHandle,
|
|
4259
|
+
renderables.length,
|
|
4260
|
+
layerVal,
|
|
4261
|
+
materialsArr[mi]?.paramSnapshot,
|
|
4262
|
+
);
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
// feat-20260625-sprite-instances-and-tilemap-terrain-static-batch M3 /
|
|
4267
|
+
// w10: SpriteInstances validation + snapshot materialisation.
|
|
4268
|
+
// Three structured EcsError fires at this single point (plan-strategy
|
|
4269
|
+
// D-6 "fail-fast at the render domain entry, not at ECS spawn-time"):
|
|
4270
|
+
let spriteInstancesSnap: SpriteInstancesSnapshot | undefined;
|
|
4271
|
+
if (hasSpriteInstances) {
|
|
4272
|
+
// (1) mutually exclusive with Instances (peers — pick one).
|
|
4273
|
+
if (hasInstances) {
|
|
4274
|
+
worldInternal._routeError(
|
|
4275
|
+
new SpriteInstancesMutuallyExclusiveWithInstancesError(entity),
|
|
4276
|
+
{
|
|
4277
|
+
severity: Severity.Error,
|
|
4278
|
+
systemName: 'RenderSystem.extract (sprite-instances-mutually-exclusive)',
|
|
4279
|
+
},
|
|
4280
|
+
);
|
|
4281
|
+
continue;
|
|
4282
|
+
}
|
|
4283
|
+
// (2) requires sprite shader — the per-instance UV region is
|
|
4284
|
+
// consumed by the sprite vertex shader path only (plan-strategy D-4
|
|
4285
|
+
// axis on sprite.wgsl). Post-collapse (PR #520): sprite is no longer
|
|
4286
|
+
// a `shadingModel` enum member; identification is via the first-pass
|
|
4287
|
+
// `materialShaderId === 'forgeax::sprite'` (OOS-1 path retained).
|
|
4288
|
+
//
|
|
4289
|
+
// feat-20260624 M1' / t6: `'forgeax::sprite-lit'` also walks the same
|
|
4290
|
+
// per-instance UV region vertex path (VsOut byte-identical, paramSchema
|
|
4291
|
+
// mirror); accept either shader id.
|
|
4292
|
+
if (
|
|
4293
|
+
materialSnap.materialShaderId !== 'forgeax::sprite' &&
|
|
4294
|
+
materialSnap.materialShaderId !== 'forgeax::sprite-lit'
|
|
4295
|
+
) {
|
|
4296
|
+
worldInternal._routeError(
|
|
4297
|
+
new SpriteInstancesRequiresSpriteShaderError(
|
|
4298
|
+
entity,
|
|
4299
|
+
materialSnap.materialShaderId ?? 'undefined',
|
|
4300
|
+
),
|
|
4301
|
+
{
|
|
4302
|
+
severity: Severity.Error,
|
|
4303
|
+
systemName: 'RenderSystem.extract (sprite-instances-requires-sprite-shader)',
|
|
4304
|
+
},
|
|
4305
|
+
);
|
|
4306
|
+
continue;
|
|
4307
|
+
}
|
|
4308
|
+
// (3) count mismatch — transforms.length / 16 === regions.length / 4
|
|
4309
|
+
// (transforms.length=0 + regions.length=0 is the zero-instance lawful
|
|
4310
|
+
// boundary; both derivations are 0 and equality holds, so no fire).
|
|
4311
|
+
const transforms = worldInternal._getArrayView(entity, SpriteInstances, 'transforms');
|
|
4312
|
+
const regions = worldInternal._getArrayView(entity, SpriteInstances, 'regions');
|
|
4313
|
+
if (transforms !== undefined && regions !== undefined) {
|
|
4314
|
+
const transformsLength = transforms.length;
|
|
4315
|
+
const regionsLength = regions.length;
|
|
4316
|
+
// Stride sanity: transforms must be mod 16, regions must be mod 4.
|
|
4317
|
+
// A stride violation expresses as a count mismatch under the
|
|
4318
|
+
// canonical derivation transforms/16 vs regions/4 — fire the
|
|
4319
|
+
// count-mismatch code (the same code carries detail.expectedStride).
|
|
4320
|
+
const tCount = transformsLength / 16;
|
|
4321
|
+
const rCount = regionsLength / 4;
|
|
4322
|
+
if (transformsLength % 16 !== 0 || regionsLength % 4 !== 0 || tCount !== rCount) {
|
|
4323
|
+
worldInternal._routeError(
|
|
4324
|
+
new SpriteInstancesCountMismatchError(transformsLength, regionsLength),
|
|
4325
|
+
{
|
|
4326
|
+
severity: Severity.Error,
|
|
4327
|
+
systemName: 'RenderSystem.extract (sprite-instances-count-mismatch)',
|
|
4328
|
+
},
|
|
4329
|
+
);
|
|
4330
|
+
continue;
|
|
4331
|
+
}
|
|
4332
|
+
// Validation passes — build the snapshot. transforms.length === 0
|
|
4333
|
+
// is lawful (zero-instance) and produces instanceCount=0; the
|
|
4334
|
+
// record stage skips drawIndexed when instanceCount===0.
|
|
4335
|
+
const transformsCopy = new Float32Array(transforms);
|
|
4336
|
+
const regionsCopy = new Float32Array(regions);
|
|
4337
|
+
spriteInstancesSnap = {
|
|
4338
|
+
transforms: transformsCopy,
|
|
4339
|
+
regions: regionsCopy,
|
|
4340
|
+
instanceCount: tCount,
|
|
4341
|
+
cacheKey: entity,
|
|
4342
|
+
archVersion,
|
|
4343
|
+
};
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
|
|
4347
|
+
let localAabb: Float32Array | undefined;
|
|
4348
|
+
let morph: MorphSnapshot | undefined;
|
|
4349
|
+
const assetHandleRaw = Math.round(fAssetHandle ?? 0);
|
|
4350
|
+
if (assetHandleRaw !== 0) {
|
|
4351
|
+
const meshRes = resolveAssetHandle(world, toShared<'MeshAsset'>(assetHandleRaw));
|
|
4352
|
+
if (meshRes.ok && meshRes.value.kind === 'mesh') {
|
|
4353
|
+
const meshAsset = meshRes.value as MeshAsset;
|
|
4354
|
+
morph = morphSnapshotFor(meshAsset, morphWeightsView);
|
|
4355
|
+
const meshAabb = meshAsset.aabb;
|
|
4356
|
+
// Morph targets can expand the authored bounds. Keep the
|
|
4357
|
+
// specialized lane conservative until a target-aware bounds
|
|
4358
|
+
// projection is available; a false-positive draw is preferable
|
|
4359
|
+
// to culling a valid deformed vertex.
|
|
4360
|
+
if (morph === undefined && hasFiniteOrderedLocalAabb(meshAabb)) {
|
|
4361
|
+
localAabb = new Float32Array(meshAabb);
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
}
|
|
4365
|
+
|
|
4366
|
+
const pointsLinesStyle: PointsLinesStyle | undefined =
|
|
4367
|
+
points !== undefined
|
|
4368
|
+
? (() => {
|
|
4369
|
+
const shape = pointShapeFromU32(points.shape);
|
|
4370
|
+
return shape === undefined
|
|
4371
|
+
? undefined
|
|
4372
|
+
: { kind: 'points' as const, sizePx: points.sizePx, shape };
|
|
4373
|
+
})()
|
|
4374
|
+
: lines === undefined
|
|
4375
|
+
? undefined
|
|
4376
|
+
: { kind: 'lines' as const, widthPx: lines.widthPx };
|
|
4377
|
+
const cullingLocalAabb = expandPointsLinesBounds(localAabb ?? [], pointsLinesStyle);
|
|
4378
|
+
const pointsLines =
|
|
4379
|
+
pointsLinesComponent === undefined
|
|
4380
|
+
? undefined
|
|
4381
|
+
: ({
|
|
4382
|
+
worldId: 0,
|
|
4383
|
+
entityKey: entity,
|
|
4384
|
+
component: pointsLinesComponent,
|
|
4385
|
+
meshHandle: assetHandleRaw,
|
|
4386
|
+
meshGeneration: assets?.catalogEpoch ?? 0,
|
|
4387
|
+
materialHandle: handleRaw,
|
|
4388
|
+
materialGeneration: assets?.catalogEpoch ?? 0,
|
|
4389
|
+
style: pointsLinesStyle,
|
|
4390
|
+
layer: layerVal,
|
|
4391
|
+
sortKey,
|
|
4392
|
+
visible: true,
|
|
4393
|
+
sourceBounds: cullingLocalAabb,
|
|
4394
|
+
viewport: { width: 0, height: 0, dpr: 1 },
|
|
4395
|
+
projection: identityProjection(),
|
|
4396
|
+
} satisfies PointsLinesRetainedSnapshot);
|
|
4397
|
+
|
|
4398
|
+
let nonIndexedFirst = 0;
|
|
4399
|
+
const gpuDrivenDraws = gpuDrivenSubmeshes.flatMap((submesh) => {
|
|
4400
|
+
const drawMaterial = materialsArr[submesh.materialSlot] ?? materialSnap;
|
|
4401
|
+
const first = gpuDrivenIndexed ? submesh.indexOffset : nonIndexedFirst;
|
|
4402
|
+
nonIndexedFirst += submesh.vertexCount;
|
|
4403
|
+
if (drawMaterial.transparent === true) return [];
|
|
4404
|
+
return [
|
|
4405
|
+
{
|
|
4406
|
+
kind: gpuDrivenIndexed ? ('indexed' as const) : ('non-indexed' as const),
|
|
4407
|
+
first,
|
|
4408
|
+
count: gpuDrivenIndexed ? submesh.indexCount : submesh.vertexCount,
|
|
4409
|
+
baseVertex: 0,
|
|
4410
|
+
materialSlot: submesh.materialSlot,
|
|
4411
|
+
topology: submesh.topology,
|
|
4412
|
+
pipelineClass: `${drawMaterial.materialShaderId ?? 'forgeax::default-unlit'}|${submesh.topology}|${JSON.stringify(drawMaterial.renderState ?? null)}`,
|
|
4413
|
+
materialResourceClass: gpuDrivenMaterialResourceClass(drawMaterial),
|
|
4414
|
+
},
|
|
4415
|
+
];
|
|
4416
|
+
});
|
|
4417
|
+
const baseRenderable: RenderableSnapshot = {
|
|
4418
|
+
assetHandle: Math.round(fAssetHandle ?? 0),
|
|
4419
|
+
transform: transformSnap,
|
|
4420
|
+
...(localAabb !== undefined ? { localAabb: cullingLocalAabb } : {}),
|
|
4421
|
+
material: materialSnap,
|
|
4422
|
+
materials: materialsArr,
|
|
4423
|
+
materialBindingSources,
|
|
4424
|
+
materialBindingDiagnostics,
|
|
4425
|
+
...(morph === undefined && gpuDrivenDraws.length > 0 ? { gpuDrivenDraws } : {}),
|
|
4426
|
+
worldId: 0,
|
|
4427
|
+
entityKey: entity,
|
|
4428
|
+
...(skinSlice !== undefined ? { skin: skinSlice } : {}),
|
|
4429
|
+
...(morph !== undefined ? { morph } : {}),
|
|
4430
|
+
...(spriteInstancesSnap !== undefined ? { spriteInstances: spriteInstancesSnap } : {}),
|
|
4431
|
+
...(pointsLines !== undefined ? { pointsLines } : {}),
|
|
4432
|
+
};
|
|
4433
|
+
|
|
4434
|
+
// feat-20260528-frustum-culling M3 / w10: frustum culling check.
|
|
4435
|
+
// Skip the entity if a valid AABB exists AND ALL cameras' frusta
|
|
4436
|
+
// reject the world-space AABB. Missing or malformed AABBs are
|
|
4437
|
+
// conservative always-visible fallbacks; valid culling bounds are
|
|
4438
|
+
// producer-owned finite local-space AABBs. Culling is unconditional
|
|
4439
|
+
// engine behavior; there is no per-entity opt-out.
|
|
4440
|
+
if (localAabb !== undefined) {
|
|
4441
|
+
// feat-20260601 D-3: cull AABB uses the resolved world mat4
|
|
4442
|
+
// directly (no compose) -- same source the record stage feeds
|
|
4443
|
+
// the mesh SSBO, so cull stays same-source with render (AC-05).
|
|
4444
|
+
const worldAabb = box3.create();
|
|
4445
|
+
box3.transformBox3(worldAabb, cullingLocalAabb, transformSnap.world);
|
|
4446
|
+
|
|
4447
|
+
// Test against all cameras. Entity is visible if one camera
|
|
4448
|
+
// frustum intersects the world-space AABB (or planes are empty
|
|
4449
|
+
// from degenerate projection).
|
|
4450
|
+
frustumTotal += 1;
|
|
4451
|
+
let visible = frustumPlanes.length === 0;
|
|
4452
|
+
for (let ci = 0; ci < frustumPlanes.length; ci++) {
|
|
4453
|
+
const planes = frustumPlanes[ci] as Float32Array;
|
|
4454
|
+
if (planes.length === 0) {
|
|
4455
|
+
visible = true;
|
|
4456
|
+
break;
|
|
4457
|
+
}
|
|
4458
|
+
if (frustum.intersectsBox(planes as frustum.Frustum, worldAabb as box3.Box3Like)) {
|
|
4459
|
+
visible = true;
|
|
4460
|
+
break;
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4463
|
+
if (!visible) {
|
|
4464
|
+
frustumCulled += 1;
|
|
4465
|
+
continue;
|
|
4466
|
+
}
|
|
4467
|
+
}
|
|
4468
|
+
|
|
4469
|
+
if (hasInstances) {
|
|
4470
|
+
const transforms = worldInternal._getArrayView(entity, Instances, 'transforms');
|
|
4471
|
+
if (transforms === undefined) {
|
|
4472
|
+
flushPendingDispatch(pendingDispatch);
|
|
4473
|
+
renderables.push(baseRenderable);
|
|
4474
|
+
} else {
|
|
4475
|
+
const actualLength = transforms.length;
|
|
4476
|
+
if (actualLength % 16 !== 0) {
|
|
4477
|
+
worldInternal._routeError(new InstanceTransformsStrideMismatchError(actualLength), {
|
|
4478
|
+
severity: Severity.Error,
|
|
4479
|
+
systemName: 'RenderSystem.extract (Instances stride)',
|
|
4480
|
+
});
|
|
4481
|
+
|
|
4482
|
+
continue;
|
|
4483
|
+
}
|
|
4484
|
+
const snapshotCopy = new Float32Array(transforms);
|
|
4485
|
+
const instanceCount = Math.max(1, Math.floor(actualLength / 16));
|
|
4486
|
+
flushPendingDispatch(pendingDispatch);
|
|
4487
|
+
renderables.push({
|
|
4488
|
+
...baseRenderable,
|
|
4489
|
+
instances: {
|
|
4490
|
+
transforms: snapshotCopy,
|
|
4491
|
+
instanceCount,
|
|
4492
|
+
cacheKey: entity,
|
|
4493
|
+
archVersion,
|
|
4494
|
+
},
|
|
4495
|
+
});
|
|
4496
|
+
}
|
|
4497
|
+
} else {
|
|
4498
|
+
flushPendingDispatch(pendingDispatch);
|
|
4499
|
+
renderables.push(baseRenderable);
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
|
|
4503
|
+
// feat-20260520-2d-sprite-layer-mvp M-3 / w22 + w25: finalise the
|
|
4504
|
+
// pending TransparentEntry with the renderableIndex pointing at the
|
|
4505
|
+
// RenderableSnapshot we just pushed (when isRenderable === true).
|
|
4506
|
+
// The check below also covers a sprite entity that survives the
|
|
4507
|
+
// dangling-Instances branch (silent skip with `dispatchEntry !==
|
|
4508
|
+
// null` early-continue) — in that case renderableIndex is still set
|
|
4509
|
+
// to the just-pushed slot which is correct because materialDispatch
|
|
4510
|
+
// already captures the dispatch position.
|
|
4511
|
+
}
|
|
4512
|
+
}
|
|
4513
|
+
|
|
4514
|
+
// M3 / w26: sort dispatch entries by queue (ascending, stable sort)
|
|
4515
|
+
// per plan-strategy D-3.
|
|
4516
|
+
dispatch = sortDispatchByQueue(dispatch);
|
|
4517
|
+
|
|
4518
|
+
// D-1: collect PostProcessParams entities into Map<shaderId, Uint8Array>.
|
|
4519
|
+
// Last-one-wins when multiple entities bear the same shader id (mirrors
|
|
4520
|
+
// Camera.exposure -> CameraSnapshot pattern; extract stage only reads).
|
|
4521
|
+
const postProcessParams: Map<string, Uint8Array> = new Map();
|
|
4522
|
+
const postProcessParamsQuery = world.query({ with: [PostProcessParams] }).unwrap();
|
|
4523
|
+
for (const row of postProcessParamsQuery) {
|
|
4524
|
+
const entity = row.entity;
|
|
4525
|
+
const read = world.get(entity, PostProcessParams);
|
|
4526
|
+
if (!read.ok) continue;
|
|
4527
|
+
postProcessParams.set(read.value.shader, read.value.data);
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
// feat-20260621 M-A3 / w13 (D-5): engine built-in tonemap data-driven
|
|
4531
|
+
// provider. The engine bridges the active camera's `Camera.exposure /
|
|
4532
|
+
// whitePoint / tonemap` onto the SAME unified params channel custom
|
|
4533
|
+
// post-processes use — `Camera.exposure` stays the AI-user-facing SSOT (D-5),
|
|
4534
|
+
// the engine itself acts as the provider for the Standard tonemap shader
|
|
4535
|
+
// id. The 16B layout is byte-identical to the prior recordTonemapPass packing
|
|
4536
|
+
// (render-system-record.ts pre-w14): Float32 [exposure, whitePoint, _, pad]
|
|
4537
|
+
// with the mode u32 occupying the third 4-byte slot via tonemapToU32 (SSOT in
|
|
4538
|
+
// camera.ts). Run AFTER the user-entity collection above so the engine's
|
|
4539
|
+
// built-in provider is authoritative for its own reserved key (a user entity
|
|
4540
|
+
// can never shadow the Standard tonemap identity. The single active camera mirrors
|
|
4541
|
+
// recordFrame's `activeCameras[0]` selection.
|
|
4542
|
+
const tonemapCamera = cameras[0];
|
|
4543
|
+
if (tonemapCamera !== undefined) {
|
|
4544
|
+
postProcessParams.set(STANDARD_TONEMAP_FEATURE_ID, tonemapParams(tonemapCamera));
|
|
4545
|
+
}
|
|
4546
|
+
|
|
4547
|
+
return {
|
|
4548
|
+
cameras,
|
|
4549
|
+
lights,
|
|
4550
|
+
renderables,
|
|
4551
|
+
dispatch,
|
|
4552
|
+
skylight,
|
|
4553
|
+
skylightCount,
|
|
4554
|
+
skybox,
|
|
4555
|
+
skyboxCount,
|
|
4556
|
+
frustumStats: { culled: frustumCulled, total: frustumTotal },
|
|
4557
|
+
visibilityStats: { explicitlyHidden: explicitlyHidden.size },
|
|
4558
|
+
postProcessParams,
|
|
4559
|
+
visibilitySnapshots: [context.visibility],
|
|
4560
|
+
featureVisibilitySnapshots: [{ world, snapshot: context.visibility }],
|
|
4561
|
+
hiddenEntityReports: [...explicitlyHidden].map((entity) => ({ world, entity })),
|
|
4562
|
+
};
|
|
4563
|
+
}
|
|
4564
|
+
|
|
4565
|
+
export function defaultMaterialSnapshot(materialHandle = 0): MaterialSnapshot {
|
|
4566
|
+
// Mid-grey unlit fallback (D-Q7 case B + extract-stage missing-spec),
|
|
4567
|
+
// matching the pre-w6 visual outcome where the record stage's force-cast
|
|
4568
|
+
// read of `firstMaterial.baseColorTexture` returned undefined and the
|
|
4569
|
+
// unlit fallback shader was selected.
|
|
4570
|
+
return {
|
|
4571
|
+
baseColor: vec3.create(0.5, 0.5, 0.5),
|
|
4572
|
+
metallic: 0,
|
|
4573
|
+
roughness: 1,
|
|
4574
|
+
materialHandle,
|
|
4575
|
+
};
|
|
4576
|
+
}
|
|
4577
|
+
|
|
4578
|
+
function identityProjection(): Float32Array {
|
|
4579
|
+
const projection = new Float32Array(16);
|
|
4580
|
+
projection[0] = 1;
|
|
4581
|
+
projection[5] = 1;
|
|
4582
|
+
projection[10] = 1;
|
|
4583
|
+
projection[15] = 1;
|
|
4584
|
+
return projection;
|
|
4585
|
+
}
|