@hology/core 0.0.259 → 0.0.261

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.
Files changed (59) hide show
  1. package/dist/effects/sequence/sequence-actor.d.ts +3 -1
  2. package/dist/effects/sequence/sequence-actor.js +1 -1
  3. package/dist/effects/sequence/sequence-player.d.ts +11 -0
  4. package/dist/effects/sequence/sequence-player.js +1 -1
  5. package/dist/effects/vfx/gpu-sprite-compiler.js +1 -1
  6. package/dist/effects/vfx/gpu-sprite-runtime.d.ts +3 -1
  7. package/dist/effects/vfx/gpu-sprite-runtime.js +1 -1
  8. package/dist/effects/vfx/trail-renderer.d.ts +2 -0
  9. package/dist/effects/vfx/trail-renderer.js +1 -1
  10. package/dist/effects/vfx/vfx-actor.d.ts +11 -0
  11. package/dist/effects/vfx/vfx-actor.js +1 -1
  12. package/dist/effects/vfx/vfx-asset.d.ts +4 -0
  13. package/dist/effects/vfx/vfx-collision-behaviour.d.ts +4 -1
  14. package/dist/effects/vfx/vfx-collision-behaviour.js +1 -1
  15. package/dist/effects/vfx/vfx-defs.d.ts +1 -0
  16. package/dist/effects/vfx/vfx-defs.js +1 -1
  17. package/dist/effects/vfx/vfx-materializer.d.ts +1 -1
  18. package/dist/effects/vfx/vfx-materializer.js +1 -1
  19. package/dist/effects/vfx/vfx-renderers.d.ts +2 -0
  20. package/dist/effects/vfx/vfx-renderers.js +1 -1
  21. package/dist/gameplay/actors/actor.d.ts +4 -0
  22. package/dist/gameplay/actors/actor.js +1 -1
  23. package/dist/gameplay/actors/builtin/components/actor-transform-persistence.d.ts +29 -0
  24. package/dist/gameplay/actors/builtin/components/actor-transform-persistence.js +4 -0
  25. package/dist/gameplay/ai/navigation.js +1 -1
  26. package/dist/gameplay/index.d.ts +2 -0
  27. package/dist/gameplay/index.js +1 -1
  28. package/dist/gameplay/initiate.d.ts +4 -0
  29. package/dist/gameplay/initiate.js +1 -1
  30. package/dist/gameplay/net/service/net-decorator.d.ts +9 -5
  31. package/dist/gameplay/net/service/net-decorator.js +1 -1
  32. package/dist/gameplay/net/service/net-service.js +1 -1
  33. package/dist/gameplay/services/actor-persistence.d.ts +69 -0
  34. package/dist/gameplay/services/actor-persistence.js +4 -0
  35. package/dist/gameplay/services/render.d.ts +1 -1
  36. package/dist/gameplay/services/scene-transition.js +1 -1
  37. package/dist/gameplay/services/world.d.ts +10 -1
  38. package/dist/gameplay/services/world.js +1 -1
  39. package/dist/rendering/webgpu-experimental-backend.js +1 -1
  40. package/dist/rendering.js +1 -1
  41. package/dist/scene/bootstrap.d.ts +2 -0
  42. package/dist/scene/bootstrap.js +1 -1
  43. package/dist/scene/materializer.d.ts +10 -0
  44. package/dist/scene/materializer.js +1 -1
  45. package/dist/shader-nodes/pom.js +1 -1
  46. package/dist/test/actor-persistence-runtime.test.d.ts +2 -0
  47. package/dist/test/actor-persistence-runtime.test.js +4 -0
  48. package/dist/test/actor-persistence.test.d.ts +2 -0
  49. package/dist/test/actor-persistence.test.js +4 -0
  50. package/dist/test/actor-transform-persistence.test.d.ts +2 -0
  51. package/dist/test/actor-transform-persistence.test.js +4 -0
  52. package/dist/test/gpu-sprite-compiler.test.js +1 -1
  53. package/dist/test/materializer-prefetch.test.js +1 -1
  54. package/dist/test/prefab-instance-params.test.js +1 -1
  55. package/dist/test/world-lifecycle.test.js +1 -1
  56. package/dist/test/world-prefab-spawn.test.js +1 -1
  57. package/docs/actor-persistence.md +87 -0
  58. package/package.json +1 -1
  59. package/tsconfig.tsbuildinfo +1 -1
@@ -1,4 +1,4 @@
1
- import{abs as e,attributes as t,cross as n,dFdx as r,dFdy as a,float as i,normalize as l,select as s,transformed as p,UniformSampler2dArraySlice as f,uniforms as $,varying as o,Vec2Node as v,vec3 as u,vec4 as m}from"three-shader-graph";import{isWgslCompiler as h,isWgslFragmentCompiler as d}from"three-shader-graph";import{glslFunction as c}from"./glsl-node.js";export function computeTangent(e,t,i){const s=r(t),p=a(t),f=r(i),$=a(i),o=n(p,e),v=n(e,s),u=o.multiplyScalar(f.x).add(v.multiplyScalar($.x));return m(l(u),1)}export function fallbackTangent(t){const r=e(t.y),a=s(r.lt(i(.999)),u(0,1,0),u(1,0,0));return l(n(a,t))}const g=t.normal,S=o(l($.normalMatrix.multiplyVec(g))),x=o(p.mvPosition.xyz.multiplyScalar(-1));export function parallaxOcclusionMapping(e,t,n,r=8,a=24){return new y(e,t,n,r,a)}class y extends v{constructor(e,t,n,r,a){super(),this.uv=e,this.heightMap=t,this.heightScale=n,this.minLayers=r,this.maxLayers=a}compile(e){if(!h(e))return{out:e.get(this.compileGlsl())};if(!d(e))throw new Error("Parallax occlusion mapping requires fragment-stage derivatives in WGSL.");const t=`pom_${e.variable()}`,n=e.get(this.uv),r=e.get(this.heightMap),a=e.get(this.heightScale),l=e.get(S),s=e.get(x),p=e.get(i(this.minLayers)),f=e.get(i(this.maxLayers)),$=C(this.heightMap)?e.get(L(this.heightMap)):null;return{chunk:`\n let ${t}Uv = ${n};\n let ${t}Normal = normalize(${l});\n let ${t}ViewPosition = ${s};\n let ${t}ViewDirection = normalize(${t}ViewPosition);\n let ${t}Dp1 = dpdx(-${t}ViewPosition);\n // WGSL fragment coordinates have the opposite Y orientation from GLSL.\n // Negate both Y derivatives so the reconstructed cotangent frame keeps\n // the same handedness as the GLSL implementation.\n let ${t}Dp2 = -dpdy(-${t}ViewPosition);\n let ${t}Duv1 = dpdx(${t}Uv);\n let ${t}Duv2 = -dpdy(${t}Uv);\n let ${t}Dp2Perp = cross(${t}Dp2, ${t}Normal);\n let ${t}Dp1Perp = cross(${t}Normal, ${t}Dp1);\n let ${t}T = ${t}Dp2Perp * ${t}Duv1.x + ${t}Dp1Perp * ${t}Duv2.x;\n let ${t}B = ${t}Dp2Perp * ${t}Duv1.y + ${t}Dp1Perp * ${t}Duv2.y;\n let ${t}InvMax = inverseSqrt(max(dot(${t}T, ${t}T), dot(${t}B, ${t}B)));\n let ${t}Tbn = mat3x3<f32>(\n ${t}T * ${t}InvMax,\n ${t}B * ${t}InvMax,\n ${t}Normal\n );\n let ${t}Vv = -(transpose(${t}Tbn) * ${t}ViewDirection);\n let ${t}SafeVvZ = select(\n ${t}Vv.z,\n select(-0.0001, 0.0001, ${t}Vv.z >= 0.0),\n abs(${t}Vv.z) < 0.0001\n );\n let ${t}ParallaxLimit = (-length(${t}Vv.xy) / ${t}SafeVvZ) * ${a};\n let ${t}OffsetDirection = normalize(${t}Vv.xy);\n let ${t}MaxOffset = ${t}OffsetDirection * ${t}ParallaxLimit;\n let ${t}NumSamples = max(\n 1.0,\n mix(${f}, ${p}, dot(-${t}ViewDirection, ${t}Normal))\n );\n let ${t}StepSize = 1.0 / ${t}NumSamples;\n var ${t}CurrentRayHeight = 1.0;\n var ${t}CurrentOffset = vec2<f32>(0.0);\n var ${t}LastOffset = vec2<f32>(0.0);\n var ${t}LastSampledHeight = 1.0;\n var ${t}CurrentSampledHeight = 1.0;\n for (var ${t}SampleIndex: i32 = 0; ${t}SampleIndex < 24; ${t}SampleIndex = ${t}SampleIndex + 1) {\n if (f32(${t}SampleIndex) > ${t}NumSamples) {\n break;\n }\n ${t}CurrentSampledHeight = ${null==$?`textureSampleLevel(${r}Texture, ${r}Sampler, ${t}Uv + ${t}CurrentOffset, 0.0).r`:`textureSampleLevel(${r}Texture, ${r}Sampler, ${t}Uv + ${t}CurrentOffset, i32(${$}), 0.0).r`};\n if (${t}CurrentSampledHeight > ${t}CurrentRayHeight) {\n let ${t}Delta1 = ${t}CurrentSampledHeight - ${t}CurrentRayHeight;\n let ${t}Delta2 = (${t}CurrentRayHeight + ${t}StepSize) - ${t}LastSampledHeight;\n let ${t}Ratio = ${t}Delta1 / (${t}Delta1 + ${t}Delta2);\n ${t}CurrentOffset = ${t}Ratio * ${t}LastOffset + (1.0 - ${t}Ratio) * ${t}CurrentOffset;\n break;\n }\n ${t}CurrentRayHeight = ${t}CurrentRayHeight - ${t}StepSize;\n ${t}LastOffset = ${t}CurrentOffset;\n ${t}CurrentOffset = ${t}CurrentOffset + ${t}StepSize * ${t}MaxOffset;\n ${t}LastSampledHeight = ${t}CurrentSampledHeight;\n }\n let ${t}UvWithOffset = ${t}Uv + ${t}CurrentOffset;\n `,out:`${t}UvWithOffset`}}compileGlsl(){const e=C(this.heightMap)?L(this.heightMap):null,t=null==e?"texture2D(heightMap, uv + vCurrOffset).r":"texture(heightMap, vec3(uv + vCurrOffset, heightMapLayer)).r",n={uv:this.uv,heightScale:this.heightScale,heightMap:this.heightMap,minLayers:i(this.minLayers),maxLayers:i(this.maxLayers),vN:S,vViewPosition:x,...null==e?{}:{heightMapLayer:e}};return c(v,n,`\n vec3 N = normalize(vN);\n vec3 viewDir = normalize(vViewPosition);\n vec3 dp1 = dFdx(-vViewPosition);\n vec3 dp2 = dFdy(-vViewPosition);\n vec2 duv1 = dFdx(uv);\n vec2 duv2 = dFdy(uv);\n vec3 dp2perp = cross(dp2, N);\n vec3 dp1perp = cross(N, dp1);\n vec3 T = dp2perp * duv1.x + dp1perp * duv2.x;\n vec3 B = dp2perp * duv1.y + dp1perp * duv2.y;\n float invmax = inversesqrt(max(dot(T, T), dot(B, B)));\n mat3 tbn = mat3(T * invmax, B * invmax, N);\n vec3 vv = -viewDir * tbn;\n float parallaxLimit = -length(vv.xy) / vv.z;\n parallaxLimit *= heightScale;\n vec2 vOffsetDir = normalize(vv.xy);\n vec2 vMaxOffset = vOffsetDir * parallaxLimit;\n float nNumSamples = mix(float(maxLayers), float(minLayers), dot(-viewDir, N));\n float fStepSize = 1.0 / nNumSamples;\n float fCurrRayHeight = 1.0;\n vec2 vCurrOffset = vec2(0.0);\n vec2 vLastOffset = vec2(0.0);\n float fLastSampledHeight = 1.0;\n float fCurrSampledHeight = 1.0;\n for (int nCurrSample = 0; nCurrSample < 24; nCurrSample++) {\n if (float(nCurrSample) > nNumSamples) break;\n fCurrSampledHeight = ${t};\n if (fCurrSampledHeight > fCurrRayHeight) {\n float delta1 = fCurrSampledHeight - fCurrRayHeight;\n float delta2 = (fCurrRayHeight + fStepSize) - fLastSampledHeight;\n float ratio = delta1 / (delta1 + delta2);\n vCurrOffset = ratio * vLastOffset + (1.0 - ratio) * vCurrOffset;\n break;\n } else {\n fCurrRayHeight -= fStepSize;\n vLastOffset = vCurrOffset;\n vCurrOffset += fStepSize * vMaxOffset;\n fLastSampledHeight = fCurrSampledHeight;\n }\n }\n return uv + vCurrOffset;\n `)}}function C(e){return e instanceof f||"sampler2DArray"===e.constructor.typeName}function L(e){return e.index??i(0)}/*
1
+ import{abs as e,attributes as t,cross as n,dFdx as a,dFdy as r,float as i,normalize as l,select as s,transformed as p,UniformSampler2dArraySlice as f,uniforms as o,varying as $,Vec2Node as v,vec3 as u,vec4 as m}from"three-shader-graph";import{isWgslCompiler as h,isWgslFragmentCompiler as d}from"three-shader-graph";import{glslFunction as c}from"./glsl-node.js";export function computeTangent(e,t,i){const s=a(t),p=r(t),f=a(i),o=r(i),$=n(p,e),v=n(e,s),u=$.multiplyScalar(f.x).add(v.multiplyScalar(o.x));return m(l(u),1)}export function fallbackTangent(t){const a=e(t.y),r=s(a.lt(i(.999)),u(0,1,0),u(1,0,0));return l(n(r,t))}const g=t.normal,S=$(l(o.normalMatrix.multiplyVec(g))),x=$(p.mvPosition.xyz.multiplyScalar(-1));export function parallaxOcclusionMapping(e,t,n,a=8,r=24){return new y(e,t,n,a,r)}class y extends v{constructor(e,t,n,a,r){super(),this.uv=e,this.heightMap=t,this.heightScale=n,this.minLayers=a,this.maxLayers=r}compile(e){if(!h(e))return{out:e.get(this.compileGlsl())};if(!d(e))throw new Error("Parallax occlusion mapping requires fragment-stage derivatives in WGSL.");const t=`pom_${e.variable()}`,n=e.get(this.uv),a=e.get(this.heightMap),r=e.get(this.heightScale),l=e.get(S),s=e.get(x),p=e.get(i(this.minLayers)),f=e.get(i(this.maxLayers)),o=C(this.heightMap)?e.get(L(this.heightMap)):null;return{chunk:`\n let ${t}Uv = ${n};\n let ${t}Normal = normalize(${l});\n let ${t}ViewPosition = ${s};\n let ${t}ViewDirection = normalize(${t}ViewPosition);\n let ${t}Dp1 = dpdx(-${t}ViewPosition);\n // WGSL fragment coordinates have the opposite Y orientation from GLSL.\n // Negate both Y derivatives so the reconstructed cotangent frame keeps\n // the same handedness as the GLSL implementation.\n let ${t}Dp2 = -dpdy(-${t}ViewPosition);\n let ${t}Duv1 = dpdx(${t}Uv);\n let ${t}Duv2 = -dpdy(${t}Uv);\n let ${t}Dp2Perp = cross(${t}Dp2, ${t}Normal);\n let ${t}Dp1Perp = cross(${t}Normal, ${t}Dp1);\n let ${t}T = ${t}Dp2Perp * ${t}Duv1.x + ${t}Dp1Perp * ${t}Duv2.x;\n let ${t}B = ${t}Dp2Perp * ${t}Duv1.y + ${t}Dp1Perp * ${t}Duv2.y;\n let ${t}InvMax = inverseSqrt(max(dot(${t}T, ${t}T), dot(${t}B, ${t}B)));\n let ${t}Tbn = mat3x3<f32>(\n ${t}T * ${t}InvMax,\n ${t}B * ${t}InvMax,\n ${t}Normal\n );\n let ${t}Vv = -(transpose(${t}Tbn) * ${t}ViewDirection);\n let ${t}SafeVvZ = select(\n ${t}Vv.z,\n select(-0.0001, 0.0001, ${t}Vv.z >= 0.0),\n abs(${t}Vv.z) < 0.0001\n );\n let ${t}ParallaxLimit = (-length(${t}Vv.xy) / ${t}SafeVvZ) * ${r};\n let ${t}OffsetDirection = normalize(${t}Vv.xy);\n let ${t}MaxOffset = ${t}OffsetDirection * ${t}ParallaxLimit;\n // Fade when the full parallax displacement approaches a pixel. This\n // follows screen footprint, so distant and heavily tiled surfaces\n // naturally stop paying for height-map samples.\n let ${t}UvFootprint = max(length(${t}Duv1), length(${t}Duv2));\n let ${t}Fade = smoothstep(\n 0.5, 2.0, length(${t}MaxOffset) / max(${t}UvFootprint, 0.000001)\n );\n let ${t}NumSamples = max(\n 1.0,\n mix(${f}, ${p}, dot(-${t}ViewDirection, ${t}Normal))\n );\n let ${t}StepSize = 1.0 / ${t}NumSamples;\n var ${t}CurrentRayHeight = 1.0;\n var ${t}CurrentOffset = vec2<f32>(0.0);\n var ${t}LastOffset = vec2<f32>(0.0);\n var ${t}LastSampledHeight = 1.0;\n var ${t}CurrentSampledHeight = 1.0;\n if (${t}Fade > 0.0) {\n for (var ${t}SampleIndex: i32 = 0; ${t}SampleIndex < 24; ${t}SampleIndex = ${t}SampleIndex + 1) {\n if (f32(${t}SampleIndex) > ${t}NumSamples) {\n break;\n }\n ${t}CurrentSampledHeight = ${null==o?`textureSampleLevel(${a}Texture, ${a}Sampler, ${t}Uv + ${t}CurrentOffset, 0.0).r`:`textureSampleLevel(${a}Texture, ${a}Sampler, ${t}Uv + ${t}CurrentOffset, i32(${o}), 0.0).r`};\n if (${t}CurrentSampledHeight > ${t}CurrentRayHeight) {\n let ${t}Delta1 = ${t}CurrentSampledHeight - ${t}CurrentRayHeight;\n let ${t}Delta2 = (${t}CurrentRayHeight + ${t}StepSize) - ${t}LastSampledHeight;\n let ${t}Ratio = ${t}Delta1 / (${t}Delta1 + ${t}Delta2);\n ${t}CurrentOffset = ${t}Ratio * ${t}LastOffset + (1.0 - ${t}Ratio) * ${t}CurrentOffset;\n break;\n }\n ${t}CurrentRayHeight = ${t}CurrentRayHeight - ${t}StepSize;\n ${t}LastOffset = ${t}CurrentOffset;\n ${t}CurrentOffset = ${t}CurrentOffset + ${t}StepSize * ${t}MaxOffset;\n ${t}LastSampledHeight = ${t}CurrentSampledHeight;\n }\n }\n let ${t}UvWithOffset = ${t}Uv + ${t}CurrentOffset * ${t}Fade;\n `,out:`${t}UvWithOffset`}}compileGlsl(){const e=C(this.heightMap)?L(this.heightMap):null,t=null==e?"texture2D(heightMap, uv + vCurrOffset).r":"texture(heightMap, vec3(uv + vCurrOffset, heightMapLayer)).r",n={uv:this.uv,heightScale:this.heightScale,heightMap:this.heightMap,minLayers:i(this.minLayers),maxLayers:i(this.maxLayers),vN:S,vViewPosition:x,...null==e?{}:{heightMapLayer:e}};return c(v,n,`\n vec3 N = normalize(vN);\n vec3 viewDir = normalize(vViewPosition);\n vec3 dp1 = dFdx(-vViewPosition);\n vec3 dp2 = dFdy(-vViewPosition);\n vec2 duv1 = dFdx(uv);\n vec2 duv2 = dFdy(uv);\n vec3 dp2perp = cross(dp2, N);\n vec3 dp1perp = cross(N, dp1);\n vec3 T = dp2perp * duv1.x + dp1perp * duv2.x;\n vec3 B = dp2perp * duv1.y + dp1perp * duv2.y;\n float invmax = inversesqrt(max(dot(T, T), dot(B, B)));\n mat3 tbn = mat3(T * invmax, B * invmax, N);\n vec3 vv = -viewDir * tbn;\n float parallaxLimit = -length(vv.xy) / vv.z;\n parallaxLimit *= heightScale;\n vec2 vOffsetDir = normalize(vv.xy);\n vec2 vMaxOffset = vOffsetDir * parallaxLimit;\n float nNumSamples = mix(float(maxLayers), float(minLayers), dot(-viewDir, N));\n float fStepSize = 1.0 / nNumSamples;\n float fCurrRayHeight = 1.0;\n vec2 vCurrOffset = vec2(0.0);\n vec2 vLastOffset = vec2(0.0);\n float fLastSampledHeight = 1.0;\n float fCurrSampledHeight = 1.0;\n for (int nCurrSample = 0; nCurrSample < 24; nCurrSample++) {\n if (float(nCurrSample) > nNumSamples) break;\n fCurrSampledHeight = ${t};\n if (fCurrSampledHeight > fCurrRayHeight) {\n float delta1 = fCurrSampledHeight - fCurrRayHeight;\n float delta2 = (fCurrRayHeight + fStepSize) - fLastSampledHeight;\n float ratio = delta1 / (delta1 + delta2);\n vCurrOffset = ratio * vLastOffset + (1.0 - ratio) * vCurrOffset;\n break;\n } else {\n fCurrRayHeight -= fStepSize;\n vLastOffset = vCurrOffset;\n vCurrOffset += fStepSize * vMaxOffset;\n fLastSampledHeight = fCurrSampledHeight;\n }\n }\n return uv + vCurrOffset;\n `)}}function C(e){return e instanceof f||"sampler2DArray"===e.constructor.typeName}function L(e){return e.index??i(0)}/*
2
2
  * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
3
  * See the LICENSE.md file for details.
4
4
  */
