@hology/core 0.0.247 → 0.0.249
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/effects/sequence/sequence-data.d.ts +24 -3
- package/dist/effects/sequence/sequence-data.js +1 -1
- package/dist/effects/sequence/sequence-definitions.d.ts +2 -1
- package/dist/effects/sequence/sequence-definitions.js +1 -1
- package/dist/effects/sequence/sequence-ops.js +1 -1
- package/dist/effects/sequence/sequence-player.d.ts +6 -0
- package/dist/effects/sequence/sequence-player.js +1 -1
- package/dist/effects/vfx/gpu-sprite-compiler.js +1 -1
- package/dist/effects/vfx/seeded-random.d.ts +4 -0
- package/dist/effects/vfx/seeded-random.js +4 -0
- package/dist/effects/vfx/vfx-actor.d.ts +1 -1
- package/dist/effects/vfx/vfx-actor.js +1 -1
- package/dist/effects/vfx/vfx-gpu-material.d.ts +9 -0
- package/dist/effects/vfx/vfx-gpu-material.js +4 -0
- package/dist/effects/vfx/vfx-materializer.js +1 -1
- package/dist/gameplay/actors/actor.d.ts +6 -0
- package/dist/gameplay/actors/actor.js +1 -1
- package/dist/gameplay/actors/builtin/camera-actor.js +1 -1
- package/dist/gameplay/actors/builtin/components/spline-component.js +1 -1
- package/dist/gameplay/actors/builtin/components/water-post-process-settings.d.ts +24 -0
- package/dist/gameplay/actors/builtin/components/water-post-process-settings.js +4 -0
- package/dist/gameplay/actors/builtin/navmesh-actor.js +1 -1
- package/dist/gameplay/actors/builtin/positional-audio-actor.js +1 -1
- package/dist/gameplay/actors/builtin/post-process-volume-actor.d.ts +1 -2
- package/dist/gameplay/actors/builtin/spawn-point.js +1 -1
- package/dist/gameplay/actors/builtin/spline-actor.js +1 -1
- package/dist/gameplay/actors/builtin/trigger-volume.js +1 -1
- package/dist/gameplay/actors/builtin/water-spline-volume-actor.d.ts +11 -1
- package/dist/gameplay/actors/builtin/water-spline-volume-actor.js +1 -1
- package/dist/gameplay/actors/builtin/water-volume-actor.d.ts +12 -1
- package/dist/gameplay/actors/builtin/water-volume-actor.js +1 -1
- package/dist/gameplay/actors/camera/camera-component.js +1 -1
- package/dist/gameplay/actors/camera/third-person-camera-component.js +1 -1
- package/dist/gameplay/actors/tabler-icon.d.ts +2 -0
- package/dist/gameplay/actors/tabler-icon.js +4 -0
- package/dist/gameplay/index.d.ts +1 -1
- package/dist/gameplay/index.js +1 -1
- package/dist/gameplay/initiate.d.ts +2 -1
- package/dist/gameplay/initiate.js +1 -1
- package/dist/gameplay/inject.d.ts +3 -0
- package/dist/gameplay/inject.js +1 -1
- package/dist/gameplay/net/service/rpc-decorator.js +1 -1
- package/dist/gameplay/services/render.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/rendering/webgpu-experimental-backend.d.ts +2 -0
- package/dist/rendering/webgpu-experimental-backend.js +1 -1
- package/dist/rendering.d.ts +18 -0
- package/dist/rendering.js +1 -1
- package/dist/scene/materializer.js +1 -1
- package/dist/shader/graph/compiler.js +1 -1
- package/dist/shader/graph/model.d.ts +1 -1
- package/dist/shader/graph/registry.js +1 -1
- package/dist/shader/parameter.d.ts +3 -0
- package/dist/shader/post-process-shader.js +1 -1
- package/dist/shader-nodes/depth.d.ts +3 -1
- package/dist/shader-nodes/depth.js +1 -1
- package/dist/shader-nodes/index.d.ts +1 -0
- package/dist/shader-nodes/index.js +1 -1
- package/dist/shader-nodes/water-volume.d.ts +9 -0
- package/dist/shader-nodes/water-volume.js +4 -0
- package/dist/shader-nodes/water.d.ts +12 -0
- package/dist/shader-nodes/water.js +4 -0
- package/dist/test/gpu-sprite-compiler.test.js +1 -1
- package/dist/test/materializer-dynamic-transform.test.js +1 -1
- package/dist/test/post-process-effect.test.js +1 -1
- package/dist/test/sequence-light.test.d.ts +2 -0
- package/dist/test/sequence-light.test.js +4 -0
- package/dist/test/sequence-vfx.test.js +1 -1
- package/dist/test/spline-component.test.js +1 -1
- package/dist/test/vfx-seeded-random.test.d.ts +2 -0
- package/dist/test/vfx-seeded-random.test.js +4 -0
- package/dist/test/water-spline-volume-actor.test.js +1 -1
- package/package.json +3 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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,varyingAttributes as c,vec3 as p}from"three-shader-graph";import{SerializedParamType as m}from"../scene/model.js";import{compileGpuSpriteEmitters as d,materializeGpuSpriteRenderClasses as u,selectVfxBackend as h}from"../effects/vfx/gpu-sprite-compiler.js";import{prepareGpuSpriteBackend as y}from"../effects/vfx/gpu-sprite-backend.js";import{calculateGpuSpriteOptionalStateLayout as f,GPU_TRAIL_RENDER_CLASS_BYTES as g,GpuSpriteDirtyRange as b,getCompiledGpuSpriteMaterial as w,getGpuSpriteComputeWgsl as C,getGpuSpriteMaterialWgsl as v,getGpuTrailMaterialWgsl as x}from"../effects/vfx/gpu-sprite-runtime.js";import{Trail as B,trailUV as S}from"../effects/vfx/trail-renderer.js";import{timeUniforms as V}from"../shader-nodes/time.js";import{GpuSpriteSchedulerRuntime as T}from"../effects/vfx/gpu-sprite-scheduler-runtime.js";import{GpuSpriteTemplateCache as M,instantiateGpuSpriteTemplate as I}from"../effects/vfx/gpu-sprite-template.js";import{GpuSpriteWorldRuntime as E}from"../effects/vfx/gpu-sprite-world-runtime.js";import{GpuSpriteRenderClassCache as P}from"../effects/vfx/gpu-sprite-compiler.js";import{addGpuSpriteRenderGraphPasses as z}from"../effects/vfx/gpu-sprite-render-graph.js";import{gpuVfxCapacityClass as O}from"../effects/vfx/gpu-vfx-capacity.js";import{buildParticleLightingShaderHeader as j}from"@hology/webgpu-renderer";import{sampleSceneColor as q}from"../shader-nodes/scene-sample.js";import{screenUV as A}from"../shader-nodes/depth.js";import{particleUniforms as R}from"../shader-nodes/particle.js";const G=e=>({type:m.Number,value:e}),k=e=>({type:m.Vector3,value:e});function _(e={}){return{type:"sprite",texture:"dot",color:"#ffffff",intensity:1,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}function L(e={}){return{type:"mesh",assetId:"simple-mesh",space:"world",...e}}function U(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 D(e,t=_()){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:G(2)}},{id:`${e}-position`,type:"positionSphere",enabled:!0,params:{position:k([0,0,0]),radius:G(1),surface:{type:m.Boolean,value:!1}}}],behaviours:[]}}function W(...e){return{id:"test-vfx",name:"test-vfx",type:"vfx",vfx:{emitters:e}}}e("GPU sprite compiler compatibility",()=>{a("bakes an authored effect input into emitter spawn delay",()=>{const e=D("bound-delay");e.rate.delayBinding={type:"vfxInput",inputId:"timing"};const a=W(e);a.vfx.inputs=[{id:"timing",name:"Timing",type:"number",defaultValue:.5}];const i=d(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=C(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=D("parent");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{bounce:G(.6),friction:G(.2),lifeLoss:G(.5),debug:{type:m.Boolean,value:!0}}});const a=D("child");a.rate={type:"once",count:3,delay:0},a.spawnEvent="collision",e.children=[a];const i=d(W(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=C(16);t(n).not.toContain("collisionDepth"),t(n).not.toContain("optionalStates"),t(n).not.toContain("triangleRecord"),t(n).not.toMatch(/__[A-Z_]+__/);const s=C(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=C(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=D("parent",_({space:"local"})),a=D("local-child",_({space:"local"})),i=D("world-child",_({space:"world"}));e.children=[a,i];const n=d(W(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=C(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(s).not.toContain("parentTransform = mat4x4");const r=C(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=D("parent",_({space:"world"}));e.children=[D("child",_({space:"local"}))];const a=d(W(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=D("parent",L({renderOrder:10})),a=D("child",_({renderOrder:-10}));e.children=[a];const i=d(W(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(O)).toEqual([1,2,4,4,8,32])}),a("compiles stretched sprites into a velocity-aligned render class",async()=>{const e=d(W(D("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 u(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].stretchScale).toBe(.75);const i=v(w(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=d(W(D("trail",U({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 u(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({historyLength:11,width:.5,taper:!0}),t(g).toBe(80);const i=C(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=d(W(D("attached-trail",U({space:"local"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=C(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(p(S.x,S.y,R.opacity),R.opacity)});B.applyTrailShaderParameters(e);const a=x(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(p(V.elapsed),1)}),a=x(e),o=v(w(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("preserves CPU raw-channel colors for color behaviours",()=>{const e=D("legacy-color",U());e.behaviours.push({id:"color",type:"changeColor",enabled:!0,params:{a:{type:m.Color,value:"#918383"},b:{type:m.Color,value:"#4f4f4f"}}});const a=d(W(e),[new o.Vector3]);t(a.emitters[0].colorStart.toArray()).toEqual([145/255,131/255,131/255]),t(a.emitters[0].colorEnd.toArray()).toEqual([79/255,79/255,79/255])}),a("materializes one mesh geometry and rejects mesh hierarchies with multiple geometries",async()=>{const e=D("mesh",L({space:"local"}));e.initializers.push({id:"mesh-scale-components",type:"scaleComponents",enabled:!0,params:{scale:k([1,2,3])}},{id:"mesh-rotation",type:"rotation",enabled:!0,params:{rotation:k([30,45,60])}}),e.behaviours.push({id:"mesh-scale-components-behaviour",type:"scaleComponents",enabled:!0,params:{a:k([2,1,1]),b:k([1,.5,2])}});const a=W(e),r=d(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 u(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 u(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l},async()=>p);t(m.renderClasses[0].material).toBe(p);const h=new o.Group;h.add(new o.Mesh(new o.BoxGeometry),new o.Mesh(new o.BoxGeometry)),await t(u(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>h})).rejects.toThrow("exactly one mesh geometry")}),a("keeps an unconfigured mesh output GPU-compatible without rendering it",()=>{const e=d(W(D("unconfigured-mesh",L({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=d(W(D("unselected-mesh-material",L({materialSource:"material",material:void 0}))),[new o.Vector3]),a=new i({color:s(n(.4),1)}),r=await u(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=d(W(D("unselected-mesh-graph",L({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:null}}))),[new o.Vector3]),a=await u(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)});t(e.compatibility.compatible).toBe(!0),t(()=>w(a.renderClasses[0].material)).not.toThrow()}),a("gates custom mesh materials on a compatible GPU material resolver",async()=>{const e=d(W(D("mesh-material",L({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(u(e.renderClasses,a)).rejects.toThrow("compatible GPU material resolver");const n=new o.Texture,s=await u(e.renderClasses,a,async()=>new i({color:l(n).sample(c.uv)}));t(s.renderClasses[0].material).toBeInstanceOf(i);const r=w(s.renderClasses[0].material);t(Object.values(r.textures)).toEqual([n])}),a("materializes non-billboard trails with orientation history",async()=>{const e=d(W(D("oriented-trail",U({billboard:!1}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await u(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({billboard:!1});const i=C(16,!0,!1,!1,!0);t(i).toContain("trailSampleOffset + 1u"),t(i).toContain("quaternionMultiply(particleRotation, movementRotation)");const n=x(B.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=d(W(D("mesh",L({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:G(1)}}))),[new o.Vector3]),i=d(W(D("mesh",L({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:G(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=d(W(D("modern",L())),[new o.Vector3]),n=d(W(D("legacy",L({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 u(i.renderClasses,s),l=await u(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=W(D("revisioned")),a={assets:{getTexture:async()=>new o.Texture},renderClassCache:new P},i=await(new M).resolve(e,{...a,revision:1}),n=await(new M).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:q(A)}),a=w(e);t(Object.values(a.sceneTextures)).toEqual(["sceneColor"])}),a("retains the shared renderer geometry semantics for lit particle materials",()=>{const e=w(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(j()).toContain("fn rendererCameraPosition()");const a=v(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;z({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=C(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=D("random-life");e.initializers[0]={id:"random-life-range",type:"randomLifetime",enabled:!0,params:{minDuration:G(.5),maxDuration:G(2.5)}};const a=d(W(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=D("random-color");e.initializers.push({id:"random-color-range",type:"randomColor",enabled:!0,params:{a:{type:m.Color,value:"#ff0000"},b:{type:m.Color,value:"#0000ff"}}});const a=d(W(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:k([1,2,3])},0],["positionSphere",{position:k([1,2,3]),radius:G(2),surface:{type:m.Boolean,value:!0}},1],["positionBox",{position:k([1,2,3]),dimensions:k([4,5,6])},2],["positionLine",{a:k([1,2,3]),b:k([4,5,6])},3],["positionRing",{position:k([1,2,3]),rotation:k([10,20,30]),radius:G(4),thickness:G(.5),angle:G(180)},4]];for(const[a,i,n]of e){const e=D(`zone-${a}`);e.initializers[1]={id:`zone-${a}`,type:a,enabled:!0,params:i};const s=d(W(e),[new o.Vector3]);t(s.compatibility.compatible).toBe(!0),t(s.emitters[0].spawnZoneType).toBe(n)}const a=C(16);t(a).toContain("fn rotateSpawnPoint"),t(a).toContain("if (zoneType == 4u)")}),a("preserves legacy per-update sprite rotation behavior",()=>{const e=D("sprite-rotate");e.behaviours.push({id:"rotate-add",type:"rotateAdd",enabled:!0,params:{rotation:k([0,0,200])}});const a=d(W(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=C(16);t(i).toContain("var rotation = particle.color.w;"),t(i).toContain("rotation += emitter.rotationInitialSpriteStep.w * frame.padding1"),e.output=L();const n=d(W(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=C(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:m.String,value:"Y"}}});const r=d(W(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=D("radial-velocity");e.initializers.push({id:"radial",type:"radialVelocity",enabled:!0,params:{speed:G(4),direction:k([0,1,0]),spread:G(30)}});const a=d(W(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=C(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=D("attraction");e.behaviours.push({id:"attract",type:"attraction",enabled:!0,params:{target:k([1,2,3]),force:G(4),radius:G(5)}});const a=d(W(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=d(W(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].attractionForce).toBe(-4);const n=C(16);t(n).toContain("attractionDistanceSq < attractionRadiusSq")}),a("compiles mesh alignment modes into spawn-only parameters",()=>{const e=D("aligned-mesh",L());e.initializers.push({id:"align",type:"alignRotation",enabled:!0,params:{axis:{type:m.String,value:"+X"},mode:{type:m.String,value:"AlongVelocity"},offset:k([10,20,30]),center:k([1,2,3])}});const a=d(W(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=C(16,!0);t(i).toContain("fn quaternionFromUnitVectors"),t(i).toContain("alignRotationMode == 3u")}),a("groups authored render classes deterministically",()=>{const e=d(W(D("a"),D("b"),D("c",_({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=W(D("first"),D("second",_({blendingMode:"normal"}))),a=[new o.Vector3,new o.Vector3(1,0,0)],i=d(e,a),n=d(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 y({asset:W(D("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 T.create({asset:W(D("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=C(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 y({asset:W(D("additive"),D("normal",_({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=C(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=D("paged-instance");e.position=[2,0,0];const a=await T.create({asset:W(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 T.create({asset:W(D("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 T.create({asset:W(D("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=D("delayed");e.rate={type:"once",count:2,delay:.25};const a=D("finite");a.rate={type:"continuous",count:2,time:.1,delay:0,duration:1};const i=d(W(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=D("unlimited");e.rate={type:"continuous",count:2,time:.1,delay:0,duration:0};const a=d(W(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=W(D("input-test"));e.vfx.inputs=[{id:"speed",name:"Speed",type:"number",defaultValue:1}],t(d(e,[new o.Vector3]).compatibility.compatible).toBe(!0),e.vfx.emitters[0].initializers[0].params.duration.binding={type:"vfxInput",inputId:"speed"};const a=d(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:m.Vector3,value:[0,1,0],binding:{type:"vfxInput",inputId:"speed"}}}}),t(d(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=D("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=W(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:2}];const i=d(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=W(D("cached-template")),a=new M;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=I(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=D("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:m.Vector3,value:[0,0,0],binding:{type:"vfxInput",inputId:"velocity"}}}});const a=W(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 T.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=_(),a=await E.create({templates:[{asset:W(D("world-a",e)),maximumInstances:2},{asset:W(D("world-b",{...e})),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},templateCache:new M,renderClassCache:new P,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=D("world-lifecycle");e.position=[2,0,0];const a=await E.create({templates:[{asset:W(e),maximumInstances:1},{asset:W(D("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 E.create({templates:[{asset:W(D("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=D("soft-local",_({softness:1,space:"local",lockY:!0}));e.behaviours.push({id:"animation",type:"animation",enabled:!0,params:{}});const a=d(W(e),[new o.Vector3]),i=h(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(h(a.compatibility,"gpu").backend).toBe("gpu")}),a("compiles vortex with pay-for-play previous-velocity state",()=>{const e=D("vortex");e.behaviours.push({id:"vortex",type:"vortex",enabled:!0,params:{axis:k([0,5,0]),amount:G(1.25)}});const a=d(W(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(C(16,!1,!1)).not.toContain("vortexVelocities");const i=C(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=D("remaining-motion");e.initializers.push({id:"mass",type:"mass",enabled:!0,params:{min:G(2)}}),e.behaviours.push({id:"drift",type:"randomDrift",enabled:!0,params:{drift:k([1,2,3]),delay:G(.25),ease:{type:m.String,value:"OutQuad"}}},{id:"follow",type:"followParent",enabled:!0,params:{speed:G(4)}},{id:"move",type:"moveTo",enabled:!0,params:{target:k([5,6,7]),ease:{type:m.String,value:"InQuad"}}});const a=d(W(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=D("easing");e.behaviours.push({id:"scale",type:"scale",enabled:!0,params:{a:G(0),b:G(1),ease:{type:m.String,value:"OutBack"}}},{id:"color",type:"changeColor",enabled:!0,params:{a:{type:m.Color,value:"#ff0000"},b:{type:m.Color,value:"#0000ff"},ease:{type:m.String,value:"InCubic"}}},{id:"opacity",type:"changeOpacity",enabled:!0,params:{a:G(0),b:G(1),ease:{type:m.String,value:"BumpSharp"}}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].curveEasing.toArray()).toEqual([18,4,20,0]);const i=C(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=D("disperse");e.behaviours.push({id:"disperse",type:"disperse",enabled:!0,params:{distance:G(3),axis:{type:m.String,value:"XZ"},ease:{type:m.String,value:"InOutSine"}}});const a=d(W(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(C(16)).not.toContain("disperseStates");const i=C(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=C(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(f({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=D("decal");e.output={type:"decal",space:"world",colorMap:"",alphaMap:"",color:"#ffffff",unlit:!0,intensity:1,blendingMode:"normal",bloom:!1};const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await u(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=D("shape");e.output={type:"shape",shape:"box",params:{},material:"shape-material",materialSource:"material",space:"world"};const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await u(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=w(new i({color:s(n(1),1)})),a=v(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=D("soft-sprite",_({softness:2})),a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const i=(await u(a.renderClasses,{getTexture:async()=>new o.Texture})).renderClasses[0].material,n=w(i);t(Object.values(n.sceneTextures)).toContain("sceneDepth")}),a("disables depth writes only for transparent custom GPU sprite materials",async()=>{const e=D("graph-sprite",_({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"graph"}})),a=d(W(e),[new o.Vector3]),r=new i({color:s(n(1),.5),transparent:!0}),l=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].material).toBe(r),t(r.depthWrite).toBe(!1),t(w(r).renderState.depthWrite).toBe(!1);const c=new i({color:s(n(1),1)}),p=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>c);t(p.renderClasses[0].material).toBe(c),t(c.depthWrite).toBe(!0),t(w(c).renderState.depthWrite).toBe(!0)}),a("supports local-space sprite render classes through the shared emitter transform table",async()=>{const e=D("local-sprite",_({space:"local"})),a=d(W(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 u(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=D("graph",_({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"missing-graph"}})),a=await y({asset:W(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=D("flipbook",_({flipbook:{enabled:!0,rows:4,columns:4,fps:12,mode:"loop"}})),a=d(W(e),[new o.Vector3]),i=await u(a.renderClasses,{getTexture:async()=>new o.Texture}),n=w(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=D("stretched-smoke");e.behaviours.push({id:"stretch",type:"scaleComponents",enabled:!0,params:{a:k([.25,1,1]),b:k([2,.5,1]),ease:{type:22,value:"Linear"}}});const a=d(W(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=D("input-driven",L());e.initializers.push({id:"scale",type:"scale",enabled:!0,params:{scale:{...G(1),binding:{type:"vfxInput",inputId:"scale-input"}}}},{id:"rotation",type:"rotation",enabled:!0,params:{rotation:{...k([0,-100,0]),binding:{type:"vfxInput",inputId:"rotation-input"}}}}),e.behaviours.push({id:"rotate",type:"rotate",enabled:!0,params:{rotation:{...k([0,-30,0]),binding:{type:"vfxInput",inputId:"step-input"}}}});const a=W(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=d(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{compileGpuSpriteEmitters as y,materializeGpuSpriteRenderClasses as f,selectVfxBackend as g}from"../effects/vfx/gpu-sprite-compiler.js";import{prepareGpuSpriteBackend as b}from"../effects/vfx/gpu-sprite-backend.js";import{calculateGpuSpriteOptionalStateLayout as w,GPU_TRAIL_RENDER_CLASS_BYTES as C,GpuSpriteDirtyRange as x,getCompiledGpuSpriteMaterial as v,getGpuSpriteComputeWgsl as B,getGpuSpriteMaterialWgsl as S,getGpuTrailMaterialWgsl as V}from"../effects/vfx/gpu-sprite-runtime.js";import{Trail as T,trailUV as M}from"../effects/vfx/trail-renderer.js";import{timeUniforms as I}from"../shader-nodes/time.js";import{GpuSpriteSchedulerRuntime as E}from"../effects/vfx/gpu-sprite-scheduler-runtime.js";import{GpuSpriteTemplateCache as P,instantiateGpuSpriteTemplate as z}from"../effects/vfx/gpu-sprite-template.js";import{GpuSpriteWorldRuntime as O}from"../effects/vfx/gpu-sprite-world-runtime.js";import{GpuSpriteRenderClassCache as j}from"../effects/vfx/gpu-sprite-compiler.js";import{addGpuSpriteRenderGraphPasses as q}from"../effects/vfx/gpu-sprite-render-graph.js";import{gpuVfxCapacityClass as A}from"../effects/vfx/gpu-vfx-capacity.js";import{buildParticleLightingShaderHeader as R}from"@hology/webgpu-renderer";import{sampleSceneColor as G}from"../shader-nodes/scene-sample.js";import{screenUV as k}from"../shader-nodes/depth.js";import{particleUniforms as L}from"../shader-nodes/particle.js";import{createGpuMeshAssetMaterial as _}from"../effects/vfx/vfx-gpu-material.js";const U=e=>({type:h.Number,value:e}),D=e=>({type:h.Vector3,value:e});function W(e={}){return{type:"sprite",texture:"dot",color:"#ffffff",intensity:1,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}function F(e={}){return{type:"mesh",assetId:"simple-mesh",space:"world",...e}}function N(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 Z(e,t=W()){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:U(2)}},{id:`${e}-position`,type:"positionSphere",enabled:!0,params:{position:D([0,0,0]),radius:U(1),surface:{type:h.Boolean,value:!1}}}],behaviours:[]}}function H(...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 _("material-id",{getAsset:async()=>({id:"material-id"}),getTexture:async()=>e,getMesh:async()=>new o.Mesh,getMaterial:async()=>s}),u=v(l),h=S(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=Z("bound-delay");e.rate.delayBinding={type:"vfxInput",inputId:"timing"};const a=H(e);a.vfx.inputs=[{id:"timing",name:"Timing",type:"number",defaultValue:.5}];const i=y(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=B(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=Z("parent");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{bounce:U(.6),friction:U(.2),lifeLoss:U(.5),debug:{type:h.Boolean,value:!0}}});const a=Z("child");a.rate={type:"once",count:3,delay:0},a.spawnEvent="collision",e.children=[a];const i=y(H(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=B(16);t(n).not.toContain("collisionDepth"),t(n).not.toContain("optionalStates"),t(n).not.toContain("triangleRecord"),t(n).not.toMatch(/__[A-Z_]+__/);const s=B(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=B(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=Z("parent",W({space:"local"})),a=Z("local-child",W({space:"local"})),i=Z("world-child",W({space:"world"}));e.children=[a,i];const n=y(H(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=B(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(s).not.toContain("parentTransform = mat4x4");const r=B(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=Z("parent",W({space:"world"}));e.children=[Z("child",W({space:"local"}))];const a=y(H(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=Z("parent",F({renderOrder:10})),a=Z("child",W({renderOrder:-10}));e.children=[a];const i=y(H(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(A)).toEqual([1,2,4,4,8,32])}),a("compiles stretched sprites into a velocity-aligned render class",async()=>{const e=y(H(Z("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=S(v(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=y(H(Z("trail",N({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(C).toBe(80);const i=B(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=y(H(Z("attached-trail",N({space:"local"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=B(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(M.x,M.y,L.opacity),L.opacity)});T.applyTrailShaderParameters(e);const a=V(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(I.elapsed),1)}),a=V(e),o=S(v(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=Z("legacy-color",N());e.behaviours.push({id:"color",type:"changeColor",enabled:!0,params:{a:{type:h.Color,value:"#918383"},b:{type:h.Color,value:"#4f4f4f"}}});const a=y(H(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=Z("mesh",F({space:"local"}));e.initializers.push({id:"mesh-scale-components",type:"scaleComponents",enabled:!0,params:{scale:D([1,2,3])}},{id:"mesh-rotation",type:"rotation",enabled:!0,params:{rotation:D([30,45,60])}}),e.behaviours.push({id:"mesh-scale-components-behaviour",type:"scaleComponents",enabled:!0,params:{a:D([2,1,1]),b:D([1,.5,2])}});const a=H(e),r=y(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=y(H(Z("unconfigured-mesh",F({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=y(H(Z("unselected-mesh-material",F({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=y(H(Z("unselected-mesh-graph",F({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(()=>v(a.renderClasses[0].material)).not.toThrow()}),a("gates custom mesh materials on a compatible GPU material resolver",async()=>{const e=y(H(Z("mesh-material",F({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=v(s.renderClasses[0].material);t(Object.values(r.textures)).toEqual([n])}),a("materializes non-billboard trails with orientation history",async()=>{const e=y(H(Z("oriented-trail",N({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=B(16,!0,!1,!1,!0);t(i).toContain("trailSampleOffset + 1u"),t(i).toContain("quaternionMultiply(particleRotation, movementRotation)");const n=V(T.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=y(H(Z("mesh",F({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:U(1)}}))),[new o.Vector3]),i=y(H(Z("mesh",F({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:U(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=y(H(Z("modern",F())),[new o.Vector3]),n=y(H(Z("legacy",F({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=H(Z("revisioned")),a={assets:{getTexture:async()=>new o.Texture},renderClassCache:new j},i=await(new P).resolve(e,{...a,revision:1}),n=await(new P).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:G(k)}),a=v(e);t(Object.values(a.sceneTextures)).toEqual(["sceneColor"])}),a("retains the shared renderer geometry semantics for lit particle materials",()=>{const e=v(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(R()).toContain("fn rendererCameraPosition()");const a=S(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;q({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 x;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=B(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=Z("random-life");e.initializers[0]={id:"random-life-range",type:"randomLifetime",enabled:!0,params:{minDuration:U(.5),maxDuration:U(2.5)}};const a=y(H(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=Z("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=y(H(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:D([1,2,3])},0],["positionSphere",{position:D([1,2,3]),radius:U(2),surface:{type:h.Boolean,value:!0}},1],["positionBox",{position:D([1,2,3]),dimensions:D([4,5,6])},2],["positionLine",{a:D([1,2,3]),b:D([4,5,6])},3],["positionRing",{position:D([1,2,3]),rotation:D([10,20,30]),radius:U(4),thickness:U(.5),angle:U(180)},4]];for(const[a,i,n]of e){const e=Z(`zone-${a}`);e.initializers[1]={id:`zone-${a}`,type:a,enabled:!0,params:i};const s=y(H(e),[new o.Vector3]);t(s.compatibility.compatible).toBe(!0),t(s.emitters[0].spawnZoneType).toBe(n)}const a=B(16);t(a).toContain("fn rotateSpawnPoint"),t(a).toContain("if (zoneType == 4u)")}),a("preserves legacy per-update sprite rotation behavior",()=>{const e=Z("sprite-rotate");e.behaviours.push({id:"rotate-add",type:"rotateAdd",enabled:!0,params:{rotation:D([0,0,200])}});const a=y(H(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=B(16);t(i).toContain("var rotation = particle.color.w;"),t(i).toContain("rotation += emitter.rotationInitialSpriteStep.w * frame.padding1"),e.output=F();const n=y(H(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=B(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=y(H(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=Z("radial-velocity");e.initializers.push({id:"radial",type:"radialVelocity",enabled:!0,params:{speed:U(4),direction:D([0,1,0]),spread:U(30)}});const a=y(H(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=B(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=Z("attraction");e.behaviours.push({id:"attract",type:"attraction",enabled:!0,params:{target:D([1,2,3]),force:U(4),radius:U(5)}});const a=y(H(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=y(H(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].attractionForce).toBe(-4);const n=B(16);t(n).toContain("attractionDistanceSq < attractionRadiusSq")}),a("compiles mesh alignment modes into spawn-only parameters",()=>{const e=Z("aligned-mesh",F());e.initializers.push({id:"align",type:"alignRotation",enabled:!0,params:{axis:{type:h.String,value:"+X"},mode:{type:h.String,value:"AlongVelocity"},offset:D([10,20,30]),center:D([1,2,3])}});const a=y(H(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=B(16,!0);t(i).toContain("fn quaternionFromUnitVectors"),t(i).toContain("alignRotationMode == 3u")}),a("groups authored render classes deterministically",()=>{const e=y(H(Z("a"),Z("b"),Z("c",W({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=H(Z("first"),Z("second",W({blendingMode:"normal"}))),a=[new o.Vector3,new o.Vector3(1,0,0)],i=y(e,a),n=y(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 b({asset:H(Z("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 E.create({asset:H(Z("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=B(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 b({asset:H(Z("additive"),Z("normal",W({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=B(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=Z("paged-instance");e.position=[2,0,0];const a=await E.create({asset:H(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 E.create({asset:H(Z("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 E.create({asset:H(Z("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=Z("delayed");e.rate={type:"once",count:2,delay:.25};const a=Z("finite");a.rate={type:"continuous",count:2,time:.1,delay:0,duration:1};const i=y(H(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=Z("unlimited");e.rate={type:"continuous",count:2,time:.1,delay:0,duration:0};const a=y(H(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=H(Z("input-test"));e.vfx.inputs=[{id:"speed",name:"Speed",type:"number",defaultValue:1}],t(y(e,[new o.Vector3]).compatibility.compatible).toBe(!0),e.vfx.emitters[0].initializers[0].params.duration.binding={type:"vfxInput",inputId:"speed"};const a=y(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(y(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=Z("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=H(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:2}];const i=y(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=H(Z("cached-template")),a=new P;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=z(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=Z("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=H(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 E.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=W(),a=await O.create({templates:[{asset:H(Z("world-a",e)),maximumInstances:2},{asset:H(Z("world-b",{...e})),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},templateCache:new P,renderClassCache:new j,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=Z("world-lifecycle");e.position=[2,0,0];const a=await O.create({templates:[{asset:H(e),maximumInstances:1},{asset:H(Z("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 O.create({templates:[{asset:H(Z("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=Z("soft-local",W({softness:1,space:"local",lockY:!0}));e.behaviours.push({id:"animation",type:"animation",enabled:!0,params:{}});const a=y(H(e),[new o.Vector3]),i=g(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(g(a.compatibility,"gpu").backend).toBe("gpu")}),a("compiles vortex with pay-for-play previous-velocity state",()=>{const e=Z("vortex");e.behaviours.push({id:"vortex",type:"vortex",enabled:!0,params:{axis:D([0,5,0]),amount:U(1.25)}});const a=y(H(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(B(16,!1,!1)).not.toContain("vortexVelocities");const i=B(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=Z("remaining-motion");e.initializers.push({id:"mass",type:"mass",enabled:!0,params:{min:U(2)}}),e.behaviours.push({id:"drift",type:"randomDrift",enabled:!0,params:{drift:D([1,2,3]),delay:U(.25),ease:{type:h.String,value:"OutQuad"}}},{id:"follow",type:"followParent",enabled:!0,params:{speed:U(4)}},{id:"move",type:"moveTo",enabled:!0,params:{target:D([5,6,7]),ease:{type:h.String,value:"InQuad"}}});const a=y(H(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=Z("easing");e.behaviours.push({id:"scale",type:"scale",enabled:!0,params:{a:U(0),b:U(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:U(0),b:U(1),ease:{type:h.String,value:"BumpSharp"}}});const a=y(H(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].curveEasing.toArray()).toEqual([18,4,20,0]);const i=B(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=Z("disperse");e.behaviours.push({id:"disperse",type:"disperse",enabled:!0,params:{distance:U(3),axis:{type:h.String,value:"XZ"},ease:{type:h.String,value:"InOutSine"}}});const a=y(H(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(B(16)).not.toContain("disperseStates");const i=B(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=B(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(w({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=Z("decal");e.output={type:"decal",space:"world",colorMap:"",alphaMap:"",color:"#ffffff",unlit:!0,intensity:1,blendingMode:"normal",bloom:!1};const a=y(H(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=Z("shape");e.output={type:"shape",shape:"box",params:{},material:"shape-material",materialSource:"material",space:"world"};const a=y(H(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=v(new i({color:s(n(1),1)})),a=S(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=Z("soft-sprite",W({softness:2})),a=y(H(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=v(i);t(Object.values(n.sceneTextures)).toContain("sceneDepth")}),a("disables depth writes only for transparent custom GPU sprite materials",async()=>{const e=Z("graph-sprite",W({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"graph"}})),a=y(H(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(v(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(v(c).renderState.depthWrite).toBe(!0)}),a("supports local-space sprite render classes through the shared emitter transform table",async()=>{const e=Z("local-sprite",W({space:"local"})),a=y(H(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=Z("graph",W({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"missing-graph"}})),a=await b({asset:H(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=Z("flipbook",W({flipbook:{enabled:!0,rows:4,columns:4,fps:12,mode:"loop"}})),a=y(H(e),[new o.Vector3]),i=await f(a.renderClasses,{getTexture:async()=>new o.Texture}),n=v(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=Z("stretched-smoke");e.behaviours.push({id:"stretch",type:"scaleComponents",enabled:!0,params:{a:D([.25,1,1]),b:D([2,.5,1]),ease:{type:22,value:"Linear"}}});const a=y(H(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=Z("input-driven",F());e.initializers.push({id:"scale",type:"scale",enabled:!0,params:{scale:{...U(1),binding:{type:"vfxInput",inputId:"scale-input"}}}},{id:"rotation",type:"rotation",enabled:!0,params:{rotation:{...D([0,-100,0]),binding:{type:"vfxInput",inputId:"rotation-input"}}}}),e.behaviours.push({id:"rotate",type:"rotate",enabled:!0,params:{rotation:{...D([0,-30,0]),binding:{type:"vfxInput",inputId:"step-input"}}}});const a=H(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=y(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{Subject as e}from"rxjs";import{Group as t,Object3D as n,Quaternion as
|
|
1
|
+
import{Subject as e}from"rxjs";import{Group as t,Object3D as n,Quaternion as o,Scene as s,Vector3 as a}from"three";import{describe as i,expect as r,it as c,vi as d}from"vitest";d.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&d.fn()),e[t]),set:(e,t,n)=>(e[t]=n,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),d.mock("../gameplay/actors/builtin/index.js",()=>({default:{}})),d.mock("../effects/vfx/vfx-actor.js",()=>({VfxActor:class{}})),d.mock("../effects/vfx/vfx-param.js",()=>({VisualEffect:class{}})),d.mock("../rendering.js",()=>({RenderingView:class{},setRenderingPaused:d.fn()}));import{PhysicsBodyType as f}from"../gameplay/services/physics/physics-system.js";import{SceneMaterializer as l}from"../scene/materializer.js";function p(t){const n={onCreate:new e,onDelete:new e,onUpdate:new e,getAsset:d.fn(async()=>null),getAssets:d.fn(async()=>[])};return new l(t,{getObjects:()=>[],onCreate:()=>{},onUpdate:()=>{},onRemove:()=>{}},n,{},{renderer:{shadowMap:{needsUpdate:!1}}},[],[],{create:async()=>null,initActor:async()=>{}})}function m(e){return{name:e.id,...e}}i("SceneMaterializer dynamic transforms",()=>{c("does not disable matrix updates on an actor root after an editor transform",()=>{const e=p(new s);e.inEditor=!0;const n=new t,o=n.updateMatrixWorld;e.beginEditorStaticSceneEdit([n]),e.endEditorStaticSceneEdit(),r(n.matrixAutoUpdate).toBe(!0),r(n.updateMatrixWorld).toBe(o)}),c("preserves a nested dynamic object world transform when moving it to the scene root",async()=>{const e=new s,i=p(e);i.inEditor=!1;const c=[.2,.7,-.1],d=[0,0,0],l=m({id:"parent",type:"group",position:[10,4,-7],rotation:c,scale:[2,3,4],children:[m({id:"dynamic-child",type:"shape_mesh",shape:"box",position:[3,-2,5],rotation:d,scale:[.5,1.5,2],physics:{type:f.dynamic}})]});await i.materialize(l);const u=e.getObjectByName("dynamic-child");r(u?.parent).toBe(e);const y=new t;y.position.set(10,4,-7),y.rotation.set(...c),y.scale.set(2,3,4);const g=new n;g.position.set(3,-2,5),g.rotation.set(...d),g.scale.set(.5,1.5,2),y.add(g);const h=g.getWorldPosition(new a),w=g.getWorldQuaternion(new o),x=g.getWorldScale(new a);r(u.position.distanceTo(h)).toBeLessThan(1e-6),r(1-Math.abs(u.quaternion.dot(w))).toBeLessThan(1e-6),r(u.scale.distanceTo(x)).toBeLessThan(1e-6)})});/*
|
|
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 o,vi as r}from"vitest";import*as n from"three";import{compileNodeShaderMaterialForWebGpu as a,float as s,NodeShaderMaterial as i,uniformFloat as l}from"../shader-nodes/index.js";import{depthUniformName as
|
|
1
|
+
import{describe as e,expect as t,it as o,vi as r}from"vitest";import*as n from"three";import{compileNodeShaderMaterialForWebGpu as a,float as s,NodeShaderMaterial as i,uniformFloat as l}from"../shader-nodes/index.js";import{depthUniformName as m,farUniformName as u,linearEyeDepth as f,nearUniformName as d,resolutionUniformName as p,screenUV as c,sceneNormalUniformName as h}from"../shader-nodes/depth.js";import{aoMapUniformName as g}from"../shader-nodes/scene-sample.js";import{elapsedTimeUniformName as T,time as v}from"../shader-nodes/time.js";import{sampleSceneColor as b}from"../shader-nodes/scene-sample.js";import{waterVolumeMask as w,waterVolumeWorldToLocalUniformName as A}from"../shader-nodes/water-volume.js";import{NodeMaterialWebGpuResolver as C}from"../rendering/node-material-webgpu-resolver.js";import{DecalStandardShader as y}from"../shader/builtin/decal-standard-shader.js";import{applyPostProcessEffectUniformState as B,defaultPostProcessEffectStage as P,PostProcessEffectPass as V,PostProcessEffectRegistration as x,postProcessEffectStages as S}from"../rendering/post-process-effect.js";import{createPostProcessMaterial as j,PostProcessShader as E}from"../shader/post-process-shader.js";r.hoisted(()=>{if("undefined"!=typeof window){if("undefined"!=typeof HTMLCanvasElement){const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&r.fn()),e[t]),set:(e,t,o)=>(e[t]=o,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e})}class e{constructor(){this.currentTime=0,this.destination={},this.listener={positionX:{linearRampToValueAtTime:r.fn(),setValueAtTime:r.fn()},positionY:{linearRampToValueAtTime:r.fn(),setValueAtTime:r.fn()},positionZ:{linearRampToValueAtTime:r.fn(),setValueAtTime:r.fn()},forwardX:{linearRampToValueAtTime:r.fn(),setValueAtTime:r.fn()},forwardY:{linearRampToValueAtTime:r.fn(),setValueAtTime:r.fn()},forwardZ:{linearRampToValueAtTime:r.fn(),setValueAtTime:r.fn()},upX:{linearRampToValueAtTime:r.fn(),setValueAtTime:r.fn()},upY:{linearRampToValueAtTime:r.fn(),setValueAtTime:r.fn()},upZ:{linearRampToValueAtTime:r.fn(),setValueAtTime:r.fn()}}}createGain(){return{connect:r.fn(),disconnect:r.fn(),gain:{value:1,setValueAtTime:r.fn()}}}}Object.defineProperty(window,"AudioContext",{configurable:!0,value:e}),Object.defineProperty(window,"webkitAudioContext",{configurable:!0,value:e})}});class _ extends E{output(){return s(1)}}e("post-process effect",()=>{o("exports stable stage names with a default late-stage slot",()=>{t(S).toEqual(["beforeFog","beforeDepthOfField","beforeColorAdjustment","beforeOutline","beforeAntiAliasing","beforeLut","beforeOutput"]),t(P).toBe("beforeOutput")}),o("builds post-process shaders with fullscreen-safe defaults",()=>{const e=(new _).build();t(e).toBeInstanceOf(i),t(e.depthWrite).toBe(!1),t(e.depthTest).toBe(!1),t(e.toneMapped).toBe(!1)}),o("updates shared renderer uniforms on post-process effect materials",()=>{const e=j(s(1));e.uniforms[p]={value:new n.Vector2},e.uniforms[m]={value:null},e.uniforms[h]={value:null},e.uniforms[d]={value:0},e.uniforms[u]={value:0},e.uniforms[T]={value:0},e.uniforms[g]={value:null};const o=new n.Texture,r=new n.Texture,a=new n.Texture,i=new n.Vector2(640,360);B(e,{aoEnabled:!0,aoTexture:a,cameraFar:500,cameraNear:.25,depthTexture:o,normalTexture:r,resolution:i,simulationTime:12.5}),t(e.uniforms[p].value.toArray()).toEqual([640,360]),t(e.uniforms[m].value).toBe(o),t(e.uniforms[h].value).toBe(r),t(e.uniforms[d].value).toBe(.25),t(e.uniforms[u].value).toBe(500),t(e.uniforms[T].value).toBe(12.5),t(e.uniforms[g].value).toBe(a),t(e.defines.USE_SSAO_MAP).toBe(""),B(e,{aoEnabled:!1,aoTexture:null,cameraFar:500,cameraNear:.25,depthTexture:o,normalTexture:r,resolution:i,simulationTime:13}),t(e.uniforms[g].value).toBeNull(),t(e.defines.USE_SSAO_MAP).toBeUndefined()}),o("compiles screen UV against the WebGPU fullscreen ABI",()=>{const e=j(b(c)),o=new C({}).compileFullscreen(e);t(o.fragmentShader).toContain("input.uv"),t(o.fragmentShader).not.toContain("rendererCamera.viewportAndTime"),t(o.fragmentShader).not.toContain("gl_FragCoord"),t(o.uniforms[p]).toBeUndefined();const r=a(e);t(r.fragmentShader).not.toContain("hologyFullscreenUv"),t(r.fragmentShader).toContain("input.position.xy"),t(r.fragmentShader).toContain("materialUniforms.hology_resolution"),t(r.fragmentShader).toContain("(input.position.xy / max(materialUniforms.hology_resolution, vec2<f32>(1.0, 1.0)))");const n=new C({}).compileFullscreen(j(v));t(n.fragmentShader).not.toContain("rendererElapsedTime"),t(n.uniforms[T]).toBeTruthy();const s=new C({}).compileFullscreen(j(f));t(s.fragmentShader).toContain("input.uv"),t(s.fragmentShader).toContain("textureSample(hology_depth_mapTexture, hology_depth_mapSampler,")}),o("uses runtime material uniform values in WebGPU fullscreen passes",()=>{const e="hology_post_process_weight",o=j(l(e,1)),r=new C({}).compileFullscreen(o);o.uniforms[e].value=.25,r.syncLiveUniforms(r.uniforms),t(r.uniforms[e].value).toBe(.25)}),o("compiles the depth-reconstructed water volume mask for WebGPU fullscreen passes",()=>{const e=j(w),o=new C({}).compileFullscreen(e);t(o.fragmentShader).toContain("hology_depth_map"),t(o.uniforms[A]).toBeTruthy(),t(o.fragmentShader).not.toContain("rendererViewMatrix"),t(o.fragmentShader).not.toContain("rendererProjectionMatrix")}),o("keeps WebGPU decal varyings within the portable location limit",()=>{const e=(new y).build();t(e).toBeInstanceOf(i);const o=a(e),r=[...o.vertexShader.matchAll(/@location\((\d+)\)/g)].map(e=>Number(e[1]));t(Math.max(...r)).toBeLessThan(16),t(o.vertexShader).not.toContain("undefined"),t(o.fragmentShader).not.toContain("undefined")}),o("keeps registration state shared with both renderer adapters",()=>{const e=j(s(1)),o=new V(e),n={id:7,material:e,pass:o,enabled:!0,priority:0,stage:"beforeOutput"},a={refreshPostProcessEffectPassOrder:r.fn(),removePostProcessEffectEntry:r.fn()},i=new x(a,n);t(i.material).toBe(e),i.enabled=!1,t(n.enabled).toBe(!1),t(o.enabled).toBe(!1),i.priority=3,i.stage="beforeFog",t(n.priority).toBe(3),t(n.stage).toBe("beforeFog"),t(a.refreshPostProcessEffectPassOrder).toHaveBeenCalledTimes(3),i.dispose(),i.dispose(),t(a.removePostProcessEffectEntry).toHaveBeenCalledTimes(1),o.dispose()}),o("exposes post-process effect registration through ViewController",async()=>{const e=j(s(1)),o={material:e,enabled:!1,priority:4,stage:"beforeColorAdjustment",dispose:r.fn()},a=r.fn().mockReturnValue(o),{ViewController:i}=await import("../gameplay/services/render.js"),l=new i({camera:new n.PerspectiveCamera,running:!1,paused:!1,onLoop:r.fn(),setCamera(e){this.camera=e},stop:r.fn(),addPostProcessEffect:a,addPostProcessVolume:r.fn(),removePostProcessVolume:r.fn()}).addPostProcessEffect(e,{enabled:!1,priority:4,stage:"beforeColorAdjustment"});t(a).toHaveBeenCalledWith(e,{enabled:!1,priority:4,stage:"beforeColorAdjustment"}),t(l).toBe(o)})});/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import{describe as e,expect as t,it as s,vi as o}from"vitest";import*as n from"three";import{createLightClipInstance as a,createLightTrack as r,createPropertyKeyframe as c,createPropertySubTrack as i,createSequenceData as p,createTransformKeyframe as f,createTransformSubTrack as l}from"../effects/sequence/sequence-data.js";import{SerializedParamType as m}from"../scene/model.js";o.mock("../gameplay/actors/builtin/index.js",()=>({builtInActors:{},default:{}})),o.mock("../gameplay/actors/index.js",()=>({CharacterAnimationComponent:class{},CharacterMovementComponent:class{}})),o.mock("../effects/vfx/vfx-param.js",()=>({VisualEffect:class{}})),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,s)=>(e[t]=s,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e})}),e("sequence light tracks",()=>{s("owns a point light whose lifetime, transform, and intensity are authored by the sequence",async()=>{const{SequencePlayer:e}=await import("../effects/sequence/sequence-player.js"),s=new e,o=new n.Scene;s.setWorld({scene:o});const u=r();u.color="#ff4000",u.clips.push(a(.25,.5));const y=l(),d=f(0);d.position=[1,2,3],y.keyframes.push(d),u.subTracks.push(y);const h=i({propertyPath:"intensity",propertyType:m.Number});h.keyframes.push(c(0,{type:m.Number,value:25})),u.subTracks.push(h),s.load({...p(),duration:1,tracks:[u]}),s.seek(.1),t(s.getActiveTrackObjects(u.id)[0].visible).toBe(!1),s.seek(.5);const[k]=s.getActiveTrackObjects(u.id);t(k).toBeInstanceOf(n.PointLight),t(k.position.toArray()).toEqual([1,2,3]),t(k.intensity).toBe(25),t(k.color.getHexString()).toBe("ff4000"),t(k.castShadow).toBe(!1),s.seek(.9),t(s.getActiveTrackObjects(u.id)[0].visible).toBe(!1),s.dispose()},15e3)});/*
|
|
2
|
+
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
|
+
* See the LICENSE.md file for details.
|
|
4
|
+
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import*as e from"three";import{describe as s,expect as a,it as t,vi as n}from"vitest";import{createSequenceData as c,createVfxClipInstance as r,createVfxTrack as o}from"../effects/sequence/sequence-data.js";import{SequencePlayer as l}from"../effects/sequence/sequence-player.js";function i(){return{object:new e.Object3D,restart:n.fn(),play:n.fn(),pause:n.fn(),previewAt:n.fn(),clearPreview:n.fn(),stop:n.fn(),getParticleCount:n.fn(()=>0)}}async function p(){await Promise.resolve(),await Promise.resolve()}n.mock("../gameplay/actors/builtin/index.js",()=>({builtInActors:{},default:{}})),n.mock("../gameplay/actors/index.js",()=>({CharacterAnimationComponent:class{},CharacterMovementComponent:class{}})),n.mock("../effects/vfx/vfx-actor.js",()=>({VfxActor:class{}})),n.mock("../effects/vfx/vfx-param.js",()=>({VisualEffect:class{}})),n.mock("../effects/vfx/vfx-service.js",()=>({VfxService:class{}})),n.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,s)=>(s in e||(e[s]=("string"!=typeof s||!s.startsWith("is"))&&n.fn()),e[s]),set:(e,s,a)=>(e[s]=a,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e})}),s("sequence VFX tracks",()=>{t("preloads unique VFX assets when preparing a sequence",async()=>{const s=new l,t=n.fn(async e=>{});s.setWorld({scene:new e.Scene,preloadVisualEffects:t}),s.setActorFactory({}),s.setAssetLoader({getAsset:n.fn(async e=>({id:e,vfx:{emitters:[]}}))});const i=c(),p=o();p.clips.push(r("vfx-asset",0,1),r("vfx-asset",1,1),r("other-vfx-asset",2,1)),i.tracks.push(p),s.load(i),await s.prepare(),a(t).toHaveBeenCalledTimes(1),a([...t.mock.calls[0][0]]).toHaveLength(2)}),t("creates and retains gameplay VFX actors while preparing a sequence",async()=>{const s=i(),t=n.fn(async()=>s),d=new l;d.setWorld({scene:new e.Scene,preloadVisualEffects:n.fn(async()=>{})}),d.setActorFactory({}),d.setAssetLoader({getAsset:n.fn(async e=>({id:e,vfx:{emitters:[]}}))}),d.setVfxService({createFromAssetId:t});const f=c(),v=o();v.clips.push(r("vfx-asset",0,1)),f.tracks.push(v),d.load(f),await d.prepare(),a(t).toHaveBeenCalledTimes(1),a(s.play).not.toHaveBeenCalled(),d.play(),await p(),a(t).toHaveBeenCalledTimes(1)}),t("restarts an already-active VFX clip when seeking backwards within the clip",async()=>{const s=i(),t=new l;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)});const d=c();d.duration=2;const f=o(),v=r("vfx-asset",0,1.5);f.clips.push(v),d.tracks.push(f),t.load(d),t.play(),await p(),t.seek(1),s.restart.mockClear(),s.play.mockClear(),t.seek(.25),a(s.restart).toHaveBeenCalledTimes(1),a(s.play).toHaveBeenCalledTimes(1)}),t("restarts active VFX clips when a looping sequence wraps to the start",async()=>{const s=i(),t=new l;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)});const d=c();d.duration=1,d.loop=!0;const f=o(),v=r("vfx-asset",0,1);f.clips.push(v),d.tracks.push(f),t.load(d),t.play(),await p(),s.restart.mockClear(),s.play.mockClear(),t.update(1.1),a(s.restart).toHaveBeenCalledTimes(1),a(s.play).toHaveBeenCalledTimes(1)}),t("does not replay a VFX while its sequence clip remains active",async()=>{const s=i(),t=new l;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)});const d=c();d.duration=1;const f=o();f.clips.push(r("vfx-asset",0,.8)),d.tracks.push(f),t.load(d),t.play(),await p(),a(s.play).toHaveBeenCalledTimes(1),t.update(.3),t.update(.3),a(s.play).toHaveBeenCalledTimes(1)}),t("clears VFX particles when seeking backwards before the clip starts",async()=>{const s=i(),t=new l;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)});const d=c();d.duration=2;const f=o(),v=r("vfx-asset",.5,1);f.clips.push(v),d.tracks.push(f),t.load(d),t.play(),t.seek(.75),await p(),s.restart.mockClear(),s.stop.mockClear(),s.pause.mockClear(),t.seek(.25),a(s.restart).toHaveBeenCalledTimes(1),a(s.stop).toHaveBeenCalled(),a(s.pause).toHaveBeenCalledTimes(1)}),t("rebuilds VFX at the playhead when an editor preview seeks while paused",async()=>{const s=i(),t=n.fn(async()=>s),d=new l;d.setWorld({scene:new e.Scene}),d.setVfxService({createFromAssetId:t}),d.externalTimeControl=!0,d.inEditor=!0;const f=c();f.duration=2;const v=o();v.clips.push(r("vfx-asset",.5,1)),f.tracks.push(v),d.load(f),d.seek(.75),await p(),a(t).toHaveBeenCalledWith("vfx-asset",a.any(e.Scene),{backend:"cpu"}),a(s.previewAt).toHaveBeenCalledWith(.25,1,"finish",.25),a(s.play).not.toHaveBeenCalled()}),t("keeps gameplay sequence VFX on the configured world backend",async()=>{const s=i(),t=n.fn(async()=>s),d=new l;d.setWorld({scene:new e.Scene}),d.setVfxService({createFromAssetId:t});const f=c();f.duration=1;const v=o();v.clips.push(r("vfx-asset",0,1)),f.tracks.push(v),d.load(f),d.play(),await p(),a(t).toHaveBeenCalledWith("vfx-asset",a.any(e.Scene),void 0)}),t("clears an editor VFX preview when seeking before its clip",async()=>{const s=i(),t=new l;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)}),t.externalTimeControl=!0,t.inEditor=!0;const d=c();d.duration=2;const f=o();f.clips.push(r("vfx-asset",.5,1)),d.tracks.push(f),t.load(d),t.seek(.75),await p(),s.clearPreview.mockClear(),t.seek(.25),a(s.clearPreview).toHaveBeenCalledTimes(1)})});/*
|
|
1
|
+
import*as e from"three";import{describe as s,expect as a,it as t,vi as n}from"vitest";import{createSequenceData as c,createVfxClipInstance as r,createVfxTrack as o}from"../effects/sequence/sequence-data.js";import{SequencePlayer as l}from"../effects/sequence/sequence-player.js";function i(){return{object:new e.Object3D,restart:n.fn(),play:n.fn(),pause:n.fn(),previewAt:n.fn(),clearPreview:n.fn(),stop:n.fn(),getParticleCount:n.fn(()=>0)}}async function p(){await Promise.resolve(),await Promise.resolve()}n.mock("../gameplay/actors/builtin/index.js",()=>({builtInActors:{},default:{}})),n.mock("../gameplay/actors/index.js",()=>({CharacterAnimationComponent:class{},CharacterMovementComponent:class{}})),n.mock("../effects/vfx/vfx-actor.js",()=>({VfxActor:class{}})),n.mock("../effects/vfx/vfx-param.js",()=>({VisualEffect:class{}})),n.mock("../effects/vfx/vfx-service.js",()=>({VfxService:class{}})),n.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,s)=>(s in e||(e[s]=("string"!=typeof s||!s.startsWith("is"))&&n.fn()),e[s]),set:(e,s,a)=>(e[s]=a,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e})}),s("sequence VFX tracks",()=>{t("preloads unique VFX assets when preparing a sequence",async()=>{const s=new l,t=n.fn(async e=>{});s.setWorld({scene:new e.Scene,preloadVisualEffects:t}),s.setActorFactory({}),s.setAssetLoader({getAsset:n.fn(async e=>({id:e,vfx:{emitters:[]}}))});const i=c(),p=o();p.clips.push(r("vfx-asset",0,1),r("vfx-asset",1,1),r("other-vfx-asset",2,1)),i.tracks.push(p),s.load(i),await s.prepare(),a(t).toHaveBeenCalledTimes(1),a([...t.mock.calls[0][0]]).toHaveLength(2)}),t("creates and retains gameplay VFX actors while preparing a sequence",async()=>{const s=i(),t=n.fn(async()=>s),d=new l;d.setWorld({scene:new e.Scene,preloadVisualEffects:n.fn(async()=>{})}),d.setActorFactory({}),d.setAssetLoader({getAsset:n.fn(async e=>({id:e,vfx:{emitters:[]}}))}),d.setVfxService({createFromAssetId:t});const f=c(),v=o();v.clips.push(r("vfx-asset",0,1)),f.tracks.push(v),d.load(f),await d.prepare(),a(t).toHaveBeenCalledTimes(1),a(s.play).not.toHaveBeenCalled(),d.play(),await p(),a(t).toHaveBeenCalledTimes(1)}),t("restarts an already-active VFX clip when seeking backwards within the clip",async()=>{const s=i(),t=new l;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)});const d=c();d.duration=2;const f=o(),v=r("vfx-asset",0,1.5);f.clips.push(v),d.tracks.push(f),t.load(d),t.play(),await p(),t.seek(1),s.restart.mockClear(),s.play.mockClear(),t.seek(.25),a(s.restart).toHaveBeenCalledTimes(1),a(s.play).toHaveBeenCalledTimes(1)}),t("restarts active VFX clips when a looping sequence wraps to the start",async()=>{const s=i(),t=new l;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)});const d=c();d.duration=1,d.loop=!0;const f=o(),v=r("vfx-asset",0,1);f.clips.push(v),d.tracks.push(f),t.load(d),t.play(),await p(),s.restart.mockClear(),s.play.mockClear(),t.update(1.1),a(s.restart).toHaveBeenCalledTimes(1),a(s.play).toHaveBeenCalledTimes(1)}),t("does not replay a VFX while its sequence clip remains active",async()=>{const s=i(),t=new l;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)});const d=c();d.duration=1;const f=o();f.clips.push(r("vfx-asset",0,.8)),d.tracks.push(f),t.load(d),t.play(),await p(),a(s.play).toHaveBeenCalledTimes(1),t.update(.3),t.update(.3),a(s.play).toHaveBeenCalledTimes(1)}),t("clears VFX particles when seeking backwards before the clip starts",async()=>{const s=i(),t=new l;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)});const d=c();d.duration=2;const f=o(),v=r("vfx-asset",.5,1);f.clips.push(v),d.tracks.push(f),t.load(d),t.play(),t.seek(.75),await p(),s.restart.mockClear(),s.stop.mockClear(),s.pause.mockClear(),t.seek(.25),a(s.restart).toHaveBeenCalledTimes(1),a(s.stop).toHaveBeenCalled(),a(s.pause).toHaveBeenCalledTimes(1)}),t("rebuilds VFX at the playhead when an editor preview seeks while paused",async()=>{const s=i(),t=n.fn(async()=>s),d=new l;d.setWorld({scene:new e.Scene}),d.setVfxService({createFromAssetId:t}),d.externalTimeControl=!0,d.inEditor=!0;const f=c();f.duration=2;const v=o();v.clips.push(r("vfx-asset",.5,1)),f.tracks.push(v),d.load(f),d.seek(.75),await p(),a(t).toHaveBeenCalledWith("vfx-asset",a.any(e.Scene),{backend:"cpu"}),a(s.previewAt).toHaveBeenCalledWith(.25,1,"finish",.25,a.any(Number)),a(s.play).not.toHaveBeenCalled()}),t("keeps gameplay sequence VFX on the configured world backend",async()=>{const s=i(),t=n.fn(async()=>s),d=new l;d.setWorld({scene:new e.Scene}),d.setVfxService({createFromAssetId:t});const f=c();f.duration=1;const v=o();v.clips.push(r("vfx-asset",0,1)),f.tracks.push(v),d.load(f),d.play(),await p(),a(t).toHaveBeenCalledWith("vfx-asset",a.any(e.Scene),void 0)}),t("clears an editor VFX preview when seeking before its clip",async()=>{const s=i(),t=new l;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)}),t.externalTimeControl=!0,t.inEditor=!0;const d=c();d.duration=2;const f=o();f.clips.push(r("vfx-asset",.5,1)),d.tracks.push(f),t.load(d),t.seek(.75),await p(),s.clearPreview.mockClear(),t.seek(.25),a(s.clearPreview).toHaveBeenCalledTimes(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{describe as
|
|
1
|
+
import{describe as e,expect as t,it as n,vi as o}from"vitest";import{Vector3 as s}from"three";import{SplineComponent as i,packSplineData as a}from"../gameplay/actors/builtin/components/spline-component.js";import{extractShaderParameters as r}from"../shader/parameter.js";function p(e=!1){const t=new i;return t.setData(a([{position:new s(0,0,0),inTangent:new s,outTangent:new s(10/3,0,0)},{position:new s(10,0,0),inTangent:new s(-10/3,0,0),outTangent:new s}],e),!1),t}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}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),e("SplineComponent",()=>{n("samples cubic position and tangent",()=>{const e=p();t(e.samplePosition(.5).x).toBeCloseTo(5),t(e.sampleTangent(.5).toArray()).toEqual([1,0,0])}),n("builds and uses an arc-length approximation",()=>{const e=p();t(e.length).toBeCloseTo(10),t(e.samplePositionAtDistance(2.5).x).toBeCloseTo(2.5,2)}),n("finds the closest point and distance along the spline",()=>{const e=p().closestPoint(new s(4,3,0));t(e.position.x).toBeCloseTo(4,2),t(e.distance).toBeCloseTo(3,2),t(e.distanceAlongSpline).toBeCloseTo(4,2)}),n("round trips compact point data",()=>{const e=p(!0),n=new i;n.setData(e.splineData,!1),t(n.closed).toBe(!0),t(n.points[1].inTangent.x).toBeCloseTo(-10/3),t(e.splineData).not.toContain("\n")}),n("declares spline data as a string editor parameter",()=>{const e=r(i).find(e=>"splineData"===e.name);t(e?.type).toBe(String)}),n("inserts a point without changing the Bezier shape",()=>{const e=p(),n=Array.from({length:9},(t,n)=>e.samplePosition(n/8).clone());t(e.insertPointOnSegment(0)).toBe(1),t(e.points).toHaveLength(3),n.forEach((n,o)=>{t(e.samplePosition(o/8).distanceTo(n)).toBeLessThan(1e-5)})})});/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import{describe as a,expect as e,it as t}from"vitest";import{hashVfxPreviewSeed as o,withSeededRandom as r}from"../effects/vfx/seeded-random.js";a("seeded CPU VFX preview randomness",()=>{t("replays the same random stream for the same clip seed",()=>{const a=o("track-clip-asset"),t=()=>r(a,()=>[Math.random(),Math.random(),Math.random()]);e(t()).toEqual(t())}),t("restores Math.random after preview simulation",()=>{const a=Math.random;r(42,()=>Math.random()),e(Math.random).toBe(a)}),t("restores Math.random when preview simulation throws",()=>{const a=Math.random;e(()=>r(42,()=>{throw new Error("simulation failed")})).toThrow("simulation failed"),e(Math.random).toBe(a)})});/*
|
|
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 n,vi as o}from"vitest";import{Box3 as i,Vector3 as
|
|
1
|
+
import{describe as e,expect as t,it as n,vi as o}from"vitest";import{Box3 as a,ShaderMaterial as i,Vector3 as s}from"three";import{NavigationArea as r}from"../gameplay/ai/navigation-areas.js";import{createWaterSplineSurfaceGeometry as l,WaterSplineVolume as c}from"../gameplay/actors/builtin/water-spline-volume-actor.js";import{packSplineData as u}from"../gameplay/actors/builtin/components/spline-component.js";function p(e,t){e.spline.setData(u([{position:new s,inTangent:new s,outTangent:new s(t/3,0,0)},{position:new s(t,0,0),inTangent:new s(-t/3,0,0),outTangent:new s}],!1),!1)}o.hoisted(()=>{if("undefined"!=typeof HTMLCanvasElement){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})}"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),e("WaterSplineVolume",()=>{n("creates water navigation prisms centered below the spline surface",()=>{const e=new c;e.width=4,e.depth=3,p(e,10);const n=e.getNavigationAreaModifiers();t(n).toHaveLength(20),t(n.every(e=>e.area===r.Water)).toBe(!0),t(n[0].vertices[0]).toEqual([0,0,2]),t(n[0].vertices[1]).toEqual([0,0,-2]),t(n[0].minHeight).toBe(-3),t(n[0].maxHeight).toBe(0)}),n("rebuilds the editor visualization when the spline changes",()=>{const e=new c;e.width=2,e.depth=1,p(e,2);const n=Object.values(e).find(e=>"WaterSplineVolumeEditorComponent"===e?.constructor?.name);n.onInit();const o=[...n.visual.children],i=(new a).setFromObject(n.visual);t(i.max.x).toBeCloseTo(2),p(e,6),e.spline.changed.next(e.spline),i.setFromObject(n.visual),t(i.max.x).toBeCloseTo(6),t(i.min.y).toBeCloseTo(-1),t(n.visual.children).toEqual(o),t(o.every(e=>!1===e.frustumCulled)).toBe(!0)}),n("clamps offset joins at sharp turns",()=>{const e=new c;e.width=10,e.depth=1,e.spline.setData(u([{position:new s(-1,0,0),inTangent:new s,outTangent:new s(1/3,0,0)},{position:new s(0,0,0),inTangent:new s(-1/3,0,0),outTangent:new s(0,0,1/3)},{position:new s(0,0,1),inTangent:new s(0,0,-1/3),outTangent:new s}],!1),!1);const n=Object.values(e).find(e=>"WaterSplineVolumeEditorComponent"===e?.constructor?.name);n.onInit();const o=(new a).setFromObject(n.visual);t(o.min.x).toBeGreaterThanOrEqual(-1-e.width*g/2),t(o.max.x).toBeLessThanOrEqual(e.width*g/2),t(o.min.z).toBeGreaterThanOrEqual(-e.width*g/2),t(o.max.z).toBeLessThanOrEqual(1+e.width*g/2);const i=l(e.spline,e.width,.1,1),r=i.getAttribute("position"),p=i.getIndex();for(let e=0;e<p.count;e+=3){const n=p.getX(e),o=p.getX(e+1),a=p.getX(e+2),i=(r.getX(o)-r.getX(n))*(r.getZ(a)-r.getZ(n))-(r.getZ(o)-r.getZ(n))*(r.getX(a)-r.getX(n));t(i).toBeLessThanOrEqual(1e-6)}}),n("creates tessellated surface data for water shaders",()=>{const e=new c;p(e,10);const n=l(e.spline,4,2,2),o=n.getAttribute("position").count;t(o).toBeGreaterThan(0),t(n.getAttribute("uv").count).toBe(o),t(n.getAttribute("waterFlowDirection").count).toBe(o),t(Math.max(...Array.from({length:o},(e,t)=>n.getAttribute("waterDistanceFromBank").getX(t)))).toBeGreaterThan(0),t(Math.max(...Array.from({length:o},(e,t)=>n.getAttribute("waterDistanceAlongSpline").getX(t)))).toBeCloseTo(10)}),n("classifies points inside the authored spline prism",()=>{const e=new c;e.width=4,e.depth=3,p(e,10),t(e.containsWaterPoint(new s(5,-1,0))).toBe(!0),t(e.containsWaterPoint(new s(5,1,0))).toBe(!1),t(e.containsWaterPoint(new s(5,-4,0))).toBe(!1),t(e.containsWaterPoint(new s(5,-1,3))).toBe(!1)}),n("applies underwater graph changes without recreating the actor",()=>{const e=new c,n=new i;t(e.underwater.applySceneParameters({postProcessMaterial:n},new Set(["postProcessMaterial"]))).toBe(!0),t(e.underwater.postProcessMaterial).toBe(n)})});const g=1.5;/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hology/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.249",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -203,7 +203,7 @@
|
|
|
203
203
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
204
204
|
"peerDependencies": {
|
|
205
205
|
"three": "0.169.0",
|
|
206
|
-
"@hology/webgpu-renderer": "^0.1.
|
|
206
|
+
"@hology/webgpu-renderer": "^0.1.12"
|
|
207
207
|
},
|
|
208
208
|
"peerDependenciesMeta": {
|
|
209
209
|
"@hology/webgpu-renderer": {
|
|
@@ -239,6 +239,6 @@
|
|
|
239
239
|
"terser": "^5.25.0",
|
|
240
240
|
"typescript": "~5.8.2",
|
|
241
241
|
"vitest": "^4.1.9",
|
|
242
|
-
"@hology/webgpu-renderer": "^0.1.
|
|
242
|
+
"@hology/webgpu-renderer": "^0.1.12"
|
|
243
243
|
}
|
|
244
244
|
}
|