@hology/core 0.0.270 → 0.0.272
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/effects/sequence/sequence-player.js +1 -1
- package/dist/effects/vfx/gpu-sprite-compiler.js +1 -1
- package/dist/effects/vfx/gpu-sprite-runtime.js +1 -1
- package/dist/effects/vfx/gpu-vfx-world-service.d.ts +2 -0
- package/dist/effects/vfx/gpu-vfx-world-service.js +1 -1
- package/dist/effects/vfx/vfx-actor.js +1 -1
- package/dist/effects/vfx/vfx-materializer.js +1 -1
- package/dist/effects/vfx/vfx-render-preparation.d.ts +3 -0
- package/dist/effects/vfx/vfx-render-preparation.js +1 -1
- package/dist/gameplay/actors/builtin/components/spline-component.d.ts +7 -39
- package/dist/gameplay/actors/builtin/components/spline-component.js +1 -1
- package/dist/gameplay/actors/builtin/navmesh-actor.d.ts +16 -2
- package/dist/gameplay/actors/builtin/navmesh-actor.js +1 -1
- package/dist/gameplay/actors/builtin/water-spline-volume-actor.d.ts +2 -2
- package/dist/gameplay/actors/builtin/water-spline-volume-actor.js +1 -1
- package/dist/gameplay/ai/build-tile.js +1 -1
- package/dist/gameplay/ai/dynamic-tiled-navmesh.js +1 -1
- package/dist/gameplay/ai/dynamic-tiled-navmesh.worker.js +1 -1
- package/dist/gameplay/ai/navigation.d.ts +2 -0
- package/dist/gameplay/ai/navigation.js +1 -1
- package/dist/gameplay/initiate.js +1 -1
- package/dist/gameplay/services/physics/physics-system.d.ts +1 -1
- package/dist/gameplay/services/physics/physics-system.js +1 -1
- package/dist/gameplay/services/render.js +1 -1
- package/dist/navigation/bake.d.ts +68 -0
- package/dist/navigation/bake.js +4 -0
- package/dist/navigation/cache.d.ts +11 -0
- package/dist/navigation/cache.js +4 -0
- package/dist/navigation/spline-data.d.ts +64 -0
- package/dist/navigation/spline-data.js +4 -0
- package/dist/navigation/water-spline-geometry.d.ts +23 -0
- package/dist/navigation/water-spline-geometry.js +4 -0
- package/dist/scene/asset-resource-loader.d.ts +7 -3
- package/dist/scene/asset-resource-loader.js +1 -1
- package/dist/scene/bootstrap.js +1 -1
- package/dist/scene/collision/authored-colliders.d.ts +4 -0
- package/dist/scene/collision/authored-colliders.js +4 -0
- package/dist/scene/collision/rapier-shape.d.ts +7 -0
- package/dist/scene/collision/rapier-shape.js +4 -0
- package/dist/scene/materializer.d.ts +4 -1
- package/dist/scene/materializer.js +1 -1
- package/dist/scene/model.d.ts +4 -0
- package/dist/scene/native-texture.d.ts +13 -0
- package/dist/scene/native-texture.js +4 -0
- package/dist/scene/objects/shapes.js +1 -1
- package/dist/scene/runtime-backend-service.d.ts +1 -0
- package/dist/scene/runtime-bundled-backend-service.d.ts +1 -0
- package/dist/scene/runtime-bundled-backend-service.js +1 -1
- package/dist/shader-nodes/particle-parameter.d.ts +4 -0
- package/dist/shader-nodes/particle-parameter.js +4 -0
- package/dist/test/asset-resource-loader-texture-array.test.js +1 -1
- package/dist/test/authored-collision-rescale.test.js +1 -1
- package/dist/test/gpu-sprite-compiler.test.js +1 -1
- package/dist/test/gpu-sprite-pipeline-cache.test.js +1 -1
- package/dist/test/gpu-vfx-preload.test.d.ts +2 -0
- package/dist/test/gpu-vfx-preload.test.js +4 -0
- package/dist/test/materializer-prefetch.test.js +1 -1
- package/dist/test/native-texture.test.d.ts +2 -0
- package/dist/test/native-texture.test.js +4 -0
- package/dist/test/navigation-bake-backend.test.d.ts +2 -0
- package/dist/test/navigation-bake-backend.test.js +4 -0
- package/dist/test/navigation-bake.test.d.ts +2 -0
- package/dist/test/navigation-bake.test.js +4 -0
- package/dist/test/sequence-vfx.test.js +1 -1
- package/dist/test/shader-graph.test.js +1 -1
- package/dist/test/vfx-render-preparation.test.js +1 -1
- package/package.json +6 -2
- package/tsconfig.tsbuildinfo +1 -1
package/dist/scene/model.d.ts
CHANGED
|
@@ -222,6 +222,10 @@ export type TextureSettings = {
|
|
|
222
222
|
* texture participates in.
|
|
223
223
|
*/
|
|
224
224
|
textureArrayFileKey?: string;
|
|
225
|
+
/** Build-generated GPU-native alternatives; fileKey remains the portable fallback. */
|
|
226
|
+
nativeFileKeys?: Partial<Record<'bc7' | 'astc', string>>;
|
|
227
|
+
/** GPU-native alternatives for textureArrayFileKey, with identical layer ordering. */
|
|
228
|
+
nativeArrayFileKeys?: Partial<Record<'bc7' | 'astc', string>>;
|
|
225
229
|
};
|
|
226
230
|
export type AnimationRigBone = {
|
|
227
231
|
/** Stable hierarchy path. Bone names alone are not guaranteed to be unique. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { CompressedTexture } from 'three';
|
|
2
|
+
export type NativeTextureFormat = 'bc7' | 'astc';
|
|
3
|
+
export type NativeTextureFileKeys = Partial<Record<NativeTextureFormat, string>>;
|
|
4
|
+
export declare function selectNativeTexture(variants: NativeTextureFileKeys | undefined, bc: boolean, astc: boolean): {
|
|
5
|
+
fileKey: string;
|
|
6
|
+
format: NativeTextureFormat;
|
|
7
|
+
} | undefined;
|
|
8
|
+
/** Read the narrowly scoped native KTX2 output emitted by our build plugin.
|
|
9
|
+
* No decoding or payload copies: mip data is viewed directly in the fetched buffer.
|
|
10
|
+
* Basis/supercompressed files continue to use Three's KTX2Loader.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseNativeTexture(buffer: ArrayBuffer, expectedFormat: NativeTextureFormat): CompressedTexture;
|
|
13
|
+
//# sourceMappingURL=native-texture.d.ts.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import{CompressedArrayTexture as t,CompressedTexture as e,LinearFilter as r,LinearMipmapLinearFilter as n,NoColorSpace as i,RGBA_ASTC_4x4_Format as a,RGBA_BPTC_Format as o,SRGBColorSpace as h,UnsignedByteType as c}from"three";export function selectNativeTexture(t,e,r){return e&&t?.bc7?{fileKey:t.bc7,format:"bc7"}:r&&t?.astc?{fileKey:t.astc,format:"astc"}:void 0}export function parseNativeTexture(g,s){const l=new Uint8Array(g);if(l.length<104||![171,75,84,88,32,50,48,187,13,10,26,10].every((t,e)=>l[e]===t))throw new Error("Invalid native KTX2 header");const f=new DataView(g),m=f.getUint32(12,!0),w=145===m||146===m;if("bc7"===s&&!w||"astc"===s&&!(157===m||158===m))throw new Error("Native KTX2 format does not match its variant");const U=f.getUint32(20,!0),u=f.getUint32(24,!0),p=f.getUint32(28,!0),d=f.getUint32(32,!0),v=f.getUint32(36,!0),M=f.getUint32(40,!0);if(!U||!u||p||1!==v||!M||M>Math.floor(Math.log2(Math.max(U,u)))+1||0!==f.getUint32(44,!0)||l.length<80+24*M)throw new Error("Unsupported native KTX2 layout or supercompression");const b=[];for(let t=0;t<M;t++){const e=80+24*t,r=Number(f.getBigUint64(e,!0)),n=Number(f.getBigUint64(e+8,!0)),i=Math.max(1,Math.floor(U/2**t)),a=Math.max(1,Math.floor(u/2**t)),o=Math.ceil(i/4)*Math.ceil(a/4)*16*Math.max(1,d);if(!Number.isSafeInteger(r)||r<80+24*M||n!==o||r+n>g.byteLength||f.getBigUint64(e+16,!0)!==BigInt(n))throw new Error("Invalid native KTX2 mip data");b.push({data:new Uint8Array(g,r,n),width:i,height:a})}const x=w?o:a,y=d>0?new t(b,U,u,d,x,c):new e(b,U,u,x,c);return y.colorSpace=146===m||158===m?h:i,y.minFilter=M>1?n:r,y.magFilter=r,y.needsUpdate=!0,y}/*
|
|
2
|
+
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
|
+
* See the LICENSE.md file for details.
|
|
4
|
+
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{BoxCollisionShape as e,CapsuleCollisionShape as t,ConeCollisionShape as r,ConvexPolyhedronCollisionShape as s,CylinderCollisionShape as n,PlaneCollisionShape as i,SphereCollisionShape as o,TrimeshCollisionShape as a}from"../collision/collision-shape.js";import{BoxGeometry as m,PlaneGeometry as h,Vector3 as l,CylinderGeometry as c,SphereGeometry as g,Euler as p,TorusGeometry as u,ConeGeometry as d,CircleGeometry as w,CapsuleGeometry as y}from"three";import{rampGeometry as f}from"./ramp-geometry.js";import{RoundedBoxGeometry as S}from"three/examples/jsm/geometries/RoundedBoxGeometry.js";import{TextGeometry as x}from"three/examples/jsm/geometries/TextGeometry.js";import{Font as j}from"three/examples/jsm/loaders/FontLoader.js";import
|
|
1
|
+
import{BoxCollisionShape as e,CapsuleCollisionShape as t,ConeCollisionShape as r,ConvexPolyhedronCollisionShape as s,CylinderCollisionShape as n,PlaneCollisionShape as i,SphereCollisionShape as o,TrimeshCollisionShape as a}from"../collision/collision-shape.js";import{BoxGeometry as m,PlaneGeometry as h,Vector3 as l,CylinderGeometry as c,SphereGeometry as g,Euler as p,TorusGeometry as u,ConeGeometry as d,CircleGeometry as w,CapsuleGeometry as y}from"three";import{rampGeometry as f}from"./ramp-geometry.js";import{RoundedBoxGeometry as S}from"three/examples/jsm/geometries/RoundedBoxGeometry.js";import{TextGeometry as x}from"three/examples/jsm/geometries/TextGeometry.js";import{Font as j}from"three/examples/jsm/loaders/FontLoader.js";import b from"three/examples/fonts/helvetiker_regular.typeface.json"with{type:"json"};import{createSpiralStairsGeometry as L,createStairsGeometry as z}from"./stairs-geometry.js";import*as B from"three/examples/jsm/utils/BufferGeometryUtils.js";function E(e){return{type:"number",default:e}}function G(e){return{type:"number",default:e,float:!0}}function k(e){return{type:"boolean",default:e}}function v(e,t){const r={};for(let s in t)null==e[s]?r[s]=t[s].default:r[s]=e[s];return r}const T={box:{geometry:()=>new m(1,1,1),collision:()=>new e(new l(1,1,1))},ramp:{geometry:()=>f(),collision:()=>new s(f())},plane:new class{constructor(){this.parameters={widthSegments:E(1),heightSegments:E(1)}}geometry(e={}){const t=v(e,this.parameters);return new h(1,1,t.widthSegments,t.heightSegments)}collision(e={}){return new i(1,1)}},cylinder:new class{constructor(){this.parameters={radialSegments:E(12),heightSegments:E(1),openEnded:k(!1)}}geometry(e={}){const t=v(e,this.parameters);return new c(.5,.5,1,t.radialSegments,t.heightSegments,t.openEnded)}collision(e={}){const t=v(e,this.parameters);return new n(.5,.5,1,t.radialSegments,new p(0,1,0))}},sphere:new class{constructor(){this.parameters={withSegments:E(12),heightSegments:E(12)}}geometry(e={}){const t=v(e,this.parameters);return new g(.5,t.withSegments,t.heightSegments)}collision(){return new o(.5)}},torus:new class{constructor(){this.parameters={radius:G(1),tube:G(.3),radialSegments:E(16),tubularSegments:E(50)}}geometry(e={}){const t=v(e,this.parameters);return new u(t.radius,t.tube,t.radialSegments,t.tubularSegments)}collision(e={}){return new a(this.geometry(e))}},cone:new class{constructor(){this.parameters={radialSegments:E(12),heightSegments:E(1),openEnded:k(!1)}}geometry(e={}){const t=v(e,this.parameters);return new d(.5,1,t.radialSegments,t.heightSegments)}collision(e={}){return new r(1,.5)}},circle:new class{constructor(){this.parameters={segments:E(32)}}geometry(e={}){const t=v(e,this.parameters);return new w(.5,t.segments)}collision(e={}){return new a(this.geometry(e))}},capsule:new class{constructor(){this.parameters={radius:G(.5),length:G(1),capSegments:E(8),radialSegments:E(32)}}geometry(e={}){const t=v(e,this.parameters);return new y(t.radius,t.length,t.capSegments,t.radialSegments)}collision(e={}){const r=v(e,this.parameters);return new t(r.length,r.radius)}},roundedBox:new class{constructor(){this.parameters={width:G(1),height:G(1),depth:G(1),segments:E(4),radius:G(.1)}}geometry(e={}){const t=v(e,this.parameters),r=new S(t.width,t.height,t.depth,t.segments,t.radius);return B.mergeVertices(r)}collision(t={}){const r=v(t,this.parameters);return new e(new l(r.width,r.height,r.depth))}},text:new class{constructor(){var e;this.parameters={text:(e="Text",{type:"string",default:e}),size:G(1),depth:G(.2)}}geometry(e={}){const t=v(e,this.parameters);return new x(t.text,{font:new j(b),size:t.size,height:t.depth})}collision(e={}){return v(e,this.parameters).text.trim().length,null}},stairs:new class{constructor(){this.parameters={floating:k(!1),width:G(1),height:G(.25),depth:G(.25),steps:E(4)}}geometry(e={}){const t=v(e,this.parameters);return z(t.floating,t.steps,t.depth,t.height,t.width)}collision(e={}){return new a(this.geometry(e))}},stairsSpiral:new class{constructor(){this.parameters={floating:k(!1),width:G(1),height:G(.25),steps:E(4),degrees:E(90),radius:G(.2),flipped:k(!1)}}geometry(e={}){const t=v(e,this.parameters);return L(t.floating,t.steps,t.height,t.width,t.degrees,t.radius,t.flipped)}collision(e={}){return new a(this.geometry(e))}}};export const ShapeLibrary=T;export const ShapeLibraryKeys=Object.keys(ShapeLibrary);/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SceneObject } from './materializer.js';
|
|
2
2
|
import type { Asset, SceneData } from './model.js';
|
|
3
3
|
export interface RuntimeBackendService {
|
|
4
|
+
getSceneNavigationBakes?(sceneId: string): Promise<import('../navigation/bake.js').NavigationBakes>;
|
|
4
5
|
getAssets(): Promise<Asset[]>;
|
|
5
6
|
getAsset(id: string): Promise<Asset>;
|
|
6
7
|
getScenes(): Promise<SceneData[]>;
|
|
@@ -3,6 +3,7 @@ import type { Asset, SceneData } from './model.js';
|
|
|
3
3
|
import type { RuntimeBackendService } from './runtime-backend-service.js';
|
|
4
4
|
export declare class RuntimeBundledBackendService implements RuntimeBackendService {
|
|
5
5
|
private readonly dataBundleUrl;
|
|
6
|
+
getSceneNavigationBakes(sceneId: string): Promise<import('../navigation/bake.js').NavigationBakes>;
|
|
6
7
|
private assets;
|
|
7
8
|
private scenes;
|
|
8
9
|
private sceneBlobs;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{decodeStorageValue as e,parseStorageJson as s}from"./storage/packed-json.js";export class RuntimeBundledBackendService{constructor(e=new URL("../hology_data.json",import.meta.url)){this.dataBundleUrl=e,this.assets=new Map,this.scenes=new Map,this.sceneBlobs=new Map}getAssets(){return Promise.resolve(Array.from(this.assets.values()))}getAsset(e){return Promise.resolve(this.assets.get(e))}getScenes(){return Promise.resolve(Array.from(this.scenes.values()))}getScene(e){return Promise.resolve(this.scenes.get(e))}async getSceneData(e){const t=this.sceneBlobs.get(e);return"string"==typeof t.data?s(t.data):t.data}static tryCreate(){}async preloadData(){const s=await(await fetch(this.dataBundleUrl)).json()??{};for(const t of s.asset??[]){const s=e(t);this.assets.set(s.id,s)}for(const e of s.scene??[])this.scenes.set(e.id,e),this.scenes.set(e.name,e);for(const t of s["scene-blob"]??[]){const s=e(t);this.sceneBlobs.set(s.id,s)}}}/*
|
|
1
|
+
import{decodeStorageValue as e,parseStorageJson as s}from"./storage/packed-json.js";export class RuntimeBundledBackendService{async getSceneNavigationBakes(e){const s=this.sceneBlobs.get(e)?.navigationBakes??{},t=String(this.dataBundleUrl);return Object.fromEntries(Object.entries(s).map(([e,s])=>{const n=this.dataBundleUrl instanceof URL||/^[a-z]+:/i.test(t)||globalThis.location?.href?new URL(s.file,new URL(t,globalThis.location?.href)).href:t.slice(0,t.lastIndexOf("/")+1)+s.file;return[e,{...s,file:n}]}))}constructor(e=new URL("../hology_data.json",import.meta.url)){this.dataBundleUrl=e,this.assets=new Map,this.scenes=new Map,this.sceneBlobs=new Map}getAssets(){return Promise.resolve(Array.from(this.assets.values()))}getAsset(e){return Promise.resolve(this.assets.get(e))}getScenes(){return Promise.resolve(Array.from(this.scenes.values()))}getScene(e){return Promise.resolve(this.scenes.get(e))}async getSceneData(e){const t=this.sceneBlobs.get(e);return"string"==typeof t.data?s(t.data):t.data}static tryCreate(){}async preloadData(){const s=await(await fetch(this.dataBundleUrl)).json()??{};for(const t of s.asset??[]){const s=e(t);this.assets.set(s.id,s)}for(const e of s.scene??[])this.scenes.set(e.id,e),this.scenes.set(e.name,e);for(const t of s["scene-blob"]??[]){const s=e(t);this.sceneBlobs.set(s.id,s)}}}/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { FloatNode } from 'three-shader-graph';
|
|
2
|
+
/** Stable bindings for static and animated values; static parameters skip texture sampling. */
|
|
3
|
+
export declare function particleParameter(name: string, start: number, end?: number, easing?: string): FloatNode;
|
|
4
|
+
//# sourceMappingURL=particle-parameter.d.ts.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import{ease as e}from"@hology/nebula";import{ClampToEdgeWrapping as t,DataTexture as r,LinearFilter as n,RedFormat as a,UnsignedByteType as i,Vector3 as s}from"three";import{FloatNode as o,UniformFloatNode as l,UniformVec3Node as m,isWgslCompiler as c,textureSampler2d as p}from"three-shader-graph";import{shaderParameterUniformName as h}from"../shader/parameter.js";import{particleUniforms as u}from"./particle.js";const $=Object.keys(e);let g;export function particleParameter(o,c,u,v){const d=Math.max(0,$.indexOf("ease"+v));return new f(new l(h(o),c,void 0,!1),new m(h(o)+"_animation",new s(u??c,(d+.5)/$.length,null==u?0:1),void 0,!1),function(){if(g)return g;const s=new Uint8Array(64*$.length);for(let t=0;t<$.length;t++){const r=e[$[t]];for(let e=0;e<64;e++)s[64*t+e]=255*r(e/63)}const o=new r(s,64,$.length,a,i);return o.name="Shared particle parameter easing curves",o.minFilter=o.magFilter=n,o.wrapS=o.wrapT=t,o.generateMipmaps=!1,o.needsUpdate=!0,g=p(o),g}())}class f extends o{constructor(e,t,r){super(),this.start=e,this.settings=t,this.curve=r}compile(e){const t=e.get(this.start),r=e.get(this.settings),n=e.get(this.curve),a=e.get(u.energy),i="particle_parameter_"+e.variable(),s=c(e)?`textureSampleLevel(${n}Texture, ${n}Sampler, vec2<f32>(1.0 - ${a}, ${r}.y), 0.0).r`:`texture2D(${n}, vec2(1.0 - ${a}, ${r}.y)).r`;return{chunk:`${c(e)?`var ${i}: f32 = ${t};`:`float ${i} = ${t};`}\n if (${r}.z > 0.5) {\n ${i} = mix(${t}, ${r}.x, ${s});\n }`,out:i}}}/*
|
|
2
|
+
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
|
+
* See the LICENSE.md file for details.
|
|
4
|
+
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{describe as e,expect as t,it as
|
|
1
|
+
import{describe as e,expect as t,it as a,vi as r}from"vitest";import{Texture as n}from"three";import{AssetResourceLoader as o}from"../scene/asset-resource-loader.js";function s(e=1){const t=new ArrayBuffer(120);new Uint8Array(t).set([171,75,84,88,32,50,48,187,13,10,26,10]);const a=new DataView(t);for(const[t,r]of[[12,146],[16,1],[20,4],[24,4],[32,e],[36,1],[40,1]])a.setUint32(t,r,!0);return a.setBigUint64(80,104n,!0),a.setBigUint64(88,16n,!0),a.setBigUint64(96,16n,!0),t}function i(e,t){return{id:e,name:e,type:"texture",fileKey:`${e}.ktx2`,fileFormat:"ktx2",texture:{textureArrayFileKey:"shared-array.ktx2",textureArrayLayer:t}}}r.mock("../scene/materializer.js",()=>({applyMaterial:r.fn(),getConvertedFbxToGlbAssignedMaterialTextureFlipY:r.fn()})),r.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&r.fn()),e[t]),set:(e,t,a)=>(e[t]=a,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),e("AssetResourceLoader texture arrays",()=>{a("uses native BC7 with the custom WebGPU device and deduplicates array requests",async()=>{const e=new o,a=e,n=r.spyOn(a.ktx2Loader,"detectSupport").mockReturnThis();e.initKtx2ForWebGpu({features:new Set(["texture-compression-bc"])}),a.getUri=e=>e;const l=r.spyOn(a.nativeTextureLoader,"loadAsync").mockResolvedValue(s()),c=r.spyOn(a.ktx2Loader,"loadAsync"),u=i("a",0);u.texture.nativeArrayFileKeys={bc7:"native-array.ktx2",astc:"mobile.ktx2"};const[d,x]=await Promise.all([e.getTextureArray(u),e.getTextureArray(u)]);t(d.texture).toBe(x.texture),t(d.texture.isCompressedArrayTexture).toBe(!0),t(l).toHaveBeenCalledExactlyOnceWith("native-array.ktx2"),t(c).not.toHaveBeenCalled(),t(n).toHaveBeenCalled(),r.restoreAllMocks()}),a("loads standalone native textures and falls back when their payload is invalid",async()=>{const e=new o,a=e;a.getUri=e=>e,a.nativeBcSupported=!0,e.setDataDir("data");const l=r.spyOn(a.nativeTextureLoader,"loadAsync").mockResolvedValueOnce(s(0)).mockResolvedValueOnce(new ArrayBuffer(0)),c=new n,u=r.spyOn(a.ktx2Loader,"loadAsync").mockResolvedValue(c),d=r.spyOn(console,"warn").mockImplementation(()=>{}),x=i("a",0);x.texture.nativeFileKeys={bc7:"native.ktx2"},t((await e.getTexture(x)).isCompressedTexture).toBe(!0);const y={...x,id:"b",fileKey:"fallback.ktx2"};t(await e.getTexture(y)).toBe(c),t(l).toHaveBeenCalledTimes(2),t(u).toHaveBeenCalledExactlyOnceWith("fallback.ktx2"),t(d).toHaveBeenCalled(),r.restoreAllMocks()}),a("selects native formats from WebGL extensions",async()=>{const e=new o,a=e;r.spyOn(a.ktx2Loader,"detectSupport").mockReturnThis(),e.initKtx2({extensions:{has:e=>"WEBGL_compressed_texture_astc"===e}}),t(a.nativeBcSupported).toBe(!1),t(a.nativeAstcSupported).toBe(!0),r.restoreAllMocks()}),a("deduplicates concurrent loads by texture array file key",async()=>{const e=new o;let a;const s=new Promise(e=>{a=e}),l=r.fn(()=>s);e._getTextureLoader=()=>({loadAsync:l}),e.getUri=e=>e;const c=e.getTextureArray(i("texture-a",0)),u=e.getTextureArray(i("texture-b",1));await r.waitFor(()=>t(l).toHaveBeenCalledTimes(1));const d=new n;a(d);const[x,y]=await Promise.all([c,u]);t(x.texture).toBe(d),t(y.texture).toBe(d),t(x.layerIndex).toBe(0),t(y.layerIndex).toBe(1)}),a("allows a failed texture array load to be retried",async()=>{const e=new o,a=new n,s=r.fn().mockRejectedValueOnce(new Error("load failed")).mockResolvedValueOnce(a);e._getTextureLoader=()=>({loadAsync:s}),e.getUri=e=>e;const l=i("texture-a",0);await t(e.getTextureArray(l)).rejects.toThrow("load failed"),await t(e.getTextureArray(l)).resolves.toEqual({texture:a,layerIndex:0}),t(s).toHaveBeenCalledTimes(2)})});/*
|
|
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 o}from"vitest";import{Vector3 as s}from"three";import{createAuthoredCollisionShapes as t}from"../scene/
|
|
1
|
+
import{expect as e,test as o}from"vitest";import{Vector3 as s}from"three";import{createAuthoredCollisionShapes as t}from"../scene/collision/authored-colliders.js";import{BoxCollisionShape as i,CapsuleCollisionShape as n,CylinderCollisionShape as l,SphereCollisionShape as r}from"../scene/collision/collision-shape.js";function a(o,s,t=1e-5){e(o.distanceTo(s)).toBeLessThan(t)}o("authored colliders are converted back to asset-local units when the asset is rescaled",()=>{const[o,d,c,p]=t([{id:"box",name:"Box Collider",type:"box",position:[.5,-1,2],size:[1,2,3]},{id:"sphere",name:"Sphere Collider",type:"sphere",position:[1,2,3],radius:.25},{id:"capsule",name:"Capsule Collider",type:"capsule",position:[-2,.5,4],radius:.2,length:1.5},{id:"cylinder",name:"Cylinder Collider",type:"cylinder",position:[3,-4,5],radius:.4,height:2.5}],.01);e(o).toBeInstanceOf(i),a(o.dimensions,new s(100,200,300)),a(o.offset,new s(50,-100,200)),e(d).toBeInstanceOf(r),e(d.radius).toBeCloseTo(25),a(d.offset,new s(100,200,300)),e(c).toBeInstanceOf(n),e(c.radius).toBeCloseTo(20),e(c.length).toBeCloseTo(150),a(c.offset,new s(-200,50,400)),e(p).toBeInstanceOf(l),e(p.radiusTop).toBeCloseTo(40),e(p.radiusBottom).toBeCloseTo(40),e(p.height).toBeCloseTo(250),a(p.offset,new s(300,-400,500))}),o("authored colliders keep their values when no asset rescale is applied",()=>{const[o]=t([{id:"box",name:"Box Collider",type:"box",position:[1,2,3],size:[4,5,6]}]);e(o).toBeInstanceOf(i),a(o.dimensions,new s(4,5,6)),a(o.offset,new s(1,2,3))});/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{describe as e,expect as t,it as a}from"vitest";import*as o from"three";import{NodeShaderMaterial as i,rgb as n,rgba as s,standardMaterial as r,textureSampler2d as l,uniformFloat as c,uniformSampler2d as p,uniformVec3 as m,varyingAttributes as d,vec3 as u}from"three-shader-graph";import{SerializedParamType as h}from"../scene/model.js";import{WorldCollisionMethod as y}from"../effects/vfx/vfx-asset.js";import{compileGpuSpriteEmitters as g,materializeGpuSpriteRenderClasses as f,requiresCpuVfxBackend as b,selectVfxBackend as w}from"../effects/vfx/gpu-sprite-compiler.js";import{prepareGpuSpriteBackend as C}from"../effects/vfx/gpu-sprite-backend.js";import{calculateGpuSpriteOptionalStateLayout as x,GPU_TRAIL_RENDER_CLASS_BYTES as v,GpuSpriteDirtyRange as B,getCompiledGpuSpriteMaterial as S,getGpuSpriteComputeWgsl as V,getGpuSpriteMaterialWgsl as T,getGpuTrailMaterialWgsl as M}from"../effects/vfx/gpu-sprite-runtime.js";import{Trail as E,trailUV as I}from"../effects/vfx/trail-renderer.js";import{timeUniforms as P}from"../shader-nodes/time.js";import{GpuSpriteSchedulerRuntime as O}from"../effects/vfx/gpu-sprite-scheduler-runtime.js";import{GpuSpriteTemplateCache as j,instantiateGpuSpriteTemplate as z}from"../effects/vfx/gpu-sprite-template.js";import{GpuSpriteWorldRuntime as q}from"../effects/vfx/gpu-sprite-world-runtime.js";import{GpuSpriteRenderClassCache as A}from"../effects/vfx/gpu-sprite-compiler.js";import{addGpuSpriteRenderGraphPasses as R}from"../effects/vfx/gpu-sprite-render-graph.js";import{gpuVfxCapacityClass as G}from"../effects/vfx/gpu-vfx-capacity.js";import{buildParticleLightingShaderHeader as L,ComputeKernel as _}from"@hology/webgpu-renderer";import{configureWebGpuRuntimeBindings as k}from"../rendering/webgpu-runtime-bridge.js";k({createComputeKernel:(e,t)=>new _(e,t),buildParticleLightingShaderHeader:L});import{sampleSceneColor as U}from"../shader-nodes/scene-sample.js";import{screenUV as D}from"../shader-nodes/depth.js";import{particleUniforms as W}from"../shader-nodes/particle.js";import{createGpuMeshAssetMaterial as F}from"../effects/vfx/vfx-gpu-material.js";const N=e=>({type:h.Number,value:e}),Z=e=>({type:h.Vector3,value:e});function H(e={}){return{type:"sprite",texture:"dot",color:"#ffffff",intensity:1,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}function X(e={}){return{type:"mesh",assetId:"simple-mesh",space:"world",...e}}function Y(e={}){return{type:"trail",length:10,width:.5,taper:!0,color:"#40a0ff",colorEnd:"#ff4080",intensity:2,intensityEnd:.25,opacityStart:1,opacityEnd:0,bloom:!1,billboard:!0,opacityChannel:"alpha",space:"world",...e}}function $(e,t=H()){return{id:e,name:e,position:[0,0,0],rotation:[0,0,0],rate:{type:"once",count:2,delay:0},output:t,initializers:[{id:`${e}-life`,type:"lifetime",enabled:!0,params:{duration:N(2)}},{id:`${e}-position`,type:"positionSphere",enabled:!0,params:{position:Z([0,0,0]),radius:N(1),surface:{type:h.Boolean,value:!1}}}],behaviours:[]}}function Q(...e){return{id:"test-vfx",name:"test-vfx",type:"vfx",vfx:{emitters:e}}}e("GPU sprite compiler compatibility",()=>{a("keeps standard asset materials lit for GPU mesh particles",async()=>{const e=new o.Texture,a=new o.Texture,n=new o.Texture,s=new i({color:r({color:m("color",new o.Vector3(.3,.2,.1)).rgb.multiply(p("map",e).sample(d.uv).rgb),roughness:c("roughness",.8).multiply(p("roughnessMap",a).sample(d.uv).g),normal:p("normalMap",n).sample(d.uv).rgb,metalness:.1}),opacity:c("opacity",1)}),l=await F("material-id",{getAsset:async()=>({id:"material-id"}),getTexture:async()=>e,getMesh:async()=>new o.Mesh,getMaterial:async()=>s}),u=S(l),h=T(u,!0);t(u.requiresRendererShaderHeader).toBe(!0),t(u.needsWorldPosition).toBe(!0),t(u.fragmentMain).toContain("rendererPointLightForLight"),t(u.fragmentMain).toContain("rendererDirectionalLightForLight"),t(h).toContain("fn rendererPointLightCount()"),t(u.textures.map).toBe(e),t(u.textures.roughnessMap).toBe(a),t(u.textures.normalMap).toBe(n)}),a("bakes an authored effect input into emitter spawn delay",()=>{const e=$("bound-delay");e.rate.delayBinding={type:"vfxInput",inputId:"timing"};const a=Q(e);a.vfx.inputs=[{id:"timing",name:"Timing",type:"number",defaultValue:.5}];const i=g(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate.delay).toBe(.5)}),a("bakes the emitter rotation into world-space spawn position, velocity, and mesh rotation",()=>{const e=V(16,!0,!1,!1,!1,!0);t(e).toContain("zoneOffset = worldSpawnOffset(emitter, zoneOffset)"),t(e).toContain("let initialVelocity = worldSpawnDirection(emitter, localInitialVelocity)"),t(e).toContain("localInitialVelocity += randomConeDirection(sampledMeshNormal"),t(e).toContain("worldSpawnRotation(emitter), quaternionFromEulerXYZ(initialRotationEuler)"),t(e).toContain("optionalStates[meshStateOffset], quaternionFromEulerXYZ(rotationDelta)"),t(e).not.toContain("optionalStates[meshStateOffset] = quaternionFromEulerXYZ(rotationEuler)"),t(e).toContain("emitter.trailTransform0 = command.trailTransform0"),t(e).not.toMatch(/__[A-Z_]+__/)}),a("compiles pay-for-play child emitters and screen-space collision metadata",()=>{const e=$("parent");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{bounce:N(.6),friction:N(.2),lifeLoss:N(.5),debug:{type:h.Boolean,value:!0}}});const a=$("child");a.rate={type:"once",count:3,delay:0},a.spawnEvent="collision",e.children=[a];const i=g(Q(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters).toHaveLength(2),t(i.emitters[0]).toMatchObject({hasWorldCollision:!0,collisionBounce:.6,collisionFriction:.2,collisionLifeLoss:.5,collisionDebug:!0}),t(i.emitters[1]).toMatchObject({parentEmitter:0,childParticlesPerParent:3,childSpawnEvent:2,childLevel:1,capacity:6});const n=V(16);t(n).not.toContain("collisionDepth"),t(n).not.toContain("optionalStates"),t(n).not.toContain("triangleRecord"),t(n).not.toMatch(/__[A-Z_]+__/);const s=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,0,!1,!0);t(s).toContain("var<storage, read_write> optionalStates"),t(s).toContain("let triangleRecord = optionalStates[triangleOffset]"),t(s).toContain("fn meshSamplerBoneMatrix"),t(s).toContain("meshSamplerVertexPosition(samplerOffset"),t(s).toContain("var triangleOffset = samplerOffset"),t(s).not.toContain("let triangleOffset = samplerOffset");const r=V(16,!1,!1,!1,!1,!1,!1,0,0,!0,!0,32,1);t(r).toContain("collisionDepth"),t(r).toContain("collisionStep <= 6u"),t(r).toContain("approachingVisibleSurface"),t(r).toContain("optionalStates[32u + owner.padding]"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("supports local-space parent particles spawning local or world-space children",()=>{const e=$("parent",H({space:"local"})),a=$("local-child",H({space:"local"})),i=$("world-child",H({space:"world"}));e.children=[a,i];const n=g(Q(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.compatibility.issues).toEqual([]),t(n.emitters.map(e=>e.localSpace)).toEqual([!0,!0,!1]);const s=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(s).not.toContain("parentTransform = mat4x4");const r=V(16,!1,!1,!1,!1,!0,!1,0,0,!1,!1,0,1,!0);t(r).toContain("parentTransform = mat4x4"),t(r).toContain("parentEmitter.trail.z > 0.0 && emitter.trail.z < 0.0"),t(r).toContain("childEmissionEnd = parent.velocityLife.w"),t(r).toContain("particle.positionAge = vec4(particle.positionAge.xyz, age)"),t(r).not.toContain("childEventReady = parentAlive"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("invalidates recycled parent spawn events during a new activation delay",()=>{const e=$("delayed-impact",H({space:"world"}));e.rate.delay=.13,e.children=[$("ground-impact",H({space:"world"}))];const a=g(Q(e),[new o.Vector3(12,0,8)]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].rate.delay).toBe(.13);const i=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(i).toContain("if (!emissionStarted || !stopAllowsEmission || totalSpawn <= simulationLocalSlot)"),t(i).toContain("particle.scaleAlphaEmitterGeneration.w = 0.0;"),t(i.indexOf("particle.scaleAlphaEmitterGeneration.w = 0.0;")).toBeLessThan(i.indexOf("particles[particleIndex] = particle;")),t(i).not.toMatch(/__[A-Z_]+__/),t(V(16)).not.toContain("particle.scaleAlphaEmitterGeneration.w = 0.0;")}),a("rejects world-space parents with local-space child output",()=>{const e=$("parent",H({space:"world"}));e.children=[$("child",H({space:"local"}))];const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues).toContainEqual(t.objectContaining({code:"child-emitter-local-space"}))}),a("orders child render classes by authored render order without changing simulation hierarchy",()=>{const e=$("parent",X({renderOrder:10})),a=$("child",H({renderOrder:-10}));e.children=[a];const i=g(Q(e),[new o.Vector3]);t(i.renderClasses.map(e=>e.output.renderOrder)).toEqual([-10,10]),t(i.emitters[0].parentEmitter).toBeUndefined(),t(i.emitters[0].renderClass).toBe(1),t(i.emitters[1]).toMatchObject({parentEmitter:0,renderClass:0})}),a("uses geometric world reservation classes without fixed template maxima",()=>{t([1,2,3,4,5,17].map(G)).toEqual([1,2,4,4,8,32])}),a("compiles stretched sprites into a velocity-aligned render class",async()=>{const e=g(Q($("stretched",function(e={}){return{type:"stretchedSprite",texture:"dot",color:"#ffffff",intensity:1,scale:.5,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}({scale:.75}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await f(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].stretchScale).toBe(.75);const i=T(S(a.renderClasses[0].material),!1);t(i).toContain("stretchedSpriteClipPosition"),t(i).toContain("bitcast<f32>(renderClass.data.w)")}),a("materializes billboard trails with CPU-compatible history sizing",async()=>{const e=g(Q($("trail",Y({length:9.6,uvMode:"repeat",unitsPerRepeat:.5}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.emitters).toHaveLength(1),t(e.emitters[0].capacity).toBe(2);const a=await f(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({historyLength:11,width:.5,taper:!0,uvMode:"repeat",unitsPerRepeat:.5}),t(a.renderClasses[0].texture?.wrapS).toBe(o.RepeatWrapping),t(v).toBe(96);const i=V(16,!1,!1,!1,!0);t(i).toContain("@group(0) @binding(8) var<storage, read_write> optionalStates"),t(i).not.toContain("@binding(9)")}),a("streams local trail transforms through emitter commands without another storage binding",()=>{const e=g(Q($("attached-trail",Y({space:"local"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=V(16,!1,!1,!1,!0,!0);t(a).toContain("trailTransform0: vec4<f32>"),t(a).toContain("emitter.trailTransform3 = command.trailTransform3"),t(a).not.toContain("@binding(12)")}),a("compiles trail graph UV varyings into the procedural ribbon ABI",()=>{const e=new i({color:s(u(I.x,I.y,W.opacity),W.opacity)});E.applyTrailShaderParameters(e);const a=M(e,!0);t(a).toContain("struct GraphVertexInput"),t(a).toContain("nodeVertexID: f32"),t(a).toContain("nodeDistance: f32"),t(a).toContain("renderClass.uvSettings.x"),t(a).toContain("headDistance - mix(historyDistance(state, control), historyDistance(state, control + 1), t)"),t(a).toContain("emitterTransforms"),t(a).not.toContain("__TRAIL_"),t(a).not.toContain("output.uv ="),t(a).not.toContain("output.color ="),t(a).not.toContain("normalize(after - before)"),t(a).not.toContain("2.0, 2.0, 2.0, 0.0")}),a("binds shader graph elapsed time to the GPU particle frame clock",()=>{const e=new i({color:s(u(P.elapsed),1)}),a=M(e),o=T(S(e),!1);t(a).toContain("frame.time"),t(o).toContain("frame.time"),t(a).not.toContain("hology_elapsed_time:"),t(o).not.toContain("hology_elapsed_time:")}),a("converts authored sRGB behavior colors to the linear render space",()=>{const e=$("legacy-color",Y());e.behaviours.push({id:"color",type:"changeColor",enabled:!0,params:{a:{type:h.Color,value:"#918383"},b:{type:h.Color,value:"#4f4f4f"}}});const a=g(Q(e),[new o.Vector3]);t(a.emitters[0].colorStart.toArray()).toEqual(new o.Color("#918383").toArray()),t(a.emitters[0].colorEnd.toArray()).toEqual(new o.Color("#4f4f4f").toArray())}),a("materializes one mesh geometry and rejects mesh hierarchies with multiple geometries",async()=>{const e=$("mesh",X({space:"local"}));e.initializers.push({id:"mesh-scale-components",type:"scaleComponents",enabled:!0,params:{scale:Z([1,2,3])}},{id:"mesh-rotation",type:"rotation",enabled:!0,params:{rotation:Z([30,45,60])}}),e.behaviours.push({id:"mesh-scale-components-behaviour",type:"scaleComponents",enabled:!0,params:{a:Z([2,1,1]),b:Z([1,.5,2])}});const a=Q(e),r=g(a,[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.renderClasses[0].output.type).toBe("mesh"),t(r.emitters[0].localSpace).toBe(!0),t(r.emitters[0].meshScaleStart.toArray()).toEqual([2,2,3]),t(r.emitters[0].meshScaleEnd.toArray()).toEqual([1,1,6]),t(r.emitters[0].meshRotation.w).not.toBe(1),t(r.emitters[0].meshRotationEuler.toArray()).toEqual([o.MathUtils.degToRad(30),o.MathUtils.degToRad(45),o.MathUtils.degToRad(60)]);const l=new o.Mesh(new o.BoxGeometry(1,1,1),new o.MeshBasicMaterial({color:3368601,opacity:.75})),c=await f(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l});t(c.renderClasses[0].meshVertices?.length).toBe(288),t(c.renderClasses[0].meshColor?.getHex()).toBe(3368601),t(c.renderClasses[0].meshOpacity).toBe(.75),t(c.renderClasses[0].localSpace).toBe(!0);const p=new i({color:s(n(.4),1)}),m=await f(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l},async()=>p);t(m.renderClasses[0].material).toBe(p);const d=new o.Group;d.add(new o.Mesh(new o.BoxGeometry),new o.Mesh(new o.BoxGeometry)),await t(f(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>d})).rejects.toThrow("exactly one mesh geometry")}),a("keeps an unconfigured mesh output GPU-compatible without rendering it",()=>{const e=g(Q($("unconfigured-mesh",X({assetId:null}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.compatibility.issues).toEqual([]),t(e.emitters).toEqual([]),t(e.renderClasses).toEqual([])}),a("treats an unselected mesh material override as the default material source",async()=>{const e=g(Q($("unselected-mesh-material",X({materialSource:"material",material:void 0}))),[new o.Vector3]),a=new i({color:s(n(.4),1)}),r=await f(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)},async()=>a);t(e.compatibility.compatible).toBe(!0),t(r.renderClasses[0].material).toBe(a)}),a("keeps an unselected mesh shader graph on the GPU default material",async()=>{const e=g(Q($("unselected-mesh-graph",X({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:null}}))),[new o.Vector3]),a=await f(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)});t(e.compatibility.compatible).toBe(!0),t(()=>S(a.renderClasses[0].material)).not.toThrow()}),a("gates custom mesh materials on a compatible GPU material resolver",async()=>{const e=g(Q($("mesh-material",X({material:"material-id"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a={getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)};await t(f(e.renderClasses,a)).rejects.toThrow("compatible GPU material resolver");const n=new o.Texture,s=await f(e.renderClasses,a,async()=>new i({color:l(n).sample(d.uv)}));t(s.renderClasses[0].material).toBeInstanceOf(i);const r=S(s.renderClasses[0].material);t(Object.values(r.textures)).toEqual([n])}),a("materializes non-billboard trails with orientation history",async()=>{const e=g(Q($("oriented-trail",Y({billboard:!1}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await f(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({billboard:!1});const i=V(16,!0,!1,!1,!0);t(i).toContain("trailSampleOffset + 1u"),t(i).toContain("quaternionMultiply(particleRotation, movementRotation)");const n=M(E.createBaseMaterial(),!1);t(n).toContain("renderClass.trail.w == 0u"),t(n).toContain("historySide(state, control)")}),a("changes the mesh render class when shader graph parameters change",()=>{const e={source:"asset",assetId:"mesh-graph"},a=g(Q($("mesh",X({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:N(1)}}))),[new o.Vector3]),i=g(Q($("mesh",X({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:N(2)}}))),[new o.Vector3]);t(a.renderClasses[0].key).not.toBe(i.renderClasses[0].key)}),a("preserves legacy mesh geometry transforms when requested",async()=>{const e=new o.Group,a=new o.Mesh((new o.BufferGeometry).setAttribute("position",new o.Float32BufferAttribute([1,0,0],3)));a.position.set(2,0,0),e.add(a);const i=g(Q($("modern",X())),[new o.Vector3]),n=g(Q($("legacy",X({useLegacyTransform:!0}))),[new o.Vector3]);t(i.renderClasses[0].key).not.toBe(n.renderClasses[0].key);const s={getTexture:async()=>new o.Texture,getMesh:async()=>e},r=await f(i.renderClasses,s),l=await f(n.renderClasses,s);t(r.renderClasses[0].meshVertices?.[0]).toBe(3),t(l.renderClasses[0].meshVertices?.[0]).toBe(1)}),a("does not reuse render classes across explicit template revisions",async()=>{const e=Q($("revisioned")),a={assets:{getTexture:async()=>new o.Texture},renderClassCache:new A},i=await(new j).resolve(e,{...a,revision:1}),n=await(new j).resolve(e,{...a,revision:2});t(i.template?.renderClasses[0]).not.toBe(n.template?.renderClasses[0])}),a("retains renderer-owned scene texture semantics for particle materials",()=>{const e=new i({color:U(D)}),a=S(e);t(Object.values(a.sceneTextures)).toEqual(["sceneColor"])}),a("retains the shared renderer geometry semantics for lit particle materials",()=>{const e=S(new i({color:r({color:n(6724044),roughness:.5,metalness:.1})}));t(e.needsWorldPosition).toBe(!0),t(e.needsWorldNormal).toBe(!0),t(e.requiresRendererShaderHeader).toBe(!0),t(e.fragmentMain).toContain("rendererDirectionalLightForLight"),t(L()).toContain("fn rendererCameraPosition()");const a=T(e,!0);t(a).toContain("fn rendererCameraPosition()"),t(a).toContain("rendererCameraPosition() - input.worldPosition"),t(a).toContain("@group(1) @binding(0)"),t(a).toContain("emitterTransform[0].xyz"),t(a).not.toContain("mat3x3<f32>(emitterTransforms["),t(a).toContain("fn normalMatrixFromLinearTransform"),t(a).not.toContain("inverse("),t(e.renderState.transparent).toBe(!1),t(e.renderState.depthWrite).toBe(!0),t(e.renderState.side).toBe(o.FrontSide)}),a("registers one ordered compute/render pair for a shared runtime",()=>{const e=[];let a;R({mainDepth:"depth",addPass:t=>e.push(t)},{executeCompute:()=>{},executeRender:(e,t)=>{a=t},getSceneTextureReads:()=>["sceneColorSnapshot"]},"color","WorldVfx"),t(e.map(e=>e.name)).toEqual(["WorldVfxSimulation","WorldVfxRender"]),t(e[1].dependsOn).toEqual(["WorldVfxSimulation"]),t(e[1].inputs).toMatchObject({sceneColorSnapshot:"sceneColorSnapshot"}),e[1].execute({}),t(a).toBe("color")}),a("coalesces touched emitter slots without allocating per mark",()=>{const e=new B;e.mark(7),e.mark(3),e.mark(5),t(e).toMatchObject({start:3,end:8,length:5}),e.clear(),t(e.length).toBe(0)}),a("generates particle-owner WGSL without reserved active identifiers",()=>{const e=V(16);t(e).toContain("enabled: u32"),t(e).not.toMatch(/\bactive\s*:/),t(e).not.toContain("owner.active"),t(e).toContain("fn applyEmitterCommandsMain"),t(e).toContain("@group(0) @binding(7) var<storage, read> emitterCommands"),t(e).toContain("var<storage, read_write> emitters"),t(e).toContain("timing: vec4<f32>"),t(e).toContain("let emitterDelta = select(frame.delta * emitter.timing.w, 0.0"),t(e).toContain("let sampledLife = mix(emitter.emission.z, emitter.emission.w"),t(e).toContain("particle.velocityLife = vec4(initialVelocity, particleLife)"),t(e).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, hash(particleSeed + 71u))"),t(e).toContain("select(animatedColor, particle.color.rgb, emitter.meshScaleStart.w > 0.5)")}),a("compiles random lifetime as a per-particle birth range",()=>{const e=$("random-life");e.initializers[0]={id:"random-life-range",type:"randomLifetime",enabled:!0,params:{minDuration:N(.5),maxDuration:N(2.5)}};const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({lifeMin:.5,life:2.5})}),a("compiles random initial color without lifetime interpolation",()=>{const e=$("random-color");e.initializers.push({id:"random-color-range",type:"randomColor",enabled:!0,params:{a:{type:h.Color,value:"#ff0000"},b:{type:h.Color,value:"#0000ff"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].randomColor).toBe(!0)}),a("compiles point, box, line, ring, and sphere-surface spawn zones",()=>{const e=[["positionPoint",{position:Z([1,2,3])},0],["positionSphere",{position:Z([1,2,3]),radius:N(2),surface:{type:h.Boolean,value:!0}},1],["positionBox",{position:Z([1,2,3]),dimensions:Z([4,5,6])},2],["positionLine",{a:Z([1,2,3]),b:Z([4,5,6])},3],["positionRing",{position:Z([1,2,3]),rotation:Z([10,20,30]),radius:N(4),thickness:N(.5),angle:N(180)},4]];for(const[a,i,n]of e){const e=$(`zone-${a}`);e.initializers[1]={id:`zone-${a}`,type:a,enabled:!0,params:i};const s=g(Q(e),[new o.Vector3]);t(s.compatibility.compatible).toBe(!0),t(s.emitters[0].spawnZoneType).toBe(n)}const a=V(16);t(a).toContain("fn rotateSpawnPoint"),t(a).toContain("if (zoneType == 4u)")}),a("preserves legacy per-update sprite rotation behavior",()=>{const e=$("sprite-rotate");e.behaviours.push({id:"rotate-add",type:"rotateAdd",enabled:!0,params:{rotation:Z([0,0,200])}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].spriteRotationStep).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const i=V(16);t(i).toContain("var rotation = particle.color.w;"),t(i).toContain("rotation += emitter.rotationInitialSpriteStep.w * frame.padding1"),e.output=X();const n=g(Q(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.emitters[0].meshRotationStep.z).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const s=V(16,!0);t(s).toContain("rotationEuler: vec4<f32>"),t(s).toContain("fn quaternionFromEulerXYZ"),t(s).toContain("emitter.rotationStep.xyz * frame.padding1"),e.initializers.push({id:"random-mesh-rotation",type:"randomRotation",enabled:!0,params:{axis:{type:h.String,value:"Y"}}});const r=g(Q(e),[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.emitters[0].randomMeshRotationAxis).toBe(3),t(s).toContain("randomRotationAxis == 1u"),t(s).toContain("randomRotationAxis == 4u")}),a("compiles radial velocity without per-particle auxiliary storage",()=>{const e=$("radial-velocity");e.initializers.push({id:"radial",type:"radialVelocity",enabled:!0,params:{speed:N(4),direction:Z([0,1,0]),spread:N(30)}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].hasRadialVelocity).toBe(!0),t(a.emitters[0].radialVelocitySpeed).toBe(4),t(a.emitters[0].radialVelocitySpread).toBeCloseTo(Math.PI/6);const i=V(16);t(i).toContain("fn radialVelocity"),t(i).toContain("emitter.radialMetadata.y > 0.5")}),a("compiles one attraction or repulsion field per emitter",()=>{const e=$("attraction");e.behaviours.push({id:"attract",type:"attraction",enabled:!0,params:{target:Z([1,2,3]),force:N(4),radius:N(5)}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].attractionTarget.toArray()).toEqual([1,2,3]),t(a.emitters[0].attractionForce).toBe(4),e.behaviours[0].type="repulsion";const i=g(Q(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].attractionForce).toBe(-4);const n=V(16);t(n).toContain("attractionDistanceSq < attractionRadiusSq")}),a("compiles mesh alignment modes into spawn-only parameters",()=>{const e=$("aligned-mesh",X());e.initializers.push({id:"align",type:"alignRotation",enabled:!0,params:{axis:{type:h.String,value:"+X"},mode:{type:h.String,value:"AlongVelocity"},offset:Z([10,20,30]),center:Z([1,2,3])}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].alignRotationAxis.toArray()).toEqual([1,0,0]),t(a.emitters[0].alignRotationMode).toBe(3),t(a.emitters[0].alignRotationOffset.x).toBeCloseTo(o.MathUtils.degToRad(10));const i=V(16,!0);t(i).toContain("fn quaternionFromUnitVectors"),t(i).toContain("alignRotationMode == 3u")}),a("groups authored render classes deterministically",()=>{const e=g(Q($("a"),$("b"),$("c",H({blendingMode:"normal"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.renderClasses).toHaveLength(2),t(e.emitters.map(e=>e.renderClass)).toEqual([0,0,1])}),a("can lay out emitter allocations contiguously per dynamic effect instance",()=>{const e=Q($("first"),$("second",H({blendingMode:"normal"}))),a=[new o.Vector3,new o.Vector3(1,0,0)],i=g(e,a),n=g(e,a,"instance-major");t(i.emitters.map(e=>e.renderClass)).toEqual([0,0,1,1]),t(n.emitters.map(e=>e.renderClass)).toEqual([0,1,0,1])}),a("reports sparse active-page simulation capacity",async()=>{const e=await C({asset:Q($("paged")),instancePositions:[new o.Vector3,new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:2,assets:{getTexture:async()=>new o.Texture}});e.runtime.setActivePages([0,2]),t(e.runtime.getStats()).toMatchObject({capacity:6,activePageCount:2,simulatedCapacity:4})}),a("compacts active page padding out of the simulation schedule",async()=>{const e=await O.create({asset:Q($("tiny-paged-effect")),maximumInstances:2,particlePageSize:256,assets:{getTexture:async()=>new o.Texture}});e.spawn(new o.Vector3),e.flushActivePages(),t(e.runtime.getStats()).toMatchObject({capacity:512,activePageCount:1,simulatedCapacity:2});const a=V(256);t(a).toContain("let particleIndex = activeParticleIndices[id.x]"),t(a).not.toContain("id.x % pageSize"),e.dispose()}),a("sizes alive indices by logical particles instead of render-class count",async()=>{const e=await C({asset:Q($("additive"),$("normal",H({blendingMode:"normal"}))),instancePositions:[new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:4,assets:{getTexture:async()=>new o.Texture}});t(e.runtime.getStats()).toMatchObject({capacity:8,renderClassCount:2,aliveIndexCapacity:8,aliveIndexBytes:32});const a=V(4);t(a).toContain("aliveIndices[indirect[renderClass].firstInstance + outputIndex]"),t(a).not.toContain("renderClass * u32(frame.capacity)")}),a("allocates and recycles multi-page dynamic effect instances",async()=>{const e=$("paged-instance");e.position=[2,0,0];const a=await O.create({asset:Q(e),maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),i=a.spawn(new o.Vector3);t(a.runtime.emitters[0].position.x).toBe(2),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),a.update(.1),t(a.scheduler.getInstance(i.handle)).not.toBeNull(),t(a.play(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!1),t(a.setTimescale(i,2)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(2),t(a.stop(i)).toBe(!0),t(a.runtime.emitters[0].stopTime).toBeGreaterThanOrEqual(0),t(a.runtime.emitters[0].expireWithin).toBe(-1),t(i.pages).toHaveLength(2),a.flushActivePages(),t(a.runtime.getStats()).toMatchObject({activePageCount:2,simulatedCapacity:2}),t(a.destroy(i)).toBe(!0);const n=a.spawn(new o.Vector3(1,0,0));t(new Set(n.pages)).toEqual(new Set(i.pages)),t(n.handle.generation).not.toBe(i.handle.generation),a.dispose()}),a("caps GPU particle drain time for expire and kill lifecycle behavior",async()=>{const e=await O.create({asset:Q($("expiring")),maximumInstances:2,assets:{getTexture:async()=>new o.Texture}}),a=e.spawn(new o.Vector3);t(e.stop(a,.5)).toBe(!0),t(e.runtime.emitters[0].expireWithin).toBe(.5);for(let t=0;t<4;t++)e.update(.1);t(e.scheduler.getInstance(a.handle)).not.toBeNull(),e.update(.1),t(e.scheduler.getInstance(a.handle)).toBeNull();const i=e.spawn(new o.Vector3);t(e.stop(i,0)).toBe(!0),e.update(.001),t(e.scheduler.getInstance(i.handle)).toBeNull(),e.dispose()}),a("rejects a template pool before allocation when its storage budget is exceeded",async()=>{const e=await O.create({asset:Q($("over-budget")),maximumInstances:128,particlePageSize:1,maximumStorageBytes:1,assets:{getTexture:async()=>new o.Texture}});t(e).toBeNull()}),a("compiles delayed and finite continuous emission timing",()=>{const e=$("delayed");e.rate={type:"once",count:2,delay:.25};const a=$("finite");a.rate={type:"continuous",count:2,time:.1,delay:0,duration:1};const i=g(Q(e,a),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate).toMatchObject({type:"once",delay:.25}),t(i.emitters[1].rate).toMatchObject({type:"continuous",delay:0,duration:1})}),a("treats zero continuous duration as unlimited like the CPU rate",()=>{const e=$("unlimited");e.rate={type:"continuous",count:2,time:.1,delay:0,duration:0};const a=g(Q(e),[new o.Vector3]);t(a.compatibility.issues.map(e=>e.code)).not.toContain("rate-duration")}),a("compiles bound lifetime metadata but falls back for unsupported input targets",()=>{const e=Q($("input-test"));e.vfx.inputs=[{id:"speed",name:"Speed",type:"number",defaultValue:1}],t(g(e,[new o.Vector3]).compatibility.compatible).toBe(!0),e.vfx.emitters[0].initializers[0].params.duration.binding={type:"vfxInput",inputId:"speed"};const a=g(e,[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.inputBindings).toEqual([t.objectContaining({inputId:"speed",emitterIndex:0,target:"initializer:lifetime:duration"})]),e.vfx.emitters[0].initializers.push({id:"bound-velocity",type:"velocity",enabled:!0,params:{velocity:{type:h.Vector3,value:[0,1,0],binding:{type:"vfxInput",inputId:"speed"}}}}),t(g(e,[new o.Vector3]).compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding")}),a("falls back for a bound continuous lifetime until particle capacity can grow",()=>{const e=$("continuous-bound-life");e.rate={type:"continuous",count:1,time:.1,delay:0,duration:0},e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"};const a=Q(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:2}];const i=g(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding"),t(i.inputBindings).toEqual([])}),a("caches compiled templates while keeping instance emitter state independent",async()=>{const e=Q($("cached-template")),a=new j;let i=0;const n={assets:{getTexture:async()=>(i+=1,new o.Texture)}},s=await a.resolve(e,n),r=await a.resolve(e,n);t(r.template).toBe(s.template),t(i).toBe(1),t(a.getStats()).toMatchObject({builds:1,hits:1});const l=z(s.template,[new o.Vector3(1,0,0),new o.Vector3(2,0,0)]);t(l[0].position).not.toBe(l[1].position),l[0].position.x=100,t(l[1].position.x).toBe(2),t(s.template.emitters[0].position.x).toBe(0),a.invalidate(e),await a.resolve(e,n),t(a.getStats()).toMatchObject({builds:2,hits:1,invalidations:1}),await a.resolve(e,{...n,revision:2}),t(a.getStats()).toMatchObject({builds:3,hits:1,invalidations:2})}),a("applies a bound lifetime only to the addressed scheduled instance",async()=>{const e=$("bound-life");e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"},e.initializers[1].params.radius.binding={type:"vfxInput",inputId:"radius"},e.initializers.push({id:"velocity",type:"velocity",enabled:!0,params:{velocity:{type:h.Vector3,value:[0,0,0],binding:{type:"vfxInput",inputId:"velocity"}}}});const a=Q(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:1},{id:"radius",name:"Radius",type:"number",defaultValue:1},{id:"velocity",name:"Velocity",type:"vec3",defaultValue:[0,0,0]}];const i=await O.create({asset:a,maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),n=i.spawn(new o.Vector3),s=i.spawn(new o.Vector3);t(i.setInput(n,"life",4)).toBe(!0),t(i.runtime.emitters[0].life).toBe(4),t(i.runtime.emitters[1].life).toBe(2),t(i.setInput(n,"radius",3)).toBe(!0),t(i.runtime.emitters[0].positionRadius).toBe(3),t(i.setInput(n,"velocity",new o.Vector3(1,2,3))).toBe(!0),t(i.runtime.emitters[0].velocityMin.toArray()).toEqual([1,2,3]),t(i.setInput(s,"missing",5)).toBe(!1),i.dispose()}),a("schedules different templates in one arena and merges shared render classes",async()=>{const e=H(),a=await q.create({templates:[{asset:Q($("world-a",e)),maximumInstances:2},{asset:Q($("world-b",{...e})),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},templateCache:new j,renderClassCache:new A,particlePageSize:1}),i=a.spawn(0,new o.Vector3),n=a.spawn(1,new o.Vector3(1,0,0));a.flushActivePages(),t(a.templateCount).toBe(2),t(a.runtime.getStats()).toMatchObject({renderClassCount:1,activePageCount:4}),t(a.scheduler.getStats()).toMatchObject({activeInstances:2,arena:{usedPages:4}}),t(a.destroy(i)).toBe(!0),t(a.destroy(n)).toBe(!0),a.flushActivePages(),t(a.runtime.hasActiveParticles).toBe(!1),t(a.runtime.getStats()).toMatchObject({aliveParticles:0,activePageCount:0,simulatedCapacity:0}),a.dispose()}),a("routes per-instance lifecycle changes through a shared multi-template world",async()=>{const e=$("world-lifecycle");e.position=[2,0,0];const a=await q.create({templates:[{asset:Q(e),maximumInstances:1},{asset:Q($("world-neighbour")),maximumInstances:1}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1}),i=a.spawn(0,new o.Vector3(3,0,0));t(a.runtime.emitters[0].position.x).toBe(5),t(a.setPosition(i,new o.Vector3(7,0,0))).toBe(!0),t(a.runtime.emitters[0].position.x).toBe(9),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),t(a.play(i)).toBe(!0),t(a.setTimescale(i,.5)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(.5),t(a.stop(i,0)).toBe(!0),a.update(.001),t(a.has(i)).toBe(!1),t(a.setPosition(i,new o.Vector3)).toBe(!1),a.dispose()}),a("rejects a shared world before runtime allocation when its storage budget is exceeded",async()=>{const e=await q.create({templates:[{asset:Q($("world-over-budget")),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1,maximumStorageBytes:1});t(e).toBeNull()}),a("falls back to CPU for semantic mismatches",()=>{const e=$("soft-local",H({softness:1,space:"local",lockY:!0}));e.behaviours.push({id:"animation",type:"animation",enabled:!0,params:{}});const a=g(Q(e),[new o.Vector3]),i=w(a.compatibility);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues.map(e=>e.code)).toEqual(t.arrayContaining(["behaviour:animation"])),t(i.backend).toBe("cpu"),t(w(a.compatibility,"gpu").backend).toBe("gpu")}),a("intentionally selects CPU simulation for physics-raycast world collision",()=>{const e=$("physics-collision");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{method:{type:h.String,value:y.PhysicsRaycast}}});const a=Q(e),i=g(a,[new o.Vector3]);t(b(a)).toBe(!0),t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues).toContainEqual(t.objectContaining({severity:"requirement",code:"physics-raycast-collision"})),t(w(i.compatibility).backend).toBe("cpu"),t(w(i.compatibility,"gpu").backend).toBe("cpu")}),a("compiles vortex with pay-for-play previous-velocity state",()=>{const e=$("vortex");e.behaviours.push({id:"vortex",type:"vortex",enabled:!0,params:{axis:Z([0,5,0]),amount:N(1.25)}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].vortexAxis.toArray()).toEqual([0,5,0]),t(a.emitters[0].vortexAmount).toBe(1.25),t(V(16,!1,!1)).not.toContain("vortexVelocities");const i=V(16,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("behaviourVelocity -= optionalStates[0u + particleIndex].xyz")}),a("compiles the remaining initializer and motion behaviours",()=>{const e=$("remaining-motion");e.initializers.push({id:"mass",type:"mass",enabled:!0,params:{min:N(2)}}),e.behaviours.push({id:"drift",type:"randomDrift",enabled:!0,params:{drift:Z([1,2,3]),delay:N(.25),ease:{type:h.String,value:"OutQuad"}}},{id:"follow",type:"followParent",enabled:!0,params:{speed:N(4)}},{id:"move",type:"moveTo",enabled:!0,params:{target:Z([5,6,7]),ease:{type:h.String,value:"InQuad"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({mass:2,randomDriftDelay:.25,randomDriftEasing:2,followParentSpeed:4,hasFollowParent:!0,moveToEasing:1,hasMoveTo:!0}),t(a.emitters[0].randomDrift.toArray()).toEqual([1,2,3]),t(a.emitters[0].moveToTarget.toArray()).toEqual([5,6,7])}),a("compiles authored easing independently for visual behavior channels",()=>{const e=$("easing");e.behaviours.push({id:"scale",type:"scale",enabled:!0,params:{a:N(0),b:N(1),ease:{type:h.String,value:"OutBack"}}},{id:"color",type:"changeColor",enabled:!0,params:{a:{type:h.Color,value:"#ff0000"},b:{type:h.Color,value:"#0000ff"},ease:{type:h.String,value:"InCubic"}}},{id:"opacity",type:"changeOpacity",enabled:!0,params:{a:N(0),b:N(1),ease:{type:h.String,value:"BumpSharp"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].curveEasing.toArray()).toEqual([18,4,20,0]);const i=V(16);t(i).toContain("fn applyEasing"),t(i).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, colorProgress)"),t(i).toContain("mix(emitter.scales.xy, emitter.scales.zw, scaleProgress)")}),a("compiles disperse into optional shared particle state",()=>{const e=$("disperse");e.behaviours.push({id:"disperse",type:"disperse",enabled:!0,params:{distance:N(3),axis:{type:h.String,value:"XZ"},ease:{type:h.String,value:"InOutSine"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].disperseDistance).toBe(3),t(a.emitters[0].dispersePlane).toBe(2),t(a.emitters[0].disperseEasing).toBe(12),t(V(16)).not.toContain("disperseStates");const i=V(16,!1,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("optionalStates[0u + particleIndex].w = disperseProgress")}),a("consolidates every optional simulation feature into the eighth storage binding",()=>{const e=V(16,!0,!0,!0,!0,!1,!1,48,64),a=[...e.matchAll(/@group\(0\) @binding\((\d+)\) var<storage/g)].map(e=>Number(e[1]));t(a).toEqual([1,2,3,4,5,6,7,8]),t(e).toContain("optionalStates[48u + particleIndex]"),t(e).toContain("optionalStates[64u + particleIndex]"),t(e).not.toMatch(/@binding\((9|10|11)\)/)}),a("lays out optional state regions without overlap",()=>{t(x({capacity:16,particleTransforms:!0,vortex:!0,disperse:!0,trailHistoryEntries:20})).toEqual({vortexOffset:48,disperseOffset:64,collisionEventOffset:80,trailOffset:80,meshSamplerOffset:100,entries:100,bytes:1600})}),a("materializes decal outputs as shared unit-box mesh render classes",async()=>{const e=$("decal");e.output={type:"decal",space:"world",colorMap:"",alphaMap:"",color:"#ffffff",unlit:!0,intensity:1,blendingMode:"normal",bloom:!1};const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await f(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].meshVertices?.length).toBe(288),t(l.renderClasses[0].material).toBe(r)}),a("materializes generated shape outputs through the shared mesh path",async()=>{const e=$("shape");e.output={type:"shape",shape:"box",params:{},material:"shape-material",materialSource:"material",space:"world"};const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await f(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].meshVertices?.length).toBe(288)}),a("forwards the physical particle index to fragment-stage model-matrix graphs",()=>{const e=S(new i({color:s(n(1),1)})),a=T(e,!0);t(a).toContain("color: vec4<f32>, instanceIndex: u32"),t(a).toContain("gpuParticle.color, particleIndex)"),t(a).toContain("@interpolate(flat) instanceIndex: u32"),t(a).toContain("output.instanceIndex = particleIndex")}),a("compiles soft default sprites with a pay-for-play scene-depth requirement",async()=>{const e=$("soft-sprite",H({softness:2})),a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const i=(await f(a.renderClasses,{getTexture:async()=>new o.Texture})).renderClasses[0].material,n=S(i);t(Object.values(n.sceneTextures)).toContain("sceneDepth")}),a("disables depth writes only for transparent custom GPU sprite materials",async()=>{const e=$("graph-sprite",H({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"graph"}})),a=g(Q(e),[new o.Vector3]),r=new i({color:s(n(1),.5),transparent:!0}),l=await f(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].material).toBe(r),t(r.depthWrite).toBe(!1),t(S(r).renderState.depthWrite).toBe(!1);const c=new i({color:s(n(1),1)}),p=await f(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>c);t(p.renderClasses[0].material).toBe(c),t(c.depthWrite).toBe(!0),t(S(c).renderState.depthWrite).toBe(!0)}),a("supports local-space sprite render classes through the shared emitter transform table",async()=>{const e=$("local-sprite",H({space:"local"})),a=g(Q(e),[new o.Vector3(4,5,6)]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].localSpace).toBe(!0),t(a.emitters[0].position.toArray()).toEqual([0,0,0]);const i=await f(a.renderClasses,{getTexture:async()=>new o.Texture});t(i.renderClasses[0].localSpace).toBe(!0)}),a("falls back before rendering when an authored graph cannot be resolved",async()=>{const e=$("graph",H({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"missing-graph"}})),a=await C({asset:Q(e),instancePositions:[new o.Vector3],assets:{getTexture:async()=>new o.Texture}});t(a.backend).toBe("cpu"),t(a.runtime).toBeNull(),t(a.reasons[0]).toContain("GPU material compilation failed")}),a("compiles default flipbook, texture, UV, and particle semantics to WGSL",async()=>{const e=$("flipbook",H({flipbook:{enabled:!0,rows:4,columns:4,fps:12,mode:"loop"}})),a=g(Q(e),[new o.Vector3]),i=await f(a.renderClasses,{getTexture:async()=>new o.Texture}),n=S(i.renderClasses[0].material);t(n.fragmentMain).toContain("textureSample"),t(`${n.vertexMain}\n${n.fragmentMain}`).toContain("gpuParticle.positionAge.w"),t(n.varyings.length).toBeGreaterThan(0)}),a("supports component scale-over-life for sprites",()=>{const e=$("stretched-smoke");e.behaviours.push({id:"stretch",type:"scaleComponents",enabled:!0,params:{a:Z([.25,1,1]),b:Z([2,.5,1]),ease:{type:22,value:"Linear"}}});const a=g(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].meshScaleStart.toArray()).toEqual([.25,1,1]),t(a.emitters[0].meshScaleEnd.toArray()).toEqual([2,.5,1])}),a("compiles authored scale and rotation input bindings",()=>{const e=$("input-driven",X());e.initializers.push({id:"scale",type:"scale",enabled:!0,params:{scale:{...N(1),binding:{type:"vfxInput",inputId:"scale-input"}}}},{id:"rotation",type:"rotation",enabled:!0,params:{rotation:{...Z([0,-100,0]),binding:{type:"vfxInput",inputId:"rotation-input"}}}}),e.behaviours.push({id:"rotate",type:"rotate",enabled:!0,params:{rotation:{...Z([0,-30,0]),binding:{type:"vfxInput",inputId:"step-input"}}}});const a=Q(e);a.vfx.inputs=[{id:"scale-input",name:"Scale",type:"number",defaultValue:1},{id:"rotation-input",name:"Rotation",type:"vec3",defaultValue:[0,-100,0]},{id:"step-input",name:"Rotation step",type:"vec3",defaultValue:[0,-30,0]}];const i=g(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.inputBindings.map(e=>e.target)).toEqual(["initializer:scale:scale","initializer:rotation:rotation","behaviour:rotate:rotation"])})});/*
|
|
1
|
+
import{describe as e,expect as t,it as a}from"vitest";import*as o from"three";import{NodeShaderMaterial as i,rgb as n,rgba as s,standardMaterial as r,textureSampler2d as l,uniformFloat as c,uniformSampler2d as p,uniformVec3 as m,varyingAttributes as d,vec3 as u}from"three-shader-graph";import{SerializedParamType as h}from"../scene/model.js";import{WorldCollisionMethod as y}from"../effects/vfx/vfx-asset.js";import{compileGpuSpriteEmitters as f,materializeGpuSpriteRenderClasses as g,requiresCpuVfxBackend as b,selectVfxBackend as w}from"../effects/vfx/gpu-sprite-compiler.js";import{prepareGpuSpriteBackend as C}from"../effects/vfx/gpu-sprite-backend.js";import{calculateGpuSpriteOptionalStateLayout as v,GPU_TRAIL_RENDER_CLASS_BYTES as x,GpuSpriteDirtyRange as B,getCompiledGpuSpriteMaterial as S,getGpuSpriteComputeWgsl as V,getGpuSpriteMaterialWgsl as T,getGpuTrailMaterialWgsl as M}from"../effects/vfx/gpu-sprite-runtime.js";import{Trail as E,trailUV as I}from"../effects/vfx/trail-renderer.js";import{timeUniforms as P}from"../shader-nodes/time.js";import{GpuSpriteSchedulerRuntime as O}from"../effects/vfx/gpu-sprite-scheduler-runtime.js";import{GpuSpriteTemplateCache as j,instantiateGpuSpriteTemplate as z}from"../effects/vfx/gpu-sprite-template.js";import{GpuSpriteWorldRuntime as q}from"../effects/vfx/gpu-sprite-world-runtime.js";import{GpuSpriteRenderClassCache as A}from"../effects/vfx/gpu-sprite-compiler.js";import{addGpuSpriteRenderGraphPasses as R}from"../effects/vfx/gpu-sprite-render-graph.js";import{gpuVfxCapacityClass as k}from"../effects/vfx/gpu-vfx-capacity.js";import{buildParticleLightingShaderHeader as G,ComputeKernel as L}from"@hology/webgpu-renderer";import{configureWebGpuRuntimeBindings as _}from"../rendering/webgpu-runtime-bridge.js";_({createComputeKernel:(e,t)=>new L(e,t),buildParticleLightingShaderHeader:G});import{sampleSceneColor as U}from"../shader-nodes/scene-sample.js";import{screenUV as D}from"../shader-nodes/depth.js";import{particleUniforms as F}from"../shader-nodes/particle.js";import{createGpuMeshAssetMaterial as W}from"../effects/vfx/vfx-gpu-material.js";const H=e=>({type:h.Number,value:e}),N=e=>({type:h.Vector3,value:e});function Z(e={}){return{type:"sprite",texture:"dot",color:"#ffffff",intensity:1,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}function X(e={}){return{type:"mesh",assetId:"simple-mesh",space:"world",...e}}function Y(e={}){return{type:"trail",length:10,width:.5,taper:!0,color:"#40a0ff",colorEnd:"#ff4080",intensity:2,intensityEnd:.25,opacityStart:1,opacityEnd:0,bloom:!1,billboard:!0,opacityChannel:"alpha",space:"world",...e}}function $(e,t=Z()){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:H(2)}},{id:`${e}-position`,type:"positionSphere",enabled:!0,params:{position:N([0,0,0]),radius:H(1),surface:{type:h.Boolean,value:!1}}}],behaviours:[]}}function Q(...e){return{id:"test-vfx",name:"test-vfx",type:"vfx",vfx:{emitters:e}}}e("GPU sprite compiler compatibility",()=>{a("keeps standard asset materials lit for GPU mesh particles",async()=>{const e=new o.Texture,a=new o.Texture,n=new o.Texture,s=new i({color:r({color:m("color",new o.Vector3(.3,.2,.1)).rgb.multiply(p("map",e).sample(d.uv).rgb),roughness:c("roughness",.8).multiply(p("roughnessMap",a).sample(d.uv).g),normal:p("normalMap",n).sample(d.uv).rgb,metalness:.1}),opacity:c("opacity",1)}),l=await W("material-id",{getAsset:async()=>({id:"material-id"}),getTexture:async()=>e,getMesh:async()=>new o.Mesh,getMaterial:async()=>s}),u=S(l),h=T(u,!0);t(u.requiresRendererShaderHeader).toBe(!0),t(u.needsWorldPosition).toBe(!0),t(u.fragmentMain).toContain("rendererPointLightForLight"),t(u.fragmentMain).toContain("rendererDirectionalLightForLight"),t(h).toContain("fn rendererPointLightCount()"),t(u.textures.map).toBe(e),t(u.textures.roughnessMap).toBe(a),t(u.textures.normalMap).toBe(n)}),a("bakes an authored effect input into emitter spawn delay",()=>{const e=$("bound-delay");e.rate.delayBinding={type:"vfxInput",inputId:"timing"};const a=Q(e);a.vfx.inputs=[{id:"timing",name:"Timing",type:"number",defaultValue:.5}];const i=f(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate.delay).toBe(.5)}),a("bakes the emitter rotation into world-space spawn position, velocity, and mesh rotation",()=>{const e=V(16,!0,!1,!1,!1,!0);t(e).toContain("zoneOffset = worldSpawnOffset(emitter, zoneOffset)"),t(e).toContain("let initialVelocity = worldSpawnDirection(emitter, localInitialVelocity)"),t(e).toContain("localInitialVelocity += randomConeDirection(sampledMeshNormal"),t(e).toContain("worldSpawnRotation(emitter), quaternionFromEulerXYZ(initialRotationEuler)"),t(e).toContain("optionalStates[meshStateOffset], quaternionFromEulerXYZ(rotationDelta)"),t(e).not.toContain("optionalStates[meshStateOffset] = quaternionFromEulerXYZ(rotationEuler)"),t(e).toContain("emitter.trailTransform0 = command.trailTransform0"),t(e).not.toMatch(/__[A-Z_]+__/)}),a("compiles pay-for-play child emitters and screen-space collision metadata",()=>{const e=$("parent");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{bounce:H(.6),friction:H(.2),lifeLoss:H(.5),debug:{type:h.Boolean,value:!0}}});const a=$("child");a.rate={type:"once",count:3,delay:0},a.spawnEvent="collision",e.children=[a];const i=f(Q(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters).toHaveLength(2),t(i.emitters[0]).toMatchObject({hasWorldCollision:!0,collisionBounce:.6,collisionFriction:.2,collisionLifeLoss:.5,collisionDebug:!0}),t(i.emitters[1]).toMatchObject({parentEmitter:0,childParticlesPerParent:3,childSpawnEvent:2,childLevel:1,capacity:6});const n=V(16);t(n).not.toContain("collisionDepth"),t(n).not.toContain("optionalStates"),t(n).not.toContain("triangleRecord"),t(n).not.toMatch(/__[A-Z_]+__/);const s=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,0,!1,!0);t(s).toContain("var<storage, read_write> optionalStates"),t(s).toContain("let triangleRecord = optionalStates[triangleOffset]"),t(s).toContain("fn meshSamplerBoneMatrix"),t(s).toContain("meshSamplerVertexPosition(samplerOffset"),t(s).toContain("var triangleOffset = samplerOffset"),t(s).not.toContain("let triangleOffset = samplerOffset");const r=V(16,!1,!1,!1,!1,!1,!1,0,0,!0,!0,32,1);t(r).toContain("collisionDepth"),t(r).toContain("collisionStep <= 6u"),t(r).toContain("approachingVisibleSurface"),t(r).toContain("optionalStates[32u + owner.padding]"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("supports local-space parent particles spawning local or world-space children",()=>{const e=$("parent",Z({space:"local"})),a=$("local-child",Z({space:"local"})),i=$("world-child",Z({space:"world"}));e.children=[a,i];const n=f(Q(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.compatibility.issues).toEqual([]),t(n.emitters.map(e=>e.localSpace)).toEqual([!0,!0,!1]);const s=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(s).not.toContain("parentTransform = mat4x4");const r=V(16,!1,!1,!1,!1,!0,!1,0,0,!1,!1,0,1,!0);t(r).toContain("parentTransform = mat4x4"),t(r).toContain("parentEmitter.trail.z > 0.0 && emitter.trail.z < 0.0"),t(r).toContain("childEmissionEnd = parent.velocityLife.w"),t(r).toContain("particle.positionAge = vec4(particle.positionAge.xyz, age)"),t(r).not.toContain("childEventReady = parentAlive"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("invalidates recycled parent spawn events during a new activation delay",()=>{const e=$("delayed-impact",Z({space:"world"}));e.rate.delay=.13,e.children=[$("ground-impact",Z({space:"world"}))];const a=f(Q(e),[new o.Vector3(12,0,8)]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].rate.delay).toBe(.13);const i=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(i).toContain("if (!emissionStarted || !stopAllowsEmission || totalSpawn <= simulationLocalSlot)"),t(i).toContain("particle.scaleAlphaEmitterGeneration.w = 0.0;"),t(i.indexOf("particle.scaleAlphaEmitterGeneration.w = 0.0;")).toBeLessThan(i.indexOf("particles[particleIndex] = particle;")),t(i).not.toMatch(/__[A-Z_]+__/),t(V(16)).not.toContain("particle.scaleAlphaEmitterGeneration.w = 0.0;")}),a("rejects world-space parents with local-space child output",()=>{const e=$("parent",Z({space:"world"}));e.children=[$("child",Z({space:"local"}))];const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues).toContainEqual(t.objectContaining({code:"child-emitter-local-space"}))}),a("orders child render classes by authored render order without changing simulation hierarchy",()=>{const e=$("parent",X({renderOrder:10})),a=$("child",Z({renderOrder:-10}));e.children=[a];const i=f(Q(e),[new o.Vector3]);t(i.renderClasses.map(e=>e.output.renderOrder)).toEqual([-10,10]),t(i.emitters[0].parentEmitter).toBeUndefined(),t(i.emitters[0].renderClass).toBe(1),t(i.emitters[1]).toMatchObject({parentEmitter:0,renderClass:0})}),a("uses geometric world reservation classes without fixed template maxima",()=>{t([1,2,3,4,5,17].map(k)).toEqual([1,2,4,4,8,32])}),a("compiles stretched sprites into a velocity-aligned render class",async()=>{const e=f(Q($("stretched",function(e={}){return{type:"stretchedSprite",texture:"dot",color:"#ffffff",intensity:1,scale:.5,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}({scale:.75}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await g(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].stretchScale).toBe(.75);const i=T(S(a.renderClasses[0].material),!1);t(i).toContain("stretchedSpriteClipPosition"),t(i).toContain("bitcast<f32>(renderClass.data.w)")}),a("materializes billboard trails with CPU-compatible history sizing",async()=>{const e=f(Q($("trail",Y({length:9.6,uvMode:"repeat",unitsPerRepeat:.5}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.emitters).toHaveLength(1),t(e.emitters[0].capacity).toBe(2);const a=await g(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({historyLength:11,width:.5,taper:!0,uvMode:"repeat",unitsPerRepeat:.5}),t(a.renderClasses[0].texture?.wrapS).toBe(o.RepeatWrapping),t(x).toBe(96);const i=V(16,!1,!1,!1,!0);t(i).toContain("@group(0) @binding(8) var<storage, read_write> optionalStates"),t(i).not.toContain("@binding(9)")}),a("streams local trail transforms through emitter commands without another storage binding",()=>{const e=f(Q($("attached-trail",Y({space:"local"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=V(16,!1,!1,!1,!0,!0);t(a).toContain("trailTransform0: vec4<f32>"),t(a).toContain("emitter.trailTransform3 = command.trailTransform3"),t(a).not.toContain("@binding(12)")}),a("compiles trail graph UV varyings into the procedural ribbon ABI",()=>{const e=new i({color:s(u(I.x,I.y,F.opacity),F.opacity)});E.applyTrailShaderParameters(e);const a=M(e,!0);t(a).toContain("struct GraphVertexInput"),t(a).toContain("nodeVertexID: f32"),t(a).toContain("nodeDistance: f32"),t(a).toContain("renderClass.uvSettings.x"),t(a).toContain("headDistance - mix(historyDistance(state, control), historyDistance(state, control + 1), t)"),t(a).toContain("emitterTransforms"),t(a).not.toContain("__TRAIL_"),t(a).not.toContain("output.uv ="),t(a).not.toContain("output.color ="),t(a).not.toContain("normalize(after - before)"),t(a).not.toContain("2.0, 2.0, 2.0, 0.0")}),a("binds shader graph elapsed time to the GPU particle frame clock",()=>{const e=new i({color:s(u(P.elapsed),1)}),a=M(e),o=T(S(e),!1);t(a).toContain("frame.time"),t(o).toContain("frame.time"),t(a).not.toContain("hology_elapsed_time:"),t(o).not.toContain("hology_elapsed_time:")}),a("converts authored sRGB behavior colors to the linear render space",()=>{const e=$("legacy-color",Y());e.behaviours.push({id:"color",type:"changeColor",enabled:!0,params:{a:{type:h.Color,value:"#918383"},b:{type:h.Color,value:"#4f4f4f"}}});const a=f(Q(e),[new o.Vector3]);t(a.emitters[0].colorStart.toArray()).toEqual(new o.Color("#918383").toArray()),t(a.emitters[0].colorEnd.toArray()).toEqual(new o.Color("#4f4f4f").toArray())}),a("materializes one mesh geometry and rejects mesh hierarchies with multiple geometries",async()=>{const e=$("mesh",X({space:"local"}));e.initializers.push({id:"mesh-scale-components",type:"scaleComponents",enabled:!0,params:{scale:N([1,2,3])}},{id:"mesh-rotation",type:"rotation",enabled:!0,params:{rotation:N([30,45,60])}}),e.behaviours.push({id:"mesh-scale-components-behaviour",type:"scaleComponents",enabled:!0,params:{a:N([2,1,1]),b:N([1,.5,2])}});const a=Q(e),r=f(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 g(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 g(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l},async()=>p);t(m.renderClasses[0].material).toBe(p);const d=new o.Group;d.add(new o.Mesh(new o.BoxGeometry),new o.Mesh(new o.BoxGeometry)),await t(g(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>d})).rejects.toThrow("exactly one mesh geometry")}),a("keeps an unconfigured mesh output GPU-compatible without rendering it",()=>{const e=f(Q($("unconfigured-mesh",X({assetId:null}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.compatibility.issues).toEqual([]),t(e.emitters).toEqual([]),t(e.renderClasses).toEqual([])}),a("treats an unselected mesh material override as the default material source",async()=>{const e=f(Q($("unselected-mesh-material",X({materialSource:"material",material:void 0}))),[new o.Vector3]),a=new i({color:s(n(.4),1)}),r=await g(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=f(Q($("unselected-mesh-graph",X({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:null}}))),[new o.Vector3]),a=await g(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)});t(e.compatibility.compatible).toBe(!0),t(()=>S(a.renderClasses[0].material)).not.toThrow()}),a("gates custom mesh materials on a compatible GPU material resolver",async()=>{const e=f(Q($("mesh-material",X({material:"material-id"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a={getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)};await t(g(e.renderClasses,a)).rejects.toThrow("compatible GPU material resolver");const n=new o.Texture,s=await g(e.renderClasses,a,async()=>new i({color:l(n).sample(d.uv)}));t(s.renderClasses[0].material).toBeInstanceOf(i);const r=S(s.renderClasses[0].material);t(Object.values(r.textures)).toEqual([n])}),a("materializes non-billboard trails with orientation history",async()=>{const e=f(Q($("oriented-trail",Y({billboard:!1}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await g(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({billboard:!1});const i=V(16,!0,!1,!1,!0);t(i).toContain("trailSampleOffset + 1u"),t(i).toContain("quaternionMultiply(particleRotation, movementRotation)");const n=M(E.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=f(Q($("mesh",X({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:H(1)}}))),[new o.Vector3]),i=f(Q($("mesh",X({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:H(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=f(Q($("modern",X())),[new o.Vector3]),n=f(Q($("legacy",X({useLegacyTransform:!0}))),[new o.Vector3]);t(i.renderClasses[0].key).not.toBe(n.renderClasses[0].key);const s={getTexture:async()=>new o.Texture,getMesh:async()=>e},r=await g(i.renderClasses,s),l=await g(n.renderClasses,s);t(r.renderClasses[0].meshVertices?.[0]).toBe(3),t(l.renderClasses[0].meshVertices?.[0]).toBe(1)}),a("does not reuse render classes across explicit template revisions",async()=>{const e=Q($("revisioned")),a={assets:{getTexture:async()=>new o.Texture},renderClassCache:new A},i=await(new j).resolve(e,{...a,revision:1}),n=await(new j).resolve(e,{...a,revision:2});t(i.template?.renderClasses[0]).not.toBe(n.template?.renderClasses[0])}),a("retains renderer-owned scene texture semantics for particle materials",()=>{const e=new i({color:U(D)}),a=S(e);t(Object.values(a.sceneTextures)).toEqual(["sceneColor"])}),a("retains the shared renderer geometry semantics for lit particle materials",()=>{const e=S(new i({color:r({color:n(6724044),roughness:.5,metalness:.1})}));t(e.needsWorldPosition).toBe(!0),t(e.needsWorldNormal).toBe(!0),t(e.requiresRendererShaderHeader).toBe(!0),t(e.fragmentMain).toContain("rendererDirectionalLightForLight"),t(G()).toContain("fn rendererCameraPosition()");const a=T(e,!0);t(a).toContain("fn rendererCameraPosition()"),t(a).toContain("rendererCameraPosition() - input.worldPosition"),t(a).toContain("@group(1) @binding(0)"),t(a).toContain("emitterTransform[0].xyz"),t(a).not.toContain("mat3x3<f32>(emitterTransforms["),t(a).toContain("fn normalMatrixFromLinearTransform"),t(a).not.toContain("inverse("),t(e.renderState.transparent).toBe(!1),t(e.renderState.depthWrite).toBe(!0),t(e.renderState.side).toBe(o.FrontSide)}),a("registers one ordered compute/render pair for a shared runtime",()=>{const e=[];let a;R({mainDepth:"depth",addPass:t=>e.push(t)},{executeCompute:()=>{},executeRender:(e,t)=>{a=t},getSceneTextureReads:()=>["sceneColorSnapshot"]},"color","WorldVfx"),t(e.map(e=>e.name)).toEqual(["WorldVfxSimulation","WorldVfxRender"]),t(e[1].dependsOn).toEqual(["WorldVfxSimulation"]),t(e[1].inputs).toMatchObject({sceneColorSnapshot:"sceneColorSnapshot"}),e[1].execute({}),t(a).toBe("color")}),a("coalesces touched emitter slots without allocating per mark",()=>{const e=new B;e.mark(7),e.mark(3),e.mark(5),t(e).toMatchObject({start:3,end:8,length:5}),e.clear(),t(e.length).toBe(0)}),a("generates particle-owner WGSL without reserved active identifiers",()=>{const e=V(16);t(e).toContain("enabled: u32"),t(e).not.toMatch(/\bactive\s*:/),t(e).not.toContain("owner.active"),t(e).toContain("fn applyEmitterCommandsMain"),t(e).toContain("@group(0) @binding(7) var<storage, read> emitterCommands"),t(e).toContain("var<storage, read_write> emitters"),t(e).toContain("timing: vec4<f32>"),t(e).toContain("let emitterDelta = select(frame.delta * emitter.timing.w, 0.0"),t(e).toContain("let sampledLife = mix(emitter.emission.z, emitter.emission.w"),t(e).toContain("particle.velocityLife = vec4(initialVelocity, particleLife)"),t(e).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, hash(particleSeed + 71u))"),t(e).toContain("select(animatedColor, particle.color.rgb, emitter.meshScaleStart.w > 0.5)")}),a("compiles random lifetime as a per-particle birth range",()=>{const e=$("random-life");e.initializers[0]={id:"random-life-range",type:"randomLifetime",enabled:!0,params:{minDuration:H(.5),maxDuration:H(2.5)}};const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({lifeMin:.5,life:2.5})}),a("compiles random initial color without lifetime interpolation",()=>{const e=$("random-color");e.initializers.push({id:"random-color-range",type:"randomColor",enabled:!0,params:{a:{type:h.Color,value:"#ff0000"},b:{type:h.Color,value:"#0000ff"}}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].randomColor).toBe(!0)}),a("compiles point, box, line, ring, and sphere-surface spawn zones",()=>{const e=[["positionPoint",{position:N([1,2,3])},0],["positionSphere",{position:N([1,2,3]),radius:H(2),surface:{type:h.Boolean,value:!0}},1],["positionBox",{position:N([1,2,3]),dimensions:N([4,5,6])},2],["positionLine",{a:N([1,2,3]),b:N([4,5,6])},3],["positionRing",{position:N([1,2,3]),rotation:N([10,20,30]),radius:H(4),thickness:H(.5),angle:H(180)},4]];for(const[a,i,n]of e){const e=$(`zone-${a}`);e.initializers[1]={id:`zone-${a}`,type:a,enabled:!0,params:i};const s=f(Q(e),[new o.Vector3]);t(s.compatibility.compatible).toBe(!0),t(s.emitters[0].spawnZoneType).toBe(n)}const a=V(16);t(a).toContain("fn rotateSpawnPoint"),t(a).toContain("if (zoneType == 4u)")}),a("preserves legacy per-update sprite rotation behavior",()=>{const e=$("sprite-rotate");e.behaviours.push({id:"rotate-add",type:"rotateAdd",enabled:!0,params:{rotation:N([0,0,200])}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].spriteRotationStep).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const i=V(16);t(i).toContain("var rotation = particle.color.w;"),t(i).toContain("rotation += emitter.rotationInitialSpriteStep.w * frame.padding1"),e.output=X();const n=f(Q(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.emitters[0].meshRotationStep.z).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const s=V(16,!0);t(s).toContain("rotationEuler: vec4<f32>"),t(s).toContain("fn quaternionFromEulerXYZ"),t(s).toContain("emitter.rotationStep.xyz * frame.padding1"),e.initializers.push({id:"random-mesh-rotation",type:"randomRotation",enabled:!0,params:{axis:{type:h.String,value:"Y"}}});const r=f(Q(e),[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.emitters[0].randomMeshRotationAxis).toBe(3),t(s).toContain("randomRotationAxis == 1u"),t(s).toContain("randomRotationAxis == 4u")}),a("compiles radial velocity without per-particle auxiliary storage",()=>{const e=$("radial-velocity");e.initializers.push({id:"radial",type:"radialVelocity",enabled:!0,params:{speed:H(4),direction:N([0,1,0]),spread:H(30)}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].hasRadialVelocity).toBe(!0),t(a.emitters[0].radialVelocitySpeed).toBe(4),t(a.emitters[0].radialVelocitySpread).toBeCloseTo(Math.PI/6);const i=V(16);t(i).toContain("fn radialVelocity"),t(i).toContain("emitter.radialMetadata.y > 0.5")}),a("compiles one attraction or repulsion field per emitter",()=>{const e=$("attraction");e.behaviours.push({id:"attract",type:"attraction",enabled:!0,params:{target:N([1,2,3]),force:H(4),radius:H(5)}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].attractionTarget.toArray()).toEqual([1,2,3]),t(a.emitters[0].attractionForce).toBe(4),e.behaviours[0].type="repulsion";const i=f(Q(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].attractionForce).toBe(-4);const n=V(16);t(n).toContain("attractionDistanceSq < attractionRadiusSq")}),a("compiles mesh alignment modes into spawn-only parameters",()=>{const e=$("aligned-mesh",X());e.initializers.push({id:"align",type:"alignRotation",enabled:!0,params:{axis:{type:h.String,value:"+X"},mode:{type:h.String,value:"AlongVelocity"},offset:N([10,20,30]),center:N([1,2,3])}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].alignRotationAxis.toArray()).toEqual([1,0,0]),t(a.emitters[0].alignRotationMode).toBe(3),t(a.emitters[0].alignRotationOffset.x).toBeCloseTo(o.MathUtils.degToRad(10));const i=V(16,!0);t(i).toContain("fn quaternionFromUnitVectors"),t(i).toContain("alignRotationMode == 3u")}),a("groups authored render classes deterministically",()=>{const e=f(Q($("a"),$("b"),$("c",Z({blendingMode:"normal"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.renderClasses).toHaveLength(2),t(e.emitters.map(e=>e.renderClass)).toEqual([0,0,1])}),a("can lay out emitter allocations contiguously per dynamic effect instance",()=>{const e=Q($("first"),$("second",Z({blendingMode:"normal"}))),a=[new o.Vector3,new o.Vector3(1,0,0)],i=f(e,a),n=f(e,a,"instance-major");t(i.emitters.map(e=>e.renderClass)).toEqual([0,0,1,1]),t(n.emitters.map(e=>e.renderClass)).toEqual([0,1,0,1])}),a("reports sparse active-page simulation capacity",async()=>{const e=await C({asset:Q($("paged")),instancePositions:[new o.Vector3,new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:2,assets:{getTexture:async()=>new o.Texture}});e.runtime.setActivePages([0,2]),t(e.runtime.getStats()).toMatchObject({capacity:6,activePageCount:2,simulatedCapacity:4})}),a("compacts active page padding out of the simulation schedule",async()=>{const e=await O.create({asset:Q($("tiny-paged-effect")),maximumInstances:2,particlePageSize:256,assets:{getTexture:async()=>new o.Texture}});e.spawn(new o.Vector3),e.flushActivePages(),t(e.runtime.getStats()).toMatchObject({capacity:512,activePageCount:1,simulatedCapacity:2});const a=V(256);t(a).toContain("let particleIndex = activeParticleIndices[id.x]"),t(a).not.toContain("id.x % pageSize"),e.dispose()}),a("sizes alive indices by logical particles instead of render-class count",async()=>{const e=await C({asset:Q($("additive"),$("normal",Z({blendingMode:"normal"}))),instancePositions:[new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:4,assets:{getTexture:async()=>new o.Texture}});t(e.runtime.getStats()).toMatchObject({capacity:8,renderClassCount:2,aliveIndexCapacity:8,aliveIndexBytes:32});const a=V(4);t(a).toContain("aliveIndices[indirect[renderClass].firstInstance + outputIndex]"),t(a).not.toContain("renderClass * u32(frame.capacity)")}),a("allocates and recycles multi-page dynamic effect instances",async()=>{const e=$("paged-instance");e.position=[2,0,0];const a=await O.create({asset:Q(e),maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),i=a.spawn(new o.Vector3);t(a.runtime.emitters[0].position.x).toBe(2),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),a.update(.1),t(a.scheduler.getInstance(i.handle)).not.toBeNull(),t(a.play(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!1),t(a.setTimescale(i,2)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(2),t(a.stop(i)).toBe(!0),t(a.runtime.emitters[0].stopTime).toBeGreaterThanOrEqual(0),t(a.runtime.emitters[0].expireWithin).toBe(-1),t(i.pages).toHaveLength(2),a.flushActivePages(),t(a.runtime.getStats()).toMatchObject({activePageCount:2,simulatedCapacity:2}),t(a.destroy(i)).toBe(!0);const n=a.spawn(new o.Vector3(1,0,0));t(new Set(n.pages)).toEqual(new Set(i.pages)),t(n.handle.generation).not.toBe(i.handle.generation),a.dispose()}),a("caps GPU particle drain time for expire and kill lifecycle behavior",async()=>{const e=await O.create({asset:Q($("expiring")),maximumInstances:2,assets:{getTexture:async()=>new o.Texture}}),a=e.spawn(new o.Vector3);t(e.stop(a,.5)).toBe(!0),t(e.runtime.emitters[0].expireWithin).toBe(.5);for(let t=0;t<4;t++)e.update(.1);t(e.scheduler.getInstance(a.handle)).not.toBeNull(),e.update(.1),t(e.scheduler.getInstance(a.handle)).toBeNull();const i=e.spawn(new o.Vector3);t(e.stop(i,0)).toBe(!0),e.update(.001),t(e.scheduler.getInstance(i.handle)).toBeNull(),e.dispose()}),a("rejects a template pool before allocation when its storage budget is exceeded",async()=>{const e=await O.create({asset:Q($("over-budget")),maximumInstances:128,particlePageSize:1,maximumStorageBytes:1,assets:{getTexture:async()=>new o.Texture}});t(e).toBeNull()}),a("compiles delayed and finite continuous emission timing",()=>{const e=$("delayed");e.rate={type:"once",count:2,delay:.25};const a=$("finite");a.rate={type:"continuous",count:2,time:.1,delay:0,duration:1};const i=f(Q(e,a),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate).toMatchObject({type:"once",delay:.25}),t(i.emitters[1].rate).toMatchObject({type:"continuous",delay:0,duration:1})}),a("treats zero continuous duration as unlimited like the CPU rate",()=>{const e=$("unlimited");e.rate={type:"continuous",count:2,time:.1,delay:0,duration:0};const a=f(Q(e),[new o.Vector3]);t(a.compatibility.issues.map(e=>e.code)).not.toContain("rate-duration")}),a("compiles bound lifetime metadata but falls back for unsupported input targets",()=>{const e=Q($("input-test"));e.vfx.inputs=[{id:"speed",name:"Speed",type:"number",defaultValue:1}],t(f(e,[new o.Vector3]).compatibility.compatible).toBe(!0),e.vfx.emitters[0].initializers[0].params.duration.binding={type:"vfxInput",inputId:"speed"};const a=f(e,[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.inputBindings).toEqual([t.objectContaining({inputId:"speed",emitterIndex:0,target:"initializer:lifetime:duration"})]),e.vfx.emitters[0].initializers.push({id:"bound-velocity",type:"velocity",enabled:!0,params:{velocity:{type:h.Vector3,value:[0,1,0],binding:{type:"vfxInput",inputId:"speed"}}}}),t(f(e,[new o.Vector3]).compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding")}),a("falls back for a bound continuous lifetime until particle capacity can grow",()=>{const e=$("continuous-bound-life");e.rate={type:"continuous",count:1,time:.1,delay:0,duration:0},e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"};const a=Q(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:2}];const i=f(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding"),t(i.inputBindings).toEqual([])}),a("caches compiled templates while keeping instance emitter state independent",async()=>{const e=Q($("cached-template")),a=new j;let i=0;const n={assets:{getTexture:async()=>(i+=1,new o.Texture)}},s=await a.resolve(e,n),r=await a.resolve(e,n);t(r.template).toBe(s.template),t(i).toBe(1),t(a.getStats()).toMatchObject({builds:1,hits:1});const l=z(s.template,[new o.Vector3(1,0,0),new o.Vector3(2,0,0)]);t(l[0].position).not.toBe(l[1].position),l[0].position.x=100,t(l[1].position.x).toBe(2),t(s.template.emitters[0].position.x).toBe(0),a.invalidate(e),await a.resolve(e,n),t(a.getStats()).toMatchObject({builds:2,hits:1,invalidations:1}),await a.resolve(e,{...n,revision:2}),t(a.getStats()).toMatchObject({builds:3,hits:1,invalidations:2})}),a("applies a bound lifetime only to the addressed scheduled instance",async()=>{const e=$("bound-life");e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"},e.initializers[1].params.radius.binding={type:"vfxInput",inputId:"radius"},e.initializers.push({id:"velocity",type:"velocity",enabled:!0,params:{velocity:{type:h.Vector3,value:[0,0,0],binding:{type:"vfxInput",inputId:"velocity"}}}});const a=Q(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:1},{id:"radius",name:"Radius",type:"number",defaultValue:1},{id:"velocity",name:"Velocity",type:"vec3",defaultValue:[0,0,0]}];const i=await O.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=Z(),a=await q.create({templates:[{asset:Q($("world-a",e)),maximumInstances:2},{asset:Q($("world-b",{...e})),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},templateCache:new j,renderClassCache:new A,particlePageSize:1}),i=a.spawn(0,new o.Vector3),n=a.spawn(1,new o.Vector3(1,0,0));a.flushActivePages(),t(a.templateCount).toBe(2),t(a.runtime.getStats()).toMatchObject({renderClassCount:1,activePageCount:4}),t(a.scheduler.getStats()).toMatchObject({activeInstances:2,arena:{usedPages:4}}),t(a.destroy(i)).toBe(!0),t(a.destroy(n)).toBe(!0),a.flushActivePages(),t(a.runtime.hasActiveParticles).toBe(!1),t(a.runtime.getStats()).toMatchObject({aliveParticles:0,activePageCount:0,simulatedCapacity:0}),a.dispose()}),a("routes per-instance lifecycle changes through a shared multi-template world",async()=>{const e=$("world-lifecycle");e.position=[2,0,0];const a=await q.create({templates:[{asset:Q(e),maximumInstances:1},{asset:Q($("world-neighbour")),maximumInstances:1}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1}),i=a.spawn(0,new o.Vector3(3,0,0));t(a.runtime.emitters[0].position.x).toBe(5),t(a.setPosition(i,new o.Vector3(7,0,0))).toBe(!0),t(a.runtime.emitters[0].position.x).toBe(9),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),t(a.play(i)).toBe(!0),t(a.setTimescale(i,.5)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(.5),t(a.stop(i,0)).toBe(!0),a.update(.001),t(a.has(i)).toBe(!1),t(a.setPosition(i,new o.Vector3)).toBe(!1),a.dispose()}),a("rejects a shared world before runtime allocation when its storage budget is exceeded",async()=>{const e=await q.create({templates:[{asset:Q($("world-over-budget")),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1,maximumStorageBytes:1});t(e).toBeNull()}),a("falls back to CPU for semantic mismatches",()=>{const e=$("soft-local",Z({softness:1,space:"local",lockY:!0}));e.behaviours.push({id:"animation",type:"animation",enabled:!0,params:{}});const a=f(Q(e),[new o.Vector3]),i=w(a.compatibility);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues.map(e=>e.code)).toEqual(t.arrayContaining(["behaviour:animation"])),t(i.backend).toBe("cpu"),t(w(a.compatibility,"gpu").backend).toBe("gpu")}),a("intentionally selects CPU simulation for physics-raycast world collision",()=>{const e=$("physics-collision");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{method:{type:h.String,value:y.PhysicsRaycast}}});const a=Q(e),i=f(a,[new o.Vector3]);t(b(a)).toBe(!0),t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues).toContainEqual(t.objectContaining({severity:"requirement",code:"physics-raycast-collision"})),t(w(i.compatibility).backend).toBe("cpu"),t(w(i.compatibility,"gpu").backend).toBe("cpu")}),a("compiles vortex with pay-for-play previous-velocity state",()=>{const e=$("vortex");e.behaviours.push({id:"vortex",type:"vortex",enabled:!0,params:{axis:N([0,5,0]),amount:H(1.25)}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].vortexAxis.toArray()).toEqual([0,5,0]),t(a.emitters[0].vortexAmount).toBe(1.25),t(V(16,!1,!1)).not.toContain("vortexVelocities");const i=V(16,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("behaviourVelocity -= optionalStates[0u + particleIndex].xyz")}),a("compiles the remaining initializer and motion behaviours",()=>{const e=$("remaining-motion");e.initializers.push({id:"mass",type:"mass",enabled:!0,params:{min:H(2)}}),e.behaviours.push({id:"drift",type:"randomDrift",enabled:!0,params:{drift:N([1,2,3]),delay:H(.25),ease:{type:h.String,value:"OutQuad"}}},{id:"follow",type:"followParent",enabled:!0,params:{speed:H(4)}},{id:"move",type:"moveTo",enabled:!0,params:{target:N([5,6,7]),ease:{type:h.String,value:"InQuad"}}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({mass:2,randomDriftDelay:.25,randomDriftEasing:2,followParentSpeed:4,hasFollowParent:!0,moveToEasing:1,hasMoveTo:!0}),t(a.emitters[0].randomDrift.toArray()).toEqual([1,2,3]),t(a.emitters[0].moveToTarget.toArray()).toEqual([5,6,7])}),a("compiles authored easing independently for visual behavior channels",()=>{const e=$("easing");e.behaviours.push({id:"scale",type:"scale",enabled:!0,params:{a:H(0),b:H(1),ease:{type:h.String,value:"OutBack"}}},{id:"color",type:"changeColor",enabled:!0,params:{a:{type:h.Color,value:"#ff0000"},b:{type:h.Color,value:"#0000ff"},ease:{type:h.String,value:"InCubic"}}},{id:"opacity",type:"changeOpacity",enabled:!0,params:{a:H(0),b:H(1),ease:{type:h.String,value:"BumpSharp"}}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].curveEasing.toArray()).toEqual([18,4,20,0]);const i=V(16);t(i).toContain("fn applyEasing"),t(i).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, colorProgress)"),t(i).toContain("mix(emitter.scales.xy, emitter.scales.zw, scaleProgress)")}),a("compiles disperse into optional shared particle state",()=>{const e=$("disperse");e.behaviours.push({id:"disperse",type:"disperse",enabled:!0,params:{distance:H(3),axis:{type:h.String,value:"XZ"},ease:{type:h.String,value:"InOutSine"}}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].disperseDistance).toBe(3),t(a.emitters[0].dispersePlane).toBe(2),t(a.emitters[0].disperseEasing).toBe(12),t(V(16)).not.toContain("disperseStates");const i=V(16,!1,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("optionalStates[0u + particleIndex].w = disperseProgress")}),a("consolidates every optional simulation feature into the eighth storage binding",()=>{const e=V(16,!0,!0,!0,!0,!1,!1,48,64),a=[...e.matchAll(/@group\(0\) @binding\((\d+)\) var<storage/g)].map(e=>Number(e[1]));t(a).toEqual([1,2,3,4,5,6,7,8]),t(e).toContain("optionalStates[48u + particleIndex]"),t(e).toContain("optionalStates[64u + particleIndex]"),t(e).not.toMatch(/@binding\((9|10|11)\)/)}),a("lays out optional state regions without overlap",()=>{t(v({capacity:16,particleTransforms:!0,vortex:!0,disperse:!0,trailHistoryEntries:20})).toEqual({vortexOffset:48,disperseOffset:64,collisionEventOffset:80,trailOffset:80,meshSamplerOffset:100,entries:100,bytes:1600})}),a("materializes decal outputs as shared unit-box mesh render classes",async()=>{const e=$("decal");e.output={type:"decal",space:"world",colorMap:"",alphaMap:"",color:"#ffffff",unlit:!0,intensity:1,blendingMode:"normal",bloom:!1};const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await g(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].meshVertices?.length).toBe(288),t(l.renderClasses[0].material).toBe(r)}),a("materializes generated shape outputs through the shared mesh path",async()=>{const e=$("shape");e.output={type:"shape",shape:"box",params:{},material:"shape-material",materialSource:"material",space:"world"};const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await g(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].meshVertices?.length).toBe(288)}),a("forwards the physical particle index to fragment-stage model-matrix graphs",()=>{const e=S(new i({color:s(n(1),1)})),a=T(e,!0);t(a).toContain("color: vec4<f32>, instanceIndex: u32"),t(a).toContain("gpuParticle.color, particleIndex)"),t(a).toContain("@interpolate(flat) instanceIndex: u32"),t(a).toContain("output.instanceIndex = particleIndex")}),a("compiles soft default sprites with a pay-for-play scene-depth requirement",async()=>{const e=$("soft-sprite",Z({softness:2})),a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const i=(await g(a.renderClasses,{getTexture:async()=>new o.Texture})).renderClasses[0].material,n=S(i);t(Object.values(n.sceneTextures)).toContain("sceneDepth")}),a("disables depth writes only for transparent custom GPU sprite materials",async()=>{const e=$("graph-sprite",Z({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"graph"}})),a=f(Q(e),[new o.Vector3]),r=new i({color:s(n(1),.5),transparent:!0}),l=await g(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].material).toBe(r),t(r.depthWrite).toBe(!1),t(S(r).renderState.depthWrite).toBe(!1);const c=new i({color:s(n(1),1)}),p=await g(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>c);t(p.renderClasses[0].material).toBe(c),t(c.depthWrite).toBe(!0),t(S(c).renderState.depthWrite).toBe(!0)}),a("supports local-space sprite render classes through the shared emitter transform table",async()=>{const e=$("local-sprite",Z({space:"local"})),a=f(Q(e),[new o.Vector3(4,5,6)]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].localSpace).toBe(!0),t(a.emitters[0].position.toArray()).toEqual([0,0,0]);const i=await g(a.renderClasses,{getTexture:async()=>new o.Texture});t(i.renderClasses[0].localSpace).toBe(!0)}),a("falls back before rendering when an authored graph cannot be resolved",async()=>{const e=$("graph",Z({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"missing-graph"}})),a=await C({asset:Q(e),instancePositions:[new o.Vector3],assets:{getTexture:async()=>new o.Texture}});t(a.backend).toBe("cpu"),t(a.runtime).toBeNull(),t(a.reasons[0]).toContain("GPU material compilation failed")}),a("compiles default flipbook, texture, UV, and particle semantics to WGSL",async()=>{const e=$("flipbook",Z({flipbook:{enabled:!0,rows:4,columns:4,fps:12,mode:"loop"}})),a=f(Q(e),[new o.Vector3]),i=await g(a.renderClasses,{getTexture:async()=>new o.Texture}),n=S(i.renderClasses[0].material);t(n.fragmentMain).toContain("textureSample"),t(`${n.vertexMain}\n${n.fragmentMain}`).toContain("gpuParticle.positionAge.w"),t(n.varyings.length).toBeGreaterThan(0)}),a("supports component scale-over-life for sprites",()=>{const e=$("stretched-smoke");e.behaviours.push({id:"stretch",type:"scaleComponents",enabled:!0,params:{a:N([.25,1,1]),b:N([2,.5,1]),ease:{type:22,value:"Linear"}}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].meshScaleStart.toArray()).toEqual([.25,1,1]),t(a.emitters[0].meshScaleEnd.toArray()).toEqual([2,.5,1])}),a("compiles authored scale and rotation input bindings",()=>{const e=$("input-driven",X());e.initializers.push({id:"scale",type:"scale",enabled:!0,params:{scale:{...H(1),binding:{type:"vfxInput",inputId:"scale-input"}}}},{id:"rotation",type:"rotation",enabled:!0,params:{rotation:{...N([0,-100,0]),binding:{type:"vfxInput",inputId:"rotation-input"}}}}),e.behaviours.push({id:"rotate",type:"rotate",enabled:!0,params:{rotation:{...N([0,-30,0]),binding:{type:"vfxInput",inputId:"step-input"}}}});const a=Q(e);a.vfx.inputs=[{id:"scale-input",name:"Scale",type:"number",defaultValue:1},{id:"rotation-input",name:"Rotation",type:"vec3",defaultValue:[0,-100,0]},{id:"step-input",name:"Rotation step",type:"vec3",defaultValue:[0,-30,0]}];const i=f(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"])})}),a("shares default sprite shader source across numeric settings without merging material bindings",async()=>{const e=Z({softness:1,flipbook:{enabled:!0,columns:4,rows:4,fps:12,mode:"loop"}}),a=Z({softness:5,flipbook:{enabled:!0,columns:8,rows:8,fps:64,mode:"loop"}}),i=f(Q($("first",e),$("second",a)),[new o.Vector3]);t(i.renderClasses).toHaveLength(2);const n=new o.Texture,s=await g(i.renderClasses,{getTexture:async()=>n}),r=s.renderClasses[0].material,l=s.renderClasses[1].material;t(r.uniforms.vfxSoftness.value).toBe(.2),t(l.uniforms.vfxSoftness.value).toBe(1),t(r.uniforms.vfxFlipbookColumns.value).toBe(4),t(l.uniforms.vfxFlipbookColumns.value).toBe(8),t(r.uniforms.vfxFlipbookFps.value).toBe(12),t(l.uniforms.vfxFlipbookFps.value).toBe(64),t(T(S(r),!0)).toBe(T(S(l),!0));const c=f(Q($("first",e),$("second",{...a,softness:1})),[new o.Vector3]);t(c.renderClasses).toHaveLength(2)});/*
|
|
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{afterEach as e,describe as t,expect as r,it as n,vi as
|
|
1
|
+
import{afterEach as e,describe as t,expect as r,it as n,vi as i}from"vitest";import{Color as a,Texture as o,Vector3 as l}from"three";import{compileGpuSpriteEmitters as p}from"../effects/vfx/gpu-sprite-compiler.js";import{GpuSpriteRuntime as s}from"../effects/vfx/gpu-sprite-runtime.js";import*as c from"../rendering/webgpu-runtime-bridge.js";e(()=>{i.unstubAllGlobals(),i.restoreAllMocks()}),t("GPU sprite pipeline sharing",()=>{n("shares pipelines across classes and worlds while keeping their bindings independent",()=>{const e=i.spyOn(c,"registerWebGpuPipelineOrigin").mockImplementation(()=>{});i.stubGlobal("GPUBufferUsage",{STORAGE:1,COPY_DST:2,UNIFORM:4,INDIRECT:8,COPY_SRC:16,MAP_READ:32}),i.stubGlobal("GPUShaderStage",{COMPUTE:1,VERTEX:2,FRAGMENT:4});const t=()=>({createBuffer:()=>({destroy:i.fn()}),createShaderModule:i.fn(()=>({})),createBindGroupLayout:i.fn(()=>({})),createPipelineLayout:()=>({}),createComputePipeline:()=>({}),createRenderPipeline:i.fn(e=>({...e})),createBindGroup:i.fn(e=>({...e})),queue:{writeBuffer:i.fn()}}),n=new o,a=new o,d=new Map([n,a].map(e=>[e,{sampler:{},view:{}}])),u=p({id:"sprite",name:"sprite",type:"vfx",vfx:{emitters:[{id:"emitter",name:"emitter",position:[0,0,0],rotation:[0,0,0],rate:{type:"once",count:1,delay:0},initializers:[],behaviours:[],output:{type:"sprite",texture:"dot",color:"#fff",intensity:1,blendingMode:"additive",space:"world"}}]}},[new l]).emitters,f=t(),m={device:f,colorFormat:"rgba16float",depthView:{},getExternalTexture:e=>d.get(e)},P=[],B=(e=m)=>{const t=new s(u,[{texture:n},{texture:a},{texture:n,localSpace:!0}]).createResource(e);return P.push(t),t.renderResources};try{const i=B();r(f.createRenderPipeline).toHaveBeenCalledTimes(2),r(e.mock.calls.find(([,,e])=>e.key.startsWith("gpu-vfx:"))?.[2]).toMatchObject({shader:{key:"builtin:sprite",name:"Default sprite"},bindings:null}),r(i[0].pipeline).toBe(i[1].pipeline),r(i[2].pipeline).not.toBe(i[0].pipeline),r(i[0].renderClassBuffer).not.toBe(i[1].renderClassBuffer);const o=i[0].getBindGroup(m),l=i[1].getBindGroup(m);r(o).not.toBe(l),r(o.layout).toBe(l.layout),r(o.entries.some(e=>e.resource===d.get(n).view)).toBe(!0),r(l.entries.some(e=>e.resource===d.get(a).view)).toBe(!0);const p=B();r(f.createRenderPipeline).toHaveBeenCalledTimes(2),r(p[0].pipeline).toBe(i[0].pipeline),r(p[0].renderClassBuffer).not.toBe(i[0].renderClassBuffer);const s=B({...m,colorFormat:"rgba8unorm"});r(f.createRenderPipeline).toHaveBeenCalledTimes(4),r(s[0].pipeline).not.toBe(i[0].pipeline),r(s[0].pipeline.vertex.module).toBe(i[0].pipeline.vertex.module),B({...m,depthView:null}),r(f.createRenderPipeline).toHaveBeenCalledTimes(6);const c=t(),u=B({...m,device:c});r(c.createRenderPipeline).toHaveBeenCalledTimes(2),r(u[0].pipeline).not.toBe(i[0].pipeline)}finally{for(const e of P)e.dispose()}})}),t("GPU sprite async compute preparation",()=>{n("starts all compute pipelines together, waits for readiness, and reuses them on first render",async()=>{i.stubGlobal("GPUBufferUsage",{STORAGE:1,COPY_DST:2,UNIFORM:4,INDIRECT:8,COPY_SRC:16,MAP_READ:32}),i.stubGlobal("GPUShaderStage",{COMPUTE:1,VERTEX:2,FRAGMENT:4});const e=[],t={createBuffer:i.fn(()=>({destroy:i.fn()})),createShaderModule:i.fn(()=>({})),createBindGroupLayout:i.fn(()=>({})),createPipelineLayout:i.fn(()=>({})),createRenderPipeline:i.fn(()=>({})),createComputePipeline:i.fn(()=>({})),createComputePipelineAsync:i.fn(()=>new Promise(t=>e.push(t))),createBindGroup:i.fn(e=>({...e})),queue:{writeBuffer:i.fn()}},n=new s([],[{texture:new o}]),a=n.prepareComputePipelines(t);r(n.prepareComputePipelines(t)).toBe(a),await i.waitFor(()=>r(t.createComputePipelineAsync).toHaveBeenCalledTimes(3)),r(t.createComputePipeline).not.toHaveBeenCalled(),r(t.createBuffer).not.toHaveBeenCalled();let l=!1;a.then(()=>{l=!0});const p=[{},{},{}];e[0](p[0]),e[1](p[1]),await Promise.resolve(),r(l).toBe(!1),e[2](p[2]),await a;const c=n.createResource({device:t,colorFormat:"rgba16float",depthView:null,getExternalTexture:()=>({sampler:{},view:{}})});try{r(c.clearPipeline).toBe(p[0]),r(c.applyCommandsPipeline).toBe(p[1]),r(c.simulatePipeline).toBe(p[2]),r(t.createComputePipeline).not.toHaveBeenCalled(),await n.prepareComputePipelines(t),r(t.createComputePipelineAsync).toHaveBeenCalledTimes(3)}finally{c.dispose()}}),n("propagates compilation failure and allows a retry",async()=>{i.stubGlobal("GPUShaderStage",{COMPUTE:1,VERTEX:2,FRAGMENT:4});const e={createShaderModule:()=>({}),createBindGroupLayout:()=>({}),createPipelineLayout:()=>({}),createComputePipelineAsync:i.fn().mockRejectedValue(new Error("compile failed"))},t=new s([],[{texture:new o}]);await r(t.prepareComputePipelines(e)).rejects.toThrow("compile failed"),e.createComputePipelineAsync.mockResolvedValue({}),await t.prepareComputePipelines(e),r(e.createComputePipelineAsync).toHaveBeenCalledTimes(6)})}),t("GPU sprite async render preparation",()=>{n("prepares sprite, mesh and trail pipelines before resource creation and shares pending variants",async()=>{i.stubGlobal("GPUBufferUsage",{STORAGE:1,COPY_DST:2,UNIFORM:4,INDIRECT:8,COPY_SRC:16,MAP_READ:32}),i.stubGlobal("GPUShaderStage",{COMPUTE:1,VERTEX:2,FRAGMENT:4});const e=new o,t=[{texture:e},{texture:e,localSpace:!0},{texture:e,meshVertices:new Float32Array(24)},{texture:e,trail:{historyLength:4,width:1,taper:!1,colorStart:new a,colorEnd:new a,opacityStart:1,opacityEnd:0,scrollSpeed:0,uvMode:"stretch",unitsPerRepeat:1,opacityChannel:"alpha",blendingMode:"additive",billboard:!0}}],n=[],l={createBuffer:i.fn(()=>({destroy:i.fn()})),createShaderModule:i.fn(()=>({})),createBindGroupLayout:i.fn(()=>({})),createPipelineLayout:i.fn(()=>({})),createComputePipeline:i.fn(()=>({})),createRenderPipeline:i.fn(()=>({})),createRenderPipelineAsync:i.fn(e=>new Promise(t=>n.push(()=>t({...e})))),createBindGroup:i.fn(e=>({...e})),queue:{writeBuffer:i.fn()}},p=new s([],t),c=new s([],t);let d=!1;const u=Promise.all([p.prepareRenderPipelines(l,"rgba16float"),c.prepareRenderPipelines(l,"rgba16float")]).then(()=>{d=!0});await i.waitFor(()=>r(l.createRenderPipelineAsync).toHaveBeenCalledTimes(4)),r(d).toBe(!1),r(l.createBuffer).not.toHaveBeenCalled();for(const e of n)e();await u;const f={device:l,colorFormat:"rgba16float",depthView:{},getExternalTexture:()=>({sampler:{},view:{}})},m=[p,c].map(e=>e.createResource(f));try{r(l.createRenderPipeline).not.toHaveBeenCalled();for(let e=0;e<t.length;e++)r(m[0].renderResources[e].pipeline).toBe(m[1].renderResources[e].pipeline);r(m[0].renderResources[2].pipeline.vertex.buffers).toHaveLength(1),r(m[0].renderResources[0].pipeline.depthStencil.format).toBe("depth32float")}finally{m.forEach(e=>e.dispose())}}),n("removes failed pending variants so preparation can retry",async()=>{i.stubGlobal("GPUShaderStage",{COMPUTE:1,VERTEX:2,FRAGMENT:4});const e={createShaderModule:()=>({}),createBindGroupLayout:()=>({}),createPipelineLayout:()=>({}),createRenderPipelineAsync:i.fn().mockRejectedValue(new Error("render compile failed"))},t=new s([],[{texture:new o}]);await r(t.prepareRenderPipelines(e,"rgba16float")).rejects.toThrow("render compile failed"),e.createRenderPipelineAsync.mockResolvedValue({}),await t.prepareRenderPipelines(e,"rgba16float"),r(e.createRenderPipelineAsync).toHaveBeenCalledTimes(2)})});/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import{describe as e,expect as r,it as t,vi as n}from"vitest";n.hoisted(()=>{const e=new Proxy({},{get:(e,r)=>e[r]??(e[r]=n.fn())});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e})}),n.mock("../rendering/webgpu-runtime-bridge.js",()=>({ensureWebGpuRuntimeBindings:n.fn(async()=>{})}));import{GpuVfxWorldService as s}from"../effects/vfx/gpu-vfx-world-service.js";function o(){let e;return{promise:new Promise(r=>{e=r}),resolve:e}}function a(){const e=[],r=Object.assign(Object.create(s.prototype),{view:{requestedBackend:"webgpu"},cpuFallbackReasons:new Map,entriesByAssetId:new Map,pendingEntryBuilds:new Set,rebuildTail:Promise.resolve(!0),pendingRebuild:null,currentEpoch:null,createEntry:n.fn(async r=>{await r.loaded;const t={asset:r,references:0,reservedInstances:1};return e.push(t),t}),rebuildWorld:n.fn(async()=>(r.currentEpoch={reservations:new Map(e.map(e=>[e,e.reservedInstances]))},!0))});return{world:r,entries:e}}async function i(){await new Promise(e=>setTimeout(e,0))}e("GPU VFX preload generations",()=>{t("waits for concurrent templates and builds one world with every actor reservation",async()=>{const{world:e,entries:t}=a(),n=o(),s={id:"first",vfx:{inputs:[],emitters:[]}},l={id:"second",vfx:{inputs:[],emitters:[]},loaded:n.promise};let c=!1;const d=Promise.all([e.prepare(s),e.prepare(s),e.prepare(l)]).then(e=>(c=!0,e));await i(),r(e.createEntry).toHaveBeenCalledTimes(2),r(e.rebuildWorld).not.toHaveBeenCalled(),r(c).toBe(!1),n.resolve();const p=await d;r(p.every(e=>null!=e)).toBe(!0),r(e.rebuildWorld).toHaveBeenCalledTimes(1);for(const n of t)r(e.currentEpoch.reservations.get(n)).toBeGreaterThanOrEqual(n.references)}),t("builds another generation for a template arriving during GPU creation",async()=>{const{world:e,entries:t}=a(),n=o();e.rebuildWorld.mockImplementationOnce(async()=>{const r=new Map(t.map(e=>[e,e.reservedInstances]));return await n.promise,e.currentEpoch={reservations:r},!0});const s=e.prepare({id:"first",vfx:{inputs:[],emitters:[]}});await i(),r(e.rebuildWorld).toHaveBeenCalledTimes(1);const l=e.prepare({id:"second",vfx:{inputs:[],emitters:[]}});await i(),r(e.rebuildWorld).toHaveBeenCalledTimes(1),n.resolve(),await Promise.all([s,l]),r(e.rebuildWorld).toHaveBeenCalledTimes(2),r(e.currentEpoch.reservations.size).toBe(2)})});/*
|
|
2
|
+
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
|
+
* See the LICENSE.md file for details.
|
|
4
|
+
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{describe as e,expect as t,it as a,vi as s}from"vitest";import{Scene as
|
|
1
|
+
import{describe as e,expect as t,it as a,vi as s}from"vitest";import{Scene as r,Texture as i}from"three";import{Subject as n}from"rxjs";s.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&s.fn()),e[t]),set:(e,t,a)=>(e[t]=a,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),s.mock("../gameplay/actors/builtin/index.js",()=>({default:{}})),s.mock("../effects/vfx/vfx-actor.js",()=>({VfxActor:class{}})),s.mock("../effects/vfx/vfx-param.js",()=>({VisualEffect:class{}})),s.mock("../rendering.js",()=>({RenderingView:class{},setRenderingPaused:s.fn()}));import{SceneMaterializer as d}from"../scene/materializer.js";import{DetailTier as o,SerializedParamType as l}from"../scene/model.js";function p(e,t){const a=s.fn(async(e,t)=>({scene:{},animations:[]})),o=s.fn(async e=>new i),l=s.fn(async e=>({texture:new i,layerIndex:0})),p=s.fn(),c={getObjects:()=>e,onCreate:()=>{},onUpdate:()=>{},onRemove:()=>{}},m={onCreate:new n,onDelete:new n,onUpdate:new n,getAsset:s.fn(async e=>t.get(e)??null),getAssets:s.fn(async()=>Array.from(t.values()))};return{materializer:new d(new r,c,m,{getMesh:a,getTexture:o,getTextureArray:l},{renderer:{initTexture:p},initTexture:p,onLoop:s.fn()},[],[],{create:async()=>null,initActor:async()=>{}}),getMesh:a,getTexture:o,getTextureArray:l,initTexture:p}}function c(e){return{name:e.id,...e}}function m(e,t){return{id:e,name:e,type:"mesh",fileKey:`${e}.glb`,fileFormat:"glb",mesh:t}}function f(e,t){return{id:e,name:e,type:"prefab",prefab:{objects:t}}}function u(e,t){return{id:e,name:e,type:"material",material:{type:"shader",side:0,params:{},shaderParams:{map:{type:l.Texture,value:t}}}}}function y(e){return{id:e,name:e,type:"texture",fileKey:`${e}.png`,fileFormat:"png"}}function h(e){return{color:"#ffffff",materialId:e}}e("SceneMaterializer asset prefetch",()=>{a("shares VFX warmup across characters and starts independent effects concurrently",async()=>{const{materializer:e}=p([],new Map);let a;e.inEditor=!1;const i=new Promise(e=>{a=e}),d=[0,1].map(()=>({object:new r,prepareRendering:s.fn(()=>i),onEndPlay:s.fn(),disposed:new n})),o=d.map((e,t)=>({asset:{id:`vfx-${t}`,vfx:{emitters:[]}},create:s.fn(async()=>e)})),l=e.preloadVisualEffects(o),c=e.preloadVisualEffects([o[0]]);await Promise.resolve(),t(o.every(e=>1===e.create.mock.calls.length)).toBe(!0),t(d.every(e=>1===e.prepareRendering.mock.calls.length)).toBe(!0),t(d.every(e=>0===e.onEndPlay.mock.calls.length)).toBe(!0),a(),await Promise.all([l,c]),await e.preloadVisualEffects(o),t(o.every(e=>1===e.create.mock.calls.length)).toBe(!0),t(d.every(e=>1===e.onEndPlay.mock.calls.length)).toBe(!0)}),a("retries a failed VFX preload",async()=>{const{materializer:e}=p([],new Map);e.inEditor=!1;const a={asset:{id:"vfx",vfx:{emitters:[]}},create:s.fn().mockRejectedValueOnce(new Error("load failed")).mockResolvedValue({object:new r,prepareRendering:s.fn(async()=>{}),onEndPlay:s.fn(),disposed:new n})};await t(e.preloadVisualEffects([a])).rejects.toThrow("load failed"),await e.preloadVisualEffects([a]),t(a.create).toHaveBeenCalledTimes(2)}),a("prefetches unique materialized asset meshes without creating discarded instances",async()=>{const e=m("mesh-a"),a=m("mesh-b"),s=m("mesh-c"),r=m("mesh-disabled"),i=m("mesh-high",{detailTier:o.high}),n=f("prefab-a",[c({id:"prefab-mesh-c",type:"asset_mesh",assetId:s.id}),c({id:"prefab-duplicate-mesh-a",type:"asset_mesh",assetId:e.id}),c({id:"prefab-cycle",type:"prefab",assetId:"prefab-a"})]),d=new Map([e,a,s,r,i,n].map(e=>[e.id,e])),l=[c({id:"root",type:"group",children:[c({id:"mesh-a-1",type:"asset_mesh",assetId:e.id}),c({id:"mesh-a-2",type:"asset_mesh",assetId:e.id}),c({id:"disabled-group",type:"group",enabled:!1,children:[c({id:"disabled-mesh",type:"asset_mesh",assetId:r.id})]}),c({id:"prefab-instance",type:"prefab",assetId:n.id}),c({id:"high-detail-mesh",type:"asset_mesh",assetId:i.id})]}),c({id:"mesh-b",type:"asset_mesh",assetId:a.id})],{materializer:u,getMesh:y}=p(l,d);u.detailTier=o.low,await u.preInit(),await u.prefetchAssets(),t(y).toHaveBeenCalledTimes(3),t(new Set(y.mock.calls.map(([e])=>e.id))).toEqual(new Set([e.id,a.id,s.id])),t(y.mock.calls.every(([,e])=>!0===e?.mergeGeomtries)).toBe(!0),t(y.mock.calls.every(([,e])=>!1===e?.clone)).toBe(!0)}),a("initializes textures from nested groups and prefab assets",async()=>{const e=y("texture-root"),a=y("texture-group"),s=y("texture-prefab"),r=y("texture-skipped"),i=u("material-root",e.id),n=u("material-group",a.id),d=u("material-prefab",s.id),o=u("material-skipped",r.id),l=f("prefab-a",[c({id:"prefab-group",type:"group",children:[c({id:"prefab-shape",type:"shape_mesh",materialAssignments:[h(d.id)]})]}),c({id:"prefab-cycle",type:"prefab",assetId:"prefab-a"})]),m=new Map([e,a,s,r,i,n,d,o,l].map(e=>[e.id,e])),g=[c({id:"root-group",type:"group",children:[c({id:"nested-mesh",type:"asset_mesh",materialAssignments:[h(n.id)]}),c({id:"disabled-group",type:"group",enabled:!1,children:[c({id:"skipped-mesh",type:"asset_mesh",materialAssignments:[h(o.id)]})]}),c({id:"prefab-instance",type:"prefab",assetId:l.id})]}),c({id:"root-shape",type:"shape_mesh",materialAssignments:[h(i.id)]})],{materializer:b,getTexture:w,initTexture:x}=p(g,m);await b.preInit(),await b.initTextures(),t(w).toHaveBeenCalledTimes(3),t(new Set(w.mock.calls.map(([e])=>e.id))).toEqual(new Set([e.id,a.id,s.id])),t(x).toHaveBeenCalledTimes(3)})});/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import{describe as e,expect as t,it as s}from"vitest";import{SRGBColorSpace as o,RGBA_BPTC_Format as r,RGBA_ASTC_4x4_Format as i}from"three";import{parseNativeTexture as n,selectNativeTexture as a}from"../scene/native-texture.js";function c(e,t){const s=[64,16,16,16].map(e=>e*Math.max(1,t)),o=new ArrayBuffer(176+s.reduce((e,t)=>e+t,0));new Uint8Array(o).set([171,75,84,88,32,50,48,187,13,10,26,10]);const r=new DataView(o);r.setUint32(12,"bc7"===e?146:158,!0),r.setUint32(16,1,!0),r.setUint32(20,8,!0),r.setUint32(24,8,!0),r.setUint32(32,t,!0),r.setUint32(36,1,!0),r.setUint32(40,4,!0);let i=176;for(let e=0;e<4;e++)r.setBigUint64(80+24*e,BigInt(i),!0),r.setBigUint64(88+24*e,BigInt(s[e]),!0),r.setBigUint64(96+24*e,BigInt(s[e]),!0),i+=s[e];return o}e("native GPU textures",()=>{s("selects only supported variants and leaves unsupported devices on the fallback",()=>{const e={bc7:"desktop.ktx2",astc:"mobile.ktx2"};t(a(e,!0,!0)?.fileKey).toBe("desktop.ktx2"),t(a(e,!1,!0)?.fileKey).toBe("mobile.ktx2"),t(a(e,!1,!1)).toBeUndefined(),t(a({astc:"mobile.ktx2"},!0,!1)).toBeUndefined()}),s.each(["bc7","astc"])("creates %s mip chains usable by the custom WebGPU renderer without copying",e=>{const s=c(e,0),a=n(s,e);t(a.format).toBe("bc7"===e?r:i),t(a.colorSpace).toBe(o),t(a.flipY).toBe(!1),t(a.generateMipmaps).toBe(!1),t(a.mipmaps).toHaveLength(4),t(a.mipmaps[3].data.buffer).toBe(s)}),s("preserves array layers for the custom WebGPU renderer",()=>{const e=n(c("bc7",3),"bc7");t(e.isCompressedArrayTexture).toBe(!0),t(e.image.depth).toBe(3),t(e.mipmaps[3].data.byteLength).toBe(48)}),s("rejects mismatched formats, truncated levels and supercompressed payloads",()=>{t(()=>n(c("astc",0),"bc7")).toThrow(/format/);const e=c("bc7",0);t(()=>n(e.slice(0,-1),"bc7")).toThrow(/mip/),new DataView(e).setUint32(44,2,!0),t(()=>n(e,"bc7")).toThrow(/supercompression/)})});/*
|
|
2
|
+
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
|
+
* See the LICENSE.md file for details.
|
|
4
|
+
*/
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import{afterEach as e,expect as a,it as n,vi as s}from"vitest";import{RuntimeBundledBackendService as t}from"../scene/runtime-bundled-backend-service.js";e(()=>s.unstubAllGlobals()),n("resolves baked references beside the bundle for browser and native URLs",async()=>{s.stubGlobal("fetch",s.fn(async()=>({json:async()=>({"scene-blob":[{id:"scene",data:[],navigationBakes:{"prefab/nav":{file:"navigation/mesh.bin",settings:"settings"}}}]})})));for(const[e,n]of[["https://example.com/game/hology_data.json","https://example.com/game/navigation/mesh.bin"],["data/hology_data.json","data/navigation/mesh.bin"]]){const s=new t(e);await s.preloadData(),a((await s.getSceneNavigationBakes("scene"))["prefab/nav"].file).toBe(n)}});/*
|
|
2
|
+
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
|
+
* See the LICENSE.md file for details.
|
|
4
|
+
*/
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import{describe as e,expect as t,it as a}from"vitest";import{Box3 as o,Matrix4 as s,Vector3 as i}from"three";import{importNavMesh as n,NavMeshQuery as r}from"recast-navigation";import{bakeNavigation as p,collectNavigationInputs as c,landscapeNavigationGeometry as d,navigationEntries as l,navigationVolumes as y}from"../navigation/bake.js";const h=e=>({id:"object",name:"Object",type:"group",...e});e("headless navigation baking",()=>{a("bakes, serializes and imports walkable collision geometry crossing the volume boundary",async()=>{const e=[h({id:"floor",type:"shape_mesh",shape:"box",position:[0,-.5,0],scale:[100,1,100]}),h({id:"nav",type:"actor",actor:{type:"NavMesh"},scale:[40,10,40]})],a=l(e,[]),o=y(a)[0],s=await c({objects:e,assets:[],loadCollisionShapes:async()=>[]},a,o.bounds),i=await p(o,s),d=n(i).navMesh,m=new r(d);try{const e=m.computePath({x:-10,y:0,z:0},{x:10,y:0,z:0});t(e.success).toBe(!0),t(e.path.at(-1)?.x).toBeCloseTo(10)}finally{m.destroy(),d.destroy()}}),a("uses full-density distant terrain, transformed bounds and authored holes",()=>{const e=h({shape:"landscape",landscape:{options:{sectionSize:10,density:2,sections:{x:8,y:1}},heightMaps:[{x:7,y:0,points:[{i:4,y:3}]}],holes:[{m:"7,0",i:0,w:[1]}]}}),a=(new s).makeTranslation(1e3,0,0),n=new o(new i(1028,-10,-11),new i(1034,10,1)),r=d(e,a,n);t(r).toHaveLength(1),t(r[0].positions[13]).toBe(3),t(r[0].positions[0]).toBe(1025),t(r[0].indices).not.toContain(0),t(r[0].indices.length).toBe(21)}),a("expands nested prefab volumes with inherited transforms and overrides",()=>{const e={id:"prefab",name:"Prefab",type:"prefab",prefab:{mainActorId:"nav",objects:[h({id:"nav",type:"actor",actor:{type:"NavMesh"},position:[2,0,0],scale:[10,5,10]})]}},a=[h({id:"instance",type:"prefab",assetId:e.id,position:[100,0,0],prefab:{params:{cellSize:{type:0,value:.5}}}})],o=y(l(a,[e]))[0];t(o.id).toBe("instance/nav"),t(o.bounds.min.x).toBe(97),t(o.config.cs).toBe(.5),t(()=>l([h({type:"prefab",assetId:"missing"})],[])).toThrow("Missing navigation prefab")}),a("waits for in-flight worker jobs before releasing a failed bake",async()=>{const e=y(l([h({type:"actor",actor:{type:"NavMesh"},scale:[100,10,100]})],[]))[0];let a=0;await t(p(e,{geometry:[]},async()=>{if(0===a++)throw new Error("worker failed");await new Promise(e=>setTimeout(e,10)),a++})).rejects.toThrow("worker failed"),t(a).toBe(7)})});/*
|
|
2
|
+
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
|
+
* See the LICENSE.md file for details.
|
|
4
|
+
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import*as e from"three";import{describe as s,expect as a,it as t,vi as n}from"vitest";import{
|
|
1
|
+
import*as e from"three";import{describe as s,expect as a,it as t,vi as n}from"vitest";import{createAudioClipInstance as r,createAudioTrack as c,createSequenceData as o,createVfxClipInstance as i,createVfxTrack as l}from"../effects/sequence/sequence-data.js";import{SequencePlayer as p}from"../effects/sequence/sequence-player.js";function d(){return{object:new e.Object3D,restart:n.fn(),play:n.fn(),pause:n.fn(),prepareRendering:n.fn(async()=>{}),previewAt:n.fn(),clearPreview:n.fn(),stop:n.fn(),getParticleCount:n.fn(()=>0)}}async function f(){await Promise.resolve(),await Promise.resolve()}n.mock("../gameplay/actors/builtin/index.js",()=>({builtInActors:{},default:{}})),n.mock("../gameplay/actors/index.js",()=>({CharacterAnimationComponent:class{},CharacterMovementComponent:class{}})),n.mock("../effects/vfx/vfx-actor.js",()=>({VfxActor:class{}})),n.mock("../effects/vfx/vfx-param.js",()=>({VisualEffect:class{}})),n.mock("../effects/vfx/vfx-service.js",()=>({VfxService:class{}})),n.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,s)=>(s in e||(e[s]=("string"!=typeof s||!s.startsWith("is"))&&n.fn()),e[s]),set:(e,s,a)=>(e[s]=a,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e})}),s("sequence VFX tracks",()=>{t("keeps preparation pending until ability audio decoding finishes",async()=>{let e;const s=new Promise(s=>{e=s}),t=n.fn(()=>s),i=new p;i.setAssetLoader({getAudioByAssetId:t});const l=o(),d=c();d.clips.push(r("swing",0,1)),l.tracks.push(d),i.load(l);let v=!1;const u=i.prepare().then(()=>{v=!0});await f(),a(v).toBe(!1),a(t).toHaveBeenCalledTimes(1),e({}),await u,a(v).toBe(!0)}),t("preloads unique VFX assets when preparing a sequence",async()=>{const s=new p,t=n.fn(async e=>{});s.setWorld({scene:new e.Scene,preloadVisualEffects:t}),s.setActorFactory({}),s.setAssetLoader({getAsset:n.fn(async e=>({id:e,vfx:{emitters:[]}}))});const r=o(),c=l();c.clips.push(i("vfx-asset",0,1),i("vfx-asset",1,1),i("other-vfx-asset",2,1)),r.tracks.push(c),s.load(r),await s.prepare(),a(t).toHaveBeenCalledTimes(1),a([...t.mock.calls[0][0]]).toHaveLength(2)}),t("creates and retains gameplay VFX actors while preparing a sequence",async()=>{const s=d(),t=n.fn(async()=>s),r=n.fn(async()=>{}),c=new p;c.setWorld({scene:new e.Scene,preloadVisualEffects:r}),c.setActorFactory({}),c.setAssetLoader({getAsset:n.fn(async e=>({id:e,vfx:{emitters:[]}}))}),c.setVfxService({createFromAssetId:t});const v=o(),u=l();u.clips.push(i("vfx-asset",0,1)),v.tracks.push(u),c.load(v),await c.prepare(),a(t).toHaveBeenCalledTimes(1),a(r).not.toHaveBeenCalled(),a(s.prepareRendering).toHaveBeenCalledTimes(1),a(s.play).not.toHaveBeenCalled(),c.play(),await f(),a(t).toHaveBeenCalledTimes(1)}),t("prepares each retained effect as soon as it loads and waits for its rendering resources",async()=>{const s=d(),t=d();let r,c;s.prepareRendering.mockReturnValue(new Promise(e=>{c=e}));const v=new Promise(e=>{r=e}),u=new p;u.setWorld({scene:new e.Scene}),u.setAssetLoader({}),u.setVfxService({createFromAssetId:n.fn(e=>"first"===e?Promise.resolve(s):v)});const m=o(),w=l();w.clips.push(i("first",0,1),i("second",1,1)),m.tracks.push(w),u.load(m);let y=!1;const h=u.prepare().then(()=>{y=!0});await f(),a(s.prepareRendering).toHaveBeenCalledTimes(1),a(t.prepareRendering).not.toHaveBeenCalled(),a(y).toBe(!1),r(t),await f(),a(t.prepareRendering).toHaveBeenCalledTimes(1),a(y).toBe(!1),c(),await h,a(y).toBe(!0)}),t("restarts an already-active VFX clip when seeking backwards within the clip",async()=>{const s=d(),t=new p;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)});const r=o();r.duration=2;const c=l(),v=i("vfx-asset",0,1.5);c.clips.push(v),r.tracks.push(c),t.load(r),t.play(),await f(),t.seek(1),s.restart.mockClear(),s.play.mockClear(),t.seek(.25),a(s.restart).toHaveBeenCalledTimes(1),a(s.play).toHaveBeenCalledTimes(1)}),t("restarts active VFX clips when a looping sequence wraps to the start",async()=>{const s=d(),t=new p;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)});const r=o();r.duration=1,r.loop=!0;const c=l(),v=i("vfx-asset",0,1);c.clips.push(v),r.tracks.push(c),t.load(r),t.play(),await f(),s.restart.mockClear(),s.play.mockClear(),t.update(1.1),a(s.restart).toHaveBeenCalledTimes(1),a(s.play).toHaveBeenCalledTimes(1)}),t("does not replay a VFX while its sequence clip remains active",async()=>{const s=d(),t=new p;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)});const r=o();r.duration=1;const c=l();c.clips.push(i("vfx-asset",0,.8)),r.tracks.push(c),t.load(r),t.play(),await f(),a(s.play).toHaveBeenCalledTimes(1),t.update(.3),t.update(.3),a(s.play).toHaveBeenCalledTimes(1)}),t("clears VFX particles when seeking backwards before the clip starts",async()=>{const s=d(),t=new p;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)});const r=o();r.duration=2;const c=l(),v=i("vfx-asset",.5,1);c.clips.push(v),r.tracks.push(c),t.load(r),t.play(),t.seek(.75),await f(),s.restart.mockClear(),s.stop.mockClear(),s.pause.mockClear(),t.seek(.25),a(s.restart).toHaveBeenCalledTimes(1),a(s.stop).toHaveBeenCalled(),a(s.pause).toHaveBeenCalledTimes(1)}),t("rebuilds VFX at the playhead when an editor preview seeks while paused",async()=>{const s=d(),t=n.fn(async()=>s),r=new p;r.setWorld({scene:new e.Scene}),r.setVfxService({createFromAssetId:t}),r.externalTimeControl=!0,r.inEditor=!0;const c=o();c.duration=2;const v=l();v.clips.push(i("vfx-asset",.5,1)),c.tracks.push(v),r.load(c),r.seek(.75),await f(),a(t).toHaveBeenCalledWith("vfx-asset",a.any(e.Scene),{backend:"cpu"}),a(s.previewAt).toHaveBeenCalledWith(.25,1,"finish",.25,a.any(Number)),a(s.play).not.toHaveBeenCalled()}),t("keeps gameplay sequence VFX on the configured world backend",async()=>{const s=d(),t=n.fn(async()=>s),r=new p;r.setWorld({scene:new e.Scene}),r.setVfxService({createFromAssetId:t});const c=o();c.duration=1;const v=l();v.clips.push(i("vfx-asset",0,1)),c.tracks.push(v),r.load(c),r.play(),await f(),a(t).toHaveBeenCalledWith("vfx-asset",a.any(e.Scene),void 0)}),t("clears an editor VFX preview when seeking before its clip",async()=>{const s=d(),t=new p;t.setWorld({scene:new e.Scene}),t.setVfxService({createFromAssetId:n.fn(async()=>s)}),t.externalTimeControl=!0,t.inEditor=!0;const r=o();r.duration=2;const c=l();c.clips.push(i("vfx-asset",.5,1)),r.tracks.push(c),t.load(r),t.seek(.75),await f(),s.clearPreview.mockClear(),t.seek(.25),a(s.clearPreview).toHaveBeenCalledTimes(1)})});/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|