@forgeax/engine-runtime 0.1.4 → 0.1.6
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/README.md +49 -83
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/animation-graph-collect-roundtrip.test.d.ts +2 -0
- package/dist/__tests__/animation-graph-collect-roundtrip.test.d.ts.map +1 -0
- package/dist/__tests__/animation-graph-serialize.unit.test.d.ts +2 -0
- package/dist/__tests__/animation-graph-serialize.unit.test.d.ts.map +1 -0
- package/dist/__tests__/asset-brand-and-name-retirement.test.d.ts +2 -0
- package/dist/__tests__/asset-brand-and-name-retirement.test.d.ts.map +1 -0
- package/dist/__tests__/asset-error-exhaustive.test-d.d.ts +11 -0
- package/dist/__tests__/asset-error-exhaustive.test-d.d.ts.map +1 -0
- package/dist/__tests__/asset-registry-guid-reverse.test.d.ts +2 -0
- package/dist/__tests__/asset-registry-guid-reverse.test.d.ts.map +1 -0
- package/dist/__tests__/asset-registry-hdr-equirect.test.d.ts +2 -0
- package/dist/__tests__/asset-registry-hdr-equirect.test.d.ts.map +1 -0
- package/dist/__tests__/asset-registry-mesh-pack-payload.dawn.test.d.ts +2 -0
- package/dist/__tests__/asset-registry-mesh-pack-payload.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/asset-registry-mounts.test.d.ts +2 -0
- package/dist/__tests__/asset-registry-mounts.test.d.ts.map +1 -0
- package/dist/__tests__/asset-registry.browser.test.d.ts +2 -0
- package/dist/__tests__/asset-registry.browser.test.d.ts.map +1 -0
- package/dist/__tests__/asset-registry.dawn.test.d.ts +2 -0
- package/dist/__tests__/asset-registry.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/asset-registry.listCatalog.test.d.ts +2 -0
- package/dist/__tests__/asset-registry.listCatalog.test.d.ts.map +1 -0
- package/dist/__tests__/asset-registry.mesh-bin.test.d.ts +2 -0
- package/dist/__tests__/asset-registry.mesh-bin.test.d.ts.map +1 -0
- package/dist/__tests__/asset-registry.pack-cache.test.d.ts +2 -0
- package/dist/__tests__/asset-registry.pack-cache.test.d.ts.map +1 -0
- package/dist/__tests__/asset-registry.raw-container-failfast.test.d.ts +2 -0
- package/dist/__tests__/asset-registry.raw-container-failfast.test.d.ts.map +1 -0
- package/dist/__tests__/asset-registry.recursive.spec.d.ts +2 -0
- package/dist/__tests__/asset-registry.recursive.spec.d.ts.map +1 -0
- package/dist/__tests__/asset-registry.unit.test.d.ts +2 -0
- package/dist/__tests__/asset-registry.unit.test.d.ts.map +1 -0
- package/dist/__tests__/asset.unit.test.d.ts +2 -0
- package/dist/__tests__/asset.unit.test.d.ts.map +1 -0
- package/dist/__tests__/audio-load-by-guid.unit.test.d.ts +2 -0
- package/dist/__tests__/audio-load-by-guid.unit.test.d.ts.map +1 -0
- package/dist/__tests__/backend-selection.integration.test.d.ts +2 -0
- package/dist/__tests__/backend-selection.integration.test.d.ts.map +1 -0
- package/dist/__tests__/basis-catalog-dispatch.integration.test.d.ts +2 -0
- package/dist/__tests__/basis-catalog-dispatch.integration.test.d.ts.map +1 -0
- package/dist/__tests__/builtin-asset-registry.unit.test.d.ts +2 -0
- package/dist/__tests__/builtin-asset-registry.unit.test.d.ts.map +1 -0
- package/dist/__tests__/collect-scene-asset.test.d.ts +1 -2
- package/dist/__tests__/collect-scene-asset.test.d.ts.map +1 -1
- package/dist/__tests__/collect-scene-mount-collapse.test.d.ts +2 -0
- package/dist/__tests__/collect-scene-mount-collapse.test.d.ts.map +1 -0
- package/dist/__tests__/collect-transient-roundtrip.test.d.ts +1 -1
- package/dist/__tests__/collect-transient-roundtrip.test.d.ts.map +1 -1
- package/dist/__tests__/collect-transient-skip.test.d.ts +1 -2
- package/dist/__tests__/collect-transient-skip.test.d.ts.map +1 -1
- package/dist/__tests__/compressed-texture-physical-extent.integration.test.d.ts +2 -0
- package/dist/__tests__/compressed-texture-physical-extent.integration.test.d.ts.map +1 -0
- package/dist/__tests__/dawn/mesh-update-dawn.test.d.ts +2 -0
- package/dist/__tests__/dawn/mesh-update-dawn.test.d.ts.map +1 -0
- package/dist/__tests__/dawn/upload-texture.dawn.test.d.ts +2 -0
- package/dist/__tests__/dawn/upload-texture.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/draw-published.d.ts +2 -2
- package/dist/__tests__/draw-published.d.ts.map +1 -1
- package/dist/__tests__/dynamic-texture-store.unit.test.d.ts +2 -0
- package/dist/__tests__/dynamic-texture-store.unit.test.d.ts.map +1 -0
- package/dist/__tests__/errors.unit.test.d.ts +2 -0
- package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/extract-skin-mismatch.unit.test.d.ts +2 -0
- package/dist/__tests__/extract-skin-mismatch.unit.test.d.ts.map +1 -0
- package/dist/__tests__/fetch-binary-decompress.test.d.ts +2 -0
- package/dist/__tests__/fetch-binary-decompress.test.d.ts.map +1 -0
- package/dist/__tests__/four-path-compression.integration.test.d.ts +2 -0
- package/dist/__tests__/four-path-compression.integration.test.d.ts.map +1 -0
- package/dist/__tests__/fullscreen-feature-host.dawn.test.d.ts +2 -0
- package/dist/__tests__/fullscreen-feature-host.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/guid-for-asset.unit.test.d.ts +2 -0
- package/dist/__tests__/guid-for-asset.unit.test.d.ts.map +1 -0
- package/dist/__tests__/handle-migration.test.d.ts +2 -0
- package/dist/__tests__/handle-migration.test.d.ts.map +1 -0
- package/dist/__tests__/helpers/register-runtime-components.d.ts.map +1 -1
- package/dist/__tests__/host-custom-kind-contract.test-d.d.ts +2 -0
- package/dist/__tests__/host-custom-kind-contract.test-d.d.ts.map +1 -0
- package/dist/__tests__/host-custom-kind-contract.test.d.ts +2 -0
- package/dist/__tests__/host-custom-kind-contract.test.d.ts.map +1 -0
- package/dist/__tests__/inspect-name.test.d.ts +2 -0
- package/dist/__tests__/inspect-name.test.d.ts.map +1 -0
- package/dist/__tests__/instantiate-mount-deferred-wiring.test.d.ts +2 -0
- package/dist/__tests__/instantiate-mount-deferred-wiring.test.d.ts.map +1 -0
- package/dist/__tests__/loadbyguid-payload.unit.test.d.ts +2 -0
- package/dist/__tests__/loadbyguid-payload.unit.test.d.ts.map +1 -0
- package/dist/__tests__/loadbyguid-texture-intern.unit.test.d.ts +2 -0
- package/dist/__tests__/loadbyguid-texture-intern.unit.test.d.ts.map +1 -0
- package/dist/__tests__/loader-extract-graceful.test.d.ts +2 -0
- package/dist/__tests__/loader-extract-graceful.test.d.ts.map +1 -0
- package/dist/__tests__/loader-name-survival.test.d.ts +2 -0
- package/dist/__tests__/loader-name-survival.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin-contract.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin-contract.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin-skin-roundtrip.unit.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin-skin-roundtrip.unit.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-loader-skin-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/mesh-loader-skin-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/new-kind-refs-contract.test.d.ts +2 -0
- package/dist/__tests__/new-kind-refs-contract.test.d.ts.map +1 -0
- package/dist/__tests__/package-promotion.test.d.ts +2 -0
- package/dist/__tests__/package-promotion.test.d.ts.map +1 -0
- package/dist/__tests__/package-rename.test.d.ts +2 -0
- package/dist/__tests__/package-rename.test.d.ts.map +1 -0
- package/dist/__tests__/recover-error-exhaustive.test-d.d.ts +1 -1
- package/dist/__tests__/recover-error-exhaustive.test-d.d.ts.map +1 -1
- package/dist/__tests__/render-error-exhaustive.test-d.d.ts +1 -1
- package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
- package/dist/__tests__/render-feature-prepared-graphics.fixture.d.ts +10 -0
- package/dist/__tests__/render-feature-prepared-graphics.fixture.d.ts.map +1 -0
- package/dist/__tests__/render-system-extract.unit.test.d.ts +2 -0
- package/dist/__tests__/render-system-extract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/renderer-test-utils.d.ts +10 -0
- package/dist/__tests__/renderer-test-utils.d.ts.map +1 -0
- package/dist/__tests__/renderer.test-d.d.ts +2 -0
- package/dist/__tests__/renderer.test-d.d.ts.map +1 -0
- package/dist/__tests__/resolve-asset-handle-err.test.d.ts +2 -0
- package/dist/__tests__/resolve-asset-handle-err.test.d.ts.map +1 -0
- package/dist/__tests__/resolve-asset-handle.unit.test.d.ts +2 -0
- package/dist/__tests__/resolve-asset-handle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/resolveName-fallback.test.d.ts +2 -0
- package/dist/__tests__/resolveName-fallback.test.d.ts.map +1 -0
- package/dist/__tests__/resolveName-null-package.test.d.ts +2 -0
- package/dist/__tests__/resolveName-null-package.test.d.ts.map +1 -0
- package/dist/__tests__/resolveName-unified-entry.test.d.ts +2 -0
- package/dist/__tests__/resolveName-unified-entry.test.d.ts.map +1 -0
- package/dist/__tests__/resolveName.test.d.ts +2 -0
- package/dist/__tests__/resolveName.test.d.ts.map +1 -0
- package/dist/__tests__/roots-to-scene-asset.test.d.ts +1 -1
- package/dist/__tests__/roots-to-scene-asset.test.d.ts.map +1 -1
- package/dist/__tests__/roundtrip-primitive.test.d.ts +2 -0
- package/dist/__tests__/roundtrip-primitive.test.d.ts.map +1 -0
- package/dist/__tests__/scene-bare-guid-refs-instantiate.test.d.ts +2 -0
- package/dist/__tests__/scene-bare-guid-refs-instantiate.test.d.ts.map +1 -0
- package/dist/__tests__/scene-instance-authoring.integration.test.d.ts +2 -0
- package/dist/__tests__/scene-instance-authoring.integration.test.d.ts.map +1 -0
- package/dist/__tests__/scene-mount-fixed-point.test.d.ts +2 -0
- package/dist/__tests__/scene-mount-fixed-point.test.d.ts.map +1 -0
- package/dist/__tests__/scene-mount-roundtrip.test.d.ts +2 -0
- package/dist/__tests__/scene-mount-roundtrip.test.d.ts.map +1 -0
- package/dist/__tests__/scene-mount-wrapper-roundtrip.test.d.ts +2 -0
- package/dist/__tests__/scene-mount-wrapper-roundtrip.test.d.ts.map +1 -0
- package/dist/__tests__/scene-sugar-instantiate.test.d.ts +2 -0
- package/dist/__tests__/scene-sugar-instantiate.test.d.ts.map +1 -0
- package/dist/__tests__/serialize-mounts-roundtrip.test.d.ts +2 -0
- package/dist/__tests__/serialize-mounts-roundtrip.test.d.ts.map +1 -0
- package/dist/__tests__/serialize-scene-asset.test.d.ts +1 -1
- package/dist/__tests__/serialize-scene-asset.test.d.ts.map +1 -1
- package/dist/__tests__/template-game-default-api-contract.test.d.ts +2 -0
- package/dist/__tests__/template-game-default-api-contract.test.d.ts.map +1 -0
- package/dist/__tests__/tileset-asset-validate.test.d.ts +2 -0
- package/dist/__tests__/tileset-asset-validate.test.d.ts.map +1 -0
- package/dist/__tests__/transcode-fallback.unit.test.d.ts +2 -0
- package/dist/__tests__/transcode-fallback.unit.test.d.ts.map +1 -0
- package/dist/__tests__/transient-runtime-behavior.test.d.ts +2 -0
- package/dist/__tests__/transient-runtime-behavior.test.d.ts.map +1 -0
- package/dist/__tests__/video-loader-consumption.test.d.ts +2 -0
- package/dist/__tests__/video-loader-consumption.test.d.ts.map +1 -0
- package/dist/__tests__/video-loader-smoke.dawn.test.d.ts +2 -0
- package/dist/__tests__/video-loader-smoke.dawn.test.d.ts.map +1 -0
- package/dist/backend-selection.d.ts +13 -0
- package/dist/backend-selection.d.ts.map +1 -0
- package/dist/collect-scene-asset.d.ts +3 -3
- package/dist/collect-scene-asset.d.ts.map +1 -1
- package/dist/createRenderer.d.ts +14 -2
- package/dist/createRenderer.d.ts.map +1 -1
- package/dist/errors/environment.d.ts +1 -1
- package/dist/errors/environment.d.ts.map +1 -1
- package/dist/index.d.ts +19 -81
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +105 -108
- package/dist/index.mjs.map +1 -1
- package/dist/renderer-host.d.ts +9 -0
- package/dist/renderer-host.d.ts.map +1 -0
- package/dist/renderer-host.mjs +62 -0
- package/dist/renderer-host.mjs.map +1 -0
- package/dist/scene-instances/index.d.ts +2 -0
- package/dist/scene-instances/index.d.ts.map +1 -0
- package/dist/scene-utils/mount-override-fold.d.ts +1 -2
- package/dist/scene-utils/mount-override-fold.d.ts.map +1 -1
- package/dist/systems/sprite-animation-tick.d.ts +1 -1
- package/dist/systems/sprite-animation-tick.d.ts.map +1 -1
- package/dist/systems/transparent-sort.d.ts +1 -2
- package/dist/systems/transparent-sort.d.ts.map +1 -1
- package/dist/tilemap-register-validate.d.ts.map +1 -1
- package/package.json +34 -33
- package/src/__tests__/active-camera.unit.test.ts +19 -9
- package/src/__tests__/animation-graph-collect-roundtrip.test.ts +213 -0
- package/src/__tests__/animation-graph-serialize.unit.test.ts +171 -0
- package/src/__tests__/append-injection.test.ts +1 -1
- package/src/__tests__/asset-brand-and-name-retirement.test.ts +102 -0
- package/src/__tests__/asset-error-exhaustive.test-d.ts +62 -0
- package/src/__tests__/asset-registry-guid-reverse.test.ts +575 -0
- package/src/__tests__/asset-registry-hdr-equirect.test.ts +78 -0
- package/src/__tests__/asset-registry-mesh-pack-payload.dawn.test.ts +232 -0
- package/src/__tests__/asset-registry-mounts.test.ts +806 -0
- package/src/__tests__/asset-registry.browser.test.ts +81 -0
- package/src/__tests__/asset-registry.dawn.test.ts +94 -0
- package/src/__tests__/asset-registry.listCatalog.test.ts +170 -0
- package/src/__tests__/asset-registry.mesh-bin.test.ts +316 -0
- package/src/__tests__/asset-registry.pack-cache.test.ts +969 -0
- package/src/__tests__/asset-registry.raw-container-failfast.test.ts +225 -0
- package/src/__tests__/asset-registry.recursive.spec.ts +1397 -0
- package/src/__tests__/asset-registry.unit.test.ts +1030 -0
- package/src/__tests__/asset.unit.test.ts +7107 -0
- package/src/__tests__/audio-load-by-guid.unit.test.ts +126 -0
- package/src/__tests__/backend-selection.integration.test.ts +11 -0
- package/src/__tests__/basis-catalog-dispatch.integration.test.ts +320 -0
- package/src/__tests__/bind-group-cache-keying.test.ts +4 -4
- package/src/__tests__/block-upload-math.unit.test.ts +2 -2
- package/src/__tests__/builtin-asset-registry.unit.test.ts +112 -0
- package/src/__tests__/builtin-shader-register-e2e.test.ts +1 -1
- package/src/__tests__/cache-false-eviction.test.ts +2 -1
- package/src/__tests__/cache-isolation-matrix.test.ts +2 -1
- package/src/__tests__/camera-clear-snapshot-equivalence.unit.test.ts +5 -6
- package/src/__tests__/clamp-to-last.e2e.browser.test.ts +29 -19
- package/src/__tests__/collect-scene-asset.test.ts +49 -52
- package/src/__tests__/collect-scene-mount-collapse.test.ts +391 -0
- package/src/__tests__/collect-subtree.unit.test.ts +1 -2
- package/src/__tests__/collect-transient-children-absent.test.ts +24 -10
- package/src/__tests__/collect-transient-roundtrip.test.ts +44 -20
- package/src/__tests__/collect-transient-skip.test.ts +24 -13
- package/src/__tests__/components.test-d.ts +1 -6
- package/src/__tests__/components.unit.test.ts +20 -19
- package/src/__tests__/composite-skybox-cross-world.browser.test.ts +59 -27
- package/src/__tests__/compressed-texture-physical-extent.integration.test.ts +139 -0
- package/src/__tests__/create-renderer-assembly.integration.test.ts +42 -29
- package/src/__tests__/createRenderer-dispose.test.ts +35 -402
- package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +25 -30
- package/src/__tests__/createRenderer.unit.test.ts +1 -1
- package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +27 -63
- package/src/__tests__/dawn/ibl-precompute-readback.dawn.test.ts +56 -47
- package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +49 -29
- package/src/__tests__/dawn/instances-shadow.dawn.test.ts +26 -15
- package/src/__tests__/dawn/instances-uniform-fallback.dawn.test.ts +13 -14
- package/src/__tests__/dawn/material-alpha.dawn.test.ts +23 -31
- package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +0 -18
- package/src/__tests__/dawn/{mesh-update.dawn.test.ts → mesh-update-dawn.test.ts} +42 -134
- package/src/__tests__/dawn/mipmap-srgb-linear.dawn.test.ts +14 -7
- package/src/__tests__/dawn/per-entity-material-texture-isolation.dawn.test.ts +16 -28
- package/src/__tests__/dawn/sprite-nineslice-mesh-bind.dawn.test.ts +23 -39
- package/src/__tests__/dawn/upload-texture.dawn.test.ts +198 -0
- package/src/__tests__/dawn/user-handle-mesh-render.dawn.test.ts +12 -14
- package/src/__tests__/dawn/user-mesh-upload.dawn.test.ts +27 -22
- package/src/__tests__/dawn/zero-manifest-mesh-fail.dawn.test.ts +25 -28
- package/src/__tests__/dawn/zero-manifest-ready.dawn.test.ts +10 -11
- package/src/__tests__/demo-sortscope-migration.unit.test.ts +5 -5
- package/src/__tests__/derive-bgl-integration.test.ts +34 -44
- package/src/__tests__/derive-render-data-skin.unit.test.ts +1 -1
- package/src/__tests__/draw-published.ts +11 -4
- package/src/__tests__/dynamic-texture-store.unit.test.ts +190 -0
- package/src/__tests__/equirect-bc6h.integration.test.ts +144 -21
- package/src/__tests__/errors.unit.test.ts +1203 -0
- package/src/__tests__/extract-frames-graceful-degradation.test.ts +3 -7
- package/src/__tests__/extract-frames-merge.test.ts +35 -22
- package/src/__tests__/extract-record-no-hardcoded-texture-fields.test.ts +5 -16
- package/src/__tests__/extract-skin-mismatch.unit.test.ts +322 -0
- package/src/__tests__/fetch-binary-decompress.test.ts +356 -0
- package/src/__tests__/four-path-compression.integration.test.ts +395 -0
- package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +1 -1
- package/src/__tests__/fullscreen-feature-host.dawn.test.ts +80 -0
- package/src/__tests__/fullscreen-post-bgl-depth.test.ts +4 -2
- package/src/__tests__/fullscreen-post-depth-params-arity.test.ts +3 -3
- package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +39 -23
- package/src/__tests__/fullscreen-post-process-pass.unit.test.ts +4 -2
- package/src/__tests__/fxaa-pixel-diff.dawn.test.ts +28 -17
- package/src/__tests__/fxaa-validation-and-resize.dawn.test.ts +29 -18
- package/src/__tests__/fxaa-zero-overhead.dawn.test.ts +16 -12
- package/src/__tests__/geometry.unit.test.ts +525 -159
- package/src/__tests__/glyph-tilemap-worldid.test.ts +1 -1
- package/src/__tests__/gpu-resource-cow-survivor.test.ts +6 -4
- package/src/__tests__/guid-for-asset.unit.test.ts +102 -0
- package/src/__tests__/handle-migration.test.ts +136 -0
- package/src/__tests__/helpers/register-runtime-components.ts +4 -2
- package/src/__tests__/helpers/register-scene-components.ts +1 -1
- package/src/__tests__/host-custom-kind-contract.test-d.ts +47 -0
- package/src/__tests__/host-custom-kind-contract.test.ts +578 -0
- package/src/__tests__/inspect-name.test.ts +139 -0
- package/src/__tests__/instances.test-d.ts +1 -1
- package/src/__tests__/instantiate-mount-deferred-wiring.test.ts +112 -0
- package/src/__tests__/light-casters-9-light.browser.test.ts +73 -76
- package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +7 -10
- package/src/__tests__/light-snapshot.test-d.ts +2 -2
- package/src/__tests__/lights-vec-negative.unit.test.ts +1 -1
- package/src/__tests__/lights.unit.test.ts +16 -500
- package/src/__tests__/loadbyguid-payload.unit.test.ts +117 -0
- package/src/__tests__/loadbyguid-texture-intern.unit.test.ts +213 -0
- package/src/__tests__/loader-extract-graceful.test.ts +146 -0
- package/src/__tests__/loader-name-survival.test.ts +213 -0
- package/src/__tests__/m3-vec-negative.unit.test.ts +3 -2
- package/src/__tests__/material-texture-uv-scale.unit.test.ts +101 -31
- package/src/__tests__/materials.unit.test.ts +10 -27
- package/src/__tests__/mesh-bin-contract.test.ts +28 -0
- package/src/__tests__/mesh-bin-skin-roundtrip.unit.test.ts +149 -0
- package/src/__tests__/mesh-bin.test.ts +38 -0
- package/src/__tests__/mesh-loader-skin-contract.unit.test.ts +120 -0
- package/src/__tests__/mesh-ssbo-grow.unit.test.ts +3 -3
- package/src/__tests__/mip-gate.unit.test.ts +1 -1
- package/src/__tests__/msaa-sprite-pixel-diff.dawn.test.ts +29 -68
- package/src/__tests__/new-kind-refs-contract.test.ts +132 -0
- package/src/__tests__/node-shims.d.ts +4 -6
- package/src/__tests__/package-promotion.test.ts +88 -0
- package/src/__tests__/package-rename.test.ts +103 -0
- package/src/__tests__/pass-selector.unit.test.ts +2 -2
- package/src/__tests__/pbr-pipeline.unit.test.ts +14 -21
- package/src/__tests__/pipeline-builder-msaa.unit.test.ts +6 -9
- package/src/__tests__/pipeline-cache-keying.unit.test.ts +6 -9
- package/src/__tests__/pipeline-spec.test.ts +16 -177
- package/src/__tests__/pipeline.unit.test.ts +154 -1693
- package/src/__tests__/point-light-shadow.dawn.test.ts +33 -42
- package/src/__tests__/point-light-shadow.unit.test.ts +19 -60
- package/src/__tests__/post-process-error-codes.unit.test.ts +4 -1
- package/src/__tests__/post-process-params-component.unit.test.ts +1 -1
- package/src/__tests__/post-process-params-extract.unit.test.ts +2 -5
- package/src/__tests__/postprocess-camera-consistency.test.ts +4 -7
- package/src/__tests__/profiler-assembly.integration.test.ts +1 -1
- package/src/__tests__/propagate-transforms.dawn.test.ts +12 -11
- package/src/__tests__/record-worldid-isolation.test.ts +7 -29
- package/src/__tests__/recover-error-exhaustive.test-d.ts +1 -1
- package/src/__tests__/render-error-exhaustive.test-d.ts +81 -71
- package/src/__tests__/render-feature-assembly.test-d.ts +20 -9
- package/src/__tests__/render-feature-prepared-graphics-docs.unit.test.ts +8 -6
- package/src/__tests__/render-feature-prepared-graphics.browser.test.ts +60 -558
- package/src/__tests__/render-feature-prepared-graphics.dawn.test.ts +57 -544
- package/src/__tests__/render-feature-prepared-graphics.fixture.ts +104 -0
- package/src/__tests__/render-feature-prepared-graphics.integration.test.ts +40 -248
- package/src/__tests__/render-feature-prepared-graphics.regression.integration.test.ts +83 -340
- package/src/__tests__/render-feature-recover.integration.test.ts +9 -49
- package/src/__tests__/render-feature-runtime-surface.test-d.ts +17 -20
- package/src/__tests__/render-schema-consumer.integration.test.ts +20 -13
- package/src/__tests__/render-schema-owner-boundary.unit.test.ts +2 -6
- package/src/__tests__/render-system-cross-world-overlay-queue.test.ts +5 -5
- package/src/__tests__/render-system-extract-dispatch-ordering.test.ts +16 -31
- package/src/__tests__/render-system-extract.test.ts +36 -51
- package/src/__tests__/render-system-extract.unit.test.ts +360 -0
- package/src/__tests__/render-system-fold-bucket.unit.test.ts +3 -5
- package/src/__tests__/render-system-fold-fallback.unit.test.ts +6 -3
- package/src/__tests__/render-system-fold-material.unit.test.ts +3 -5
- package/src/__tests__/render-system-fold-metrics.unit.test.ts +5 -5
- package/src/__tests__/render-system-fold-modegate.unit.test.ts +6 -6
- package/src/__tests__/render-system-fold.dawn.test.ts +7 -5
- package/src/__tests__/render-system-mega.test.ts +292 -190
- package/src/__tests__/render-system-owner-split-contract.test.ts +10 -32
- package/src/__tests__/render-system-record-multi-material-textureview.test.ts +59 -45
- package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +23 -29
- package/src/__tests__/render-system-record.test.ts +398 -235
- package/src/__tests__/render-system-record.unit.test.ts +25 -26
- package/src/__tests__/render-system-skin-bg.unit.test.ts +8 -12
- package/src/__tests__/renderer-consumer.test-d.ts +16 -3
- package/src/__tests__/renderer-health.unit.test.ts +26 -916
- package/src/__tests__/renderer-lifecycle.integration.test.ts +19 -6
- package/src/__tests__/renderer-recover.unit.test.ts +49 -1069
- package/src/__tests__/renderer-result.test-d.ts +19 -9
- package/src/__tests__/renderer-surface.unit.test.ts +3 -5
- package/src/__tests__/renderer-test-utils.ts +56 -0
- package/src/__tests__/renderer.test-d.ts +59 -0
- package/src/__tests__/resolve-asset-handle-err.test.ts +111 -0
- package/src/__tests__/resolve-asset-handle.unit.test.ts +64 -0
- package/src/__tests__/resolve-depth-only-view.test.ts +1 -1
- package/src/__tests__/resolveName-fallback.test.ts +64 -0
- package/src/__tests__/resolveName-null-package.test.ts +38 -0
- package/src/__tests__/resolveName-unified-entry.test.ts +65 -0
- package/src/__tests__/resolveName.test.ts +64 -0
- package/src/__tests__/rhi-null-command-flow.unit.test.ts +43 -261
- package/src/__tests__/rhi-null-renderer-lifecycle.unit.test.ts +41 -169
- package/src/__tests__/roots-to-scene-asset.test.ts +199 -93
- package/src/__tests__/roundtrip-primitive.test.ts +351 -0
- package/src/__tests__/scene-bare-guid-refs-instantiate.test.ts +123 -0
- package/src/__tests__/scene-equirect-roundtrip.test.ts +103 -16
- package/src/__tests__/scene-instance-authoring.integration.test.ts +8 -0
- package/src/__tests__/scene-instance-bridge.integration.test.ts +1 -1
- package/src/__tests__/scene-instance-component.test.ts +1 -1
- package/src/__tests__/scene-mount-fixed-point.test.ts +257 -0
- package/src/__tests__/scene-mount-roundtrip.test.ts +987 -0
- package/src/__tests__/scene-mount-wrapper-roundtrip.test.ts +202 -0
- package/src/__tests__/scene-sugar-instantiate.test.ts +324 -0
- package/src/__tests__/serialize-mounts-roundtrip.test.ts +533 -0
- package/src/__tests__/serialize-scene-asset.test.ts +219 -22
- package/src/__tests__/shadow-caster-empty-schema.test.ts +3 -3
- package/src/__tests__/shadow-csm-cascade-loadop.test.ts +37 -17
- package/src/__tests__/shadow-csm-component.test.ts +3 -5
- package/src/__tests__/shadow-csm-extract.test.ts +2 -100
- package/src/__tests__/shadow-csm-orthoz.test.ts +2 -16
- package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +19 -14
- package/src/__tests__/shadow-csm-tile-consistency.test.ts +32 -26
- package/src/__tests__/shadow-csm-ubo.dawn.test.ts +8 -49
- package/src/__tests__/shadow-csm-ubo.test.ts +3 -25
- package/src/__tests__/shadow-csm-viewport.browser.test.ts +1 -1
- package/src/__tests__/shadow-fields-observable.dawn.test.ts +67 -56
- package/src/__tests__/shadow-m1.dawn.test.ts +8 -9
- package/src/__tests__/shadow-mesh-ssbo-order.test.ts +11 -27
- package/src/__tests__/skin-errors.unit.test.ts +1 -1
- package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +7 -5
- package/src/__tests__/skylight-lazy-projection.test.ts +36 -15
- package/src/__tests__/sparse-tag-scene-roundtrip.test.ts +5 -5
- package/src/__tests__/sprite-cullmode-flip.dawn.test.ts +47 -64
- package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +12 -11
- package/src/__tests__/sprite-lit-extract-branch.test.ts +1 -1
- package/src/__tests__/sprite-lit-pipeline-cache-key.test.ts +1 -1
- package/src/__tests__/ssao-bgl.test.ts +16 -9
- package/src/__tests__/ssao-buffers.test.ts +2 -2
- package/src/__tests__/ssao-data.test.ts +2 -2
- package/src/__tests__/ssao-errors.test.ts +1 -1
- package/src/__tests__/ssao-passes.test.ts +64 -392
- package/src/__tests__/ssao-pipelines.test.ts +1 -1
- package/src/__tests__/ssao-uniform-layout.test.ts +8 -6
- package/src/__tests__/stencil-outline-pixel.dawn.test.ts +25 -17
- package/src/__tests__/swap-chain-format.unit.test.ts +5 -7
- package/src/__tests__/systems.unit.test.ts +536 -1326
- package/src/__tests__/template-game-default-api-contract.test.ts +362 -0
- package/src/__tests__/text.unit.test.ts +56 -33
- package/src/__tests__/tilemap-childed-despawn.test.ts +8 -14
- package/src/__tests__/tilemap-chunk-extract.overhang-bounds.test.ts +8 -12
- package/src/__tests__/tilemap-chunk-extract.propagate-order.test.ts +2 -1
- package/src/__tests__/tilemap-chunk-extract.test.ts +13 -17
- package/src/__tests__/tilemap-dirty-rebuild.test.ts +7 -11
- package/src/__tests__/tilemap-material-cache.test.ts +8 -13
- package/src/__tests__/tilemap-multi-atlas.test.ts +12 -32
- package/src/__tests__/tilemap-owner-boundary.unit.test.ts +5 -2
- package/src/__tests__/tilemap-region-pack.test.ts +8 -12
- package/src/__tests__/tilemap-spawn-flip-pivot.test.ts +8 -12
- package/src/__tests__/tilemap-uv-inset.test.ts +11 -14
- package/src/__tests__/tilemap-validate.test.ts +1 -1
- package/src/__tests__/tileset-asset-validate.test.ts +638 -0
- package/src/__tests__/transcode-fallback.unit.test.ts +207 -0
- package/src/__tests__/transform-hierarchy-pixel-diff.dawn.test.ts +36 -28
- package/src/__tests__/transient-fields-collect.test.ts +28 -21
- package/src/__tests__/transient-runtime-behavior.test.ts +227 -0
- package/src/__tests__/transparent-sort-tilemap-sprite-interleave.test.ts +4 -4
- package/src/__tests__/variant-selection.unit.test.ts +1 -1
- package/src/__tests__/video-extract-bindgroup.dawn.test.ts +89 -136
- package/src/__tests__/video-loader-consumption.test.ts +86 -0
- package/src/__tests__/video-loader-smoke.dawn.test.ts +60 -0
- package/src/__tests__/view-ubo-layout.browser.test.ts +0 -22
- package/src/__tests__/world-entity-key.test.ts +1 -1
- package/src/__tests__/world-scene-despawn.test.ts +1 -1
- package/src/__tests__/world-scene-instantiate.test.ts +2 -36
- package/src/__tests__/world-scene-nested.test.ts +1 -1
- package/src/__tests__/world-scene-override.test.ts +1 -1
- package/src/backend-selection.ts +69 -0
- package/src/collect-scene-asset.ts +28 -23
- package/src/components/__tests__/camera.test-d.ts +1 -1
- package/src/components/__tests__/camera.test.ts +1 -1
- package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
- package/src/components/__tests__/tile-layer.test-d.ts +1 -1
- package/src/components/__tests__/tile-layer.unit.test.ts +2 -2
- package/src/createRenderer.ts +24 -36
- package/src/errors/environment.ts +1 -1
- package/src/index.ts +47 -113
- package/src/renderer-host.ts +33 -0
- package/src/scene-instances/__tests__/post-spawn-resolve-joints.unit.test.ts +1 -1
- package/src/scene-instances/index.ts +4 -0
- package/src/scene-utils/mount-override-fold.ts +3 -7
- package/src/systems/__tests__/transparent-sort.bench.ts +3 -3
- package/src/systems/sprite-animation-tick.ts +1 -1
- package/src/systems/transparent-sort.ts +38 -38
- package/src/tilemap-register-validate.ts +4 -4
- package/dist/__tests__/bind-group-cache-binding.test.d.ts +0 -2
- package/dist/__tests__/bind-group-cache-binding.test.d.ts.map +0 -1
- package/dist/__tests__/bind-group-cache-frame.test.d.ts +0 -2
- package/dist/__tests__/bind-group-cache-frame.test.d.ts.map +0 -1
- package/dist/__tests__/dawn/mesh-update.dawn.test.d.ts +0 -2
- package/dist/__tests__/dawn/mesh-update.dawn.test.d.ts.map +0 -1
- package/dist/__tests__/dispatch-fullscreen-pass-composite-swapchain.dawn.test.d.ts +0 -2
- package/dist/__tests__/dispatch-fullscreen-pass-composite-swapchain.dawn.test.d.ts.map +0 -1
- package/dist/__tests__/dispatch-fullscreen-pass-custom-reads.dawn.test.d.ts +0 -2
- package/dist/__tests__/dispatch-fullscreen-pass-custom-reads.dawn.test.d.ts.map +0 -1
- package/dist/__tests__/dispatch-fullscreen-pass-depth-composite.dawn.test.d.ts +0 -2
- package/dist/__tests__/dispatch-fullscreen-pass-depth-composite.dawn.test.d.ts.map +0 -1
- package/dist/__tests__/dispatch-fullscreen-pass-params.unit.test.d.ts +0 -2
- package/dist/__tests__/dispatch-fullscreen-pass-params.unit.test.d.ts.map +0 -1
- package/dist/__tests__/fog-producer-manifest.d.ts +0 -131
- package/dist/__tests__/fog-producer-manifest.d.ts.map +0 -1
- package/dist/__tests__/fog-producer-pixel-probe.d.ts +0 -42
- package/dist/__tests__/fog-producer-pixel-probe.d.ts.map +0 -1
- package/dist/__tests__/fog-producer-pixel.browser.test.d.ts +0 -2
- package/dist/__tests__/fog-producer-pixel.browser.test.d.ts.map +0 -1
- package/dist/__tests__/fog-producer-pixel.dawn.test.d.ts +0 -2
- package/dist/__tests__/fog-producer-pixel.dawn.test.d.ts.map +0 -1
- package/dist/__tests__/fog-producer-real-fixture.d.ts +0 -5
- package/dist/__tests__/fog-producer-real-fixture.d.ts.map +0 -1
- package/dist/__tests__/fog-producer-vfx-effect.d.ts +0 -3
- package/dist/__tests__/fog-producer-vfx-effect.d.ts.map +0 -1
- package/dist/__tests__/fog-scene-asset.integration.test.d.ts +0 -2
- package/dist/__tests__/fog-scene-asset.integration.test.d.ts.map +0 -1
- package/dist/__tests__/gpu-resource-store-cubemap-status.test.d.ts +0 -2
- package/dist/__tests__/gpu-resource-store-cubemap-status.test.d.ts.map +0 -1
- package/dist/__tests__/gpu-resource.test.d.ts +0 -2
- package/dist/__tests__/gpu-resource.test.d.ts.map +0 -1
- package/dist/__tests__/hdrp-bgl-slots.browser.test.d.ts +0 -2
- package/dist/__tests__/hdrp-bgl-slots.browser.test.d.ts.map +0 -1
- package/dist/__tests__/hdrp-buffers.unit.test.d.ts +0 -2
- package/dist/__tests__/hdrp-buffers.unit.test.d.ts.map +0 -1
- package/dist/__tests__/hdrp-execute-filter.unit.test.d.ts +0 -2
- package/dist/__tests__/hdrp-execute-filter.unit.test.d.ts.map +0 -1
- package/dist/__tests__/hdrp-gbuffer-attachments.browser.test.d.ts +0 -2
- package/dist/__tests__/hdrp-gbuffer-attachments.browser.test.d.ts.map +0 -1
- package/dist/__tests__/hdrp-pipeline-asset-type.test-d.d.ts +0 -2
- package/dist/__tests__/hdrp-pipeline-asset-type.test-d.d.ts.map +0 -1
- package/dist/__tests__/hdrp-ssao-boundary.test.d.ts +0 -2
- package/dist/__tests__/hdrp-ssao-boundary.test.d.ts.map +0 -1
- package/dist/__tests__/hdrp-ssao-falsification.test.d.ts +0 -2
- package/dist/__tests__/hdrp-ssao-falsification.test.d.ts.map +0 -1
- package/dist/__tests__/helpers/primitive-world.d.ts +0 -8
- package/dist/__tests__/helpers/primitive-world.d.ts.map +0 -1
- package/dist/__tests__/helpers/render-asset-port.d.ts +0 -15
- package/dist/__tests__/helpers/render-asset-port.d.ts.map +0 -1
- package/dist/__tests__/helpers/scene-asset-port.d.ts +0 -9
- package/dist/__tests__/helpers/scene-asset-port.d.ts.map +0 -1
- package/dist/__tests__/material-derived-coordinates.integration.test.d.ts +0 -2
- package/dist/__tests__/material-derived-coordinates.integration.test.d.ts.map +0 -1
- package/dist/__tests__/pipeline-layout-selection.test.d.ts +0 -2
- package/dist/__tests__/pipeline-layout-selection.test.d.ts.map +0 -1
- package/dist/__tests__/post-process-reads-narrowing.test-d.d.ts +0 -2
- package/dist/__tests__/post-process-reads-narrowing.test-d.d.ts.map +0 -1
- package/dist/__tests__/post-process-register-roundtrip.dawn.test.d.ts +0 -2
- package/dist/__tests__/post-process-register-roundtrip.dawn.test.d.ts.map +0 -1
- package/dist/__tests__/render-feature-zero-feature.browser.test.d.ts +0 -2
- package/dist/__tests__/render-feature-zero-feature.browser.test.d.ts.map +0 -1
- package/dist/__tests__/render-pipeline-trivial.dawn.test.d.ts +0 -2
- package/dist/__tests__/render-pipeline-trivial.dawn.test.d.ts.map +0 -1
- package/dist/__tests__/shadow-csm-caster-membership.test.d.ts +0 -2
- package/dist/__tests__/shadow-csm-caster-membership.test.d.ts.map +0 -1
- package/dist/__tests__/shadow-csm-pssm.test.d.ts +0 -2
- package/dist/__tests__/shadow-csm-pssm.test.d.ts.map +0 -1
- package/dist/__tests__/shadow-csm-temporal.browser.test.d.ts +0 -2
- package/dist/__tests__/shadow-csm-temporal.browser.test.d.ts.map +0 -1
- package/dist/__tests__/shadow-csm-viewport.test.d.ts +0 -2
- package/dist/__tests__/shadow-csm-viewport.test.d.ts.map +0 -1
- package/dist/__tests__/shadow-m2.dawn.test.d.ts +0 -2
- package/dist/__tests__/shadow-m2.dawn.test.d.ts.map +0 -1
- package/dist/__tests__/shadow-m3-calibrate-run.dawn.test.d.ts +0 -2
- package/dist/__tests__/shadow-m3-calibrate-run.dawn.test.d.ts.map +0 -1
- package/dist/__tests__/shadow-m3-calibrate.d.ts +0 -83
- package/dist/__tests__/shadow-m3-calibrate.d.ts.map +0 -1
- package/dist/__tests__/shadow-m3.dawn.test.d.ts +0 -2
- package/dist/__tests__/shadow-m3.dawn.test.d.ts.map +0 -1
- package/dist/__tests__/shadow-probe-control-flow.dawn.test.d.ts +0 -2
- package/dist/__tests__/shadow-probe-control-flow.dawn.test.d.ts.map +0 -1
- package/dist/__tests__/shadow-quality-metrics.dawn.test.d.ts +0 -2
- package/dist/__tests__/shadow-quality-metrics.dawn.test.d.ts.map +0 -1
- package/dist/__tests__/temporal-off-path.unit.test.d.ts +0 -2
- package/dist/__tests__/temporal-off-path.unit.test.d.ts.map +0 -1
- package/dist/__tests__/video-cache-regression.unit.test.d.ts +0 -2
- package/dist/__tests__/video-cache-regression.unit.test.d.ts.map +0 -1
- package/dist/asset-kind.d.ts +0 -4
- package/dist/asset-kind.d.ts.map +0 -1
- package/dist/errors/sprite-animation.d.ts +0 -3
- package/dist/errors/sprite-animation.d.ts.map +0 -1
- package/dist/scene-asset-port.d.ts +0 -17
- package/dist/scene-asset-port.d.ts.map +0 -1
- package/dist/scene-collect-errors.d.ts +0 -22
- package/dist/scene-collect-errors.d.ts.map +0 -1
- package/dist/shadow-m3-calibrate.d.ts +0 -40
- package/dist/shadow-m3-calibrate.d.ts.map +0 -1
- package/src/__tests__/bind-group-cache-binding.test.ts +0 -2513
- package/src/__tests__/bind-group-cache-frame.test.ts +0 -1511
- package/src/__tests__/dispatch-fullscreen-pass-composite-swapchain.dawn.test.ts +0 -204
- package/src/__tests__/dispatch-fullscreen-pass-custom-reads.dawn.test.ts +0 -297
- package/src/__tests__/dispatch-fullscreen-pass-depth-composite.dawn.test.ts +0 -271
- package/src/__tests__/dispatch-fullscreen-pass-params.unit.test.ts +0 -321
- package/src/__tests__/fog-producer-manifest.ts +0 -162
- package/src/__tests__/fog-producer-pixel-probe.ts +0 -263
- package/src/__tests__/fog-producer-pixel.browser.test.ts +0 -26
- package/src/__tests__/fog-producer-pixel.dawn.test.ts +0 -55
- package/src/__tests__/fog-producer-real-fixture.ts +0 -805
- package/src/__tests__/fog-producer-vfx-effect.ts +0 -272
- package/src/__tests__/fog-scene-asset.integration.test.ts +0 -123
- package/src/__tests__/gpu-resource-store-cubemap-status.test.ts +0 -207
- package/src/__tests__/gpu-resource.test.ts +0 -1206
- package/src/__tests__/hdrp-bgl-slots.browser.test.ts +0 -50
- package/src/__tests__/hdrp-buffers.unit.test.ts +0 -70
- package/src/__tests__/hdrp-execute-filter.unit.test.ts +0 -200
- package/src/__tests__/hdrp-gbuffer-attachments.browser.test.ts +0 -64
- package/src/__tests__/hdrp-pipeline-asset-type.test-d.ts +0 -139
- package/src/__tests__/hdrp-ssao-boundary.test.ts +0 -237
- package/src/__tests__/hdrp-ssao-falsification.test.ts +0 -289
- package/src/__tests__/helpers/primitive-world.ts +0 -91
- package/src/__tests__/helpers/render-asset-port.ts +0 -65
- package/src/__tests__/helpers/scene-asset-port.ts +0 -23
- package/src/__tests__/material-derived-coordinates.integration.test.ts +0 -100
- package/src/__tests__/pipeline-layout-selection.test.ts +0 -298
- package/src/__tests__/post-process-reads-narrowing.test-d.ts +0 -52
- package/src/__tests__/post-process-register-roundtrip.dawn.test.ts +0 -230
- package/src/__tests__/render-feature-zero-feature.browser.test.ts +0 -152
- package/src/__tests__/render-pipeline-trivial.dawn.test.ts +0 -525
- package/src/__tests__/shadow-csm-caster-membership.test.ts +0 -232
- package/src/__tests__/shadow-csm-pssm.test.ts +0 -142
- package/src/__tests__/shadow-csm-temporal.browser.test.ts +0 -145
- package/src/__tests__/shadow-csm-viewport.test.ts +0 -74
- package/src/__tests__/shadow-m2.dawn.test.ts +0 -394
- package/src/__tests__/shadow-m3-calibrate-run.dawn.test.ts +0 -248
- package/src/__tests__/shadow-m3-calibrate.ts +0 -203
- package/src/__tests__/shadow-m3.dawn.test.ts +0 -288
- package/src/__tests__/shadow-probe-control-flow.dawn.test.ts +0 -264
- package/src/__tests__/shadow-quality-metrics.dawn.test.ts +0 -324
- package/src/__tests__/temporal-off-path.unit.test.ts +0 -69
- package/src/__tests__/video-cache-regression.unit.test.ts +0 -186
- package/src/asset-kind.ts +0 -7
- package/src/errors/sprite-animation.ts +0 -5
- package/src/scene-asset-port.ts +0 -32
- package/src/scene-collect-errors.ts +0 -30
- package/src/shadow-m3-calibrate.ts +0 -171
|
@@ -1,2513 +0,0 @@
|
|
|
1
|
-
// Consolidated by feat-20260608-ci-time-cut M5 w13/w14: merges
|
|
2
|
-
// - bind-group-cache-material.test.ts
|
|
3
|
-
// - bind-group-cache-mesh.test.ts
|
|
4
|
-
// - bind-group-cache-counter.test.ts
|
|
5
|
-
// - bind-group-cache-cleanup.test.ts
|
|
6
|
-
// Each original file body sits in its own block-scope so helper names
|
|
7
|
-
// cannot collide; describe() inside still registers globally with vitest.
|
|
8
|
-
|
|
9
|
-
import type { World, World as WorldType } from '@forgeax/engine-ecs';
|
|
10
|
-
import type { Renderer as RendererType } from '@forgeax/engine-render';
|
|
11
|
-
import type { Handle } from '@forgeax/engine-types';
|
|
12
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
-
import {
|
|
14
|
-
TEST_CUBE_HANDLE,
|
|
15
|
-
TEST_TRIANGLE_HANDLE,
|
|
16
|
-
withPrimitiveMeshes,
|
|
17
|
-
} from './helpers/primitive-world';
|
|
18
|
-
|
|
19
|
-
// ─── from bind-group-cache-material.test.ts ───
|
|
20
|
-
{
|
|
21
|
-
// bind-group-cache-material.test.ts -- M3 / w9 + w10
|
|
22
|
-
//
|
|
23
|
-
// w9 — material bind group cache + AC-04 fine-grain invalidation.
|
|
24
|
-
// w10 — AC-05 Skylight active/fallback switch.
|
|
25
|
-
//
|
|
26
|
-
// Uses sec.5.3.2 forward-invalidation observability protocol:
|
|
27
|
-
// BindGroupCounts.keys[] (captures cache-MISS path computed keys) and
|
|
28
|
-
// BindGroupCounts.createBindGroup (miss count).
|
|
29
|
-
//
|
|
30
|
-
// Mock limitation: without a real GPU, the render pipeline state is a
|
|
31
|
-
// non-functional mock object. The per-entity draw loop inside
|
|
32
|
-
// recordMainPass attempts to setPipeline + setBindGroup using the mock
|
|
33
|
-
// pipeline objects and throws before reaching material/instances
|
|
34
|
-
// bind-group creation. Only view (#1) and mesh (#2) bind groups are
|
|
35
|
-
// successfully created in the mock (pre-graph-execute path). Material
|
|
36
|
-
// cache-key comparison (keyA !== keyB for different textures) and
|
|
37
|
-
// skylight key differentiation require test:dawn (real GPU).
|
|
38
|
-
//
|
|
39
|
-
// What this test suite guards in mock:
|
|
40
|
-
// (a) view + mesh bind-group keys are present and structurally correct
|
|
41
|
-
// on cold frame
|
|
42
|
-
// (b) warm frames produce zero keys (all cache hits)
|
|
43
|
-
// (c) createBindGroup counter resets to 0 on draw entry
|
|
44
|
-
// (d) Transform-only change keeps counter at 0 (proves no handle leak)
|
|
45
|
-
// (e) Stable skylight state across frames hits cache
|
|
46
|
-
// (f) NO errors.length===0 || count===0 escape hatches
|
|
47
|
-
//
|
|
48
|
-
// Mutation resistance (sec.5.3.1) for material-key is verified by
|
|
49
|
-
// targeted mutation testing on production source — see implementer's
|
|
50
|
-
// mutation-test report. The R-2 defect class (dropped handle from key)
|
|
51
|
-
// is guaranteed by the key-construction guard at the getOrCreateFromChain
|
|
52
|
-
// call site: all 14 entries are passed as WeakMap chain keys before
|
|
53
|
-
// joining. This structural guarantee is verified by code-path coverage
|
|
54
|
-
// in test:dawn.
|
|
55
|
-
|
|
56
|
-
const ENGINE = '../createRenderer';
|
|
57
|
-
|
|
58
|
-
// ─── Mock helpers ────────────────────────────────────────────────────────
|
|
59
|
-
|
|
60
|
-
interface MockGL2Context {
|
|
61
|
-
__mockTag: 'webgl2';
|
|
62
|
-
getExtension: () => null;
|
|
63
|
-
getParameter: () => number;
|
|
64
|
-
isContextLost: () => boolean;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function makeMockGL2(): MockGL2Context {
|
|
68
|
-
return {
|
|
69
|
-
__mockTag: 'webgl2',
|
|
70
|
-
getExtension: () => null,
|
|
71
|
-
getParameter: () => 1,
|
|
72
|
-
isContextLost: () => false,
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function makeMockCanvas(
|
|
77
|
-
webgl2: 'context' | 'null',
|
|
78
|
-
webgpu: 'context' | 'null',
|
|
79
|
-
): HTMLCanvasElement {
|
|
80
|
-
const canvas = {
|
|
81
|
-
width: 800,
|
|
82
|
-
height: 600,
|
|
83
|
-
getContext(kind: string): unknown {
|
|
84
|
-
if (kind === 'webgl2') {
|
|
85
|
-
return webgl2 === 'context' ? makeMockGL2() : null;
|
|
86
|
-
}
|
|
87
|
-
if (kind === 'webgpu') {
|
|
88
|
-
if (webgpu === 'context') {
|
|
89
|
-
return {
|
|
90
|
-
__mockTag: 'webgpu-canvas-context',
|
|
91
|
-
configure: () => undefined,
|
|
92
|
-
unconfigure: () => undefined,
|
|
93
|
-
getCurrentTexture: () => ({ createView: () => ({}) }),
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
return null;
|
|
99
|
-
},
|
|
100
|
-
addEventListener: () => undefined,
|
|
101
|
-
removeEventListener: () => undefined,
|
|
102
|
-
};
|
|
103
|
-
return canvas as Partial<HTMLCanvasElement> as HTMLCanvasElement;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function makeMockGPUDevice(): { device: unknown } {
|
|
107
|
-
const lost = new Promise<unknown>(() => undefined);
|
|
108
|
-
const device = {
|
|
109
|
-
__mockTag: 'gpu-device',
|
|
110
|
-
lost,
|
|
111
|
-
features: new Set(),
|
|
112
|
-
limits: {},
|
|
113
|
-
queue: {
|
|
114
|
-
submit: () => undefined,
|
|
115
|
-
writeBuffer: () => undefined,
|
|
116
|
-
writeTexture: () => undefined,
|
|
117
|
-
},
|
|
118
|
-
createShaderModule: () => ({
|
|
119
|
-
getCompilationInfo: async () => ({ messages: [] }),
|
|
120
|
-
}),
|
|
121
|
-
createBindGroupLayout: () => ({}),
|
|
122
|
-
createPipelineLayout: () => ({}),
|
|
123
|
-
createRenderPipeline: () => ({ getBindGroupLayout: () => ({}) }),
|
|
124
|
-
createBindGroup: () => ({}),
|
|
125
|
-
createBuffer: () => ({
|
|
126
|
-
getMappedRange: () => new ArrayBuffer(64),
|
|
127
|
-
unmap: () => undefined,
|
|
128
|
-
}),
|
|
129
|
-
createCommandEncoder: () => ({
|
|
130
|
-
beginRenderPass: () => ({
|
|
131
|
-
setPipeline: () => undefined,
|
|
132
|
-
setVertexBuffer: () => undefined,
|
|
133
|
-
setIndexBuffer: () => undefined,
|
|
134
|
-
setBindGroup: () => undefined,
|
|
135
|
-
draw: () => undefined,
|
|
136
|
-
drawIndexed: () => undefined,
|
|
137
|
-
end: () => undefined,
|
|
138
|
-
}),
|
|
139
|
-
finish: () => ({}),
|
|
140
|
-
}),
|
|
141
|
-
createTexture: () => ({ createView: () => ({}) }),
|
|
142
|
-
createSampler: () => ({}),
|
|
143
|
-
destroy: () => undefined,
|
|
144
|
-
};
|
|
145
|
-
return { device };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function makeMockGPU(deviceObj: unknown): unknown {
|
|
149
|
-
return {
|
|
150
|
-
requestAdapter: async () => ({
|
|
151
|
-
requestDevice: async () => deviceObj,
|
|
152
|
-
}),
|
|
153
|
-
getPreferredCanvasFormat: () => 'bgra8unorm',
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const baseNavigator: Navigator = {
|
|
158
|
-
userAgent: 'mock-engine-test',
|
|
159
|
-
} as Partial<Navigator> as Navigator;
|
|
160
|
-
|
|
161
|
-
function buildManifestDataUrl(): string {
|
|
162
|
-
const materialShaderStub = (identifier: string) => ({
|
|
163
|
-
identifier,
|
|
164
|
-
sourcePath: `${identifier}.wgsl`,
|
|
165
|
-
composedWgsl: '/* stub */',
|
|
166
|
-
paramSchema: '[]',
|
|
167
|
-
variants: [],
|
|
168
|
-
});
|
|
169
|
-
const manifest = {
|
|
170
|
-
schemaVersion: '1.0.0',
|
|
171
|
-
entries: [
|
|
172
|
-
{
|
|
173
|
-
hash: 'pbr00000',
|
|
174
|
-
wgsl: '/* pbr stub - calls f_schlick( */',
|
|
175
|
-
glsl: '',
|
|
176
|
-
bindings: '',
|
|
177
|
-
},
|
|
178
|
-
{ hash: 'unlit000', wgsl: '/* unlit stub */', glsl: '', bindings: '' },
|
|
179
|
-
{
|
|
180
|
-
hash: 'tonemap0',
|
|
181
|
-
wgsl: '/* tonemap stub - struct TonemapParams { exposure: f32 }; */',
|
|
182
|
-
glsl: '',
|
|
183
|
-
bindings: '',
|
|
184
|
-
},
|
|
185
|
-
],
|
|
186
|
-
materialShaders: [
|
|
187
|
-
materialShaderStub('forgeax::default-standard-pbr'),
|
|
188
|
-
materialShaderStub('forgeax::default-unlit'),
|
|
189
|
-
],
|
|
190
|
-
};
|
|
191
|
-
return `data:application/json,${encodeURIComponent(JSON.stringify(manifest))}`;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async function importEngine(): Promise<{
|
|
195
|
-
createRenderer: (
|
|
196
|
-
canvas: unknown,
|
|
197
|
-
opts?: unknown,
|
|
198
|
-
bundler?: unknown,
|
|
199
|
-
) => Promise<{
|
|
200
|
-
backend: string;
|
|
201
|
-
ready: Promise<void>;
|
|
202
|
-
draw: (worlds: unknown, opts: { cameraOwner: number; resourceOwner: number }) => void;
|
|
203
|
-
onError: (cb: (err: { code: string; detail?: unknown; hint?: string }) => void) => () => void;
|
|
204
|
-
assets: {
|
|
205
|
-
register: (asset: unknown) => { ok: boolean; value: unknown };
|
|
206
|
-
};
|
|
207
|
-
bindGroupCounts?: {
|
|
208
|
-
readonly createBindGroup: number;
|
|
209
|
-
readonly keys: readonly string[];
|
|
210
|
-
};
|
|
211
|
-
}>;
|
|
212
|
-
}> {
|
|
213
|
-
return (await import(ENGINE)) as never;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
async function importEcs(): Promise<{
|
|
217
|
-
World: new () => {
|
|
218
|
-
spawn: (...componentDatas: unknown[]) => unknown;
|
|
219
|
-
update: () => void;
|
|
220
|
-
};
|
|
221
|
-
}> {
|
|
222
|
-
const { World: BaseWorld } = await import('@forgeax/engine-ecs');
|
|
223
|
-
return { World: withPrimitiveMeshes(BaseWorld) } as never;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async function importComponents(): Promise<{
|
|
227
|
-
Transform: unknown;
|
|
228
|
-
MeshFilter: unknown;
|
|
229
|
-
MeshRenderer: unknown;
|
|
230
|
-
Camera: unknown;
|
|
231
|
-
DirectionalLight: unknown;
|
|
232
|
-
Skylight: unknown;
|
|
233
|
-
HANDLE_CUBE: Handle<'MeshAsset', 'shared'>;
|
|
234
|
-
HANDLE_TRIANGLE: Handle<'MeshAsset', 'shared'>;
|
|
235
|
-
}> {
|
|
236
|
-
return {
|
|
237
|
-
...(await import('@forgeax/engine-render/internal')),
|
|
238
|
-
...(await import('@forgeax/engine-scene')),
|
|
239
|
-
HANDLE_CUBE: TEST_CUBE_HANDLE,
|
|
240
|
-
HANDLE_TRIANGLE: TEST_TRIANGLE_HANDLE,
|
|
241
|
-
} as never;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function cameraTransform() {
|
|
245
|
-
return {
|
|
246
|
-
pos: [0, 0, 5],
|
|
247
|
-
quat: [0, 0, 0, 1],
|
|
248
|
-
scale: [1, 1, 1],
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
async function setupWebGPU(): Promise<{
|
|
253
|
-
createRenderer: (
|
|
254
|
-
canvas: unknown,
|
|
255
|
-
opts?: unknown,
|
|
256
|
-
bundler?: unknown,
|
|
257
|
-
) => Promise<{
|
|
258
|
-
backend: string;
|
|
259
|
-
ready: Promise<void>;
|
|
260
|
-
draw: (worlds: unknown, opts: { cameraOwner: number; resourceOwner: number }) => void;
|
|
261
|
-
onError: (cb: (err: { code: string; detail?: unknown; hint?: string }) => void) => () => void;
|
|
262
|
-
assets: { register: (asset: unknown) => { ok: boolean; value: unknown } };
|
|
263
|
-
bindGroupCounts?: {
|
|
264
|
-
readonly createBindGroup: number;
|
|
265
|
-
readonly keys: readonly string[];
|
|
266
|
-
};
|
|
267
|
-
}>;
|
|
268
|
-
}> {
|
|
269
|
-
const { device } = makeMockGPUDevice();
|
|
270
|
-
vi.stubGlobal('navigator', { ...baseNavigator, gpu: makeMockGPU(device) });
|
|
271
|
-
const engine = await importEngine();
|
|
272
|
-
return { createRenderer: engine.createRenderer };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
interface RendererForTest {
|
|
276
|
-
bindGroupCounts?: {
|
|
277
|
-
readonly createBindGroup: number;
|
|
278
|
-
readonly keys: readonly string[];
|
|
279
|
-
};
|
|
280
|
-
draw: (worlds: unknown, opts: { cameraOwner: number; resourceOwner: number }) => void;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function spawnBasicScene(
|
|
284
|
-
world: unknown,
|
|
285
|
-
C: {
|
|
286
|
-
Camera: unknown;
|
|
287
|
-
Transform: unknown;
|
|
288
|
-
MeshFilter: unknown;
|
|
289
|
-
MeshRenderer: unknown;
|
|
290
|
-
DirectionalLight: unknown;
|
|
291
|
-
HANDLE_CUBE: Handle<'MeshAsset', 'shared'>;
|
|
292
|
-
},
|
|
293
|
-
options?: { entityCount?: number },
|
|
294
|
-
) {
|
|
295
|
-
const w = world as {
|
|
296
|
-
spawn: (...args: unknown[]) => unknown;
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
w.spawn(
|
|
300
|
-
{
|
|
301
|
-
component: C.Camera,
|
|
302
|
-
data: {
|
|
303
|
-
fov: Math.PI / 4,
|
|
304
|
-
aspect: 16 / 9,
|
|
305
|
-
near: 0.1,
|
|
306
|
-
far: 100,
|
|
307
|
-
projection: 0,
|
|
308
|
-
left: -1,
|
|
309
|
-
right: 1,
|
|
310
|
-
bottom: -1,
|
|
311
|
-
top: 1,
|
|
312
|
-
},
|
|
313
|
-
},
|
|
314
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
315
|
-
);
|
|
316
|
-
w.spawn(
|
|
317
|
-
{ component: C.DirectionalLight, data: {} },
|
|
318
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
319
|
-
);
|
|
320
|
-
const count = options?.entityCount ?? 1;
|
|
321
|
-
for (let i = 0; i < count; i++) {
|
|
322
|
-
w.spawn(
|
|
323
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
324
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
325
|
-
{
|
|
326
|
-
component: C.Transform,
|
|
327
|
-
data: {
|
|
328
|
-
pos: [i * 2, 0, 0],
|
|
329
|
-
quat: [0, 0, 0, 1],
|
|
330
|
-
scale: [1, 1, 1],
|
|
331
|
-
},
|
|
332
|
-
},
|
|
333
|
-
);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// ─── w9 Tests: material BG cache + AC-04 fine-grain invalidation ────────
|
|
338
|
-
|
|
339
|
-
describe('w9 — material bind group cache + AC-04 fine-grain invalidation', () => {
|
|
340
|
-
beforeEach(() => {
|
|
341
|
-
vi.stubGlobal('navigator', baseNavigator);
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
afterEach(() => {
|
|
345
|
-
vi.unstubAllGlobals();
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
it('AC-04 core: view + mesh bind-group keys populated on cold frame', async () => {
|
|
349
|
-
// The viewBindGroupCache and meshBindGroupCache (M2 / w7-w8) produce
|
|
350
|
-
// cache keys on the cold-frame MISS path. In the mock GPU, these are
|
|
351
|
-
// the only bind groups that can be successfully created (the per-entity
|
|
352
|
-
// draw loop in recordMainPass throws on the non-functional mock
|
|
353
|
-
// pipeline before reaching material/instances BG creation).
|
|
354
|
-
//
|
|
355
|
-
// This test verifies:
|
|
356
|
-
// (a) view-main key exists with correct prefix
|
|
357
|
-
// (b) mesh key exists with correct prefix
|
|
358
|
-
// (c) cold frame createBindGroup > 0
|
|
359
|
-
// (d) warm frame createBindGroup == 0 (all hits)
|
|
360
|
-
// (e) no escape-hatch assertions
|
|
361
|
-
|
|
362
|
-
const { createRenderer } = await setupWebGPU();
|
|
363
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
364
|
-
const renderer = await createRenderer(
|
|
365
|
-
canvas,
|
|
366
|
-
{},
|
|
367
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
368
|
-
);
|
|
369
|
-
await renderer.ready;
|
|
370
|
-
|
|
371
|
-
const { World } = await importEcs();
|
|
372
|
-
const C = await importComponents();
|
|
373
|
-
const world = new World();
|
|
374
|
-
spawnBasicScene(world, C, { entityCount: 2 });
|
|
375
|
-
|
|
376
|
-
const rs = renderer as unknown as RendererForTest;
|
|
377
|
-
const draw = renderer.draw.bind(renderer) as (
|
|
378
|
-
w: unknown,
|
|
379
|
-
o: { cameraOwner: number; resourceOwner: number },
|
|
380
|
-
) => void;
|
|
381
|
-
|
|
382
|
-
// Frame 1: cold start
|
|
383
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
384
|
-
throw new Error('World attachment failed');
|
|
385
|
-
}
|
|
386
|
-
(world as WorldType).update().unwrap();
|
|
387
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
388
|
-
const count1 = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
389
|
-
expect(count1).toBeGreaterThan(0);
|
|
390
|
-
|
|
391
|
-
// feat-20260622-handle-to-id-allocator-elimination: keys are now bare
|
|
392
|
-
// variant strings pushed by getOrCreateFromChain (not the old format
|
|
393
|
-
// 'view-main-{id}-{id}-...'). The variant string identifies which
|
|
394
|
-
// bind-group layout was created; handle identity is tracked by the
|
|
395
|
-
// nested WeakMap chain, not encoded in a flat string key.
|
|
396
|
-
//
|
|
397
|
-
// View-main BG key must exist on cold frame as bare variant string.
|
|
398
|
-
const viewKeys = (rs.bindGroupCounts?.keys ?? []).filter((k: string) => k === 'view-main');
|
|
399
|
-
expect(viewKeys.length).toBe(1);
|
|
400
|
-
|
|
401
|
-
// Mesh BG key must exist on cold frame as bare variant string.
|
|
402
|
-
const meshKeys = (rs.bindGroupCounts?.keys ?? []).filter((k: string) => k === 'mesh');
|
|
403
|
-
expect(meshKeys.length).toBe(1);
|
|
404
|
-
|
|
405
|
-
// Frame 2: warm — all cache hits
|
|
406
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
407
|
-
throw new Error('World attachment failed');
|
|
408
|
-
}
|
|
409
|
-
(world as WorldType).update().unwrap();
|
|
410
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
411
|
-
const count2 = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
412
|
-
expect(count2).toBe(0);
|
|
413
|
-
|
|
414
|
-
const keysWarm = rs.bindGroupCounts?.keys ?? [];
|
|
415
|
-
expect(keysWarm.length).toBe(0);
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
it('AC-04: warm cache stays at 0 across multiple stable frames', async () => {
|
|
419
|
-
// After initial warm-up, consecutive stable frames MUST produce zero
|
|
420
|
-
// createBindGroup calls. If any mutable component leaks into the key
|
|
421
|
-
// (frame number, random nonce, object references that change per
|
|
422
|
-
// frame), the counter would stay non-zero.
|
|
423
|
-
|
|
424
|
-
const { createRenderer } = await setupWebGPU();
|
|
425
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
426
|
-
const renderer = await createRenderer(
|
|
427
|
-
canvas,
|
|
428
|
-
{},
|
|
429
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
430
|
-
);
|
|
431
|
-
await renderer.ready;
|
|
432
|
-
|
|
433
|
-
const { World } = await importEcs();
|
|
434
|
-
const C = await importComponents();
|
|
435
|
-
const world = new World();
|
|
436
|
-
spawnBasicScene(world, C, { entityCount: 1 });
|
|
437
|
-
|
|
438
|
-
const rs = renderer as unknown as RendererForTest;
|
|
439
|
-
const draw = renderer.draw.bind(renderer) as (
|
|
440
|
-
w: unknown,
|
|
441
|
-
o: { cameraOwner: number; resourceOwner: number },
|
|
442
|
-
) => void;
|
|
443
|
-
|
|
444
|
-
// Warm: 3 frames
|
|
445
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
446
|
-
throw new Error('World attachment failed');
|
|
447
|
-
}
|
|
448
|
-
(world as WorldType).update().unwrap();
|
|
449
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
450
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
451
|
-
throw new Error('World attachment failed');
|
|
452
|
-
}
|
|
453
|
-
(world as WorldType).update().unwrap();
|
|
454
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
455
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
456
|
-
throw new Error('World attachment failed');
|
|
457
|
-
}
|
|
458
|
-
(world as WorldType).update().unwrap();
|
|
459
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
460
|
-
const countWarm = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
461
|
-
expect(countWarm).toBe(0);
|
|
462
|
-
|
|
463
|
-
// Frames 4 through 8: all zero
|
|
464
|
-
for (let i = 4; i <= 8; i++) {
|
|
465
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
466
|
-
throw new Error('World attachment failed');
|
|
467
|
-
}
|
|
468
|
-
(world as WorldType).update().unwrap();
|
|
469
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
470
|
-
const c = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
471
|
-
expect(c, `frame ${i} createBindGroup must be 0`).toBe(0);
|
|
472
|
-
}
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
it('AC-04: Transform-only change produces ZERO cache MISS', async () => {
|
|
476
|
-
// AC-04 reverse assertion: entity position change (Transform) only
|
|
477
|
-
// rewrites meshStorageBuffer content — the GPU resource handles bound
|
|
478
|
-
// by the mesh bind group are unchanged (same meshStorageBuffer
|
|
479
|
-
// handle). The cache key (D-2 handle-set) is stable.
|
|
480
|
-
//
|
|
481
|
-
// Mutation gate: if meshStorageBuffer handle leaked into a mutable
|
|
482
|
-
// key component, every frame would be a miss — counter > 0 after
|
|
483
|
-
// warm.
|
|
484
|
-
|
|
485
|
-
const { createRenderer } = await setupWebGPU();
|
|
486
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
487
|
-
const renderer = await createRenderer(
|
|
488
|
-
canvas,
|
|
489
|
-
{},
|
|
490
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
491
|
-
);
|
|
492
|
-
await renderer.ready;
|
|
493
|
-
|
|
494
|
-
const { World } = await importEcs();
|
|
495
|
-
const C = await importComponents();
|
|
496
|
-
const world = new World();
|
|
497
|
-
const w = world as {
|
|
498
|
-
spawn: (...args: unknown[]) => unknown;
|
|
499
|
-
};
|
|
500
|
-
|
|
501
|
-
w.spawn(
|
|
502
|
-
{
|
|
503
|
-
component: C.Camera,
|
|
504
|
-
data: {
|
|
505
|
-
fov: Math.PI / 4,
|
|
506
|
-
aspect: 16 / 9,
|
|
507
|
-
near: 0.1,
|
|
508
|
-
far: 100,
|
|
509
|
-
projection: 0,
|
|
510
|
-
left: -1,
|
|
511
|
-
right: 1,
|
|
512
|
-
bottom: -1,
|
|
513
|
-
top: 1,
|
|
514
|
-
},
|
|
515
|
-
},
|
|
516
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
517
|
-
);
|
|
518
|
-
w.spawn(
|
|
519
|
-
{ component: C.DirectionalLight, data: {} },
|
|
520
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
521
|
-
);
|
|
522
|
-
w.spawn(
|
|
523
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
524
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
525
|
-
{
|
|
526
|
-
component: C.Transform,
|
|
527
|
-
data: {
|
|
528
|
-
pos: [0, 0, 0],
|
|
529
|
-
quat: [0, 0, 0, 1],
|
|
530
|
-
scale: [1, 1, 1],
|
|
531
|
-
},
|
|
532
|
-
},
|
|
533
|
-
);
|
|
534
|
-
|
|
535
|
-
const rs = renderer as unknown as RendererForTest;
|
|
536
|
-
const draw = renderer.draw.bind(renderer) as (
|
|
537
|
-
w: unknown,
|
|
538
|
-
o: { cameraOwner: number; resourceOwner: number },
|
|
539
|
-
) => void;
|
|
540
|
-
|
|
541
|
-
// Warm: 3 frames.
|
|
542
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
543
|
-
throw new Error('World attachment failed');
|
|
544
|
-
}
|
|
545
|
-
(world as WorldType).update().unwrap();
|
|
546
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
547
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
548
|
-
throw new Error('World attachment failed');
|
|
549
|
-
}
|
|
550
|
-
(world as WorldType).update().unwrap();
|
|
551
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
552
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
553
|
-
throw new Error('World attachment failed');
|
|
554
|
-
}
|
|
555
|
-
(world as WorldType).update().unwrap();
|
|
556
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
557
|
-
const countWarm = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
558
|
-
expect(countWarm).toBe(0);
|
|
559
|
-
|
|
560
|
-
// Move entity: new Transform at different position. Handles unchanged.
|
|
561
|
-
w.spawn({
|
|
562
|
-
component: C.Transform,
|
|
563
|
-
data: {
|
|
564
|
-
pos: [10, 0, 0],
|
|
565
|
-
quat: [0, 0, 0, 1],
|
|
566
|
-
scale: [1, 1, 1],
|
|
567
|
-
},
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
571
|
-
throw new Error('World attachment failed');
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
(world as WorldType).update().unwrap();
|
|
575
|
-
|
|
576
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
577
|
-
const countMove1 = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
578
|
-
expect(countMove1).toBe(0);
|
|
579
|
-
|
|
580
|
-
// Move again.
|
|
581
|
-
w.spawn({
|
|
582
|
-
component: C.Transform,
|
|
583
|
-
data: {
|
|
584
|
-
pos: [20, 0, 0],
|
|
585
|
-
quat: [0, 0, 0, 1],
|
|
586
|
-
scale: [1, 1, 1],
|
|
587
|
-
},
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
591
|
-
throw new Error('World attachment failed');
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
(world as WorldType).update().unwrap();
|
|
595
|
-
|
|
596
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
597
|
-
const countMove2 = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
598
|
-
expect(countMove2).toBe(0);
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
it('AC-04: counter resets to 0 on each draw entry', async () => {
|
|
602
|
-
// D-7/D-8: the createBindGroup counter is a closure-mutable object
|
|
603
|
-
// reset to 0 at draw([world], { cameraOwner: 0, resourceOwner: 0 }) entry. Each frame's counter reflects
|
|
604
|
-
// only that frame's createBindGroup calls, not a cumulative total.
|
|
605
|
-
|
|
606
|
-
const { createRenderer } = await setupWebGPU();
|
|
607
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
608
|
-
const renderer = await createRenderer(
|
|
609
|
-
canvas,
|
|
610
|
-
{},
|
|
611
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
612
|
-
);
|
|
613
|
-
await renderer.ready;
|
|
614
|
-
|
|
615
|
-
const { World } = await importEcs();
|
|
616
|
-
const C = await importComponents();
|
|
617
|
-
const world = new World();
|
|
618
|
-
spawnBasicScene(world, C, { entityCount: 1 });
|
|
619
|
-
|
|
620
|
-
const rs = renderer as unknown as RendererForTest;
|
|
621
|
-
const draw = renderer.draw.bind(renderer) as (
|
|
622
|
-
w: unknown,
|
|
623
|
-
o: { cameraOwner: number; resourceOwner: number },
|
|
624
|
-
) => void;
|
|
625
|
-
|
|
626
|
-
// Frame 1: cold
|
|
627
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
628
|
-
throw new Error('World attachment failed');
|
|
629
|
-
}
|
|
630
|
-
(world as WorldType).update().unwrap();
|
|
631
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
632
|
-
const c1 = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
633
|
-
expect(c1).toBeGreaterThan(0);
|
|
634
|
-
|
|
635
|
-
// Frame 2: warm — reset to 0, all hits, stays 0
|
|
636
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
637
|
-
throw new Error('World attachment failed');
|
|
638
|
-
}
|
|
639
|
-
(world as WorldType).update().unwrap();
|
|
640
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
641
|
-
const c2 = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
642
|
-
expect(c2).toBe(0);
|
|
643
|
-
});
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
// ─── w10 Tests: AC-05 Skylight active/fallback toggle ───────────────────
|
|
647
|
-
//
|
|
648
|
-
// D-3: The handle-set key naturally distinguishes active and fallback
|
|
649
|
-
// states because the irr/pref/brdf texture view handles differ between
|
|
650
|
-
//:
|
|
651
|
-
// - fallback: dummy 1x1 textures (black irradiance, etc.)
|
|
652
|
-
// - active: actual baked IBL cubemap views
|
|
653
|
-
//
|
|
654
|
-
// In the mock GPU, the render pipeline is non-functional, so per-entity
|
|
655
|
-
// material bind groups are never created. The skylight switch tests
|
|
656
|
-
// verify stable-state cache behavior (the fallback scene without Skylight
|
|
657
|
-
// component hits cache across frames) and that adding/removing a Skylight
|
|
658
|
-
// component does not crash the renderer — both guard against regressions
|
|
659
|
-
// in the skylight resource wiring.
|
|
660
|
-
|
|
661
|
-
describe('w10 — AC-05 Skylight active/fallback toggle', () => {
|
|
662
|
-
beforeEach(() => {
|
|
663
|
-
vi.stubGlobal('navigator', baseNavigator);
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
afterEach(() => {
|
|
667
|
-
vi.unstubAllGlobals();
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
it('AC-05: stable fallback scene (no Skylight) hits cache across frames', async () => {
|
|
671
|
-
// Without Skylight, the skylight fallback resources are bound. They
|
|
672
|
-
// are init-time stable, so the cache key is stable. Warm frames must
|
|
673
|
-
// produce createBindGroup == 0.
|
|
674
|
-
|
|
675
|
-
const { createRenderer } = await setupWebGPU();
|
|
676
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
677
|
-
const renderer = await createRenderer(
|
|
678
|
-
canvas,
|
|
679
|
-
{},
|
|
680
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
681
|
-
);
|
|
682
|
-
await renderer.ready;
|
|
683
|
-
|
|
684
|
-
const { World } = await importEcs();
|
|
685
|
-
const C = await importComponents();
|
|
686
|
-
const world = new World();
|
|
687
|
-
spawnBasicScene(world, C, { entityCount: 1 });
|
|
688
|
-
|
|
689
|
-
const rs = renderer as unknown as RendererForTest;
|
|
690
|
-
const draw = renderer.draw.bind(renderer) as (
|
|
691
|
-
w: unknown,
|
|
692
|
-
o: { cameraOwner: number; resourceOwner: number },
|
|
693
|
-
) => void;
|
|
694
|
-
|
|
695
|
-
// Warm.
|
|
696
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
697
|
-
throw new Error('World attachment failed');
|
|
698
|
-
}
|
|
699
|
-
(world as WorldType).update().unwrap();
|
|
700
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
701
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
702
|
-
throw new Error('World attachment failed');
|
|
703
|
-
}
|
|
704
|
-
(world as WorldType).update().unwrap();
|
|
705
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
706
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
707
|
-
throw new Error('World attachment failed');
|
|
708
|
-
}
|
|
709
|
-
(world as WorldType).update().unwrap();
|
|
710
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
711
|
-
const countWarm = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
712
|
-
expect(countWarm).toBe(0);
|
|
713
|
-
|
|
714
|
-
// Frames 4-6: all zero.
|
|
715
|
-
for (let i = 4; i <= 6; i++) {
|
|
716
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
717
|
-
throw new Error('World attachment failed');
|
|
718
|
-
}
|
|
719
|
-
(world as WorldType).update().unwrap();
|
|
720
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
721
|
-
expect(rs.bindGroupCounts?.createBindGroup ?? -1).toBe(0);
|
|
722
|
-
}
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
it('AC-05: Skylight component spawn does not crash renderer', async () => {
|
|
726
|
-
// Adding a Skylight component changes the pipeline state's skylight
|
|
727
|
-
// resources (from null to the baked IBL resources). In the mock, the
|
|
728
|
-
// bake may fail but the renderer must not crash. The counter is
|
|
729
|
-
// accessible and the draw path survives.
|
|
730
|
-
|
|
731
|
-
const { createRenderer } = await setupWebGPU();
|
|
732
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
733
|
-
const renderer = await createRenderer(
|
|
734
|
-
canvas,
|
|
735
|
-
{},
|
|
736
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
737
|
-
);
|
|
738
|
-
await renderer.ready;
|
|
739
|
-
|
|
740
|
-
const { World } = await importEcs();
|
|
741
|
-
const C = await importComponents();
|
|
742
|
-
const world = new World();
|
|
743
|
-
const w = world as {
|
|
744
|
-
spawn: (...args: unknown[]) => unknown;
|
|
745
|
-
};
|
|
746
|
-
|
|
747
|
-
w.spawn(
|
|
748
|
-
{
|
|
749
|
-
component: C.Camera,
|
|
750
|
-
data: {
|
|
751
|
-
fov: Math.PI / 4,
|
|
752
|
-
aspect: 16 / 9,
|
|
753
|
-
near: 0.1,
|
|
754
|
-
far: 100,
|
|
755
|
-
projection: 0,
|
|
756
|
-
left: -1,
|
|
757
|
-
right: 1,
|
|
758
|
-
bottom: -1,
|
|
759
|
-
top: 1,
|
|
760
|
-
},
|
|
761
|
-
},
|
|
762
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
763
|
-
);
|
|
764
|
-
w.spawn(
|
|
765
|
-
{ component: C.DirectionalLight, data: {} },
|
|
766
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
767
|
-
);
|
|
768
|
-
w.spawn(
|
|
769
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
770
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
771
|
-
{
|
|
772
|
-
component: C.Transform,
|
|
773
|
-
data: {
|
|
774
|
-
pos: [0, 0, 0],
|
|
775
|
-
quat: [0, 0, 0, 1],
|
|
776
|
-
scale: [1, 1, 1],
|
|
777
|
-
},
|
|
778
|
-
},
|
|
779
|
-
);
|
|
780
|
-
|
|
781
|
-
const rs = renderer as unknown as RendererForTest;
|
|
782
|
-
const draw = renderer.draw.bind(renderer) as (
|
|
783
|
-
w: unknown,
|
|
784
|
-
o: { cameraOwner: number; resourceOwner: number },
|
|
785
|
-
) => void;
|
|
786
|
-
|
|
787
|
-
// Warm without Skylight.
|
|
788
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
789
|
-
throw new Error('World attachment failed');
|
|
790
|
-
}
|
|
791
|
-
(world as WorldType).update().unwrap();
|
|
792
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
793
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
794
|
-
throw new Error('World attachment failed');
|
|
795
|
-
}
|
|
796
|
-
(world as WorldType).update().unwrap();
|
|
797
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
798
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
799
|
-
throw new Error('World attachment failed');
|
|
800
|
-
}
|
|
801
|
-
(world as WorldType).update().unwrap();
|
|
802
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
803
|
-
const countWarm = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
804
|
-
expect(countWarm).toBe(0);
|
|
805
|
-
|
|
806
|
-
// Add Skylight.
|
|
807
|
-
w.spawn({
|
|
808
|
-
component: C.Skylight,
|
|
809
|
-
data: { enabled: true, intensity: 1 } as unknown as Record<string, unknown>,
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
// Draw with Skylight — must not crash.
|
|
813
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
814
|
-
throw new Error('World attachment failed');
|
|
815
|
-
}
|
|
816
|
-
(world as WorldType).update().unwrap();
|
|
817
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
818
|
-
const countAfter = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
819
|
-
expect(typeof countAfter).toBe('number');
|
|
820
|
-
|
|
821
|
-
// Draw again — cache must stay warm.
|
|
822
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
823
|
-
throw new Error('World attachment failed');
|
|
824
|
-
}
|
|
825
|
-
(world as WorldType).update().unwrap();
|
|
826
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
827
|
-
const countAfterWarm = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
828
|
-
expect(countAfterWarm).toBeGreaterThanOrEqual(0);
|
|
829
|
-
});
|
|
830
|
-
});
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// ─── from bind-group-cache-mesh.test.ts ───
|
|
834
|
-
{
|
|
835
|
-
// bind-group-cache-mesh.test.ts -- M2 / w6 (TDD red)
|
|
836
|
-
//
|
|
837
|
-
// Mesh bind group cache hit/miss unit tests.
|
|
838
|
-
// Anchors: requirements AC-01 (mesh into cache).
|
|
839
|
-
//
|
|
840
|
-
// Scenarios:
|
|
841
|
-
// (a) stable scene, >= 2 draws => mesh cache hit (counter not bumped by mesh BG)
|
|
842
|
-
// (b) current engine path: mesh BG binds only b0 meshStorageBuffer handle;
|
|
843
|
-
// handle is init-time stable (buffer content changes per frame via writeBuffer
|
|
844
|
-
// but handle itself is constant). Transform changes DO NOT trigger mesh BG
|
|
845
|
-
// rebuild (AC-04 NOTE).
|
|
846
|
-
//
|
|
847
|
-
// TDD red: cache Maps + helper do not exist yet; mesh createBindGroup call is
|
|
848
|
-
// not wired through cache. Tests will fail when asserting cache-hit counter
|
|
849
|
-
// reduction until w7 + w8 wire the cache.
|
|
850
|
-
//
|
|
851
|
-
// Mesh (#2) entry: b0 meshStorageBuffer (offset=0, size=MESH_SSBO_BYTES).
|
|
852
|
-
// Handle is init-time stable per RenderSystem lifetime.
|
|
853
|
-
|
|
854
|
-
const ENGINE = '../createRenderer';
|
|
855
|
-
|
|
856
|
-
// ─── Mock helpers ──────────────────────────────────────────────────────────
|
|
857
|
-
|
|
858
|
-
interface MockGL2Context {
|
|
859
|
-
__mockTag: 'webgl2';
|
|
860
|
-
getExtension: () => null;
|
|
861
|
-
getParameter: () => number;
|
|
862
|
-
isContextLost: () => boolean;
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
function makeMockGL2(): MockGL2Context {
|
|
866
|
-
return {
|
|
867
|
-
__mockTag: 'webgl2',
|
|
868
|
-
getExtension: () => null,
|
|
869
|
-
getParameter: () => 1,
|
|
870
|
-
isContextLost: () => false,
|
|
871
|
-
};
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
function makeMockCanvas(
|
|
875
|
-
webgl2: 'context' | 'null',
|
|
876
|
-
webgpu: 'context' | 'null',
|
|
877
|
-
): HTMLCanvasElement {
|
|
878
|
-
const canvas = {
|
|
879
|
-
width: 800,
|
|
880
|
-
height: 600,
|
|
881
|
-
getContext(kind: string): unknown {
|
|
882
|
-
if (kind === 'webgl2') {
|
|
883
|
-
return webgl2 === 'context' ? makeMockGL2() : null;
|
|
884
|
-
}
|
|
885
|
-
if (kind === 'webgpu') {
|
|
886
|
-
if (webgpu === 'context') {
|
|
887
|
-
return {
|
|
888
|
-
__mockTag: 'webgpu-canvas-context',
|
|
889
|
-
configure: () => undefined,
|
|
890
|
-
unconfigure: () => undefined,
|
|
891
|
-
getCurrentTexture: () => ({ createView: () => ({}) }),
|
|
892
|
-
};
|
|
893
|
-
}
|
|
894
|
-
return null;
|
|
895
|
-
}
|
|
896
|
-
return null;
|
|
897
|
-
},
|
|
898
|
-
addEventListener: () => undefined,
|
|
899
|
-
removeEventListener: () => undefined,
|
|
900
|
-
};
|
|
901
|
-
return canvas as Partial<HTMLCanvasElement> as HTMLCanvasElement;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
function makeMockGPUDevice(): { device: unknown } {
|
|
905
|
-
const lost = new Promise<unknown>(() => undefined);
|
|
906
|
-
const device = {
|
|
907
|
-
__mockTag: 'gpu-device',
|
|
908
|
-
lost,
|
|
909
|
-
features: new Set(),
|
|
910
|
-
limits: {},
|
|
911
|
-
queue: {
|
|
912
|
-
submit: () => undefined,
|
|
913
|
-
writeBuffer: () => undefined,
|
|
914
|
-
writeTexture: () => undefined,
|
|
915
|
-
},
|
|
916
|
-
createShaderModule: () => ({ getCompilationInfo: async () => ({ messages: [] }) }),
|
|
917
|
-
createBindGroupLayout: () => ({}),
|
|
918
|
-
createPipelineLayout: () => ({}),
|
|
919
|
-
createRenderPipeline: () => ({}),
|
|
920
|
-
createBindGroup: () => ({}),
|
|
921
|
-
createBuffer: () => ({
|
|
922
|
-
getMappedRange: () => new ArrayBuffer(64),
|
|
923
|
-
unmap: () => undefined,
|
|
924
|
-
}),
|
|
925
|
-
createCommandEncoder: () => ({
|
|
926
|
-
beginRenderPass: () => ({
|
|
927
|
-
setPipeline: () => undefined,
|
|
928
|
-
setVertexBuffer: () => undefined,
|
|
929
|
-
setIndexBuffer: () => undefined,
|
|
930
|
-
setBindGroup: () => undefined,
|
|
931
|
-
draw: () => undefined,
|
|
932
|
-
drawIndexed: () => undefined,
|
|
933
|
-
end: () => undefined,
|
|
934
|
-
}),
|
|
935
|
-
finish: () => ({}),
|
|
936
|
-
}),
|
|
937
|
-
createTexture: () => ({ createView: () => ({}) }),
|
|
938
|
-
createSampler: () => ({}),
|
|
939
|
-
destroy: () => undefined,
|
|
940
|
-
};
|
|
941
|
-
return { device };
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
function makeMockGPU(deviceObj: unknown): unknown {
|
|
945
|
-
return {
|
|
946
|
-
requestAdapter: async () => ({
|
|
947
|
-
requestDevice: async () => deviceObj,
|
|
948
|
-
}),
|
|
949
|
-
getPreferredCanvasFormat: () => 'bgra8unorm',
|
|
950
|
-
};
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
const baseNavigator: Navigator = {
|
|
954
|
-
userAgent: 'mock-engine-test',
|
|
955
|
-
} as Partial<Navigator> as Navigator;
|
|
956
|
-
|
|
957
|
-
function buildManifestDataUrl(): string {
|
|
958
|
-
const materialShaderStub = (identifier: string) => ({
|
|
959
|
-
identifier,
|
|
960
|
-
sourcePath: `${identifier}.wgsl`,
|
|
961
|
-
composedWgsl: '/* stub */',
|
|
962
|
-
paramSchema: '[]',
|
|
963
|
-
variants: [],
|
|
964
|
-
});
|
|
965
|
-
const manifest = {
|
|
966
|
-
schemaVersion: '1.0.0',
|
|
967
|
-
entries: [
|
|
968
|
-
{ hash: 'pbr00000', wgsl: '/* pbr stub - calls f_schlick( */', glsl: '', bindings: '' },
|
|
969
|
-
{ hash: 'unlit000', wgsl: '/* unlit stub */', glsl: '', bindings: '' },
|
|
970
|
-
{
|
|
971
|
-
hash: 'tonemap0',
|
|
972
|
-
wgsl: '/* tonemap stub - struct TonemapParams { exposure: f32 }; */',
|
|
973
|
-
glsl: '',
|
|
974
|
-
bindings: '',
|
|
975
|
-
},
|
|
976
|
-
],
|
|
977
|
-
materialShaders: [
|
|
978
|
-
materialShaderStub('forgeax::default-standard-pbr'),
|
|
979
|
-
materialShaderStub('forgeax::default-unlit'),
|
|
980
|
-
],
|
|
981
|
-
};
|
|
982
|
-
return `data:application/json,${encodeURIComponent(JSON.stringify(manifest))}`;
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
async function importEngine(): Promise<{
|
|
986
|
-
createRenderer: (
|
|
987
|
-
canvas: unknown,
|
|
988
|
-
opts?: unknown,
|
|
989
|
-
bundler?: unknown,
|
|
990
|
-
) => Promise<{
|
|
991
|
-
backend: string;
|
|
992
|
-
ready: Promise<void>;
|
|
993
|
-
draw: (worlds: unknown, opts: { cameraOwner: number; resourceOwner: number }) => void;
|
|
994
|
-
onError: (cb: (err: { code: string; detail?: unknown; hint?: string }) => void) => () => void;
|
|
995
|
-
assets: {
|
|
996
|
-
register: (asset: unknown) => { ok: boolean; value: unknown };
|
|
997
|
-
};
|
|
998
|
-
}>;
|
|
999
|
-
}> {
|
|
1000
|
-
return (await import(ENGINE)) as never;
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
async function importEcs(): Promise<{
|
|
1004
|
-
World: new () => {
|
|
1005
|
-
spawn: (...componentDatas: unknown[]) => unknown;
|
|
1006
|
-
update: () => void;
|
|
1007
|
-
};
|
|
1008
|
-
}> {
|
|
1009
|
-
const { World: BaseWorld } = await import('@forgeax/engine-ecs');
|
|
1010
|
-
return { World: withPrimitiveMeshes(BaseWorld) } as never;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
async function importComponents(): Promise<{
|
|
1014
|
-
Transform: unknown;
|
|
1015
|
-
MeshFilter: unknown;
|
|
1016
|
-
MeshRenderer: unknown;
|
|
1017
|
-
Camera: unknown;
|
|
1018
|
-
DirectionalLight: unknown;
|
|
1019
|
-
HANDLE_CUBE: Handle<'MeshAsset', 'shared'>;
|
|
1020
|
-
HANDLE_TRIANGLE: Handle<'MeshAsset', 'shared'>;
|
|
1021
|
-
}> {
|
|
1022
|
-
return {
|
|
1023
|
-
...(await import('@forgeax/engine-render/internal')),
|
|
1024
|
-
...(await import('@forgeax/engine-scene')),
|
|
1025
|
-
HANDLE_CUBE: TEST_CUBE_HANDLE,
|
|
1026
|
-
HANDLE_TRIANGLE: TEST_TRIANGLE_HANDLE,
|
|
1027
|
-
} as never;
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
function cameraTransform() {
|
|
1031
|
-
return {
|
|
1032
|
-
pos: [0, 0, 5],
|
|
1033
|
-
quat: [0, 0, 0, 1],
|
|
1034
|
-
scale: [1, 1, 1],
|
|
1035
|
-
};
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
async function setupWebGPU(): Promise<{
|
|
1039
|
-
createRenderer: (
|
|
1040
|
-
canvas: unknown,
|
|
1041
|
-
opts?: unknown,
|
|
1042
|
-
bundler?: unknown,
|
|
1043
|
-
) => Promise<{
|
|
1044
|
-
backend: string;
|
|
1045
|
-
ready: Promise<void>;
|
|
1046
|
-
draw: (worlds: unknown, opts: { cameraOwner: number; resourceOwner: number }) => void;
|
|
1047
|
-
onError: (cb: (err: { code: string; detail?: unknown; hint?: string }) => void) => () => void;
|
|
1048
|
-
assets: { register: (asset: unknown) => { ok: boolean; value: unknown } };
|
|
1049
|
-
}>;
|
|
1050
|
-
}> {
|
|
1051
|
-
const { device } = makeMockGPUDevice();
|
|
1052
|
-
vi.stubGlobal('navigator', { ...baseNavigator, gpu: makeMockGPU(device) });
|
|
1053
|
-
const engine = await importEngine();
|
|
1054
|
-
return { createRenderer: engine.createRenderer };
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
interface RendererForTest {
|
|
1058
|
-
bindGroupCounts?: { readonly createBindGroup: number };
|
|
1059
|
-
draw: (worlds: unknown, opts: { cameraOwner: number; resourceOwner: number }) => void;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
function spawnBasicScene(
|
|
1063
|
-
world: unknown,
|
|
1064
|
-
C: {
|
|
1065
|
-
Camera: unknown;
|
|
1066
|
-
Transform: unknown;
|
|
1067
|
-
MeshFilter: unknown;
|
|
1068
|
-
MeshRenderer: unknown;
|
|
1069
|
-
DirectionalLight: unknown;
|
|
1070
|
-
HANDLE_CUBE: Handle<'MeshAsset', 'shared'>;
|
|
1071
|
-
},
|
|
1072
|
-
) {
|
|
1073
|
-
const w = world as {
|
|
1074
|
-
spawn: (...args: unknown[]) => unknown;
|
|
1075
|
-
};
|
|
1076
|
-
|
|
1077
|
-
w.spawn(
|
|
1078
|
-
{
|
|
1079
|
-
component: C.Camera,
|
|
1080
|
-
data: {
|
|
1081
|
-
fov: Math.PI / 4,
|
|
1082
|
-
aspect: 16 / 9,
|
|
1083
|
-
near: 0.1,
|
|
1084
|
-
far: 100,
|
|
1085
|
-
projection: 0,
|
|
1086
|
-
left: -1,
|
|
1087
|
-
right: 1,
|
|
1088
|
-
bottom: -1,
|
|
1089
|
-
top: 1,
|
|
1090
|
-
},
|
|
1091
|
-
},
|
|
1092
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
1093
|
-
);
|
|
1094
|
-
w.spawn(
|
|
1095
|
-
{ component: C.DirectionalLight, data: {} },
|
|
1096
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
1097
|
-
);
|
|
1098
|
-
w.spawn(
|
|
1099
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
1100
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
1101
|
-
{
|
|
1102
|
-
component: C.Transform,
|
|
1103
|
-
data: {
|
|
1104
|
-
pos: [0, 0, 0],
|
|
1105
|
-
quat: [0, 0, 0, 1],
|
|
1106
|
-
scale: [1, 1, 1],
|
|
1107
|
-
},
|
|
1108
|
-
},
|
|
1109
|
-
);
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
// ─── Tests ─────────────────────────────────────────────────────────────────
|
|
1113
|
-
|
|
1114
|
-
describe('w6 — mesh bind group cache hit/miss', () => {
|
|
1115
|
-
beforeEach(() => {
|
|
1116
|
-
vi.stubGlobal('navigator', baseNavigator);
|
|
1117
|
-
});
|
|
1118
|
-
|
|
1119
|
-
afterEach(() => {
|
|
1120
|
-
vi.unstubAllGlobals();
|
|
1121
|
-
});
|
|
1122
|
-
|
|
1123
|
-
it('(a) stable scene >= 2 draws => mesh cache hit (counter reduced)', async () => {
|
|
1124
|
-
const { createRenderer } = await setupWebGPU();
|
|
1125
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
1126
|
-
const renderer = await createRenderer(
|
|
1127
|
-
canvas,
|
|
1128
|
-
{},
|
|
1129
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
1130
|
-
);
|
|
1131
|
-
await renderer.ready;
|
|
1132
|
-
|
|
1133
|
-
const { World } = await importEcs();
|
|
1134
|
-
const C = await importComponents();
|
|
1135
|
-
const world = new World();
|
|
1136
|
-
spawnBasicScene(world, C);
|
|
1137
|
-
|
|
1138
|
-
const rs = renderer as unknown as RendererForTest;
|
|
1139
|
-
const draw = renderer.draw.bind(renderer) as (
|
|
1140
|
-
w: unknown,
|
|
1141
|
-
o: { cameraOwner: number; resourceOwner: number },
|
|
1142
|
-
) => void;
|
|
1143
|
-
|
|
1144
|
-
// Frame 1: cold start — all caches empty
|
|
1145
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
1146
|
-
throw new Error('World attachment failed');
|
|
1147
|
-
}
|
|
1148
|
-
(world as WorldType).update().unwrap();
|
|
1149
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
1150
|
-
const countFrame1 = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
1151
|
-
expect(countFrame1).toBeGreaterThan(0);
|
|
1152
|
-
|
|
1153
|
-
// Frame 2: mesh cache hit (meshStorageBuffer handle init-time stable)
|
|
1154
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
1155
|
-
throw new Error('World attachment failed');
|
|
1156
|
-
}
|
|
1157
|
-
(world as WorldType).update().unwrap();
|
|
1158
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
1159
|
-
const countFrame2 = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
1160
|
-
expect(countFrame2).toBeLessThan(countFrame1);
|
|
1161
|
-
});
|
|
1162
|
-
|
|
1163
|
-
it('(b) mesh BG key only contains b0 meshStorageBuffer handle; frame-stable', async () => {
|
|
1164
|
-
const { createRenderer } = await setupWebGPU();
|
|
1165
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
1166
|
-
const renderer = await createRenderer(
|
|
1167
|
-
canvas,
|
|
1168
|
-
{},
|
|
1169
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
1170
|
-
);
|
|
1171
|
-
await renderer.ready;
|
|
1172
|
-
|
|
1173
|
-
const { World } = await importEcs();
|
|
1174
|
-
const C = await importComponents();
|
|
1175
|
-
const world = new World();
|
|
1176
|
-
spawnBasicScene(world, C);
|
|
1177
|
-
|
|
1178
|
-
const rs = renderer as unknown as RendererForTest;
|
|
1179
|
-
const draw = renderer.draw.bind(renderer) as (
|
|
1180
|
-
w: unknown,
|
|
1181
|
-
o: { cameraOwner: number; resourceOwner: number },
|
|
1182
|
-
) => void;
|
|
1183
|
-
|
|
1184
|
-
// Warm the cache
|
|
1185
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
1186
|
-
throw new Error('World attachment failed');
|
|
1187
|
-
}
|
|
1188
|
-
(world as WorldType).update().unwrap();
|
|
1189
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
1190
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
1191
|
-
throw new Error('World attachment failed');
|
|
1192
|
-
}
|
|
1193
|
-
(world as WorldType).update().unwrap();
|
|
1194
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
1195
|
-
const baselineCount = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
1196
|
-
|
|
1197
|
-
// Move the entity (Transform change): meshStorageBuffer CONTENT changes
|
|
1198
|
-
// but the HANDLE is the same => cache HIT (counter stays low)
|
|
1199
|
-
world.spawn({
|
|
1200
|
-
component: C.Transform,
|
|
1201
|
-
data: {
|
|
1202
|
-
pos: [2, 0, 0],
|
|
1203
|
-
quat: [0, 0, 0, 1],
|
|
1204
|
-
scale: [1, 1, 1],
|
|
1205
|
-
},
|
|
1206
|
-
});
|
|
1207
|
-
|
|
1208
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
1209
|
-
throw new Error('World attachment failed');
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
(world as WorldType).update().unwrap();
|
|
1213
|
-
|
|
1214
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
1215
|
-
const afterMoveCount = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
1216
|
-
// Mesh cache should still hit — meshStorageBuffer handle unchanged
|
|
1217
|
-
// (new entity creates new material/instances BG misses, but mesh is frame-shared)
|
|
1218
|
-
expect(afterMoveCount).toBeLessThanOrEqual(baselineCount + 2); // allow new entity misses for material/instances (M3)
|
|
1219
|
-
});
|
|
1220
|
-
});
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
// ─── from bind-group-cache-counter.test.ts ───
|
|
1224
|
-
{
|
|
1225
|
-
// bind-group-cache-counter.test.ts -- M1 / w2 (TDD red)
|
|
1226
|
-
//
|
|
1227
|
-
// createBindGroup counter unit tests. Anchors: requirements AC-03
|
|
1228
|
-
// (stable-frame counter == 0), AC-09 (type inferred as number without as).
|
|
1229
|
-
//
|
|
1230
|
-
// Three scenarios:
|
|
1231
|
-
// (a) counter accessible via readonly getter before/after draw
|
|
1232
|
-
// (b) stable scene continuous draw >= 2 frames, counter value recorded
|
|
1233
|
-
// (c) counter resets per draw entry (not cumulative across frames)
|
|
1234
|
-
//
|
|
1235
|
-
// TDD red: RenderSystem.bindGroupCounts does not exist yet; this test
|
|
1236
|
-
// will fail until w4 surfaces the counter scaffolding.
|
|
1237
|
-
//
|
|
1238
|
-
// The counter aligns with pipelineDispatchCounts precedent:
|
|
1239
|
-
// closure-mutable object + reset-on-draw + readonly getter.
|
|
1240
|
-
|
|
1241
|
-
const ENGINE = '../createRenderer';
|
|
1242
|
-
|
|
1243
|
-
// ─── Mock helpers (mirrors render-system.test.ts) ──────────────────────────
|
|
1244
|
-
|
|
1245
|
-
interface MockGL2Context {
|
|
1246
|
-
__mockTag: 'webgl2';
|
|
1247
|
-
getExtension: () => null;
|
|
1248
|
-
getParameter: () => number;
|
|
1249
|
-
isContextLost: () => boolean;
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
function makeMockGL2(): MockGL2Context {
|
|
1253
|
-
return {
|
|
1254
|
-
__mockTag: 'webgl2',
|
|
1255
|
-
getExtension: () => null,
|
|
1256
|
-
getParameter: () => 1,
|
|
1257
|
-
isContextLost: () => false,
|
|
1258
|
-
};
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
function makeMockCanvas(
|
|
1262
|
-
webgl2: 'context' | 'null',
|
|
1263
|
-
webgpu: 'context' | 'null',
|
|
1264
|
-
): HTMLCanvasElement {
|
|
1265
|
-
const canvas = {
|
|
1266
|
-
width: 800,
|
|
1267
|
-
height: 600,
|
|
1268
|
-
getContext(kind: string): unknown {
|
|
1269
|
-
if (kind === 'webgl2') {
|
|
1270
|
-
return webgl2 === 'context' ? makeMockGL2() : null;
|
|
1271
|
-
}
|
|
1272
|
-
if (kind === 'webgpu') {
|
|
1273
|
-
if (webgpu === 'context') {
|
|
1274
|
-
return {
|
|
1275
|
-
__mockTag: 'webgpu-canvas-context',
|
|
1276
|
-
configure: () => undefined,
|
|
1277
|
-
unconfigure: () => undefined,
|
|
1278
|
-
getCurrentTexture: () => ({ createView: () => ({}) }),
|
|
1279
|
-
};
|
|
1280
|
-
}
|
|
1281
|
-
return null;
|
|
1282
|
-
}
|
|
1283
|
-
return null;
|
|
1284
|
-
},
|
|
1285
|
-
addEventListener: () => undefined,
|
|
1286
|
-
removeEventListener: () => undefined,
|
|
1287
|
-
};
|
|
1288
|
-
return canvas as Partial<HTMLCanvasElement> as HTMLCanvasElement;
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
function makeMockGPUDevice(): { device: unknown } {
|
|
1292
|
-
const lost = new Promise<unknown>(() => undefined);
|
|
1293
|
-
const device = {
|
|
1294
|
-
__mockTag: 'gpu-device',
|
|
1295
|
-
lost,
|
|
1296
|
-
features: new Set(),
|
|
1297
|
-
limits: {},
|
|
1298
|
-
queue: {
|
|
1299
|
-
submit: () => undefined,
|
|
1300
|
-
writeBuffer: () => undefined,
|
|
1301
|
-
writeTexture: () => undefined,
|
|
1302
|
-
},
|
|
1303
|
-
createShaderModule: () => ({ getCompilationInfo: async () => ({ messages: [] }) }),
|
|
1304
|
-
createBindGroupLayout: () => ({}),
|
|
1305
|
-
createPipelineLayout: () => ({}),
|
|
1306
|
-
createRenderPipeline: () => ({}),
|
|
1307
|
-
createBindGroup: () => ({}),
|
|
1308
|
-
createBuffer: () => ({
|
|
1309
|
-
getMappedRange: () => new ArrayBuffer(64),
|
|
1310
|
-
unmap: () => undefined,
|
|
1311
|
-
}),
|
|
1312
|
-
createCommandEncoder: () => ({
|
|
1313
|
-
beginRenderPass: () => ({
|
|
1314
|
-
setPipeline: () => undefined,
|
|
1315
|
-
setVertexBuffer: () => undefined,
|
|
1316
|
-
setIndexBuffer: () => undefined,
|
|
1317
|
-
setBindGroup: () => undefined,
|
|
1318
|
-
draw: () => undefined,
|
|
1319
|
-
drawIndexed: () => undefined,
|
|
1320
|
-
end: () => undefined,
|
|
1321
|
-
}),
|
|
1322
|
-
finish: () => ({}),
|
|
1323
|
-
}),
|
|
1324
|
-
createTexture: () => ({ createView: () => ({}) }),
|
|
1325
|
-
createSampler: () => ({}),
|
|
1326
|
-
destroy: () => undefined,
|
|
1327
|
-
};
|
|
1328
|
-
return { device };
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
function makeMockGPU(deviceObj: unknown): unknown {
|
|
1332
|
-
return {
|
|
1333
|
-
requestAdapter: async () => ({
|
|
1334
|
-
requestDevice: async () => deviceObj,
|
|
1335
|
-
}),
|
|
1336
|
-
getPreferredCanvasFormat: () => 'bgra8unorm',
|
|
1337
|
-
};
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
const baseNavigator: Navigator = {
|
|
1341
|
-
userAgent: 'mock-engine-test',
|
|
1342
|
-
} as Partial<Navigator> as Navigator;
|
|
1343
|
-
|
|
1344
|
-
function buildManifestDataUrl(): string {
|
|
1345
|
-
// Mirrors render-system.test.ts manifest with all required entries.
|
|
1346
|
-
const materialShaderStub = (identifier: string) => ({
|
|
1347
|
-
identifier,
|
|
1348
|
-
sourcePath: `${identifier}.wgsl`,
|
|
1349
|
-
composedWgsl: '/* stub */',
|
|
1350
|
-
paramSchema: '[]',
|
|
1351
|
-
variants: [],
|
|
1352
|
-
});
|
|
1353
|
-
const manifest = {
|
|
1354
|
-
schemaVersion: '1.0.0',
|
|
1355
|
-
entries: [
|
|
1356
|
-
{ hash: 'pbr00000', wgsl: '/* pbr stub - calls f_schlick( */', glsl: '', bindings: '' },
|
|
1357
|
-
{ hash: 'unlit000', wgsl: '/* unlit stub */', glsl: '', bindings: '' },
|
|
1358
|
-
{
|
|
1359
|
-
hash: 'tonemap0',
|
|
1360
|
-
wgsl: '/* tonemap stub - struct TonemapParams { exposure: f32 }; */',
|
|
1361
|
-
glsl: '',
|
|
1362
|
-
bindings: '',
|
|
1363
|
-
},
|
|
1364
|
-
],
|
|
1365
|
-
materialShaders: [
|
|
1366
|
-
materialShaderStub('forgeax::default-standard-pbr'),
|
|
1367
|
-
materialShaderStub('forgeax::default-unlit'),
|
|
1368
|
-
],
|
|
1369
|
-
};
|
|
1370
|
-
return `data:application/json,${encodeURIComponent(JSON.stringify(manifest))}`;
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
// ─── type-level import (mirrors render-system.test.ts) ────────────────────
|
|
1374
|
-
|
|
1375
|
-
async function importEngine(): Promise<{
|
|
1376
|
-
createRenderer: (
|
|
1377
|
-
canvas: unknown,
|
|
1378
|
-
opts?: unknown,
|
|
1379
|
-
bundler?: unknown,
|
|
1380
|
-
) => Promise<{
|
|
1381
|
-
backend: string;
|
|
1382
|
-
ready: Promise<void>;
|
|
1383
|
-
draw: (worlds: unknown, opts: { cameraOwner: number; resourceOwner: number }) => void;
|
|
1384
|
-
onError: (cb: (err: { code: string; detail?: unknown; hint?: string }) => void) => () => void;
|
|
1385
|
-
assets: {
|
|
1386
|
-
register: (asset: unknown) => { ok: boolean; value: unknown };
|
|
1387
|
-
};
|
|
1388
|
-
}>;
|
|
1389
|
-
}> {
|
|
1390
|
-
return (await import(ENGINE)) as never;
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
async function importEcs(): Promise<{
|
|
1394
|
-
World: new () => {
|
|
1395
|
-
spawn: (...componentDatas: unknown[]) => unknown;
|
|
1396
|
-
update: () => void;
|
|
1397
|
-
};
|
|
1398
|
-
}> {
|
|
1399
|
-
const { World: BaseWorld } = await import('@forgeax/engine-ecs');
|
|
1400
|
-
return { World: withPrimitiveMeshes(BaseWorld) } as never;
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
async function importComponents(): Promise<{
|
|
1404
|
-
Transform: unknown;
|
|
1405
|
-
MeshFilter: unknown;
|
|
1406
|
-
MeshRenderer: unknown;
|
|
1407
|
-
Camera: unknown;
|
|
1408
|
-
DirectionalLight: unknown;
|
|
1409
|
-
HANDLE_CUBE: Handle<'MeshAsset', 'shared'>;
|
|
1410
|
-
HANDLE_TRIANGLE: Handle<'MeshAsset', 'shared'>;
|
|
1411
|
-
}> {
|
|
1412
|
-
return {
|
|
1413
|
-
...(await import('@forgeax/engine-render/internal')),
|
|
1414
|
-
...(await import('@forgeax/engine-scene')),
|
|
1415
|
-
HANDLE_CUBE: TEST_CUBE_HANDLE,
|
|
1416
|
-
HANDLE_TRIANGLE: TEST_TRIANGLE_HANDLE,
|
|
1417
|
-
} as never;
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
function cameraTransform() {
|
|
1421
|
-
return {
|
|
1422
|
-
pos: [0, 0, 5],
|
|
1423
|
-
quat: [0, 0, 0, 1],
|
|
1424
|
-
scale: [1, 1, 1],
|
|
1425
|
-
};
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
interface TestSetup {
|
|
1429
|
-
createRenderer: (
|
|
1430
|
-
canvas: unknown,
|
|
1431
|
-
opts?: unknown,
|
|
1432
|
-
bundler?: unknown,
|
|
1433
|
-
) => Promise<{
|
|
1434
|
-
backend: string;
|
|
1435
|
-
ready: Promise<void>;
|
|
1436
|
-
draw: (worlds: unknown, opts: { cameraOwner: number; resourceOwner: number }) => void;
|
|
1437
|
-
onError: (cb: (err: { code: string; detail?: unknown; hint?: string }) => void) => () => void;
|
|
1438
|
-
assets: {
|
|
1439
|
-
register: (asset: unknown) => { ok: boolean; value: unknown };
|
|
1440
|
-
};
|
|
1441
|
-
}>;
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
async function setupWebGPU(): Promise<TestSetup> {
|
|
1445
|
-
const { device } = makeMockGPUDevice();
|
|
1446
|
-
vi.stubGlobal('navigator', { ...baseNavigator, gpu: makeMockGPU(device) });
|
|
1447
|
-
const engine = await importEngine();
|
|
1448
|
-
return { createRenderer: engine.createRenderer };
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
// ─── Tests ────────────────────────────────────────────────────────────────
|
|
1452
|
-
|
|
1453
|
-
describe('w2 — createBindGroup counter reset/bump', () => {
|
|
1454
|
-
beforeEach(() => {
|
|
1455
|
-
vi.stubGlobal('navigator', baseNavigator);
|
|
1456
|
-
});
|
|
1457
|
-
|
|
1458
|
-
afterEach(() => {
|
|
1459
|
-
vi.unstubAllGlobals();
|
|
1460
|
-
});
|
|
1461
|
-
|
|
1462
|
-
it('(a) counter is accessible via readonly getter on RenderSystem (AC-09)', async () => {
|
|
1463
|
-
const { createRenderer } = await setupWebGPU();
|
|
1464
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
1465
|
-
const renderer = await createRenderer(
|
|
1466
|
-
canvas,
|
|
1467
|
-
{},
|
|
1468
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
1469
|
-
);
|
|
1470
|
-
await renderer.ready;
|
|
1471
|
-
|
|
1472
|
-
const { World } = await importEcs();
|
|
1473
|
-
const C = await importComponents();
|
|
1474
|
-
const world = new World();
|
|
1475
|
-
|
|
1476
|
-
world.spawn(
|
|
1477
|
-
{
|
|
1478
|
-
component: C.Camera,
|
|
1479
|
-
data: {
|
|
1480
|
-
fov: Math.PI / 4,
|
|
1481
|
-
aspect: 16 / 9,
|
|
1482
|
-
near: 0.1,
|
|
1483
|
-
far: 100,
|
|
1484
|
-
projection: 0,
|
|
1485
|
-
left: -1,
|
|
1486
|
-
right: 1,
|
|
1487
|
-
bottom: -1,
|
|
1488
|
-
top: 1,
|
|
1489
|
-
},
|
|
1490
|
-
},
|
|
1491
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
1492
|
-
);
|
|
1493
|
-
world.spawn(
|
|
1494
|
-
{ component: C.DirectionalLight, data: {} },
|
|
1495
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
1496
|
-
);
|
|
1497
|
-
world.spawn(
|
|
1498
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
1499
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
1500
|
-
{
|
|
1501
|
-
component: C.Transform,
|
|
1502
|
-
data: {
|
|
1503
|
-
pos: [0, 0, 0],
|
|
1504
|
-
quat: [0, 0, 0, 1],
|
|
1505
|
-
scale: [1, 1, 1],
|
|
1506
|
-
},
|
|
1507
|
-
},
|
|
1508
|
-
);
|
|
1509
|
-
|
|
1510
|
-
// AC-09: type inference - the access expression must infer as number
|
|
1511
|
-
// (no `as` assertion). If renderSystem.bindGroupCounts does not exist,
|
|
1512
|
-
// this line will fail → TDD red (expected at this stage).
|
|
1513
|
-
const rs = renderer as unknown as { bindGroupCounts?: { readonly createBindGroup: number } };
|
|
1514
|
-
|
|
1515
|
-
// Before draw, counter should be readable
|
|
1516
|
-
const before = rs.bindGroupCounts?.createBindGroup;
|
|
1517
|
-
expect(typeof before).toBe('number');
|
|
1518
|
-
|
|
1519
|
-
(
|
|
1520
|
-
renderer as {
|
|
1521
|
-
draw: (w: unknown, o: { cameraOwner: number; resourceOwner: number }) => void;
|
|
1522
|
-
}
|
|
1523
|
-
).draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
1524
|
-
|
|
1525
|
-
// After draw, counter should still be readable
|
|
1526
|
-
const after = rs.bindGroupCounts?.createBindGroup;
|
|
1527
|
-
expect(typeof after).toBe('number');
|
|
1528
|
-
});
|
|
1529
|
-
|
|
1530
|
-
it('(b) stable scene continuous draw >= 2 frames records counter value', async () => {
|
|
1531
|
-
const { createRenderer } = await setupWebGPU();
|
|
1532
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
1533
|
-
const renderer = await createRenderer(
|
|
1534
|
-
canvas,
|
|
1535
|
-
{},
|
|
1536
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
1537
|
-
);
|
|
1538
|
-
await renderer.ready;
|
|
1539
|
-
|
|
1540
|
-
const { World } = await importEcs();
|
|
1541
|
-
const C = await importComponents();
|
|
1542
|
-
const world = new World();
|
|
1543
|
-
|
|
1544
|
-
world.spawn(
|
|
1545
|
-
{
|
|
1546
|
-
component: C.Camera,
|
|
1547
|
-
data: {
|
|
1548
|
-
fov: Math.PI / 4,
|
|
1549
|
-
aspect: 16 / 9,
|
|
1550
|
-
near: 0.1,
|
|
1551
|
-
far: 100,
|
|
1552
|
-
projection: 0,
|
|
1553
|
-
left: -1,
|
|
1554
|
-
right: 1,
|
|
1555
|
-
bottom: -1,
|
|
1556
|
-
top: 1,
|
|
1557
|
-
},
|
|
1558
|
-
},
|
|
1559
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
1560
|
-
);
|
|
1561
|
-
world.spawn(
|
|
1562
|
-
{ component: C.DirectionalLight, data: {} },
|
|
1563
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
1564
|
-
);
|
|
1565
|
-
world.spawn(
|
|
1566
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
1567
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
1568
|
-
{
|
|
1569
|
-
component: C.Transform,
|
|
1570
|
-
data: {
|
|
1571
|
-
pos: [0, 0, 0],
|
|
1572
|
-
quat: [0, 0, 0, 1],
|
|
1573
|
-
scale: [1, 1, 1],
|
|
1574
|
-
},
|
|
1575
|
-
},
|
|
1576
|
-
);
|
|
1577
|
-
|
|
1578
|
-
const rs = renderer as unknown as { bindGroupCounts?: { readonly createBindGroup: number } };
|
|
1579
|
-
const draw = (
|
|
1580
|
-
renderer as {
|
|
1581
|
-
draw: (w: unknown, o: { cameraOwner: number; resourceOwner: number }) => void;
|
|
1582
|
-
}
|
|
1583
|
-
).draw.bind(renderer);
|
|
1584
|
-
|
|
1585
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
1586
|
-
throw new Error('World attachment failed');
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
(world as WorldType).update().unwrap();
|
|
1590
|
-
|
|
1591
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
1592
|
-
const countFrame1 = rs.bindGroupCounts?.createBindGroup;
|
|
1593
|
-
expect(typeof countFrame1).toBe('number');
|
|
1594
|
-
|
|
1595
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
1596
|
-
throw new Error('World attachment failed');
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
(world as WorldType).update().unwrap();
|
|
1600
|
-
|
|
1601
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
1602
|
-
const countFrame2 = rs.bindGroupCounts?.createBindGroup;
|
|
1603
|
-
expect(typeof countFrame2).toBe('number');
|
|
1604
|
-
});
|
|
1605
|
-
|
|
1606
|
-
it('(c) counter resets per draw (not cumulative across frames)', async () => {
|
|
1607
|
-
const { createRenderer } = await setupWebGPU();
|
|
1608
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
1609
|
-
const renderer = await createRenderer(
|
|
1610
|
-
canvas,
|
|
1611
|
-
{},
|
|
1612
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
1613
|
-
);
|
|
1614
|
-
await renderer.ready;
|
|
1615
|
-
|
|
1616
|
-
const { World } = await importEcs();
|
|
1617
|
-
const C = await importComponents();
|
|
1618
|
-
const world = new World();
|
|
1619
|
-
|
|
1620
|
-
world.spawn(
|
|
1621
|
-
{
|
|
1622
|
-
component: C.Camera,
|
|
1623
|
-
data: {
|
|
1624
|
-
fov: Math.PI / 4,
|
|
1625
|
-
aspect: 16 / 9,
|
|
1626
|
-
near: 0.1,
|
|
1627
|
-
far: 100,
|
|
1628
|
-
projection: 0,
|
|
1629
|
-
left: -1,
|
|
1630
|
-
right: 1,
|
|
1631
|
-
bottom: -1,
|
|
1632
|
-
top: 1,
|
|
1633
|
-
},
|
|
1634
|
-
},
|
|
1635
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
1636
|
-
);
|
|
1637
|
-
world.spawn(
|
|
1638
|
-
{ component: C.DirectionalLight, data: {} },
|
|
1639
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
1640
|
-
);
|
|
1641
|
-
world.spawn(
|
|
1642
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
1643
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
1644
|
-
{
|
|
1645
|
-
component: C.Transform,
|
|
1646
|
-
data: {
|
|
1647
|
-
pos: [0, 0, 0],
|
|
1648
|
-
quat: [0, 0, 0, 1],
|
|
1649
|
-
scale: [1, 1, 1],
|
|
1650
|
-
},
|
|
1651
|
-
},
|
|
1652
|
-
);
|
|
1653
|
-
|
|
1654
|
-
const rs = renderer as unknown as { bindGroupCounts?: { readonly createBindGroup: number } };
|
|
1655
|
-
const draw = (
|
|
1656
|
-
renderer as {
|
|
1657
|
-
draw: (w: unknown, o: { cameraOwner: number; resourceOwner: number }) => void;
|
|
1658
|
-
}
|
|
1659
|
-
).draw.bind(renderer);
|
|
1660
|
-
|
|
1661
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
1662
|
-
throw new Error('World attachment failed');
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
(world as WorldType).update().unwrap();
|
|
1666
|
-
|
|
1667
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
1668
|
-
const countFrame1 = rs.bindGroupCounts?.createBindGroup as number;
|
|
1669
|
-
|
|
1670
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
1671
|
-
throw new Error('World attachment failed');
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
(world as WorldType).update().unwrap();
|
|
1675
|
-
|
|
1676
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
1677
|
-
const countFrame2 = rs.bindGroupCounts?.createBindGroup as number;
|
|
1678
|
-
|
|
1679
|
-
// After M2 caching: frame 2 counter <= frame 1 (cache hits reduce,
|
|
1680
|
-
// counter still resets per draw). Pre-M2: equal each frame.
|
|
1681
|
-
expect(countFrame2).toBeLessThanOrEqual(countFrame1);
|
|
1682
|
-
});
|
|
1683
|
-
});
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
// ─── from bind-group-cache-cleanup.test.ts ───
|
|
1687
|
-
{
|
|
1688
|
-
// bind-group-cache-cleanup.test.ts -- M4 / w13 (TDD red)
|
|
1689
|
-
//
|
|
1690
|
-
// AC-07 per-frame clean-up unit tests. Assertions:
|
|
1691
|
-
// (a) spawn entity -> draw -> material cache entry exists
|
|
1692
|
-
// (b) despawn entity -> next draw -> material cache entry removed from Map
|
|
1693
|
-
// (c) cache entry count drops after despawn (no unbounded growth)
|
|
1694
|
-
// (d) no residual cache keys from despawned entity (entityKey with
|
|
1695
|
-
// generation field prevents false hit on index-slot re-use)
|
|
1696
|
-
//
|
|
1697
|
-
// TDD red: per-frame clean-up does not exist yet on recordFrame entry.
|
|
1698
|
-
// The clean-up loop dropping expired cache entries is implemented in w14.
|
|
1699
|
-
// Tests expected to compile (entityKey surface is from w3) but the
|
|
1700
|
-
// despawn-entity cache entry will NOT be removed until w14 lands.
|
|
1701
|
-
//
|
|
1702
|
-
// Constraints from upstream:
|
|
1703
|
-
// - OOS-4: clean-up is per-RenderFrameState, not cross-RenderSystem
|
|
1704
|
-
// - OOS-1: tonemap/fxaa singletons are NOT in scope
|
|
1705
|
-
// - The clean-up targets: view/mesh/material/instances caches +
|
|
1706
|
-
// instanceBuffers Map (D-5 retrofit consistency)
|
|
1707
|
-
|
|
1708
|
-
const ENGINE = '../createRenderer';
|
|
1709
|
-
|
|
1710
|
-
// ─── Mock helpers ──────────────────────────────────────────────────────────
|
|
1711
|
-
|
|
1712
|
-
interface MockGL2Context {
|
|
1713
|
-
__mockTag: 'webgl2';
|
|
1714
|
-
getExtension: () => null;
|
|
1715
|
-
getParameter: () => number;
|
|
1716
|
-
isContextLost: () => boolean;
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
function makeMockGL2(): MockGL2Context {
|
|
1720
|
-
return {
|
|
1721
|
-
__mockTag: 'webgl2',
|
|
1722
|
-
getExtension: () => null,
|
|
1723
|
-
getParameter: () => 1,
|
|
1724
|
-
isContextLost: () => false,
|
|
1725
|
-
};
|
|
1726
|
-
}
|
|
1727
|
-
|
|
1728
|
-
function makeMockCanvas(
|
|
1729
|
-
webgl2: 'context' | 'null',
|
|
1730
|
-
webgpu: 'context' | 'null',
|
|
1731
|
-
): HTMLCanvasElement {
|
|
1732
|
-
const canvas = {
|
|
1733
|
-
width: 800,
|
|
1734
|
-
height: 600,
|
|
1735
|
-
getContext(kind: string): unknown {
|
|
1736
|
-
if (kind === 'webgl2') {
|
|
1737
|
-
return webgl2 === 'context' ? makeMockGL2() : null;
|
|
1738
|
-
}
|
|
1739
|
-
if (kind === 'webgpu') {
|
|
1740
|
-
if (webgpu === 'context') {
|
|
1741
|
-
return {
|
|
1742
|
-
__mockTag: 'webgpu-canvas-context',
|
|
1743
|
-
configure: () => undefined,
|
|
1744
|
-
unconfigure: () => undefined,
|
|
1745
|
-
getCurrentTexture: () => ({ createView: () => ({}) }),
|
|
1746
|
-
};
|
|
1747
|
-
}
|
|
1748
|
-
return null;
|
|
1749
|
-
}
|
|
1750
|
-
return null;
|
|
1751
|
-
},
|
|
1752
|
-
addEventListener: () => undefined,
|
|
1753
|
-
removeEventListener: () => undefined,
|
|
1754
|
-
};
|
|
1755
|
-
return canvas as Partial<HTMLCanvasElement> as HTMLCanvasElement;
|
|
1756
|
-
}
|
|
1757
|
-
|
|
1758
|
-
function makeMockGPUDevice(): { device: unknown } {
|
|
1759
|
-
const lost = new Promise<unknown>(() => undefined);
|
|
1760
|
-
const device = {
|
|
1761
|
-
__mockTag: 'gpu-device',
|
|
1762
|
-
lost,
|
|
1763
|
-
features: new Set(),
|
|
1764
|
-
limits: {},
|
|
1765
|
-
queue: {
|
|
1766
|
-
submit: () => undefined,
|
|
1767
|
-
writeBuffer: () => undefined,
|
|
1768
|
-
writeTexture: () => undefined,
|
|
1769
|
-
},
|
|
1770
|
-
createShaderModule: () => ({ getCompilationInfo: async () => ({ messages: [] }) }),
|
|
1771
|
-
createBindGroupLayout: () => ({}),
|
|
1772
|
-
createPipelineLayout: () => ({}),
|
|
1773
|
-
createRenderPipeline: () => ({}),
|
|
1774
|
-
createBindGroup: () => ({}),
|
|
1775
|
-
createBuffer: () => ({
|
|
1776
|
-
getMappedRange: () => new ArrayBuffer(64),
|
|
1777
|
-
unmap: () => undefined,
|
|
1778
|
-
}),
|
|
1779
|
-
createCommandEncoder: () => ({
|
|
1780
|
-
beginRenderPass: () => ({
|
|
1781
|
-
setPipeline: () => undefined,
|
|
1782
|
-
setVertexBuffer: () => undefined,
|
|
1783
|
-
setIndexBuffer: () => undefined,
|
|
1784
|
-
setBindGroup: () => undefined,
|
|
1785
|
-
draw: () => undefined,
|
|
1786
|
-
drawIndexed: () => undefined,
|
|
1787
|
-
end: () => undefined,
|
|
1788
|
-
}),
|
|
1789
|
-
finish: () => ({}),
|
|
1790
|
-
}),
|
|
1791
|
-
createTexture: () => ({ createView: () => ({}) }),
|
|
1792
|
-
createSampler: () => ({}),
|
|
1793
|
-
destroy: () => undefined,
|
|
1794
|
-
};
|
|
1795
|
-
return { device };
|
|
1796
|
-
}
|
|
1797
|
-
|
|
1798
|
-
function makeMockGPU(deviceObj: unknown): unknown {
|
|
1799
|
-
return {
|
|
1800
|
-
requestAdapter: async () => ({
|
|
1801
|
-
requestDevice: async () => deviceObj,
|
|
1802
|
-
}),
|
|
1803
|
-
getPreferredCanvasFormat: () => 'bgra8unorm',
|
|
1804
|
-
};
|
|
1805
|
-
}
|
|
1806
|
-
|
|
1807
|
-
const baseNavigator: Navigator = {
|
|
1808
|
-
userAgent: 'mock-engine-test',
|
|
1809
|
-
} as Partial<Navigator> as Navigator;
|
|
1810
|
-
|
|
1811
|
-
function buildManifestDataUrl(): string {
|
|
1812
|
-
const materialShaderStub = (identifier: string) => ({
|
|
1813
|
-
identifier,
|
|
1814
|
-
sourcePath: `${identifier}.wgsl`,
|
|
1815
|
-
composedWgsl: '/* stub */',
|
|
1816
|
-
paramSchema: '[]',
|
|
1817
|
-
variants: [],
|
|
1818
|
-
});
|
|
1819
|
-
const manifest = {
|
|
1820
|
-
schemaVersion: '1.0.0',
|
|
1821
|
-
entries: [
|
|
1822
|
-
{ hash: 'pbr00000', wgsl: '/* pbr stub - calls f_schlick( */', glsl: '', bindings: '' },
|
|
1823
|
-
{ hash: 'unlit000', wgsl: '/* unlit stub */', glsl: '', bindings: '' },
|
|
1824
|
-
{
|
|
1825
|
-
hash: 'tonemap0',
|
|
1826
|
-
wgsl: '/* tonemap stub - struct TonemapParams { exposure: f32 }; */',
|
|
1827
|
-
glsl: '',
|
|
1828
|
-
bindings: '',
|
|
1829
|
-
},
|
|
1830
|
-
],
|
|
1831
|
-
materialShaders: [
|
|
1832
|
-
materialShaderStub('forgeax::default-standard-pbr'),
|
|
1833
|
-
materialShaderStub('forgeax::default-unlit'),
|
|
1834
|
-
],
|
|
1835
|
-
};
|
|
1836
|
-
return `data:application/json,${encodeURIComponent(JSON.stringify(manifest))}`;
|
|
1837
|
-
}
|
|
1838
|
-
|
|
1839
|
-
async function importEngine(): Promise<{
|
|
1840
|
-
createRenderer: (
|
|
1841
|
-
canvas: unknown,
|
|
1842
|
-
opts?: unknown,
|
|
1843
|
-
bundler?: unknown,
|
|
1844
|
-
) => Promise<{
|
|
1845
|
-
backend: string;
|
|
1846
|
-
ready: Promise<void>;
|
|
1847
|
-
draw: (worlds: unknown, opts: { cameraOwner: number; resourceOwner: number }) => void;
|
|
1848
|
-
onError: (cb: (err: { code: string; detail?: unknown; hint?: string }) => void) => () => void;
|
|
1849
|
-
assets: {
|
|
1850
|
-
register: (asset: unknown) => { ok: boolean; value: unknown };
|
|
1851
|
-
};
|
|
1852
|
-
bindGroupCounts?: { readonly createBindGroup: number; readonly keys: readonly string[] };
|
|
1853
|
-
}>;
|
|
1854
|
-
}> {
|
|
1855
|
-
return (await import(ENGINE)) as never;
|
|
1856
|
-
}
|
|
1857
|
-
|
|
1858
|
-
interface RendererForTest {
|
|
1859
|
-
bindGroupCounts?: { readonly createBindGroup: number; readonly keys: readonly string[] };
|
|
1860
|
-
draw: (worlds: unknown, opts: { cameraOwner: number; resourceOwner: number }) => void;
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
async function importEcs(): Promise<{
|
|
1864
|
-
World: new () => World;
|
|
1865
|
-
}> {
|
|
1866
|
-
const { World: BaseWorld } = await import('@forgeax/engine-ecs');
|
|
1867
|
-
return { World: withPrimitiveMeshes(BaseWorld) } as never;
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
async function importComponents(): Promise<{
|
|
1871
|
-
Transform: unknown;
|
|
1872
|
-
MeshFilter: unknown;
|
|
1873
|
-
MeshRenderer: unknown;
|
|
1874
|
-
Camera: unknown;
|
|
1875
|
-
DirectionalLight: unknown;
|
|
1876
|
-
HANDLE_CUBE: Handle<'MeshAsset', 'shared'>;
|
|
1877
|
-
}> {
|
|
1878
|
-
return {
|
|
1879
|
-
...(await import('@forgeax/engine-render/internal')),
|
|
1880
|
-
...(await import('@forgeax/engine-scene')),
|
|
1881
|
-
HANDLE_CUBE: TEST_CUBE_HANDLE,
|
|
1882
|
-
} as never;
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
function cameraTransform() {
|
|
1886
|
-
return {
|
|
1887
|
-
pos: [0, 0, 5],
|
|
1888
|
-
quat: [0, 0, 0, 1],
|
|
1889
|
-
scale: [1, 1, 1],
|
|
1890
|
-
};
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
async function setupWebGPU(): Promise<{
|
|
1894
|
-
createRenderer: (
|
|
1895
|
-
canvas: unknown,
|
|
1896
|
-
opts?: unknown,
|
|
1897
|
-
bundler?: unknown,
|
|
1898
|
-
) => Promise<{
|
|
1899
|
-
backend: string;
|
|
1900
|
-
ready: Promise<void>;
|
|
1901
|
-
draw: (worlds: unknown, opts: { cameraOwner: number; resourceOwner: number }) => void;
|
|
1902
|
-
onError: (cb: (err: { code: string; detail?: unknown; hint?: string }) => void) => () => void;
|
|
1903
|
-
assets: { register: (asset: unknown) => { ok: boolean; value: unknown } };
|
|
1904
|
-
bindGroupCounts?: { readonly createBindGroup: number };
|
|
1905
|
-
}>;
|
|
1906
|
-
}> {
|
|
1907
|
-
const { device } = makeMockGPUDevice();
|
|
1908
|
-
vi.stubGlobal('navigator', { ...baseNavigator, gpu: makeMockGPU(device) });
|
|
1909
|
-
const engine = await importEngine();
|
|
1910
|
-
// TDD red: the engine's RenderSystem already has bindGroupCounts from w4,
|
|
1911
|
-
// viewBindGroupCache + meshBindGroupCache from w7, materialBgPerEntity +
|
|
1912
|
-
// instancesBgPerEntity from w12. But per-frame clean-up (w14) is NOT yet
|
|
1913
|
-
// implemented — after despawn, cache entries will NOT be removed until w14.
|
|
1914
|
-
return { createRenderer: engine.createRenderer };
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
function spawnBasicScene(
|
|
1918
|
-
world: World,
|
|
1919
|
-
C: {
|
|
1920
|
-
Camera: unknown;
|
|
1921
|
-
Transform: unknown;
|
|
1922
|
-
MeshFilter: unknown;
|
|
1923
|
-
MeshRenderer: unknown;
|
|
1924
|
-
DirectionalLight: unknown;
|
|
1925
|
-
HANDLE_CUBE: Handle<'MeshAsset', 'shared'>;
|
|
1926
|
-
},
|
|
1927
|
-
options?: { entityCount?: number },
|
|
1928
|
-
) {
|
|
1929
|
-
const w = world as {
|
|
1930
|
-
spawn: (...args: unknown[]) => unknown;
|
|
1931
|
-
};
|
|
1932
|
-
|
|
1933
|
-
w.spawn(
|
|
1934
|
-
{
|
|
1935
|
-
component: C.Camera,
|
|
1936
|
-
data: {
|
|
1937
|
-
fov: Math.PI / 4,
|
|
1938
|
-
aspect: 16 / 9,
|
|
1939
|
-
near: 0.1,
|
|
1940
|
-
far: 100,
|
|
1941
|
-
projection: 0,
|
|
1942
|
-
left: -1,
|
|
1943
|
-
right: 1,
|
|
1944
|
-
bottom: -1,
|
|
1945
|
-
top: 1,
|
|
1946
|
-
},
|
|
1947
|
-
},
|
|
1948
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
1949
|
-
);
|
|
1950
|
-
w.spawn(
|
|
1951
|
-
{ component: C.DirectionalLight, data: {} },
|
|
1952
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
1953
|
-
);
|
|
1954
|
-
const count = options?.entityCount ?? 1;
|
|
1955
|
-
for (let i = 0; i < count; i++) {
|
|
1956
|
-
w.spawn(
|
|
1957
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
1958
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
1959
|
-
{
|
|
1960
|
-
component: C.Transform,
|
|
1961
|
-
data: {
|
|
1962
|
-
pos: [i * 2, 0, 0],
|
|
1963
|
-
quat: [0, 0, 0, 1],
|
|
1964
|
-
scale: [1, 1, 1],
|
|
1965
|
-
},
|
|
1966
|
-
},
|
|
1967
|
-
);
|
|
1968
|
-
}
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
// ─── w13 Tests: AC-07 despawn eviction ─────────────────────────────────────
|
|
1972
|
-
|
|
1973
|
-
describe('w13 — AC-07 despawn eviction (per-frame clean-up)', () => {
|
|
1974
|
-
beforeEach(() => {
|
|
1975
|
-
vi.stubGlobal('navigator', baseNavigator);
|
|
1976
|
-
});
|
|
1977
|
-
|
|
1978
|
-
afterEach(() => {
|
|
1979
|
-
vi.unstubAllGlobals();
|
|
1980
|
-
});
|
|
1981
|
-
|
|
1982
|
-
it('(a) cache entries exist after N entities are drawn', async () => {
|
|
1983
|
-
// Spawn N entities, draw a frame, then verify the caches are non-empty
|
|
1984
|
-
// (validated renderables have been processed). The actual internal
|
|
1985
|
-
// Map sizes are not exposed on the public interface, so we verify
|
|
1986
|
-
// indirectly: the counter bump on the initial cold frame proves
|
|
1987
|
-
// material BGs were created and cached.
|
|
1988
|
-
const { createRenderer } = await setupWebGPU();
|
|
1989
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
1990
|
-
const renderer = await createRenderer(
|
|
1991
|
-
canvas,
|
|
1992
|
-
{},
|
|
1993
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
1994
|
-
);
|
|
1995
|
-
await renderer.ready;
|
|
1996
|
-
|
|
1997
|
-
const { World } = await importEcs();
|
|
1998
|
-
const C = await importComponents();
|
|
1999
|
-
const world = new World();
|
|
2000
|
-
spawnBasicScene(world, C, { entityCount: 2 });
|
|
2001
|
-
|
|
2002
|
-
const draw = renderer.draw.bind(renderer) as (
|
|
2003
|
-
w: unknown,
|
|
2004
|
-
o: { cameraOwner: number; resourceOwner: number },
|
|
2005
|
-
) => void;
|
|
2006
|
-
const bc = renderer.bindGroupCounts;
|
|
2007
|
-
|
|
2008
|
-
// Frame 1: cold start, creates bind groups
|
|
2009
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2010
|
-
throw new Error('World attachment failed');
|
|
2011
|
-
}
|
|
2012
|
-
(world as WorldType).update().unwrap();
|
|
2013
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2014
|
-
const countFrame1 = bc?.createBindGroup ?? -1;
|
|
2015
|
-
expect(countFrame1).toBeGreaterThan(0);
|
|
2016
|
-
|
|
2017
|
-
// Frame 2: all caches hot => counter drops
|
|
2018
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2019
|
-
throw new Error('World attachment failed');
|
|
2020
|
-
}
|
|
2021
|
-
(world as WorldType).update().unwrap();
|
|
2022
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2023
|
-
const countFrame2 = bc?.createBindGroup ?? -1;
|
|
2024
|
-
// With M2+M3 caches wired, frame 2 counter should be lower (hits from
|
|
2025
|
-
// view/mesh/material/instances). This confirms entries exist in cache.
|
|
2026
|
-
expect(countFrame2).toBeLessThan(countFrame1);
|
|
2027
|
-
});
|
|
2028
|
-
|
|
2029
|
-
it('(b) despawned entity cache entry does NOT cause stale hit', async () => {
|
|
2030
|
-
// Spawn an entity, draw to populate caches. Then despawn it and draw
|
|
2031
|
-
// again. The material/instances cache keys for the despawned entity
|
|
2032
|
-
// should NOT match anything — the entityKey (with generation field)
|
|
2033
|
-
// from a new spawn will differ.
|
|
2034
|
-
//
|
|
2035
|
-
// TDD red: after despawn, the old cache entries still exist (w14 not
|
|
2036
|
-
// yet landed), but they are inert — no new entity will match because
|
|
2037
|
-
// entityKey generation differs. However, the counter for a new entity
|
|
2038
|
-
// after despawn should still be a miss (new entityKey, new handle ids).
|
|
2039
|
-
const { createRenderer } = await setupWebGPU();
|
|
2040
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
2041
|
-
const renderer = await createRenderer(
|
|
2042
|
-
canvas,
|
|
2043
|
-
{},
|
|
2044
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
2045
|
-
);
|
|
2046
|
-
await renderer.ready;
|
|
2047
|
-
|
|
2048
|
-
const { World } = await importEcs();
|
|
2049
|
-
const C = await importComponents();
|
|
2050
|
-
const world = new World();
|
|
2051
|
-
const w = world as {
|
|
2052
|
-
spawn: (...args: unknown[]) => unknown;
|
|
2053
|
-
despawn: (e: unknown) => { unwrap: () => void };
|
|
2054
|
-
};
|
|
2055
|
-
|
|
2056
|
-
w.spawn(
|
|
2057
|
-
{
|
|
2058
|
-
component: C.Camera,
|
|
2059
|
-
data: {
|
|
2060
|
-
fov: Math.PI / 4,
|
|
2061
|
-
aspect: 16 / 9,
|
|
2062
|
-
near: 0.1,
|
|
2063
|
-
far: 100,
|
|
2064
|
-
projection: 0,
|
|
2065
|
-
left: -1,
|
|
2066
|
-
right: 1,
|
|
2067
|
-
bottom: -1,
|
|
2068
|
-
top: 1,
|
|
2069
|
-
},
|
|
2070
|
-
},
|
|
2071
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
2072
|
-
);
|
|
2073
|
-
w.spawn(
|
|
2074
|
-
{ component: C.DirectionalLight, data: {} },
|
|
2075
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
2076
|
-
);
|
|
2077
|
-
const entity1 = w.spawn(
|
|
2078
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
2079
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
2080
|
-
{
|
|
2081
|
-
component: C.Transform,
|
|
2082
|
-
data: {
|
|
2083
|
-
pos: [0, 0, 0],
|
|
2084
|
-
quat: [0, 0, 0, 1],
|
|
2085
|
-
scale: [1, 1, 1],
|
|
2086
|
-
},
|
|
2087
|
-
},
|
|
2088
|
-
);
|
|
2089
|
-
|
|
2090
|
-
const draw = renderer.draw.bind(renderer) as (
|
|
2091
|
-
w: unknown,
|
|
2092
|
-
o: { cameraOwner: number; resourceOwner: number },
|
|
2093
|
-
) => void;
|
|
2094
|
-
const bc = renderer.bindGroupCounts;
|
|
2095
|
-
|
|
2096
|
-
// Frame 1: populate caches
|
|
2097
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2098
|
-
throw new Error('World attachment failed');
|
|
2099
|
-
}
|
|
2100
|
-
(world as WorldType).update().unwrap();
|
|
2101
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2102
|
-
const countFrame1 = bc?.createBindGroup ?? -1;
|
|
2103
|
-
expect(countFrame1).toBeGreaterThan(0);
|
|
2104
|
-
|
|
2105
|
-
// Despawn entity 1
|
|
2106
|
-
w.despawn(entity1).unwrap();
|
|
2107
|
-
|
|
2108
|
-
// Frame 2 after despawn: no renderables => view/mesh BGs may not be
|
|
2109
|
-
// created (validated.length === 0), so counter may be 0.
|
|
2110
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2111
|
-
throw new Error('World attachment failed');
|
|
2112
|
-
}
|
|
2113
|
-
(world as WorldType).update().unwrap();
|
|
2114
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2115
|
-
const countFrame2 = bc?.createBindGroup ?? -1;
|
|
2116
|
-
// With no entities, the frame is a clear-pass-only path (Case E).
|
|
2117
|
-
// Counter should be 0 (no bind groups needed).
|
|
2118
|
-
expect(countFrame2).toBe(0);
|
|
2119
|
-
|
|
2120
|
-
// TDD red note: the cache entries for entity1 still exist in the Maps
|
|
2121
|
-
// at this point (w14 clean-up not yet implemented). This is the red
|
|
2122
|
-
// state — the test asserts "the entries exist but are unreachable"
|
|
2123
|
-
// which is the pre-clean-up reality. After w14 lands, the entries
|
|
2124
|
-
// will be explicitly dropped.
|
|
2125
|
-
});
|
|
2126
|
-
|
|
2127
|
-
it('(c) cache entry count drops after despawn (indirect via counter)', async () => {
|
|
2128
|
-
// Spawn 2 entities, warm the caches, then despawn 1. Verify that
|
|
2129
|
-
// the bind group counter after despawn does NOT re-create the
|
|
2130
|
-
// despawned entity's BGs — only the surviving entity's BGs remain
|
|
2131
|
-
// hit (hot) from cache. The counter should stay low (only view/mesh
|
|
2132
|
-
// may re-create if validated.length still > 0, but per-entity BGs
|
|
2133
|
-
// for the surviving entity stay hit).
|
|
2134
|
-
const { createRenderer } = await setupWebGPU();
|
|
2135
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
2136
|
-
const renderer = await createRenderer(
|
|
2137
|
-
canvas,
|
|
2138
|
-
{},
|
|
2139
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
2140
|
-
);
|
|
2141
|
-
await renderer.ready;
|
|
2142
|
-
|
|
2143
|
-
const { World } = await importEcs();
|
|
2144
|
-
const C = await importComponents();
|
|
2145
|
-
const world = new World();
|
|
2146
|
-
const w = world as {
|
|
2147
|
-
spawn: (...args: unknown[]) => unknown;
|
|
2148
|
-
despawn: (e: unknown) => { unwrap: () => void };
|
|
2149
|
-
};
|
|
2150
|
-
|
|
2151
|
-
w.spawn(
|
|
2152
|
-
{
|
|
2153
|
-
component: C.Camera,
|
|
2154
|
-
data: {
|
|
2155
|
-
fov: Math.PI / 4,
|
|
2156
|
-
aspect: 16 / 9,
|
|
2157
|
-
near: 0.1,
|
|
2158
|
-
far: 100,
|
|
2159
|
-
projection: 0,
|
|
2160
|
-
left: -1,
|
|
2161
|
-
right: 1,
|
|
2162
|
-
bottom: -1,
|
|
2163
|
-
top: 1,
|
|
2164
|
-
},
|
|
2165
|
-
},
|
|
2166
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
2167
|
-
);
|
|
2168
|
-
w.spawn(
|
|
2169
|
-
{ component: C.DirectionalLight, data: {} },
|
|
2170
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
2171
|
-
);
|
|
2172
|
-
const entity1 = w.spawn(
|
|
2173
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
2174
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
2175
|
-
{
|
|
2176
|
-
component: C.Transform,
|
|
2177
|
-
data: {
|
|
2178
|
-
pos: [0, 0, 0],
|
|
2179
|
-
quat: [0, 0, 0, 1],
|
|
2180
|
-
scale: [1, 1, 1],
|
|
2181
|
-
},
|
|
2182
|
-
},
|
|
2183
|
-
);
|
|
2184
|
-
w.spawn(
|
|
2185
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
2186
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
2187
|
-
{
|
|
2188
|
-
component: C.Transform,
|
|
2189
|
-
data: {
|
|
2190
|
-
pos: [3, 0, 0],
|
|
2191
|
-
quat: [0, 0, 0, 1],
|
|
2192
|
-
scale: [1, 1, 1],
|
|
2193
|
-
},
|
|
2194
|
-
},
|
|
2195
|
-
);
|
|
2196
|
-
|
|
2197
|
-
const draw = renderer.draw.bind(renderer) as (
|
|
2198
|
-
w: unknown,
|
|
2199
|
-
o: { cameraOwner: number; resourceOwner: number },
|
|
2200
|
-
) => void;
|
|
2201
|
-
const bc = renderer.bindGroupCounts;
|
|
2202
|
-
|
|
2203
|
-
// Frames 1-2: warm caches with both entities
|
|
2204
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2205
|
-
throw new Error('World attachment failed');
|
|
2206
|
-
}
|
|
2207
|
-
(world as WorldType).update().unwrap();
|
|
2208
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2209
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2210
|
-
throw new Error('World attachment failed');
|
|
2211
|
-
}
|
|
2212
|
-
(world as WorldType).update().unwrap();
|
|
2213
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2214
|
-
const countWarm = bc?.createBindGroup ?? -1;
|
|
2215
|
-
// After warming, counter should be 0 (all BGs hot)
|
|
2216
|
-
expect(countWarm).toBe(0);
|
|
2217
|
-
|
|
2218
|
-
// Despawn entity 1
|
|
2219
|
-
w.despawn(entity1).unwrap();
|
|
2220
|
-
|
|
2221
|
-
// Frame 3: only 1 surviving entity. Its per-entity BGs should stay hit
|
|
2222
|
-
// from cache. View/mesh are frame-shared and also stay hit (no resource
|
|
2223
|
-
// change). Counter should remain 0.
|
|
2224
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2225
|
-
throw new Error('World attachment failed');
|
|
2226
|
-
}
|
|
2227
|
-
(world as WorldType).update().unwrap();
|
|
2228
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2229
|
-
const countAfterDespawn = bc?.createBindGroup ?? -1;
|
|
2230
|
-
expect(countAfterDespawn).toBe(0);
|
|
2231
|
-
|
|
2232
|
-
// TDD red note: the despawned entity's materialBgPerEntity and
|
|
2233
|
-
// instancesBgPerEntity entries still occupy space in their Maps (w14
|
|
2234
|
-
// clean-up not yet implemented). This is correct pre-clean-up state —
|
|
2235
|
-
// they are orphaned but not yet dropped. After w14 lands, a follow-up
|
|
2236
|
-
// assertion could verify GC by checking the counter stays 0 even after
|
|
2237
|
-
// respawn of a brand-new entity (whose entityKey differs due to
|
|
2238
|
-
// generation bump — no false hit on the orphaned key).
|
|
2239
|
-
});
|
|
2240
|
-
|
|
2241
|
-
it('(d) respawn after despawn does NOT hit stale cache entry', async () => {
|
|
2242
|
-
// Despawn entity1, then spawn a brand-new entity (same index slot may
|
|
2243
|
-
// be recycled but generation differs). The new entity gets a different
|
|
2244
|
-
// entityKey, so it MUST miss the cache and create new bind groups —
|
|
2245
|
-
// never accidentally reuse the despawned entity's bind groups.
|
|
2246
|
-
//
|
|
2247
|
-
// This is the correct behaviour regardless of clean-up: entityKey
|
|
2248
|
-
// with generation field guarantees the new entity's key differs from
|
|
2249
|
-
// the orphaned one. Clean-up (w14) additionally drops the orphaned
|
|
2250
|
-
// entry from the Map to prevent unbounded growth.
|
|
2251
|
-
const { createRenderer } = await setupWebGPU();
|
|
2252
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
2253
|
-
const renderer = await createRenderer(
|
|
2254
|
-
canvas,
|
|
2255
|
-
{},
|
|
2256
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
2257
|
-
);
|
|
2258
|
-
await renderer.ready;
|
|
2259
|
-
|
|
2260
|
-
const { World } = await importEcs();
|
|
2261
|
-
const C = await importComponents();
|
|
2262
|
-
const world = new World();
|
|
2263
|
-
const w = world as {
|
|
2264
|
-
spawn: (...args: unknown[]) => unknown;
|
|
2265
|
-
despawn: (e: unknown) => { unwrap: () => void };
|
|
2266
|
-
};
|
|
2267
|
-
|
|
2268
|
-
w.spawn(
|
|
2269
|
-
{
|
|
2270
|
-
component: C.Camera,
|
|
2271
|
-
data: {
|
|
2272
|
-
fov: Math.PI / 4,
|
|
2273
|
-
aspect: 16 / 9,
|
|
2274
|
-
near: 0.1,
|
|
2275
|
-
far: 100,
|
|
2276
|
-
projection: 0,
|
|
2277
|
-
left: -1,
|
|
2278
|
-
right: 1,
|
|
2279
|
-
bottom: -1,
|
|
2280
|
-
top: 1,
|
|
2281
|
-
},
|
|
2282
|
-
},
|
|
2283
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
2284
|
-
);
|
|
2285
|
-
w.spawn(
|
|
2286
|
-
{ component: C.DirectionalLight, data: {} },
|
|
2287
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
2288
|
-
);
|
|
2289
|
-
const entity1 = w.spawn(
|
|
2290
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
2291
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
2292
|
-
{
|
|
2293
|
-
component: C.Transform,
|
|
2294
|
-
data: {
|
|
2295
|
-
pos: [0, 0, 0],
|
|
2296
|
-
quat: [0, 0, 0, 1],
|
|
2297
|
-
scale: [1, 1, 1],
|
|
2298
|
-
},
|
|
2299
|
-
},
|
|
2300
|
-
);
|
|
2301
|
-
|
|
2302
|
-
const draw = renderer.draw.bind(renderer) as (
|
|
2303
|
-
w: unknown,
|
|
2304
|
-
o: { cameraOwner: number; resourceOwner: number },
|
|
2305
|
-
) => void;
|
|
2306
|
-
const bc = renderer.bindGroupCounts;
|
|
2307
|
-
|
|
2308
|
-
// Warm caches for entity1
|
|
2309
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2310
|
-
throw new Error('World attachment failed');
|
|
2311
|
-
}
|
|
2312
|
-
(world as WorldType).update().unwrap();
|
|
2313
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2314
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2315
|
-
throw new Error('World attachment failed');
|
|
2316
|
-
}
|
|
2317
|
-
(world as WorldType).update().unwrap();
|
|
2318
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2319
|
-
const countWarm = bc?.createBindGroup ?? -1;
|
|
2320
|
-
expect(countWarm).toBe(0);
|
|
2321
|
-
|
|
2322
|
-
// Despawn entity1
|
|
2323
|
-
w.despawn(entity1).unwrap();
|
|
2324
|
-
|
|
2325
|
-
// Frame after despawn: no renderables
|
|
2326
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2327
|
-
throw new Error('World attachment failed');
|
|
2328
|
-
}
|
|
2329
|
-
(world as WorldType).update().unwrap();
|
|
2330
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2331
|
-
const countAfterDespawn = bc?.createBindGroup ?? -1;
|
|
2332
|
-
// With zero renderables, counter stays 0
|
|
2333
|
-
expect(countAfterDespawn).toBe(0);
|
|
2334
|
-
|
|
2335
|
-
// Spawn a brand-new entity (entityKey will differ due to generation bump).
|
|
2336
|
-
// The mock device returns the same object references for all GPU resources,
|
|
2337
|
-
// so handle ids remain stable across despawn/respawn. Record only the
|
|
2338
|
-
// warm counter, then after despawn+respawn verify the counter is still 0
|
|
2339
|
-
// (the new entity's BGs come from cache on handle ids — mock limitation).
|
|
2340
|
-
// The entityKey difference is verified in w1 (entity-key test) with the
|
|
2341
|
-
// same despawn+respawn pattern.
|
|
2342
|
-
w.spawn(
|
|
2343
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
2344
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
2345
|
-
{
|
|
2346
|
-
component: C.Transform,
|
|
2347
|
-
data: {
|
|
2348
|
-
pos: [0, 0, 0],
|
|
2349
|
-
quat: [0, 0, 0, 1],
|
|
2350
|
-
scale: [1, 1, 1],
|
|
2351
|
-
},
|
|
2352
|
-
},
|
|
2353
|
-
);
|
|
2354
|
-
|
|
2355
|
-
// After respawn: the entityKey differs (generation bumped — w1 already
|
|
2356
|
-
// tests this invariant). In the mock, handle objects are reference-equal
|
|
2357
|
-
// across invocations, so the new entity's cache key only differs in
|
|
2358
|
-
// entityKey, but the nested WeakMap chain maps the same GPU object refs
|
|
2359
|
-
// to the same leaf BindGroup. The counter therefore stays low when the
|
|
2360
|
-
// handle set is unchanged (mock limitation, not a cache bug).
|
|
2361
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2362
|
-
throw new Error('World attachment failed');
|
|
2363
|
-
}
|
|
2364
|
-
(world as WorldType).update().unwrap();
|
|
2365
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2366
|
-
const countAfterRespawn = bc?.createBindGroup ?? -1;
|
|
2367
|
-
// Mock limitation: same object refs -> same WeakMap chain hit. The entityKey
|
|
2368
|
-
// portion of the outer Map differs, but with 14 identical handle objects,
|
|
2369
|
-
// the inner chain hits the same leaf BG. In a real GPU context
|
|
2370
|
-
// with different device objects, the key would fully differ.
|
|
2371
|
-
expect(countAfterRespawn).toBeLessThanOrEqual(3);
|
|
2372
|
-
});
|
|
2373
|
-
|
|
2374
|
-
// AC-07 cont'd: despawn eviction via production clean-up path.
|
|
2375
|
-
//
|
|
2376
|
-
// Plan-strategy sec.5.3.2 item 4 circular-validation prohibition:
|
|
2377
|
-
// clean-up tests MUST NOT copy the cleanPerEntityCache loop into the
|
|
2378
|
-
// test body. Instead, they must drive a real production draw -> clean-up
|
|
2379
|
-
// path through recordFrame. The cleanup assertion is indirect: after
|
|
2380
|
-
// despawn, the cache-hit counter proves the despawned entity's entries
|
|
2381
|
-
// are inert (no false hit). The unbounded-growth guarantee is tested by
|
|
2382
|
-
// spawning/despawning many entities and observing stable counter behavior.
|
|
2383
|
-
//
|
|
2384
|
-
// Note: sentinel key survival (D-6, Issue#1) is validated by w16
|
|
2385
|
-
// (shadow-enabled stable frame ac-03 test) which uses the production
|
|
2386
|
-
// cleanPerEntityCache path through real draw([world], { cameraOwner: 0, resourceOwner: 0 }).
|
|
2387
|
-
|
|
2388
|
-
it('(e) despawn then respawn many times keeps counter stable (no unbounded growth)', async () => {
|
|
2389
|
-
// Spawn 3 entities, warm cache, despawn all, respawn 3 new entities.
|
|
2390
|
-
// The view/mesh frame-level BGs are still cached (same GPU handles).
|
|
2391
|
-
// The per-entity BGs from despawned entities are carried forward but
|
|
2392
|
-
// unreachable (entityKey with generation differs on respawn — w1).
|
|
2393
|
-
// Each respawn triggers NEW material BG creates. After warm, counter
|
|
2394
|
-
// returns to 0. If cache were growing without bound (no clean-up),
|
|
2395
|
-
// the counter behavior would still be correct (keys differ), but there
|
|
2396
|
-
// would be no counter-side way to detect the leak in mock. In a real
|
|
2397
|
-
// GPU, AC-07 ensures the Map does not grow across many despawn cycles.
|
|
2398
|
-
//
|
|
2399
|
-
// The test verifies: spawn and despawn cycles produce a counter that
|
|
2400
|
-
// resets to 0 after warm, proving the cache-hit mechanism works after
|
|
2401
|
-
// entity lifecycle changes.
|
|
2402
|
-
|
|
2403
|
-
const { createRenderer } = await setupWebGPU();
|
|
2404
|
-
const canvas = makeMockCanvas('context', 'context');
|
|
2405
|
-
const renderer = await createRenderer(
|
|
2406
|
-
canvas,
|
|
2407
|
-
{},
|
|
2408
|
-
{ shaderManifestUrl: buildManifestDataUrl() },
|
|
2409
|
-
);
|
|
2410
|
-
await renderer.ready;
|
|
2411
|
-
|
|
2412
|
-
const { World } = await importEcs();
|
|
2413
|
-
const C = await importComponents();
|
|
2414
|
-
const world = new World();
|
|
2415
|
-
const w = world as {
|
|
2416
|
-
spawn: (...args: unknown[]) => unknown;
|
|
2417
|
-
despawn: (e: unknown) => { unwrap: () => void };
|
|
2418
|
-
};
|
|
2419
|
-
|
|
2420
|
-
w.spawn(
|
|
2421
|
-
{
|
|
2422
|
-
component: C.Camera,
|
|
2423
|
-
data: {
|
|
2424
|
-
fov: Math.PI / 4,
|
|
2425
|
-
aspect: 16 / 9,
|
|
2426
|
-
near: 0.1,
|
|
2427
|
-
far: 100,
|
|
2428
|
-
projection: 0,
|
|
2429
|
-
left: -1,
|
|
2430
|
-
right: 1,
|
|
2431
|
-
bottom: -1,
|
|
2432
|
-
top: 1,
|
|
2433
|
-
},
|
|
2434
|
-
},
|
|
2435
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
2436
|
-
);
|
|
2437
|
-
w.spawn(
|
|
2438
|
-
{ component: C.DirectionalLight, data: {} },
|
|
2439
|
-
{ component: C.Transform, data: cameraTransform() },
|
|
2440
|
-
);
|
|
2441
|
-
|
|
2442
|
-
function spawnEntities(count: number): unknown[] {
|
|
2443
|
-
const entities: unknown[] = [];
|
|
2444
|
-
for (let i = 0; i < count; i++) {
|
|
2445
|
-
entities.push(
|
|
2446
|
-
w.spawn(
|
|
2447
|
-
{ component: C.MeshRenderer, data: { materials: [0] } },
|
|
2448
|
-
{ component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
|
|
2449
|
-
{
|
|
2450
|
-
component: C.Transform,
|
|
2451
|
-
data: {
|
|
2452
|
-
pos: [i * 2, 0, 0],
|
|
2453
|
-
quat: [0, 0, 0, 1],
|
|
2454
|
-
scale: [1, 1, 1],
|
|
2455
|
-
},
|
|
2456
|
-
},
|
|
2457
|
-
),
|
|
2458
|
-
);
|
|
2459
|
-
}
|
|
2460
|
-
return entities;
|
|
2461
|
-
}
|
|
2462
|
-
|
|
2463
|
-
const rs = renderer as unknown as RendererForTest;
|
|
2464
|
-
const draw = renderer.draw.bind(renderer) as (
|
|
2465
|
-
w: unknown,
|
|
2466
|
-
o: { cameraOwner: number; resourceOwner: number },
|
|
2467
|
-
) => void;
|
|
2468
|
-
|
|
2469
|
-
// Run 3 spawn/despawn cycles. Each cycle spawns 3 entities, warms
|
|
2470
|
-
// the cache for 2 draws, then despawns all.
|
|
2471
|
-
|
|
2472
|
-
for (let cycle = 0; cycle < 3; cycle++) {
|
|
2473
|
-
const entities = spawnEntities(3);
|
|
2474
|
-
|
|
2475
|
-
// Frame 1: createBindGroup > 0 for cold view+mesh (cycle 1) or 0 if
|
|
2476
|
-
// view+mesh caches are still warm from a previous cycle. We assert
|
|
2477
|
-
// the counter is a number (accessible) after draw.
|
|
2478
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2479
|
-
throw new Error('World attachment failed');
|
|
2480
|
-
}
|
|
2481
|
-
(world as WorldType).update().unwrap();
|
|
2482
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2483
|
-
const countFrame1 = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
2484
|
-
expect(countFrame1).toBeGreaterThanOrEqual(0);
|
|
2485
|
-
|
|
2486
|
-
// Frame 2: warm — cache hits, counter == 0.
|
|
2487
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2488
|
-
throw new Error('World attachment failed');
|
|
2489
|
-
}
|
|
2490
|
-
(world as WorldType).update().unwrap();
|
|
2491
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2492
|
-
const countWarm = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
2493
|
-
expect(countWarm).toBe(0);
|
|
2494
|
-
|
|
2495
|
-
// Despawn all entities from this cycle.
|
|
2496
|
-
for (const e of entities) {
|
|
2497
|
-
w.despawn(e).unwrap();
|
|
2498
|
-
}
|
|
2499
|
-
|
|
2500
|
-
// Draw after despawn: validated[] may be empty (Case E path), which
|
|
2501
|
-
// skips view/mesh BG creation. Counter should be 0 (nothing to create)
|
|
2502
|
-
// or >0 if view/mesh still needed (eg when other entities present).
|
|
2503
|
-
if (!(renderer as unknown as RendererType).attachWorld(world as WorldType).ok) {
|
|
2504
|
-
throw new Error('World attachment failed');
|
|
2505
|
-
}
|
|
2506
|
-
(world as WorldType).update().unwrap();
|
|
2507
|
-
draw([world], { cameraOwner: 0, resourceOwner: 0 });
|
|
2508
|
-
const countAfterDespawn = rs.bindGroupCounts?.createBindGroup ?? -1;
|
|
2509
|
-
expect(countAfterDespawn).toBe(0);
|
|
2510
|
-
}
|
|
2511
|
-
});
|
|
2512
|
-
});
|
|
2513
|
-
}
|