@@ -0,0 +1,2 @@
1
+ import 'reflect-metadata';
2
+ //# sourceMappingURL=actor-persistence-runtime.test.d.ts.map
@@ -0,0 +1,4 @@
1
+ import"reflect-metadata";import{expect as e,test as t,vi as n}from"vitest";n.hoisted(()=>{const e=new Proxy({},{get:(e,t)=>e[t]??(e[t]=n.fn())});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e})}),n.mock("../scene/bootstrap.js",()=>({loadScene:n.fn()})),n.mock("../rendering.js",()=>({RenderingView:class{},GraphicsRuntime:class{}}));import{loadScene as r}from"../scene/bootstrap.js";import{HologyApplicationRuntime as s}from"../gameplay/initiate.js";import{NetMode as o}from"../gameplay/net/net-session.js";import{SceneRef as i}from"../scene/scene-ref.js";function a(){const e=function(){let e,t;return{promise:new Promise((n,r)=>{e=n,t=r}),resolve:e,reject:t}}(),t={},i=[],a={registerPersistentActors:()=>[t]},c={sceneName:"old",netMode:o.none,sceneRoots:[],components:{},materializer:{dispose:n.fn()},container:{set:n.fn()},viewController:{paused:!1},renderingView:{compileAsync:n.fn(),initTexturesAsync:n.fn()},physics:{removeRemoved:n.fn(),addFromScene:n.fn()},sceneTransition:{publish:n.fn()},world:{actors:[],stopSceneEventGraphs:n.fn(),removeSceneLifetimeActors:n.fn(),persistence:{captureAll:n.fn(()=>i.push("capture")),restore:n.fn(()=>e.promise)},addActor:n.fn(function(e){this.actors.push(e),i.push("register")}),beginActorPlay:n.fn(()=>i.push("begin")),startSceneEventGraphs:n.fn(()=>i.push("graphs"))}},l={},p=Object.create(s.prototype);return Object.assign(p,{config:{shaders:{},actors:{},dataDir:""},getApplicationService:n.fn(),loading:n.fn(),checkIntent:n.fn(),setState:n.fn(),renderingHost:{applicationView:l,activate:n.fn(e=>i.push(e===l?"hide":"present"))}}),n.mocked(r).mockResolvedValue({materializer:a,actors:[t],sceneRoots:[],scene:{}}),{runtime:p,session:c,restore:e,order:i}}t("travel suspends presentation and simulation until restore, begin-play and graphs complete",async()=>{const{runtime:t,session:r,restore:s,order:o}=a(),c=t.performSceneTransition(r,{scene:new i("next")},1);await n.waitFor(()=>e(r.world.persistence.restore).toHaveBeenCalled()),e(r.viewController.paused).toBe(!0),e(o).toEqual(["hide","capture","register"]),s.resolve(),await c,e(o).toEqual(["hide","capture","register","begin","graphs","present"]),e(r.viewController.paused).toBe(!1)}),t("failed restore leaves the scene suspended without begin-play or event graphs",async()=>{const{runtime:t,session:r,restore:s,order:o}=a(),c=t.performSceneTransition(r,{scene:new i("next")},1),l=e(c).rejects.toThrow("invalid saved actor");await n.waitFor(()=>e(r.world.persistence.restore).toHaveBeenCalled()),s.reject(new Error("invalid saved actor")),await l,e(r.viewController.paused).toBe(!0),e(o).toEqual(["hide","capture","register"])}),t("cancelled loading cannot begin-play or present a restored scene",async()=>{const{runtime:t,session:r,restore:s,order:o}=a(),c=t.performSceneTransition(r,{scene:new i("next")},1),l=e(c).rejects.toThrow("cancelled");await n.waitFor(()=>e(r.world.persistence.restore).toHaveBeenCalled()),t.checkIntent.mockImplementation(()=>{throw new Error("cancelled")}),s.resolve(),await l,e(o).not.toContain("begin"),e(o).not.toContain("present")}),t("network persistence is rejected before creating a session",async()=>{const{runtime:t}=a();t.createSession=n.fn(),await e(t.performLoad(class{},{persistence:{},multi:{session:{mode:o.client}}},1)).rejects.toThrow("single-player"),e(t.createSession).not.toHaveBeenCalled()});/*
2
+ * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
+ * See the LICENSE.md file for details.
4
+ */
@@ -0,0 +1,2 @@
1
+ import 'reflect-metadata';
2
+ //# sourceMappingURL=actor-persistence.test.d.ts.map
@@ -0,0 +1,4 @@
1
+ import"reflect-metadata";import{describe as e,expect as t,test as s,vi as n}from"vitest";import{ActorPersistence as o,assertActorPersistenceData as r}from"../gameplay/services/actor-persistence.js";class a{constructor(e="health"){this.persistenceKey=e,this.persistenceVersion=1,this.value=10}onCapturePersistence(){return{value:this.value}}onRestorePersistence(e){this.value=e.value}}function c(e=!0){const s=new a;return{persistenceEnabled:e,attachedComponents:[s],component:s,persistenceVersion:2,value:"closed",onCapturePersistence(){return{value:this.value}},onRestorePersistence(e,s){t(s.version).toBe(2),this.value=e.value}}}function i(e){const t=new o;return t.configure({data:e}),t}a.__isActorComponent=!0;const p={sceneId:"stable-scene",path:"prefab/enemy"};e("ActorPersistence",()=>{s("captures independent actor and component state without aliasing snapshots",async()=>{const e=i(),s=c();e.register(s,p),s.component.value=31,s.value="open";const n=e.snapshot();s.component.value=80,t(n.scenes["stable-scene"]["prefab/enemy"].components.health.data).toEqual({value:31});const o=i(n),r=c();o.register(r,p),await o.restore([r]),t(r.component.value).toBe(31),t(r.value).toBe("open")}),s("deduplicates aliased components but rejects duplicate stable keys",()=>{const e=i(),s=c();t(e.register(s,p)).toBe(!0),t(Object.keys(e.snapshot().scenes["stable-scene"]["prefab/enemy"].components)).toEqual(["health"]);const n=c();n.attachedComponents.push(new a),t(()=>i().register(n,p)).toThrow(/Duplicate/)}),s("restores all participants before post-restore and supports cyclic identity lookups",async()=>{const e=i(),s=c(),o=c(),r={sceneId:"stable-scene",path:"other"};e.register(s,p),e.register(o,r),o.component.value=42;const a=i(e.snapshot()),l=c(),u=c();a.register(l,p),a.register(u,r);const d=n.fn(()=>{t(a.resolve(r)).toBe(u),t(u.component.value).toBe(42),t(a.resolve(p)).toBe(l)});Object.assign(l,{onPersistenceRestored:d}),await a.restore([l,u]),t(d).toHaveBeenCalledOnce()}),s("preserves unloaded scenes and unknown component entries",async()=>{const e=i({version:1,scenes:{elsewhere:{chest:{actor:{version:5,data:"untouched"}}},"stable-scene":{"prefab/enemy":{components:{future:{version:3,data:[1,2]}}}}},dynamic:{}}),s=c();e.register(s,p),await e.restore([s]);const n=e.snapshot();t(n.scenes.elsewhere.chest.actor.version).toBe(5),t(n.scenes["stable-scene"]["prefab/enemy"].components.future).toEqual({version:3,data:[1,2]})}),s("records whole-prefab removal immediately and never resurrects it on capture or teardown",()=>{const e=i(),s=c();e.register(s,p,{...p,path:"prefab"}),t(e.markRemoved(s)).toBe(!0),t(e.resolve(p)).toBeUndefined(),t(e.isRemoved({...p,path:"prefab/other"})).toBe(!0),e.unregister(s),t(e.snapshot().scenes["stable-scene"]).toEqual({prefab:{removed:!0}}),t(()=>e.register(c(),p)).toThrow(/removed/)}),s("ordinary unregister captures state without marking removal and permits scene revisit",async()=>{const e=i(),s=c();e.register(s,p),s.component.value=17,e.unregister(s),t(e.isRemoved(p)).toBe(!1);const n=c();e.register(n,p),await e.restore([n]),t(n.component.value).toBe(17)}),s("disabled sessions and nonparticipating actors do not inspect component state",()=>{const e=c(!1);Object.defineProperty(e,"component",{get(){throw new Error("must not inspect")}}),t(i().register(e,p)).toBe(!1),t((new o).register(c(),p)).toBe(!1)}),s("dynamic identities survive new sessions and reject duplicate registration",async()=>{const e=i(),s=c(),n={dynamicId:"quest-companion",path:""};e.register(s,n),t(()=>e.register(c(),n)).toThrow(/Duplicate/);const o=i(e.snapshot()),r=c();o.register(r,n),await o.restore([r]),t(o.getIdentity(r)).toEqual(n)}),s("failed or cancelled async restores do not overwrite good saved state",async()=>{const e=i(),s=c();e.register(s,p);const n=e.snapshot(),o=i(n),r=c();Object.assign(r.component,{async onRestorePersistence(){throw this.value=-100,new Error("bad version")}}),o.register(r,p),await t(o.restore([r])).rejects.toThrow(/health/),o.unregister(r),t(o.snapshot()).toEqual(n);const a=i(n),l=c();a.register(l,p),await t(a.restore([l],()=>{throw new Error("cancelled")})).rejects.toThrow("cancelled")}),s("validates document versions and JSON payloads",()=>{t(()=>r({version:2,scenes:{},dynamic:{}})).toThrow();const e=i(),s=c();e.register(s,p),Object.assign(s,{onCapturePersistence:()=>({value:1/0})}),t(()=>e.snapshot()).toThrow(/capture/),t(()=>i({version:1,scenes:{a:{b:{components:[]}}},dynamic:{}})).toThrow()})});/*
2
+ * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
+ * See the LICENSE.md file for details.
4
+ */
@@ -0,0 +1,2 @@
1
+ import 'reflect-metadata';
2
+ //# sourceMappingURL=actor-transform-persistence.test.d.ts.map
@@ -0,0 +1,4 @@
1
+ import{__setFunctionName as o}from"tslib";import"reflect-metadata";import{describe as t,expect as e,test as n,vi as s}from"vitest";import{Group as r,Vector3 as a}from"three";import{ActorPersistence as i}from"../gameplay/services/actor-persistence.js";import{ActorTransformPersistenceComponent as c,captureActorTransform as p,restoreActorTransform as m}from"../gameplay/actors/builtin/components/actor-transform-persistence.js";function y(){const o=new r,t={object:o,position:o.position,rotation:o.rotation,persistenceEnabled:!0,attachedComponents:[]},e=Object.create(c.prototype);return Object.assign(e,{actor:t,persistenceKey:"transform",persistenceVersion:1,restored:!1,physics:{setPosition:s.fn(),setRotation:s.fn(),flushModifiedBodyPositionsToColliders:s.fn()},worldPosition:new a,worldRotation:o.quaternion.clone()}),t.attachedComponents.push(e),{actor:t,component:e}}s.mock("../gameplay/actors/component.js",()=>{var t;return{ActorComponent:(t=class{},o(t,"ActorComponent"),t.__isActorComponent=!0,t),Component:()=>o=>o}}),s.mock("../gameplay/inject.js",()=>({inject:()=>({})})),s.mock("../gameplay/services/physics/physics-system.js",()=>({PhysicsSystem:class{}})),t("actor transform persistence",()=>{n("round trips position and yaw independently of pitch and roll",async()=>{const o=y();o.actor.position.set(3,4,5),o.actor.rotation.set(.25,1.2,.5);const t=new i;t.configure({}),t.register(o.actor,{sceneId:"scene",path:"enemy"});const n=t.snapshot();e(n.scenes.scene.enemy.components?.transform).toEqual({version:1,data:{position:{x:3,y:4,z:5},yaw:1.2}});const s=y();s.actor.rotation.set(.4,0,.6);const r=new i;r.configure({data:n}),r.register(s.actor,{sceneId:"scene",path:"enemy"}),await r.restore([s.actor]),e(s.actor.position.toArray()).toEqual([3,4,5]),e(s.actor.rotation.y).toBe(1.2),e(s.actor.rotation.x).toBe(.4),e(s.actor.rotation.z).toBe(.6),e(s.component.restored).toBe(!0)}),n("rejects invalid payload without partially changing the actor",()=>{const{actor:o}=y();o.position.set(7,8,9),e(()=>m(o,{position:{x:1,y:2,z:"bad"},yaw:1})).toThrow(/Invalid actor transform/),e(o.position.toArray()).toEqual([7,8,9]),e(p(o).position).toEqual({x:7,y:8,z:9})}),n("old saves without a transform retain the authored position",async()=>{const{actor:o,component:t}=y();o.position.set(2,0,-3);const n=new i;n.configure({data:{version:1,scenes:{},dynamic:{}}}),n.register(o,{sceneId:"scene",path:"enemy"}),await n.restore([o]),e(o.position.toArray()).toEqual([2,0,-3]),e(t.restored).toBe(!1)})});/*
2
+ * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
+ * See the LICENSE.md file for details.
4
+ */
@@ -1,4 +1,4 @@
1
- import{describe as e,expect as t,it as a}from"vitest";import*as o from"three";import{NodeShaderMaterial as i,rgb as n,rgba as s,standardMaterial as r,textureSampler2d as l,uniformFloat as c,uniformSampler2d as p,uniformVec3 as m,varyingAttributes as d,vec3 as u}from"three-shader-graph";import{SerializedParamType as h}from"../scene/model.js";import{WorldCollisionMethod as y}from"../effects/vfx/vfx-asset.js";import{compileGpuSpriteEmitters as g,materializeGpuSpriteRenderClasses as f,requiresCpuVfxBackend as b,selectVfxBackend as w}from"../effects/vfx/gpu-sprite-compiler.js";import{prepareGpuSpriteBackend as C}from"../effects/vfx/gpu-sprite-backend.js";import{calculateGpuSpriteOptionalStateLayout as x,GPU_TRAIL_RENDER_CLASS_BYTES as v,GpuSpriteDirtyRange as B,getCompiledGpuSpriteMaterial as S,getGpuSpriteComputeWgsl as V,getGpuSpriteMaterialWgsl as T,getGpuTrailMaterialWgsl as M}from"../effects/vfx/gpu-sprite-runtime.js";import{Trail as I,trailUV as P}from"../effects/vfx/trail-renderer.js";import{timeUniforms as E}from"../shader-nodes/time.js";import{GpuSpriteSchedulerRuntime as j}from"../effects/vfx/gpu-sprite-scheduler-runtime.js";import{GpuSpriteTemplateCache as z,instantiateGpuSpriteTemplate as O}from"../effects/vfx/gpu-sprite-template.js";import{GpuSpriteWorldRuntime as q}from"../effects/vfx/gpu-sprite-world-runtime.js";import{GpuSpriteRenderClassCache as A}from"../effects/vfx/gpu-sprite-compiler.js";import{addGpuSpriteRenderGraphPasses as R}from"../effects/vfx/gpu-sprite-render-graph.js";import{gpuVfxCapacityClass as G}from"../effects/vfx/gpu-vfx-capacity.js";import{buildParticleLightingShaderHeader as k,ComputeKernel as L}from"@hology/webgpu-renderer";import{configureWebGpuRuntimeBindings as _}from"../rendering/webgpu-runtime-bridge.js";_({createComputeKernel:(e,t)=>new L(e,t),buildParticleLightingShaderHeader:k});import{sampleSceneColor as U}from"../shader-nodes/scene-sample.js";import{screenUV as D}from"../shader-nodes/depth.js";import{particleUniforms as W}from"../shader-nodes/particle.js";import{createGpuMeshAssetMaterial as F}from"../effects/vfx/vfx-gpu-material.js";const N=e=>({type:h.Number,value:e}),Z=e=>({type:h.Vector3,value:e});function H(e={}){return{type:"sprite",texture:"dot",color:"#ffffff",intensity:1,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}function X(e={}){return{type:"mesh",assetId:"simple-mesh",space:"world",...e}}function Y(e={}){return{type:"trail",length:10,width:.5,taper:!0,color:"#40a0ff",colorEnd:"#ff4080",intensity:2,intensityEnd:.25,opacityStart:1,opacityEnd:0,bloom:!1,billboard:!0,opacityChannel:"alpha",space:"world",...e}}function $(e,t=H()){return{id:e,name:e,position:[0,0,0],rotation:[0,0,0],rate:{type:"once",count:2,delay:0},output:t,initializers:[{id:`${e}-life`,type:"lifetime",enabled:!0,params:{duration:N(2)}},{id:`${e}-position`,type:"positionSphere",enabled:!0,params:{position:Z([0,0,0]),radius:N(1),surface:{type:h.Boolean,value:!1}}}],behaviours:[]}}function Q(...e){return{id:"test-vfx",name:"test-vfx",type:"vfx",vfx:{emitters:e}}}e("GPU sprite compiler compatibility",()=>{a("keeps standard asset materials lit for GPU mesh particles",async()=>{const e=new o.Texture,a=new o.Texture,n=new o.Texture,s=new i({color:r({color:m("color",new o.Vector3(.3,.2,.1)).rgb.multiply(p("map",e).sample(d.uv).rgb),roughness:c("roughness",.8).multiply(p("roughnessMap",a).sample(d.uv).g),normal:p("normalMap",n).sample(d.uv).rgb,metalness:.1}),opacity:c("opacity",1)}),l=await F("material-id",{getAsset:async()=>({id:"material-id"}),getTexture:async()=>e,getMesh:async()=>new o.Mesh,getMaterial:async()=>s}),u=S(l),h=T(u,!0);t(u.requiresRendererShaderHeader).toBe(!0),t(u.needsWorldPosition).toBe(!0),t(u.fragmentMain).toContain("rendererPointLightForLight"),t(u.fragmentMain).toContain("rendererDirectionalLightForLight"),t(h).toContain("fn rendererPointLightCount()"),t(u.textures.map).toBe(e),t(u.textures.roughnessMap).toBe(a),t(u.textures.normalMap).toBe(n)}),a("bakes an authored effect input into emitter spawn delay",()=>{const e=$("bound-delay");e.rate.delayBinding={type:"vfxInput",inputId:"timing"};const a=Q(e);a.vfx.inputs=[{id:"timing",name:"Timing",type:"number",defaultValue:.5}];const i=g(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate.delay).toBe(.5)}),a("bakes the emitter rotation into world-space spawn position, velocity, and mesh rotation",()=>{const e=V(16,!0,!1,!1,!1,!0);t(e).toContain("zoneOffset = worldSpawnOffset(emitter, zoneOffset)"),t(e).toContain("let initialVelocity = worldSpawnDirection(emitter, localInitialVelocity)"),t(e).toContain("localInitialVelocity += randomConeDirection(sampledMeshNormal"),t(e).toContain("worldSpawnRotation(emitter), quaternionFromEulerXYZ(initialRotationEuler)"),t(e).toContain("optionalStates[meshStateOffset], quaternionFromEulerXYZ(rotationDelta)"),t(e).not.toContain("optionalStates[meshStateOffset] = quaternionFromEulerXYZ(rotationEuler)"),t(e).toContain("emitter.trailTransform0 = command.trailTransform0"),t(e).not.toMatch(/__[A-Z_]+__/)}),a("compiles pay-for-play child emitters and screen-space collision metadata",()=>{const e=$("parent");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{bounce:N(.6),friction:N(.2),lifeLoss:N(.5),debug:{type:h.Boolean,value:!0}}});const a=$("child");a.rate={type:"once",count:3,delay:0},a.spawnEvent="collision",e.children=[a];const i=g(Q(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters).toHaveLength(2),t(i.emitters[0]).toMatchObject({hasWorldCollision:!0,collisionBounce:.6,collisionFriction:.2,collisionLifeLoss:.5,collisionDebug:!0}),t(i.emitters[1]).toMatchObject({parentEmitter:0,childParticlesPerParent:3,childSpawnEvent:2,childLevel:1,capacity:6});const n=V(16);t(n).not.toContain("collisionDepth"),t(n).not.toContain("optionalStates"),t(n).not.toContain("triangleRecord"),t(n).not.toMatch(/__[A-Z_]+__/);const s=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,0,!1,!0);t(s).toContain("var<storage, read_write> optionalStates"),t(s).toContain("let triangleRecord = optionalStates[triangleOffset]"),t(s).toContain("fn meshSamplerBoneMatrix"),t(s).toContain("meshSamplerVertexPosition(samplerOffset"),t(s).toContain("var triangleOffset = samplerOffset"),t(s).not.toContain("let triangleOffset = samplerOffset");const r=V(16,!1,!1,!1,!1,!1,!1,0,0,!0,!0,32,1);t(r).toContain("collisionDepth"),t(r).toContain("collisionStep <= 6u"),t(r).toContain("approachingVisibleSurface"),t(r).toContain("optionalStates[32u + owner.padding]"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("supports local-space parent particles spawning local or world-space children",()=>{const e=$("parent",H({space:"local"})),a=$("local-child",H({space:"local"})),i=$("world-child",H({space:"world"}));e.children=[a,i];const n=g(Q(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.compatibility.issues).toEqual([]),t(n.emitters.map(e=>e.localSpace)).toEqual([!0,!0,!1]);const s=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(s).not.toContain("parentTransform = mat4x4");const r=V(16,!1,!1,!1,!1,!0,!1,0,0,!1,!1,0,1,!0);t(r).toContain("parentTransform = mat4x4"),t(r).toContain("parentEmitter.trail.z > 0.0 && emitter.trail.z < 0.0"),t(r).toContain("childEmissionEnd = parent.velocityLife.w"),t(r).toContain("particle.positionAge = vec4(particle.positionAge.xyz, age)"),t(r).not.toContain("childEventReady = parentAlive"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("rejects world-space parents with local-space child output",()=>{const e=$("parent",H({space:"world"}));e.children=[$("child",H({space:"local"}))];const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues).toContainEqual(t.objectContaining({code:"child-emitter-local-space"}))}),a("orders child render classes by authored render order without changing simulation hierarchy",()=>{const e=$("parent",X({renderOrder:10})),a=$("child",H({renderOrder:-10}));e.children=[a];const i=g(Q(e),[new o.Vector3]);t(i.renderClasses.map(e=>e.output.renderOrder)).toEqual([-10,10]),t(i.emitters[0].parentEmitter).toBeUndefined(),t(i.emitters[0].renderClass).toBe(1),t(i.emitters[1]).toMatchObject({parentEmitter:0,renderClass:0})}),a("uses geometric world reservation classes without fixed template maxima",()=>{t([1,2,3,4,5,17].map(G)).toEqual([1,2,4,4,8,32])}),a("compiles stretched sprites into a velocity-aligned render class",async()=>{const e=g(Q($("stretched",function(e={}){return{type:"stretchedSprite",texture:"dot",color:"#ffffff",intensity:1,scale:.5,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}({scale:.75}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await f(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].stretchScale).toBe(.75);const i=T(S(a.renderClasses[0].material),!1);t(i).toContain("stretchedSpriteClipPosition"),t(i).toContain("bitcast<f32>(renderClass.data.w)")}),a("materializes billboard trails with CPU-compatible history sizing",async()=>{const e=g(Q($("trail",Y({length:9.6}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.emitters).toHaveLength(1),t(e.emitters[0].capacity).toBe(2);const a=await f(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({historyLength:11,width:.5,taper:!0}),t(v).toBe(80);const i=V(16,!1,!1,!1,!0);t(i).toContain("@group(0) @binding(8) var<storage, read_write> optionalStates"),t(i).not.toContain("@binding(9)")}),a("streams local trail transforms through emitter commands without another storage binding",()=>{const e=g(Q($("attached-trail",Y({space:"local"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=V(16,!1,!1,!1,!0,!0);t(a).toContain("trailTransform0: vec4<f32>"),t(a).toContain("emitter.trailTransform3 = command.trailTransform3"),t(a).not.toContain("@binding(12)")}),a("compiles trail graph UV varyings into the procedural ribbon ABI",()=>{const e=new i({color:s(u(P.x,P.y,W.opacity),W.opacity)});I.applyTrailShaderParameters(e);const a=M(e,!0);t(a).toContain("struct GraphVertexInput"),t(a).toContain("nodeVertexID: f32"),t(a).toContain("emitterTransforms"),t(a).not.toContain("__TRAIL_"),t(a).not.toContain("output.uv ="),t(a).not.toContain("output.color ="),t(a).not.toContain("normalize(after - before)"),t(a).not.toContain("2.0, 2.0, 2.0, 0.0")}),a("binds shader graph elapsed time to the GPU particle frame clock",()=>{const e=new i({color:s(u(E.elapsed),1)}),a=M(e),o=T(S(e),!1);t(a).toContain("frame.time"),t(o).toContain("frame.time"),t(a).not.toContain("hology_elapsed_time:"),t(o).not.toContain("hology_elapsed_time:")}),a("converts authored sRGB behavior colors to the linear render space",()=>{const e=$("legacy-color",Y());e.behaviours.push({id:"color",type:"changeColor",enabled:!0,params:{a:{type:h.Color,value:"#918383"},b:{type:h.Color,value:"#4f4f4f"}}});const a=g(Q(e),[new o.Vector3]);t(a.emitters[0].colorStart.toArray()).toEqual(new o.Color("#918383").toArray()),t(a.emitters[0].colorEnd.toArray()).toEqual(new o.Color("#4f4f4f").toArray())}),a("materializes one mesh geometry and rejects mesh hierarchies with multiple geometries",async()=>{const e=$("mesh",X({space:"local"}));e.initializers.push({id:"mesh-scale-components",type:"scaleComponents",enabled:!0,params:{scale:Z([1,2,3])}},{id:"mesh-rotation",type:"rotation",enabled:!0,params:{rotation:Z([30,45,60])}}),e.behaviours.push({id:"mesh-scale-components-behaviour",type:"scaleComponents",enabled:!0,params:{a:Z([2,1,1]),b:Z([1,.5,2])}});const a=Q(e),r=g(a,[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.renderClasses[0].output.type).toBe("mesh"),t(r.emitters[0].localSpace).toBe(!0),t(r.emitters[0].meshScaleStart.toArray()).toEqual([2,2,3]),t(r.emitters[0].meshScaleEnd.toArray()).toEqual([1,1,6]),t(r.emitters[0].meshRotation.w).not.toBe(1),t(r.emitters[0].meshRotationEuler.toArray()).toEqual([o.MathUtils.degToRad(30),o.MathUtils.degToRad(45),o.MathUtils.degToRad(60)]);const l=new o.Mesh(new o.BoxGeometry(1,1,1),new o.MeshBasicMaterial({color:3368601,opacity:.75})),c=await f(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l});t(c.renderClasses[0].meshVertices?.length).toBe(288),t(c.renderClasses[0].meshColor?.getHex()).toBe(3368601),t(c.renderClasses[0].meshOpacity).toBe(.75),t(c.renderClasses[0].localSpace).toBe(!0);const p=new i({color:s(n(.4),1)}),m=await f(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l},async()=>p);t(m.renderClasses[0].material).toBe(p);const d=new o.Group;d.add(new o.Mesh(new o.BoxGeometry),new o.Mesh(new o.BoxGeometry)),await t(f(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>d})).rejects.toThrow("exactly one mesh geometry")}),a("keeps an unconfigured mesh output GPU-compatible without rendering it",()=>{const e=g(Q($("unconfigured-mesh",X({assetId:null}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.compatibility.issues).toEqual([]),t(e.emitters).toEqual([]),t(e.renderClasses).toEqual([])}),a("treats an unselected mesh material override as the default material source",async()=>{const e=g(Q($("unselected-mesh-material",X({materialSource:"material",material:void 0}))),[new o.Vector3]),a=new i({color:s(n(.4),1)}),r=await f(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)},async()=>a);t(e.compatibility.compatible).toBe(!0),t(r.renderClasses[0].material).toBe(a)}),a("keeps an unselected mesh shader graph on the GPU default material",async()=>{const e=g(Q($("unselected-mesh-graph",X({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:null}}))),[new o.Vector3]),a=await f(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)});t(e.compatibility.compatible).toBe(!0),t(()=>S(a.renderClasses[0].material)).not.toThrow()}),a("gates custom mesh materials on a compatible GPU material resolver",async()=>{const e=g(Q($("mesh-material",X({material:"material-id"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a={getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)};await t(f(e.renderClasses,a)).rejects.toThrow("compatible GPU material resolver");const n=new o.Texture,s=await f(e.renderClasses,a,async()=>new i({color:l(n).sample(d.uv)}));t(s.renderClasses[0].material).toBeInstanceOf(i);const r=S(s.renderClasses[0].material);t(Object.values(r.textures)).toEqual([n])}),a("materializes non-billboard trails with orientation history",async()=>{const e=g(Q($("oriented-trail",Y({billboard:!1}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await f(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({billboard:!1});const i=V(16,!0,!1,!1,!0);t(i).toContain("trailSampleOffset + 1u"),t(i).toContain("quaternionMultiply(particleRotation, movementRotation)");const n=M(I.createBaseMaterial(),!1);t(n).toContain("renderClass.trail.w == 0u"),t(n).toContain("historySide(state, control)")}),a("changes the mesh render class when shader graph parameters change",()=>{const e={source:"asset",assetId:"mesh-graph"},a=g(Q($("mesh",X({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:N(1)}}))),[new o.Vector3]),i=g(Q($("mesh",X({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:N(2)}}))),[new o.Vector3]);t(a.renderClasses[0].key).not.toBe(i.renderClasses[0].key)}),a("preserves legacy mesh geometry transforms when requested",async()=>{const e=new o.Group,a=new o.Mesh((new o.BufferGeometry).setAttribute("position",new o.Float32BufferAttribute([1,0,0],3)));a.position.set(2,0,0),e.add(a);const i=g(Q($("modern",X())),[new o.Vector3]),n=g(Q($("legacy",X({useLegacyTransform:!0}))),[new o.Vector3]);t(i.renderClasses[0].key).not.toBe(n.renderClasses[0].key);const s={getTexture:async()=>new o.Texture,getMesh:async()=>e},r=await f(i.renderClasses,s),l=await f(n.renderClasses,s);t(r.renderClasses[0].meshVertices?.[0]).toBe(3),t(l.renderClasses[0].meshVertices?.[0]).toBe(1)}),a("does not reuse render classes across explicit template revisions",async()=>{const e=Q($("revisioned")),a={assets:{getTexture:async()=>new o.Texture},renderClassCache:new A},i=await(new z).resolve(e,{...a,revision:1}),n=await(new z).resolve(e,{...a,revision:2});t(i.template?.renderClasses[0]).not.toBe(n.template?.renderClasses[0])}),a("retains renderer-owned scene texture semantics for particle materials",()=>{const e=new i({color:U(D)}),a=S(e);t(Object.values(a.sceneTextures)).toEqual(["sceneColor"])}),a("retains the shared renderer geometry semantics for lit particle materials",()=>{const e=S(new i({color:r({color:n(6724044),roughness:.5,metalness:.1})}));t(e.needsWorldPosition).toBe(!0),t(e.needsWorldNormal).toBe(!0),t(e.requiresRendererShaderHeader).toBe(!0),t(e.fragmentMain).toContain("rendererDirectionalLightForLight"),t(k()).toContain("fn rendererCameraPosition()");const a=T(e,!0);t(a).toContain("fn rendererCameraPosition()"),t(a).toContain("rendererCameraPosition() - input.worldPosition"),t(a).toContain("@group(1) @binding(0)"),t(a).toContain("emitterTransform[0].xyz"),t(a).not.toContain("mat3x3<f32>(emitterTransforms["),t(a).toContain("fn normalMatrixFromLinearTransform"),t(a).not.toContain("inverse("),t(e.renderState.transparent).toBe(!1),t(e.renderState.depthWrite).toBe(!0),t(e.renderState.side).toBe(o.FrontSide)}),a("registers one ordered compute/render pair for a shared runtime",()=>{const e=[];let a;R({mainDepth:"depth",addPass:t=>e.push(t)},{executeCompute:()=>{},executeRender:(e,t)=>{a=t},getSceneTextureReads:()=>["sceneColorSnapshot"]},"color","WorldVfx"),t(e.map(e=>e.name)).toEqual(["WorldVfxSimulation","WorldVfxRender"]),t(e[1].dependsOn).toEqual(["WorldVfxSimulation"]),t(e[1].inputs).toMatchObject({sceneColorSnapshot:"sceneColorSnapshot"}),e[1].execute({}),t(a).toBe("color")}),a("coalesces touched emitter slots without allocating per mark",()=>{const e=new B;e.mark(7),e.mark(3),e.mark(5),t(e).toMatchObject({start:3,end:8,length:5}),e.clear(),t(e.length).toBe(0)}),a("generates particle-owner WGSL without reserved active identifiers",()=>{const e=V(16);t(e).toContain("enabled: u32"),t(e).not.toMatch(/\bactive\s*:/),t(e).not.toContain("owner.active"),t(e).toContain("fn applyEmitterCommandsMain"),t(e).toContain("@group(0) @binding(7) var<storage, read> emitterCommands"),t(e).toContain("var<storage, read_write> emitters"),t(e).toContain("timing: vec4<f32>"),t(e).toContain("let emitterDelta = select(frame.delta * emitter.timing.w, 0.0"),t(e).toContain("let sampledLife = mix(emitter.emission.z, emitter.emission.w"),t(e).toContain("particle.velocityLife = vec4(initialVelocity, particleLife)"),t(e).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, hash(particleSeed + 71u))"),t(e).toContain("select(animatedColor, particle.color.rgb, emitter.meshScaleStart.w > 0.5)")}),a("compiles random lifetime as a per-particle birth range",()=>{const e=$("random-life");e.initializers[0]={id:"random-life-range",type:"randomLifetime",enabled:!0,params:{minDuration:N(.5),maxDuration:N(2.5)}};const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({lifeMin:.5,life:2.5})}),a("compiles random initial color without lifetime interpolation",()=>{const e=$("random-color");e.initializers.push({id:"random-color-range",type:"randomColor",enabled:!0,params:{a:{type:h.Color,value:"#ff0000"},b:{type:h.Color,value:"#0000ff"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].randomColor).toBe(!0)}),a("compiles point, box, line, ring, and sphere-surface spawn zones",()=>{const e=[["positionPoint",{position:Z([1,2,3])},0],["positionSphere",{position:Z([1,2,3]),radius:N(2),surface:{type:h.Boolean,value:!0}},1],["positionBox",{position:Z([1,2,3]),dimensions:Z([4,5,6])},2],["positionLine",{a:Z([1,2,3]),b:Z([4,5,6])},3],["positionRing",{position:Z([1,2,3]),rotation:Z([10,20,30]),radius:N(4),thickness:N(.5),angle:N(180)},4]];for(const[a,i,n]of e){const e=$(`zone-${a}`);e.initializers[1]={id:`zone-${a}`,type:a,enabled:!0,params:i};const s=g(Q(e),[new o.Vector3]);t(s.compatibility.compatible).toBe(!0),t(s.emitters[0].spawnZoneType).toBe(n)}const a=V(16);t(a).toContain("fn rotateSpawnPoint"),t(a).toContain("if (zoneType == 4u)")}),a("preserves legacy per-update sprite rotation behavior",()=>{const e=$("sprite-rotate");e.behaviours.push({id:"rotate-add",type:"rotateAdd",enabled:!0,params:{rotation:Z([0,0,200])}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].spriteRotationStep).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const i=V(16);t(i).toContain("var rotation = particle.color.w;"),t(i).toContain("rotation += emitter.rotationInitialSpriteStep.w * frame.padding1"),e.output=X();const n=g(Q(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.emitters[0].meshRotationStep.z).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const s=V(16,!0);t(s).toContain("rotationEuler: vec4<f32>"),t(s).toContain("fn quaternionFromEulerXYZ"),t(s).toContain("emitter.rotationStep.xyz * frame.padding1"),e.initializers.push({id:"random-mesh-rotation",type:"randomRotation",enabled:!0,params:{axis:{type:h.String,value:"Y"}}});const r=g(Q(e),[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.emitters[0].randomMeshRotationAxis).toBe(3),t(s).toContain("randomRotationAxis == 1u"),t(s).toContain("randomRotationAxis == 4u")}),a("compiles radial velocity without per-particle auxiliary storage",()=>{const e=$("radial-velocity");e.initializers.push({id:"radial",type:"radialVelocity",enabled:!0,params:{speed:N(4),direction:Z([0,1,0]),spread:N(30)}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].hasRadialVelocity).toBe(!0),t(a.emitters[0].radialVelocitySpeed).toBe(4),t(a.emitters[0].radialVelocitySpread).toBeCloseTo(Math.PI/6);const i=V(16);t(i).toContain("fn radialVelocity"),t(i).toContain("emitter.radialMetadata.y > 0.5")}),a("compiles one attraction or repulsion field per emitter",()=>{const e=$("attraction");e.behaviours.push({id:"attract",type:"attraction",enabled:!0,params:{target:Z([1,2,3]),force:N(4),radius:N(5)}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].attractionTarget.toArray()).toEqual([1,2,3]),t(a.emitters[0].attractionForce).toBe(4),e.behaviours[0].type="repulsion";const i=g(Q(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].attractionForce).toBe(-4);const n=V(16);t(n).toContain("attractionDistanceSq < attractionRadiusSq")}),a("compiles mesh alignment modes into spawn-only parameters",()=>{const e=$("aligned-mesh",X());e.initializers.push({id:"align",type:"alignRotation",enabled:!0,params:{axis:{type:h.String,value:"+X"},mode:{type:h.String,value:"AlongVelocity"},offset:Z([10,20,30]),center:Z([1,2,3])}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].alignRotationAxis.toArray()).toEqual([1,0,0]),t(a.emitters[0].alignRotationMode).toBe(3),t(a.emitters[0].alignRotationOffset.x).toBeCloseTo(o.MathUtils.degToRad(10));const i=V(16,!0);t(i).toContain("fn quaternionFromUnitVectors"),t(i).toContain("alignRotationMode == 3u")}),a("groups authored render classes deterministically",()=>{const e=g(Q($("a"),$("b"),$("c",H({blendingMode:"normal"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.renderClasses).toHaveLength(2),t(e.emitters.map(e=>e.renderClass)).toEqual([0,0,1])}),a("can lay out emitter allocations contiguously per dynamic effect instance",()=>{const e=Q($("first"),$("second",H({blendingMode:"normal"}))),a=[new o.Vector3,new o.Vector3(1,0,0)],i=g(e,a),n=g(e,a,"instance-major");t(i.emitters.map(e=>e.renderClass)).toEqual([0,0,1,1]),t(n.emitters.map(e=>e.renderClass)).toEqual([0,1,0,1])}),a("reports sparse active-page simulation capacity",async()=>{const e=await C({asset:Q($("paged")),instancePositions:[new o.Vector3,new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:2,assets:{getTexture:async()=>new o.Texture}});e.runtime.setActivePages([0,2]),t(e.runtime.getStats()).toMatchObject({capacity:6,activePageCount:2,simulatedCapacity:4})}),a("compacts active page padding out of the simulation schedule",async()=>{const e=await j.create({asset:Q($("tiny-paged-effect")),maximumInstances:2,particlePageSize:256,assets:{getTexture:async()=>new o.Texture}});e.spawn(new o.Vector3),e.flushActivePages(),t(e.runtime.getStats()).toMatchObject({capacity:512,activePageCount:1,simulatedCapacity:2});const a=V(256);t(a).toContain("let particleIndex = activeParticleIndices[id.x]"),t(a).not.toContain("id.x % pageSize"),e.dispose()}),a("sizes alive indices by logical particles instead of render-class count",async()=>{const e=await C({asset:Q($("additive"),$("normal",H({blendingMode:"normal"}))),instancePositions:[new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:4,assets:{getTexture:async()=>new o.Texture}});t(e.runtime.getStats()).toMatchObject({capacity:8,renderClassCount:2,aliveIndexCapacity:8,aliveIndexBytes:32});const a=V(4);t(a).toContain("aliveIndices[indirect[renderClass].firstInstance + outputIndex]"),t(a).not.toContain("renderClass * u32(frame.capacity)")}),a("allocates and recycles multi-page dynamic effect instances",async()=>{const e=$("paged-instance");e.position=[2,0,0];const a=await j.create({asset:Q(e),maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),i=a.spawn(new o.Vector3);t(a.runtime.emitters[0].position.x).toBe(2),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),a.update(.1),t(a.scheduler.getInstance(i.handle)).not.toBeNull(),t(a.play(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!1),t(a.setTimescale(i,2)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(2),t(a.stop(i)).toBe(!0),t(a.runtime.emitters[0].stopTime).toBeGreaterThanOrEqual(0),t(a.runtime.emitters[0].expireWithin).toBe(-1),t(i.pages).toHaveLength(2),a.flushActivePages(),t(a.runtime.getStats()).toMatchObject({activePageCount:2,simulatedCapacity:2}),t(a.destroy(i)).toBe(!0);const n=a.spawn(new o.Vector3(1,0,0));t(new Set(n.pages)).toEqual(new Set(i.pages)),t(n.handle.generation).not.toBe(i.handle.generation),a.dispose()}),a("caps GPU particle drain time for expire and kill lifecycle behavior",async()=>{const e=await j.create({asset:Q($("expiring")),maximumInstances:2,assets:{getTexture:async()=>new o.Texture}}),a=e.spawn(new o.Vector3);t(e.stop(a,.5)).toBe(!0),t(e.runtime.emitters[0].expireWithin).toBe(.5);for(let t=0;t<4;t++)e.update(.1);t(e.scheduler.getInstance(a.handle)).not.toBeNull(),e.update(.1),t(e.scheduler.getInstance(a.handle)).toBeNull();const i=e.spawn(new o.Vector3);t(e.stop(i,0)).toBe(!0),e.update(.001),t(e.scheduler.getInstance(i.handle)).toBeNull(),e.dispose()}),a("rejects a template pool before allocation when its storage budget is exceeded",async()=>{const e=await j.create({asset:Q($("over-budget")),maximumInstances:128,particlePageSize:1,maximumStorageBytes:1,assets:{getTexture:async()=>new o.Texture}});t(e).toBeNull()}),a("compiles delayed and finite continuous emission timing",()=>{const e=$("delayed");e.rate={type:"once",count:2,delay:.25};const a=$("finite");a.rate={type:"continuous",count:2,time:.1,delay:0,duration:1};const i=g(Q(e,a),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate).toMatchObject({type:"once",delay:.25}),t(i.emitters[1].rate).toMatchObject({type:"continuous",delay:0,duration:1})}),a("treats zero continuous duration as unlimited like the CPU rate",()=>{const e=$("unlimited");e.rate={type:"continuous",count:2,time:.1,delay:0,duration:0};const a=g(Q(e),[new o.Vector3]);t(a.compatibility.issues.map(e=>e.code)).not.toContain("rate-duration")}),a("compiles bound lifetime metadata but falls back for unsupported input targets",()=>{const e=Q($("input-test"));e.vfx.inputs=[{id:"speed",name:"Speed",type:"number",defaultValue:1}],t(g(e,[new o.Vector3]).compatibility.compatible).toBe(!0),e.vfx.emitters[0].initializers[0].params.duration.binding={type:"vfxInput",inputId:"speed"};const a=g(e,[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.inputBindings).toEqual([t.objectContaining({inputId:"speed",emitterIndex:0,target:"initializer:lifetime:duration"})]),e.vfx.emitters[0].initializers.push({id:"bound-velocity",type:"velocity",enabled:!0,params:{velocity:{type:h.Vector3,value:[0,1,0],binding:{type:"vfxInput",inputId:"speed"}}}}),t(g(e,[new o.Vector3]).compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding")}),a("falls back for a bound continuous lifetime until particle capacity can grow",()=>{const e=$("continuous-bound-life");e.rate={type:"continuous",count:1,time:.1,delay:0,duration:0},e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"};const a=Q(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:2}];const i=g(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding"),t(i.inputBindings).toEqual([])}),a("caches compiled templates while keeping instance emitter state independent",async()=>{const e=Q($("cached-template")),a=new z;let i=0;const n={assets:{getTexture:async()=>(i+=1,new o.Texture)}},s=await a.resolve(e,n),r=await a.resolve(e,n);t(r.template).toBe(s.template),t(i).toBe(1),t(a.getStats()).toMatchObject({builds:1,hits:1});const l=O(s.template,[new o.Vector3(1,0,0),new o.Vector3(2,0,0)]);t(l[0].position).not.toBe(l[1].position),l[0].position.x=100,t(l[1].position.x).toBe(2),t(s.template.emitters[0].position.x).toBe(0),a.invalidate(e),await a.resolve(e,n),t(a.getStats()).toMatchObject({builds:2,hits:1,invalidations:1}),await a.resolve(e,{...n,revision:2}),t(a.getStats()).toMatchObject({builds:3,hits:1,invalidations:2})}),a("applies a bound lifetime only to the addressed scheduled instance",async()=>{const e=$("bound-life");e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"},e.initializers[1].params.radius.binding={type:"vfxInput",inputId:"radius"},e.initializers.push({id:"velocity",type:"velocity",enabled:!0,params:{velocity:{type:h.Vector3,value:[0,0,0],binding:{type:"vfxInput",inputId:"velocity"}}}});const a=Q(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:1},{id:"radius",name:"Radius",type:"number",defaultValue:1},{id:"velocity",name:"Velocity",type:"vec3",defaultValue:[0,0,0]}];const i=await j.create({asset:a,maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),n=i.spawn(new o.Vector3),s=i.spawn(new o.Vector3);t(i.setInput(n,"life",4)).toBe(!0),t(i.runtime.emitters[0].life).toBe(4),t(i.runtime.emitters[1].life).toBe(2),t(i.setInput(n,"radius",3)).toBe(!0),t(i.runtime.emitters[0].positionRadius).toBe(3),t(i.setInput(n,"velocity",new o.Vector3(1,2,3))).toBe(!0),t(i.runtime.emitters[0].velocityMin.toArray()).toEqual([1,2,3]),t(i.setInput(s,"missing",5)).toBe(!1),i.dispose()}),a("schedules different templates in one arena and merges shared render classes",async()=>{const e=H(),a=await q.create({templates:[{asset:Q($("world-a",e)),maximumInstances:2},{asset:Q($("world-b",{...e})),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},templateCache:new z,renderClassCache:new A,particlePageSize:1}),i=a.spawn(0,new o.Vector3),n=a.spawn(1,new o.Vector3(1,0,0));a.flushActivePages(),t(a.templateCount).toBe(2),t(a.runtime.getStats()).toMatchObject({renderClassCount:1,activePageCount:4}),t(a.scheduler.getStats()).toMatchObject({activeInstances:2,arena:{usedPages:4}}),t(a.destroy(i)).toBe(!0),t(a.destroy(n)).toBe(!0),a.flushActivePages(),t(a.runtime.hasActiveParticles).toBe(!1),t(a.runtime.getStats()).toMatchObject({aliveParticles:0,activePageCount:0,simulatedCapacity:0}),a.dispose()}),a("routes per-instance lifecycle changes through a shared multi-template world",async()=>{const e=$("world-lifecycle");e.position=[2,0,0];const a=await q.create({templates:[{asset:Q(e),maximumInstances:1},{asset:Q($("world-neighbour")),maximumInstances:1}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1}),i=a.spawn(0,new o.Vector3(3,0,0));t(a.runtime.emitters[0].position.x).toBe(5),t(a.setPosition(i,new o.Vector3(7,0,0))).toBe(!0),t(a.runtime.emitters[0].position.x).toBe(9),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),t(a.play(i)).toBe(!0),t(a.setTimescale(i,.5)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(.5),t(a.stop(i,0)).toBe(!0),a.update(.001),t(a.has(i)).toBe(!1),t(a.setPosition(i,new o.Vector3)).toBe(!1),a.dispose()}),a("rejects a shared world before runtime allocation when its storage budget is exceeded",async()=>{const e=await q.create({templates:[{asset:Q($("world-over-budget")),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1,maximumStorageBytes:1});t(e).toBeNull()}),a("falls back to CPU for semantic mismatches",()=>{const e=$("soft-local",H({softness:1,space:"local",lockY:!0}));e.behaviours.push({id:"animation",type:"animation",enabled:!0,params:{}});const a=g(Q(e),[new o.Vector3]),i=w(a.compatibility);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues.map(e=>e.code)).toEqual(t.arrayContaining(["behaviour:animation"])),t(i.backend).toBe("cpu"),t(w(a.compatibility,"gpu").backend).toBe("gpu")}),a("intentionally selects CPU simulation for physics-raycast world collision",()=>{const e=$("physics-collision");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{method:{type:h.String,value:y.PhysicsRaycast}}});const a=Q(e),i=g(a,[new o.Vector3]);t(b(a)).toBe(!0),t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues).toContainEqual(t.objectContaining({severity:"requirement",code:"physics-raycast-collision"})),t(w(i.compatibility).backend).toBe("cpu"),t(w(i.compatibility,"gpu").backend).toBe("cpu")}),a("compiles vortex with pay-for-play previous-velocity state",()=>{const e=$("vortex");e.behaviours.push({id:"vortex",type:"vortex",enabled:!0,params:{axis:Z([0,5,0]),amount:N(1.25)}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].vortexAxis.toArray()).toEqual([0,5,0]),t(a.emitters[0].vortexAmount).toBe(1.25),t(V(16,!1,!1)).not.toContain("vortexVelocities");const i=V(16,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("behaviourVelocity -= optionalStates[0u + particleIndex].xyz")}),a("compiles the remaining initializer and motion behaviours",()=>{const e=$("remaining-motion");e.initializers.push({id:"mass",type:"mass",enabled:!0,params:{min:N(2)}}),e.behaviours.push({id:"drift",type:"randomDrift",enabled:!0,params:{drift:Z([1,2,3]),delay:N(.25),ease:{type:h.String,value:"OutQuad"}}},{id:"follow",type:"followParent",enabled:!0,params:{speed:N(4)}},{id:"move",type:"moveTo",enabled:!0,params:{target:Z([5,6,7]),ease:{type:h.String,value:"InQuad"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({mass:2,randomDriftDelay:.25,randomDriftEasing:2,followParentSpeed:4,hasFollowParent:!0,moveToEasing:1,hasMoveTo:!0}),t(a.emitters[0].randomDrift.toArray()).toEqual([1,2,3]),t(a.emitters[0].moveToTarget.toArray()).toEqual([5,6,7])}),a("compiles authored easing independently for visual behavior channels",()=>{const e=$("easing");e.behaviours.push({id:"scale",type:"scale",enabled:!0,params:{a:N(0),b:N(1),ease:{type:h.String,value:"OutBack"}}},{id:"color",type:"changeColor",enabled:!0,params:{a:{type:h.Color,value:"#ff0000"},b:{type:h.Color,value:"#0000ff"},ease:{type:h.String,value:"InCubic"}}},{id:"opacity",type:"changeOpacity",enabled:!0,params:{a:N(0),b:N(1),ease:{type:h.String,value:"BumpSharp"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].curveEasing.toArray()).toEqual([18,4,20,0]);const i=V(16);t(i).toContain("fn applyEasing"),t(i).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, colorProgress)"),t(i).toContain("mix(emitter.scales.xy, emitter.scales.zw, scaleProgress)")}),a("compiles disperse into optional shared particle state",()=>{const e=$("disperse");e.behaviours.push({id:"disperse",type:"disperse",enabled:!0,params:{distance:N(3),axis:{type:h.String,value:"XZ"},ease:{type:h.String,value:"InOutSine"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].disperseDistance).toBe(3),t(a.emitters[0].dispersePlane).toBe(2),t(a.emitters[0].disperseEasing).toBe(12),t(V(16)).not.toContain("disperseStates");const i=V(16,!1,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("optionalStates[0u + particleIndex].w = disperseProgress")}),a("consolidates every optional simulation feature into the eighth storage binding",()=>{const e=V(16,!0,!0,!0,!0,!1,!1,48,64),a=[...e.matchAll(/@group\(0\) @binding\((\d+)\) var<storage/g)].map(e=>Number(e[1]));t(a).toEqual([1,2,3,4,5,6,7,8]),t(e).toContain("optionalStates[48u + particleIndex]"),t(e).toContain("optionalStates[64u + particleIndex]"),t(e).not.toMatch(/@binding\((9|10|11)\)/)}),a("lays out optional state regions without overlap",()=>{t(x({capacity:16,particleTransforms:!0,vortex:!0,disperse:!0,trailHistoryEntries:20})).toEqual({vortexOffset:48,disperseOffset:64,collisionEventOffset:80,trailOffset:80,meshSamplerOffset:100,entries:100,bytes:1600})}),a("materializes decal outputs as shared unit-box mesh render classes",async()=>{const e=$("decal");e.output={type:"decal",space:"world",colorMap:"",alphaMap:"",color:"#ffffff",unlit:!0,intensity:1,blendingMode:"normal",bloom:!1};const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await f(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].meshVertices?.length).toBe(288),t(l.renderClasses[0].material).toBe(r)}),a("materializes generated shape outputs through the shared mesh path",async()=>{const e=$("shape");e.output={type:"shape",shape:"box",params:{},material:"shape-material",materialSource:"material",space:"world"};const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await f(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].meshVertices?.length).toBe(288)}),a("forwards the physical particle index to fragment-stage model-matrix graphs",()=>{const e=S(new i({color:s(n(1),1)})),a=T(e,!0);t(a).toContain("color: vec4<f32>, instanceIndex: u32"),t(a).toContain("gpuParticle.color, particleIndex)"),t(a).toContain("@interpolate(flat) instanceIndex: u32"),t(a).toContain("output.instanceIndex = particleIndex")}),a("compiles soft default sprites with a pay-for-play scene-depth requirement",async()=>{const e=$("soft-sprite",H({softness:2})),a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const i=(await f(a.renderClasses,{getTexture:async()=>new o.Texture})).renderClasses[0].material,n=S(i);t(Object.values(n.sceneTextures)).toContain("sceneDepth")}),a("disables depth writes only for transparent custom GPU sprite materials",async()=>{const e=$("graph-sprite",H({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"graph"}})),a=g(Q(e),[new o.Vector3]),r=new i({color:s(n(1),.5),transparent:!0}),l=await f(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].material).toBe(r),t(r.depthWrite).toBe(!1),t(S(r).renderState.depthWrite).toBe(!1);const c=new i({color:s(n(1),1)}),p=await f(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>c);t(p.renderClasses[0].material).toBe(c),t(c.depthWrite).toBe(!0),t(S(c).renderState.depthWrite).toBe(!0)}),a("supports local-space sprite render classes through the shared emitter transform table",async()=>{const e=$("local-sprite",H({space:"local"})),a=g(Q(e),[new o.Vector3(4,5,6)]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].localSpace).toBe(!0),t(a.emitters[0].position.toArray()).toEqual([0,0,0]);const i=await f(a.renderClasses,{getTexture:async()=>new o.Texture});t(i.renderClasses[0].localSpace).toBe(!0)}),a("falls back before rendering when an authored graph cannot be resolved",async()=>{const e=$("graph",H({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"missing-graph"}})),a=await C({asset:Q(e),instancePositions:[new o.Vector3],assets:{getTexture:async()=>new o.Texture}});t(a.backend).toBe("cpu"),t(a.runtime).toBeNull(),t(a.reasons[0]).toContain("GPU material compilation failed")}),a("compiles default flipbook, texture, UV, and particle semantics to WGSL",async()=>{const e=$("flipbook",H({flipbook:{enabled:!0,rows:4,columns:4,fps:12,mode:"loop"}})),a=g(Q(e),[new o.Vector3]),i=await f(a.renderClasses,{getTexture:async()=>new o.Texture}),n=S(i.renderClasses[0].material);t(n.fragmentMain).toContain("textureSample"),t(`${n.vertexMain}\n${n.fragmentMain}`).toContain("gpuParticle.positionAge.w"),t(n.varyings.length).toBeGreaterThan(0)}),a("supports component scale-over-life for sprites",()=>{const e=$("stretched-smoke");e.behaviours.push({id:"stretch",type:"scaleComponents",enabled:!0,params:{a:Z([.25,1,1]),b:Z([2,.5,1]),ease:{type:22,value:"Linear"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].meshScaleStart.toArray()).toEqual([.25,1,1]),t(a.emitters[0].meshScaleEnd.toArray()).toEqual([2,.5,1])}),a("compiles authored scale and rotation input bindings",()=>{const e=$("input-driven",X());e.initializers.push({id:"scale",type:"scale",enabled:!0,params:{scale:{...N(1),binding:{type:"vfxInput",inputId:"scale-input"}}}},{id:"rotation",type:"rotation",enabled:!0,params:{rotation:{...Z([0,-100,0]),binding:{type:"vfxInput",inputId:"rotation-input"}}}}),e.behaviours.push({id:"rotate",type:"rotate",enabled:!0,params:{rotation:{...Z([0,-30,0]),binding:{type:"vfxInput",inputId:"step-input"}}}});const a=Q(e);a.vfx.inputs=[{id:"scale-input",name:"Scale",type:"number",defaultValue:1},{id:"rotation-input",name:"Rotation",type:"vec3",defaultValue:[0,-100,0]},{id:"step-input",name:"Rotation step",type:"vec3",defaultValue:[0,-30,0]}];const i=g(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.inputBindings.map(e=>e.target)).toEqual(["initializer:scale:scale","initializer:rotation:rotation","behaviour:rotate:rotation"])})});/*
1
+ import{describe as e,expect as t,it as a}from"vitest";import*as o from"three";import{NodeShaderMaterial as i,rgb as n,rgba as s,standardMaterial as r,textureSampler2d as l,uniformFloat as c,uniformSampler2d as p,uniformVec3 as m,varyingAttributes as d,vec3 as u}from"three-shader-graph";import{SerializedParamType as h}from"../scene/model.js";import{WorldCollisionMethod as y}from"../effects/vfx/vfx-asset.js";import{compileGpuSpriteEmitters as g,materializeGpuSpriteRenderClasses as f,requiresCpuVfxBackend as b,selectVfxBackend as w}from"../effects/vfx/gpu-sprite-compiler.js";import{prepareGpuSpriteBackend as C}from"../effects/vfx/gpu-sprite-backend.js";import{calculateGpuSpriteOptionalStateLayout as x,GPU_TRAIL_RENDER_CLASS_BYTES as v,GpuSpriteDirtyRange as B,getCompiledGpuSpriteMaterial as S,getGpuSpriteComputeWgsl as V,getGpuSpriteMaterialWgsl as T,getGpuTrailMaterialWgsl as M}from"../effects/vfx/gpu-sprite-runtime.js";import{Trail as I,trailUV as P}from"../effects/vfx/trail-renderer.js";import{timeUniforms as E}from"../shader-nodes/time.js";import{GpuSpriteSchedulerRuntime as j}from"../effects/vfx/gpu-sprite-scheduler-runtime.js";import{GpuSpriteTemplateCache as z,instantiateGpuSpriteTemplate as O}from"../effects/vfx/gpu-sprite-template.js";import{GpuSpriteWorldRuntime as q}from"../effects/vfx/gpu-sprite-world-runtime.js";import{GpuSpriteRenderClassCache as R}from"../effects/vfx/gpu-sprite-compiler.js";import{addGpuSpriteRenderGraphPasses as A}from"../effects/vfx/gpu-sprite-render-graph.js";import{gpuVfxCapacityClass as G}from"../effects/vfx/gpu-vfx-capacity.js";import{buildParticleLightingShaderHeader as k,ComputeKernel as L}from"@hology/webgpu-renderer";import{configureWebGpuRuntimeBindings as _}from"../rendering/webgpu-runtime-bridge.js";_({createComputeKernel:(e,t)=>new L(e,t),buildParticleLightingShaderHeader:k});import{sampleSceneColor as U}from"../shader-nodes/scene-sample.js";import{screenUV as D}from"../shader-nodes/depth.js";import{particleUniforms as W}from"../shader-nodes/particle.js";import{createGpuMeshAssetMaterial as F}from"../effects/vfx/vfx-gpu-material.js";const N=e=>({type:h.Number,value:e}),Z=e=>({type:h.Vector3,value:e});function H(e={}){return{type:"sprite",texture:"dot",color:"#ffffff",intensity:1,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}function X(e={}){return{type:"mesh",assetId:"simple-mesh",space:"world",...e}}function Y(e={}){return{type:"trail",length:10,width:.5,taper:!0,color:"#40a0ff",colorEnd:"#ff4080",intensity:2,intensityEnd:.25,opacityStart:1,opacityEnd:0,bloom:!1,billboard:!0,opacityChannel:"alpha",space:"world",...e}}function $(e,t=H()){return{id:e,name:e,position:[0,0,0],rotation:[0,0,0],rate:{type:"once",count:2,delay:0},output:t,initializers:[{id:`${e}-life`,type:"lifetime",enabled:!0,params:{duration:N(2)}},{id:`${e}-position`,type:"positionSphere",enabled:!0,params:{position:Z([0,0,0]),radius:N(1),surface:{type:h.Boolean,value:!1}}}],behaviours:[]}}function Q(...e){return{id:"test-vfx",name:"test-vfx",type:"vfx",vfx:{emitters:e}}}e("GPU sprite compiler compatibility",()=>{a("keeps standard asset materials lit for GPU mesh particles",async()=>{const e=new o.Texture,a=new o.Texture,n=new o.Texture,s=new i({color:r({color:m("color",new o.Vector3(.3,.2,.1)).rgb.multiply(p("map",e).sample(d.uv).rgb),roughness:c("roughness",.8).multiply(p("roughnessMap",a).sample(d.uv).g),normal:p("normalMap",n).sample(d.uv).rgb,metalness:.1}),opacity:c("opacity",1)}),l=await F("material-id",{getAsset:async()=>({id:"material-id"}),getTexture:async()=>e,getMesh:async()=>new o.Mesh,getMaterial:async()=>s}),u=S(l),h=T(u,!0);t(u.requiresRendererShaderHeader).toBe(!0),t(u.needsWorldPosition).toBe(!0),t(u.fragmentMain).toContain("rendererPointLightForLight"),t(u.fragmentMain).toContain("rendererDirectionalLightForLight"),t(h).toContain("fn rendererPointLightCount()"),t(u.textures.map).toBe(e),t(u.textures.roughnessMap).toBe(a),t(u.textures.normalMap).toBe(n)}),a("bakes an authored effect input into emitter spawn delay",()=>{const e=$("bound-delay");e.rate.delayBinding={type:"vfxInput",inputId:"timing"};const a=Q(e);a.vfx.inputs=[{id:"timing",name:"Timing",type:"number",defaultValue:.5}];const i=g(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate.delay).toBe(.5)}),a("bakes the emitter rotation into world-space spawn position, velocity, and mesh rotation",()=>{const e=V(16,!0,!1,!1,!1,!0);t(e).toContain("zoneOffset = worldSpawnOffset(emitter, zoneOffset)"),t(e).toContain("let initialVelocity = worldSpawnDirection(emitter, localInitialVelocity)"),t(e).toContain("localInitialVelocity += randomConeDirection(sampledMeshNormal"),t(e).toContain("worldSpawnRotation(emitter), quaternionFromEulerXYZ(initialRotationEuler)"),t(e).toContain("optionalStates[meshStateOffset], quaternionFromEulerXYZ(rotationDelta)"),t(e).not.toContain("optionalStates[meshStateOffset] = quaternionFromEulerXYZ(rotationEuler)"),t(e).toContain("emitter.trailTransform0 = command.trailTransform0"),t(e).not.toMatch(/__[A-Z_]+__/)}),a("compiles pay-for-play child emitters and screen-space collision metadata",()=>{const e=$("parent");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{bounce:N(.6),friction:N(.2),lifeLoss:N(.5),debug:{type:h.Boolean,value:!0}}});const a=$("child");a.rate={type:"once",count:3,delay:0},a.spawnEvent="collision",e.children=[a];const i=g(Q(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters).toHaveLength(2),t(i.emitters[0]).toMatchObject({hasWorldCollision:!0,collisionBounce:.6,collisionFriction:.2,collisionLifeLoss:.5,collisionDebug:!0}),t(i.emitters[1]).toMatchObject({parentEmitter:0,childParticlesPerParent:3,childSpawnEvent:2,childLevel:1,capacity:6});const n=V(16);t(n).not.toContain("collisionDepth"),t(n).not.toContain("optionalStates"),t(n).not.toContain("triangleRecord"),t(n).not.toMatch(/__[A-Z_]+__/);const s=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,0,!1,!0);t(s).toContain("var<storage, read_write> optionalStates"),t(s).toContain("let triangleRecord = optionalStates[triangleOffset]"),t(s).toContain("fn meshSamplerBoneMatrix"),t(s).toContain("meshSamplerVertexPosition(samplerOffset"),t(s).toContain("var triangleOffset = samplerOffset"),t(s).not.toContain("let triangleOffset = samplerOffset");const r=V(16,!1,!1,!1,!1,!1,!1,0,0,!0,!0,32,1);t(r).toContain("collisionDepth"),t(r).toContain("collisionStep <= 6u"),t(r).toContain("approachingVisibleSurface"),t(r).toContain("optionalStates[32u + owner.padding]"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("supports local-space parent particles spawning local or world-space children",()=>{const e=$("parent",H({space:"local"})),a=$("local-child",H({space:"local"})),i=$("world-child",H({space:"world"}));e.children=[a,i];const n=g(Q(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.compatibility.issues).toEqual([]),t(n.emitters.map(e=>e.localSpace)).toEqual([!0,!0,!1]);const s=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(s).not.toContain("parentTransform = mat4x4");const r=V(16,!1,!1,!1,!1,!0,!1,0,0,!1,!1,0,1,!0);t(r).toContain("parentTransform = mat4x4"),t(r).toContain("parentEmitter.trail.z > 0.0 && emitter.trail.z < 0.0"),t(r).toContain("childEmissionEnd = parent.velocityLife.w"),t(r).toContain("particle.positionAge = vec4(particle.positionAge.xyz, age)"),t(r).not.toContain("childEventReady = parentAlive"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("rejects world-space parents with local-space child output",()=>{const e=$("parent",H({space:"world"}));e.children=[$("child",H({space:"local"}))];const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues).toContainEqual(t.objectContaining({code:"child-emitter-local-space"}))}),a("orders child render classes by authored render order without changing simulation hierarchy",()=>{const e=$("parent",X({renderOrder:10})),a=$("child",H({renderOrder:-10}));e.children=[a];const i=g(Q(e),[new o.Vector3]);t(i.renderClasses.map(e=>e.output.renderOrder)).toEqual([-10,10]),t(i.emitters[0].parentEmitter).toBeUndefined(),t(i.emitters[0].renderClass).toBe(1),t(i.emitters[1]).toMatchObject({parentEmitter:0,renderClass:0})}),a("uses geometric world reservation classes without fixed template maxima",()=>{t([1,2,3,4,5,17].map(G)).toEqual([1,2,4,4,8,32])}),a("compiles stretched sprites into a velocity-aligned render class",async()=>{const e=g(Q($("stretched",function(e={}){return{type:"stretchedSprite",texture:"dot",color:"#ffffff",intensity:1,scale:.5,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}({scale:.75}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await f(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].stretchScale).toBe(.75);const i=T(S(a.renderClasses[0].material),!1);t(i).toContain("stretchedSpriteClipPosition"),t(i).toContain("bitcast<f32>(renderClass.data.w)")}),a("materializes billboard trails with CPU-compatible history sizing",async()=>{const e=g(Q($("trail",Y({length:9.6,uvMode:"repeat",unitsPerRepeat:.5}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.emitters).toHaveLength(1),t(e.emitters[0].capacity).toBe(2);const a=await f(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({historyLength:11,width:.5,taper:!0,uvMode:"repeat",unitsPerRepeat:.5}),t(a.renderClasses[0].texture?.wrapS).toBe(o.RepeatWrapping),t(v).toBe(96);const i=V(16,!1,!1,!1,!0);t(i).toContain("@group(0) @binding(8) var<storage, read_write> optionalStates"),t(i).not.toContain("@binding(9)")}),a("streams local trail transforms through emitter commands without another storage binding",()=>{const e=g(Q($("attached-trail",Y({space:"local"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=V(16,!1,!1,!1,!0,!0);t(a).toContain("trailTransform0: vec4<f32>"),t(a).toContain("emitter.trailTransform3 = command.trailTransform3"),t(a).not.toContain("@binding(12)")}),a("compiles trail graph UV varyings into the procedural ribbon ABI",()=>{const e=new i({color:s(u(P.x,P.y,W.opacity),W.opacity)});I.applyTrailShaderParameters(e);const a=M(e,!0);t(a).toContain("struct GraphVertexInput"),t(a).toContain("nodeVertexID: f32"),t(a).toContain("nodeDistance: f32"),t(a).toContain("renderClass.uvSettings.x"),t(a).toContain("headDistance - mix(historyDistance(state, control), historyDistance(state, control + 1), t)"),t(a).toContain("emitterTransforms"),t(a).not.toContain("__TRAIL_"),t(a).not.toContain("output.uv ="),t(a).not.toContain("output.color ="),t(a).not.toContain("normalize(after - before)"),t(a).not.toContain("2.0, 2.0, 2.0, 0.0")}),a("binds shader graph elapsed time to the GPU particle frame clock",()=>{const e=new i({color:s(u(E.elapsed),1)}),a=M(e),o=T(S(e),!1);t(a).toContain("frame.time"),t(o).toContain("frame.time"),t(a).not.toContain("hology_elapsed_time:"),t(o).not.toContain("hology_elapsed_time:")}),a("converts authored sRGB behavior colors to the linear render space",()=>{const e=$("legacy-color",Y());e.behaviours.push({id:"color",type:"changeColor",enabled:!0,params:{a:{type:h.Color,value:"#918383"},b:{type:h.Color,value:"#4f4f4f"}}});const a=g(Q(e),[new o.Vector3]);t(a.emitters[0].colorStart.toArray()).toEqual(new o.Color("#918383").toArray()),t(a.emitters[0].colorEnd.toArray()).toEqual(new o.Color("#4f4f4f").toArray())}),a("materializes one mesh geometry and rejects mesh hierarchies with multiple geometries",async()=>{const e=$("mesh",X({space:"local"}));e.initializers.push({id:"mesh-scale-components",type:"scaleComponents",enabled:!0,params:{scale:Z([1,2,3])}},{id:"mesh-rotation",type:"rotation",enabled:!0,params:{rotation:Z([30,45,60])}}),e.behaviours.push({id:"mesh-scale-components-behaviour",type:"scaleComponents",enabled:!0,params:{a:Z([2,1,1]),b:Z([1,.5,2])}});const a=Q(e),r=g(a,[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.renderClasses[0].output.type).toBe("mesh"),t(r.emitters[0].localSpace).toBe(!0),t(r.emitters[0].meshScaleStart.toArray()).toEqual([2,2,3]),t(r.emitters[0].meshScaleEnd.toArray()).toEqual([1,1,6]),t(r.emitters[0].meshRotation.w).not.toBe(1),t(r.emitters[0].meshRotationEuler.toArray()).toEqual([o.MathUtils.degToRad(30),o.MathUtils.degToRad(45),o.MathUtils.degToRad(60)]);const l=new o.Mesh(new o.BoxGeometry(1,1,1),new o.MeshBasicMaterial({color:3368601,opacity:.75})),c=await f(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l});t(c.renderClasses[0].meshVertices?.length).toBe(288),t(c.renderClasses[0].meshColor?.getHex()).toBe(3368601),t(c.renderClasses[0].meshOpacity).toBe(.75),t(c.renderClasses[0].localSpace).toBe(!0);const p=new i({color:s(n(.4),1)}),m=await f(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l},async()=>p);t(m.renderClasses[0].material).toBe(p);const d=new o.Group;d.add(new o.Mesh(new o.BoxGeometry),new o.Mesh(new o.BoxGeometry)),await t(f(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>d})).rejects.toThrow("exactly one mesh geometry")}),a("keeps an unconfigured mesh output GPU-compatible without rendering it",()=>{const e=g(Q($("unconfigured-mesh",X({assetId:null}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.compatibility.issues).toEqual([]),t(e.emitters).toEqual([]),t(e.renderClasses).toEqual([])}),a("treats an unselected mesh material override as the default material source",async()=>{const e=g(Q($("unselected-mesh-material",X({materialSource:"material",material:void 0}))),[new o.Vector3]),a=new i({color:s(n(.4),1)}),r=await f(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)},async()=>a);t(e.compatibility.compatible).toBe(!0),t(r.renderClasses[0].material).toBe(a)}),a("keeps an unselected mesh shader graph on the GPU default material",async()=>{const e=g(Q($("unselected-mesh-graph",X({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:null}}))),[new o.Vector3]),a=await f(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)});t(e.compatibility.compatible).toBe(!0),t(()=>S(a.renderClasses[0].material)).not.toThrow()}),a("gates custom mesh materials on a compatible GPU material resolver",async()=>{const e=g(Q($("mesh-material",X({material:"material-id"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a={getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)};await t(f(e.renderClasses,a)).rejects.toThrow("compatible GPU material resolver");const n=new o.Texture,s=await f(e.renderClasses,a,async()=>new i({color:l(n).sample(d.uv)}));t(s.renderClasses[0].material).toBeInstanceOf(i);const r=S(s.renderClasses[0].material);t(Object.values(r.textures)).toEqual([n])}),a("materializes non-billboard trails with orientation history",async()=>{const e=g(Q($("oriented-trail",Y({billboard:!1}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await f(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({billboard:!1});const i=V(16,!0,!1,!1,!0);t(i).toContain("trailSampleOffset + 1u"),t(i).toContain("quaternionMultiply(particleRotation, movementRotation)");const n=M(I.createBaseMaterial(),!1);t(n).toContain("renderClass.trail.w == 0u"),t(n).toContain("historySide(state, control)")}),a("changes the mesh render class when shader graph parameters change",()=>{const e={source:"asset",assetId:"mesh-graph"},a=g(Q($("mesh",X({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:N(1)}}))),[new o.Vector3]),i=g(Q($("mesh",X({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:N(2)}}))),[new o.Vector3]);t(a.renderClasses[0].key).not.toBe(i.renderClasses[0].key)}),a("preserves legacy mesh geometry transforms when requested",async()=>{const e=new o.Group,a=new o.Mesh((new o.BufferGeometry).setAttribute("position",new o.Float32BufferAttribute([1,0,0],3)));a.position.set(2,0,0),e.add(a);const i=g(Q($("modern",X())),[new o.Vector3]),n=g(Q($("legacy",X({useLegacyTransform:!0}))),[new o.Vector3]);t(i.renderClasses[0].key).not.toBe(n.renderClasses[0].key);const s={getTexture:async()=>new o.Texture,getMesh:async()=>e},r=await f(i.renderClasses,s),l=await f(n.renderClasses,s);t(r.renderClasses[0].meshVertices?.[0]).toBe(3),t(l.renderClasses[0].meshVertices?.[0]).toBe(1)}),a("does not reuse render classes across explicit template revisions",async()=>{const e=Q($("revisioned")),a={assets:{getTexture:async()=>new o.Texture},renderClassCache:new R},i=await(new z).resolve(e,{...a,revision:1}),n=await(new z).resolve(e,{...a,revision:2});t(i.template?.renderClasses[0]).not.toBe(n.template?.renderClasses[0])}),a("retains renderer-owned scene texture semantics for particle materials",()=>{const e=new i({color:U(D)}),a=S(e);t(Object.values(a.sceneTextures)).toEqual(["sceneColor"])}),a("retains the shared renderer geometry semantics for lit particle materials",()=>{const e=S(new i({color:r({color:n(6724044),roughness:.5,metalness:.1})}));t(e.needsWorldPosition).toBe(!0),t(e.needsWorldNormal).toBe(!0),t(e.requiresRendererShaderHeader).toBe(!0),t(e.fragmentMain).toContain("rendererDirectionalLightForLight"),t(k()).toContain("fn rendererCameraPosition()");const a=T(e,!0);t(a).toContain("fn rendererCameraPosition()"),t(a).toContain("rendererCameraPosition() - input.worldPosition"),t(a).toContain("@group(1) @binding(0)"),t(a).toContain("emitterTransform[0].xyz"),t(a).not.toContain("mat3x3<f32>(emitterTransforms["),t(a).toContain("fn normalMatrixFromLinearTransform"),t(a).not.toContain("inverse("),t(e.renderState.transparent).toBe(!1),t(e.renderState.depthWrite).toBe(!0),t(e.renderState.side).toBe(o.FrontSide)}),a("registers one ordered compute/render pair for a shared runtime",()=>{const e=[];let a;A({mainDepth:"depth",addPass:t=>e.push(t)},{executeCompute:()=>{},executeRender:(e,t)=>{a=t},getSceneTextureReads:()=>["sceneColorSnapshot"]},"color","WorldVfx"),t(e.map(e=>e.name)).toEqual(["WorldVfxSimulation","WorldVfxRender"]),t(e[1].dependsOn).toEqual(["WorldVfxSimulation"]),t(e[1].inputs).toMatchObject({sceneColorSnapshot:"sceneColorSnapshot"}),e[1].execute({}),t(a).toBe("color")}),a("coalesces touched emitter slots without allocating per mark",()=>{const e=new B;e.mark(7),e.mark(3),e.mark(5),t(e).toMatchObject({start:3,end:8,length:5}),e.clear(),t(e.length).toBe(0)}),a("generates particle-owner WGSL without reserved active identifiers",()=>{const e=V(16);t(e).toContain("enabled: u32"),t(e).not.toMatch(/\bactive\s*:/),t(e).not.toContain("owner.active"),t(e).toContain("fn applyEmitterCommandsMain"),t(e).toContain("@group(0) @binding(7) var<storage, read> emitterCommands"),t(e).toContain("var<storage, read_write> emitters"),t(e).toContain("timing: vec4<f32>"),t(e).toContain("let emitterDelta = select(frame.delta * emitter.timing.w, 0.0"),t(e).toContain("let sampledLife = mix(emitter.emission.z, emitter.emission.w"),t(e).toContain("particle.velocityLife = vec4(initialVelocity, particleLife)"),t(e).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, hash(particleSeed + 71u))"),t(e).toContain("select(animatedColor, particle.color.rgb, emitter.meshScaleStart.w > 0.5)")}),a("compiles random lifetime as a per-particle birth range",()=>{const e=$("random-life");e.initializers[0]={id:"random-life-range",type:"randomLifetime",enabled:!0,params:{minDuration:N(.5),maxDuration:N(2.5)}};const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({lifeMin:.5,life:2.5})}),a("compiles random initial color without lifetime interpolation",()=>{const e=$("random-color");e.initializers.push({id:"random-color-range",type:"randomColor",enabled:!0,params:{a:{type:h.Color,value:"#ff0000"},b:{type:h.Color,value:"#0000ff"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].randomColor).toBe(!0)}),a("compiles point, box, line, ring, and sphere-surface spawn zones",()=>{const e=[["positionPoint",{position:Z([1,2,3])},0],["positionSphere",{position:Z([1,2,3]),radius:N(2),surface:{type:h.Boolean,value:!0}},1],["positionBox",{position:Z([1,2,3]),dimensions:Z([4,5,6])},2],["positionLine",{a:Z([1,2,3]),b:Z([4,5,6])},3],["positionRing",{position:Z([1,2,3]),rotation:Z([10,20,30]),radius:N(4),thickness:N(.5),angle:N(180)},4]];for(const[a,i,n]of e){const e=$(`zone-${a}`);e.initializers[1]={id:`zone-${a}`,type:a,enabled:!0,params:i};const s=g(Q(e),[new o.Vector3]);t(s.compatibility.compatible).toBe(!0),t(s.emitters[0].spawnZoneType).toBe(n)}const a=V(16);t(a).toContain("fn rotateSpawnPoint"),t(a).toContain("if (zoneType == 4u)")}),a("preserves legacy per-update sprite rotation behavior",()=>{const e=$("sprite-rotate");e.behaviours.push({id:"rotate-add",type:"rotateAdd",enabled:!0,params:{rotation:Z([0,0,200])}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].spriteRotationStep).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const i=V(16);t(i).toContain("var rotation = particle.color.w;"),t(i).toContain("rotation += emitter.rotationInitialSpriteStep.w * frame.padding1"),e.output=X();const n=g(Q(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.emitters[0].meshRotationStep.z).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const s=V(16,!0);t(s).toContain("rotationEuler: vec4<f32>"),t(s).toContain("fn quaternionFromEulerXYZ"),t(s).toContain("emitter.rotationStep.xyz * frame.padding1"),e.initializers.push({id:"random-mesh-rotation",type:"randomRotation",enabled:!0,params:{axis:{type:h.String,value:"Y"}}});const r=g(Q(e),[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.emitters[0].randomMeshRotationAxis).toBe(3),t(s).toContain("randomRotationAxis == 1u"),t(s).toContain("randomRotationAxis == 4u")}),a("compiles radial velocity without per-particle auxiliary storage",()=>{const e=$("radial-velocity");e.initializers.push({id:"radial",type:"radialVelocity",enabled:!0,params:{speed:N(4),direction:Z([0,1,0]),spread:N(30)}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].hasRadialVelocity).toBe(!0),t(a.emitters[0].radialVelocitySpeed).toBe(4),t(a.emitters[0].radialVelocitySpread).toBeCloseTo(Math.PI/6);const i=V(16);t(i).toContain("fn radialVelocity"),t(i).toContain("emitter.radialMetadata.y > 0.5")}),a("compiles one attraction or repulsion field per emitter",()=>{const e=$("attraction");e.behaviours.push({id:"attract",type:"attraction",enabled:!0,params:{target:Z([1,2,3]),force:N(4),radius:N(5)}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].attractionTarget.toArray()).toEqual([1,2,3]),t(a.emitters[0].attractionForce).toBe(4),e.behaviours[0].type="repulsion";const i=g(Q(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].attractionForce).toBe(-4);const n=V(16);t(n).toContain("attractionDistanceSq < attractionRadiusSq")}),a("compiles mesh alignment modes into spawn-only parameters",()=>{const e=$("aligned-mesh",X());e.initializers.push({id:"align",type:"alignRotation",enabled:!0,params:{axis:{type:h.String,value:"+X"},mode:{type:h.String,value:"AlongVelocity"},offset:Z([10,20,30]),center:Z([1,2,3])}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].alignRotationAxis.toArray()).toEqual([1,0,0]),t(a.emitters[0].alignRotationMode).toBe(3),t(a.emitters[0].alignRotationOffset.x).toBeCloseTo(o.MathUtils.degToRad(10));const i=V(16,!0);t(i).toContain("fn quaternionFromUnitVectors"),t(i).toContain("alignRotationMode == 3u")}),a("groups authored render classes deterministically",()=>{const e=g(Q($("a"),$("b"),$("c",H({blendingMode:"normal"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.renderClasses).toHaveLength(2),t(e.emitters.map(e=>e.renderClass)).toEqual([0,0,1])}),a("can lay out emitter allocations contiguously per dynamic effect instance",()=>{const e=Q($("first"),$("second",H({blendingMode:"normal"}))),a=[new o.Vector3,new o.Vector3(1,0,0)],i=g(e,a),n=g(e,a,"instance-major");t(i.emitters.map(e=>e.renderClass)).toEqual([0,0,1,1]),t(n.emitters.map(e=>e.renderClass)).toEqual([0,1,0,1])}),a("reports sparse active-page simulation capacity",async()=>{const e=await C({asset:Q($("paged")),instancePositions:[new o.Vector3,new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:2,assets:{getTexture:async()=>new o.Texture}});e.runtime.setActivePages([0,2]),t(e.runtime.getStats()).toMatchObject({capacity:6,activePageCount:2,simulatedCapacity:4})}),a("compacts active page padding out of the simulation schedule",async()=>{const e=await j.create({asset:Q($("tiny-paged-effect")),maximumInstances:2,particlePageSize:256,assets:{getTexture:async()=>new o.Texture}});e.spawn(new o.Vector3),e.flushActivePages(),t(e.runtime.getStats()).toMatchObject({capacity:512,activePageCount:1,simulatedCapacity:2});const a=V(256);t(a).toContain("let particleIndex = activeParticleIndices[id.x]"),t(a).not.toContain("id.x % pageSize"),e.dispose()}),a("sizes alive indices by logical particles instead of render-class count",async()=>{const e=await C({asset:Q($("additive"),$("normal",H({blendingMode:"normal"}))),instancePositions:[new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:4,assets:{getTexture:async()=>new o.Texture}});t(e.runtime.getStats()).toMatchObject({capacity:8,renderClassCount:2,aliveIndexCapacity:8,aliveIndexBytes:32});const a=V(4);t(a).toContain("aliveIndices[indirect[renderClass].firstInstance + outputIndex]"),t(a).not.toContain("renderClass * u32(frame.capacity)")}),a("allocates and recycles multi-page dynamic effect instances",async()=>{const e=$("paged-instance");e.position=[2,0,0];const a=await j.create({asset:Q(e),maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),i=a.spawn(new o.Vector3);t(a.runtime.emitters[0].position.x).toBe(2),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),a.update(.1),t(a.scheduler.getInstance(i.handle)).not.toBeNull(),t(a.play(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!1),t(a.setTimescale(i,2)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(2),t(a.stop(i)).toBe(!0),t(a.runtime.emitters[0].stopTime).toBeGreaterThanOrEqual(0),t(a.runtime.emitters[0].expireWithin).toBe(-1),t(i.pages).toHaveLength(2),a.flushActivePages(),t(a.runtime.getStats()).toMatchObject({activePageCount:2,simulatedCapacity:2}),t(a.destroy(i)).toBe(!0);const n=a.spawn(new o.Vector3(1,0,0));t(new Set(n.pages)).toEqual(new Set(i.pages)),t(n.handle.generation).not.toBe(i.handle.generation),a.dispose()}),a("caps GPU particle drain time for expire and kill lifecycle behavior",async()=>{const e=await j.create({asset:Q($("expiring")),maximumInstances:2,assets:{getTexture:async()=>new o.Texture}}),a=e.spawn(new o.Vector3);t(e.stop(a,.5)).toBe(!0),t(e.runtime.emitters[0].expireWithin).toBe(.5);for(let t=0;t<4;t++)e.update(.1);t(e.scheduler.getInstance(a.handle)).not.toBeNull(),e.update(.1),t(e.scheduler.getInstance(a.handle)).toBeNull();const i=e.spawn(new o.Vector3);t(e.stop(i,0)).toBe(!0),e.update(.001),t(e.scheduler.getInstance(i.handle)).toBeNull(),e.dispose()}),a("rejects a template pool before allocation when its storage budget is exceeded",async()=>{const e=await j.create({asset:Q($("over-budget")),maximumInstances:128,particlePageSize:1,maximumStorageBytes:1,assets:{getTexture:async()=>new o.Texture}});t(e).toBeNull()}),a("compiles delayed and finite continuous emission timing",()=>{const e=$("delayed");e.rate={type:"once",count:2,delay:.25};const a=$("finite");a.rate={type:"continuous",count:2,time:.1,delay:0,duration:1};const i=g(Q(e,a),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate).toMatchObject({type:"once",delay:.25}),t(i.emitters[1].rate).toMatchObject({type:"continuous",delay:0,duration:1})}),a("treats zero continuous duration as unlimited like the CPU rate",()=>{const e=$("unlimited");e.rate={type:"continuous",count:2,time:.1,delay:0,duration:0};const a=g(Q(e),[new o.Vector3]);t(a.compatibility.issues.map(e=>e.code)).not.toContain("rate-duration")}),a("compiles bound lifetime metadata but falls back for unsupported input targets",()=>{const e=Q($("input-test"));e.vfx.inputs=[{id:"speed",name:"Speed",type:"number",defaultValue:1}],t(g(e,[new o.Vector3]).compatibility.compatible).toBe(!0),e.vfx.emitters[0].initializers[0].params.duration.binding={type:"vfxInput",inputId:"speed"};const a=g(e,[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.inputBindings).toEqual([t.objectContaining({inputId:"speed",emitterIndex:0,target:"initializer:lifetime:duration"})]),e.vfx.emitters[0].initializers.push({id:"bound-velocity",type:"velocity",enabled:!0,params:{velocity:{type:h.Vector3,value:[0,1,0],binding:{type:"vfxInput",inputId:"speed"}}}}),t(g(e,[new o.Vector3]).compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding")}),a("falls back for a bound continuous lifetime until particle capacity can grow",()=>{const e=$("continuous-bound-life");e.rate={type:"continuous",count:1,time:.1,delay:0,duration:0},e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"};const a=Q(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:2}];const i=g(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding"),t(i.inputBindings).toEqual([])}),a("caches compiled templates while keeping instance emitter state independent",async()=>{const e=Q($("cached-template")),a=new z;let i=0;const n={assets:{getTexture:async()=>(i+=1,new o.Texture)}},s=await a.resolve(e,n),r=await a.resolve(e,n);t(r.template).toBe(s.template),t(i).toBe(1),t(a.getStats()).toMatchObject({builds:1,hits:1});const l=O(s.template,[new o.Vector3(1,0,0),new o.Vector3(2,0,0)]);t(l[0].position).not.toBe(l[1].position),l[0].position.x=100,t(l[1].position.x).toBe(2),t(s.template.emitters[0].position.x).toBe(0),a.invalidate(e),await a.resolve(e,n),t(a.getStats()).toMatchObject({builds:2,hits:1,invalidations:1}),await a.resolve(e,{...n,revision:2}),t(a.getStats()).toMatchObject({builds:3,hits:1,invalidations:2})}),a("applies a bound lifetime only to the addressed scheduled instance",async()=>{const e=$("bound-life");e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"},e.initializers[1].params.radius.binding={type:"vfxInput",inputId:"radius"},e.initializers.push({id:"velocity",type:"velocity",enabled:!0,params:{velocity:{type:h.Vector3,value:[0,0,0],binding:{type:"vfxInput",inputId:"velocity"}}}});const a=Q(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:1},{id:"radius",name:"Radius",type:"number",defaultValue:1},{id:"velocity",name:"Velocity",type:"vec3",defaultValue:[0,0,0]}];const i=await j.create({asset:a,maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),n=i.spawn(new o.Vector3),s=i.spawn(new o.Vector3);t(i.setInput(n,"life",4)).toBe(!0),t(i.runtime.emitters[0].life).toBe(4),t(i.runtime.emitters[1].life).toBe(2),t(i.setInput(n,"radius",3)).toBe(!0),t(i.runtime.emitters[0].positionRadius).toBe(3),t(i.setInput(n,"velocity",new o.Vector3(1,2,3))).toBe(!0),t(i.runtime.emitters[0].velocityMin.toArray()).toEqual([1,2,3]),t(i.setInput(s,"missing",5)).toBe(!1),i.dispose()}),a("schedules different templates in one arena and merges shared render classes",async()=>{const e=H(),a=await q.create({templates:[{asset:Q($("world-a",e)),maximumInstances:2},{asset:Q($("world-b",{...e})),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},templateCache:new z,renderClassCache:new R,particlePageSize:1}),i=a.spawn(0,new o.Vector3),n=a.spawn(1,new o.Vector3(1,0,0));a.flushActivePages(),t(a.templateCount).toBe(2),t(a.runtime.getStats()).toMatchObject({renderClassCount:1,activePageCount:4}),t(a.scheduler.getStats()).toMatchObject({activeInstances:2,arena:{usedPages:4}}),t(a.destroy(i)).toBe(!0),t(a.destroy(n)).toBe(!0),a.flushActivePages(),t(a.runtime.hasActiveParticles).toBe(!1),t(a.runtime.getStats()).toMatchObject({aliveParticles:0,activePageCount:0,simulatedCapacity:0}),a.dispose()}),a("routes per-instance lifecycle changes through a shared multi-template world",async()=>{const e=$("world-lifecycle");e.position=[2,0,0];const a=await q.create({templates:[{asset:Q(e),maximumInstances:1},{asset:Q($("world-neighbour")),maximumInstances:1}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1}),i=a.spawn(0,new o.Vector3(3,0,0));t(a.runtime.emitters[0].position.x).toBe(5),t(a.setPosition(i,new o.Vector3(7,0,0))).toBe(!0),t(a.runtime.emitters[0].position.x).toBe(9),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),t(a.play(i)).toBe(!0),t(a.setTimescale(i,.5)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(.5),t(a.stop(i,0)).toBe(!0),a.update(.001),t(a.has(i)).toBe(!1),t(a.setPosition(i,new o.Vector3)).toBe(!1),a.dispose()}),a("rejects a shared world before runtime allocation when its storage budget is exceeded",async()=>{const e=await q.create({templates:[{asset:Q($("world-over-budget")),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1,maximumStorageBytes:1});t(e).toBeNull()}),a("falls back to CPU for semantic mismatches",()=>{const e=$("soft-local",H({softness:1,space:"local",lockY:!0}));e.behaviours.push({id:"animation",type:"animation",enabled:!0,params:{}});const a=g(Q(e),[new o.Vector3]),i=w(a.compatibility);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues.map(e=>e.code)).toEqual(t.arrayContaining(["behaviour:animation"])),t(i.backend).toBe("cpu"),t(w(a.compatibility,"gpu").backend).toBe("gpu")}),a("intentionally selects CPU simulation for physics-raycast world collision",()=>{const e=$("physics-collision");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{method:{type:h.String,value:y.PhysicsRaycast}}});const a=Q(e),i=g(a,[new o.Vector3]);t(b(a)).toBe(!0),t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues).toContainEqual(t.objectContaining({severity:"requirement",code:"physics-raycast-collision"})),t(w(i.compatibility).backend).toBe("cpu"),t(w(i.compatibility,"gpu").backend).toBe("cpu")}),a("compiles vortex with pay-for-play previous-velocity state",()=>{const e=$("vortex");e.behaviours.push({id:"vortex",type:"vortex",enabled:!0,params:{axis:Z([0,5,0]),amount:N(1.25)}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].vortexAxis.toArray()).toEqual([0,5,0]),t(a.emitters[0].vortexAmount).toBe(1.25),t(V(16,!1,!1)).not.toContain("vortexVelocities");const i=V(16,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("behaviourVelocity -= optionalStates[0u + particleIndex].xyz")}),a("compiles the remaining initializer and motion behaviours",()=>{const e=$("remaining-motion");e.initializers.push({id:"mass",type:"mass",enabled:!0,params:{min:N(2)}}),e.behaviours.push({id:"drift",type:"randomDrift",enabled:!0,params:{drift:Z([1,2,3]),delay:N(.25),ease:{type:h.String,value:"OutQuad"}}},{id:"follow",type:"followParent",enabled:!0,params:{speed:N(4)}},{id:"move",type:"moveTo",enabled:!0,params:{target:Z([5,6,7]),ease:{type:h.String,value:"InQuad"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({mass:2,randomDriftDelay:.25,randomDriftEasing:2,followParentSpeed:4,hasFollowParent:!0,moveToEasing:1,hasMoveTo:!0}),t(a.emitters[0].randomDrift.toArray()).toEqual([1,2,3]),t(a.emitters[0].moveToTarget.toArray()).toEqual([5,6,7])}),a("compiles authored easing independently for visual behavior channels",()=>{const e=$("easing");e.behaviours.push({id:"scale",type:"scale",enabled:!0,params:{a:N(0),b:N(1),ease:{type:h.String,value:"OutBack"}}},{id:"color",type:"changeColor",enabled:!0,params:{a:{type:h.Color,value:"#ff0000"},b:{type:h.Color,value:"#0000ff"},ease:{type:h.String,value:"InCubic"}}},{id:"opacity",type:"changeOpacity",enabled:!0,params:{a:N(0),b:N(1),ease:{type:h.String,value:"BumpSharp"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].curveEasing.toArray()).toEqual([18,4,20,0]);const i=V(16);t(i).toContain("fn applyEasing"),t(i).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, colorProgress)"),t(i).toContain("mix(emitter.scales.xy, emitter.scales.zw, scaleProgress)")}),a("compiles disperse into optional shared particle state",()=>{const e=$("disperse");e.behaviours.push({id:"disperse",type:"disperse",enabled:!0,params:{distance:N(3),axis:{type:h.String,value:"XZ"},ease:{type:h.String,value:"InOutSine"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].disperseDistance).toBe(3),t(a.emitters[0].dispersePlane).toBe(2),t(a.emitters[0].disperseEasing).toBe(12),t(V(16)).not.toContain("disperseStates");const i=V(16,!1,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("optionalStates[0u + particleIndex].w = disperseProgress")}),a("consolidates every optional simulation feature into the eighth storage binding",()=>{const e=V(16,!0,!0,!0,!0,!1,!1,48,64),a=[...e.matchAll(/@group\(0\) @binding\((\d+)\) var<storage/g)].map(e=>Number(e[1]));t(a).toEqual([1,2,3,4,5,6,7,8]),t(e).toContain("optionalStates[48u + particleIndex]"),t(e).toContain("optionalStates[64u + particleIndex]"),t(e).not.toMatch(/@binding\((9|10|11)\)/)}),a("lays out optional state regions without overlap",()=>{t(x({capacity:16,particleTransforms:!0,vortex:!0,disperse:!0,trailHistoryEntries:20})).toEqual({vortexOffset:48,disperseOffset:64,collisionEventOffset:80,trailOffset:80,meshSamplerOffset:100,entries:100,bytes:1600})}),a("materializes decal outputs as shared unit-box mesh render classes",async()=>{const e=$("decal");e.output={type:"decal",space:"world",colorMap:"",alphaMap:"",color:"#ffffff",unlit:!0,intensity:1,blendingMode:"normal",bloom:!1};const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await f(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].meshVertices?.length).toBe(288),t(l.renderClasses[0].material).toBe(r)}),a("materializes generated shape outputs through the shared mesh path",async()=>{const e=$("shape");e.output={type:"shape",shape:"box",params:{},material:"shape-material",materialSource:"material",space:"world"};const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await f(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].meshVertices?.length).toBe(288)}),a("forwards the physical particle index to fragment-stage model-matrix graphs",()=>{const e=S(new i({color:s(n(1),1)})),a=T(e,!0);t(a).toContain("color: vec4<f32>, instanceIndex: u32"),t(a).toContain("gpuParticle.color, particleIndex)"),t(a).toContain("@interpolate(flat) instanceIndex: u32"),t(a).toContain("output.instanceIndex = particleIndex")}),a("compiles soft default sprites with a pay-for-play scene-depth requirement",async()=>{const e=$("soft-sprite",H({softness:2})),a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const i=(await f(a.renderClasses,{getTexture:async()=>new o.Texture})).renderClasses[0].material,n=S(i);t(Object.values(n.sceneTextures)).toContain("sceneDepth")}),a("disables depth writes only for transparent custom GPU sprite materials",async()=>{const e=$("graph-sprite",H({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"graph"}})),a=g(Q(e),[new o.Vector3]),r=new i({color:s(n(1),.5),transparent:!0}),l=await f(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].material).toBe(r),t(r.depthWrite).toBe(!1),t(S(r).renderState.depthWrite).toBe(!1);const c=new i({color:s(n(1),1)}),p=await f(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>c);t(p.renderClasses[0].material).toBe(c),t(c.depthWrite).toBe(!0),t(S(c).renderState.depthWrite).toBe(!0)}),a("supports local-space sprite render classes through the shared emitter transform table",async()=>{const e=$("local-sprite",H({space:"local"})),a=g(Q(e),[new o.Vector3(4,5,6)]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].localSpace).toBe(!0),t(a.emitters[0].position.toArray()).toEqual([0,0,0]);const i=await f(a.renderClasses,{getTexture:async()=>new o.Texture});t(i.renderClasses[0].localSpace).toBe(!0)}),a("falls back before rendering when an authored graph cannot be resolved",async()=>{const e=$("graph",H({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"missing-graph"}})),a=await C({asset:Q(e),instancePositions:[new o.Vector3],assets:{getTexture:async()=>new o.Texture}});t(a.backend).toBe("cpu"),t(a.runtime).toBeNull(),t(a.reasons[0]).toContain("GPU material compilation failed")}),a("compiles default flipbook, texture, UV, and particle semantics to WGSL",async()=>{const e=$("flipbook",H({flipbook:{enabled:!0,rows:4,columns:4,fps:12,mode:"loop"}})),a=g(Q(e),[new o.Vector3]),i=await f(a.renderClasses,{getTexture:async()=>new o.Texture}),n=S(i.renderClasses[0].material);t(n.fragmentMain).toContain("textureSample"),t(`${n.vertexMain}\n${n.fragmentMain}`).toContain("gpuParticle.positionAge.w"),t(n.varyings.length).toBeGreaterThan(0)}),a("supports component scale-over-life for sprites",()=>{const e=$("stretched-smoke");e.behaviours.push({id:"stretch",type:"scaleComponents",enabled:!0,params:{a:Z([.25,1,1]),b:Z([2,.5,1]),ease:{type:22,value:"Linear"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].meshScaleStart.toArray()).toEqual([.25,1,1]),t(a.emitters[0].meshScaleEnd.toArray()).toEqual([2,.5,1])}),a("compiles authored scale and rotation input bindings",()=>{const e=$("input-driven",X());e.initializers.push({id:"scale",type:"scale",enabled:!0,params:{scale:{...N(1),binding:{type:"vfxInput",inputId:"scale-input"}}}},{id:"rotation",type:"rotation",enabled:!0,params:{rotation:{...Z([0,-100,0]),binding:{type:"vfxInput",inputId:"rotation-input"}}}}),e.behaviours.push({id:"rotate",type:"rotate",enabled:!0,params:{rotation:{...Z([0,-30,0]),binding:{type:"vfxInput",inputId:"step-input"}}}});const a=Q(e);a.vfx.inputs=[{id:"scale-input",name:"Scale",type:"number",defaultValue:1},{id:"rotation-input",name:"Rotation",type:"vec3",defaultValue:[0,-100,0]},{id:"step-input",name:"Rotation step",type:"vec3",defaultValue:[0,-30,0]}];const i=g(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.inputBindings.map(e=>e.target)).toEqual(["initializer:scale:scale","initializer:rotation:rotation","behaviour:rotate:rotation"])})});/*
2
2
  * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
3
  * See the LICENSE.md file for details.
4
4
  */
@@ -1,4 +1,4 @@
1
- import{describe as e,expect as t,it as a,vi as s}from"vitest";import{Scene as i,Texture as r}from"three";import{Subject as n}from"rxjs";s.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&s.fn()),e[t]),set:(e,t,a)=>(e[t]=a,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),s.mock("../gameplay/actors/builtin/index.js",()=>({default:{}})),s.mock("../effects/vfx/vfx-actor.js",()=>({VfxActor:class{}})),s.mock("../effects/vfx/vfx-param.js",()=>({VisualEffect:class{}})),s.mock("../rendering.js",()=>({RenderingView:class{},setRenderingPaused:s.fn()}));import{SceneMaterializer as d}from"../scene/materializer.js";import{DetailTier as p,SerializedParamType as o}from"../scene/model.js";function m(e,t){const a=s.fn(async(e,t)=>({scene:{},animations:[]})),p=s.fn(async e=>new r),o=s.fn(async e=>({texture:new r,layerIndex:0})),m=s.fn(),f={getObjects:()=>e,onCreate:()=>{},onUpdate:()=>{},onRemove:()=>{}},l={onCreate:new n,onDelete:new n,onUpdate:new n,getAsset:s.fn(async e=>t.get(e)??null),getAssets:s.fn(async()=>Array.from(t.values()))};return{materializer:new d(new i,f,l,{getMesh:a,getTexture:p,getTextureArray:o},{renderer:{initTexture:m},onLoop:s.fn()},[],[],{create:async()=>null,initActor:async()=>{}}),getMesh:a,getTexture:p,getTextureArray:o,initTexture:m}}function f(e){return{name:e.id,...e}}function l(e,t){return{id:e,name:e,type:"mesh",fileKey:`${e}.glb`,fileFormat:"glb",mesh:t}}function c(e,t){return{id:e,name:e,type:"prefab",prefab:{objects:t}}}function u(e,t){return{id:e,name:e,type:"material",material:{type:"shader",side:0,params:{},shaderParams:{map:{type:o.Texture,value:t}}}}}function h(e){return{id:e,name:e,type:"texture",fileKey:`${e}.png`,fileFormat:"png"}}function y(e){return{color:"#ffffff",materialId:e}}e("SceneMaterializer asset prefetch",()=>{a("prefetches unique materialized asset meshes with the same options used by createFromAsset",async()=>{const e=l("mesh-a"),a=l("mesh-b"),s=l("mesh-c"),i=l("mesh-disabled"),r=l("mesh-high",{detailTier:p.high}),n=c("prefab-a",[f({id:"prefab-mesh-c",type:"asset_mesh",assetId:s.id}),f({id:"prefab-duplicate-mesh-a",type:"asset_mesh",assetId:e.id}),f({id:"prefab-cycle",type:"prefab",assetId:"prefab-a"})]),d=new Map([e,a,s,i,r,n].map(e=>[e.id,e])),o=[f({id:"root",type:"group",children:[f({id:"mesh-a-1",type:"asset_mesh",assetId:e.id}),f({id:"mesh-a-2",type:"asset_mesh",assetId:e.id}),f({id:"disabled-group",type:"group",enabled:!1,children:[f({id:"disabled-mesh",type:"asset_mesh",assetId:i.id})]}),f({id:"prefab-instance",type:"prefab",assetId:n.id}),f({id:"high-detail-mesh",type:"asset_mesh",assetId:r.id})]}),f({id:"mesh-b",type:"asset_mesh",assetId:a.id})],{materializer:u,getMesh:h}=m(o,d);u.detailTier=p.low,await u.preInit(),await u.prefetchAssets(),t(h).toHaveBeenCalledTimes(3),t(new Set(h.mock.calls.map(([e])=>e.id))).toEqual(new Set([e.id,a.id,s.id])),t(h.mock.calls.every(([,e])=>!0===e?.mergeGeomtries)).toBe(!0)}),a("initializes textures from nested groups and prefab assets",async()=>{const e=h("texture-root"),a=h("texture-group"),s=h("texture-prefab"),i=h("texture-skipped"),r=u("material-root",e.id),n=u("material-group",a.id),d=u("material-prefab",s.id),p=u("material-skipped",i.id),o=c("prefab-a",[f({id:"prefab-group",type:"group",children:[f({id:"prefab-shape",type:"shape_mesh",materialAssignments:[y(d.id)]})]}),f({id:"prefab-cycle",type:"prefab",assetId:"prefab-a"})]),l=new Map([e,a,s,i,r,n,d,p,o].map(e=>[e.id,e])),g=[f({id:"root-group",type:"group",children:[f({id:"nested-mesh",type:"asset_mesh",materialAssignments:[y(n.id)]}),f({id:"disabled-group",type:"group",enabled:!1,children:[f({id:"skipped-mesh",type:"asset_mesh",materialAssignments:[y(p.id)]})]}),f({id:"prefab-instance",type:"prefab",assetId:o.id})]}),f({id:"root-shape",type:"shape_mesh",materialAssignments:[y(r.id)]})],{materializer:b,getTexture:x,initTexture:w}=m(g,l);await b.preInit(),await b.initTextures(),t(x).toHaveBeenCalledTimes(3),t(new Set(x.mock.calls.map(([e])=>e.id))).toEqual(new Set([e.id,a.id,s.id])),t(w).toHaveBeenCalledTimes(3)})});/*
1
+ import{describe as e,expect as t,it as a,vi as s}from"vitest";import{Scene as i,Texture as r}from"three";import{Subject as n}from"rxjs";s.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&s.fn()),e[t]),set:(e,t,a)=>(e[t]=a,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),s.mock("../gameplay/actors/builtin/index.js",()=>({default:{}})),s.mock("../effects/vfx/vfx-actor.js",()=>({VfxActor:class{}})),s.mock("../effects/vfx/vfx-param.js",()=>({VisualEffect:class{}})),s.mock("../rendering.js",()=>({RenderingView:class{},setRenderingPaused:s.fn()}));import{SceneMaterializer as d}from"../scene/materializer.js";import{DetailTier as p,SerializedParamType as o}from"../scene/model.js";function m(e,t){const a=s.fn(async(e,t)=>({scene:{},animations:[]})),p=s.fn(async e=>new r),o=s.fn(async e=>({texture:new r,layerIndex:0})),m=s.fn(),f={getObjects:()=>e,onCreate:()=>{},onUpdate:()=>{},onRemove:()=>{}},l={onCreate:new n,onDelete:new n,onUpdate:new n,getAsset:s.fn(async e=>t.get(e)??null),getAssets:s.fn(async()=>Array.from(t.values()))};return{materializer:new d(new i,f,l,{getMesh:a,getTexture:p,getTextureArray:o},{renderer:{initTexture:m},initTexture:m,onLoop:s.fn()},[],[],{create:async()=>null,initActor:async()=>{}}),getMesh:a,getTexture:p,getTextureArray:o,initTexture:m}}function f(e){return{name:e.id,...e}}function l(e,t){return{id:e,name:e,type:"mesh",fileKey:`${e}.glb`,fileFormat:"glb",mesh:t}}function c(e,t){return{id:e,name:e,type:"prefab",prefab:{objects:t}}}function u(e,t){return{id:e,name:e,type:"material",material:{type:"shader",side:0,params:{},shaderParams:{map:{type:o.Texture,value:t}}}}}function h(e){return{id:e,name:e,type:"texture",fileKey:`${e}.png`,fileFormat:"png"}}function y(e){return{color:"#ffffff",materialId:e}}e("SceneMaterializer asset prefetch",()=>{a("prefetches unique materialized asset meshes with the same options used by createFromAsset",async()=>{const e=l("mesh-a"),a=l("mesh-b"),s=l("mesh-c"),i=l("mesh-disabled"),r=l("mesh-high",{detailTier:p.high}),n=c("prefab-a",[f({id:"prefab-mesh-c",type:"asset_mesh",assetId:s.id}),f({id:"prefab-duplicate-mesh-a",type:"asset_mesh",assetId:e.id}),f({id:"prefab-cycle",type:"prefab",assetId:"prefab-a"})]),d=new Map([e,a,s,i,r,n].map(e=>[e.id,e])),o=[f({id:"root",type:"group",children:[f({id:"mesh-a-1",type:"asset_mesh",assetId:e.id}),f({id:"mesh-a-2",type:"asset_mesh",assetId:e.id}),f({id:"disabled-group",type:"group",enabled:!1,children:[f({id:"disabled-mesh",type:"asset_mesh",assetId:i.id})]}),f({id:"prefab-instance",type:"prefab",assetId:n.id}),f({id:"high-detail-mesh",type:"asset_mesh",assetId:r.id})]}),f({id:"mesh-b",type:"asset_mesh",assetId:a.id})],{materializer:u,getMesh:h}=m(o,d);u.detailTier=p.low,await u.preInit(),await u.prefetchAssets(),t(h).toHaveBeenCalledTimes(3),t(new Set(h.mock.calls.map(([e])=>e.id))).toEqual(new Set([e.id,a.id,s.id])),t(h.mock.calls.every(([,e])=>!0===e?.mergeGeomtries)).toBe(!0)}),a("initializes textures from nested groups and prefab assets",async()=>{const e=h("texture-root"),a=h("texture-group"),s=h("texture-prefab"),i=h("texture-skipped"),r=u("material-root",e.id),n=u("material-group",a.id),d=u("material-prefab",s.id),p=u("material-skipped",i.id),o=c("prefab-a",[f({id:"prefab-group",type:"group",children:[f({id:"prefab-shape",type:"shape_mesh",materialAssignments:[y(d.id)]})]}),f({id:"prefab-cycle",type:"prefab",assetId:"prefab-a"})]),l=new Map([e,a,s,i,r,n,d,p,o].map(e=>[e.id,e])),g=[f({id:"root-group",type:"group",children:[f({id:"nested-mesh",type:"asset_mesh",materialAssignments:[y(n.id)]}),f({id:"disabled-group",type:"group",enabled:!1,children:[f({id:"skipped-mesh",type:"asset_mesh",materialAssignments:[y(p.id)]})]}),f({id:"prefab-instance",type:"prefab",assetId:o.id})]}),f({id:"root-shape",type:"shape_mesh",materialAssignments:[y(r.id)]})],{materializer:b,getTexture:x,initTexture:w}=m(g,l);await b.preInit(),await b.initTextures(),t(x).toHaveBeenCalledTimes(3),t(new Set(x.mock.calls.map(([e])=>e.id))).toEqual(new Set([e.id,a.id,s.id])),t(w).toHaveBeenCalledTimes(3)})});/*
2
2
  * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
3
  * See the LICENSE.md file for details.
4
4
  */
@@ -1,4 +1,4 @@
1
- import{__decorate as e,__metadata as a}from"tslib";import{describe as t,expect as r,it as n,vi as o}from"vitest";import{Scene as i}from"three";import{Subject as s}from"rxjs";o.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,a)=>(a in e||(e[a]=("string"!=typeof a||!a.startsWith("is"))&&o.fn()),e[a]),set:(e,a,t)=>(e[a]=t,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),o.mock("../gameplay/actors/builtin/index.js",()=>({default:{}})),o.mock("../effects/vfx/vfx-actor.js",()=>({VfxActor:class{}})),o.mock("../effects/vfx/vfx-param.js",()=>({VisualEffect:class{}})),o.mock("../rendering.js",()=>({RenderingView:class{},setRenderingPaused:o.fn()}));import{BaseActor as c}from"../gameplay/actors/actor.js";import{ActorComponent as p}from"../gameplay/actors/component.js";import{Parameter as m}from"../shader/parameter.js";import{SceneMaterializer as l}from"../scene/materializer.js";import{SerializedParamType as u}from"../scene/model.js";class f extends p{constructor(){super(...arguments),this.animationPreset="mixamo"}}e([m(),a("design:type",Object)],f.prototype,"animationPreset",void 0);class d extends c{constructor(){super(...arguments),this.animation=new f,this.animationPreset="mixamo"}}e([m(),a("design:type",Object)],d.prototype,"animationPreset",void 0);class v extends c{constructor(){super(...arguments),this.value="before"}applySceneParameters(e,a){return!(1!==a.size||!a.has("value"))&&(this.value=e.value,!0)}}e([m(),a("design:type",Object)],v.prototype,"value",void 0);class y extends p{constructor(){super(...arguments),this.value="before"}applySceneParameters(e,a){return!(1!==a.size||!a.has("value"))&&(this.value=e.value,!0)}}e([m(),a("design:type",Object)],y.prototype,"value",void 0);class b extends c{constructor(){super(...arguments),this.component=new y}}class P extends c{}async function g(e){return(await w(e)).actor}async function w(e){const a=x("prefab-asset",[A({id:"prefab-actor",type:"actor",actor:{type:"PrefabParamActor",params:{animationPreset:{type:u.String,value:e.prefabActorValue}},innerParams:null==e.prefabComponentValue?[]:[{path:["animation"],params:{animationPreset:{type:u.String,value:e.prefabComponentValue}}}],components:[]}})],"prefab-actor"),t=A({id:"prefab-instance",type:"prefab",assetId:a.id,prefab:{mainActorId:"prefab-actor",params:null==e.instanceParam?{}:{animationPreset:e.instanceParam},innerParams:null==e.instanceInnerParam?[]:[{path:["animation"],params:{animationPreset:e.instanceInnerParam}}]}}),{materializer:r}=h([t],new Map([[a.id,a]]));return await r.materialize(t),await r.initActorsPostInit(),{actor:r.materializedActors.get("prefab-instance/prefab-actor"),materializer:r}}function h(e,a,t=!1){const r={getObjects:()=>e,onCreate:()=>{},onUpdate:()=>{},onRemove:()=>{}},n={supportsLivePrefabParameters:t,onCreate:new s,onDelete:new s,onUpdate:new s,getAsset:o.fn(async e=>a.get(e)??null),getAssets:o.fn(async()=>Array.from(a.values()))},c={create:o.fn(async(e,a)=>{const t=new e;return null!=a&&t.position.copy(a),t}),initActor:o.fn(async e=>{e.__isInitialised=!0})};return{materializer:new l(new i,r,n,{},{renderer:{shadowMap:{needsUpdate:!1}}},[],[{name:"PrefabParamActor",type:d},{name:"LiveParamActor",type:v},{name:"SceneAttachedComponentActor",type:P}],c,[{name:"LiveParamComponent",type:y}]),assetsProvider:n}}function A(e){return{name:e.id,position:[0,0,0],rotation:[0,0,0,"XYZ"],scale:[1,1,1],...e}}function x(e,a,t){return{id:e,name:e,type:"prefab",prefab:{objects:a,mainActorId:t}}}t("prefab instance actor params",()=>{n("does not let generated prefab instance defaults override the prefab actor value",async()=>{const e=await g({prefabActorValue:"basic",instanceParam:{type:u.String,value:"mixamo"}});r(e.animationPreset).toBe("basic")}),n("applies prefab instance params when the override is explicit",async()=>{const e=await g({prefabActorValue:"basic",instanceParam:{type:u.String,value:"mixamo",override:!0}});r(e.animationPreset).toBe("mixamo")}),n("does not let generated prefab instance component defaults override prefab component values",async()=>{const e=await g({prefabActorValue:"basic",prefabComponentValue:"basic",instanceInnerParam:{type:u.String,value:"mixamo"}});r(e.animation.animationPreset).toBe("basic")}),n("applies prefab instance component params when the override is explicit",async()=>{const e=await g({prefabActorValue:"basic",prefabComponentValue:"basic",instanceInnerParam:{type:u.String,value:"mixamo",override:!0}});r(e.animation.animationPreset).toBe("mixamo")}),n("resolves direct scene actors and exposed prefab actors by scene object id",async()=>{const{actor:e,materializer:a}=await w({prefabActorValue:"basic"}),t=new d;a.materializedActors.set("direct-actor",t),r(a.resolveSceneActor("direct-actor")).toBe(t),r(a.resolveSceneActor("prefab-instance")).toBe(e)}),n("applies supported scene parameter changes without recreating the actor",async()=>{const e=A({id:"live-actor",type:"actor",actor:{type:"LiveParamActor",params:{value:{type:u.String,value:"after"}},innerParams:[],components:[]}}),a={...e.actor,params:{value:{type:u.String,value:"before"}}},{materializer:t}=h([e],new Map),n=new v,o=await t.tryApplyLiveActorParameterUpdate(n,e,JSON.stringify(a));r(o).toBe(!0),r(n.value).toBe("after")}),n("applies supported component parameter changes without recreating the actor",async()=>{const e=A({id:"live-component",type:"actor",actor:{type:"LiveComponentParamActor",params:{},innerParams:[{path:["component"],params:{value:{type:u.String,value:"after"}}}],components:[]}}),a={...e.actor,innerParams:[{path:["component"],params:{value:{type:u.String,value:"before"}}}]},{materializer:t}=h([e],new Map),n=new b,o=await t.tryApplyLiveActorParameterUpdate(n,e,JSON.stringify(a));r(o).toBe(!0),r(n.component.value).toBe("after")}),n("applies supported scene-attached component parameter changes without recreating the actor",async()=>{const e=A({id:"scene-attached-component",type:"actor",actor:{type:"SceneAttachedComponentActor",params:{},innerParams:[],components:[{type:"LiveParamComponent",params:{value:{type:u.String,value:"before"}},innerParams:[]}]}}),{materializer:a}=h([e],new Map);await a.materialize(e),await a.initActorsPostInit();const t=a.materializedActors.get(e.id),n=t.attachedComponents[0],o=JSON.stringify(e.actor);e.actor.components[0].params.value={type:u.String,value:"after"};const i=await a.tryApplyLiveActorParameterUpdate(t,e,o);r(i).toBe(!0),r(n.value).toBe("after"),r(t.attachedComponents[0]).toBe(n)}),n("applies saved prefab parameters to a runtime-spawned prefab instance",async()=>{const e=x("live-prefab",[A({id:"live-actor",type:"actor",actor:{type:"LiveParamActor",params:{value:{type:u.String,value:"before"}},components:[]}})],"live-actor"),a=structuredClone(e);a.prefab.objects[0].actor.params.value={type:u.String,value:"after"};const{materializer:t,assetsProvider:n}=h([],new Map([[e.id,e]]),!0),i=await t.createFromPrefabAsset(e,{sceneObjectChain:["runtime-instance"],runtimeSpawn:!0},void 0,void 0,!1),s=i.mainActor;n.onUpdate.next(a),await o.waitFor(()=>r(s.value).toBe("after")),r(i.mainActor).toBe(s)}),n("applies supported component changes when the actor rejects an independent change",async()=>{const e=x("partially-live-prefab",[A({id:"component-actor",type:"actor",actor:{type:"SceneAttachedComponentActor",params:{unsupported:{type:u.String,value:"before"}},components:[{type:"LiveParamComponent",params:{value:{type:u.String,value:"before"}}}]}})],"component-actor"),a=structuredClone(e);a.prefab.objects[0].actor.params.unsupported.value="after",a.prefab.objects[0].actor.components[0].params.value.value="after";const{materializer:t,assetsProvider:n}=h([],new Map([[e.id,e]]),!0),i=await t.createFromPrefabAsset(e,{sceneObjectChain:["runtime-component-instance"],runtimeSpawn:!0},void 0,void 0,!1),s=i.mainActor,c=s.attachedComponents[0],p=o.spyOn(console,"warn").mockImplementation(()=>{});n.onUpdate.next(a),await o.waitFor(()=>r(c.value).toBe("after")),r(i.mainActor).toBe(s),r(p).toHaveBeenCalledWith(r.stringContaining("were not applied")),p.mockRestore()})});/*
1
+ import{__decorate as e,__metadata as t}from"tslib";import{describe as a,expect as r,it as n,vi as i}from"vitest";import{Scene as o}from"three";import{Subject as s}from"rxjs";i.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&i.fn()),e[t]),set:(e,t,a)=>(e[t]=a,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),i.mock("../gameplay/actors/builtin/index.js",()=>({default:{}})),i.mock("../effects/vfx/vfx-actor.js",()=>({VfxActor:class{}})),i.mock("../effects/vfx/vfx-param.js",()=>({VisualEffect:class{}})),i.mock("../rendering.js",()=>({RenderingView:class{},setRenderingPaused:i.fn()}));import{BaseActor as c}from"../gameplay/actors/actor.js";import{ActorComponent as p}from"../gameplay/actors/component.js";import{Parameter as m}from"../shader/parameter.js";import{SceneMaterializer as l}from"../scene/materializer.js";import{SerializedParamType as d}from"../scene/model.js";import{ActorPersistence as f}from"../gameplay/services/actor-persistence.js";class u extends p{constructor(){super(...arguments),this.animationPreset="mixamo"}}e([m(),t("design:type",Object)],u.prototype,"animationPreset",void 0);class v extends c{constructor(){super(...arguments),this.target=null,this.targets=[],this.animation=new u,this.animationPreset="mixamo"}get persistenceEnabled(){return!0}}e([m(),t("design:type",c)],v.prototype,"target",void 0),e([m({type:c,array:!0}),t("design:type",Array)],v.prototype,"targets",void 0),e([m(),t("design:type",Object)],v.prototype,"animationPreset",void 0);class y extends c{constructor(){super(...arguments),this.value="before"}applySceneParameters(e,t){return!(1!==t.size||!t.has("value"))&&(this.value=e.value,!0)}}e([m(),t("design:type",Object)],y.prototype,"value",void 0);class b extends p{constructor(){super(...arguments),this.value="before"}applySceneParameters(e,t){return!(1!==t.size||!t.has("value"))&&(this.value=e.value,!0)}}e([m(),t("design:type",Object)],b.prototype,"value",void 0);class w extends c{constructor(){super(...arguments),this.component=new b}}class P extends c{}async function g(e){return(await A(e)).actor}async function A(e){const t=B("prefab-asset",[z({id:"prefab-actor",type:"actor",actor:{type:"PrefabParamActor",params:{animationPreset:{type:d.String,value:e.prefabActorValue}},innerParams:null==e.prefabComponentValue?[]:[{path:["animation"],params:{animationPreset:{type:d.String,value:e.prefabComponentValue}}}],components:[]}})],"prefab-actor"),a=z({id:"prefab-instance",type:"prefab",assetId:t.id,prefab:{mainActorId:"prefab-actor",params:null==e.instanceParam?{}:{animationPreset:e.instanceParam},innerParams:null==e.instanceInnerParam?[]:[{path:["animation"],params:{animationPreset:e.instanceInnerParam}}]}}),{materializer:r}=h([a],new Map([[t.id,t]]));return await r.materialize(a),await r.initActorsPostInit(),{actor:r.materializedActors.get("prefab-instance/prefab-actor"),materializer:r}}function h(e,t,a=!1){const r={getObjects:()=>e,onCreate:()=>{},onUpdate:()=>{},onRemove:()=>{}},n={supportsLivePrefabParameters:a,onCreate:new s,onDelete:new s,onUpdate:new s,getAsset:i.fn(async e=>t.get(e)??null),getAssets:i.fn(async()=>Array.from(t.values()))},c={create:i.fn(async(e,t)=>{const a=new e;return null!=t&&a.position.copy(t),a}),initActor:i.fn(async e=>{e.__isInitialised=!0})},p={invalidateShadows:i.fn(),renderer:{shadowMap:{needsUpdate:!1}}};return{materializer:new l(new o,r,n,{},p,[],[{name:"PrefabParamActor",type:v},{name:"LiveParamActor",type:y},{name:"SceneAttachedComponentActor",type:P}],c,[{name:"LiveParamComponent",type:b}]),assetsProvider:n,actorProvider:c}}function z(e){return{name:e.id,position:[0,0,0],rotation:[0,0,0,"XYZ"],scale:[1,1,1],...e}}function B(e,t,a){return{id:e,name:e,type:"prefab",prefab:{objects:t,mainActorId:a}}}a("prefab instance actor params",()=>{n("does not let generated prefab instance defaults override the prefab actor value",async()=>{const e=await g({prefabActorValue:"basic",instanceParam:{type:d.String,value:"mixamo"}});r(e.animationPreset).toBe("basic")}),n("applies prefab instance params when the override is explicit",async()=>{const e=await g({prefabActorValue:"basic",instanceParam:{type:d.String,value:"mixamo",override:!0}});r(e.animationPreset).toBe("mixamo")}),n("does not let generated prefab instance component defaults override prefab component values",async()=>{const e=await g({prefabActorValue:"basic",prefabComponentValue:"basic",instanceInnerParam:{type:d.String,value:"mixamo"}});r(e.animation.animationPreset).toBe("basic")}),n("applies prefab instance component params when the override is explicit",async()=>{const e=await g({prefabActorValue:"basic",prefabComponentValue:"basic",instanceInnerParam:{type:d.String,value:"mixamo",override:!0}});r(e.animation.animationPreset).toBe("mixamo")}),n("resolves direct scene actors and exposed prefab actors by scene object id",async()=>{const{actor:e,materializer:t}=await A({prefabActorValue:"basic"}),a=new v;t.materializedActors.set("direct-actor",a),r(t.resolveSceneActor("direct-actor")).toBe(a),r(t.resolveSceneActor("prefab-instance")).toBe(e)}),n("applies supported scene parameter changes without recreating the actor",async()=>{const e=z({id:"live-actor",type:"actor",actor:{type:"LiveParamActor",params:{value:{type:d.String,value:"after"}},innerParams:[],components:[]}}),t={...e.actor,params:{value:{type:d.String,value:"before"}}},{materializer:a}=h([e],new Map),n=new y,i=await a.tryApplyLiveActorParameterUpdate(n,e,JSON.stringify(t));r(i).toBe(!0),r(n.value).toBe("after")}),n("applies supported component parameter changes without recreating the actor",async()=>{const e=z({id:"live-component",type:"actor",actor:{type:"LiveComponentParamActor",params:{},innerParams:[{path:["component"],params:{value:{type:d.String,value:"after"}}}],components:[]}}),t={...e.actor,innerParams:[{path:["component"],params:{value:{type:d.String,value:"before"}}}]},{materializer:a}=h([e],new Map),n=new w,i=await a.tryApplyLiveActorParameterUpdate(n,e,JSON.stringify(t));r(i).toBe(!0),r(n.component.value).toBe("after")}),n("applies supported scene-attached component parameter changes without recreating the actor",async()=>{const e=z({id:"scene-attached-component",type:"actor",actor:{type:"SceneAttachedComponentActor",params:{},innerParams:[],components:[{type:"LiveParamComponent",params:{value:{type:d.String,value:"before"}},innerParams:[]}]}}),{materializer:t}=h([e],new Map);await t.materialize(e),await t.initActorsPostInit();const a=t.materializedActors.get(e.id),n=a.attachedComponents[0],i=JSON.stringify(e.actor);e.actor.components[0].params.value={type:d.String,value:"after"};const o=await t.tryApplyLiveActorParameterUpdate(a,e,i);r(o).toBe(!0),r(n.value).toBe("after"),r(a.attachedComponents[0]).toBe(n)}),n("applies saved prefab parameters to a runtime-spawned prefab instance",async()=>{const e=B("live-prefab",[z({id:"live-actor",type:"actor",actor:{type:"LiveParamActor",params:{value:{type:d.String,value:"before"}},components:[]}})],"live-actor"),t=structuredClone(e);t.prefab.objects[0].actor.params.value={type:d.String,value:"after"};const{materializer:a,assetsProvider:n}=h([],new Map([[e.id,e]]),!0),o=await a.createFromPrefabAsset(e,{sceneObjectChain:["runtime-instance"],runtimeSpawn:!0},void 0,void 0,!1),s=o.mainActor;n.onUpdate.next(t),await i.waitFor(()=>r(s.value).toBe("after")),r(o.mainActor).toBe(s)}),n("applies supported component changes when the actor rejects an independent change",async()=>{const e=B("partially-live-prefab",[z({id:"component-actor",type:"actor",actor:{type:"SceneAttachedComponentActor",params:{unsupported:{type:d.String,value:"before"}},components:[{type:"LiveParamComponent",params:{value:{type:d.String,value:"before"}}}]}})],"component-actor"),t=structuredClone(e);t.prefab.objects[0].actor.params.unsupported.value="after",t.prefab.objects[0].actor.components[0].params.value.value="after";const{materializer:a,assetsProvider:n}=h([],new Map([[e.id,e]]),!0),o=await a.createFromPrefabAsset(e,{sceneObjectChain:["runtime-component-instance"],runtimeSpawn:!0},void 0,void 0,!1),s=o.mainActor,c=s.attachedComponents[0],p=i.spyOn(console,"warn").mockImplementation(()=>{});n.onUpdate.next(t),await i.waitFor(()=>r(c.value).toBe("after")),r(o.mainActor).toBe(s),r(p).toHaveBeenCalledWith(r.stringContaining("were not applied")),p.mockRestore()})}),a("authored persistence materialization",()=>{const e=()=>z({id:"enemy",type:"actor",actor:{type:"PrefabParamActor"}}),t=(e,t)=>z({id:e,type:"prefab",assetId:t}),a=()=>{const e=new f;return e.configure({}),e};n("skips removed prefab contents before constructors and preserves other instances",async()=>{const n=B("enemy-prefab",[e()],"enemy"),i=t("first",n.id),o=t("second",n.id),s=new Map([[n.id,n]]),c=h([i,o],s),p=a();await c.materializer.preparePersistence(p,"scene-id",[i,o]),await c.materializer.materialize(i),await c.materializer.materialize(o),await c.materializer.initActorsPostInit(),c.materializer.registerPersistentActors(),p.markRemoved(c.materializer.resolveSceneActor("first"));const m=p.snapshot(),l=new f;l.configure({data:m});const d=h([i,o],s),u=await d.materializer.preparePersistence(l,"scene-id",[i,o]);r(u.map(e=>e.id)).toEqual(["second"]),r(await d.materializer.materialize(i)).toBeUndefined(),r(d.actorProvider.create).not.toHaveBeenCalled(),await d.materializer.materialize(o),r(d.actorProvider.create).toHaveBeenCalledTimes(1),r(d.materializer.resolveSceneActor("first")).toBeUndefined(),r(l.isRemoved({sceneId:"scene-id",path:"first"})).toBe(!0)}),n("filters an actor subtree before static instancing can visit its children",async()=>{const t=e();t.children=[z({id:"collider",type:"shape_mesh"})];const a=new f;a.configure({data:{version:1,scenes:{scene:{enemy:{removed:!0}}},dynamic:{}}});const{materializer:n,actorProvider:i}=h([t],new Map);r(await n.preparePersistence(a,"scene",[t])).toEqual([]),r(await n.materialize(t.children[0])).toBeUndefined(),r(i.create).not.toHaveBeenCalled()}),n("keeps nested prefab identities distinct and removes the outer exposed placement",async()=>{const n=B("inner",[e()],"enemy"),i=B("outer",[t("nested",n.id)],"nested"),o=[t("one",i.id),t("two",i.id)],{materializer:s}=h(o,new Map([[n.id,n],[i.id,i]])),c=a();await s.preparePersistence(c,"scene",o);for(const e of o)await s.materialize(e);await s.initActorsPostInit(),s.registerPersistentActors();const p=s.resolveSceneActor("one"),m=s.resolveSceneActor("two");r(c.getIdentity(p)).toEqual({sceneId:"scene",path:"one/nested/enemy"}),r(c.getIdentity(m)).toEqual({sceneId:"scene",path:"two/nested/enemy"}),c.markRemoved(p),r(c.snapshot().scenes.scene.one).toEqual({removed:!0}),r(c.isRemoved(c.getIdentity(m))).toBe(!1)}),n("does not replace a removed exposed actor with a remaining prefab sibling",async()=>{const a=z({...e(),id:"sibling"}),n=B("prefab",[e(),a],"enemy"),i=t("placed",n.id),o=z({id:"reference",type:"actor",actor:{type:"PrefabParamActor",params:{target:{type:d.BaseActor,value:{type:"prefab",id:"placed"}}}}}),s=new f;s.configure({data:{version:1,scenes:{scene:{"placed/enemy":{removed:!0}}},dynamic:{}}});const{materializer:c}=h([i,o],new Map([[n.id,n]]));await c.preparePersistence(s,"scene",[i,o]),await c.materialize(i),await c.materialize(o),await c.initActorsPostInit(),r(c.materializedActors.size).toBe(2),r(c.resolveSceneActor("placed")).toBeUndefined(),r(c.resolveSceneActor("reference").target).toBeNull()}),n("binds circular actor references and resolves direct removed references to null",async()=>{const e=(e,t)=>z({id:e,type:"actor",actor:{type:"PrefabParamActor",params:{target:{type:d.BaseActor,value:{type:"actor",id:t}},targets:{type:d.Array,element:d.BaseActor,value:[{type:"actor",id:t}]}}}}),t=[e("a","b"),e("b","a")],{materializer:n}=h(t,new Map),i=a();await n.preparePersistence(i,"scene",t);for(const e of t)await n.materialize(e);await n.initActorsPostInit();const o=n.resolveSceneActor("a"),s=n.resolveSceneActor("b");r(o.target).toBe(s),r(s.target).toBe(o),r(o.targets).toEqual([s]),n.registerPersistentActors(),i.markRemoved(s);const c=new f;c.configure({data:i.snapshot()});const p=h(t,new Map).materializer;await p.preparePersistence(c,"scene",t);for(const e of t)await p.materialize(e);await p.initActorsPostInit(),r(p.resolveSceneActor("a").target).toBeNull(),r(p.resolveSceneActor("a").targets).toEqual([null])}),n("reuses retained authored actors without another initialization or restore",async()=>{const t=e(),n=a(),i=h([t],new Map);await i.materializer.preparePersistence(n,"scene",[t]),await i.materializer.materialize(t),await i.materializer.initActorsPostInit();const[o]=i.materializer.registerPersistentActors(),s=h([t],new Map);await s.materializer.preparePersistence(n,"scene",[t]),await s.materializer.materialize(t),await s.materializer.initActorsPostInit(),r(s.actorProvider.create).not.toHaveBeenCalled(),r(s.actorProvider.initActor).not.toHaveBeenCalled(),r(s.materializer.resolveSceneActor("enemy")).toBe(o),r(s.materializer.registerPersistentActors()).toEqual([])}),n("does not apply a scene-local tombstone to an identically named prefab-local ID",async()=>{const a=B("prefab",[e()],"enemy"),n=t("placed",a.id),i=new f;i.configure({data:{version:1,scenes:{scene:{enemy:{removed:!0}}},dynamic:{}}});const{materializer:o}=h([e(),n],new Map([[a.id,a]])),s=await o.preparePersistence(i,"scene",[e(),n]);for(const e of s)await o.materialize(e);r(o.materializedActors.has("placed/enemy")).toBe(!0),r(o.materializedActors.has("enemy")).toBe(!1)})});/*
2
2
  * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
3
  * See the LICENSE.md file for details.
4
4
  */
@@ -1,4 +1,4 @@
1
- import{__decorate as t}from"tslib";import"reflect-metadata";import{expect as e,test as n,vi as o}from"vitest";o.mock("../gameplay/services/render.js",()=>({ViewController:class{}})),o.mock("../gameplay/services/physics/physics-system.js",()=>({PhysicsSystem:class{}})),o.mock("../rendering.js",()=>({RenderingView:class{}}));import{Scene as r}from"three";import{Subject as i}from"rxjs";import{Container as s}from"typedi";import{Actor as a,BaseActor as c}from"../gameplay/actors/actor.js";import{ActorComponent as d,Component as l}from"../gameplay/actors/component.js";import{ActorFactory as m}from"../gameplay/actors/factory.js";import{World as y}from"../gameplay/services/world.js";function u(t){const e=Object.create(y.prototype);return e.actorFactory=t,e.physics={removeActor(){}},e.actors=[],e.actorLifetimes=new Map,e.actorPrefabInstances=new Map,e.prefabInstances=new Set,e.sceneEventGraphs=new Map,e.sceneEventGraphSubscriptions=[],e.actorAdded=new i,e.actorRemoved=new i,e.scene=new r,e}n("world begin and end play skip runtime-only components in editor",async()=>{const t=new m(s.of("world-lifecycle-editor"),{inEditor:!0}),n=await t.create(p),o=u(t);o.addActor(n),o.removeActor(n),e(n.beginCount).toBe(1),e(n.endCount).toBe(1),e(n.runtimeOnly.beginCount).toBe(0),e(n.runtimeOnly.endCount).toBe(0),e(n.runtimeOnly.actor).toBeUndefined(),e(n.inEditor.beginCount).toBe(1),e(n.inEditor.endCount).toBe(1),e(n.inEditor.actor).toBe(n),e(n.editorOnly.beginCount).toBe(1),e(n.editorOnly.endCount).toBe(1),e(n.editorOnly.actor).toBe(n)}),n("world begin and end play skip editor-only components at runtime",async()=>{const t=new m(s.of("world-lifecycle-runtime"),{inEditor:!1}),n=await t.create(p),o=u(t);o.addActor(n),o.removeActor(n),e(n.beginCount).toBe(1),e(n.endCount).toBe(1),e(n.runtimeOnly.beginCount).toBe(1),e(n.runtimeOnly.endCount).toBe(1),e(n.runtimeOnly.actor).toBe(n),e(n.inEditor.beginCount).toBe(1),e(n.inEditor.endCount).toBe(1),e(n.inEditor.actor).toBe(n),e(n.editorOnly.beginCount).toBe(0),e(n.editorOnly.endCount).toBe(0),e(n.editorOnly.actor).toBeUndefined()});let p=class extends c{constructor(){super(...arguments),this.beginCount=0,this.endCount=0,this.runtimeOnly=this.attach(g),this.inEditor=this.attach(h),this.editorOnly=this.attach(B)}onBeginPlay(){this.beginCount++}onEndPlay(){this.endCount++}};p=t([a()],p);class C extends d{constructor(){super(...arguments),this.beginCount=0,this.endCount=0}onBeginPlay(){this.beginCount++}onEndPlay(){this.endCount++}}let g=class extends C{};g=t([l()],g);let h=class extends C{};h=t([l({inEditor:!0})],h);let B=class extends C{};B=t([l({inEditor:!0,editorOnly:!0})],B);/*
1
+ import{__decorate as e}from"tslib";import"reflect-metadata";import{expect as t,test as n,vi as o}from"vitest";o.hoisted(()=>{const e=new Proxy({},{get:(e,t)=>e[t]??(e[t]=o.fn())});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e})}),o.mock("../gameplay/services/render.js",()=>({ViewController:class{}})),o.mock("../gameplay/services/physics/physics-system.js",()=>({PhysicsSystem:class{}})),o.mock("../rendering.js",()=>({RenderingView:class{}}));import{Scene as r}from"three";import{Subject as i}from"rxjs";import{Container as s}from"typedi";import{Actor as a,BaseActor as c}from"../gameplay/actors/actor.js";import{ActorComponent as d,Component as l}from"../gameplay/actors/component.js";import{ActorFactory as p}from"../gameplay/actors/factory.js";import{World as m}from"../gameplay/services/world.js";import{ActorPersistence as y}from"../gameplay/services/actor-persistence.js";function u(e){const t=Object.create(m.prototype);return t.actorFactory=e,t.physics={removeActor(){}},t.actors=[],t.actorLifetimes=new Map,t.actorPrefabInstances=new Map,t.prefabInstances=new Set,t.sceneEventGraphs=new Map,t.sceneEventGraphSubscriptions=[],t.actorAdded=new i,t.actorRemoved=new i,t.scene=new r,t}n("persistent batch is visible for lookup before restore and begin-play",async()=>{const e=new p(s.of("persistent-world-lifecycle"),{inEditor:!1}),n=u(e),o=new y;o.configure({}),n.persistence=o;const r=await e.create(h),i=await e.create(h);Object.defineProperty(r,"persistenceEnabled",{value:!0}),Object.defineProperty(i,"persistenceEnabled",{value:!0}),o.register(r,{sceneId:"scene",path:"first"}),o.register(i,{sceneId:"scene",path:"second"}),n.addActor(r,void 0,void 0,void 0,!0),n.addActor(i,void 0,void 0,void 0,!0),Object.assign(r,{onPersistenceRestored(){t(n.actors).toEqual([r,i]),t(r.beginCount).toBe(0),t(i.beginCount).toBe(0)}}),await o.restore([r,i]),n.beginActorPlay(r),n.beginActorPlay(i),t(r.beginCount).toBe(1),t(i.beginCount).toBe(1),n.removeActor(r),t(o.isRemoved({sceneId:"scene",path:"first"})).toBe(!1)}),n("explicit dynamic spawns restore before begin-play and reject removed IDs before construction",async()=>{const n=new p(s.of("persistent-dynamic-spawn"),{inEditor:!1}),r=u(n),i=new y;i.configure({data:{version:1,scenes:{},dynamic:{companion:{"":{actor:{version:1,data:{health:23}}}},removed:{"":{removed:!0}}}}}),r.persistence=i;let d=class extends c{constructor(){super(...arguments),this.health=100,this.begunHealth=0,this.persistenceVersion=1}onCapturePersistence(){return{health:this.health}}async onRestorePersistence(e){this.health=e.health}onBeginPlay(){this.begunHealth=this.health}};d=e([a({persistent:!0})],d);const l=await r.spawnActor(d,void 0,void 0,{persistenceId:"companion"});t(l.begunHealth).toBe(23);const m=o.spyOn(n,"create");await t(r.spawnActor(d,void 0,void 0,{persistenceId:"removed"})).rejects.toThrow("removed"),t(m).not.toHaveBeenCalled(),t(()=>r.spawnActorSync(d,void 0,void 0,{persistenceId:"sync"})).toThrow("spawnActorSync")}),n("world begin and end play skip runtime-only components in editor",async()=>{const e=new p(s.of("world-lifecycle-editor"),{inEditor:!0}),n=await e.create(h),o=u(e);o.addActor(n),o.removeActor(n),t(n.beginCount).toBe(1),t(n.endCount).toBe(1),t(n.runtimeOnly.beginCount).toBe(0),t(n.runtimeOnly.endCount).toBe(0),t(n.runtimeOnly.actor).toBe(n),t(n.inEditor.beginCount).toBe(1),t(n.inEditor.endCount).toBe(1),t(n.inEditor.actor).toBe(n),t(n.editorOnly.beginCount).toBe(1),t(n.editorOnly.endCount).toBe(1),t(n.editorOnly.actor).toBe(n)}),n("world begin and end play skip editor-only components at runtime",async()=>{const e=new p(s.of("world-lifecycle-runtime"),{inEditor:!1}),n=await e.create(h),o=u(e);o.addActor(n),o.removeActor(n),t(n.beginCount).toBe(1),t(n.endCount).toBe(1),t(n.runtimeOnly.beginCount).toBe(1),t(n.runtimeOnly.endCount).toBe(1),t(n.runtimeOnly.actor).toBe(n),t(n.inEditor.beginCount).toBe(1),t(n.inEditor.endCount).toBe(1),t(n.inEditor.actor).toBe(n),t(n.editorOnly.beginCount).toBe(0),t(n.editorOnly.endCount).toBe(0),t(n.editorOnly.actor).toBe(n)});let h=class extends c{constructor(){super(...arguments),this.beginCount=0,this.endCount=0,this.runtimeOnly=this.attach(b),this.inEditor=this.attach(v),this.editorOnly=this.attach(w)}onBeginPlay(){this.beginCount++}onEndPlay(){this.endCount++}};h=e([a()],h);class g extends d{constructor(){super(...arguments),this.beginCount=0,this.endCount=0}onBeginPlay(){this.beginCount++}onEndPlay(){this.endCount++}}let b=class extends g{};b=e([l()],b);let v=class extends g{};v=e([l({inEditor:!0})],v);let w=class extends g{};w=e([l({inEditor:!0,editorOnly:!0})],w);/*
2
2
  * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
3
  * See the LICENSE.md file for details.
4
4
  */
@@ -1,4 +1,4 @@
1
- import{describe as e,expect as t,test as n,vi as o}from"vitest";import{Euler as a,Group as r,Scene as s,Vector3 as c}from"three";import{Subject as i}from"rxjs";o.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=o.fn()),e[t]),set:(e,t,n)=>(e[t]=n,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e})}),o.mock("../rendering.js",()=>({RenderingView:class{}})),o.mock("../gameplay/services/physics/physics-system.js",()=>({PhysicsSystem:class{}}));import{World as d}from"../gameplay/services/world.js";import{ActorLifetime as p}from"../gameplay/actors/actor.js";e("World.spawnPrefab",()=>{n("makes the requested transform available while prefab actors initialize",async()=>{const e=new s,n=new s,f=new r;f.position.set(1,0,0);const m={object:f,disposed:new i,attachedComponents:[],onBeginPlay:o.fn(),onEndPlay:o.fn(),constructor:Object},l=new r;let w,y=n;const b={id:"prefab-a",type:"prefab",prefab:{objects:[]},revision:2},v={inEditor:!0,setScene:o.fn(e=>{y=e}),createFromPrefabAsset:o.fn(async(e,t,n)=>(n.add(l),l.add(f),v.inEditor||y.attach(f),w=f.position.clone(),{object:l,actors:[m],mainActor:m,instanceId:"runtime-instance"})),unregisterLivePrefabInstance:o.fn()},j=Object.create(d.prototype);j.scene=e,j.materializer=v,j.actorFactory={inEditor:!1},j.physics={addFromScene:o.fn(),removeRemoved:o.fn(),removeActor:o.fn()},j.assetsProvider={getAsset:o.fn(async()=>b)},j.actorLifetimes=new Map,j.actorPrefabInstances=new Map,j.prefabInstances=new Set,j.sceneEventGraphs=new Map,j.actorAdded=new i,j.actorRemoved=new i,j.actors=[];const P=new c(10,2,5),g=new a(0,Math.PI/2,0),h=new r,B=new r;h.position.copy(P),h.rotation.copy(g),B.position.set(1,0,0),h.add(B),h.updateWorldMatrix(!0,!0);const u=B.getWorldPosition(new c),E=await j.spawnPrefab({asset:{id:"prefab-a",type:"prefab",prefab:{objects:[]}}},P,g);t(w).toEqual(u),t(v.setScene).toHaveBeenCalledWith(e),t(v.inEditor).toBe(!1),t(f.parent).toBe(e),t(E?.object.parent).toBe(e),t(E?.object.position).toEqual(P),t(E?.object.rotation.y).toBeCloseTo(g.y),t(E?.lifetime).toBe(p.scene),t(j.getActorLifetime(m)).toBe(E?.lifetime),t(v.createFromPrefabAsset).toHaveBeenCalledWith(b,t.anything(),t.any(r),void 0,!1),j.removePrefab(E),t(v.unregisterLivePrefabInstance).toHaveBeenCalledWith("runtime-instance")})}),n("scene cleanup uses explicit actor lifetime instead of spawn origin",()=>{const e=Object.create(d.prototype);e.scene=new s,e.actors=[],e.actorLifetimes=new Map,e.actorPrefabInstances=new Map,e.prefabInstances=new Set,e.sceneEventGraphs=new Map,e.physics={removeActor:o.fn()},e.actorFactory={inEditor:!1},e.actorAdded=new i,e.actorRemoved=new i;const n=new r;e.scene.add(n);const a=()=>({object:new r,disposed:new i,attachedComponents:[],onBeginPlay:o.fn(),onEndPlay:o.fn(),constructor:Object}),c=a(),f=a();n.add(c.object,f.object),e.addActor(c),e.addActor(f,void 0,void 0,p.game);const m=e.removeSceneLifetimeActors([n]);t(e.actors).toEqual([f]),t(m.has(f)).toBe(!0),t(f.object.parent).toBe(e.scene),t(c.onEndPlay).toHaveBeenCalledOnce(),t(f.onEndPlay).not.toHaveBeenCalled()}),n("spawnActorSync applies explicit owner and lifetime",()=>{const e=Object.create(d.prototype),n={id:1},a={object:new r,disposed:new i,attachedComponents:[],onBeginPlay:o.fn(),onEndPlay:o.fn(),constructor:Object};e.actorFactory={inEditor:!1,createSync:o.fn(()=>a)},e.actors=[],e.actorLifetimes=new Map,e.actorPrefabInstances=new Map,e.actorAdded=new i,e.scene=new s;const c=e.spawnActorSync(Object,void 0,void 0,{owner:n,lifetime:p.game});t(c).toBe(a),t(a.owner).toBe(n),t(e.getActorLifetime(a)).toBe(p.game)});/*
1
+ import{describe as e,expect as t,test as n,vi as o}from"vitest";import{Euler as a,Group as r,Scene as s,Vector3 as c}from"three";import{Subject as i}from"rxjs";o.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=o.fn()),e[t]),set:(e,t,n)=>(e[t]=n,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e})}),o.mock("../rendering.js",()=>({RenderingView:class{}})),o.mock("../gameplay/services/physics/physics-system.js",()=>({PhysicsSystem:class{}}));import{World as d}from"../gameplay/services/world.js";import{ActorLifetime as p}from"../gameplay/actors/actor.js";import{ActorPersistence as m}from"../gameplay/services/actor-persistence.js";n("a dynamic persistent prefab restores every actor before any begin-play",async()=>{const e=Object.create(d.prototype),n=[],a=o=>({name:o,object:new r,disposed:new i,attachedComponents:[],constructor:Object,persistenceEnabled:!0,persistenceVersion:1,value:0,onCapturePersistence(){return this.value},async onRestorePersistence(a){t(e.actors).toHaveLength(2),t(n.every(e=>e.startsWith("restore"))).toBe(!0),await Promise.resolve(),this.value=a,n.push("restore-"+o)},onBeginPlay(){n.push("begin-"+o)},onEndPlay(){}}),c=a("main"),p=a("child"),f=new r,l=new m;l.configure({data:{version:1,scenes:{},dynamic:{drop:{"":{actor:{version:1,data:7}},child:{actor:{version:1,data:9}}}}}}),e.persistence=l,e.scene=new s,Object.assign(e,{actors:[],actorLifetimes:new Map,actorPrefabInstances:new Map,prefabInstances:new Set,actorAdded:new i,actorRemoved:new i,actorFactory:{inEditor:!1},physics:{addFromScene:o.fn()},materializer:{setScene:o.fn(),createFromPrefabAsset:async()=>({object:f,actors:[c,p],actorsById:new Map([["main",c],["child",p]]),mainActor:c,instanceId:"runtime"})}}),await e.spawnPrefab({asset:{id:"asset",prefab:{}}},void 0,void 0,{persistenceId:"drop"}),t(n).toEqual(["restore-main","restore-child","begin-main","begin-child"]),t(c.value).toBe(7),t(p.value).toBe(9)}),e("World.spawnPrefab",()=>{n("makes the requested transform available while prefab actors initialize",async()=>{const e=new s,n=new s,m=new r;m.position.set(1,0,0);const f={object:m,disposed:new i,attachedComponents:[],onBeginPlay:o.fn(),onEndPlay:o.fn(),constructor:Object},l=new r;let w,b=n;const y={id:"prefab-a",type:"prefab",prefab:{objects:[]},revision:2},v={inEditor:!0,setScene:o.fn(e=>{b=e}),createFromPrefabAsset:o.fn(async(e,t,n)=>(n.add(l),l.add(m),v.inEditor||b.attach(m),w=m.position.clone(),{object:l,actors:[f],mainActor:f,instanceId:"runtime-instance"})),unregisterLivePrefabInstance:o.fn()},h=Object.create(d.prototype);h.scene=e,h.materializer=v,h.actorFactory={inEditor:!1},h.physics={addFromScene:o.fn(),removeRemoved:o.fn(),removeActor:o.fn()},h.assetsProvider={getAsset:o.fn(async()=>y)},h.actorLifetimes=new Map,h.actorPrefabInstances=new Map,h.prefabInstances=new Set,h.sceneEventGraphs=new Map,h.actorAdded=new i,h.actorRemoved=new i,h.actors=[];const P=new c(10,2,5),g=new a(0,Math.PI/2,0),j=new r,u=new r;j.position.copy(P),j.rotation.copy(g),u.position.set(1,0,0),j.add(u),j.updateWorldMatrix(!0,!0);const B=u.getWorldPosition(new c),E=await h.spawnPrefab({asset:{id:"prefab-a",type:"prefab",prefab:{objects:[]}}},P,g);t(w).toEqual(B),t(v.setScene).toHaveBeenCalledWith(e),t(v.inEditor).toBe(!1),t(m.parent).toBe(e),t(E?.object.parent).toBe(e),t(E?.object.position).toEqual(P),t(E?.object.rotation.y).toBeCloseTo(g.y),t(E?.lifetime).toBe(p.scene),t(h.getActorLifetime(f)).toBe(E?.lifetime),t(v.createFromPrefabAsset).toHaveBeenCalledWith(y,t.anything(),t.any(r),void 0,!1),h.removePrefab(E),t(v.unregisterLivePrefabInstance).toHaveBeenCalledWith("runtime-instance")})}),n("scene cleanup uses explicit actor lifetime instead of spawn origin",()=>{const e=Object.create(d.prototype);e.scene=new s,e.actors=[],e.actorLifetimes=new Map,e.actorPrefabInstances=new Map,e.prefabInstances=new Set,e.sceneEventGraphs=new Map,e.physics={removeActor:o.fn()},e.actorFactory={inEditor:!1},e.actorAdded=new i,e.actorRemoved=new i;const n=new r;e.scene.add(n);const a=()=>({object:new r,disposed:new i,attachedComponents:[],onBeginPlay:o.fn(),onEndPlay:o.fn(),constructor:Object}),c=a(),m=a();n.add(c.object,m.object),e.addActor(c),e.addActor(m,void 0,void 0,p.game);const f=e.removeSceneLifetimeActors([n]);t(e.actors).toEqual([m]),t(f.has(m)).toBe(!0),t(m.object.parent).toBe(e.scene),t(c.onEndPlay).toHaveBeenCalledOnce(),t(m.onEndPlay).not.toHaveBeenCalled()}),n("spawnActorSync applies explicit owner and lifetime",()=>{const e=Object.create(d.prototype),n={id:1},a={object:new r,disposed:new i,attachedComponents:[],onBeginPlay:o.fn(),onEndPlay:o.fn(),constructor:Object};e.actorFactory={inEditor:!1,createSync:o.fn(()=>a)},e.actors=[],e.actorLifetimes=new Map,e.actorPrefabInstances=new Map,e.actorAdded=new i,e.scene=new s;const c=e.spawnActorSync(Object,void 0,void 0,{owner:n,lifetime:p.game});t(c).toBe(a),t(a.owner).toBe(n),t(e.getActorLifetime(a)).toBe(p.game)});/*
2
2
  * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
3
  * See the LICENSE.md file for details.
4
4
  */
@@ -0,0 +1,87 @@
1
+ # Actor persistence
2
+
3
+ Persistence is opt-in and storage-independent. Enable it when loading a single-player session:
4
+
5
+ ```ts
6
+ await application.loadGame(Game, {
7
+ sceneName: save.sceneName,
8
+ persistence: { data: save.persistence }, // undefined data starts an empty document
9
+ })
10
+ ```
11
+
12
+ `initiateGame()` and React's `HologyScene` accept the same `persistence` option. Games load and migrate their save envelope before loading the scene, then write `inject(ActorPersistence).snapshot()` alongside their other save data. Capture is synchronous and returns detached JSON data; do it before awaiting storage. Sessions without this option do not register actors. Network sessions reject the option.
13
+
14
+ ## Actor and component hooks
15
+
16
+ ```ts
17
+ @Actor({ persistent: true })
18
+ class Chest extends BaseActor implements PersistentState<{ opened: boolean }> {
19
+ readonly persistenceVersion = 1
20
+ opened = false
21
+
22
+ onCapturePersistence() { return { opened: this.opened } }
23
+ onRestorePersistence(state: { opened: boolean }, context: PersistenceContext) {
24
+ if (context.version !== 1 || typeof state?.opened !== 'boolean') {
25
+ throw new Error('Unsupported chest state')
26
+ }
27
+ this.opened = state.opened
28
+ }
29
+ onPersistenceRestored() { this.updateAppearance() }
30
+ }
31
+
32
+ @Component()
33
+ class Cooldown extends ActorComponent implements PersistentState<{ remaining: number }> {
34
+ // Stable within the owning actor, including nested components. Two cooldowns
35
+ // need distinct keys, e.g. 'primary-attack' and 'dodge'. Never use array indices.
36
+ persistenceKey = 'primary-attack'
37
+ readonly persistenceVersion = 1
38
+ remaining = 0
39
+
40
+ onCapturePersistence() { return { remaining: this.remaining } }
41
+ onRestorePersistence(state: { remaining: number }, context: PersistenceContext) {
42
+ if (context.version !== 1 || !Number.isFinite(state?.remaining)) {
43
+ throw new Error('Unsupported cooldown state')
44
+ }
45
+ this.remaining = Math.max(0, state.remaining)
46
+ }
47
+ }
48
+ ```
49
+
50
+ Override `get persistenceEnabled()` for a policy based on authored parameters (for example, enemy faction). It is evaluated after initialization. A component never opts its actor in. An opted-in actor may have only component hooks, or no hooks if it only needs permanent removal.
51
+
52
+ For the common position-and-yaw case, attach `ActorTransformPersistenceComponent` to an opted-in actor. It uses the stable component key `transform` and stores local position and yaw, leaving pitch and roll at their authored values. It restores before begin-play and synchronizes an existing physics body after the restore batch. Attach it only once per actor. `captureActorTransform(actor)` and `restoreActorTransform(actor, state)` provide the same JSON shape for custom hooks; the latter validates the payload and sets the object transform, so custom movement systems must also synchronize their own state and physics. A character movement component, for example, can call `movement.teleport(actor.position, actor.rotation.y)` in the actor's `onPersistenceRestored()` when a saved transform was applied (`transformComponent.restored`).
53
+
54
+ Initialize defaults in `onInit()`. Restore runs after all actors/components initialize and references bind, before `onBeginPlay()`, event graphs, game preload and presentation. Do not reset persisted fields in begin-play. Components restore before their actor. `onPersistenceRestored()` runs after the entire batch restores, including actors with no saved entry. Ordinary restore hooks may resolve other actors, but cannot assume those actors have restored yet. Hooks should not spawn participants or mutate the batch.
55
+
56
+ Save finite JSON values, not actors, vectors, signals, functions, dates or cyclic objects. Validate or migrate component/actor payload versions in the restore hook. Unknown component entries are retained for later saves. Restore failures stop loading and include identity and component key in the error. Restore by assigning state, without replaying damage, death, item rewards or other gameplay events. Save remaining durations rather than wall-clock deadlines unless the game intentionally advances them while closed.
57
+
58
+ ## Identity and removal
59
+
60
+ Authored identities are `{ sceneId, path }`: the stable scene metadata ID and the existing object/prefab instance path. Names are not IDs. Renaming does not change identity; recreating an object or moving it across prefab boundaries may require a game save migration.
61
+
62
+ Call `persistence.markRemoved(actor)` at the permanent gameplay outcome, such as enemy death, before delayed presentation cleanup. It records removal without destroying the live object. The materializer skips that authored subtree on the next load. For a prefab's exposed actor, this removes the whole exposed placement, including nested prefabs, meshes, collisions and event graphs. Ordinary `world.removeActor()` and unloading capture state but never mark it removed.
63
+
64
+ References to removed actors become `null`. Gameplay must handle that possibility. `getIdentity(actor)`, `resolve(identity)` and `isRemoved(identity)` support game save references. Scene parameters can use direct actor references, including arrays; a removed actor resolves to `null` in the corresponding field.
65
+
66
+ Scene travel captures outgoing actors and retains unloaded scene records. Game-lifetime actors remain registered and are not restored again when their authored scene is revisited.
67
+
68
+ ## Dynamic actors
69
+
70
+ The game saves its own spawn descriptors and recreates actors/prefabs explicitly:
71
+
72
+ ```ts
73
+ await world.spawnActor(Companion, position, rotation, { persistenceId: 'quest-companion' })
74
+ await world.spawnPrefab(droppedItemPrefab, position, rotation, { persistenceId: savedDrop.id })
75
+ ```
76
+
77
+ The actor must still opt in. Dynamic identities are `{ dynamicId, path }`, with an empty path for the main actor and prefab-local paths for other actors. IDs are unique across the session, independent of scene. The game decides which scene to spawn them in. A removed or already active ID is an error; use `isRemoved({ dynamicId, path: '' })` before recreating it. All actors in a prefab restore before any begin-play. References across separately spawned dynamic actors require game-controlled creation order. Synchronous spawning cannot restore persistent state.
78
+
79
+ The engine does not recreate dynamically attached components. The game must attach them with stable keys before restoration. Dynamic prefabs are removed as a unit when any persistent member is marked removed.
80
+
81
+ ## Cost and testing
82
+
83
+ Persistence has no frame callback. Nonparticipating actors receive no persistence record or subscription. Discovery occurs at initialization/capture/restore boundaries, and snapshots traverse registered actors only. Snapshot size and capture frequency remain under game control.
84
+
85
+ Core checks: `npm test -- --run src/test/actor-persistence.test.ts src/test/actor-persistence-runtime.test.ts src/test/world-lifecycle.test.ts src/test/world-prefab-spawn.test.ts src/test/prefab-instance-params.test.ts src/test/rendering-host.test.ts` from `packages/core`.
86
+
87
+ Maran integration checks, with both repositories side by side: `npm exec -- vitest run --config ../../../legend-of-maran/tests/persistence.config.mts` from `packages/core`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hology/core",
3
- "version": "0.0.259",
3
+ "version": "0.0.261",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",