@hology/core 0.0.242 → 0.0.244
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/vfx/gpu-sprite-runtime.js +1 -1
- package/dist/effects/vfx/gpu-sprite-scheduler-runtime.js +1 -1
- package/dist/effects/vfx/gpu-sprite-world-runtime.js +1 -1
- package/dist/effects/vfx/initializsers.d.ts +7 -1
- package/dist/effects/vfx/initializsers.js +1 -1
- package/dist/effects/vfx/vfx-materializer.js +1 -1
- package/dist/rendering/node-material-webgpu-resolver.d.ts +5 -3
- package/dist/rendering/node-material-webgpu-resolver.js +1 -1
- package/dist/scene/materializer.js +1 -1
- package/dist/scene/model.d.ts +2 -0
- package/dist/scene/storage/storage.d.ts +7 -0
- package/dist/scene/storage/storage.js +1 -1
- package/dist/shader/graph/compiler.d.ts +35 -0
- package/dist/shader/graph/compiler.js +1 -1
- package/dist/shader/graph/model.d.ts +3 -3
- package/dist/shader/graph/registry.js +1 -1
- package/dist/test/gpu-sprite-compiler.test.js +1 -1
- package/dist/test/node-material-webgpu-resolver.test.js +1 -1
- package/dist/test/shader-graph.test.js +1 -1
- package/dist/test/vfx-world-space-emitter-rotation.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 c,varyingAttributes as l,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 g,GPU_TRAIL_RENDER_CLASS_BYTES as f,GpuSpriteDirtyRange as b,getCompiledGpuSpriteMaterial as w,getGpuSpriteComputeWgsl as C,getGpuSpriteMaterialWgsl as x,getGpuTrailMaterialWgsl as v}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 j}from"../effects/vfx/gpu-vfx-capacity.js";import{buildParticleLightingShaderHeader as q}from"@hology/webgpu-renderer";import{sampleSceneColor as A}from"../shader-nodes/scene-sample.js";import{screenUV as O}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 L(e={}){return{type:"sprite",texture:"dot",color:"#ffffff",intensity:1,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}function U(e={}){return{type:"mesh",assetId:"simple-mesh",space:"world",...e}}function _(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=L()){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("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.toMatch(/__[A-Z_]+__/);const s=C(16,!1,!1,!1,!1,!1,!1,0,0,!0,!0,32,1);t(s).toContain("collisionDepth"),t(s).toContain("collisionStep <= 6u"),t(s).toContain("approachingVisibleSurface"),t(s).toContain("optionalStates[32u + owner.padding]"),t(s).not.toMatch(/__[A-Z_]+__/)}),a("supports local-space parent particles spawning local or world-space children",()=>{const e=D("parent",L({space:"local"})),a=D("local-child",L({space:"local"})),i=D("world-child",L({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",L({space:"world"}));e.children=[D("child",L({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",U({renderOrder:10})),a=D("child",L({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(j)).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=x(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",_({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(f).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",_({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=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(p(V.elapsed),1)}),a=v(e),o=x(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",_());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",U({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 c=new o.Mesh(new o.BoxGeometry(1,1,1),new o.MeshBasicMaterial({color:3368601,opacity:.75})),l=await u(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>c});t(l.renderClasses[0].meshVertices?.length).toBe(288),t(l.renderClasses[0].meshColor?.getHex()).toBe(3368601),t(l.renderClasses[0].meshOpacity).toBe(.75),t(l.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()=>c},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",U({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",U({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",U({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",U({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:c(n).sample(l.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",_({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=v(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",U({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:G(1)}}))),[new o.Vector3]),i=d(W(D("mesh",U({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",U())),[new o.Vector3]),n=d(W(D("legacy",U({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),c=await u(n.renderClasses,s);t(r.renderClasses[0].meshVertices?.[0]).toBe(3),t(c.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:A(O)}),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(q()).toContain("fn rendererCameraPosition()");const a=x(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=U();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",U());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",L({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",L({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",L({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 c=I(s.template,[new o.Vector3(1,0,0),new o.Vector3(2,0,0)]);t(c[0].position).not.toBe(c[1].position),c[0].position.x=100,t(c[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=L(),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",L({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(g({capacity:16,particleTransforms:!0,vortex:!0,disperse:!0,trailHistoryEntries:20})).toEqual({vortexOffset:48,disperseOffset:64,collisionEventOffset:80,trailOffset:80,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)}),c=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(c.renderClasses[0].meshVertices?.length).toBe(288),t(c.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)}),c=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(c.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=x(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",L({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",L({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"graph"}})),a=d(W(e),[new o.Vector3]),r=new i({color:s(n(1),.5),transparent:!0}),c=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(c.renderClasses[0].material).toBe(r),t(r.depthWrite).toBe(!1),t(w(r).renderState.depthWrite).toBe(!1);const l=new i({color:s(n(1),1)}),p=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>l);t(p.renderClasses[0].material).toBe(l),t(l.depthWrite).toBe(!0),t(w(l).renderState.depthWrite).toBe(!0)}),a("supports local-space sprite render classes through the shared emitter transform table",async()=>{const e=D("local-sprite",L({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",L({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",L({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",U());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,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 g,GPU_TRAIL_RENDER_CLASS_BYTES as f,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 j}from"../effects/vfx/gpu-vfx-capacity.js";import{buildParticleLightingShaderHeader as q}from"@hology/webgpu-renderer";import{sampleSceneColor as O}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 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 = worldSpawnDirection(emitter, zoneOffset)"),t(e).toContain("let initialVelocity = worldSpawnDirection(emitter, localInitialVelocity)"),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.toMatch(/__[A-Z_]+__/);const s=C(16,!1,!1,!1,!1,!1,!1,0,0,!0,!0,32,1);t(s).toContain("collisionDepth"),t(s).toContain("collisionStep <= 6u"),t(s).toContain("approachingVisibleSurface"),t(s).toContain("optionalStates[32u + owner.padding]"),t(s).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(j)).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(f).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:O(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(q()).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(g({capacity:16,particleTransforms:!0,vortex:!0,disperse:!0,trailHistoryEntries:20})).toEqual({vortexOffset:48,disperseOffset:64,collisionEventOffset:80,trailOffset:80,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"])})});/*
|
|
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}from"vitest";import{DataTexture as a,Material as r,MeshStandardMaterial as n,RGBAFormat as s,SpriteMaterial as i,Vector4 as l}from"three";import{batchingUniformVec4 as d,float as h,NodeShaderMaterial as p,rgb as u,rgba as m,standardMaterial as c,translateX as f,uniformFloat as v,uniformVec4 as S,uniformSampler2d as g,varyingAttributes as w,vec3 as x,vec4 as y}from"three-shader-graph";import{createNodeMaterialSignature as C,markMaterialUniformValuesNeedUpdate as B,NodeMaterialWebGpuResolver as M,setMaterialUniformValuesAutoUpdate as T,withThreeDefaultVertexAttributeValues as W}from"../rendering/node-material-webgpu-resolver.js";import{LandscapeCompositeShader as L}from"../shader/builtin/landscape-composite-shader.js";import{DecalUnlitShader as b}from"../shader/builtin/decal-unlit-shader.js";import{getSpritePosition as _}from"../shader/sprite-shader.js";import{depthNormal as G,depthWorldPosition as U,fragmentLinearEyeDepth as A,sampleSceneDepth as V,screenUV as j}from"../shader-nodes/depth.js";import{sampleSceneColor as E}from"../shader-nodes/scene-sample.js";import{applyDithering as F}from"../shader-nodes/dither.js";import{parallaxOcclusionMapping as O}from"../shader-nodes/pom.js";import{elapsedTimeUniformName as q}from"../shader-nodes/time.js";import{Trail as I}from"../effects/vfx/trail-renderer.js";import{particleUniforms as D}from"../shader-nodes/particle.js";class P extends r{set uniformValuesNeedsUpdate(e){e&&this.uniformValuesAutoVersion++}constructor(e){super(),this.disposed=!1,this.uniformValuesAutoUpdate=!0,this.uniformValuesAutoVersion=0,this.vertexShader=e.vertexShader,this.fragmentShader=e.fragmentShader,this.depthVertexShader=e.depthVertexShader??null,this.depthFragmentShader=e.depthFragmentShader??null,this.pointShadowVertexShader=e.pointShadowVertexShader??null,this.pointShadowFragmentShader=e.pointShadowFragmentShader??null,this.uniforms=e.uniforms,this.sceneTextures=e.sceneTextures??{},this.graphTextures=e.graphTextures??{},this.vertexAttributes=e.vertexAttributes??[],this.transparent=e.transparent??!1,this.alphaTest=e.alphaTest??0,this.depthTest=e.depthTest??!0,this.depthWrite=e.depthWrite??!0,this.side=e.side??this.side}dispose(){this.disposed=!0,super.dispose()}}e("NodeMaterialWebGpuResolver",()=>{o("compiles decal surface rejection without undefined WGSL expressions",()=>{const e=(new b).build(),o=new M({WGSLShaderMaterial:P}).resolve(e);t(o?.fragmentShader).not.toContain("undefined"),t(o?.fragmentShader).toContain("hology_scene_mapTexture")}),o("recompiles an opaque variant when receiveDecals changes",()=>{const e=new p({color:m(16777215)});e.userData.receiveDecals=!0;const o=new M({WGSLShaderMaterial:P},e=>[m(16777215,!1===e.userData.receiveDecals?0:2)]),a=o.resolve(e,"opaque");e.userData.receiveDecals=!1,e.needsUpdate=!0;const r=o.resolve(e,"opaque");t(r).not.toBe(a),t(a.disposed).toBe(!0),t(a.fragmentShader).not.toBe(r.fragmentShader)}),o("maps Hology elapsed time to the renderer frame clock",()=>{const e=v(q),o=new p({color:m(x(e)),transform:f(e)}),a=new M({WGSLShaderMaterial:P}).resolve(o);t(a?.vertexShader).toContain("rendererElapsedTime()"),t(a?.fragmentShader).toContain("rendererElapsedTime()"),t(a?.depthVertexShader).toContain("rendererElapsedTime()"),t(a?.pointShadowVertexShader).toContain("rendererElapsedTime()"),t(a?.uniforms).not.toHaveProperty(q)}),o("compiles the native sprite billboard position without a GLSL fallback",()=>{const e=new p({color:m(16777215),position:_(v("rotation",.25),v("screenSpaceSize",0),v("lockYAxis",1))}),o=new M({WGSLShaderMaterial:P}).resolve(e);t(o).toBeInstanceOf(P),t(o?.vertexShader).toContain("cos("),t(o?.vertexShader).toContain("sin("),t(o?.vertexShader).toContain("materialUniforms.lockYAxis"),t(o?.vertexShader).not.toContain("customFunction_")}),o("uses the per-instance rotation attribute for instanced sprites",()=>{const e=new p({color:m(16777215),position:_(v("rotation",0),void 0,void 0,!0)}),o=new M({WGSLShaderMaterial:P}).resolve(e);t(o?.vertexAttributes).toEqual(t.arrayContaining([t.objectContaining({name:"rotation",format:"float32",stepMode:"instance"})])),t(o?.vertexShader).toContain("input.rotation"),t(o?.vertexShader).not.toContain("materialUniforms.rotation")}),o("uses per-instance particle opacity in WebGPU fragment shaders",()=>{const e=new p({color:m(u(16777215),D.opacity)}),o=new M({WGSLShaderMaterial:P}).resolve(e);t(o?.vertexAttributes).toEqual(t.arrayContaining([t.objectContaining({name:"particleData",format:"float32x3",stepMode:"instance"})])),t(o?.fragmentShader).toContain(".x"),t(o?.fragmentShader).toContain("> 0.5")}),o("maps THREE.SpriteMaterial to the native sprite shader",()=>{const e=new a(new Uint8Array([255,255,255,255]),1,1,s),o=new i({map:e,color:8429760,opacity:.6,rotation:.25,sizeAttenuation:!1,alphaTest:.2,transparent:!0}),r=new M({WGSLShaderMaterial:P}),n=r.resolve(o);t(n).toBeInstanceOf(P),t(n?.vertexShader).toContain("materialUniforms.rotation"),t(n?.vertexShader).toContain("materialUniforms.screenSpaceSize"),t(n?.fragmentShader).toContain("textureSample("),t(n?.alphaTest).toBe(.2),t(n?.transparent).toBe(!0),t(n?.uniforms.rotation.value).toBe(.25),t(n?.uniforms.screenSpaceSize.value).toBe(1),o.rotation=.75;const l=r.resolve(o);t(l).toBe(n),t(l?.uniforms.rotation.value).toBe(.75)}),o("memoizes node-material signatures for repeated lookups",()=>{const e=["version","vertexShader","fragmentShader","alphaTest","alphaToCoverage","blending","colorWrite","depthFunc","depthTest","depthWrite","polygonOffset","polygonOffsetFactor","polygonOffsetUnits","premultipliedAlpha","side","stencilWrite","transparent"],o=new Map([["version",1],["vertexShader","#vertex"],["fragmentShader","#fragment"],["alphaTest",0],["alphaToCoverage",!1],["blending",1],["colorWrite",!0],["depthFunc",3],["depthTest",!0],["depthWrite",!0],["polygonOffset",!1],["polygonOffsetFactor",0],["polygonOffsetUnits",0],["premultipliedAlpha",!1],["side",0],["stencilWrite",!1],["transparent",!1]]);let a=0;const r=new Proxy({},{get:(t,r)=>"string"==typeof r&&e.includes(r)?(a+=1,o.get(r)):Reflect.get(t,r)});C(r),C(r),t(a).toBe(e.length)}),o("adapts a NodeShaderMaterial without replacing its scene material",()=>{const e=v("intensity",.75),o=new p({color:c({color:u(5210052).multiplyScalar(e),roughness:.42,metalness:.18}),transparent:!0,alphaTest:.25}),a=new M({WGSLShaderMaterial:P}),r=a.resolve(o);t(r).toBeInstanceOf(P),t(r).not.toBe(o),t(r?.transparent).toBe(!0),t(r?.alphaTest).toBe(.25),t(r?.fragmentShader).toContain("rendererPointLightForLight"),t(o.isShaderMaterial).toBe(!0),e.value=.25,t(a.resolve(o)).toBe(r),t(r?.uniforms.intensity?.value).toBe(.25)}),o("syncs direct NodeShaderMaterial uniform mutations to the resolved material",()=>{const e=S("trailTint",new l(0,0,0,0)),o=v("hology_particle_energy",1),a=new p({color:e.multiplyScalar(o)}),r=new M({WGSLShaderMaterial:P});a.uniforms.trailTint.value=new l(1,.25,.5,.75),a.uniforms.hology_particle_energy.value=.4;const n=r.resolve(a);t(n?.uniforms.trailTint.value).toEqual(new l(1,.25,.5,.75)),t(n?.uniforms.hology_particle_energy.value).toBe(.4),a.uniforms.trailTint.value.set(.2,.4,.6,.8),a.uniforms.hology_particle_energy.value=.1,t(r.resolve(a)).toBe(n),t(n?.uniforms.trailTint.value).toEqual(new l(.2,.4,.6,.8)),t(n?.uniforms.hology_particle_energy.value).toBe(.1)}),o("only syncs explicitly invalidated uniforms when auto update is disabled",()=>{const e=v("intensity",.75),o=new p({color:m(x(e))});o.uniformValuesAutoUpdate=!1;const a=new M({WGSLShaderMaterial:P}),r=a.resolve(o),n=r.uniformValuesAutoVersion;e.value=.25,t(a.resolve(o)).toBe(r),t(r?.uniforms.intensity.value).toBe(.75),t(r?.uniformValuesAutoVersion).toBe(n),o.uniformValuesNeedsUpdate=!0,t(a.resolve(o)).toBe(r),t(r?.uniforms.intensity.value).toBe(.25),t(r?.uniformValuesAutoVersion).toBe(n+1)}),o("supports explicit invalidation for mapped Three.js materials",()=>{const e=new n({roughness:.8});T(e,!1);const o=new M({WGSLShaderMaterial:P}),a=o.resolve(e);e.roughness=.2,t(o.resolve(e)).toBe(a),t(a?.uniforms.roughness.value).toBe(.8),B(e),t(o.resolve(e)).toBe(a),t(a?.uniforms.roughness.value).toBe(.2)}),o("binds uniforms referenced only by an MRT material output",()=>{const e=v("roughness",.7),o=new p({color:m(5601177),alphaTest:.1});o.outputRoughness=e;const a=new M({WGSLShaderMaterial:P},e=>[e.outputColor,y(x(0,0,1),e.outputRoughness)]).resolve(o);t(a?.fragmentShader).toContain("materialUniforms.roughness"),t(a?.fragmentShader).not.toContain("graphColor"),t(a?.fragmentShader).toMatch(/if \(.+ < 0\.1\) \{ discard; \}/),t(a?.uniforms.roughness?.value).toBe(.7)}),o("compiles a landscape composite built from node material semantic outputs",()=>{const e=new M({WGSLShaderMaterial:P}),o=new p({color:c({color:u(5147450),roughness:.82,metalness:0})}),a=new p({color:c({color:u(7432286),roughness:.6,metalness:.05})}),r=new L;r.layers=[o,a];const n=e.resolve(r.build());t(n).toBeInstanceOf(P),t(n?.fragmentShader).toContain("rendererDirectionalLightForLight"),t(n?.vertexShader).toContain("@location(6) material: vec4<f32>"),t(n?.vertexShader).toContain("@location(8) hole: f32")}),o("allocates the trail's authored vertex attributes",()=>{const e=new M({WGSLShaderMaterial:P}).resolve(I.createBaseMaterial());t(e).toBeInstanceOf(P),t(e?.vertexAttributes).toEqual(t.arrayContaining([t.objectContaining({name:"nodeID",shaderLocation:6,format:"float32"}),t.objectContaining({name:"nodeCenter",shaderLocation:7,format:"float32x3"})]))}),o("uses Three's default value when a geometry has no uv1 attribute",()=>{const e=W([{name:"uv1",shaderLocation:6,format:"float32x2"},{name:"custom",shaderLocation:7,format:"float32"}]);t(e).toEqual(t.arrayContaining([t.objectContaining({name:"uv1",defaultValue:[0,0]}),t.objectContaining({name:"custom"})])),t(e[1]).not.toHaveProperty("defaultValue")}),o("compiles the foliage transform and named texture-uniform pattern",()=>{const e=new M({WGSLShaderMaterial:P}),o=new a(new Uint8Array([255,255,255,255]),1,1,s),r=new p({color:g("map").sample(w.uv),transform:f(v("windOffset",.25)),uniforms:{map:{value:o}},alphaTest:.5}),n=e.resolve(r);t(n).toBeInstanceOf(P),t(n?.vertexShader).toContain("rendererClipPosition(_ng_localPosition"),t(n?.depthVertexShader).toContain("materialUniforms.windOffset"),t(n?.depthFragmentShader).toContain("textureSample("),t(n?.depthFragmentShader).toContain("< 0.5) { discard;"),t(n?.pointShadowFragmentShader).toContain("distanceToLight")}),o("rebuilds cached materials when scene-local texture bindings change",()=>{const e=new M({WGSLShaderMaterial:P}),o=new a(new Uint8Array([255,255,255,255]),1,1,s),r=new a(new Uint8Array([32,64,128,255]),1,1,s),n=new p({color:g("map").sample(w.uv),uniforms:{map:{value:o}},alphaTest:.5}),i=e.resolve(n);n.uniforms.map.value=r;const l=e.resolve(n);t(l).not.toBe(i),t(i.disposed).toBe(!0),n.dispose();const d=e.resolve(n);t(d).not.toBe(l),t(l.disposed).toBe(!0)}),o("uses the renderer batch id without Three’s batchingIdTexture",()=>{const e=new M({WGSLShaderMaterial:P}),o=new a(new Uint8Array([255,96,24,255]),1,1,s),r=new p({color:m(d("vp0")),uniforms:{vp0:{value:o}}}),n=e.resolve(r);t(n).toBeInstanceOf(P),t(n?.vertexShader).toContain("rendererBatchingId(input.instanceIndex)")}),o("compiles depth nodes that use fragment-coordinate expressions",()=>{const e=new M({WGSLShaderMaterial:P}),o=new p({color:m(x(j.x,j.y,A))}),a=e.resolve(o);t(a).toBeInstanceOf(P),t(a?.fragmentShader).toContain("input.position.xy"),t(a?.fragmentShader).toContain("input.position.z"),t(a?.fragmentShader).toContain("rendererViewportSize()"),t(a?.fragmentShader).not.toContain("gl_FragCoord")}),o("maps scene color and depth nodes to renderer-owned textures",()=>{const e=new M({WGSLShaderMaterial:P}),o=new p({color:E(j).multiplyScalar(V(j))}),a=e.resolve(o);t(a.sceneTextures).toEqual({hology_scene_map:"sceneColor",hology_depth_map:"sceneDepth"}),t(a.graphTextures).toEqual({}),t(a.fragmentShader).toContain("hology_scene_mapTexture"),t(a.fragmentShader).toContain("hology_depth_mapTexture"),t(a.fragmentShader).toContain("vec4<f32>(textureSample(hology_depth_mapTexture")}),o("caches distinct material variants for opaque and late draw stages",()=>{const e=new M({WGSLShaderMaterial:P},(e,t)=>"opaque"===t?[m(x(1)),m(x(0))]:void 0),o=new p({color:m(x(1))}),a=e.resolve(o,"opaque"),r=e.resolve(o,"late");t(a).not.toBe(r),t(e.resolve(o,"opaque")).toBe(a),t(e.resolve(o,"late")).toBe(r),t(a?.fragmentShader).toContain("@location(1)"),t(r?.fragmentShader).not.toContain("@location(1)")}),o("reconstructs WebGPU depth positions with inverted NDC Y",()=>{const e=new M({WGSLShaderMaterial:P}),o=new p({color:m(U.add(G.multiplyScalar(0)))}),a=e.resolve(o);t(a.fragmentShader).toContain("vec2<f32>(2.0, -2.0)"),t(a.fragmentShader).toMatch(/-\(float_op_\d+\)/)}),o("emits dither and parallax nodes without GLSL custom functions",()=>{const e=new M({WGSLShaderMaterial:P}),o=new a(new Uint8Array([128,128,128,255]),1,1,s),r=O(w.uv,g("heightMap",o),h(.03)),n=new p({color:y(r.x,r.y,F(x(.2,.4,.8)).z,1)}),i=e.resolve(n);t(i).toBeInstanceOf(P),t(i?.fragmentShader).toContain("textureSampleLevel(heightMapTexture, heightMapSampler"),t(i?.fragmentShader).toMatch(/= -dpdy\(-pom_\d+ViewPosition\)/),t(i?.fragmentShader).toMatch(/= -dpdy\(pom_\d+Uv\)/),t(i?.fragmentShader).not.toContain("customFunction_"),t(i?.fragmentShader).not.toContain("gl_FragCoord")}),o("inherits, updates, and clears the WebGPU world environment",()=>{const e=new a(new Uint8Array([255,255,255,255]),1,1,s),o=new p({color:c({color:u(8421504),roughness:.5,metalness:.1})}),r=new M({WGSLShaderMaterial:P});r.setWorldEnvironment({texture:e,intensity:.75});const n=r.resolve(o);t(n.fragmentShader).toContain("envMapTexture"),t(n.uniforms.envMapIntensity?.value).toBe(.75),t(o.envMap).toBeUndefined(),r.setWorldEnvironment({texture:e,intensity:.25});const i=r.resolve(o);t(i).toBe(n),t(i.uniforms.envMapIntensity?.value).toBe(.25),r.setWorldEnvironment(null);const l=r.resolve(o);t(l).not.toBe(n),t(n.disposed).toBe(!0),t(l.fragmentShader).not.toContain("envMapTexture")}),o("keeps an authored material environment ahead of the world environment",()=>{const e=new a(new Uint8Array([255,255,255,255]),1,1,s),o=new a(new Uint8Array([64,96,128,255]),1,1,s),r=new p({color:c({color:u(8421504),roughness:.5,metalness:.1}),envMap:o}),n=new M({WGSLShaderMaterial:P});n.setWorldEnvironment({texture:e,intensity:.2}),n.resolve(r),t(r.uniforms.envMap.value).toBe(o),t(r.envMap).toBe(o)})});/*
|
|
1
|
+
import{describe as e,expect as t,it as o}from"vitest";import{DataTexture as n,Material as a,MeshStandardMaterial as r,RGBAFormat as i,SpriteMaterial as s,Vector4 as l}from"three";import{batchingUniformVec4 as d,float as h,NodeShaderMaterial as u,rgb as m,rgba as c,standardMaterial as p,translateX as f,uniformFloat as S,uniformVec4 as v,uniformSampler2d as g,varyingAttributes as w,vec3 as x,vec4 as C}from"three-shader-graph";import{createNodeMaterialSignature as y,markMaterialUniformValuesNeedUpdate as B,NodeMaterialWebGpuResolver as M,setMaterialUniformValuesAutoUpdate as b,withThreeDefaultVertexAttributeValues as T}from"../rendering/node-material-webgpu-resolver.js";import{LandscapeCompositeShader as L}from"../shader/builtin/landscape-composite-shader.js";import{DecalUnlitShader as W}from"../shader/builtin/decal-unlit-shader.js";import{getSpritePosition as _}from"../shader/sprite-shader.js";import{depthNormal as G,depthWorldPosition as A,fragmentLinearEyeDepth as U,sampleSceneDepth as I,screenUV as V}from"../shader-nodes/depth.js";import{sampleSceneColor as k}from"../shader-nodes/scene-sample.js";import{applyDithering as j}from"../shader-nodes/dither.js";import{parallaxOcclusionMapping as D}from"../shader-nodes/pom.js";import{elapsedTimeUniformName as E}from"../shader-nodes/time.js";import{Trail as F}from"../effects/vfx/trail-renderer.js";import{particleUniforms as O}from"../shader-nodes/particle.js";import{buildShaderGraphMaterial as q,createDefaultShaderGraphDocument as P}from"../shader/graph/index.js";class z extends a{set uniformValuesNeedsUpdate(e){e&&this.uniformValuesAutoVersion++}constructor(e){super(),this.disposed=!1,this.uniformValuesAutoUpdate=!0,this.uniformValuesAutoVersion=0,this.vertexShader=e.vertexShader,this.fragmentShader=e.fragmentShader,this.depthVertexShader=e.depthVertexShader??null,this.depthFragmentShader=e.depthFragmentShader??null,this.pointShadowVertexShader=e.pointShadowVertexShader??null,this.pointShadowFragmentShader=e.pointShadowFragmentShader??null,this.uniforms=e.uniforms,this.sceneTextures=e.sceneTextures??{},this.graphTextures=e.graphTextures??{},this.vertexAttributes=e.vertexAttributes??[],this.transparent=e.transparent??!1,this.alphaTest=e.alphaTest??0,this.depthTest=e.depthTest??!0,this.depthWrite=e.depthWrite??!0,this.side=e.side??this.side}dispose(){this.disposed=!0,super.dispose()}}e("NodeMaterialWebGpuResolver",()=>{o("derives a textured, darkened albedo outline and updates its controls",()=>{const e=new n(new Uint8Array([128,96,64,255]),1,1,i);e.needsUpdate=!0;const o=g("outlineAlbedo",e).sample(w.uv),a=new u({color:p({color:o})});a.userData.outlineParameters={colorMode:"darkenedAlbedo",thickness:.012,darkening:.65,alpha:.8};const r=new M({WGSLShaderMaterial:z}),s=r.resolve(a,"outline");t(s).toBeInstanceOf(z),t(s.side).toBe(1),t(s.vertexShader).toContain("materialUniforms.outlineThickness"),t(s.vertexAttributes).toEqual(t.arrayContaining([t.objectContaining({name:"skinIndex"}),t.objectContaining({name:"skinWeight"})])),t(s.vertexShader).toContain("rendererSkinPosition"),t(s.vertexShader).toContain("outline_local_position_"),t(s.vertexShader).toContain("outline_local_normal_"),t(s.vertexShader).toContain("sqrt("),t(s.fragmentShader).toContain("outlineAlbedoTexture"),t(s.fragmentShader).toContain("materialUniforms.outlineDarkening"),t(s.uniforms.outlineThickness.value).toBe(.012),t(s.uniforms.outlineDarkening.value).toBe(.65),t(s.uniforms.outlineAlpha.value).toBe(.8),a.userData.outlineParameters.darkening=2,a.userData.outlineParameters.thickness=.02;const l=r.resolve(a,"outline");t(l).toBe(s),t(l.uniforms.outlineThickness.value).toBe(.02),t(l.uniforms.outlineDarkening.value).toBe(1)}),o("declines an albedo outline when the source graph has no albedo output",()=>{const e=new u({color:c(16777215)}),o=new M({WGSLShaderMaterial:z});t(o.resolve(e,"outline")).toBeUndefined()}),o("compiles shader graph scene-light nodes against the renderer light ABI",()=>{const e=P("surface");e.shadingModel="unlit",e.nodes.push({id:"ambient",kind:"scene.ambientLight"},{id:"directional",kind:"scene.directionalLight"},{id:"combined",kind:"math.add"}),e.edges.push({from:{nodeId:"ambient",port:"color"},to:{nodeId:"combined",port:"a"}},{from:{nodeId:"directional",port:"color"},to:{nodeId:"combined",port:"b"}}),e.outputs.color={input:{nodeId:"combined",port:"value"}};const o=new M({WGSLShaderMaterial:z}).resolve(q(e));t(o?.fragmentShader).toContain("rendererAmbientLight()"),t(o?.fragmentShader).toContain("rendererDirectionalLightForLight"),t(o?.fragmentShader).toContain("rendererDirectionalLightCount()"),t(o?.fragmentShader).not.toContain("NUM_DIR_LIGHTS")}),o("compiles decal surface rejection without undefined WGSL expressions",()=>{const e=(new W).build(),o=new M({WGSLShaderMaterial:z}).resolve(e);t(o?.fragmentShader).not.toContain("undefined"),t(o?.fragmentShader).toContain("hology_scene_mapTexture")}),o("recompiles an opaque variant when receiveDecals changes",()=>{const e=new u({color:c(16777215)});e.userData.receiveDecals=!0;const o=new M({WGSLShaderMaterial:z},e=>[c(16777215,!1===e.userData.receiveDecals?0:2)]),n=o.resolve(e,"opaque");e.userData.receiveDecals=!1,e.needsUpdate=!0;const a=o.resolve(e,"opaque");t(a).not.toBe(n),t(n.disposed).toBe(!0),t(n.fragmentShader).not.toBe(a.fragmentShader)}),o("maps Hology elapsed time to the renderer frame clock",()=>{const e=S(E),o=new u({color:c(x(e)),transform:f(e)}),n=new M({WGSLShaderMaterial:z}).resolve(o);t(n?.vertexShader).toContain("rendererElapsedTime()"),t(n?.fragmentShader).toContain("rendererElapsedTime()"),t(n?.depthVertexShader).toContain("rendererElapsedTime()"),t(n?.pointShadowVertexShader).toContain("rendererElapsedTime()"),t(n?.uniforms).not.toHaveProperty(E)}),o("compiles the native sprite billboard position without a GLSL fallback",()=>{const e=new u({color:c(16777215),position:_(S("rotation",.25),S("screenSpaceSize",0),S("lockYAxis",1))}),o=new M({WGSLShaderMaterial:z}).resolve(e);t(o).toBeInstanceOf(z),t(o?.vertexShader).toContain("cos("),t(o?.vertexShader).toContain("sin("),t(o?.vertexShader).toContain("materialUniforms.lockYAxis"),t(o?.vertexShader).not.toContain("customFunction_")}),o("uses the per-instance rotation attribute for instanced sprites",()=>{const e=new u({color:c(16777215),position:_(S("rotation",0),void 0,void 0,!0)}),o=new M({WGSLShaderMaterial:z}).resolve(e);t(o?.vertexAttributes).toEqual(t.arrayContaining([t.objectContaining({name:"rotation",format:"float32",stepMode:"instance"})])),t(o?.vertexShader).toContain("input.rotation"),t(o?.vertexShader).not.toContain("materialUniforms.rotation")}),o("uses per-instance particle opacity in WebGPU fragment shaders",()=>{const e=new u({color:c(m(16777215),O.opacity)}),o=new M({WGSLShaderMaterial:z}).resolve(e);t(o?.vertexAttributes).toEqual(t.arrayContaining([t.objectContaining({name:"particleData",format:"float32x3",stepMode:"instance"})])),t(o?.fragmentShader).toContain(".x"),t(o?.fragmentShader).toContain("> 0.5")}),o("maps THREE.SpriteMaterial to the native sprite shader",()=>{const e=new n(new Uint8Array([255,255,255,255]),1,1,i),o=new s({map:e,color:8429760,opacity:.6,rotation:.25,sizeAttenuation:!1,alphaTest:.2,transparent:!0}),a=new M({WGSLShaderMaterial:z}),r=a.resolve(o);t(r).toBeInstanceOf(z),t(r?.vertexShader).toContain("materialUniforms.rotation"),t(r?.vertexShader).toContain("materialUniforms.screenSpaceSize"),t(r?.fragmentShader).toContain("textureSample("),t(r?.alphaTest).toBe(.2),t(r?.transparent).toBe(!0),t(r?.uniforms.rotation.value).toBe(.25),t(r?.uniforms.screenSpaceSize.value).toBe(1),o.rotation=.75;const l=a.resolve(o);t(l).toBe(r),t(l?.uniforms.rotation.value).toBe(.75)}),o("memoizes node-material signatures for repeated lookups",()=>{const e=["version","vertexShader","fragmentShader","alphaTest","alphaToCoverage","blending","colorWrite","depthFunc","depthTest","depthWrite","polygonOffset","polygonOffsetFactor","polygonOffsetUnits","premultipliedAlpha","side","stencilWrite","transparent"],o=new Map([["version",1],["vertexShader","#vertex"],["fragmentShader","#fragment"],["alphaTest",0],["alphaToCoverage",!1],["blending",1],["colorWrite",!0],["depthFunc",3],["depthTest",!0],["depthWrite",!0],["polygonOffset",!1],["polygonOffsetFactor",0],["polygonOffsetUnits",0],["premultipliedAlpha",!1],["side",0],["stencilWrite",!1],["transparent",!1]]);let n=0;const a=new Proxy({},{get:(t,a)=>"string"==typeof a&&e.includes(a)?(n+=1,o.get(a)):Reflect.get(t,a)});y(a),y(a),t(n).toBe(e.length)}),o("adapts a NodeShaderMaterial without replacing its scene material",()=>{const e=S("intensity",.75),o=new u({color:p({color:m(5210052).multiplyScalar(e),roughness:.42,metalness:.18}),transparent:!0,alphaTest:.25}),n=new M({WGSLShaderMaterial:z}),a=n.resolve(o);t(a).toBeInstanceOf(z),t(a).not.toBe(o),t(a?.transparent).toBe(!0),t(a?.alphaTest).toBe(.25),t(a?.fragmentShader).toContain("rendererPointLightForLight"),t(o.isShaderMaterial).toBe(!0),e.value=.25,t(n.resolve(o)).toBe(a),t(a?.uniforms.intensity?.value).toBe(.25)}),o("syncs direct NodeShaderMaterial uniform mutations to the resolved material",()=>{const e=v("trailTint",new l(0,0,0,0)),o=S("hology_particle_energy",1),n=new u({color:e.multiplyScalar(o)}),a=new M({WGSLShaderMaterial:z});n.uniforms.trailTint.value=new l(1,.25,.5,.75),n.uniforms.hology_particle_energy.value=.4;const r=a.resolve(n);t(r?.uniforms.trailTint.value).toEqual(new l(1,.25,.5,.75)),t(r?.uniforms.hology_particle_energy.value).toBe(.4),n.uniforms.trailTint.value.set(.2,.4,.6,.8),n.uniforms.hology_particle_energy.value=.1,t(a.resolve(n)).toBe(r),t(r?.uniforms.trailTint.value).toEqual(new l(.2,.4,.6,.8)),t(r?.uniforms.hology_particle_energy.value).toBe(.1)}),o("only syncs explicitly invalidated uniforms when auto update is disabled",()=>{const e=S("intensity",.75),o=new u({color:c(x(e))});o.uniformValuesAutoUpdate=!1;const n=new M({WGSLShaderMaterial:z}),a=n.resolve(o),r=a.uniformValuesAutoVersion;e.value=.25,t(n.resolve(o)).toBe(a),t(a?.uniforms.intensity.value).toBe(.75),t(a?.uniformValuesAutoVersion).toBe(r),o.uniformValuesNeedsUpdate=!0,t(n.resolve(o)).toBe(a),t(a?.uniforms.intensity.value).toBe(.25),t(a?.uniformValuesAutoVersion).toBe(r+1)}),o("supports explicit invalidation for mapped Three.js materials",()=>{const e=new r({roughness:.8});b(e,!1);const o=new M({WGSLShaderMaterial:z}),n=o.resolve(e);e.roughness=.2,t(o.resolve(e)).toBe(n),t(n?.uniforms.roughness.value).toBe(.8),B(e),t(o.resolve(e)).toBe(n),t(n?.uniforms.roughness.value).toBe(.2)}),o("binds uniforms referenced only by an MRT material output",()=>{const e=S("roughness",.7),o=new u({color:c(5601177),alphaTest:.1});o.outputRoughness=e;const n=new M({WGSLShaderMaterial:z},e=>[e.outputColor,C(x(0,0,1),e.outputRoughness)]).resolve(o);t(n?.fragmentShader).toContain("materialUniforms.roughness"),t(n?.fragmentShader).not.toContain("graphColor"),t(n?.fragmentShader).toMatch(/if \(.+ < 0\.1\) \{ discard; \}/),t(n?.uniforms.roughness?.value).toBe(.7)}),o("compiles a landscape composite built from node material semantic outputs",()=>{const e=new M({WGSLShaderMaterial:z}),o=new u({color:p({color:m(5147450),roughness:.82,metalness:0})}),n=new u({color:p({color:m(7432286),roughness:.6,metalness:.05})}),a=new L;a.layers=[o,n];const r=e.resolve(a.build());t(r).toBeInstanceOf(z),t(r?.fragmentShader).toContain("rendererDirectionalLightForLight"),t(r?.vertexShader).toContain("@location(6) material: vec4<f32>"),t(r?.vertexShader).toContain("@location(8) hole: f32")}),o("allocates the trail's authored vertex attributes",()=>{const e=new M({WGSLShaderMaterial:z}).resolve(F.createBaseMaterial());t(e).toBeInstanceOf(z),t(e?.vertexAttributes).toEqual(t.arrayContaining([t.objectContaining({name:"nodeID",shaderLocation:6,format:"float32"}),t.objectContaining({name:"nodeCenter",shaderLocation:7,format:"float32x3"})]))}),o("uses Three's default value when a geometry has no uv1 attribute",()=>{const e=T([{name:"uv1",shaderLocation:6,format:"float32x2"},{name:"custom",shaderLocation:7,format:"float32"}]);t(e).toEqual(t.arrayContaining([t.objectContaining({name:"uv1",defaultValue:[0,0]}),t.objectContaining({name:"custom"})])),t(e[1]).not.toHaveProperty("defaultValue")}),o("compiles the foliage transform and named texture-uniform pattern",()=>{const e=new M({WGSLShaderMaterial:z}),o=new n(new Uint8Array([255,255,255,255]),1,1,i),a=new u({color:g("map").sample(w.uv),transform:f(S("windOffset",.25)),uniforms:{map:{value:o}},alphaTest:.5}),r=e.resolve(a);t(r).toBeInstanceOf(z),t(r?.vertexShader).toContain("rendererClipPosition(_ng_localPosition"),t(r?.depthVertexShader).toContain("materialUniforms.windOffset"),t(r?.depthFragmentShader).toContain("textureSample("),t(r?.depthFragmentShader).toContain("< 0.5) { discard;"),t(r?.pointShadowFragmentShader).toContain("distanceToLight")}),o("rebuilds cached materials when scene-local texture bindings change",()=>{const e=new M({WGSLShaderMaterial:z}),o=new n(new Uint8Array([255,255,255,255]),1,1,i),a=new n(new Uint8Array([32,64,128,255]),1,1,i),r=new u({color:g("map").sample(w.uv),uniforms:{map:{value:o}},alphaTest:.5}),s=e.resolve(r);r.uniforms.map.value=a;const l=e.resolve(r);t(l).not.toBe(s),t(s.disposed).toBe(!0),r.dispose();const d=e.resolve(r);t(d).not.toBe(l),t(l.disposed).toBe(!0)}),o("uses the renderer batch id without Three’s batchingIdTexture",()=>{const e=new M({WGSLShaderMaterial:z}),o=new n(new Uint8Array([255,96,24,255]),1,1,i),a=new u({color:c(d("vp0")),uniforms:{vp0:{value:o}}}),r=e.resolve(a);t(r).toBeInstanceOf(z),t(r?.vertexShader).toContain("rendererBatchingId(input.instanceIndex)")}),o("compiles depth nodes that use fragment-coordinate expressions",()=>{const e=new M({WGSLShaderMaterial:z}),o=new u({color:c(x(V.x,V.y,U))}),n=e.resolve(o);t(n).toBeInstanceOf(z),t(n?.fragmentShader).toContain("input.position.xy"),t(n?.fragmentShader).toContain("input.position.z"),t(n?.fragmentShader).toContain("rendererViewportSize()"),t(n?.fragmentShader).not.toContain("gl_FragCoord")}),o("maps scene color and depth nodes to renderer-owned textures",()=>{const e=new M({WGSLShaderMaterial:z}),o=new u({color:k(V).multiplyScalar(I(V))}),n=e.resolve(o);t(n.sceneTextures).toEqual({hology_scene_map:"sceneColor",hology_depth_map:"sceneDepth"}),t(n.graphTextures).toEqual({}),t(n.fragmentShader).toContain("hology_scene_mapTexture"),t(n.fragmentShader).toContain("hology_depth_mapTexture"),t(n.fragmentShader).toContain("vec4<f32>(textureSample(hology_depth_mapTexture")}),o("caches distinct material variants for opaque and late draw stages",()=>{const e=new M({WGSLShaderMaterial:z},(e,t)=>"opaque"===t?[c(x(1)),c(x(0))]:void 0),o=new u({color:c(x(1))}),n=e.resolve(o,"opaque"),a=e.resolve(o,"late");t(n).not.toBe(a),t(e.resolve(o,"opaque")).toBe(n),t(e.resolve(o,"late")).toBe(a),t(n?.fragmentShader).toContain("@location(1)"),t(a?.fragmentShader).not.toContain("@location(1)")}),o("reconstructs WebGPU depth positions with inverted NDC Y",()=>{const e=new M({WGSLShaderMaterial:z}),o=new u({color:c(A.add(G.multiplyScalar(0)))}),n=e.resolve(o);t(n.fragmentShader).toContain("vec2<f32>(2.0, -2.0)"),t(n.fragmentShader).toMatch(/-\(float_op_\d+\)/)}),o("emits dither and parallax nodes without GLSL custom functions",()=>{const e=new M({WGSLShaderMaterial:z}),o=new n(new Uint8Array([128,128,128,255]),1,1,i),a=D(w.uv,g("heightMap",o),h(.03)),r=new u({color:C(a.x,a.y,j(x(.2,.4,.8)).z,1)}),s=e.resolve(r);t(s).toBeInstanceOf(z),t(s?.fragmentShader).toContain("textureSampleLevel(heightMapTexture, heightMapSampler"),t(s?.fragmentShader).toMatch(/= -dpdy\(-pom_\d+ViewPosition\)/),t(s?.fragmentShader).toMatch(/= -dpdy\(pom_\d+Uv\)/),t(s?.fragmentShader).not.toContain("customFunction_"),t(s?.fragmentShader).not.toContain("gl_FragCoord")}),o("inherits, updates, and clears the WebGPU world environment",()=>{const e=new n(new Uint8Array([255,255,255,255]),1,1,i),o=new u({color:p({color:m(8421504),roughness:.5,metalness:.1})}),a=new M({WGSLShaderMaterial:z});a.setWorldEnvironment({texture:e,intensity:.75});const r=a.resolve(o);t(r.fragmentShader).toContain("envMapTexture"),t(r.uniforms.envMapIntensity?.value).toBe(.75),t(o.envMap).toBeUndefined(),a.setWorldEnvironment({texture:e,intensity:.25});const s=a.resolve(o);t(s).toBe(r),t(s.uniforms.envMapIntensity?.value).toBe(.25),a.setWorldEnvironment(null);const l=a.resolve(o);t(l).not.toBe(r),t(r.disposed).toBe(!0),t(l.fragmentShader).not.toContain("envMapTexture")}),o("keeps an authored material environment ahead of the world environment",()=>{const e=new n(new Uint8Array([255,255,255,255]),1,1,i),o=new n(new Uint8Array([64,96,128,255]),1,1,i),a=new u({color:p({color:m(8421504),roughness:.5,metalness:.1}),envMap:o}),r=new M({WGSLShaderMaterial:z});r.setWorldEnvironment({texture:e,intensity:.2}),r.resolve(a),t(a.uniforms.envMap.value).toBe(o),t(a.envMap).toBe(o)})});/*
|
|
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{expect as e,test as t,vi as o}from"vitest";import{DoubleSide as a,FrontSide as r,Texture as n}from"three";o.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&o.fn()),e[t]),set:(e,t,o)=>(e[t]=o,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),o.mock("three/examples/jsm/Addons.js",async e=>({...await e(),ConvexHull:class{},SkeletonUtils:{clone:e=>e},RectAreaLightHelper:class{}})),o.mock("three-shader-graph",async e=>{const t=await e();return{...t,rgb:(e,o,a)=>null==o&&null==a&&!0===e?.isColor&&"function"==typeof e.getHexString?t.rgb("#"+e.getHexString()):t.rgb(e,o,a)}}),o.mock("../rendering.js",()=>({RenderingView:class{constructor(){this._id="test",this.csm={setupMaterial:o.fn()}}getEnvTexture(e){return e}},setRenderingPaused:o.fn()}));import{materialFromAsset as s,prepareCustomParamsFromShaderGraph as d,prepareShaderGraphParameters as u}from"../scene/materializer.js";import{SerializedParamType as p}from"../scene/model.js";import{AssetsProvider as i}from"../scene/assets-provider.js";import{AssetResourceLoader as l}from"../scene/asset-resource-loader.js";import{buildShaderGraphMaterial as c,buildShaderGraphPostProcessMaterial as m,compileShaderGraph as v,compileShaderGraphPreview as f,createDefaultPostProcessShaderGraphDocument as h,createDefaultShaderGraphDocument as y,createDefaultVfxShaderGraphDocument as g,shaderGraphPostProcessWeightUniformName as I,shaderGraphParametersToPropertyParameters as b}from"../shader/graph/index.js";import{NodeShaderMaterial as x,textureSampler2d as k,UniformFloatNode as B,UniformSampler2dArraySlice as T,vec2 as w}from"../shader-nodes/index.js";import{StandardShader as C}from"../shader/builtin/standard-shader.js";import{sceneMapUniformName as j}from"../shader-nodes/scene-sample.js";function M(e,t,o){return{version:1,target:"surface",parameters:[],nodes:e,edges:t,outputs:o}}function D(e,t){return{id:e,name:e,type:"material",material:{type:"shaderGraph",side:r,params:{},shaderParams:{},shaderGraph:t}}}t("graph validation rejects missing nodes, invalid ports, and incompatible edges",()=>{const t=y();t.outputs.color={input:{nodeId:"missing",port:"value"}},e(()=>v(t)).toThrow(/Missing shader graph node/);const o=y();o.outputs.color={input:{nodeId:"base-color",port:"missing"}},e(()=>v(o)).toThrow(/does not have output/);const a=M([{id:"a",kind:"constant.vec2",params:{value:[1,1]}},{id:"b",kind:"constant.vec3",params:{value:[1,1,1]}},{id:"add",kind:"math.add"}],[{from:{nodeId:"a",port:"value"},to:{nodeId:"add",port:"a"}},{from:{nodeId:"b",port:"value"},to:{nodeId:"add",port:"b"}}],{color:{input:{nodeId:"add",port:"value"}}});e(()=>v(a)).toThrow(/Cannot add vec2 and vec3/)}),t("named reroutes pass values through declarations to multiple usages",()=>{const t=M([{id:"source",kind:"constant.float",params:{value:.75}},{id:"declaration",kind:"utility.namedRerouteDeclaration",params:{name:"Mask"}},{id:"usage-a",kind:"utility.namedRerouteUsage",params:{declarationId:"declaration"}},{id:"usage-b",kind:"utility.namedRerouteUsage",params:{declarationId:"declaration"}},{id:"add",kind:"math.add"}],[{from:{nodeId:"source",port:"value"},to:{nodeId:"declaration",port:"value"}},{from:{nodeId:"usage-a",port:"value"},to:{nodeId:"add",port:"a"}},{from:{nodeId:"usage-b",port:"value"},to:{nodeId:"add",port:"b"}}],{color:{input:{nodeId:"add",port:"value"}}}),o=v(t),a=o.nodes.get("declaration")?.outputs.value;e(a?.type).toBe("float"),e(o.nodes.get("usage-a")?.outputs.value).toBe(a),e(o.nodes.get("usage-b")?.outputs.value).toBe(a),e(o.output.color).toBeTruthy()}),t("named reroutes preserve dynamic vector types",()=>{const t=M([{id:"source",kind:"constant.vec3",params:{value:[1,2,3]}},{id:"declaration",kind:"utility.namedRerouteDeclaration",params:{name:"Direction"}},{id:"usage",kind:"utility.namedRerouteUsage",params:{declarationId:"declaration"}}],[{from:{nodeId:"source",port:"value"},to:{nodeId:"declaration",port:"value"}}],{color:{input:{nodeId:"usage",port:"value"}}}),o=v(t);e(o.nodes.get("usage")?.outputs.value.type).toBe("vec3")}),t("named reroute validation rejects missing and invalid declarations",()=>{const t=M([{id:"usage",kind:"utility.namedRerouteUsage",params:{declarationId:"missing"}}],[],{});e(()=>v(t)).toThrow(/declaration "missing" does not exist/);const o=M([{id:"not-declaration",kind:"constant.float",params:{value:1}},{id:"usage",kind:"utility.namedRerouteUsage",params:{declarationId:"not-declaration"}}],[],{});e(()=>v(o)).toThrow(/not a named reroute declaration/)}),t("named reroute cycles report a compile error",()=>{const t=M([{id:"declaration",kind:"utility.namedRerouteDeclaration",params:{name:"Cycle"}},{id:"usage",kind:"utility.namedRerouteUsage",params:{declarationId:"declaration"}}],[{from:{nodeId:"usage",port:"value"},to:{nodeId:"declaration",port:"value"}}],{color:{input:{nodeId:"usage",port:"value"}}});e(()=>v(t)).toThrow(/contains a cycle/)}),t("surface graph compiles to NodeShaderOutput",()=>{const t=v(y("surface"));e(t.output.color).toBeTruthy(),e(t.output.transparent).toBe(!1)}),t("inline literal inputs compile without separate constant nodes",()=>{const t=M([{id:"uv",kind:"builtin.uv"},{id:"scale-uv",kind:"math.multiply",inputs:{b:{value:2,valueType:"float"}}},{id:"rotate",kind:"uv.rotate",inputs:{angle:{value:.5,valueType:"float"},center:{value:[.25,.75],valueType:"vec2"}}},{id:"translate",kind:"transform.translate",inputs:{offset:{value:[0,1,0],valueType:"vec3"}}}],[{from:{nodeId:"uv",port:"value"},to:{nodeId:"scale-uv",port:"a"}},{from:{nodeId:"scale-uv",port:"value"},to:{nodeId:"rotate",port:"uv"}}],{color:{input:{nodeId:"rotate",port:"value"}},transform:{input:{nodeId:"translate",port:"value"}}}),o=v(t);e(o.nodes.get("scale-uv")?.outputs.value.type).toBe("vec2"),e(o.nodes.get("rotate")?.outputs.value.type).toBe("vec2"),e(o.nodes.get("translate")?.outputs.value.type).toBe("mat4"),e(o.output.color).toBeTruthy(),e(o.output.transform).toBeTruthy()}),t("uv attribute node supports selecting non-default uv channels",()=>{const t=M([{id:"uv",kind:"builtin.uv",params:{channel:2}}],[],{color:{input:{nodeId:"uv",port:"value"}}}),o=v(t);e(o.nodes.get("uv")?.outputs.value.type).toBe("vec2");const a=c(t);e(a.fragmentShader).toContain("uv2")}),t("sprite graph compiles with sprite-compatible material output",()=>{const t=y("sprite"),o=v(t);e(o.output.color).toBeTruthy(),e(o.output.transparent).toBe(!0)}),t("default VFX shader graphs compile mapped targets and build non-sprite materials",()=>{for(const t of["sprite","surface","decal","trail"]){const o=g(t);if(e(v(o).output.color).toBeTruthy(),"sprite"===t)continue;const a=c(o,{trailBillboard:"trail"===t});e(a.isMaterial).toBe(!0)}}),t("graph material options apply to supported material targets",()=>{const t=y("surface");t.materialOptions={side:"double",transparent:!0};const o=c(t);e(o.side).toBe(a),e(o.transparent).toBe(!0);const r=y("decal");r.materialOptions={transparent:!0};const n=c(r);e(n.transparent).toBe(!0)}),t("standard graph compiles extended material outputs and vertex transform",()=>{const t=M([{id:"metalness",kind:"constant.float",params:{value:.8}},{id:"ao",kind:"constant.float",params:{value:.5}},{id:"sheen",kind:"constant.vec3",params:{value:[.1,.2,.3]}},{id:"offset",kind:"constant.vec3",params:{value:[0,1,0]}},{id:"translate",kind:"transform.translate"}],[{from:{nodeId:"offset",port:"value"},to:{nodeId:"translate",port:"offset"}}],{metalness:{input:{nodeId:"metalness",port:"value"}},ambientOcclusion:{input:{nodeId:"ao",port:"value"}},sheenColor:{input:{nodeId:"sheen",port:"value"}},transform:{input:{nodeId:"translate",port:"value"}}}),o=v(t);e(o.output.metalness).toBeTruthy(),e(o.output.ambientOcclusion).toBeTruthy(),e(o.output.sheenColor).toBeTruthy(),e(o.output.transform).toBeTruthy();const a=c(t);e(a.outputMetalness).toBeTruthy(),e(a.outputAmbientOcclusion).toBeTruthy(),e(a.outputTransform).toBeTruthy()}),t("math multiply composes transform matrices",()=>{const t=M([{id:"translate",kind:"transform.translate",inputs:{offset:{value:[0,1,0],valueType:"vec3"}}},{id:"scale",kind:"transform.scale",inputs:{scale:{value:[2,2,2],valueType:"vec3"}}},{id:"compose",kind:"math.multiply"}],[{from:{nodeId:"translate",port:"value"},to:{nodeId:"compose",port:"a"}},{from:{nodeId:"scale",port:"value"},to:{nodeId:"compose",port:"b"}}],{transform:{input:{nodeId:"compose",port:"value"}}}),o=v(t);e(o.nodes.get("compose")?.outputs.value.type).toBe("mat4"),e(o.output.transform).toBeTruthy(),e(c(t).outputTransform).toBeTruthy()}),t("matrix input nodes compile as mat4 uniforms",()=>{for(const t of["builtin.viewMatrix","builtin.projectionMatrix","builtin.modelViewMatrix"]){const o=M([{id:"matrix",kind:t}],[],{transform:{input:{nodeId:"matrix",port:"value"}}}),a=v(o);e(a.nodes.get("matrix")?.outputs.value.type).toBe("mat4"),e(a.output.transform).toBeTruthy()}}),t("compose vec4 node compiles from float components",()=>{const t=M([{id:"compose",kind:"compose.vec4",inputs:{x:{value:.1},y:{value:.2},z:{value:.3},w:{value:1}}}],[],{color:{input:{nodeId:"compose",port:"value"}}}),o=v(t);e(o.nodes.get("compose")?.outputs.value.type).toBe("vec4"),e(o.output.color).toBeTruthy()}),t("atan2 node compiles from y and x inputs",()=>{const t=M([{id:"atan2",kind:"math.atan2",inputs:{y:{value:1},x:{value:0}}}],[],{color:{input:{nodeId:"atan2",port:"value"}}}),o=v(t);e(o.nodes.get("atan2")?.outputs.value.type).toBe("float"),e(o.output.color).toBeTruthy()}),t("built-in attribute nodes compile for fragment and transform outputs",()=>{const t=M([{id:"color",kind:"builtin.color"},{id:"normal",kind:"builtin.normal"},{id:"position",kind:"builtin.position"},{id:"worldPosition",kind:"builtin.worldPosition"},{id:"objectPosition",kind:"builtin.objectPosition"},{id:"translate",kind:"transform.translate"}],[{from:{nodeId:"objectPosition",port:"value"},to:{nodeId:"translate",port:"offset"}}],{color:{input:{nodeId:"color",port:"value"}},normal:{input:{nodeId:"normal",port:"value"}},roughness:{input:{nodeId:"position",port:"x"}},metalness:{input:{nodeId:"worldPosition",port:"x"}},transform:{input:{nodeId:"translate",port:"value"}}}),o=v(t);e(o.nodes.get("color")?.outputs.value.type).toBe("rgba"),e(o.nodes.get("normal")?.outputs.value.type).toBe("vec3"),e(o.nodes.get("position")?.outputs.value.type).toBe("vec3"),e(o.nodes.get("worldPosition")?.outputs.value.type).toBe("vec3"),e(o.nodes.get("objectPosition")?.outputs.value.type).toBe("vec3"),e(o.output.color).toBeTruthy(),e(o.output.normal).toBeTruthy(),e(o.output.transform).toBeTruthy(),e(c(t).outputTransform).toBeTruthy()}),t("painted layer mix node compiles color layers and builds a material",()=>{const t=M([{id:"base",kind:"constant.rgba",params:{value:"#224422",alpha:1}},{id:"grass",kind:"constant.rgba",params:{value:"#55aa33",alpha:1}},{id:"stone",kind:"constant.rgba",params:{value:"#777777",alpha:1}},{id:"mix",kind:"layer.mixPainted",params:{mode:"soft",enableNoise:!0,softness:.25,noiseScale:.2,noiseAmount:.4}}],[{from:{nodeId:"base",port:"value"},to:{nodeId:"mix",port:"base"}},{from:{nodeId:"grass",port:"value"},to:{nodeId:"mix",port:"layer1"}},{from:{nodeId:"stone",port:"value"},to:{nodeId:"mix",port:"layer3"}}],{color:{input:{nodeId:"mix",port:"value"}}}),o=v(t);e(o.nodes.get("mix")?.outputs.value.type).toBe("rgba"),e(o.output.color).toBeTruthy(),e(c(t).isMaterial).toBe(!0)}),t("painted layer mix node compiles sparse float layers in hard and soft modes",()=>{for(const t of["hard","soft"]){const o=M([{id:"base",kind:"constant.float",params:{value:.8}},{id:"mud",kind:"constant.float",params:{value:1}},{id:"wet",kind:"constant.float",params:{value:.25}},{id:"mix",kind:"layer.mixPainted",params:{mode:t,enableNoise:!1,softness:.3,noiseScale:.1,noiseAmount:.5}}],[{from:{nodeId:"base",port:"value"},to:{nodeId:"mix",port:"base"}},{from:{nodeId:"mud",port:"value"},to:{nodeId:"mix",port:"layer2"}},{from:{nodeId:"wet",port:"value"},to:{nodeId:"mix",port:"layer8"}}],{roughness:{input:{nodeId:"mix",port:"value"}}}),a=v(o);e(a.nodes.get("mix")?.outputs.value.type).toBe("float"),e(a.output.roughness).toBeTruthy()}}),t("painted layer mix node rejects incompatible layer types",()=>{const t=M([{id:"base",kind:"constant.vec2",params:{value:[0,1]}},{id:"layer",kind:"constant.vec3",params:{value:[1,0,0]}},{id:"mix",kind:"layer.mixPainted"}],[{from:{nodeId:"base",port:"value"},to:{nodeId:"mix",port:"base"}},{from:{nodeId:"layer",port:"value"},to:{nodeId:"mix",port:"layer1"}}],{color:{input:{nodeId:"mix",port:"value"}}});e(()=>v(t)).toThrow(/Cannot mix vec2 and vec3/)}),t("additional math, uv, shape, noise, and effect nodes compile",()=>{const t=M([{id:"uv",kind:"builtin.uv"},{id:"angle",kind:"constant.float",params:{value:.5}},{id:"scale",kind:"constant.float",params:{value:4}},{id:"half",kind:"constant.float",params:{value:.5}},{id:"axis",kind:"constant.vec3",params:{value:[0,1,0]}},{id:"rotatedUv",kind:"uv.rotate"},{id:"twirl",kind:"uv.twirl"},{id:"bulge",kind:"uv.bulge"},{id:"radial",kind:"uv.radial"},{id:"flipbook",kind:"uv.flipbook"},{id:"shape",kind:"shape.roundedRectangle"},{id:"noise",kind:"noise.simplex"},{id:"voronoi",kind:"noise.voronoi2d"},{id:"fresnel",kind:"effect.fresnel"},{id:"sin",kind:"math.sin"},{id:"pow",kind:"math.pow"},{id:"mix",kind:"math.mix"},{id:"rotateAxis",kind:"transform.rotateAxis"}],[{from:{nodeId:"uv",port:"value"},to:{nodeId:"rotatedUv",port:"uv"}},{from:{nodeId:"angle",port:"value"},to:{nodeId:"rotatedUv",port:"angle"}},{from:{nodeId:"rotatedUv",port:"value"},to:{nodeId:"twirl",port:"uv"}},{from:{nodeId:"scale",port:"value"},to:{nodeId:"twirl",port:"strength"}},{from:{nodeId:"twirl",port:"value"},to:{nodeId:"bulge",port:"uv"}},{from:{nodeId:"half",port:"value"},to:{nodeId:"bulge",port:"power"}},{from:{nodeId:"bulge",port:"value"},to:{nodeId:"radial",port:"uv"}},{from:{nodeId:"twirl",port:"value"},to:{nodeId:"flipbook",port:"uv"}},{from:{nodeId:"twirl",port:"value"},to:{nodeId:"shape",port:"uv"}},{from:{nodeId:"half",port:"value"},to:{nodeId:"shape",port:"width"}},{from:{nodeId:"half",port:"value"},to:{nodeId:"shape",port:"height"}},{from:{nodeId:"half",port:"value"},to:{nodeId:"shape",port:"radius"}},{from:{nodeId:"twirl",port:"value"},to:{nodeId:"noise",port:"uv"}},{from:{nodeId:"scale",port:"value"},to:{nodeId:"noise",port:"scale"}},{from:{nodeId:"twirl",port:"value"},to:{nodeId:"voronoi",port:"uv"}},{from:{nodeId:"scale",port:"value"},to:{nodeId:"voronoi",port:"scale"}},{from:{nodeId:"scale",port:"value"},to:{nodeId:"fresnel",port:"power"}},{from:{nodeId:"radial",port:"angle"},to:{nodeId:"sin",port:"value"}},{from:{nodeId:"noise",port:"value"},to:{nodeId:"pow",port:"a"}},{from:{nodeId:"half",port:"value"},to:{nodeId:"pow",port:"b"}},{from:{nodeId:"shape",port:"value"},to:{nodeId:"mix",port:"a"}},{from:{nodeId:"voronoi",port:"value"},to:{nodeId:"mix",port:"b"}},{from:{nodeId:"fresnel",port:"value"},to:{nodeId:"mix",port:"t"}},{from:{nodeId:"axis",port:"value"},to:{nodeId:"rotateAxis",port:"axis"}},{from:{nodeId:"angle",port:"value"},to:{nodeId:"rotateAxis",port:"angle"}}],{color:{input:{nodeId:"mix",port:"value"}},opacity:{input:{nodeId:"sin",port:"value"}},anisotropyDirection:{input:{nodeId:"flipbook",port:"value"}},transform:{input:{nodeId:"rotateAxis",port:"value"}}}),o=v(t);e(o.nodes.get("mix")?.outputs.value.type).toBe("float"),e(o.nodes.get("twirl")?.outputs.value.type).toBe("vec2"),e(o.nodes.get("bulge")?.outputs.value.type).toBe("vec2"),e(o.nodes.get("radial")?.outputs.coords.type).toBe("vec2"),e(o.nodes.get("flipbook")?.outputs.value.type).toBe("vec2"),e(o.nodes.get("rotateAxis")?.outputs.value.type).toBe("mat4"),e(o.output.color).toBeTruthy(),e(o.output.opacity).toBeTruthy(),e(o.output.transform).toBeTruthy()}),t("slider input node outputs a clamped float value",()=>{const t=M([{id:"slider",kind:"input.slider",params:{value:1.25}}],[],{roughness:{input:{nodeId:"slider",port:"value"}}}),o=v(t);e(o.nodes.get("slider")?.outputs.value.type).toBe("float"),e(o.output.roughness).toBeTruthy()}),t("comparison and boolean graph nodes compile to boolean outputs",()=>{const t=M([{id:"a",kind:"constant.float",params:{value:.75}},{id:"b",kind:"constant.float",params:{value:.5}},{id:"greater",kind:"compare.greaterThan"},{id:"lessEqual",kind:"compare.lessThanOrEqual"},{id:"not",kind:"boolean.not"},{id:"and",kind:"boolean.and"},{id:"or",kind:"boolean.or"}],[{from:{nodeId:"a",port:"value"},to:{nodeId:"greater",port:"a"}},{from:{nodeId:"b",port:"value"},to:{nodeId:"greater",port:"b"}},{from:{nodeId:"a",port:"value"},to:{nodeId:"lessEqual",port:"a"}},{from:{nodeId:"b",port:"value"},to:{nodeId:"lessEqual",port:"b"}},{from:{nodeId:"lessEqual",port:"value"},to:{nodeId:"not",port:"value"}},{from:{nodeId:"greater",port:"value"},to:{nodeId:"and",port:"a"}},{from:{nodeId:"not",port:"value"},to:{nodeId:"and",port:"b"}},{from:{nodeId:"and",port:"value"},to:{nodeId:"or",port:"a"}},{from:{nodeId:"lessEqual",port:"value"},to:{nodeId:"or",port:"b"}}],{discard:{input:{nodeId:"or",port:"value"}}}),o=v(t);e(o.nodes.get("greater")?.outputs.value.type).toBe("boolean"),e(o.nodes.get("lessEqual")?.outputs.value.type).toBe("boolean"),e(o.nodes.get("not")?.outputs.value.type).toBe("boolean"),e(o.nodes.get("and")?.outputs.value.type).toBe("boolean"),e(o.nodes.get("or")?.outputs.value.type).toBe("boolean"),e(o.output.discard).toBeTruthy()}),t("split component node exposes vector and color channels as separate outputs",()=>{const t=M([{id:"vec",kind:"constant.vec3",params:{value:[.2,.4,.6]}},{id:"color",kind:"constant.rgba",params:{value:"#336699",alpha:.75}},{id:"splitVec",kind:"decompose.split"},{id:"splitColor",kind:"decompose.split"},{id:"sum",kind:"math.add"}],[{from:{nodeId:"vec",port:"value"},to:{nodeId:"splitVec",port:"value"}},{from:{nodeId:"color",port:"value"},to:{nodeId:"splitColor",port:"value"}},{from:{nodeId:"splitVec",port:"z"},to:{nodeId:"sum",port:"a"}},{from:{nodeId:"splitColor",port:"a"},to:{nodeId:"sum",port:"b"}}],{roughness:{input:{nodeId:"sum",port:"value"}}}),o=v(t);e(Object.keys(o.nodes.get("splitVec")?.outputs??{})).toEqual(["x","y","z"]),e(Object.keys(o.nodes.get("splitColor")?.outputs??{})).toEqual(["r","g","b","a","x","y","z","w"]),e(o.nodes.get("splitVec")?.outputs.z.type).toBe("float"),e(o.nodes.get("splitColor")?.outputs.a.type).toBe("float"),e(o.output.roughness).toBeTruthy()}),t("color gradient node compiles editable stops to a sampled color",()=>{const t=M([{id:"threshold",kind:"constant.float",params:{value:.75}},{id:"gradient",kind:"color.gradient",params:{stops:[{position:0,color:"#000000",alpha:1},{position:.5,color:"#ff0000",alpha:1},{position:1,color:"#ffffff",alpha:1}]}}],[{from:{nodeId:"threshold",port:"value"},to:{nodeId:"gradient",port:"t"}}],{color:{input:{nodeId:"gradient",port:"value"}}}),o=v(t);e(o.nodes.get("gradient")?.outputs.value.type).toBe("rgba"),e(o.output.color).toBeTruthy(),e(f(t,"gradient").color).toBeTruthy()}),t("curve node compiles editable curve to a sampled float",()=>{const t=M([{id:"t",kind:"constant.float",params:{value:.75}},{id:"curve",kind:"utility.curve",params:{curve:"InOutQuad"}}],[{from:{nodeId:"t",port:"value"},to:{nodeId:"curve",port:"t"}}],{roughness:{input:{nodeId:"curve",port:"value"}}}),o=v(t);e(o.nodes.get("curve")?.outputs.value.type).toBe("float"),e(o.output.roughness).toBeTruthy(),e(f(t,"curve").color).toBeTruthy()}),t("graph parameters convert to property params and custom defaults",()=>{const t=y();t.parameters.push({name:"opacity",type:"float",group:"Surface",defaultValue:.5});const o=b(t);e(o.map(e=>e.name)).toEqual(["baseColor","opacity"]),e(o.every(e=>!0===e.options.optional)).toBe(!0),e(o.map(e=>e.options.group??null)).toEqual([null,"Surface"]);const a=d(t);e(a.baseColor).toMatchObject({type:p.RgbNode,value:"#ffffff",override:!1}),e(a.opacity).toMatchObject({type:p.FloatNode,value:.5,override:!1})}),t("existing shader graph param values keep overriding unless explicitly disabled",()=>{const t=y(),o=d(t,{baseColor:{type:p.RgbNode,value:"#ff0000"}});e(o.baseColor).toMatchObject({type:p.RgbNode,value:"#ff0000",override:!0});const a=d(t,{baseColor:{type:p.RgbNode,value:"#00ff00",override:!1}});e(a.baseColor).toMatchObject({type:p.RgbNode,value:"#ffffff",override:!1})}),t("texture graph parameter defaults serialize as sampler parameters",()=>{const t=M([],[],{});t.parameters.push({name:"map",type:"texture",defaultValue:"texture-asset-id"});const o=d(t);e(o.map).toMatchObject({type:p.Sampler2DNode,value:"texture-asset-id"})}),t("shader graph params keep graph-declared serialized types over previous string params",()=>{const t=y();t.parameters.push({name:"map",type:"texture",defaultValue:null});const o=d(t,{baseColor:{type:p.String,value:"#ff0000"},map:{type:p.String,value:"texture-asset-id"}});e(o.baseColor).toMatchObject({type:p.RgbNode,value:"#ff0000"}),e(o.map).toMatchObject({type:p.Sampler2DNode,value:"texture-asset-id"})}),t("shader graph texture params resolve asset ids before compile",async()=>{const t=M([{id:"texture",kind:"input.parameter",params:{parameter:"map"}}],[],{color:{input:{nodeId:"texture",port:"value"}}});t.parameters.push({name:"map",type:"texture",defaultValue:"texture-asset-id"});const o=await u({},t,new A([{id:"texture-asset-id",name:"Map",type:"texture",fileKey:"map.png",fileFormat:"png"}]),new O);e(o.map).toBeTruthy(),e(typeof o.map).not.toBe("string"),e(v(t,{parameters:o}).output.color).toBeTruthy()}),t("shader graph texture array params expose named slice uniforms",async()=>{const t=M([{id:"texture",kind:"input.parameter",params:{parameter:"map"}}],[],{color:{input:{nodeId:"texture",port:"value"}}});t.parameters.push({name:"map",type:"texture",defaultValue:"texture-array-layer"});const o=await u({},t,new A([{id:"texture-array-layer",name:"Map Layer",type:"texture",fileKey:"map.png",fileFormat:"png",texture:{textureArrayFileKey:"textures/packed.ktx2",textureArrayLayer:5}}]),new P(new n,5)),a=c(t,{parameters:o});e(a.uniforms.pu_map_i?.value).toBe(5),e(a.fragmentShader).toContain("uniform float pu_map_i")}),t("standard shader height maps support texture array slices",()=>{const t=new C;t.heightMap=new T("heightMap",new n,new B("heightMap_i",3,void 0,!1)),t.map=k(new n),t.heightScale=.05;const o=t.build();e(o.fragmentShader).toContain("uniform sampler2DArray heightMap"),e(o.fragmentShader).toContain("uniform float heightMap_i"),e(o.fragmentShader).toContain("texture(heightMap, vec3(uv + vCurrOffset, heightMapLayer)).r")}),t("standard shader supports particle opacity for instanced VFX materials",()=>{const t=(new C).build();e(t.fragmentShader).toContain("IS_PARTICLE"),e(t.fragmentShader).toContain("particleData")}),t("material graph asset reference and local embedded graph both build materials",async()=>{const t={id:"graph-asset",name:"Reusable Graph",type:"shaderGraph",shaderGraph:y()},o=D("referenced",{source:"asset",assetId:t.id}),a=D("local",{source:"local",graph:y()}),r=new A([t,o,a]),n=new l;e((await s(o,null,r,n,[],!1)).isMaterial).toBe(!0),e((await s(a,null,r,n,[],!1)).isMaterial).toBe(!0)}),t("selected-node preview compiles scalar, vector, color, rgba, and boolean outputs",()=>{const t=[{id:"float",kind:"constant.float",params:{value:.25}},{id:"slider",kind:"input.slider",params:{value:.5}},{id:"vec2",kind:"constant.vec2",params:{value:[1,0]}},{id:"rgb",kind:"constant.vec3",params:{value:[1,.5,0]}},{id:"rgba",kind:"constant.rgba",params:{value:"#ff8800",alpha:.75}},{id:"boolean",kind:"constant.boolean",params:{value:!0}}];for(const o of t){const t=M([o],[],{color:{input:{nodeId:o.id,port:"value"}}});e(f(t,o.id).color).toBeTruthy()}}),t("post-process shader graph builds a fullscreen-safe material",()=>{const t=h(),o=m(t);e(o).toBeInstanceOf(x),e(o.depthWrite).toBe(!1),e(o.depthTest).toBe(!1),e(o.toneMapped).toBe(!1),e(o.transparent).toBe(!1),e(o.userData.isPostProcessShaderGraph).toBe(!0),e(o.uniforms[j]).toBeTruthy(),e(o.uniforms[I]).toBeTruthy()}),t("empty post-process shader graph defaults to identity scene color",()=>{const t=h();t.nodes=[],t.edges=[],t.outputs={};const o=v(t),a=m(t);e(o.output.color).toBeTruthy(),e(o.output.transparent).toBe(!1),e(a.uniforms[j]).toBeTruthy(),e(a.uniforms[I]).toBeTruthy()}),t("post-process color and opacity outputs expose blend weight uniform",()=>{const t=M([{id:"color",kind:"constant.rgba",params:{value:"#ff0044",alpha:1}},{id:"opacity",kind:"constant.float",params:{value:.35}}],[],{color:{input:{nodeId:"color",port:"value"}},opacity:{input:{nodeId:"opacity",port:"value"}},roughness:{value:.8}});t.target="postProcess";const o=v(t),a=m(t);e(o.output.color).toBeTruthy(),e(o.output.opacity).toBeTruthy(),e(o.output.roughness).toBeUndefined(),e(a.uniforms[I]).toBeTruthy()}),t("selected-node preview builds through post-process fullscreen path",()=>{const t=M([{id:"float",kind:"constant.float",params:{value:.25}}],[],{color:{input:{nodeId:"float",port:"value"}}});t.target="postProcess";const o=m(t,{previewNodeId:"float"});e(o).toBeInstanceOf(x),e(o.userData.isPostProcessShaderGraph).toBe(!0),e(o.uniforms[j]).toBeTruthy(),e(o.uniforms[I]).toBeTruthy()}),t("texture parameter nodes expose sampled value and channel outputs",()=>{const t=M([{id:"texture",kind:"input.parameter",params:{parameter:"albedo"}}],[],{color:{input:{nodeId:"texture",port:"value"}}});t.parameters.push({name:"albedo",type:"texture",defaultValue:null});const o=v(t,{parameters:{albedo:new n}}),a=o.nodes.get("texture")?.outputs??{};e(Object.keys(a)).toEqual(["value","rgb","r","g","b","a","sampler"]),e(a.value.type).toBe("rgba"),e(a.rgb.type).toBe("rgb"),e(a.r.type).toBe("float"),e(a.g.type).toBe("float"),e(a.b.type).toBe("float"),e(a.a.type).toBe("float"),e(a.sampler.type).toBe("sampler2d"),e(o.output.color).toBeTruthy()}),t("texture parameter nodes accept resolved sampler values",()=>{const t=M([{id:"texture",kind:"input.parameter",params:{parameter:"map"}}],[],{color:{input:{nodeId:"texture",port:"value"}}});t.parameters.push({name:"map",type:"texture",defaultValue:"texture-asset-id"});const o=v(t,{parameters:{map:k(new n)}});e(o.output.color).toBeTruthy()}),t("missing texture parameter values warn and use fallback sampler",()=>{const t=M([{id:"texture",kind:"input.parameter",params:{parameter:"missingTexture"}}],[],{color:{input:{nodeId:"texture",port:"value"}}});t.parameters.push({name:"missingTexture",type:"texture",defaultValue:null});const a=o.spyOn(console,"warn").mockImplementation(()=>{});try{const o=v(t),r=o.nodes.get("texture")?.outputs??{};e(r.sampler.type).toBe("sampler2d"),e(o.output.color).toBeTruthy(),e(a).toHaveBeenCalledWith(e.stringContaining("missingTexture"))}finally{a.mockRestore()}}),t("scene depth graph nodes expose sampled and fragment linear eye depth",()=>{const t=M([{id:"uv",kind:"builtin.screenUv"},{id:"sceneDepth",kind:"scene.sampleDepth"},{id:"fragmentDepth",kind:"scene.fragmentDepth"},{id:"depthFade",kind:"math.subtract"}],[{from:{nodeId:"uv",port:"value"},to:{nodeId:"sceneDepth",port:"uv"}},{from:{nodeId:"sceneDepth",port:"linearEyeDepth"},to:{nodeId:"depthFade",port:"a"}},{from:{nodeId:"fragmentDepth",port:"linearEyeDepth"},to:{nodeId:"depthFade",port:"b"}}],{opacity:{input:{nodeId:"depthFade",port:"value"}}}),o=v(t);e(o.nodes.get("sceneDepth")?.outputs.depth.type).toBe("float"),e(o.nodes.get("sceneDepth")?.outputs.linearEyeDepth.type).toBe("float"),e(o.nodes.get("fragmentDepth")?.outputs.linearEyeDepth.type).toBe("float"),e(o.output.opacity).toBeTruthy()}),t("screen UV builtin accepts a fullscreen compiler override",()=>{const t=M([{id:"uv",kind:"builtin.screenUv"}],[],{}),o=w(.25,.75),a=v(t,{screenUV:o});e(a.nodes.get("uv")?.outputs.value.value).toBe(o)}),t("unpack normal node compiles with various input combinations",()=>{const t=M([{id:"sample",kind:"constant.vec3",params:{value:[.5,.5,1]}},{id:"scale",kind:"constant.float",params:{value:1}},{id:"normal",kind:"builtin.normal"},{id:"toNormal",kind:"color.unpackNormal"}],[{from:{nodeId:"sample",port:"value"},to:{nodeId:"toNormal",port:"color"}},{from:{nodeId:"scale",port:"value"},to:{nodeId:"toNormal",port:"scale"}},{from:{nodeId:"normal",port:"value"},to:{nodeId:"toNormal",port:"normal"}}],{normal:{input:{nodeId:"toNormal",port:"value"}}}),o=v(t);e(o.nodes.get("toNormal")?.outputs.value.type).toBe("vec3"),e(o.output.normal).toBeTruthy()});class A extends i{constructor(e){super(),this.onCreate=null,this.onDelete=null,this.onUpdate=null,this.assets=new Map(e.map(e=>[e.id,e]))}async getAsset(e){return this.assets.get(e)}async getAssets(){return Array.from(this.assets.values())}}class O extends l{async getTexture(){return new n}async getTextureArray(){return{texture:null,layerIndex:null}}}class P extends O{constructor(e,t){super(),this.textureArray=e,this.layerIndex=t}async getTextureArray(){return{texture:this.textureArray,layerIndex:this.layerIndex}}}/*
|
|
1
|
+
import{expect as e,test as t,vi as o}from"vitest";import{DoubleSide as a,FrontSide as r,Texture as n}from"three";o.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&o.fn()),e[t]),set:(e,t,o)=>(e[t]=o,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),o.mock("three/examples/jsm/Addons.js",async e=>({...await e(),ConvexHull:class{},SkeletonUtils:{clone:e=>e},RectAreaLightHelper:class{}})),o.mock("three-shader-graph",async e=>{const t=await e();return{...t,rgb:(e,o,a)=>null==o&&null==a&&!0===e?.isColor&&"function"==typeof e.getHexString?t.rgb("#"+e.getHexString()):t.rgb(e,o,a)}}),o.mock("../rendering.js",()=>({RenderingView:class{constructor(){this._id="test",this.csm={setupMaterial:o.fn()}}getEnvTexture(e){return e}},setRenderingPaused:o.fn()}));import{materialFromAsset as s,prepareCustomParamsFromShaderGraph as d,prepareShaderGraphParameters as u}from"../scene/materializer.js";import{SerializedParamType as i}from"../scene/model.js";import{AssetsProvider as p}from"../scene/assets-provider.js";import{AssetResourceLoader as l}from"../scene/asset-resource-loader.js";import{buildShaderGraphMaterial as c,buildShaderGraphPostProcessMaterial as m,compileShaderGraph as v,compileShaderGraphPreview as f,createDefaultPostProcessShaderGraphDocument as h,createDefaultShaderGraphDocument as g,createDefaultVfxShaderGraphDocument as y,shaderGraphPostProcessWeightUniformName as I,shaderGraphParametersToPropertyParameters as b}from"../shader/graph/index.js";import{NodeShaderMaterial as x,textureSampler2d as k,UniformFloatNode as B,UniformSampler2dArraySlice as T,vec2 as w}from"../shader-nodes/index.js";import{StandardShader as C}from"../shader/builtin/standard-shader.js";import{sceneMapUniformName as M}from"../shader-nodes/scene-sample.js";function j(e,t,o){return{version:1,target:"surface",parameters:[],nodes:e,edges:t,outputs:o}}function S(e,t){return{id:e,name:e,type:"material",material:{type:"shaderGraph",side:r,params:{},shaderParams:{},shaderGraph:t}}}t("graph validation rejects missing nodes, invalid ports, and incompatible edges",()=>{const t=g();t.outputs.color={input:{nodeId:"missing",port:"value"}},e(()=>v(t)).toThrow(/Missing shader graph node/);const o=g();o.outputs.color={input:{nodeId:"base-color",port:"missing"}},e(()=>v(o)).toThrow(/does not have output/);const a=j([{id:"a",kind:"constant.vec2",params:{value:[1,1]}},{id:"b",kind:"constant.vec3",params:{value:[1,1,1]}},{id:"add",kind:"math.add"}],[{from:{nodeId:"a",port:"value"},to:{nodeId:"add",port:"a"}},{from:{nodeId:"b",port:"value"},to:{nodeId:"add",port:"b"}}],{color:{input:{nodeId:"add",port:"value"}}});e(()=>v(a)).toThrow(/Cannot add vec2 and vec3/)}),t("scene lighting nodes expose renderer ambient and directional light values",()=>{const t=j([{id:"ambient",kind:"scene.ambientLight"},{id:"directional",kind:"scene.directionalLight"}],[],{color:{input:{nodeId:"ambient",port:"color"}}}),o=v(t);e(o.nodes.get("ambient")?.outputs.color.type).toBe("rgb"),e(o.nodes.get("directional")?.outputs.color.type).toBe("rgb"),e(o.nodes.get("directional")?.outputs.direction.type).toBe("vec3");const a=c(t);e(a.fragmentShader).toContain("ambientLightColor"),t.outputs.color={input:{nodeId:"directional",port:"color"}};const r=c(t);e(r.fragmentShader).toContain("directionalLights"),e(r.fragmentShader).toContain("NUM_DIR_LIGHTS"),e(r.fragmentShader).toMatch(/max\s*\(\s*float\s*\(\s*NUM_DIR_LIGHTS\s*\)/)}),t("named reroutes pass values through declarations to multiple usages",()=>{const t=j([{id:"source",kind:"constant.float",params:{value:.75}},{id:"declaration",kind:"utility.namedRerouteDeclaration",params:{name:"Mask"}},{id:"usage-a",kind:"utility.namedRerouteUsage",params:{declarationId:"declaration"}},{id:"usage-b",kind:"utility.namedRerouteUsage",params:{declarationId:"declaration"}},{id:"add",kind:"math.add"}],[{from:{nodeId:"source",port:"value"},to:{nodeId:"declaration",port:"value"}},{from:{nodeId:"usage-a",port:"value"},to:{nodeId:"add",port:"a"}},{from:{nodeId:"usage-b",port:"value"},to:{nodeId:"add",port:"b"}}],{color:{input:{nodeId:"add",port:"value"}}}),o=v(t),a=o.nodes.get("declaration")?.outputs.value;e(a?.type).toBe("float"),e(o.nodes.get("usage-a")?.outputs.value).toBe(a),e(o.nodes.get("usage-b")?.outputs.value).toBe(a),e(o.output.color).toBeTruthy()}),t("named reroutes preserve dynamic vector types",()=>{const t=j([{id:"source",kind:"constant.vec3",params:{value:[1,2,3]}},{id:"declaration",kind:"utility.namedRerouteDeclaration",params:{name:"Direction"}},{id:"usage",kind:"utility.namedRerouteUsage",params:{declarationId:"declaration"}}],[{from:{nodeId:"source",port:"value"},to:{nodeId:"declaration",port:"value"}}],{color:{input:{nodeId:"usage",port:"value"}}}),o=v(t);e(o.nodes.get("usage")?.outputs.value.type).toBe("vec3")}),t("named reroute validation rejects missing and invalid declarations",()=>{const t=j([{id:"usage",kind:"utility.namedRerouteUsage",params:{declarationId:"missing"}}],[],{});e(()=>v(t)).toThrow(/declaration "missing" does not exist/);const o=j([{id:"not-declaration",kind:"constant.float",params:{value:1}},{id:"usage",kind:"utility.namedRerouteUsage",params:{declarationId:"not-declaration"}}],[],{});e(()=>v(o)).toThrow(/not a named reroute declaration/)}),t("named reroute cycles report a compile error",()=>{const t=j([{id:"declaration",kind:"utility.namedRerouteDeclaration",params:{name:"Cycle"}},{id:"usage",kind:"utility.namedRerouteUsage",params:{declarationId:"declaration"}}],[{from:{nodeId:"usage",port:"value"},to:{nodeId:"declaration",port:"value"}}],{color:{input:{nodeId:"usage",port:"value"}}});e(()=>v(t)).toThrow(/contains a cycle/)}),t("surface graph compiles to NodeShaderOutput",()=>{const t=v(g("surface"));e(t.output.color).toBeTruthy(),e(t.output.transparent).toBe(!1)}),t("inline literal inputs compile without separate constant nodes",()=>{const t=j([{id:"uv",kind:"builtin.uv"},{id:"scale-uv",kind:"math.multiply",inputs:{b:{value:2,valueType:"float"}}},{id:"rotate",kind:"uv.rotate",inputs:{angle:{value:.5,valueType:"float"},center:{value:[.25,.75],valueType:"vec2"}}},{id:"translate",kind:"transform.translate",inputs:{offset:{value:[0,1,0],valueType:"vec3"}}}],[{from:{nodeId:"uv",port:"value"},to:{nodeId:"scale-uv",port:"a"}},{from:{nodeId:"scale-uv",port:"value"},to:{nodeId:"rotate",port:"uv"}}],{color:{input:{nodeId:"rotate",port:"value"}},transform:{input:{nodeId:"translate",port:"value"}}}),o=v(t);e(o.nodes.get("scale-uv")?.outputs.value.type).toBe("vec2"),e(o.nodes.get("rotate")?.outputs.value.type).toBe("vec2"),e(o.nodes.get("translate")?.outputs.value.type).toBe("mat4"),e(o.output.color).toBeTruthy(),e(o.output.transform).toBeTruthy()}),t("uv attribute node supports selecting non-default uv channels",()=>{const t=j([{id:"uv",kind:"builtin.uv",params:{channel:2}}],[],{color:{input:{nodeId:"uv",port:"value"}}}),o=v(t);e(o.nodes.get("uv")?.outputs.value.type).toBe("vec2");const a=c(t);e(a.fragmentShader).toContain("uv2")}),t("sprite graph compiles with sprite-compatible material output",()=>{const t=g("sprite"),o=v(t);e(o.output.color).toBeTruthy(),e(o.output.transparent).toBe(!0)}),t("default VFX shader graphs compile mapped targets and build non-sprite materials",()=>{for(const t of["sprite","surface","decal","trail"]){const o=y(t);if(e(v(o).output.color).toBeTruthy(),"sprite"===t)continue;const a=c(o,{trailBillboard:"trail"===t});e(a.isMaterial).toBe(!0)}}),t("graph material options apply to supported material targets",()=>{const t=g("surface");t.materialOptions={side:"double",transparent:!0};const o=c(t);e(o.side).toBe(a),e(o.transparent).toBe(!0);const r=g("decal");r.materialOptions={transparent:!0};const n=c(r);e(n.transparent).toBe(!0)}),t("standard graph compiles extended material outputs and vertex transform",()=>{const t=j([{id:"metalness",kind:"constant.float",params:{value:.8}},{id:"ao",kind:"constant.float",params:{value:.5}},{id:"sheen",kind:"constant.vec3",params:{value:[.1,.2,.3]}},{id:"offset",kind:"constant.vec3",params:{value:[0,1,0]}},{id:"translate",kind:"transform.translate"}],[{from:{nodeId:"offset",port:"value"},to:{nodeId:"translate",port:"offset"}}],{metalness:{input:{nodeId:"metalness",port:"value"}},ambientOcclusion:{input:{nodeId:"ao",port:"value"}},sheenColor:{input:{nodeId:"sheen",port:"value"}},transform:{input:{nodeId:"translate",port:"value"}}}),o=v(t);e(o.output.metalness).toBeTruthy(),e(o.output.ambientOcclusion).toBeTruthy(),e(o.output.sheenColor).toBeTruthy(),e(o.output.transform).toBeTruthy();const a=c(t);e(a.outputMetalness).toBeTruthy(),e(a.outputAmbientOcclusion).toBeTruthy(),e(a.outputTransform).toBeTruthy()}),t("math multiply composes transform matrices",()=>{const t=j([{id:"translate",kind:"transform.translate",inputs:{offset:{value:[0,1,0],valueType:"vec3"}}},{id:"scale",kind:"transform.scale",inputs:{scale:{value:[2,2,2],valueType:"vec3"}}},{id:"compose",kind:"math.multiply"}],[{from:{nodeId:"translate",port:"value"},to:{nodeId:"compose",port:"a"}},{from:{nodeId:"scale",port:"value"},to:{nodeId:"compose",port:"b"}}],{transform:{input:{nodeId:"compose",port:"value"}}}),o=v(t);e(o.nodes.get("compose")?.outputs.value.type).toBe("mat4"),e(o.output.transform).toBeTruthy(),e(c(t).outputTransform).toBeTruthy()}),t("matrix input nodes compile as mat4 uniforms",()=>{for(const t of["builtin.viewMatrix","builtin.projectionMatrix","builtin.modelViewMatrix"]){const o=j([{id:"matrix",kind:t}],[],{transform:{input:{nodeId:"matrix",port:"value"}}}),a=v(o);e(a.nodes.get("matrix")?.outputs.value.type).toBe("mat4"),e(a.output.transform).toBeTruthy()}}),t("compose vec4 node compiles from float components",()=>{const t=j([{id:"compose",kind:"compose.vec4",inputs:{x:{value:.1},y:{value:.2},z:{value:.3},w:{value:1}}}],[],{color:{input:{nodeId:"compose",port:"value"}}}),o=v(t);e(o.nodes.get("compose")?.outputs.value.type).toBe("vec4"),e(o.output.color).toBeTruthy()}),t("atan2 node compiles from y and x inputs",()=>{const t=j([{id:"atan2",kind:"math.atan2",inputs:{y:{value:1},x:{value:0}}}],[],{color:{input:{nodeId:"atan2",port:"value"}}}),o=v(t);e(o.nodes.get("atan2")?.outputs.value.type).toBe("float"),e(o.output.color).toBeTruthy()}),t("built-in attribute nodes compile for fragment and transform outputs",()=>{const t=j([{id:"color",kind:"builtin.color"},{id:"normal",kind:"builtin.normal"},{id:"position",kind:"builtin.position"},{id:"worldPosition",kind:"builtin.worldPosition"},{id:"objectPosition",kind:"builtin.objectPosition"},{id:"translate",kind:"transform.translate"}],[{from:{nodeId:"objectPosition",port:"value"},to:{nodeId:"translate",port:"offset"}}],{color:{input:{nodeId:"color",port:"value"}},normal:{input:{nodeId:"normal",port:"value"}},roughness:{input:{nodeId:"position",port:"x"}},metalness:{input:{nodeId:"worldPosition",port:"x"}},transform:{input:{nodeId:"translate",port:"value"}}}),o=v(t);e(o.nodes.get("color")?.outputs.value.type).toBe("rgba"),e(o.nodes.get("normal")?.outputs.value.type).toBe("vec3"),e(o.nodes.get("position")?.outputs.value.type).toBe("vec3"),e(o.nodes.get("worldPosition")?.outputs.value.type).toBe("vec3"),e(o.nodes.get("objectPosition")?.outputs.value.type).toBe("vec3"),e(o.output.color).toBeTruthy(),e(o.output.normal).toBeTruthy(),e(o.output.transform).toBeTruthy(),e(c(t).outputTransform).toBeTruthy()}),t("painted layer mix node compiles color layers and builds a material",()=>{const t=j([{id:"base",kind:"constant.rgba",params:{value:"#224422",alpha:1}},{id:"grass",kind:"constant.rgba",params:{value:"#55aa33",alpha:1}},{id:"stone",kind:"constant.rgba",params:{value:"#777777",alpha:1}},{id:"mix",kind:"layer.mixPainted",params:{mode:"soft",enableNoise:!0,softness:.25,noiseScale:.2,noiseAmount:.4}}],[{from:{nodeId:"base",port:"value"},to:{nodeId:"mix",port:"base"}},{from:{nodeId:"grass",port:"value"},to:{nodeId:"mix",port:"layer1"}},{from:{nodeId:"stone",port:"value"},to:{nodeId:"mix",port:"layer3"}}],{color:{input:{nodeId:"mix",port:"value"}}}),o=v(t);e(o.nodes.get("mix")?.outputs.value.type).toBe("rgba"),e(o.output.color).toBeTruthy(),e(c(t).isMaterial).toBe(!0)}),t("painted layer mix node compiles sparse float layers in hard and soft modes",()=>{for(const t of["hard","soft"]){const o=j([{id:"base",kind:"constant.float",params:{value:.8}},{id:"mud",kind:"constant.float",params:{value:1}},{id:"wet",kind:"constant.float",params:{value:.25}},{id:"mix",kind:"layer.mixPainted",params:{mode:t,enableNoise:!1,softness:.3,noiseScale:.1,noiseAmount:.5}}],[{from:{nodeId:"base",port:"value"},to:{nodeId:"mix",port:"base"}},{from:{nodeId:"mud",port:"value"},to:{nodeId:"mix",port:"layer2"}},{from:{nodeId:"wet",port:"value"},to:{nodeId:"mix",port:"layer8"}}],{roughness:{input:{nodeId:"mix",port:"value"}}}),a=v(o);e(a.nodes.get("mix")?.outputs.value.type).toBe("float"),e(a.output.roughness).toBeTruthy()}}),t("painted layer mix node rejects incompatible layer types",()=>{const t=j([{id:"base",kind:"constant.vec2",params:{value:[0,1]}},{id:"layer",kind:"constant.vec3",params:{value:[1,0,0]}},{id:"mix",kind:"layer.mixPainted"}],[{from:{nodeId:"base",port:"value"},to:{nodeId:"mix",port:"base"}},{from:{nodeId:"layer",port:"value"},to:{nodeId:"mix",port:"layer1"}}],{color:{input:{nodeId:"mix",port:"value"}}});e(()=>v(t)).toThrow(/Cannot mix vec2 and vec3/)}),t("additional math, uv, shape, noise, and effect nodes compile",()=>{const t=j([{id:"uv",kind:"builtin.uv"},{id:"angle",kind:"constant.float",params:{value:.5}},{id:"scale",kind:"constant.float",params:{value:4}},{id:"half",kind:"constant.float",params:{value:.5}},{id:"axis",kind:"constant.vec3",params:{value:[0,1,0]}},{id:"rotatedUv",kind:"uv.rotate"},{id:"twirl",kind:"uv.twirl"},{id:"bulge",kind:"uv.bulge"},{id:"radial",kind:"uv.radial"},{id:"flipbook",kind:"uv.flipbook"},{id:"shape",kind:"shape.roundedRectangle"},{id:"noise",kind:"noise.simplex"},{id:"voronoi",kind:"noise.voronoi2d"},{id:"fresnel",kind:"effect.fresnel"},{id:"sin",kind:"math.sin"},{id:"pow",kind:"math.pow"},{id:"mix",kind:"math.mix"},{id:"rotateAxis",kind:"transform.rotateAxis"}],[{from:{nodeId:"uv",port:"value"},to:{nodeId:"rotatedUv",port:"uv"}},{from:{nodeId:"angle",port:"value"},to:{nodeId:"rotatedUv",port:"angle"}},{from:{nodeId:"rotatedUv",port:"value"},to:{nodeId:"twirl",port:"uv"}},{from:{nodeId:"scale",port:"value"},to:{nodeId:"twirl",port:"strength"}},{from:{nodeId:"twirl",port:"value"},to:{nodeId:"bulge",port:"uv"}},{from:{nodeId:"half",port:"value"},to:{nodeId:"bulge",port:"power"}},{from:{nodeId:"bulge",port:"value"},to:{nodeId:"radial",port:"uv"}},{from:{nodeId:"twirl",port:"value"},to:{nodeId:"flipbook",port:"uv"}},{from:{nodeId:"twirl",port:"value"},to:{nodeId:"shape",port:"uv"}},{from:{nodeId:"half",port:"value"},to:{nodeId:"shape",port:"width"}},{from:{nodeId:"half",port:"value"},to:{nodeId:"shape",port:"height"}},{from:{nodeId:"half",port:"value"},to:{nodeId:"shape",port:"radius"}},{from:{nodeId:"twirl",port:"value"},to:{nodeId:"noise",port:"uv"}},{from:{nodeId:"scale",port:"value"},to:{nodeId:"noise",port:"scale"}},{from:{nodeId:"twirl",port:"value"},to:{nodeId:"voronoi",port:"uv"}},{from:{nodeId:"scale",port:"value"},to:{nodeId:"voronoi",port:"scale"}},{from:{nodeId:"scale",port:"value"},to:{nodeId:"fresnel",port:"power"}},{from:{nodeId:"radial",port:"angle"},to:{nodeId:"sin",port:"value"}},{from:{nodeId:"noise",port:"value"},to:{nodeId:"pow",port:"a"}},{from:{nodeId:"half",port:"value"},to:{nodeId:"pow",port:"b"}},{from:{nodeId:"shape",port:"value"},to:{nodeId:"mix",port:"a"}},{from:{nodeId:"voronoi",port:"value"},to:{nodeId:"mix",port:"b"}},{from:{nodeId:"fresnel",port:"value"},to:{nodeId:"mix",port:"t"}},{from:{nodeId:"axis",port:"value"},to:{nodeId:"rotateAxis",port:"axis"}},{from:{nodeId:"angle",port:"value"},to:{nodeId:"rotateAxis",port:"angle"}}],{color:{input:{nodeId:"mix",port:"value"}},opacity:{input:{nodeId:"sin",port:"value"}},anisotropyDirection:{input:{nodeId:"flipbook",port:"value"}},transform:{input:{nodeId:"rotateAxis",port:"value"}}}),o=v(t);e(o.nodes.get("mix")?.outputs.value.type).toBe("float"),e(o.nodes.get("twirl")?.outputs.value.type).toBe("vec2"),e(o.nodes.get("bulge")?.outputs.value.type).toBe("vec2"),e(o.nodes.get("radial")?.outputs.coords.type).toBe("vec2"),e(o.nodes.get("flipbook")?.outputs.value.type).toBe("vec2"),e(o.nodes.get("rotateAxis")?.outputs.value.type).toBe("mat4"),e(o.output.color).toBeTruthy(),e(o.output.opacity).toBeTruthy(),e(o.output.transform).toBeTruthy()}),t("slider input node outputs a clamped float value",()=>{const t=j([{id:"slider",kind:"input.slider",params:{value:1.25}}],[],{roughness:{input:{nodeId:"slider",port:"value"}}}),o=v(t);e(o.nodes.get("slider")?.outputs.value.type).toBe("float"),e(o.output.roughness).toBeTruthy()}),t("comparison and boolean graph nodes compile to boolean outputs",()=>{const t=j([{id:"a",kind:"constant.float",params:{value:.75}},{id:"b",kind:"constant.float",params:{value:.5}},{id:"greater",kind:"compare.greaterThan"},{id:"lessEqual",kind:"compare.lessThanOrEqual"},{id:"not",kind:"boolean.not"},{id:"and",kind:"boolean.and"},{id:"or",kind:"boolean.or"}],[{from:{nodeId:"a",port:"value"},to:{nodeId:"greater",port:"a"}},{from:{nodeId:"b",port:"value"},to:{nodeId:"greater",port:"b"}},{from:{nodeId:"a",port:"value"},to:{nodeId:"lessEqual",port:"a"}},{from:{nodeId:"b",port:"value"},to:{nodeId:"lessEqual",port:"b"}},{from:{nodeId:"lessEqual",port:"value"},to:{nodeId:"not",port:"value"}},{from:{nodeId:"greater",port:"value"},to:{nodeId:"and",port:"a"}},{from:{nodeId:"not",port:"value"},to:{nodeId:"and",port:"b"}},{from:{nodeId:"and",port:"value"},to:{nodeId:"or",port:"a"}},{from:{nodeId:"lessEqual",port:"value"},to:{nodeId:"or",port:"b"}}],{discard:{input:{nodeId:"or",port:"value"}}}),o=v(t);e(o.nodes.get("greater")?.outputs.value.type).toBe("boolean"),e(o.nodes.get("lessEqual")?.outputs.value.type).toBe("boolean"),e(o.nodes.get("not")?.outputs.value.type).toBe("boolean"),e(o.nodes.get("and")?.outputs.value.type).toBe("boolean"),e(o.nodes.get("or")?.outputs.value.type).toBe("boolean"),e(o.output.discard).toBeTruthy()}),t("split component node exposes vector and color channels as separate outputs",()=>{const t=j([{id:"vec",kind:"constant.vec3",params:{value:[.2,.4,.6]}},{id:"color",kind:"constant.rgba",params:{value:"#336699",alpha:.75}},{id:"splitVec",kind:"decompose.split"},{id:"splitColor",kind:"decompose.split"},{id:"sum",kind:"math.add"}],[{from:{nodeId:"vec",port:"value"},to:{nodeId:"splitVec",port:"value"}},{from:{nodeId:"color",port:"value"},to:{nodeId:"splitColor",port:"value"}},{from:{nodeId:"splitVec",port:"z"},to:{nodeId:"sum",port:"a"}},{from:{nodeId:"splitColor",port:"a"},to:{nodeId:"sum",port:"b"}}],{roughness:{input:{nodeId:"sum",port:"value"}}}),o=v(t);e(Object.keys(o.nodes.get("splitVec")?.outputs??{})).toEqual(["x","y","z"]),e(Object.keys(o.nodes.get("splitColor")?.outputs??{})).toEqual(["r","g","b","a","x","y","z","w"]),e(o.nodes.get("splitVec")?.outputs.z.type).toBe("float"),e(o.nodes.get("splitColor")?.outputs.a.type).toBe("float"),e(o.output.roughness).toBeTruthy()}),t("color gradient node compiles editable stops to a sampled color",()=>{const t=j([{id:"threshold",kind:"constant.float",params:{value:.75}},{id:"gradient",kind:"color.gradient",params:{stops:[{position:0,color:"#000000",alpha:1},{position:.5,color:"#ff0000",alpha:1},{position:1,color:"#ffffff",alpha:1}]}}],[{from:{nodeId:"threshold",port:"value"},to:{nodeId:"gradient",port:"t"}}],{color:{input:{nodeId:"gradient",port:"value"}}}),o=v(t);e(o.nodes.get("gradient")?.outputs.value.type).toBe("rgba"),e(o.output.color).toBeTruthy(),e(f(t,"gradient").color).toBeTruthy()}),t("curve node compiles editable curve to a sampled float",()=>{const t=j([{id:"t",kind:"constant.float",params:{value:.75}},{id:"curve",kind:"utility.curve",params:{curve:"InOutQuad"}}],[{from:{nodeId:"t",port:"value"},to:{nodeId:"curve",port:"t"}}],{roughness:{input:{nodeId:"curve",port:"value"}}}),o=v(t);e(o.nodes.get("curve")?.outputs.value.type).toBe("float"),e(o.output.roughness).toBeTruthy(),e(f(t,"curve").color).toBeTruthy()}),t("graph parameters convert to property params and custom defaults",()=>{const t=g();t.parameters.push({name:"opacity",type:"float",group:"Surface",defaultValue:.5});const o=b(t);e(o.map(e=>e.name)).toEqual(["baseColor","opacity"]),e(o.every(e=>!0===e.options.optional)).toBe(!0),e(o.map(e=>e.options.group??null)).toEqual([null,"Surface"]);const a=d(t);e(a.baseColor).toMatchObject({type:i.RgbNode,value:"#ffffff",override:!1}),e(a.opacity).toMatchObject({type:i.FloatNode,value:.5,override:!1})}),t("existing shader graph param values keep overriding unless explicitly disabled",()=>{const t=g(),o=d(t,{baseColor:{type:i.RgbNode,value:"#ff0000"}});e(o.baseColor).toMatchObject({type:i.RgbNode,value:"#ff0000",override:!0});const a=d(t,{baseColor:{type:i.RgbNode,value:"#00ff00",override:!1}});e(a.baseColor).toMatchObject({type:i.RgbNode,value:"#ffffff",override:!1})}),t("texture graph parameter defaults serialize as sampler parameters",()=>{const t=j([],[],{});t.parameters.push({name:"map",type:"texture",defaultValue:"texture-asset-id"});const o=d(t);e(o.map).toMatchObject({type:i.Sampler2DNode,value:"texture-asset-id"})}),t("shader graph params keep graph-declared serialized types over previous string params",()=>{const t=g();t.parameters.push({name:"map",type:"texture",defaultValue:null});const o=d(t,{baseColor:{type:i.String,value:"#ff0000"},map:{type:i.String,value:"texture-asset-id"}});e(o.baseColor).toMatchObject({type:i.RgbNode,value:"#ff0000"}),e(o.map).toMatchObject({type:i.Sampler2DNode,value:"texture-asset-id"})}),t("shader graph texture params resolve asset ids before compile",async()=>{const t=j([{id:"texture",kind:"input.parameter",params:{parameter:"map"}}],[],{color:{input:{nodeId:"texture",port:"value"}}});t.parameters.push({name:"map",type:"texture",defaultValue:"texture-asset-id"});const o=await u({},t,new D([{id:"texture-asset-id",name:"Map",type:"texture",fileKey:"map.png",fileFormat:"png"}]),new A);e(o.map).toBeTruthy(),e(typeof o.map).not.toBe("string"),e(v(t,{parameters:o}).output.color).toBeTruthy()}),t("shader graph texture array params expose named slice uniforms",async()=>{const t=j([{id:"texture",kind:"input.parameter",params:{parameter:"map"}}],[],{color:{input:{nodeId:"texture",port:"value"}}});t.parameters.push({name:"map",type:"texture",defaultValue:"texture-array-layer"});const o=await u({},t,new D([{id:"texture-array-layer",name:"Map Layer",type:"texture",fileKey:"map.png",fileFormat:"png",texture:{textureArrayFileKey:"textures/packed.ktx2",textureArrayLayer:5}}]),new O(new n,5)),a=c(t,{parameters:o});e(a.uniforms.pu_map_i?.value).toBe(5),e(a.fragmentShader).toContain("uniform float pu_map_i")}),t("standard shader height maps support texture array slices",()=>{const t=new C;t.heightMap=new T("heightMap",new n,new B("heightMap_i",3,void 0,!1)),t.map=k(new n),t.heightScale=.05;const o=t.build();e(o.fragmentShader).toContain("uniform sampler2DArray heightMap"),e(o.fragmentShader).toContain("uniform float heightMap_i"),e(o.fragmentShader).toContain("texture(heightMap, vec3(uv + vCurrOffset, heightMapLayer)).r")}),t("standard shader supports particle opacity for instanced VFX materials",()=>{const t=(new C).build();e(t.fragmentShader).toContain("IS_PARTICLE"),e(t.fragmentShader).toContain("particleData")}),t("material graph asset reference and local embedded graph both build materials",async()=>{const t={id:"graph-asset",name:"Reusable Graph",type:"shaderGraph",shaderGraph:g()},o=S("referenced",{source:"asset",assetId:t.id}),a=S("local",{source:"local",graph:g()}),r=new D([t,o,a]),n=new l;e((await s(o,null,r,n,[],!1)).isMaterial).toBe(!0),e((await s(a,null,r,n,[],!1)).isMaterial).toBe(!0)}),t("selected-node preview compiles scalar, vector, color, rgba, and boolean outputs",()=>{const t=[{id:"float",kind:"constant.float",params:{value:.25}},{id:"slider",kind:"input.slider",params:{value:.5}},{id:"vec2",kind:"constant.vec2",params:{value:[1,0]}},{id:"rgb",kind:"constant.vec3",params:{value:[1,.5,0]}},{id:"rgba",kind:"constant.rgba",params:{value:"#ff8800",alpha:.75}},{id:"boolean",kind:"constant.boolean",params:{value:!0}}];for(const o of t){const t=j([o],[],{color:{input:{nodeId:o.id,port:"value"}}});e(f(t,o.id).color).toBeTruthy()}}),t("post-process shader graph builds a fullscreen-safe material",()=>{const t=h(),o=m(t);e(o).toBeInstanceOf(x),e(o.depthWrite).toBe(!1),e(o.depthTest).toBe(!1),e(o.toneMapped).toBe(!1),e(o.transparent).toBe(!1),e(o.userData.isPostProcessShaderGraph).toBe(!0),e(o.uniforms[M]).toBeTruthy(),e(o.uniforms[I]).toBeTruthy()}),t("empty post-process shader graph defaults to identity scene color",()=>{const t=h();t.nodes=[],t.edges=[],t.outputs={};const o=v(t),a=m(t);e(o.output.color).toBeTruthy(),e(o.output.transparent).toBe(!1),e(a.uniforms[M]).toBeTruthy(),e(a.uniforms[I]).toBeTruthy()}),t("post-process color and opacity outputs expose blend weight uniform",()=>{const t=j([{id:"color",kind:"constant.rgba",params:{value:"#ff0044",alpha:1}},{id:"opacity",kind:"constant.float",params:{value:.35}}],[],{color:{input:{nodeId:"color",port:"value"}},opacity:{input:{nodeId:"opacity",port:"value"}},roughness:{value:.8}});t.target="postProcess";const o=v(t),a=m(t);e(o.output.color).toBeTruthy(),e(o.output.opacity).toBeTruthy(),e(o.output.roughness).toBeUndefined(),e(a.uniforms[I]).toBeTruthy()}),t("selected-node preview builds through post-process fullscreen path",()=>{const t=j([{id:"float",kind:"constant.float",params:{value:.25}}],[],{color:{input:{nodeId:"float",port:"value"}}});t.target="postProcess";const o=m(t,{previewNodeId:"float"});e(o).toBeInstanceOf(x),e(o.userData.isPostProcessShaderGraph).toBe(!0),e(o.uniforms[M]).toBeTruthy(),e(o.uniforms[I]).toBeTruthy()}),t("texture parameter nodes expose sampled value and channel outputs",()=>{const t=j([{id:"texture",kind:"input.parameter",params:{parameter:"albedo"}}],[],{color:{input:{nodeId:"texture",port:"value"}}});t.parameters.push({name:"albedo",type:"texture",defaultValue:null});const o=v(t,{parameters:{albedo:new n}}),a=o.nodes.get("texture")?.outputs??{};e(Object.keys(a)).toEqual(["value","rgb","r","g","b","a","sampler"]),e(a.value.type).toBe("rgba"),e(a.rgb.type).toBe("rgb"),e(a.r.type).toBe("float"),e(a.g.type).toBe("float"),e(a.b.type).toBe("float"),e(a.a.type).toBe("float"),e(a.sampler.type).toBe("sampler2d"),e(o.output.color).toBeTruthy()}),t("texture parameter nodes accept resolved sampler values",()=>{const t=j([{id:"texture",kind:"input.parameter",params:{parameter:"map"}}],[],{color:{input:{nodeId:"texture",port:"value"}}});t.parameters.push({name:"map",type:"texture",defaultValue:"texture-asset-id"});const o=v(t,{parameters:{map:k(new n)}});e(o.output.color).toBeTruthy()}),t("missing texture parameter values warn and use fallback sampler",()=>{const t=j([{id:"texture",kind:"input.parameter",params:{parameter:"missingTexture"}}],[],{color:{input:{nodeId:"texture",port:"value"}}});t.parameters.push({name:"missingTexture",type:"texture",defaultValue:null});const a=o.spyOn(console,"warn").mockImplementation(()=>{});try{const o=v(t),r=o.nodes.get("texture")?.outputs??{};e(r.sampler.type).toBe("sampler2d"),e(o.output.color).toBeTruthy(),e(a).toHaveBeenCalledWith(e.stringContaining("missingTexture"))}finally{a.mockRestore()}}),t("scene depth graph nodes expose sampled and fragment linear eye depth",()=>{const t=j([{id:"uv",kind:"builtin.screenUv"},{id:"sceneDepth",kind:"scene.sampleDepth"},{id:"fragmentDepth",kind:"scene.fragmentDepth"},{id:"depthFade",kind:"math.subtract"}],[{from:{nodeId:"uv",port:"value"},to:{nodeId:"sceneDepth",port:"uv"}},{from:{nodeId:"sceneDepth",port:"linearEyeDepth"},to:{nodeId:"depthFade",port:"a"}},{from:{nodeId:"fragmentDepth",port:"linearEyeDepth"},to:{nodeId:"depthFade",port:"b"}}],{opacity:{input:{nodeId:"depthFade",port:"value"}}}),o=v(t);e(o.nodes.get("sceneDepth")?.outputs.depth.type).toBe("float"),e(o.nodes.get("sceneDepth")?.outputs.linearEyeDepth.type).toBe("float"),e(o.nodes.get("fragmentDepth")?.outputs.linearEyeDepth.type).toBe("float"),e(o.output.opacity).toBeTruthy()}),t("screen UV builtin accepts a fullscreen compiler override",()=>{const t=j([{id:"uv",kind:"builtin.screenUv"}],[],{}),o=w(.25,.75),a=v(t,{screenUV:o});e(a.nodes.get("uv")?.outputs.value.value).toBe(o)}),t("unpack normal node compiles with various input combinations",()=>{const t=j([{id:"sample",kind:"constant.vec3",params:{value:[.5,.5,1]}},{id:"scale",kind:"constant.float",params:{value:1}},{id:"normal",kind:"builtin.normal"},{id:"toNormal",kind:"color.unpackNormal"}],[{from:{nodeId:"sample",port:"value"},to:{nodeId:"toNormal",port:"color"}},{from:{nodeId:"scale",port:"value"},to:{nodeId:"toNormal",port:"scale"}},{from:{nodeId:"normal",port:"value"},to:{nodeId:"toNormal",port:"normal"}}],{normal:{input:{nodeId:"toNormal",port:"value"}}}),o=v(t);e(o.nodes.get("toNormal")?.outputs.value.type).toBe("vec3"),e(o.output.normal).toBeTruthy()});class D extends p{constructor(e){super(),this.onCreate=null,this.onDelete=null,this.onUpdate=null,this.assets=new Map(e.map(e=>[e.id,e]))}async getAsset(e){return this.assets.get(e)}async getAssets(){return Array.from(this.assets.values())}}class A extends l{async getTexture(){return new n}async getTextureArray(){return{texture:null,layerIndex:null}}}class O extends A{constructor(e,t){super(),this.textureArray=e,this.layerIndex=t}async getTextureArray(){return{texture:this.textureArray,layerIndex:this.layerIndex}}}/*
|
|
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{Emitter as t,Particle as
|
|
1
|
+
import{Emitter as o,InitializerUtil as t,Particle as e,Position as i}from"@hology/nebula";import{Euler as n,Quaternion as s}from"three";import{describe as r,expect as a,test as l}from"vitest";import{WorldSpaceEmitterTransformInitializer as p}from"../effects/vfx/initializsers.js";import{PointZone as w}from"../effects/vfx/zones.js";r("world-space VFX emitter rotation",()=>{l("composes emitter rotation with the particle local rotation once at spawn",()=>{const t=new o;t.rotation.set(0,Math.PI/2,0);const i=new e;i.rotation.set(Math.PI/6,0,0),i.position.set(0,0,2),new p(!0).init(t,i),a(i.position.x).toBeCloseTo(2),a(i.position.y).toBeCloseTo(0),a(i.position.z).toBeCloseTo(0);const r=(new s).setFromEuler(new n(i.rotation.x,i.rotation.y,i.rotation.z)),l=(new s).setFromEuler(new n(0,Math.PI/2,0)).multiply((new s).setFromEuler(new n(Math.PI/6,0,0)));a(Math.abs(r.dot(l))).toBeCloseTo(1),t.rotation.set(0,Math.PI,0);const w=(new s).setFromEuler(new n(i.rotation.x,i.rotation.y,i.rotation.z));a(Math.abs(w.dot(l))).toBeCloseTo(1)}),l("rotates an authored point before adding the world-space emitter origin",()=>{const n=new o;n.position.set(10,0,20),n.rotation.set(0,Math.PI/2,0);const s=new e;n.addInitializers([new i(new w(0,0,1.8)),new p(!1)]),t.initialize(n,s,n.initializers),a(s.position.x).toBeCloseTo(11.8),a(s.position.y).toBeCloseTo(0),a(s.position.z).toBeCloseTo(20)})});/*
|
|
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.244",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -220,7 +220,7 @@
|
|
|
220
220
|
"recast-navigation": "0.39.0",
|
|
221
221
|
"rxjs": "7.8.1",
|
|
222
222
|
"three-mesh-bvh": "^0.7.5",
|
|
223
|
-
"three-shader-graph": "0.2.
|
|
223
|
+
"three-shader-graph": "0.2.63",
|
|
224
224
|
"three-stdlib": "2.34.0",
|
|
225
225
|
"ts-key-enum": "^2.0.12",
|
|
226
226
|
"typedi": "^0.10.0"
|
|
@@ -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.10"
|
|
243
243
|
}
|
|
244
244
|
}
|