@hology/core 0.0.241 → 0.0.243

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.
@@ -16,6 +16,7 @@ export declare class ObjectStorage<T extends StorageEntity> {
16
16
  private filePathFn?;
17
17
  private serializer;
18
18
  private path;
19
+ private getAllInFlight;
19
20
  private basePathUpdates;
20
21
  private basePath;
21
22
  private loaded;
@@ -54,6 +55,7 @@ export declare class ObjectStorage<T extends StorageEntity> {
54
55
  private reloadSubdirectory;
55
56
  private readFileIfExists;
56
57
  getAll(relativePath?: string): Promise<T[]>;
58
+ private getAllUncached;
57
59
  private getObjectFilePaths;
58
60
  get(id: string): Promise<T>;
59
61
  save(obj: T): Promise<T>;
@@ -89,6 +91,11 @@ export type StorageWatchEvent<T> = {
89
91
  object: T | null;
90
92
  path: string;
91
93
  filename: string;
94
+ } | {
95
+ event: "resource-change";
96
+ object: null;
97
+ path: string;
98
+ filename: string;
92
99
  };
93
100
  export declare function defaultSerializer<T>(obj: Partial<T>): string;
94
101
  //# sourceMappingURL=storage.d.ts.map
@@ -1,4 +1,4 @@
1
- import{randomUUID as e}from"../../utils/uuid.js";import{pathJoin as t}from"../../utils/files.js";import{EMPTY as a,filter as i,firstValueFrom as n,from as s,map as r,merge as h,mergeAll as o,mergeMap as l,Observable as c,of as d,startWith as u,Subject as p,switchMap as f,tap as w}from"rxjs";import{sleepDelay as m}from"../../utils/async.js";import{parseStorageJson as y,serializeStorageJson as v}from"./packed-json.js";export{decodeStorageValue,parseStorageJson,serializeStorageJson}from"./packed-json.js";const x={},j={},b={},g={};null==x.read&&window.require&&(Object.assign(x,window.require("fs")),Object.assign(j,x.promises),Object.assign(b,window.require("path")),Object.assign(g,window.require("chokidar")));const I=null!=x.existsSync;let P=0,F=0;const O=[];async function S(e){await function(){if(P<32)return P++,Promise.resolve();return new Promise(e=>O.push(e))}();try{return await e()}finally{!function(){const e=O[F];if(null==e)return O.length=0,F=0,void P--;F++,F>64&&2*F>O.length&&(O.splice(0,F),F=0);e()}()}}function C(e){return S(()=>j.readFile(e))}function E(){if(I){const e="--path=",t=window.process.argv.find(t=>t.startsWith(e));return t?t.substring(e.length):""}return""}const A=/^[A-Z]:/;function k(...e){return 0===e.length?"":A.test(e[0])||I?b.join(...e):t(...e)}export class ObjectStorage{get pathResources(){return k(this.path+"-resources")}constructor(e,t,a=defaultSerializer){this.name=e,this.filePathFn=t,this.serializer=a,this.basePathUpdates=new p,this.basePath=this.basePathUpdates.pipe(r(e=>k(E(),e)),w(e=>{this.path=k(e,this.name),this.cachedIndex=null,this.loadingIndex=null,D(this.path),this.determineIfMetaFileShouldBeCreated()})),this.loaded=n(this.basePath),this.cachedIndex=null,this.loadingIndex=null,this.shouldCreateIndex=!1}setBasePath(e){this.basePathUpdates.next(e)}async determineIfMetaFileShouldBeCreated(){if(I)try{const e=["vite.renderer.config.ts","vite.config.ts"];for(const t of e){const e=k(E(),t);if(!await U(e))continue;if((await C(e)).toString().includes("hologyBuild"))return void(this.shouldCreateIndex=!1)}this.shouldCreateIndex=!0}catch(e){console.warn("Failed to read vite config to determine if meta files should be created")}}async createFolder(e,t=""){K(),await j.mkdir(b.join(this.path,t,e),{recursive:!0})}async deleteFolderForceDangerous(e){await j.rm(b.join(this.path,e),{recursive:!0,force:!0})}async moveFolder(e,t){if(K(),""==e)return void console.warn("Can not move a folder in root");const a=b.resolve(b.join(this.path,t),b.basename(e));if(await $(a)){if(a!==b.resolve(this.path,e))throw new Error("Can not move to directory as a file already exists with the same name")}else{if(function(e,t){const a=b.resolve(e),i=b.resolve(t),n=b.normalize(a)+b.sep;return(b.normalize(i)+b.sep).startsWith(n)}(b.join(this.path,e),b.join(this.path,t)))throw new Error("Can not move a folder into a folder it contains");await j.rename(b.join(this.path,e),a)}}async renameFolder(e,t){await j.rename(b.join(this.path,e),b.resolve(b.dirname(b.join(this.path,e)),t))}async moveToFolder(e,t){if(K(),(e.path??"")===t)return e;const a={...e,path:t};await this.assertObjectFilePathAvailable(a,e.id);const i=this.privateObjectPath(a);return await j.rename(this.privateObjectPath(e),i),await this.addOrUpdateIndexEntry(a),a}getAbsolutePath(e=""){return b.join(this.path,e)}getResourceAbsolutePath(e=""){return b.join(this.pathResources,e)}watchFolders(){return s(this.loaded).pipe(f(()=>s(D(this.path))),f(()=>this.watchDir(this.path).pipe(i(e=>!e.filename.endsWith(".json")),u(null),f(()=>s(j.readdir(this.path,{recursive:!0,withFileTypes:!0}))),r(e=>Array.from(new Set(e.filter(e=>e.isDirectory()).map(e=>b.relative(this.path,b.join(e.parentPath,e.name)).replace(/^\.$/,"")).values()))))))}watch(){K();const e=s(this.loaded).pipe(f(()=>s(D(this.pathResources))),f(()=>this.watchDir(this.pathResources)),l(e=>{if(null==e||!["add","change","unlink"].includes(e.event))return a;const t=b.basename(e.filename);return s(this.getAll()).pipe(l(e=>{const i=e.filter(e=>e.fileKey===t);return 0===i.length?a:s(i).pipe(l(e=>{const t=this.privateObjectRelativePath(e);return s(this.readFileIfExists(t)).pipe(r(a=>({event:"change",object:a,path:e.path,filename:t})))}))}))})),t=s(this.loaded).pipe(f(()=>this.watchDir(this.path)),i(e=>"_meta.json"!==e.filename),l(e=>{const t={event:e.event,path:b.dirname(e.filename).replace(/^\.$/,""),filename:b.basename(e.filename)};return e.filename.endsWith(".json")?"unlink"!==e.event?s(this.readFileIfExists(e.filename)).pipe(w(e=>{null!=e&&(null==this.cachedIndex&&(this.cachedIndex={}),this.cachedIndex[e.id]={id:e.id,name:e.name??e.id,path:e.path},this.persistIndex())}),r(e=>({object:e,...t}))):d({object:null,...t}).pipe(w(()=>{const t=Object.keys(this.cachedIndex??{}).find(t=>this.privateObjectRelativePath(this.cachedIndex[t])===e.filename);t&&this.removeIndexEntry(t)})):"change"===e.event?s(this.reloadSubdirectory(e.filename)).pipe(o(),r(e=>({object:e,...t}))):a}));return h(t,e)}async reloadSubdirectory(e){return(await this.getAll(e)).filter(t=>t.path.startsWith(e))}async readFileIfExists(e){const t=k(this.path,e);for(let a=0;a<3;a++)try{const a=await C(t);return{...y(a.toString()),path:b.dirname(e).replace(/^\.$/,""),filename:b.basename(e)}}catch(e){if(2===a)return console.error("Could not read file at "+t,e),null;await m(50*(a+1))}return null}async getAll(e){if(I){await this.loaded,await D(this.path);const t=await this.getObjectFilePaths(e??""),a=null==e||""===e,i=[];for(let e=0;e<t.length;e+=32){const a=Math.min(32,t.length-e),n=await Promise.all(Array.from({length:a},(a,i)=>{const n=t[e+i];return C(k(this.path,n)).then(e=>({...y(e.toString()),path:b.dirname(n).replace(/^\.$/,""),filename:b.basename(n)}))}));i.push(...n)}return a&&(this.cachedIndex=z(i),await this.persistIndex()),i}const t=await this.loadIndex();return Promise.all(Object.keys(t).map(e=>this.get(e)))}async getObjectFilePaths(e){const t=this.path,a=b.join(t,e),i=[];return await async function e(a){const n=await j.readdir(a,{withFileTypes:!0});for(const s of n){const n=b.join(a,s.name);s.isDirectory()?await e(n):s.isFile()&&s.name.endsWith(".json")&&!/^[\._]/.test(s.name)&&i.push(b.relative(t,n))}}(a),i}async get(e){const t=await this.loadIndex(),a=t[e]??Object.values(t).find(t=>t.name===e);if(null==a)return;const i=this.privateObjectPath(a);if(!I)return y(await(await fetch(i)).text());return await U(i)?{...y((await C(i)).toString()),path:a.path??"",filename:b.basename(this.privateObjectRelativePath(a))}:null}async save(e){if(K(),await this.loaded,null==e.path){const t=(await this.loadIndex())[e.id];null!=t?.path&&(e={...e,path:t.path})}return await this.assertObjectFilePathAvailable(e,e.id),await j.writeFile(this.privateObjectPath(e),this.serialize(e)),await this.addOrUpdateIndexEntry(e),e}async rename(e,t){const a={...e,name:t};await this.assertObjectFilePathAvailable(a,e.id);const i=this.privateObjectPath(e),n=this.privateObjectPath(a);try{await j.rename(i,n)}catch(e){console.error(e),console.warn("Rename failed, retrying",{currentPath:i,newPath:n}),await m(400),await j.rename(i,n)}return await this.save(a),a}async delete(e){await j.unlink(this.privateObjectPath(e)),this.removeIndexEntry(e.id)}async create(t){K(),await this.loaded,t.id=e();const a=this.privateObjectPath(t);if(await this.assertObjectFilePathAvailable(t),await $(a))throw Error(`Can not create because a file already exists at ${a}`);return await j.writeFile(a,this.serialize(t)),await this.addOrUpdateIndexEntry(t),t}prepareCreate(t){return K(),t.id=e(),t}serialize(e){return this.serializer(e)}async addOrUpdateIndexEntry(e){null==this.cachedIndex&&await this.loadIndex(),this.cachedIndex[e.id]={id:e.id,name:e.name??e.id,path:e.path},await this.persistIndex()}removeIndexEntry(e){null!=this.cachedIndex&&delete this.cachedIndex[e],this.persistIndex()}async persistIndex(){I&&this.shouldCreateIndex&&null!=this.cachedIndex&&await j.writeFile(this.indexFilePath,JSON.stringify(this.cachedIndex,null,2))}get indexFilePath(){return k(this.path,"_meta.json")}async loadIndex(){if(null!=this.cachedIndex)return this.cachedIndex;this.loadingIndex??(this.loadingIndex=this.createIndex());try{return await this.loadingIndex}finally{this.loadingIndex=null}}async createIndex(){if(I){const e=await this.getAll();this.cachedIndex=z(e),await this.persistIndex()}else this.cachedIndex=await(await fetch(this.indexFilePath)).json();return this.cachedIndex}async ensureResourceDir(){await D(k(this.path+"-resources"))}async saveFile(e,t){return K(),await D(k(this.path+"-resources")),j.copyFile(t.path,k(this.path+"-resources",e.fileKey))}async saveExtraFile(e,t){return K(),await D(k(this.path+"-resources")),j.copyFile(e,k(this.path+"-resources",t))}getAssetPath(e){return window&&"function"==typeof window.require?window.require("path").join(this.path+"-resources",e.fileKey):k(this.path+"-resources",e.fileKey)}async replaceFile(e,t){if(await U(t))return j.copyFile(t,k(this.path+"-resources",e.fileKey));console.error("Failed to replace file using path "+t)}async deleteFile(e){if(null==e)return;K();const t=k(this.path+"-resources",e);return await U(t)?j.unlink(t):void 0}privateObjectPath(e){return k(this.path,this.privateObjectRelativePath(e))}privateObjectRelativePath(e){return this.filePathFn?k(e.path??"",this.filePathFn(e)):k(e.path??"",tokenizeName(e.name??e.id)+".json")}async assertObjectFilePathAvailable(e,t){const a=await this.findObjectFilePathCollision(e,t);if(null!=a)throw Error(`Can not save "${e.name??e.id}" because "${a.name}" already uses the same storage file name. Use a name that differs beyond letter casing.`)}async findObjectFilePathCollision(e,t){const a=R(this.privateObjectRelativePath(e)),i=await this.loadIndex();return Object.values(i).find(e=>e.id!==t&&R(this.privateObjectRelativePath(e))===a)??null}watchDir(e){return new c(t=>{if("win32"===process.platform&&I){const a=x.watch(e,{recursive:!0},(a,i)=>{if(i){const n=b.join(e,i);x.access(n,x.constants.F_OK,e=>{const n=e?"unlink":"rename"===a?"add":"change";t.next({event:n,filename:i})})}});return()=>a.close()}const a=g.watch(e,{cwd:e,ignoreInitial:!0,disableGlobbing:!0});return a.on("all",(e,a,i)=>{t.next({event:e,filename:a})}),a.on("error",()=>{}),()=>{a.close()}})}}export function tokenizeName(e){return e.trim().replace(/\s/g,"_").replace(/[^a-z0-9_\-\.]/gi,"")}function R(e){return e.replace(/\\/g,"/").toLowerCase()}function z(e){const t={};for(const a of e)t[a.id]={id:a.id,name:a.name??a.id,path:a.path};return t}async function D(e){I&&(await U(e)||await j.mkdir(e,{recursive:!0}))}function U(e){return!!I&&new Promise(function(t,a){x.exists(e,function(e){t(e)})})}function K(){if(!I)throw new Error("Must have direct access to filesystem")}async function $(e){try{await j.access(e,j.constants.F_OK)}catch(e){return!1}return!0}export function defaultSerializer(e){const t={...e};return delete t.path,delete t.filename,v(t)}/*
1
+ import{randomUUID as e}from"../../utils/uuid.js";import{pathJoin as t}from"../../utils/files.js";import{EMPTY as a,filter as i,firstValueFrom as n,from as s,map as r,merge as h,mergeAll as l,mergeMap as o,Observable as c,of as d,startWith as u,Subject as p,switchMap as f,tap as w}from"rxjs";import{sleepDelay as m}from"../../utils/async.js";import{parseStorageJson as y,serializeStorageJson as g}from"./packed-json.js";export{decodeStorageValue,parseStorageJson,serializeStorageJson}from"./packed-json.js";const v={},x={},j={},b={};null==v.read&&window.require&&(Object.assign(v,window.require("fs")),Object.assign(x,v.promises),Object.assign(j,window.require("path")),Object.assign(b,window.require("chokidar")));const I=null!=v.existsSync;let F=0,P=0;const O=[];async function A(e){await function(){if(F<32)return F++,Promise.resolve();return new Promise(e=>O.push(e))}();try{return await e()}finally{!function(){const e=O[P];if(null==e)return O.length=0,P=0,void F--;P++,P>64&&2*P>O.length&&(O.splice(0,P),P=0);e()}()}}function S(e){return A(()=>x.readFile(e))}function C(){if(I){const e="--path=",t=window.process.argv.find(t=>t.startsWith(e));return t?t.substring(e.length):""}return""}const E=/^[A-Z]:/;function k(...e){return 0===e.length?"":E.test(e[0])||I?j.join(...e):t(...e)}export class ObjectStorage{get pathResources(){return k(this.path+"-resources")}constructor(e,t,a=defaultSerializer){this.name=e,this.filePathFn=t,this.serializer=a,this.getAllInFlight=new Map,this.basePathUpdates=new p,this.basePath=this.basePathUpdates.pipe(r(e=>k(C(),e)),w(e=>{this.path=k(e,this.name),this.getAllInFlight.clear(),this.cachedIndex=null,this.loadingIndex=null,U(this.path),this.determineIfMetaFileShouldBeCreated()})),this.loaded=n(this.basePath),this.cachedIndex=null,this.loadingIndex=null,this.shouldCreateIndex=!1}setBasePath(e){this.basePathUpdates.next(e)}async determineIfMetaFileShouldBeCreated(){if(I)try{const e=["vite.renderer.config.ts","vite.config.ts"];for(const t of e){const e=k(C(),t);if(!await D(e))continue;if((await S(e)).toString().includes("hologyBuild"))return void(this.shouldCreateIndex=!1)}this.shouldCreateIndex=!0}catch(e){console.warn("Failed to read vite config to determine if meta files should be created")}}async createFolder(e,t=""){$(),await x.mkdir(j.join(this.path,t,e),{recursive:!0})}async deleteFolderForceDangerous(e){await x.rm(j.join(this.path,e),{recursive:!0,force:!0})}async moveFolder(e,t){if($(),""==e)return void console.warn("Can not move a folder in root");const a=j.resolve(j.join(this.path,t),j.basename(e));if(await _(a)){if(a!==j.resolve(this.path,e))throw new Error("Can not move to directory as a file already exists with the same name")}else{if(function(e,t){const a=j.resolve(e),i=j.resolve(t),n=j.normalize(a)+j.sep;return(j.normalize(i)+j.sep).startsWith(n)}(j.join(this.path,e),j.join(this.path,t)))throw new Error("Can not move a folder into a folder it contains");await x.rename(j.join(this.path,e),a)}}async renameFolder(e,t){await x.rename(j.join(this.path,e),j.resolve(j.dirname(j.join(this.path,e)),t))}async moveToFolder(e,t){if($(),(e.path??"")===t)return e;const a={...e,path:t};await this.assertObjectFilePathAvailable(a,e.id);const i=this.privateObjectPath(a);return await x.rename(this.privateObjectPath(e),i),await this.addOrUpdateIndexEntry(a),a}getAbsolutePath(e=""){return j.join(this.path,e)}getResourceAbsolutePath(e=""){return j.join(this.pathResources,e)}watchFolders(){return s(this.loaded).pipe(f(()=>s(U(this.path))),f(()=>this.watchDir(this.path).pipe(i(e=>!e.filename.endsWith(".json")),u(null),f(()=>s(x.readdir(this.path,{recursive:!0,withFileTypes:!0}))),r(e=>Array.from(new Set(e.filter(e=>e.isDirectory()).map(e=>j.relative(this.path,j.join(e.parentPath,e.name)).replace(/^\.$/,"")).values()))))))}watch(){$();const e=s(this.loaded).pipe(f(()=>s(U(this.pathResources))),f(()=>this.watchDir(this.pathResources)),i(e=>null!=e&&["add","change","unlink"].includes(e.event)),r(e=>({event:"resource-change",object:null,path:j.dirname(e.filename).replace(/^\.$/,""),filename:j.basename(e.filename)}))),t=s(this.loaded).pipe(f(()=>this.watchDir(this.path)),i(e=>"_meta.json"!==e.filename),o(e=>{const t={event:e.event,path:j.dirname(e.filename).replace(/^\.$/,""),filename:j.basename(e.filename)};return e.filename.endsWith(".json")?"unlink"!==e.event?s(this.readFileIfExists(e.filename)).pipe(w(e=>{null!=e&&(null==this.cachedIndex&&(this.cachedIndex={}),this.cachedIndex[e.id]={id:e.id,name:e.name??e.id,path:e.path},this.persistIndex())}),r(e=>({object:e,...t}))):d({object:null,...t}).pipe(w(()=>{const t=Object.keys(this.cachedIndex??{}).find(t=>this.privateObjectRelativePath(this.cachedIndex[t])===e.filename);t&&this.removeIndexEntry(t)})):"change"===e.event?s(this.reloadSubdirectory(e.filename)).pipe(l(),r(e=>({object:e,...t}))):a}));return h(t,e)}async reloadSubdirectory(e){return(await this.getAll(e)).filter(t=>t.path.startsWith(e))}async readFileIfExists(e){const t=k(this.path,e);for(let a=0;a<3;a++)try{const a=await S(t);return{...y(a.toString()),path:j.dirname(e).replace(/^\.$/,""),filename:j.basename(e)}}catch(e){if(2===a)return console.error("Could not read file at "+t,e),null;await m(50*(a+1))}return null}async getAll(e){if(!I)return this.getAllUncached(e);const t=e??"",a=this.getAllInFlight.get(t);if(null!=a)return(await a).slice();const i=this.getAllUncached(e);this.getAllInFlight.set(t,i);try{return(await i).slice()}finally{this.getAllInFlight.get(t)===i&&this.getAllInFlight.delete(t)}}async getAllUncached(e){if(I){await this.loaded,await U(this.path);const t=await this.getObjectFilePaths(e??""),a=null==e||""===e,i=[];for(let e=0;e<t.length;e+=32){const a=Math.min(32,t.length-e),n=await Promise.all(Array.from({length:a},(a,i)=>{const n=t[e+i];return S(k(this.path,n)).then(e=>({...y(e.toString()),path:j.dirname(n).replace(/^\.$/,""),filename:j.basename(n)}))}));i.push(...n)}return a&&(this.cachedIndex=R(i),await this.persistIndex()),i}const t=await this.loadIndex();return Promise.all(Object.keys(t).map(e=>this.get(e)))}async getObjectFilePaths(e){const t=this.path,a=j.join(t,e),i=[];return await async function e(a){const n=await x.readdir(a,{withFileTypes:!0});for(const s of n){const n=j.join(a,s.name);s.isDirectory()?await e(n):s.isFile()&&s.name.endsWith(".json")&&!/^[\._]/.test(s.name)&&i.push(j.relative(t,n))}}(a),i}async get(e){const t=await this.loadIndex(),a=t[e]??Object.values(t).find(t=>t.name===e);if(null==a)return;const i=this.privateObjectPath(a);if(!I)return y(await(await fetch(i)).text());return await D(i)?{...y((await S(i)).toString()),path:a.path??"",filename:j.basename(this.privateObjectRelativePath(a))}:null}async save(e){if($(),await this.loaded,null==e.path){const t=(await this.loadIndex())[e.id];null!=t?.path&&(e={...e,path:t.path})}return await this.assertObjectFilePathAvailable(e,e.id),await x.writeFile(this.privateObjectPath(e),this.serialize(e)),await this.addOrUpdateIndexEntry(e),e}async rename(e,t){const a={...e,name:t};await this.assertObjectFilePathAvailable(a,e.id);const i=this.privateObjectPath(e),n=this.privateObjectPath(a);try{await x.rename(i,n)}catch(e){console.error(e),console.warn("Rename failed, retrying",{currentPath:i,newPath:n}),await m(400),await x.rename(i,n)}return await this.save(a),a}async delete(e){await x.unlink(this.privateObjectPath(e)),this.removeIndexEntry(e.id)}async create(t){$(),await this.loaded,t.id=e();const a=this.privateObjectPath(t);if(await this.assertObjectFilePathAvailable(t),await _(a))throw Error(`Can not create because a file already exists at ${a}`);return await x.writeFile(a,this.serialize(t)),await this.addOrUpdateIndexEntry(t),t}prepareCreate(t){return $(),t.id=e(),t}serialize(e){return this.serializer(e)}async addOrUpdateIndexEntry(e){null==this.cachedIndex&&await this.loadIndex(),this.cachedIndex[e.id]={id:e.id,name:e.name??e.id,path:e.path},await this.persistIndex()}removeIndexEntry(e){null!=this.cachedIndex&&delete this.cachedIndex[e],this.persistIndex()}async persistIndex(){I&&this.shouldCreateIndex&&null!=this.cachedIndex&&await x.writeFile(this.indexFilePath,JSON.stringify(this.cachedIndex,null,2))}get indexFilePath(){return k(this.path,"_meta.json")}async loadIndex(){if(null!=this.cachedIndex)return this.cachedIndex;this.loadingIndex??(this.loadingIndex=this.createIndex());try{return await this.loadingIndex}finally{this.loadingIndex=null}}async createIndex(){if(I){const e=await this.getAll();this.cachedIndex=R(e),await this.persistIndex()}else this.cachedIndex=await(await fetch(this.indexFilePath)).json();return this.cachedIndex}async ensureResourceDir(){await U(k(this.path+"-resources"))}async saveFile(e,t){return $(),await U(k(this.path+"-resources")),x.copyFile(t.path,k(this.path+"-resources",e.fileKey))}async saveExtraFile(e,t){return $(),await U(k(this.path+"-resources")),x.copyFile(e,k(this.path+"-resources",t))}getAssetPath(e){return window&&"function"==typeof window.require?window.require("path").join(this.path+"-resources",e.fileKey):k(this.path+"-resources",e.fileKey)}async replaceFile(e,t){if(await D(t))return x.copyFile(t,k(this.path+"-resources",e.fileKey));console.error("Failed to replace file using path "+t)}async deleteFile(e){if(null==e)return;$();const t=k(this.path+"-resources",e);return await D(t)?x.unlink(t):void 0}privateObjectPath(e){return k(this.path,this.privateObjectRelativePath(e))}privateObjectRelativePath(e){return this.filePathFn?k(e.path??"",this.filePathFn(e)):k(e.path??"",tokenizeName(e.name??e.id)+".json")}async assertObjectFilePathAvailable(e,t){const a=await this.findObjectFilePathCollision(e,t);if(null!=a)throw Error(`Can not save "${e.name??e.id}" because "${a.name}" already uses the same storage file name. Use a name that differs beyond letter casing.`)}async findObjectFilePathCollision(e,t){const a=z(this.privateObjectRelativePath(e)),i=await this.loadIndex();return Object.values(i).find(e=>e.id!==t&&z(this.privateObjectRelativePath(e))===a)??null}watchDir(e){return new c(t=>{if("win32"===process.platform&&I){const a=v.watch(e,{recursive:!0},(a,i)=>{if(i){const n=j.join(e,i);v.access(n,v.constants.F_OK,e=>{const n=e?"unlink":"rename"===a?"add":"change";t.next({event:n,filename:i})})}});return()=>a.close()}const a=b.watch(e,{cwd:e,ignoreInitial:!0,disableGlobbing:!0});return a.on("all",(e,a,i)=>{t.next({event:e,filename:a})}),a.on("error",()=>{}),()=>{a.close()}})}}export function tokenizeName(e){return e.trim().replace(/\s/g,"_").replace(/[^a-z0-9_\-\.]/gi,"")}function z(e){return e.replace(/\\/g,"/").toLowerCase()}function R(e){const t={};for(const a of e)t[a.id]={id:a.id,name:a.name??a.id,path:a.path};return t}async function U(e){I&&(await D(e)||await x.mkdir(e,{recursive:!0}))}function D(e){return!!I&&new Promise(function(t,a){v.exists(e,function(e){t(e)})})}function $(){if(!I)throw new Error("Must have direct access to filesystem")}async function _(e){try{await x.access(e,x.constants.F_OK)}catch(e){return!1}return!0}export function defaultSerializer(e){const t={...e};return delete t.path,delete t.filename,g(t)}/*
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{ComputeKernel as e}from"@hology/webgpu-renderer";const t=64;export class WebGpuSecondaryMotionRuntime{constructor(t,n={}){this.device=t,this.collisionColliderData=new Float32Array(96),this.collisionCollidersInitialized=!1,this.collisionBindGroups=new Map,this.integrationParamsData=new ArrayBuffer(80),this.integrationParamsView=new DataView(this.integrationParamsData),this.regions=new Map,this.pendingSteps=0,this.disposed=!1,this.lastExecuteSeconds=null,this.fixedStep=new SecondaryMotionFixedStep(n),this.constraintIterations=Math.max(0,Math.trunc(n.constraintIterations??4)),this.constraintStiffness=Math.max(0,Math.min(1,n.constraintStiffness??.9)),this.autoAdvance=n.autoAdvance??!0,this.integrationKernel=new e(t,{label:"secondary-motion-integrate",code:s,bindGroupLayoutEntries:[i(0,!0),i(1,!1),i(2,!1),i(3,!0),i(4,!0),{binding:5,visibility:GPUShaderStage.COMPUTE,buffer:{type:"uniform"}}]}),this.constraintKernel=new e(t,{label:"secondary-motion-distance-constraints",code:o,bindGroupLayoutEntries:[i(0,!1),i(1,!0),i(2,!0),i(3,!0),{binding:4,visibility:GPUShaderStage.COMPUTE,buffer:{type:"uniform"}}]}),this.packKernel=new e(t,{label:"secondary-motion-pack-render-positions",code:a,bindGroupLayoutEntries:[i(0,!0),i(1,!0),i(2,!1)]}),this.finalizeKernel=new e(t,{label:"secondary-motion-finalize-velocity",code:l,bindGroupLayoutEntries:[i(0,!0),i(1,!0),i(2,!1),{binding:3,visibility:GPUShaderStage.COMPUTE,buffer:{type:"uniform"}}]}),this.collisionKernel=new e(t,{label:"secondary-motion-sphere-collision",code:c,bindGroupLayoutEntries:[i(0,!1),i(1,!0),i(2,!0),i(3,!0),i(4,!0),{binding:5,visibility:GPUShaderStage.COMPUTE,buffer:{type:"uniform"}}]}),this.integrationParams=t.createBuffer({label:"secondary-motion-integration-params",size:80,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST}),this.collisionSpheres=t.createBuffer({label:"secondary-motion-collision-spheres",size:384,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST}),this.previousCollisionSpheres=t.createBuffer({label:"secondary-motion-previous-collision-spheres",size:384,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST}),this.collisionParams=t.createBuffer({label:"secondary-motion-collision-params",size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST}),t.queue.writeBuffer(this.collisionParams,0,new Uint32Array(4));const r=this.integrationParamsView,d=n.gravity??[0,-9.81,0];r.setFloat32(0,this.fixedStep.step,!0),r.setFloat32(4,Math.max(0,Math.min(1,n.damping??.985)),!0),r.setFloat32(8,Math.max(0,n.attachment??12),!0),r.setFloat32(16,d[0],!0),r.setFloat32(20,d[1],!0),r.setFloat32(24,d[2],!0),r.setFloat32(76,Math.max(1,4*Math.hypot(d[0],d[1],d[2])),!0),t.queue.writeBuffer(this.integrationParams,0,this.integrationParamsData)}setFrameMotion(e){this.writeFrameVector(32,e.linearAcceleration),this.writeFrameVector(48,e.angularVelocity),this.writeFrameVector(64,e.angularAcceleration),this.device.queue.writeBuffer(this.integrationParams,32,this.integrationParamsData,32,48)}setSphereColliders(e){this.setEllipsoidColliders(e.map(e=>({center:e.center,radii:[e.radius,e.radius,e.radius]})))}setEllipsoidColliders(e){const t=Math.min(e.length,8),i=new Float32Array(96);for(let n=0;n<t;n++){const t=e[n],r=12*n;i[r]=t.center[0],i[r+1]=t.center[1],i[r+2]=t.center[2],i[r+4]=Math.max(0,t.radii[0]),i[r+5]=Math.max(0,t.radii[1]),i[r+6]=Math.max(0,t.radii[2]),i[r+8]=t.rotation?.[0]??0,i[r+9]=t.rotation?.[1]??0,i[r+10]=t.rotation?.[2]??0,i[r+11]=t.rotation?.[3]??1}this.collisionCollidersInitialized?this.device.queue.writeBuffer(this.previousCollisionSpheres,0,this.collisionColliderData):(this.device.queue.writeBuffer(this.previousCollisionSpheres,0,i),this.collisionCollidersInitialized=!0),this.collisionColliderData.set(i),this.device.queue.writeBuffer(this.collisionSpheres,0,this.collisionColliderData),this.device.queue.writeBuffer(this.collisionParams,0,new Uint32Array([t,0,0,0]))}advance(e){const t=this.fixedStep.consume(e);return this.pendingSteps=Math.min(this.pendingSteps+t,this.fixedStep.maxSubsteps),t}addRegion(e){if(this.assertActive(),this.regions.has(e.id))throw new Error(`Secondary-motion region "${e.id}" is already registered`);const t=e.particleInverseMass.length,i=e.renderVertexIndices.length;if(e.particlePositions.length!==3*t)throw new Error("Secondary-motion particle positions must contain three values per particle");const s=r(e.particlePositions),o=[this.createStorageBuffer(`${e.id}-positions-a`,s),this.createStorageBuffer(`${e.id}-positions-b`,s)],a=this.createStorageBuffer(`${e.id}-velocities`,new Float32Array(4*t)),c=this.createStorageBuffer(`${e.id}-targets`,e.particlePositions),l=this.createStorageBuffer(`${e.id}-inverse-mass`,e.particleInverseMass),d=this.createStorageBuffer(`${e.id}-render-positions`,new Float32Array(3*i)),u=this.createStorageBuffer(`${e.id}-render-vertex-indices`,e.renderVertexIndices),p=[0,1].map(t=>this.integrationKernel.createBindGroup(this.device,[n(0,o[t]),n(1,a),n(2,o[1-t]),n(3,c),n(4,l),n(5,this.integrationParams)],`${e.id}-integration-${t}`)),f=o.map(e=>this.packKernel.createBindGroup(this.device,[n(0,e),n(1,u),n(2,d)])),h=[0,1].map(e=>this.finalizeKernel.createBindGroup(this.device,[n(0,o[1-e]),n(1,o[e]),n(2,a),n(3,this.integrationParams)])),g=function(e,t){if(0===e.constraints.length)return null;if(e.constraints.length%2!=0)throw new Error("Secondary-motion distance constraints must contain particle pairs");const i=e.constraints.length/2;if(e.constraintRest.length!==i||e.constraintColors.length!==i)throw new Error("Secondary-motion constraint arrays have inconsistent lengths");const n=Array.from({length:i},(e,t)=>t).sort((t,i)=>e.constraintColors[t]-e.constraintColors[i]),r=new Uint32Array(e.constraints.length),s=new Float32Array(i),o=[];let a=-1;for(let c=0;c<i;c++){const i=n[c],l=e.constraintColors[i],d=e.constraints[2*i],u=e.constraints[2*i+1];if(d>=t||u>=t)throw new Error("Secondary-motion constraint references an invalid particle");r[2*c]=d,r[2*c+1]=u,s[c]=e.constraintRest[i],l!==a?(o.push({offset:c,count:1}),a=l):o[o.length-1].count++}return{pairs:r,rest:s,batches:o}}(e,t),m=g?this.createStorageBuffer(`${e.id}-constraints`,g.pairs):null,v=g?this.createStorageBuffer(`${e.id}-constraint-rest`,g.rest):null,y=[];if(g&&m&&v)for(const t of g.batches){const i=this.device.createBuffer({label:`${e.id}-constraint-params`,size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST}),r=new ArrayBuffer(16),s=new DataView(r);s.setUint32(0,t.offset,!0),s.setUint32(4,t.count,!0),s.setFloat32(8,this.constraintStiffness,!0),this.device.queue.writeBuffer(i,0,r),y.push({count:t.count,params:i,bindGroups:o.map(e=>this.constraintKernel.createBindGroup(this.device,[n(0,e),n(1,l),n(2,m),n(3,v),n(4,i)]))})}const x={id:e.id,particleCount:t,renderVertexCount:i,positions:o,velocities:a,targets:c,inverseMass:l,renderPositions:d,renderVertexIndices:u,integrationBindGroups:p,packBindGroups:f,finalizeBindGroups:h,constraints:m,constraintRest:v,constraintBatches:y,state:0,disposed:!1,renderGeometry:null};this.regions.set(e.id,x);const P=this;return{id:e.id,particleCount:t,renderVertexCount:i,get positionBuffer(){return x.positions[x.state]},setRenderGeometry(e){x.renderGeometry=e},updateTargets(e){P.writeTargets(x.targets,e,t)},reset(t=e.particlePositions){P.resetRegion(x,t)},dispose(){P.removeRegion(x)}}}execute(e){if(this.assertActive(),e.device!==this.device)throw new Error("Secondary-motion runtime was created for a different GPU device");if(this.autoAdvance){const e=.001*performance.now();null!=this.lastExecuteSeconds&&this.advance(e-this.lastExecuteSeconds),this.lastExecuteSeconds=e}const i=this.pendingSteps;if(this.pendingSteps=0,0===this.regions.size)return;const n=e.encoder.beginComputePass({label:"secondary-motion-simulation"});for(let e=0;e<i;e++)for(const e of this.regions.values())if(!e.disposed&&0!==e.particleCount){if(n.setPipeline(this.integrationKernel.pipeline),n.setBindGroup(0,e.integrationBindGroups[e.state]),n.dispatchWorkgroups(Math.ceil(e.particleCount/t)),e.state=0===e.state?1:0,e.constraintBatches.length>0){n.setPipeline(this.constraintKernel.pipeline);for(let i=0;i<this.constraintIterations;i++)for(const i of e.constraintBatches)n.setBindGroup(0,i.bindGroups[e.state]),n.dispatchWorkgroups(Math.ceil(i.count/t))}n.setPipeline(this.collisionKernel.pipeline),n.setBindGroup(0,this.getCollisionBindGroup(e)),n.dispatchWorkgroups(Math.ceil(e.particleCount/t)),n.setPipeline(this.finalizeKernel.pipeline),n.setBindGroup(0,e.finalizeBindGroups[e.state]),n.dispatchWorkgroups(Math.ceil(e.particleCount/t))}for(const e of this.regions.values())e.disposed||0===e.particleCount||null==e.renderGeometry||(n.setPipeline(this.packKernel.pipeline),n.setBindGroup(0,e.packBindGroups[e.state]),n.dispatchWorkgroups(Math.ceil(e.renderVertexCount/t)));n.end();for(const t of this.regions.values())if(!t.disposed&&null!=t.renderGeometry){const i=e.getGeometryVertexBuffer(t.renderGeometry,"position");e.encoder.copyBufferToBuffer(t.renderPositions,0,i,0,12*t.renderVertexCount)}}dispose(){if(!this.disposed){for(const e of Array.from(this.regions.values()))this.removeRegion(e);this.integrationParams.destroy(),this.collisionSpheres.destroy(),this.previousCollisionSpheres.destroy(),this.collisionParams.destroy(),this.collisionBindGroups.clear(),this.disposed=!0}}resetRegion(e,t){this.writeTargets(e.targets,t,e.particleCount);const i=function(e,t){if(e.length!==3*t)throw new Error(`Expected ${3*t} secondary-motion position values, received ${e.length}`);return r(e)}(t,e.particleCount);for(const t of e.positions)this.device.queue.writeBuffer(t,0,i);this.device.queue.writeBuffer(e.velocities,0,new Float32Array(4*e.particleCount)),e.state=0,this.fixedStep.reset(),this.pendingSteps=0}writeTargets(e,t,i){if(t.length!==3*i)throw new Error(`Expected ${3*i} secondary-motion target values, received ${t.length}`);this.device.queue.writeBuffer(e,0,t)}removeRegion(e){if(!e.disposed){e.disposed=!0,this.regions.delete(e.id);for(const t of e.positions)this.collisionBindGroups.delete(t);for(const t of e.positions)t.destroy();e.velocities.destroy(),e.targets.destroy(),e.inverseMass.destroy(),e.renderPositions.destroy(),e.renderVertexIndices.destroy(),e.constraints?.destroy(),e.constraintRest?.destroy();for(const t of e.constraintBatches)t.params.destroy()}}createStorageBuffer(e,t){const i=this.device.createBuffer({label:e,size:Math.max(4,t.byteLength+3&-4),usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST|GPUBufferUsage.COPY_SRC});return t.byteLength>0&&this.device.queue.writeBuffer(i,0,t.buffer,t.byteOffset,t.byteLength),i}getCollisionBindGroup(e){const t=e.positions[e.state];let i=this.collisionBindGroups.get(t);return null==i&&(i=this.collisionKernel.createBindGroup(this.device,[n(0,t),n(1,e.positions[1-e.state]),n(2,e.inverseMass),n(3,this.collisionSpheres),n(4,this.previousCollisionSpheres),n(5,this.collisionParams)],`${e.id}-sphere-collision-${e.state}`),this.collisionBindGroups.set(t,i)),i}assertActive(){if(this.disposed)throw new Error("Secondary-motion runtime is disposed")}writeFrameVector(e,t){this.integrationParamsView.setFloat32(e,t?.[0]??0,!0),this.integrationParamsView.setFloat32(e+4,t?.[1]??0,!0),this.integrationParamsView.setFloat32(e+8,t?.[2]??0,!0)}}export function createSecondaryMotionRenderGraphExtension(e,t="SecondaryMotionSimulation"){const i=i=>{i.addPass({name:t,execute:t=>e.execute(t)})};return i.stage="beforeScene",i}export class SecondaryMotionFixedStep{constructor(e={}){this.accumulator=0,this.step=1/(e.frequency??60),this.maxSubsteps=e.maxSubsteps??4,this.maxFrameDelta=e.maxFrameDelta??.1}consume(e){if(!Number.isFinite(e)||e<0||e>this.maxFrameDelta)return this.accumulator=0,0;this.accumulator=Math.min(this.accumulator+e,this.step*this.maxSubsteps);const t=Math.min(Math.floor(this.accumulator/this.step),this.maxSubsteps);return this.accumulator-=t*this.step,t}reset(){this.accumulator=0}}export function selectSecondaryMotionLod(e,t,i){return t?i?"hero":e<8?"near":e<20?"mid":e<40?"far":"sleep":"sleep"}export function secondaryMotionTransformDiscontinuous(e,t,i=2,n=.5){if(e.length<7||t.length<7)return!0;const r=t[0]-e[0],s=t[1]-e[1],o=t[2]-e[2];if(r*r+s*s+o*o>i*i)return!0;return Math.abs(e[3]*t[3]+e[4]*t[4]+e[5]*t[5]+e[6]*t[6])<n}function i(e,t){return{binding:e,visibility:GPUShaderStage.COMPUTE,buffer:{type:t?"read-only-storage":"storage"}}}function n(e,t){return{binding:e,resource:{buffer:t}}}function r(e){const t=new Float32Array(e.length/3*4);for(let i=0,n=0;i<e.length;i+=3,n+=4)t[n]=e[i],t[n+1]=e[i+1],t[n+2]=e[i+2],t[n+3]=1;return t}const s="\nstruct IntegrationParams {\n deltaTime: f32,\n damping: f32,\n attachment: f32,\n _padding: f32,\n gravity: vec3<f32>,\n _padding2: f32,\n frameLinearAcceleration: vec3<f32>,\n _padding3: f32,\n frameAngularVelocity: vec3<f32>,\n _padding4: f32,\n frameAngularAcceleration: vec3<f32>,\n maxFrameAcceleration: f32,\n}\n@group(0) @binding(0) var<storage, read> currentPositions: array<vec4<f32>>;\n@group(0) @binding(1) var<storage, read_write> velocities: array<vec4<f32>>;\n@group(0) @binding(2) var<storage, read_write> outputPositions: array<vec4<f32>>;\nstruct PackedPosition { x: f32, y: f32, z: f32 }\n@group(0) @binding(3) var<storage, read> targetPositions: array<PackedPosition>;\n@group(0) @binding(4) var<storage, read> inverseMass: array<f32>;\n@group(0) @binding(5) var<uniform> params: IntegrationParams;\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) invocation: vec3<u32>) {\n let index = invocation.x;\n if (index >= arrayLength(&inverseMass)) { return; }\n let packedTarget = targetPositions[index];\n let targetPosition = vec3<f32>(packedTarget.x, packedTarget.y, packedTarget.z);\n if (inverseMass[index] <= 0.0) {\n velocities[index] = vec4(0.0);\n outputPositions[index] = vec4(targetPosition, 1.0);\n return;\n }\n let current = currentPositions[index].xyz;\n // Fully simulated cloth should be driven through its structural connection\n // to the animated roots. Pulling every free particle toward its skinned rest\n // pose makes collision sandwich it against the character like shrink-wrap.\n let animationInfluence = 1.0 - clamp(inverseMass[index], 0.0, 1.0);\n let targetAcceleration = (targetPosition - current) * params.attachment * animationInfluence;\n let previousVelocity = velocities[index].xyz;\n let rotationalAcceleration =\n cross(params.frameAngularAcceleration, current) +\n cross(params.frameAngularVelocity, cross(params.frameAngularVelocity, current)) +\n 2.0 * cross(params.frameAngularVelocity, previousVelocity);\n var frameAcceleration = params.frameLinearAcceleration + rotationalAcceleration;\n let frameAccelerationLength = length(frameAcceleration);\n if (frameAccelerationLength > params.maxFrameAcceleration) {\n frameAcceleration *= params.maxFrameAcceleration / frameAccelerationLength;\n }\n let acceleration = params.gravity + targetAcceleration - frameAcceleration;\n let velocity = previousVelocity * params.damping + acceleration * params.deltaTime;\n let next = current + velocity * params.deltaTime;\n velocities[index] = vec4(velocity, 0.0);\n outputPositions[index] = vec4(next, 1.0);\n}\n",o="\nstruct ConstraintParams {\n offset: u32,\n count: u32,\n stiffness: f32,\n _padding: f32,\n}\n@group(0) @binding(0) var<storage, read_write> positions: array<vec4<f32>>;\n@group(0) @binding(1) var<storage, read> inverseMass: array<f32>;\n@group(0) @binding(2) var<storage, read> pairs: array<vec2<u32>>;\n@group(0) @binding(3) var<storage, read> restLengths: array<f32>;\n@group(0) @binding(4) var<uniform> params: ConstraintParams;\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) invocation: vec3<u32>) {\n if (invocation.x >= params.count) { return; }\n let constraintIndex = params.offset + invocation.x;\n let pair = pairs[constraintIndex];\n let weightA = inverseMass[pair.x];\n let weightB = inverseMass[pair.y];\n let weight = weightA + weightB;\n if (weight <= 0.0) { return; }\n let delta = positions[pair.y].xyz - positions[pair.x].xyz;\n let length = length(delta);\n if (length <= 0.000001) { return; }\n let correction = delta * ((length - restLengths[constraintIndex]) / length) * params.stiffness;\n positions[pair.x] = vec4(positions[pair.x].xyz + correction * (weightA / weight), 1.0);\n positions[pair.y] = vec4(positions[pair.y].xyz - correction * (weightB / weight), 1.0);\n}\n",a="\nstruct PackedPosition { x: f32, y: f32, z: f32 }\n@group(0) @binding(0) var<storage, read> positions: array<vec4<f32>>;\n@group(0) @binding(1) var<storage, read> particleByRenderVertex: array<u32>;\n@group(0) @binding(2) var<storage, read_write> output: array<PackedPosition>;\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) invocation: vec3<u32>) {\n let index = invocation.x;\n if (index >= arrayLength(&particleByRenderVertex)) { return; }\n let position = positions[particleByRenderVertex[index]];\n output[index].x = position.x; output[index].y = position.y; output[index].z = position.z;\n}\n",c="\nstruct CollisionParams { count: u32, _padding1: u32, _padding2: u32, _padding3: u32 }\n@group(0) @binding(0) var<storage, read_write> positions: array<vec4<f32>>;\nstruct Ellipsoid { center: vec4<f32>, radii: vec4<f32>, rotation: vec4<f32> }\n@group(0) @binding(1) var<storage, read> previousPositions: array<vec4<f32>>;\n@group(0) @binding(2) var<storage, read> inverseMass: array<f32>;\n@group(0) @binding(3) var<storage, read> ellipsoids: array<Ellipsoid>;\n@group(0) @binding(4) var<storage, read> previousEllipsoids: array<Ellipsoid>;\n@group(0) @binding(5) var<uniform> params: CollisionParams;\n\nfn rotateByQuaternion(value: vec3<f32>, rotation: vec4<f32>) -> vec3<f32> {\n return value + 2.0 * cross(rotation.xyz, cross(rotation.xyz, value) + rotation.w * value);\n}\n\nfn toEllipsoidSpace(value: vec3<f32>, shape: Ellipsoid) -> vec3<f32> {\n return rotateByQuaternion(value - shape.center.xyz, vec4<f32>(-shape.rotation.xyz, shape.rotation.w)) / shape.radii.xyz;\n}\n\nfn fromEllipsoidSpace(value: vec3<f32>, shape: Ellipsoid) -> vec3<f32> {\n return shape.center.xyz + rotateByQuaternion(value * shape.radii.xyz, shape.rotation);\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) invocation: vec3<u32>) {\n let index = invocation.x;\n if (index >= arrayLength(&inverseMass) || inverseMass[index] <= 0.0) { return; }\n var position = positions[index].xyz;\n for (var collider = 0u; collider < params.count; collider++) {\n let shape = ellipsoids[collider];\n if (all(shape.radii.xyz > vec3<f32>(0.0))) {\n let scaled = toEllipsoidSpace(position, shape);\n let scaledLengthSquared = dot(scaled, scaled);\n if (scaledLengthSquared < 1.0) {\n let direction = select(vec3<f32>(0.0, 0.0, 1.0), scaled * inverseSqrt(scaledLengthSquared), scaledLengthSquared > 0.00000001);\n position = fromEllipsoidSpace(direction * 1.06, shape);\n } else {\n let previousShape = previousEllipsoids[collider];\n let start = toEllipsoidSpace(previousPositions[index].xyz, previousShape);\n let travel = scaled - start;\n let a = dot(travel, travel);\n let b = 2.0 * dot(start, travel);\n let c = dot(start, start) - 1.0;\n let discriminant = b * b - 4.0 * a * c;\n if (a > 0.00000001 && discriminant >= 0.0) {\n let hit = (-b - sqrt(discriminant)) / (2.0 * a);\n if (hit > 0.0001 && hit <= 1.0) {\n let direction = normalize(start + travel * hit);\n position = fromEllipsoidSpace(direction * 1.06, shape);\n }\n }\n }\n }\n }\n positions[index] = vec4(position, 1.0);\n}\n",l="\nstruct IntegrationParams {\n deltaTime: f32,\n damping: f32,\n attachment: f32,\n _padding: f32,\n gravity: vec3<f32>,\n _padding2: f32,\n}\n@group(0) @binding(0) var<storage, read> previousPositions: array<vec4<f32>>;\n@group(0) @binding(1) var<storage, read> constrainedPositions: array<vec4<f32>>;\n@group(0) @binding(2) var<storage, read_write> velocities: array<vec4<f32>>;\n@group(0) @binding(3) var<uniform> params: IntegrationParams;\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) invocation: vec3<u32>) {\n let index = invocation.x;\n if (index >= arrayLength(&velocities)) { return; }\n velocities[index] = vec4(\n (constrainedPositions[index].xyz - previousPositions[index].xyz) / params.deltaTime,\n 0.0,\n );\n}\n";/*
1
+ import{ComputeKernel as e}from"@hology/webgpu-renderer";const t=64;export class WebGpuSecondaryMotionRuntime{constructor(t,n={}){this.device=t,this.collisionColliderData=new Float32Array(96),this.collisionCollidersInitialized=!1,this.collisionBindGroups=new Map,this.integrationParamsData=new ArrayBuffer(80),this.integrationParamsView=new DataView(this.integrationParamsData),this.regions=new Map,this.pendingSteps=0,this.disposed=!1,this.lastExecuteSeconds=null,this.fixedStep=new SecondaryMotionFixedStep(n),this.constraintIterations=Math.max(0,Math.trunc(n.constraintIterations??4)),this.constraintStiffness=Math.max(0,Math.min(1,n.constraintStiffness??.9)),this.autoAdvance=n.autoAdvance??!0,this.integrationKernel=new e(t,{label:"secondary-motion-integrate",code:o,bindGroupLayoutEntries:[i(0,!0),i(1,!1),i(2,!1),i(3,!0),i(4,!0),{binding:5,visibility:GPUShaderStage.COMPUTE,buffer:{type:"uniform"}}]}),this.constraintKernel=new e(t,{label:"secondary-motion-distance-constraints",code:s,bindGroupLayoutEntries:[i(0,!1),i(1,!0),i(2,!0),i(3,!0),{binding:4,visibility:GPUShaderStage.COMPUTE,buffer:{type:"uniform"}}]}),this.packKernel=new e(t,{label:"secondary-motion-pack-render-positions",code:a,bindGroupLayoutEntries:[i(0,!0),i(1,!0),i(2,!1)]}),this.finalizeKernel=new e(t,{label:"secondary-motion-finalize-velocity",code:l,bindGroupLayoutEntries:[i(0,!0),i(1,!0),i(2,!1),{binding:3,visibility:GPUShaderStage.COMPUTE,buffer:{type:"uniform"}}]}),this.collisionKernel=new e(t,{label:"secondary-motion-sphere-collision",code:c,bindGroupLayoutEntries:[i(0,!1),i(1,!0),i(2,!0),i(3,!0),i(4,!0),i(5,!1),{binding:6,visibility:GPUShaderStage.COMPUTE,buffer:{type:"uniform"}}]}),this.integrationParams=t.createBuffer({label:"secondary-motion-integration-params",size:80,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST}),this.collisionSpheres=t.createBuffer({label:"secondary-motion-collision-spheres",size:384,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST}),this.previousCollisionSpheres=t.createBuffer({label:"secondary-motion-previous-collision-spheres",size:384,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST}),this.collisionParams=t.createBuffer({label:"secondary-motion-collision-params",size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST}),t.queue.writeBuffer(this.collisionParams,0,new Uint32Array(4));const r=this.integrationParamsView,d=n.gravity??[0,-9.81,0];r.setFloat32(0,this.fixedStep.step,!0),r.setFloat32(4,Math.max(0,Math.min(1,n.damping??.985)),!0),r.setFloat32(8,Math.max(0,n.attachment??12),!0),r.setFloat32(16,d[0],!0),r.setFloat32(20,d[1],!0),r.setFloat32(24,d[2],!0),r.setFloat32(76,Math.max(1,4*Math.hypot(d[0],d[1],d[2])),!0),t.queue.writeBuffer(this.integrationParams,0,this.integrationParamsData)}setFrameMotion(e){this.writeFrameVector(32,e.linearAcceleration),this.writeFrameVector(48,e.angularVelocity),this.writeFrameVector(64,e.angularAcceleration),this.device.queue.writeBuffer(this.integrationParams,32,this.integrationParamsData,32,48)}setSphereColliders(e){this.setEllipsoidColliders(e.map(e=>({center:e.center,radii:[e.radius,e.radius,e.radius]})))}setEllipsoidColliders(e){const t=Math.min(e.length,8),i=new Float32Array(96);for(let n=0;n<t;n++){const t=e[n],r=12*n;i[r]=t.center[0],i[r+1]=t.center[1],i[r+2]=t.center[2],i[r+4]=Math.max(0,t.radii[0]),i[r+5]=Math.max(0,t.radii[1]),i[r+6]=Math.max(0,t.radii[2]),i[r+8]=t.rotation?.[0]??0,i[r+9]=t.rotation?.[1]??0,i[r+10]=t.rotation?.[2]??0,i[r+11]=t.rotation?.[3]??1}this.collisionCollidersInitialized?this.device.queue.writeBuffer(this.previousCollisionSpheres,0,this.collisionColliderData):(this.device.queue.writeBuffer(this.previousCollisionSpheres,0,i),this.collisionCollidersInitialized=!0),this.collisionColliderData.set(i),this.device.queue.writeBuffer(this.collisionSpheres,0,this.collisionColliderData),this.device.queue.writeBuffer(this.collisionParams,0,new Uint32Array([t,0,0,0]))}advance(e){const t=this.fixedStep.consume(e);return this.pendingSteps=Math.min(this.pendingSteps+t,this.fixedStep.maxSubsteps),t}addRegion(e){if(this.assertActive(),this.regions.has(e.id))throw new Error(`Secondary-motion region "${e.id}" is already registered`);const t=e.particleInverseMass.length,i=e.renderVertexIndices.length;if(e.particlePositions.length!==3*t)throw new Error("Secondary-motion particle positions must contain three values per particle");const o=r(e.particlePositions),s=[this.createStorageBuffer(`${e.id}-positions-a`,o),this.createStorageBuffer(`${e.id}-positions-b`,o)],a=this.createStorageBuffer(`${e.id}-velocities`,new Float32Array(4*t)),c=this.createStorageBuffer(`${e.id}-targets`,e.particlePositions),l=this.createStorageBuffer(`${e.id}-inverse-mass`,e.particleInverseMass),d=this.createStorageBuffer(`${e.id}-render-positions`,new Float32Array(3*i)),u=this.createStorageBuffer(`${e.id}-render-vertex-indices`,e.renderVertexIndices),p=[0,1].map(t=>this.integrationKernel.createBindGroup(this.device,[n(0,s[t]),n(1,a),n(2,s[1-t]),n(3,c),n(4,l),n(5,this.integrationParams)],`${e.id}-integration-${t}`)),f=s.map(e=>this.packKernel.createBindGroup(this.device,[n(0,e),n(1,u),n(2,d)])),h=[0,1].map(e=>this.finalizeKernel.createBindGroup(this.device,[n(0,s[1-e]),n(1,s[e]),n(2,a),n(3,this.integrationParams)])),g=function(e,t){if(0===e.constraints.length)return null;if(e.constraints.length%2!=0)throw new Error("Secondary-motion distance constraints must contain particle pairs");const i=e.constraints.length/2;if(e.constraintRest.length!==i||e.constraintColors.length!==i)throw new Error("Secondary-motion constraint arrays have inconsistent lengths");const n=Array.from({length:i},(e,t)=>t).sort((t,i)=>e.constraintColors[t]-e.constraintColors[i]),r=new Uint32Array(e.constraints.length),o=new Float32Array(i),s=[];let a=-1;for(let c=0;c<i;c++){const i=n[c],l=e.constraintColors[i],d=e.constraints[2*i],u=e.constraints[2*i+1];if(d>=t||u>=t)throw new Error("Secondary-motion constraint references an invalid particle");r[2*c]=d,r[2*c+1]=u,o[c]=e.constraintRest[i],l!==a?(s.push({offset:c,count:1}),a=l):s[s.length-1].count++}return{pairs:r,rest:o,batches:s}}(e,t),m=g?this.createStorageBuffer(`${e.id}-constraints`,g.pairs):null,v=g?this.createStorageBuffer(`${e.id}-constraint-rest`,g.rest):null,y=[];if(g&&m&&v)for(const t of g.batches){const i=this.device.createBuffer({label:`${e.id}-constraint-params`,size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST}),r=new ArrayBuffer(16),o=new DataView(r);o.setUint32(0,t.offset,!0),o.setUint32(4,t.count,!0),o.setFloat32(8,this.constraintStiffness,!0),this.device.queue.writeBuffer(i,0,r),y.push({count:t.count,params:i,bindGroups:s.map(e=>this.constraintKernel.createBindGroup(this.device,[n(0,e),n(1,l),n(2,m),n(3,v),n(4,i)]))})}const x={id:e.id,particleCount:t,renderVertexCount:i,positions:s,velocities:a,targets:c,inverseMass:l,renderPositions:d,renderVertexIndices:u,integrationBindGroups:p,packBindGroups:f,finalizeBindGroups:h,constraints:m,constraintRest:v,constraintBatches:y,state:0,disposed:!1,renderGeometry:null};this.regions.set(e.id,x);const P=this;return{id:e.id,particleCount:t,renderVertexCount:i,get positionBuffer(){return x.positions[x.state]},setRenderGeometry(e){x.renderGeometry=e},updateTargets(e){P.writeTargets(x.targets,e,t)},reset(t=e.particlePositions){P.resetRegion(x,t)},dispose(){P.removeRegion(x)}}}execute(e){if(this.assertActive(),e.device!==this.device)throw new Error("Secondary-motion runtime was created for a different GPU device");if(this.autoAdvance){const e=.001*performance.now();null!=this.lastExecuteSeconds&&this.advance(e-this.lastExecuteSeconds),this.lastExecuteSeconds=e}const i=this.pendingSteps;if(this.pendingSteps=0,0===this.regions.size)return;const n=e.encoder.beginComputePass({label:"secondary-motion-simulation"});for(let e=0;e<i;e++)for(const e of this.regions.values())if(!e.disposed&&0!==e.particleCount){if(n.setPipeline(this.integrationKernel.pipeline),n.setBindGroup(0,e.integrationBindGroups[e.state]),n.dispatchWorkgroups(Math.ceil(e.particleCount/t)),e.state=0===e.state?1:0,e.constraintBatches.length>0){n.setPipeline(this.constraintKernel.pipeline);for(let i=0;i<this.constraintIterations;i++)for(const i of e.constraintBatches)n.setBindGroup(0,i.bindGroups[e.state]),n.dispatchWorkgroups(Math.ceil(i.count/t))}n.setPipeline(this.finalizeKernel.pipeline),n.setBindGroup(0,e.finalizeBindGroups[e.state]),n.dispatchWorkgroups(Math.ceil(e.particleCount/t)),n.setPipeline(this.collisionKernel.pipeline),n.setBindGroup(0,this.getCollisionBindGroup(e)),n.dispatchWorkgroups(Math.ceil(e.particleCount/t))}for(const e of this.regions.values())e.disposed||0===e.particleCount||null==e.renderGeometry||(n.setPipeline(this.packKernel.pipeline),n.setBindGroup(0,e.packBindGroups[e.state]),n.dispatchWorkgroups(Math.ceil(e.renderVertexCount/t)));n.end();for(const t of this.regions.values())if(!t.disposed&&null!=t.renderGeometry){const i=e.getGeometryVertexBuffer(t.renderGeometry,"position");e.encoder.copyBufferToBuffer(t.renderPositions,0,i,0,12*t.renderVertexCount)}}dispose(){if(!this.disposed){for(const e of Array.from(this.regions.values()))this.removeRegion(e);this.integrationParams.destroy(),this.collisionSpheres.destroy(),this.previousCollisionSpheres.destroy(),this.collisionParams.destroy(),this.collisionBindGroups.clear(),this.disposed=!0}}resetRegion(e,t){this.writeTargets(e.targets,t,e.particleCount);const i=function(e,t){if(e.length!==3*t)throw new Error(`Expected ${3*t} secondary-motion position values, received ${e.length}`);return r(e)}(t,e.particleCount);for(const t of e.positions)this.device.queue.writeBuffer(t,0,i);this.device.queue.writeBuffer(e.velocities,0,new Float32Array(4*e.particleCount)),e.state=0,this.fixedStep.reset(),this.pendingSteps=0}writeTargets(e,t,i){if(t.length!==3*i)throw new Error(`Expected ${3*i} secondary-motion target values, received ${t.length}`);this.device.queue.writeBuffer(e,0,t)}removeRegion(e){if(!e.disposed){e.disposed=!0,this.regions.delete(e.id);for(const t of e.positions)this.collisionBindGroups.delete(t);for(const t of e.positions)t.destroy();e.velocities.destroy(),e.targets.destroy(),e.inverseMass.destroy(),e.renderPositions.destroy(),e.renderVertexIndices.destroy(),e.constraints?.destroy(),e.constraintRest?.destroy();for(const t of e.constraintBatches)t.params.destroy()}}createStorageBuffer(e,t){const i=this.device.createBuffer({label:e,size:Math.max(4,t.byteLength+3&-4),usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST|GPUBufferUsage.COPY_SRC});return t.byteLength>0&&this.device.queue.writeBuffer(i,0,t.buffer,t.byteOffset,t.byteLength),i}getCollisionBindGroup(e){const t=e.positions[e.state];let i=this.collisionBindGroups.get(t);return null==i&&(i=this.collisionKernel.createBindGroup(this.device,[n(0,t),n(1,e.positions[1-e.state]),n(2,e.inverseMass),n(3,this.collisionSpheres),n(4,this.previousCollisionSpheres),n(5,e.velocities),n(6,this.collisionParams)],`${e.id}-sphere-collision-${e.state}`),this.collisionBindGroups.set(t,i)),i}assertActive(){if(this.disposed)throw new Error("Secondary-motion runtime is disposed")}writeFrameVector(e,t){this.integrationParamsView.setFloat32(e,t?.[0]??0,!0),this.integrationParamsView.setFloat32(e+4,t?.[1]??0,!0),this.integrationParamsView.setFloat32(e+8,t?.[2]??0,!0)}}export function createSecondaryMotionRenderGraphExtension(e,t="SecondaryMotionSimulation"){const i=i=>{i.addPass({name:t,execute:t=>e.execute(t)})};return i.stage="beforeScene",i}export class SecondaryMotionFixedStep{constructor(e={}){this.accumulator=0,this.step=1/(e.frequency??60),this.maxSubsteps=e.maxSubsteps??4,this.maxFrameDelta=e.maxFrameDelta??.1}consume(e){if(!Number.isFinite(e)||e<0||e>this.maxFrameDelta)return this.accumulator=0,0;this.accumulator=Math.min(this.accumulator+e,this.step*this.maxSubsteps);const t=Math.min(Math.floor(this.accumulator/this.step),this.maxSubsteps);return this.accumulator-=t*this.step,t}reset(){this.accumulator=0}}export function selectSecondaryMotionLod(e,t,i){return t?i?"hero":e<8?"near":e<20?"mid":e<40?"far":"sleep":"sleep"}export function secondaryMotionTransformDiscontinuous(e,t,i=2,n=.5){if(e.length<7||t.length<7)return!0;const r=t[0]-e[0],o=t[1]-e[1],s=t[2]-e[2];if(r*r+o*o+s*s>i*i)return!0;return Math.abs(e[3]*t[3]+e[4]*t[4]+e[5]*t[5]+e[6]*t[6])<n}function i(e,t){return{binding:e,visibility:GPUShaderStage.COMPUTE,buffer:{type:t?"read-only-storage":"storage"}}}function n(e,t){return{binding:e,resource:{buffer:t}}}function r(e){const t=new Float32Array(e.length/3*4);for(let i=0,n=0;i<e.length;i+=3,n+=4)t[n]=e[i],t[n+1]=e[i+1],t[n+2]=e[i+2],t[n+3]=1;return t}const o="\nstruct IntegrationParams {\n deltaTime: f32,\n damping: f32,\n attachment: f32,\n _padding: f32,\n gravity: vec3<f32>,\n _padding2: f32,\n frameLinearAcceleration: vec3<f32>,\n _padding3: f32,\n frameAngularVelocity: vec3<f32>,\n _padding4: f32,\n frameAngularAcceleration: vec3<f32>,\n maxFrameAcceleration: f32,\n}\n@group(0) @binding(0) var<storage, read> currentPositions: array<vec4<f32>>;\n@group(0) @binding(1) var<storage, read_write> velocities: array<vec4<f32>>;\n@group(0) @binding(2) var<storage, read_write> outputPositions: array<vec4<f32>>;\nstruct PackedPosition { x: f32, y: f32, z: f32 }\n@group(0) @binding(3) var<storage, read> targetPositions: array<PackedPosition>;\n@group(0) @binding(4) var<storage, read> inverseMass: array<f32>;\n@group(0) @binding(5) var<uniform> params: IntegrationParams;\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) invocation: vec3<u32>) {\n let index = invocation.x;\n if (index >= arrayLength(&inverseMass)) { return; }\n let packedTarget = targetPositions[index];\n let targetPosition = vec3<f32>(packedTarget.x, packedTarget.y, packedTarget.z);\n if (inverseMass[index] <= 0.0) {\n velocities[index] = vec4(0.0);\n outputPositions[index] = vec4(targetPosition, 1.0);\n return;\n }\n let current = currentPositions[index].xyz;\n // Fully simulated cloth should be driven through its structural connection\n // to the animated roots. Pulling every free particle toward its skinned rest\n // pose makes collision sandwich it against the character like shrink-wrap.\n let animationInfluence = 1.0 - clamp(inverseMass[index], 0.0, 1.0);\n let targetAcceleration = (targetPosition - current) * params.attachment * animationInfluence;\n let previousVelocity = velocities[index].xyz;\n let rotationalAcceleration =\n cross(params.frameAngularAcceleration, current) +\n cross(params.frameAngularVelocity, cross(params.frameAngularVelocity, current)) +\n 2.0 * cross(params.frameAngularVelocity, previousVelocity);\n var frameAcceleration = params.frameLinearAcceleration + rotationalAcceleration;\n let frameAccelerationLength = length(frameAcceleration);\n if (frameAccelerationLength > params.maxFrameAcceleration) {\n frameAcceleration *= params.maxFrameAcceleration / frameAccelerationLength;\n }\n let acceleration = params.gravity + targetAcceleration - frameAcceleration;\n let velocity = previousVelocity * params.damping + acceleration * params.deltaTime;\n let next = current + velocity * params.deltaTime;\n velocities[index] = vec4(velocity, 0.0);\n outputPositions[index] = vec4(next, 1.0);\n}\n",s="\nstruct ConstraintParams {\n offset: u32,\n count: u32,\n stiffness: f32,\n _padding: f32,\n}\n@group(0) @binding(0) var<storage, read_write> positions: array<vec4<f32>>;\n@group(0) @binding(1) var<storage, read> inverseMass: array<f32>;\n@group(0) @binding(2) var<storage, read> pairs: array<vec2<u32>>;\n@group(0) @binding(3) var<storage, read> restLengths: array<f32>;\n@group(0) @binding(4) var<uniform> params: ConstraintParams;\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) invocation: vec3<u32>) {\n if (invocation.x >= params.count) { return; }\n let constraintIndex = params.offset + invocation.x;\n let pair = pairs[constraintIndex];\n let weightA = inverseMass[pair.x];\n let weightB = inverseMass[pair.y];\n let weight = weightA + weightB;\n if (weight <= 0.0) { return; }\n let delta = positions[pair.y].xyz - positions[pair.x].xyz;\n let length = length(delta);\n if (length <= 0.000001) { return; }\n let correction = delta * ((length - restLengths[constraintIndex]) / length) * params.stiffness;\n positions[pair.x] = vec4(positions[pair.x].xyz + correction * (weightA / weight), 1.0);\n positions[pair.y] = vec4(positions[pair.y].xyz - correction * (weightB / weight), 1.0);\n}\n",a="\nstruct PackedPosition { x: f32, y: f32, z: f32 }\n@group(0) @binding(0) var<storage, read> positions: array<vec4<f32>>;\n@group(0) @binding(1) var<storage, read> particleByRenderVertex: array<u32>;\n@group(0) @binding(2) var<storage, read_write> output: array<PackedPosition>;\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) invocation: vec3<u32>) {\n let index = invocation.x;\n if (index >= arrayLength(&particleByRenderVertex)) { return; }\n let position = positions[particleByRenderVertex[index]];\n output[index].x = position.x; output[index].y = position.y; output[index].z = position.z;\n}\n",c="\nstruct CollisionParams { count: u32, _padding1: u32, _padding2: u32, _padding3: u32 }\n@group(0) @binding(0) var<storage, read_write> positions: array<vec4<f32>>;\nstruct Ellipsoid { center: vec4<f32>, radii: vec4<f32>, rotation: vec4<f32> }\n@group(0) @binding(1) var<storage, read> previousPositions: array<vec4<f32>>;\n@group(0) @binding(2) var<storage, read> inverseMass: array<f32>;\n@group(0) @binding(3) var<storage, read> ellipsoids: array<Ellipsoid>;\n@group(0) @binding(4) var<storage, read> previousEllipsoids: array<Ellipsoid>;\n@group(0) @binding(5) var<storage, read_write> velocities: array<vec4<f32>>;\n@group(0) @binding(6) var<uniform> params: CollisionParams;\n\nfn rotateByQuaternion(value: vec3<f32>, rotation: vec4<f32>) -> vec3<f32> {\n return value + 2.0 * cross(rotation.xyz, cross(rotation.xyz, value) + rotation.w * value);\n}\n\nfn toEllipsoidSpace(value: vec3<f32>, shape: Ellipsoid) -> vec3<f32> {\n return rotateByQuaternion(value - shape.center.xyz, vec4<f32>(-shape.rotation.xyz, shape.rotation.w)) / shape.radii.xyz;\n}\n\nfn fromEllipsoidSpace(value: vec3<f32>, shape: Ellipsoid) -> vec3<f32> {\n return shape.center.xyz + rotateByQuaternion(value * shape.radii.xyz, shape.rotation);\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) invocation: vec3<u32>) {\n let index = invocation.x;\n if (index >= arrayLength(&inverseMass) || inverseMass[index] <= 0.0) { return; }\n var position = positions[index].xyz;\n var velocity = velocities[index].xyz;\n for (var collider = 0u; collider < params.count; collider++) {\n let shape = ellipsoids[collider];\n if (all(shape.radii.xyz > vec3<f32>(0.0))) {\n let scaled = toEllipsoidSpace(position, shape);\n let scaledLengthSquared = dot(scaled, scaled);\n if (scaledLengthSquared < 1.0) {\n let direction = select(vec3<f32>(0.0, 0.0, 1.0), scaled * inverseSqrt(scaledLengthSquared), scaledLengthSquared > 0.00000001);\n position = fromEllipsoidSpace(direction * 1.02, shape);\n let normal = normalize(rotateByQuaternion(direction / shape.radii.xyz, shape.rotation));\n velocity -= normal * min(dot(velocity, normal), 0.0);\n } else {\n let previousShape = previousEllipsoids[collider];\n let start = toEllipsoidSpace(previousPositions[index].xyz, previousShape);\n let travel = scaled - start;\n let a = dot(travel, travel);\n let b = 2.0 * dot(start, travel);\n let c = dot(start, start) - 1.0;\n let discriminant = b * b - 4.0 * a * c;\n if (a > 0.00000001 && discriminant >= 0.0) {\n let hit = (-b - sqrt(discriminant)) / (2.0 * a);\n if (hit > 0.0001 && hit <= 1.0) {\n let direction = normalize(start + travel * hit);\n position = fromEllipsoidSpace(direction * 1.02, shape);\n let normal = normalize(rotateByQuaternion(direction / shape.radii.xyz, shape.rotation));\n velocity -= normal * min(dot(velocity, normal), 0.0);\n }\n }\n }\n }\n }\n positions[index] = vec4(position, 1.0);\n velocities[index] = vec4(velocity, 0.0);\n}\n",l="\nstruct IntegrationParams {\n deltaTime: f32,\n damping: f32,\n attachment: f32,\n _padding: f32,\n gravity: vec3<f32>,\n _padding2: f32,\n}\n@group(0) @binding(0) var<storage, read> previousPositions: array<vec4<f32>>;\n@group(0) @binding(1) var<storage, read> constrainedPositions: array<vec4<f32>>;\n@group(0) @binding(2) var<storage, read_write> velocities: array<vec4<f32>>;\n@group(0) @binding(3) var<uniform> params: IntegrationParams;\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) invocation: vec3<u32>) {\n let index = invocation.x;\n if (index >= arrayLength(&velocities)) { return; }\n velocities[index] = vec4(\n (constrainedPositions[index].xyz - previousPositions[index].xyz) / params.deltaTime,\n 0.0,\n );\n}\n";/*
2
2
  * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
3
  * See the LICENSE.md file for details.
4
4
  */
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=asset-resource-loader-converted-fbx.test.d.ts.map
@@ -0,0 +1,4 @@
1
+ import{describe as e,expect as t,it as n,vi as o}from"vitest";import{Group as a,Scene as s}from"three";import{AssetResourceLoader as r}from"../scene/asset-resource-loader.js";o.mock("../scene/materializer.js",()=>({applyMaterial:o.fn(),getConvertedFbxToGlbAssignedMaterialTextureFlipY:o.fn()})),o.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=o.fn()),e[t]),set:(e,t,n)=>(e[t]=n,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),e("AssetResourceLoader converted FBX hierarchy",()=>{n("unwraps the synthetic glTF scene around a Three-converted FBX root",async()=>{const e=new r;e.setDataDir("");const n=new s,i=new a;i.name="AuthoredFbxRoot",n.add(i),e.glbLoader={loadAsync:o.fn().mockResolvedValue({scene:n,animations:[]})};const l=await e.loadByAsset({id:"converted-fbx",name:"Converted FBX",type:"mesh",fileKey:"model.glb",fileFormat:"glb",originalFileKey:"model.fbx"});t(l.scene).toBe(i),t(i.parent).toBeNull()}),n("keeps the synthetic scene for an ordinary GLB",async()=>{const e=new r;e.setDataDir("");const n=new s;n.add(new a),e.glbLoader={loadAsync:o.fn().mockResolvedValue({scene:n,animations:[]})};const i=await e.loadByAsset({id:"native-glb",name:"Native GLB",type:"mesh",fileKey:"model.glb",fileFormat:"glb"});t(i.scene).toBe(n)})});/*
2
+ * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
+ * See the LICENSE.md file for details.
4
+ */
@@ -1,4 +1,4 @@
1
- import{describe as e,expect as t,it as a}from"vitest";import*as o from"three";import{NodeShaderMaterial as i,rgb as n,rgba as s,standardMaterial as r,textureSampler2d as c,varyingAttributes as l,vec3 as p}from"three-shader-graph";import{SerializedParamType as m}from"../scene/model.js";import{compileGpuSpriteEmitters as d,materializeGpuSpriteRenderClasses as u,selectVfxBackend as h}from"../effects/vfx/gpu-sprite-compiler.js";import{prepareGpuSpriteBackend as y}from"../effects/vfx/gpu-sprite-backend.js";import{calculateGpuSpriteOptionalStateLayout as g,GPU_TRAIL_RENDER_CLASS_BYTES as f,GpuSpriteDirtyRange as b,getCompiledGpuSpriteMaterial as w,getGpuSpriteComputeWgsl as C,getGpuSpriteMaterialWgsl as x,getGpuTrailMaterialWgsl as v}from"../effects/vfx/gpu-sprite-runtime.js";import{Trail as B,trailUV as S}from"../effects/vfx/trail-renderer.js";import{timeUniforms as V}from"../shader-nodes/time.js";import{GpuSpriteSchedulerRuntime as T}from"../effects/vfx/gpu-sprite-scheduler-runtime.js";import{GpuSpriteTemplateCache as M,instantiateGpuSpriteTemplate as I}from"../effects/vfx/gpu-sprite-template.js";import{GpuSpriteWorldRuntime as E}from"../effects/vfx/gpu-sprite-world-runtime.js";import{GpuSpriteRenderClassCache as P}from"../effects/vfx/gpu-sprite-compiler.js";import{addGpuSpriteRenderGraphPasses as z}from"../effects/vfx/gpu-sprite-render-graph.js";import{gpuVfxCapacityClass as j}from"../effects/vfx/gpu-vfx-capacity.js";import{buildParticleLightingShaderHeader as q}from"@hology/webgpu-renderer";import{sampleSceneColor as A}from"../shader-nodes/scene-sample.js";import{screenUV as O}from"../shader-nodes/depth.js";import{particleUniforms as R}from"../shader-nodes/particle.js";const G=e=>({type:m.Number,value:e}),k=e=>({type:m.Vector3,value:e});function L(e={}){return{type:"sprite",texture:"dot",color:"#ffffff",intensity:1,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}function U(e={}){return{type:"mesh",assetId:"simple-mesh",space:"world",...e}}function _(e={}){return{type:"trail",length:10,width:.5,taper:!0,color:"#40a0ff",colorEnd:"#ff4080",intensity:2,intensityEnd:.25,opacityStart:1,opacityEnd:0,bloom:!1,billboard:!0,opacityChannel:"alpha",space:"world",...e}}function D(e,t=L()){return{id:e,name:e,position:[0,0,0],rotation:[0,0,0],rate:{type:"once",count:2,delay:0},output:t,initializers:[{id:`${e}-life`,type:"lifetime",enabled:!0,params:{duration:G(2)}},{id:`${e}-position`,type:"positionSphere",enabled:!0,params:{position:k([0,0,0]),radius:G(1),surface:{type:m.Boolean,value:!1}}}],behaviours:[]}}function W(...e){return{id:"test-vfx",name:"test-vfx",type:"vfx",vfx:{emitters:e}}}e("GPU sprite compiler compatibility",()=>{a("compiles pay-for-play child emitters and screen-space collision metadata",()=>{const e=D("parent");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{bounce:G(.6),friction:G(.2),lifeLoss:G(.5),debug:{type:m.Boolean,value:!0}}});const a=D("child");a.rate={type:"once",count:3,delay:0},a.spawnEvent="collision",e.children=[a];const i=d(W(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters).toHaveLength(2),t(i.emitters[0]).toMatchObject({hasWorldCollision:!0,collisionBounce:.6,collisionFriction:.2,collisionLifeLoss:.5,collisionDebug:!0}),t(i.emitters[1]).toMatchObject({parentEmitter:0,childParticlesPerParent:3,childSpawnEvent:2,childLevel:1,capacity:6});const n=C(16);t(n).not.toContain("collisionDepth"),t(n).not.toMatch(/__[A-Z_]+__/);const s=C(16,!1,!1,!1,!1,!1,!1,0,0,!0,!0,32,1);t(s).toContain("collisionDepth"),t(s).toContain("collisionStep <= 6u"),t(s).toContain("approachingVisibleSurface"),t(s).toContain("optionalStates[32u + owner.padding]"),t(s).not.toMatch(/__[A-Z_]+__/)}),a("supports local-space parent particles spawning local or world-space children",()=>{const e=D("parent",L({space:"local"})),a=D("local-child",L({space:"local"})),i=D("world-child",L({space:"world"}));e.children=[a,i];const n=d(W(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.compatibility.issues).toEqual([]),t(n.emitters.map(e=>e.localSpace)).toEqual([!0,!0,!1]);const s=C(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(s).not.toContain("parentTransform = mat4x4");const r=C(16,!1,!1,!1,!1,!0,!1,0,0,!1,!1,0,1,!0);t(r).toContain("parentTransform = mat4x4"),t(r).toContain("parentEmitter.trail.z > 0.0 && emitter.trail.z < 0.0"),t(r).toContain("childEmissionEnd = parent.velocityLife.w"),t(r).toContain("particle.positionAge = vec4(particle.positionAge.xyz, age)"),t(r).not.toContain("childEventReady = parentAlive"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("rejects world-space parents with local-space child output",()=>{const e=D("parent",L({space:"world"}));e.children=[D("child",L({space:"local"}))];const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues).toContainEqual(t.objectContaining({code:"child-emitter-local-space"}))}),a("orders child render classes by authored render order without changing simulation hierarchy",()=>{const e=D("parent",U({renderOrder:10})),a=D("child",L({renderOrder:-10}));e.children=[a];const i=d(W(e),[new o.Vector3]);t(i.renderClasses.map(e=>e.output.renderOrder)).toEqual([-10,10]),t(i.emitters[0].parentEmitter).toBeUndefined(),t(i.emitters[0].renderClass).toBe(1),t(i.emitters[1]).toMatchObject({parentEmitter:0,renderClass:0})}),a("uses geometric world reservation classes without fixed template maxima",()=>{t([1,2,3,4,5,17].map(j)).toEqual([1,2,4,4,8,32])}),a("compiles stretched sprites into a velocity-aligned render class",async()=>{const e=d(W(D("stretched",function(e={}){return{type:"stretchedSprite",texture:"dot",color:"#ffffff",intensity:1,scale:.5,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}({scale:.75}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await u(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].stretchScale).toBe(.75);const i=x(w(a.renderClasses[0].material),!1);t(i).toContain("stretchedSpriteClipPosition"),t(i).toContain("bitcast<f32>(renderClass.data.w)")}),a("materializes billboard trails with CPU-compatible history sizing",async()=>{const e=d(W(D("trail",_({length:9.6}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.emitters).toHaveLength(1),t(e.emitters[0].capacity).toBe(2);const a=await u(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({historyLength:11,width:.5,taper:!0}),t(f).toBe(80);const i=C(16,!1,!1,!1,!0);t(i).toContain("@group(0) @binding(8) var<storage, read_write> optionalStates"),t(i).not.toContain("@binding(9)")}),a("streams local trail transforms through emitter commands without another storage binding",()=>{const e=d(W(D("attached-trail",_({space:"local"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=C(16,!1,!1,!1,!0,!0);t(a).toContain("trailTransform0: vec4<f32>"),t(a).toContain("emitter.trailTransform3 = command.trailTransform3"),t(a).not.toContain("@binding(12)")}),a("compiles trail graph UV varyings into the procedural ribbon ABI",()=>{const e=new i({color:s(p(S.x,S.y,R.opacity),R.opacity)});B.applyTrailShaderParameters(e);const a=v(e,!0);t(a).toContain("struct GraphVertexInput"),t(a).toContain("nodeVertexID: f32"),t(a).toContain("emitterTransforms"),t(a).not.toContain("__TRAIL_"),t(a).not.toContain("output.uv ="),t(a).not.toContain("output.color ="),t(a).not.toContain("normalize(after - before)"),t(a).not.toContain("2.0, 2.0, 2.0, 0.0")}),a("binds shader graph elapsed time to the GPU particle frame clock",()=>{const e=new i({color:s(p(V.elapsed),1)}),a=v(e),o=x(w(e),!1);t(a).toContain("frame.time"),t(o).toContain("frame.time"),t(a).not.toContain("hology_elapsed_time:"),t(o).not.toContain("hology_elapsed_time:")}),a("preserves CPU raw-channel colors for color behaviours",()=>{const e=D("legacy-color",_());e.behaviours.push({id:"color",type:"changeColor",enabled:!0,params:{a:{type:m.Color,value:"#918383"},b:{type:m.Color,value:"#4f4f4f"}}});const a=d(W(e),[new o.Vector3]);t(a.emitters[0].colorStart.toArray()).toEqual([145/255,131/255,131/255]),t(a.emitters[0].colorEnd.toArray()).toEqual([79/255,79/255,79/255])}),a("materializes one mesh geometry and rejects mesh hierarchies with multiple geometries",async()=>{const e=D("mesh",U({space:"local"}));e.initializers.push({id:"mesh-scale-components",type:"scaleComponents",enabled:!0,params:{scale:k([1,2,3])}},{id:"mesh-rotation",type:"rotation",enabled:!0,params:{rotation:k([30,45,60])}}),e.behaviours.push({id:"mesh-scale-components-behaviour",type:"scaleComponents",enabled:!0,params:{a:k([2,1,1]),b:k([1,.5,2])}});const a=W(e),r=d(a,[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.renderClasses[0].output.type).toBe("mesh"),t(r.emitters[0].localSpace).toBe(!0),t(r.emitters[0].meshScaleStart.toArray()).toEqual([2,2,3]),t(r.emitters[0].meshScaleEnd.toArray()).toEqual([1,1,6]),t(r.emitters[0].meshRotation.w).not.toBe(1),t(r.emitters[0].meshRotationEuler.toArray()).toEqual([o.MathUtils.degToRad(30),o.MathUtils.degToRad(45),o.MathUtils.degToRad(60)]);const c=new o.Mesh(new o.BoxGeometry(1,1,1),new o.MeshBasicMaterial({color:3368601,opacity:.75})),l=await u(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>c});t(l.renderClasses[0].meshVertices?.length).toBe(288),t(l.renderClasses[0].meshColor?.getHex()).toBe(3368601),t(l.renderClasses[0].meshOpacity).toBe(.75),t(l.renderClasses[0].localSpace).toBe(!0);const p=new i({color:s(n(.4),1)}),m=await u(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>c},async()=>p);t(m.renderClasses[0].material).toBe(p);const h=new o.Group;h.add(new o.Mesh(new o.BoxGeometry),new o.Mesh(new o.BoxGeometry)),await t(u(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>h})).rejects.toThrow("exactly one mesh geometry")}),a("keeps an unconfigured mesh output GPU-compatible without rendering it",()=>{const e=d(W(D("unconfigured-mesh",U({assetId:null}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.compatibility.issues).toEqual([]),t(e.emitters).toEqual([]),t(e.renderClasses).toEqual([])}),a("treats an unselected mesh material override as the default material source",async()=>{const e=d(W(D("unselected-mesh-material",U({materialSource:"material",material:void 0}))),[new o.Vector3]),a=new i({color:s(n(.4),1)}),r=await u(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)},async()=>a);t(e.compatibility.compatible).toBe(!0),t(r.renderClasses[0].material).toBe(a)}),a("keeps an unselected mesh shader graph on the GPU default material",async()=>{const e=d(W(D("unselected-mesh-graph",U({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:null}}))),[new o.Vector3]),a=await u(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)});t(e.compatibility.compatible).toBe(!0),t(()=>w(a.renderClasses[0].material)).not.toThrow()}),a("gates custom mesh materials on a compatible GPU material resolver",async()=>{const e=d(W(D("mesh-material",U({material:"material-id"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a={getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)};await t(u(e.renderClasses,a)).rejects.toThrow("compatible GPU material resolver");const n=new o.Texture,s=await u(e.renderClasses,a,async()=>new i({color:c(n).sample(l.uv)}));t(s.renderClasses[0].material).toBeInstanceOf(i);const r=w(s.renderClasses[0].material);t(Object.values(r.textures)).toEqual([n])}),a("materializes non-billboard trails with orientation history",async()=>{const e=d(W(D("oriented-trail",_({billboard:!1}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await u(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({billboard:!1});const i=C(16,!0,!1,!1,!0);t(i).toContain("trailSampleOffset + 1u"),t(i).toContain("quaternionMultiply(particleRotation, movementRotation)");const n=v(B.createBaseMaterial(),!1);t(n).toContain("renderClass.trail.w == 0u"),t(n).toContain("historySide(state, control)")}),a("changes the mesh render class when shader graph parameters change",()=>{const e={source:"asset",assetId:"mesh-graph"},a=d(W(D("mesh",U({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:G(1)}}))),[new o.Vector3]),i=d(W(D("mesh",U({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:G(2)}}))),[new o.Vector3]);t(a.renderClasses[0].key).not.toBe(i.renderClasses[0].key)}),a("preserves legacy mesh geometry transforms when requested",async()=>{const e=new o.Group,a=new o.Mesh((new o.BufferGeometry).setAttribute("position",new o.Float32BufferAttribute([1,0,0],3)));a.position.set(2,0,0),e.add(a);const i=d(W(D("modern",U())),[new o.Vector3]),n=d(W(D("legacy",U({useLegacyTransform:!0}))),[new o.Vector3]);t(i.renderClasses[0].key).not.toBe(n.renderClasses[0].key);const s={getTexture:async()=>new o.Texture,getMesh:async()=>e},r=await u(i.renderClasses,s),c=await u(n.renderClasses,s);t(r.renderClasses[0].meshVertices?.[0]).toBe(3),t(c.renderClasses[0].meshVertices?.[0]).toBe(1)}),a("does not reuse render classes across explicit template revisions",async()=>{const e=W(D("revisioned")),a={assets:{getTexture:async()=>new o.Texture},renderClassCache:new P},i=await(new M).resolve(e,{...a,revision:1}),n=await(new M).resolve(e,{...a,revision:2});t(i.template?.renderClasses[0]).not.toBe(n.template?.renderClasses[0])}),a("retains renderer-owned scene texture semantics for particle materials",()=>{const e=new i({color:A(O)}),a=w(e);t(Object.values(a.sceneTextures)).toEqual(["sceneColor"])}),a("retains the shared renderer geometry semantics for lit particle materials",()=>{const e=w(new i({color:r({color:n(6724044),roughness:.5,metalness:.1})}));t(e.needsWorldPosition).toBe(!0),t(e.needsWorldNormal).toBe(!0),t(e.requiresRendererShaderHeader).toBe(!0),t(e.fragmentMain).toContain("rendererDirectionalLightForLight"),t(q()).toContain("fn rendererCameraPosition()");const a=x(e,!0);t(a).toContain("fn rendererCameraPosition()"),t(a).toContain("rendererCameraPosition() - input.worldPosition"),t(a).toContain("@group(1) @binding(0)"),t(a).toContain("emitterTransform[0].xyz"),t(a).not.toContain("mat3x3<f32>(emitterTransforms["),t(a).toContain("fn normalMatrixFromLinearTransform"),t(a).not.toContain("inverse("),t(e.renderState.transparent).toBe(!1),t(e.renderState.depthWrite).toBe(!0),t(e.renderState.side).toBe(o.FrontSide)}),a("registers one ordered compute/render pair for a shared runtime",()=>{const e=[];let a;z({mainDepth:"depth",addPass:t=>e.push(t)},{executeCompute:()=>{},executeRender:(e,t)=>{a=t},getSceneTextureReads:()=>["sceneColorSnapshot"]},"color","WorldVfx"),t(e.map(e=>e.name)).toEqual(["WorldVfxSimulation","WorldVfxRender"]),t(e[1].dependsOn).toEqual(["WorldVfxSimulation"]),t(e[1].inputs).toMatchObject({sceneColorSnapshot:"sceneColorSnapshot"}),e[1].execute({}),t(a).toBe("color")}),a("coalesces touched emitter slots without allocating per mark",()=>{const e=new b;e.mark(7),e.mark(3),e.mark(5),t(e).toMatchObject({start:3,end:8,length:5}),e.clear(),t(e.length).toBe(0)}),a("generates particle-owner WGSL without reserved active identifiers",()=>{const e=C(16);t(e).toContain("enabled: u32"),t(e).not.toMatch(/\bactive\s*:/),t(e).not.toContain("owner.active"),t(e).toContain("fn applyEmitterCommandsMain"),t(e).toContain("@group(0) @binding(7) var<storage, read> emitterCommands"),t(e).toContain("var<storage, read_write> emitters"),t(e).toContain("timing: vec4<f32>"),t(e).toContain("let emitterDelta = select(frame.delta * emitter.timing.w, 0.0"),t(e).toContain("let sampledLife = mix(emitter.emission.z, emitter.emission.w"),t(e).toContain("particle.velocityLife = vec4(initialVelocity, particleLife)"),t(e).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, hash(particleSeed + 71u))"),t(e).toContain("select(animatedColor, particle.color.rgb, emitter.meshScaleStart.w > 0.5)")}),a("compiles random lifetime as a per-particle birth range",()=>{const e=D("random-life");e.initializers[0]={id:"random-life-range",type:"randomLifetime",enabled:!0,params:{minDuration:G(.5),maxDuration:G(2.5)}};const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({lifeMin:.5,life:2.5})}),a("compiles random initial color without lifetime interpolation",()=>{const e=D("random-color");e.initializers.push({id:"random-color-range",type:"randomColor",enabled:!0,params:{a:{type:m.Color,value:"#ff0000"},b:{type:m.Color,value:"#0000ff"}}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].randomColor).toBe(!0)}),a("compiles point, box, line, ring, and sphere-surface spawn zones",()=>{const e=[["positionPoint",{position:k([1,2,3])},0],["positionSphere",{position:k([1,2,3]),radius:G(2),surface:{type:m.Boolean,value:!0}},1],["positionBox",{position:k([1,2,3]),dimensions:k([4,5,6])},2],["positionLine",{a:k([1,2,3]),b:k([4,5,6])},3],["positionRing",{position:k([1,2,3]),rotation:k([10,20,30]),radius:G(4),thickness:G(.5),angle:G(180)},4]];for(const[a,i,n]of e){const e=D(`zone-${a}`);e.initializers[1]={id:`zone-${a}`,type:a,enabled:!0,params:i};const s=d(W(e),[new o.Vector3]);t(s.compatibility.compatible).toBe(!0),t(s.emitters[0].spawnZoneType).toBe(n)}const a=C(16);t(a).toContain("fn rotateSpawnPoint"),t(a).toContain("if (zoneType == 4u)")}),a("preserves legacy per-update sprite rotation behavior",()=>{const e=D("sprite-rotate");e.behaviours.push({id:"rotate-add",type:"rotateAdd",enabled:!0,params:{rotation:k([0,0,200])}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].spriteRotationStep).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const i=C(16);t(i).toContain("var rotation = particle.color.w;"),t(i).toContain("rotation += emitter.rotationInitialSpriteStep.w * frame.padding1"),e.output=U();const n=d(W(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.emitters[0].meshRotationStep.z).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const s=C(16,!0);t(s).toContain("rotationEuler: vec4<f32>"),t(s).toContain("fn quaternionFromEulerXYZ"),t(s).toContain("emitter.rotationStep.xyz * frame.padding1"),e.initializers.push({id:"random-mesh-rotation",type:"randomRotation",enabled:!0,params:{axis:{type:m.String,value:"Y"}}});const r=d(W(e),[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.emitters[0].randomMeshRotationAxis).toBe(3),t(s).toContain("randomRotationAxis == 1u"),t(s).toContain("randomRotationAxis == 4u")}),a("compiles radial velocity without per-particle auxiliary storage",()=>{const e=D("radial-velocity");e.initializers.push({id:"radial",type:"radialVelocity",enabled:!0,params:{speed:G(4),direction:k([0,1,0]),spread:G(30)}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].hasRadialVelocity).toBe(!0),t(a.emitters[0].radialVelocitySpeed).toBe(4),t(a.emitters[0].radialVelocitySpread).toBeCloseTo(Math.PI/6);const i=C(16);t(i).toContain("fn radialVelocity"),t(i).toContain("emitter.radialMetadata.y > 0.5")}),a("compiles one attraction or repulsion field per emitter",()=>{const e=D("attraction");e.behaviours.push({id:"attract",type:"attraction",enabled:!0,params:{target:k([1,2,3]),force:G(4),radius:G(5)}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].attractionTarget.toArray()).toEqual([1,2,3]),t(a.emitters[0].attractionForce).toBe(4),e.behaviours[0].type="repulsion";const i=d(W(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].attractionForce).toBe(-4);const n=C(16);t(n).toContain("attractionDistanceSq < attractionRadiusSq")}),a("compiles mesh alignment modes into spawn-only parameters",()=>{const e=D("aligned-mesh",U());e.initializers.push({id:"align",type:"alignRotation",enabled:!0,params:{axis:{type:m.String,value:"+X"},mode:{type:m.String,value:"AlongVelocity"},offset:k([10,20,30]),center:k([1,2,3])}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].alignRotationAxis.toArray()).toEqual([1,0,0]),t(a.emitters[0].alignRotationMode).toBe(3),t(a.emitters[0].alignRotationOffset.x).toBeCloseTo(o.MathUtils.degToRad(10));const i=C(16,!0);t(i).toContain("fn quaternionFromUnitVectors"),t(i).toContain("alignRotationMode == 3u")}),a("groups authored render classes deterministically",()=>{const e=d(W(D("a"),D("b"),D("c",L({blendingMode:"normal"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.renderClasses).toHaveLength(2),t(e.emitters.map(e=>e.renderClass)).toEqual([0,0,1])}),a("can lay out emitter allocations contiguously per dynamic effect instance",()=>{const e=W(D("first"),D("second",L({blendingMode:"normal"}))),a=[new o.Vector3,new o.Vector3(1,0,0)],i=d(e,a),n=d(e,a,"instance-major");t(i.emitters.map(e=>e.renderClass)).toEqual([0,0,1,1]),t(n.emitters.map(e=>e.renderClass)).toEqual([0,1,0,1])}),a("reports sparse active-page simulation capacity",async()=>{const e=await y({asset:W(D("paged")),instancePositions:[new o.Vector3,new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:2,assets:{getTexture:async()=>new o.Texture}});e.runtime.setActivePages([0,2]),t(e.runtime.getStats()).toMatchObject({capacity:6,activePageCount:2,simulatedCapacity:4})}),a("compacts active page padding out of the simulation schedule",async()=>{const e=await T.create({asset:W(D("tiny-paged-effect")),maximumInstances:2,particlePageSize:256,assets:{getTexture:async()=>new o.Texture}});e.spawn(new o.Vector3),e.flushActivePages(),t(e.runtime.getStats()).toMatchObject({capacity:512,activePageCount:1,simulatedCapacity:2});const a=C(256);t(a).toContain("let particleIndex = activeParticleIndices[id.x]"),t(a).not.toContain("id.x % pageSize"),e.dispose()}),a("sizes alive indices by logical particles instead of render-class count",async()=>{const e=await y({asset:W(D("additive"),D("normal",L({blendingMode:"normal"}))),instancePositions:[new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:4,assets:{getTexture:async()=>new o.Texture}});t(e.runtime.getStats()).toMatchObject({capacity:8,renderClassCount:2,aliveIndexCapacity:8,aliveIndexBytes:32});const a=C(4);t(a).toContain("aliveIndices[indirect[renderClass].firstInstance + outputIndex]"),t(a).not.toContain("renderClass * u32(frame.capacity)")}),a("allocates and recycles multi-page dynamic effect instances",async()=>{const e=D("paged-instance");e.position=[2,0,0];const a=await T.create({asset:W(e),maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),i=a.spawn(new o.Vector3);t(a.runtime.emitters[0].position.x).toBe(2),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),a.update(.1),t(a.scheduler.getInstance(i.handle)).not.toBeNull(),t(a.play(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!1),t(a.setTimescale(i,2)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(2),t(a.stop(i)).toBe(!0),t(a.runtime.emitters[0].stopTime).toBeGreaterThanOrEqual(0),t(a.runtime.emitters[0].expireWithin).toBe(-1),t(i.pages).toHaveLength(2),a.flushActivePages(),t(a.runtime.getStats()).toMatchObject({activePageCount:2,simulatedCapacity:2}),t(a.destroy(i)).toBe(!0);const n=a.spawn(new o.Vector3(1,0,0));t(new Set(n.pages)).toEqual(new Set(i.pages)),t(n.handle.generation).not.toBe(i.handle.generation),a.dispose()}),a("caps GPU particle drain time for expire and kill lifecycle behavior",async()=>{const e=await T.create({asset:W(D("expiring")),maximumInstances:2,assets:{getTexture:async()=>new o.Texture}}),a=e.spawn(new o.Vector3);t(e.stop(a,.5)).toBe(!0),t(e.runtime.emitters[0].expireWithin).toBe(.5);for(let t=0;t<4;t++)e.update(.1);t(e.scheduler.getInstance(a.handle)).not.toBeNull(),e.update(.1),t(e.scheduler.getInstance(a.handle)).toBeNull();const i=e.spawn(new o.Vector3);t(e.stop(i,0)).toBe(!0),e.update(.001),t(e.scheduler.getInstance(i.handle)).toBeNull(),e.dispose()}),a("rejects a template pool before allocation when its storage budget is exceeded",async()=>{const e=await T.create({asset:W(D("over-budget")),maximumInstances:128,particlePageSize:1,maximumStorageBytes:1,assets:{getTexture:async()=>new o.Texture}});t(e).toBeNull()}),a("compiles delayed and finite continuous emission timing",()=>{const e=D("delayed");e.rate={type:"once",count:2,delay:.25};const a=D("finite");a.rate={type:"continuous",count:2,time:.1,delay:0,duration:1};const i=d(W(e,a),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate).toMatchObject({type:"once",delay:.25}),t(i.emitters[1].rate).toMatchObject({type:"continuous",delay:0,duration:1})}),a("treats zero continuous duration as unlimited like the CPU rate",()=>{const e=D("unlimited");e.rate={type:"continuous",count:2,time:.1,delay:0,duration:0};const a=d(W(e),[new o.Vector3]);t(a.compatibility.issues.map(e=>e.code)).not.toContain("rate-duration")}),a("compiles bound lifetime metadata but falls back for unsupported input targets",()=>{const e=W(D("input-test"));e.vfx.inputs=[{id:"speed",name:"Speed",type:"number",defaultValue:1}],t(d(e,[new o.Vector3]).compatibility.compatible).toBe(!0),e.vfx.emitters[0].initializers[0].params.duration.binding={type:"vfxInput",inputId:"speed"};const a=d(e,[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.inputBindings).toEqual([t.objectContaining({inputId:"speed",emitterIndex:0,target:"initializer:lifetime:duration"})]),e.vfx.emitters[0].initializers.push({id:"bound-velocity",type:"velocity",enabled:!0,params:{velocity:{type:m.Vector3,value:[0,1,0],binding:{type:"vfxInput",inputId:"speed"}}}}),t(d(e,[new o.Vector3]).compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding")}),a("falls back for a bound continuous lifetime until particle capacity can grow",()=>{const e=D("continuous-bound-life");e.rate={type:"continuous",count:1,time:.1,delay:0,duration:0},e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"};const a=W(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:2}];const i=d(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding"),t(i.inputBindings).toEqual([])}),a("caches compiled templates while keeping instance emitter state independent",async()=>{const e=W(D("cached-template")),a=new M;let i=0;const n={assets:{getTexture:async()=>(i+=1,new o.Texture)}},s=await a.resolve(e,n),r=await a.resolve(e,n);t(r.template).toBe(s.template),t(i).toBe(1),t(a.getStats()).toMatchObject({builds:1,hits:1});const c=I(s.template,[new o.Vector3(1,0,0),new o.Vector3(2,0,0)]);t(c[0].position).not.toBe(c[1].position),c[0].position.x=100,t(c[1].position.x).toBe(2),t(s.template.emitters[0].position.x).toBe(0),a.invalidate(e),await a.resolve(e,n),t(a.getStats()).toMatchObject({builds:2,hits:1,invalidations:1}),await a.resolve(e,{...n,revision:2}),t(a.getStats()).toMatchObject({builds:3,hits:1,invalidations:2})}),a("applies a bound lifetime only to the addressed scheduled instance",async()=>{const e=D("bound-life");e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"},e.initializers[1].params.radius.binding={type:"vfxInput",inputId:"radius"},e.initializers.push({id:"velocity",type:"velocity",enabled:!0,params:{velocity:{type:m.Vector3,value:[0,0,0],binding:{type:"vfxInput",inputId:"velocity"}}}});const a=W(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:1},{id:"radius",name:"Radius",type:"number",defaultValue:1},{id:"velocity",name:"Velocity",type:"vec3",defaultValue:[0,0,0]}];const i=await T.create({asset:a,maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),n=i.spawn(new o.Vector3),s=i.spawn(new o.Vector3);t(i.setInput(n,"life",4)).toBe(!0),t(i.runtime.emitters[0].life).toBe(4),t(i.runtime.emitters[1].life).toBe(2),t(i.setInput(n,"radius",3)).toBe(!0),t(i.runtime.emitters[0].positionRadius).toBe(3),t(i.setInput(n,"velocity",new o.Vector3(1,2,3))).toBe(!0),t(i.runtime.emitters[0].velocityMin.toArray()).toEqual([1,2,3]),t(i.setInput(s,"missing",5)).toBe(!1),i.dispose()}),a("schedules different templates in one arena and merges shared render classes",async()=>{const e=L(),a=await E.create({templates:[{asset:W(D("world-a",e)),maximumInstances:2},{asset:W(D("world-b",{...e})),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},templateCache:new M,renderClassCache:new P,particlePageSize:1}),i=a.spawn(0,new o.Vector3),n=a.spawn(1,new o.Vector3(1,0,0));a.flushActivePages(),t(a.templateCount).toBe(2),t(a.runtime.getStats()).toMatchObject({renderClassCount:1,activePageCount:4}),t(a.scheduler.getStats()).toMatchObject({activeInstances:2,arena:{usedPages:4}}),t(a.destroy(i)).toBe(!0),t(a.destroy(n)).toBe(!0),a.flushActivePages(),t(a.runtime.hasActiveParticles).toBe(!1),t(a.runtime.getStats()).toMatchObject({aliveParticles:0,activePageCount:0,simulatedCapacity:0}),a.dispose()}),a("routes per-instance lifecycle changes through a shared multi-template world",async()=>{const e=D("world-lifecycle");e.position=[2,0,0];const a=await E.create({templates:[{asset:W(e),maximumInstances:1},{asset:W(D("world-neighbour")),maximumInstances:1}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1}),i=a.spawn(0,new o.Vector3(3,0,0));t(a.runtime.emitters[0].position.x).toBe(5),t(a.setPosition(i,new o.Vector3(7,0,0))).toBe(!0),t(a.runtime.emitters[0].position.x).toBe(9),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),t(a.play(i)).toBe(!0),t(a.setTimescale(i,.5)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(.5),t(a.stop(i,0)).toBe(!0),a.update(.001),t(a.has(i)).toBe(!1),t(a.setPosition(i,new o.Vector3)).toBe(!1),a.dispose()}),a("rejects a shared world before runtime allocation when its storage budget is exceeded",async()=>{const e=await E.create({templates:[{asset:W(D("world-over-budget")),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1,maximumStorageBytes:1});t(e).toBeNull()}),a("falls back to CPU for semantic mismatches",()=>{const e=D("soft-local",L({softness:1,space:"local",lockY:!0}));e.behaviours.push({id:"animation",type:"animation",enabled:!0,params:{}});const a=d(W(e),[new o.Vector3]),i=h(a.compatibility);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues.map(e=>e.code)).toEqual(t.arrayContaining(["behaviour:animation"])),t(i.backend).toBe("cpu"),t(h(a.compatibility,"gpu").backend).toBe("gpu")}),a("compiles vortex with pay-for-play previous-velocity state",()=>{const e=D("vortex");e.behaviours.push({id:"vortex",type:"vortex",enabled:!0,params:{axis:k([0,5,0]),amount:G(1.25)}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].vortexAxis.toArray()).toEqual([0,5,0]),t(a.emitters[0].vortexAmount).toBe(1.25),t(C(16,!1,!1)).not.toContain("vortexVelocities");const i=C(16,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("behaviourVelocity -= optionalStates[0u + particleIndex].xyz")}),a("compiles the remaining initializer and motion behaviours",()=>{const e=D("remaining-motion");e.initializers.push({id:"mass",type:"mass",enabled:!0,params:{min:G(2)}}),e.behaviours.push({id:"drift",type:"randomDrift",enabled:!0,params:{drift:k([1,2,3]),delay:G(.25),ease:{type:m.String,value:"OutQuad"}}},{id:"follow",type:"followParent",enabled:!0,params:{speed:G(4)}},{id:"move",type:"moveTo",enabled:!0,params:{target:k([5,6,7]),ease:{type:m.String,value:"InQuad"}}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({mass:2,randomDriftDelay:.25,randomDriftEasing:2,followParentSpeed:4,hasFollowParent:!0,moveToEasing:1,hasMoveTo:!0}),t(a.emitters[0].randomDrift.toArray()).toEqual([1,2,3]),t(a.emitters[0].moveToTarget.toArray()).toEqual([5,6,7])}),a("compiles authored easing independently for visual behavior channels",()=>{const e=D("easing");e.behaviours.push({id:"scale",type:"scale",enabled:!0,params:{a:G(0),b:G(1),ease:{type:m.String,value:"OutBack"}}},{id:"color",type:"changeColor",enabled:!0,params:{a:{type:m.Color,value:"#ff0000"},b:{type:m.Color,value:"#0000ff"},ease:{type:m.String,value:"InCubic"}}},{id:"opacity",type:"changeOpacity",enabled:!0,params:{a:G(0),b:G(1),ease:{type:m.String,value:"BumpSharp"}}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].curveEasing.toArray()).toEqual([18,4,20,0]);const i=C(16);t(i).toContain("fn applyEasing"),t(i).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, colorProgress)"),t(i).toContain("mix(emitter.scales.xy, emitter.scales.zw, scaleProgress)")}),a("compiles disperse into optional shared particle state",()=>{const e=D("disperse");e.behaviours.push({id:"disperse",type:"disperse",enabled:!0,params:{distance:G(3),axis:{type:m.String,value:"XZ"},ease:{type:m.String,value:"InOutSine"}}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].disperseDistance).toBe(3),t(a.emitters[0].dispersePlane).toBe(2),t(a.emitters[0].disperseEasing).toBe(12),t(C(16)).not.toContain("disperseStates");const i=C(16,!1,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("optionalStates[0u + particleIndex].w = disperseProgress")}),a("consolidates every optional simulation feature into the eighth storage binding",()=>{const e=C(16,!0,!0,!0,!0,!1,!1,48,64),a=[...e.matchAll(/@group\(0\) @binding\((\d+)\) var<storage/g)].map(e=>Number(e[1]));t(a).toEqual([1,2,3,4,5,6,7,8]),t(e).toContain("optionalStates[48u + particleIndex]"),t(e).toContain("optionalStates[64u + particleIndex]"),t(e).not.toMatch(/@binding\((9|10|11)\)/)}),a("lays out optional state regions without overlap",()=>{t(g({capacity:16,particleTransforms:!0,vortex:!0,disperse:!0,trailHistoryEntries:20})).toEqual({vortexOffset:48,disperseOffset:64,collisionEventOffset:80,trailOffset:80,entries:100,bytes:1600})}),a("materializes decal outputs as shared unit-box mesh render classes",async()=>{const e=D("decal");e.output={type:"decal",space:"world",colorMap:"",alphaMap:"",color:"#ffffff",unlit:!0,intensity:1,blendingMode:"normal",bloom:!1};const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),c=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(c.renderClasses[0].meshVertices?.length).toBe(288),t(c.renderClasses[0].material).toBe(r)}),a("materializes generated shape outputs through the shared mesh path",async()=>{const e=D("shape");e.output={type:"shape",shape:"box",params:{},material:"shape-material",materialSource:"material",space:"world"};const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),c=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(c.renderClasses[0].meshVertices?.length).toBe(288)}),a("forwards the physical particle index to fragment-stage model-matrix graphs",()=>{const e=w(new i({color:s(n(1),1)})),a=x(e,!0);t(a).toContain("color: vec4<f32>, instanceIndex: u32"),t(a).toContain("gpuParticle.color, particleIndex)"),t(a).toContain("@interpolate(flat) instanceIndex: u32"),t(a).toContain("output.instanceIndex = particleIndex")}),a("compiles soft default sprites with a pay-for-play scene-depth requirement",async()=>{const e=D("soft-sprite",L({softness:2})),a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const i=(await u(a.renderClasses,{getTexture:async()=>new o.Texture})).renderClasses[0].material,n=w(i);t(Object.values(n.sceneTextures)).toContain("sceneDepth")}),a("disables depth writes only for transparent custom GPU sprite materials",async()=>{const e=D("graph-sprite",L({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"graph"}})),a=d(W(e),[new o.Vector3]),r=new i({color:s(n(1),.5),transparent:!0}),c=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(c.renderClasses[0].material).toBe(r),t(r.depthWrite).toBe(!1),t(w(r).renderState.depthWrite).toBe(!1);const l=new i({color:s(n(1),1)}),p=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>l);t(p.renderClasses[0].material).toBe(l),t(l.depthWrite).toBe(!0),t(w(l).renderState.depthWrite).toBe(!0)}),a("supports local-space sprite render classes through the shared emitter transform table",async()=>{const e=D("local-sprite",L({space:"local"})),a=d(W(e),[new o.Vector3(4,5,6)]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].localSpace).toBe(!0),t(a.emitters[0].position.toArray()).toEqual([0,0,0]);const i=await u(a.renderClasses,{getTexture:async()=>new o.Texture});t(i.renderClasses[0].localSpace).toBe(!0)}),a("falls back before rendering when an authored graph cannot be resolved",async()=>{const e=D("graph",L({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"missing-graph"}})),a=await y({asset:W(e),instancePositions:[new o.Vector3],assets:{getTexture:async()=>new o.Texture}});t(a.backend).toBe("cpu"),t(a.runtime).toBeNull(),t(a.reasons[0]).toContain("GPU material compilation failed")}),a("compiles default flipbook, texture, UV, and particle semantics to WGSL",async()=>{const e=D("flipbook",L({flipbook:{enabled:!0,rows:4,columns:4,fps:12,mode:"loop"}})),a=d(W(e),[new o.Vector3]),i=await u(a.renderClasses,{getTexture:async()=>new o.Texture}),n=w(i.renderClasses[0].material);t(n.fragmentMain).toContain("textureSample"),t(`${n.vertexMain}\n${n.fragmentMain}`).toContain("gpuParticle.positionAge.w"),t(n.varyings.length).toBeGreaterThan(0)}),a("supports component scale-over-life for sprites",()=>{const e=D("stretched-smoke");e.behaviours.push({id:"stretch",type:"scaleComponents",enabled:!0,params:{a:k([.25,1,1]),b:k([2,.5,1]),ease:{type:22,value:"Linear"}}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].meshScaleStart.toArray()).toEqual([.25,1,1]),t(a.emitters[0].meshScaleEnd.toArray()).toEqual([2,.5,1])}),a("compiles authored scale and rotation input bindings",()=>{const e=D("input-driven",U());e.initializers.push({id:"scale",type:"scale",enabled:!0,params:{scale:{...G(1),binding:{type:"vfxInput",inputId:"scale-input"}}}},{id:"rotation",type:"rotation",enabled:!0,params:{rotation:{...k([0,-100,0]),binding:{type:"vfxInput",inputId:"rotation-input"}}}}),e.behaviours.push({id:"rotate",type:"rotate",enabled:!0,params:{rotation:{...k([0,-30,0]),binding:{type:"vfxInput",inputId:"step-input"}}}});const a=W(e);a.vfx.inputs=[{id:"scale-input",name:"Scale",type:"number",defaultValue:1},{id:"rotation-input",name:"Rotation",type:"vec3",defaultValue:[0,-100,0]},{id:"step-input",name:"Rotation step",type:"vec3",defaultValue:[0,-30,0]}];const i=d(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.inputBindings.map(e=>e.target)).toEqual(["initializer:scale:scale","initializer:rotation:rotation","behaviour:rotate:rotation"])})});/*
1
+ import{describe as e,expect as t,it as a}from"vitest";import*as o from"three";import{NodeShaderMaterial as i,rgb as n,rgba as s,standardMaterial as r,textureSampler2d as l,varyingAttributes as c,vec3 as p}from"three-shader-graph";import{SerializedParamType as m}from"../scene/model.js";import{compileGpuSpriteEmitters as d,materializeGpuSpriteRenderClasses as u,selectVfxBackend as h}from"../effects/vfx/gpu-sprite-compiler.js";import{prepareGpuSpriteBackend as y}from"../effects/vfx/gpu-sprite-backend.js";import{calculateGpuSpriteOptionalStateLayout as g,GPU_TRAIL_RENDER_CLASS_BYTES as f,GpuSpriteDirtyRange as b,getCompiledGpuSpriteMaterial as w,getGpuSpriteComputeWgsl as C,getGpuSpriteMaterialWgsl as v,getGpuTrailMaterialWgsl as x}from"../effects/vfx/gpu-sprite-runtime.js";import{Trail as B,trailUV as S}from"../effects/vfx/trail-renderer.js";import{timeUniforms as V}from"../shader-nodes/time.js";import{GpuSpriteSchedulerRuntime as T}from"../effects/vfx/gpu-sprite-scheduler-runtime.js";import{GpuSpriteTemplateCache as M,instantiateGpuSpriteTemplate as I}from"../effects/vfx/gpu-sprite-template.js";import{GpuSpriteWorldRuntime as E}from"../effects/vfx/gpu-sprite-world-runtime.js";import{GpuSpriteRenderClassCache as P}from"../effects/vfx/gpu-sprite-compiler.js";import{addGpuSpriteRenderGraphPasses as z}from"../effects/vfx/gpu-sprite-render-graph.js";import{gpuVfxCapacityClass as j}from"../effects/vfx/gpu-vfx-capacity.js";import{buildParticleLightingShaderHeader as q}from"@hology/webgpu-renderer";import{sampleSceneColor as O}from"../shader-nodes/scene-sample.js";import{screenUV as A}from"../shader-nodes/depth.js";import{particleUniforms as R}from"../shader-nodes/particle.js";const G=e=>({type:m.Number,value:e}),k=e=>({type:m.Vector3,value:e});function _(e={}){return{type:"sprite",texture:"dot",color:"#ffffff",intensity:1,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}function L(e={}){return{type:"mesh",assetId:"simple-mesh",space:"world",...e}}function U(e={}){return{type:"trail",length:10,width:.5,taper:!0,color:"#40a0ff",colorEnd:"#ff4080",intensity:2,intensityEnd:.25,opacityStart:1,opacityEnd:0,bloom:!1,billboard:!0,opacityChannel:"alpha",space:"world",...e}}function D(e,t=_()){return{id:e,name:e,position:[0,0,0],rotation:[0,0,0],rate:{type:"once",count:2,delay:0},output:t,initializers:[{id:`${e}-life`,type:"lifetime",enabled:!0,params:{duration:G(2)}},{id:`${e}-position`,type:"positionSphere",enabled:!0,params:{position:k([0,0,0]),radius:G(1),surface:{type:m.Boolean,value:!1}}}],behaviours:[]}}function W(...e){return{id:"test-vfx",name:"test-vfx",type:"vfx",vfx:{emitters:e}}}e("GPU sprite compiler compatibility",()=>{a("bakes the emitter rotation into world-space spawn position, velocity, and mesh rotation",()=>{const e=C(16,!0,!1,!1,!1,!0);t(e).toContain("zoneOffset = worldSpawnDirection(emitter, zoneOffset)"),t(e).toContain("let initialVelocity = worldSpawnDirection(emitter, localInitialVelocity)"),t(e).toContain("worldSpawnRotation(emitter), quaternionFromEulerXYZ(initialRotationEuler)"),t(e).toContain("optionalStates[meshStateOffset], quaternionFromEulerXYZ(rotationDelta)"),t(e).not.toContain("optionalStates[meshStateOffset] = quaternionFromEulerXYZ(rotationEuler)"),t(e).toContain("emitter.trailTransform0 = command.trailTransform0"),t(e).not.toMatch(/__[A-Z_]+__/)}),a("compiles pay-for-play child emitters and screen-space collision metadata",()=>{const e=D("parent");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{bounce:G(.6),friction:G(.2),lifeLoss:G(.5),debug:{type:m.Boolean,value:!0}}});const a=D("child");a.rate={type:"once",count:3,delay:0},a.spawnEvent="collision",e.children=[a];const i=d(W(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters).toHaveLength(2),t(i.emitters[0]).toMatchObject({hasWorldCollision:!0,collisionBounce:.6,collisionFriction:.2,collisionLifeLoss:.5,collisionDebug:!0}),t(i.emitters[1]).toMatchObject({parentEmitter:0,childParticlesPerParent:3,childSpawnEvent:2,childLevel:1,capacity:6});const n=C(16);t(n).not.toContain("collisionDepth"),t(n).not.toMatch(/__[A-Z_]+__/);const s=C(16,!1,!1,!1,!1,!1,!1,0,0,!0,!0,32,1);t(s).toContain("collisionDepth"),t(s).toContain("collisionStep <= 6u"),t(s).toContain("approachingVisibleSurface"),t(s).toContain("optionalStates[32u + owner.padding]"),t(s).not.toMatch(/__[A-Z_]+__/)}),a("supports local-space parent particles spawning local or world-space children",()=>{const e=D("parent",_({space:"local"})),a=D("local-child",_({space:"local"})),i=D("world-child",_({space:"world"}));e.children=[a,i];const n=d(W(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.compatibility.issues).toEqual([]),t(n.emitters.map(e=>e.localSpace)).toEqual([!0,!0,!1]);const s=C(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(s).not.toContain("parentTransform = mat4x4");const r=C(16,!1,!1,!1,!1,!0,!1,0,0,!1,!1,0,1,!0);t(r).toContain("parentTransform = mat4x4"),t(r).toContain("parentEmitter.trail.z > 0.0 && emitter.trail.z < 0.0"),t(r).toContain("childEmissionEnd = parent.velocityLife.w"),t(r).toContain("particle.positionAge = vec4(particle.positionAge.xyz, age)"),t(r).not.toContain("childEventReady = parentAlive"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("rejects world-space parents with local-space child output",()=>{const e=D("parent",_({space:"world"}));e.children=[D("child",_({space:"local"}))];const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues).toContainEqual(t.objectContaining({code:"child-emitter-local-space"}))}),a("orders child render classes by authored render order without changing simulation hierarchy",()=>{const e=D("parent",L({renderOrder:10})),a=D("child",_({renderOrder:-10}));e.children=[a];const i=d(W(e),[new o.Vector3]);t(i.renderClasses.map(e=>e.output.renderOrder)).toEqual([-10,10]),t(i.emitters[0].parentEmitter).toBeUndefined(),t(i.emitters[0].renderClass).toBe(1),t(i.emitters[1]).toMatchObject({parentEmitter:0,renderClass:0})}),a("uses geometric world reservation classes without fixed template maxima",()=>{t([1,2,3,4,5,17].map(j)).toEqual([1,2,4,4,8,32])}),a("compiles stretched sprites into a velocity-aligned render class",async()=>{const e=d(W(D("stretched",function(e={}){return{type:"stretchedSprite",texture:"dot",color:"#ffffff",intensity:1,scale:.5,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}({scale:.75}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await u(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].stretchScale).toBe(.75);const i=v(w(a.renderClasses[0].material),!1);t(i).toContain("stretchedSpriteClipPosition"),t(i).toContain("bitcast<f32>(renderClass.data.w)")}),a("materializes billboard trails with CPU-compatible history sizing",async()=>{const e=d(W(D("trail",U({length:9.6}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.emitters).toHaveLength(1),t(e.emitters[0].capacity).toBe(2);const a=await u(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({historyLength:11,width:.5,taper:!0}),t(f).toBe(80);const i=C(16,!1,!1,!1,!0);t(i).toContain("@group(0) @binding(8) var<storage, read_write> optionalStates"),t(i).not.toContain("@binding(9)")}),a("streams local trail transforms through emitter commands without another storage binding",()=>{const e=d(W(D("attached-trail",U({space:"local"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=C(16,!1,!1,!1,!0,!0);t(a).toContain("trailTransform0: vec4<f32>"),t(a).toContain("emitter.trailTransform3 = command.trailTransform3"),t(a).not.toContain("@binding(12)")}),a("compiles trail graph UV varyings into the procedural ribbon ABI",()=>{const e=new i({color:s(p(S.x,S.y,R.opacity),R.opacity)});B.applyTrailShaderParameters(e);const a=x(e,!0);t(a).toContain("struct GraphVertexInput"),t(a).toContain("nodeVertexID: f32"),t(a).toContain("emitterTransforms"),t(a).not.toContain("__TRAIL_"),t(a).not.toContain("output.uv ="),t(a).not.toContain("output.color ="),t(a).not.toContain("normalize(after - before)"),t(a).not.toContain("2.0, 2.0, 2.0, 0.0")}),a("binds shader graph elapsed time to the GPU particle frame clock",()=>{const e=new i({color:s(p(V.elapsed),1)}),a=x(e),o=v(w(e),!1);t(a).toContain("frame.time"),t(o).toContain("frame.time"),t(a).not.toContain("hology_elapsed_time:"),t(o).not.toContain("hology_elapsed_time:")}),a("preserves CPU raw-channel colors for color behaviours",()=>{const e=D("legacy-color",U());e.behaviours.push({id:"color",type:"changeColor",enabled:!0,params:{a:{type:m.Color,value:"#918383"},b:{type:m.Color,value:"#4f4f4f"}}});const a=d(W(e),[new o.Vector3]);t(a.emitters[0].colorStart.toArray()).toEqual([145/255,131/255,131/255]),t(a.emitters[0].colorEnd.toArray()).toEqual([79/255,79/255,79/255])}),a("materializes one mesh geometry and rejects mesh hierarchies with multiple geometries",async()=>{const e=D("mesh",L({space:"local"}));e.initializers.push({id:"mesh-scale-components",type:"scaleComponents",enabled:!0,params:{scale:k([1,2,3])}},{id:"mesh-rotation",type:"rotation",enabled:!0,params:{rotation:k([30,45,60])}}),e.behaviours.push({id:"mesh-scale-components-behaviour",type:"scaleComponents",enabled:!0,params:{a:k([2,1,1]),b:k([1,.5,2])}});const a=W(e),r=d(a,[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.renderClasses[0].output.type).toBe("mesh"),t(r.emitters[0].localSpace).toBe(!0),t(r.emitters[0].meshScaleStart.toArray()).toEqual([2,2,3]),t(r.emitters[0].meshScaleEnd.toArray()).toEqual([1,1,6]),t(r.emitters[0].meshRotation.w).not.toBe(1),t(r.emitters[0].meshRotationEuler.toArray()).toEqual([o.MathUtils.degToRad(30),o.MathUtils.degToRad(45),o.MathUtils.degToRad(60)]);const l=new o.Mesh(new o.BoxGeometry(1,1,1),new o.MeshBasicMaterial({color:3368601,opacity:.75})),c=await u(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l});t(c.renderClasses[0].meshVertices?.length).toBe(288),t(c.renderClasses[0].meshColor?.getHex()).toBe(3368601),t(c.renderClasses[0].meshOpacity).toBe(.75),t(c.renderClasses[0].localSpace).toBe(!0);const p=new i({color:s(n(.4),1)}),m=await u(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l},async()=>p);t(m.renderClasses[0].material).toBe(p);const h=new o.Group;h.add(new o.Mesh(new o.BoxGeometry),new o.Mesh(new o.BoxGeometry)),await t(u(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>h})).rejects.toThrow("exactly one mesh geometry")}),a("keeps an unconfigured mesh output GPU-compatible without rendering it",()=>{const e=d(W(D("unconfigured-mesh",L({assetId:null}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.compatibility.issues).toEqual([]),t(e.emitters).toEqual([]),t(e.renderClasses).toEqual([])}),a("treats an unselected mesh material override as the default material source",async()=>{const e=d(W(D("unselected-mesh-material",L({materialSource:"material",material:void 0}))),[new o.Vector3]),a=new i({color:s(n(.4),1)}),r=await u(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)},async()=>a);t(e.compatibility.compatible).toBe(!0),t(r.renderClasses[0].material).toBe(a)}),a("keeps an unselected mesh shader graph on the GPU default material",async()=>{const e=d(W(D("unselected-mesh-graph",L({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:null}}))),[new o.Vector3]),a=await u(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)});t(e.compatibility.compatible).toBe(!0),t(()=>w(a.renderClasses[0].material)).not.toThrow()}),a("gates custom mesh materials on a compatible GPU material resolver",async()=>{const e=d(W(D("mesh-material",L({material:"material-id"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a={getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)};await t(u(e.renderClasses,a)).rejects.toThrow("compatible GPU material resolver");const n=new o.Texture,s=await u(e.renderClasses,a,async()=>new i({color:l(n).sample(c.uv)}));t(s.renderClasses[0].material).toBeInstanceOf(i);const r=w(s.renderClasses[0].material);t(Object.values(r.textures)).toEqual([n])}),a("materializes non-billboard trails with orientation history",async()=>{const e=d(W(D("oriented-trail",U({billboard:!1}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await u(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({billboard:!1});const i=C(16,!0,!1,!1,!0);t(i).toContain("trailSampleOffset + 1u"),t(i).toContain("quaternionMultiply(particleRotation, movementRotation)");const n=x(B.createBaseMaterial(),!1);t(n).toContain("renderClass.trail.w == 0u"),t(n).toContain("historySide(state, control)")}),a("changes the mesh render class when shader graph parameters change",()=>{const e={source:"asset",assetId:"mesh-graph"},a=d(W(D("mesh",L({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:G(1)}}))),[new o.Vector3]),i=d(W(D("mesh",L({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:G(2)}}))),[new o.Vector3]);t(a.renderClasses[0].key).not.toBe(i.renderClasses[0].key)}),a("preserves legacy mesh geometry transforms when requested",async()=>{const e=new o.Group,a=new o.Mesh((new o.BufferGeometry).setAttribute("position",new o.Float32BufferAttribute([1,0,0],3)));a.position.set(2,0,0),e.add(a);const i=d(W(D("modern",L())),[new o.Vector3]),n=d(W(D("legacy",L({useLegacyTransform:!0}))),[new o.Vector3]);t(i.renderClasses[0].key).not.toBe(n.renderClasses[0].key);const s={getTexture:async()=>new o.Texture,getMesh:async()=>e},r=await u(i.renderClasses,s),l=await u(n.renderClasses,s);t(r.renderClasses[0].meshVertices?.[0]).toBe(3),t(l.renderClasses[0].meshVertices?.[0]).toBe(1)}),a("does not reuse render classes across explicit template revisions",async()=>{const e=W(D("revisioned")),a={assets:{getTexture:async()=>new o.Texture},renderClassCache:new P},i=await(new M).resolve(e,{...a,revision:1}),n=await(new M).resolve(e,{...a,revision:2});t(i.template?.renderClasses[0]).not.toBe(n.template?.renderClasses[0])}),a("retains renderer-owned scene texture semantics for particle materials",()=>{const e=new i({color:O(A)}),a=w(e);t(Object.values(a.sceneTextures)).toEqual(["sceneColor"])}),a("retains the shared renderer geometry semantics for lit particle materials",()=>{const e=w(new i({color:r({color:n(6724044),roughness:.5,metalness:.1})}));t(e.needsWorldPosition).toBe(!0),t(e.needsWorldNormal).toBe(!0),t(e.requiresRendererShaderHeader).toBe(!0),t(e.fragmentMain).toContain("rendererDirectionalLightForLight"),t(q()).toContain("fn rendererCameraPosition()");const a=v(e,!0);t(a).toContain("fn rendererCameraPosition()"),t(a).toContain("rendererCameraPosition() - input.worldPosition"),t(a).toContain("@group(1) @binding(0)"),t(a).toContain("emitterTransform[0].xyz"),t(a).not.toContain("mat3x3<f32>(emitterTransforms["),t(a).toContain("fn normalMatrixFromLinearTransform"),t(a).not.toContain("inverse("),t(e.renderState.transparent).toBe(!1),t(e.renderState.depthWrite).toBe(!0),t(e.renderState.side).toBe(o.FrontSide)}),a("registers one ordered compute/render pair for a shared runtime",()=>{const e=[];let a;z({mainDepth:"depth",addPass:t=>e.push(t)},{executeCompute:()=>{},executeRender:(e,t)=>{a=t},getSceneTextureReads:()=>["sceneColorSnapshot"]},"color","WorldVfx"),t(e.map(e=>e.name)).toEqual(["WorldVfxSimulation","WorldVfxRender"]),t(e[1].dependsOn).toEqual(["WorldVfxSimulation"]),t(e[1].inputs).toMatchObject({sceneColorSnapshot:"sceneColorSnapshot"}),e[1].execute({}),t(a).toBe("color")}),a("coalesces touched emitter slots without allocating per mark",()=>{const e=new b;e.mark(7),e.mark(3),e.mark(5),t(e).toMatchObject({start:3,end:8,length:5}),e.clear(),t(e.length).toBe(0)}),a("generates particle-owner WGSL without reserved active identifiers",()=>{const e=C(16);t(e).toContain("enabled: u32"),t(e).not.toMatch(/\bactive\s*:/),t(e).not.toContain("owner.active"),t(e).toContain("fn applyEmitterCommandsMain"),t(e).toContain("@group(0) @binding(7) var<storage, read> emitterCommands"),t(e).toContain("var<storage, read_write> emitters"),t(e).toContain("timing: vec4<f32>"),t(e).toContain("let emitterDelta = select(frame.delta * emitter.timing.w, 0.0"),t(e).toContain("let sampledLife = mix(emitter.emission.z, emitter.emission.w"),t(e).toContain("particle.velocityLife = vec4(initialVelocity, particleLife)"),t(e).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, hash(particleSeed + 71u))"),t(e).toContain("select(animatedColor, particle.color.rgb, emitter.meshScaleStart.w > 0.5)")}),a("compiles random lifetime as a per-particle birth range",()=>{const e=D("random-life");e.initializers[0]={id:"random-life-range",type:"randomLifetime",enabled:!0,params:{minDuration:G(.5),maxDuration:G(2.5)}};const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({lifeMin:.5,life:2.5})}),a("compiles random initial color without lifetime interpolation",()=>{const e=D("random-color");e.initializers.push({id:"random-color-range",type:"randomColor",enabled:!0,params:{a:{type:m.Color,value:"#ff0000"},b:{type:m.Color,value:"#0000ff"}}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].randomColor).toBe(!0)}),a("compiles point, box, line, ring, and sphere-surface spawn zones",()=>{const e=[["positionPoint",{position:k([1,2,3])},0],["positionSphere",{position:k([1,2,3]),radius:G(2),surface:{type:m.Boolean,value:!0}},1],["positionBox",{position:k([1,2,3]),dimensions:k([4,5,6])},2],["positionLine",{a:k([1,2,3]),b:k([4,5,6])},3],["positionRing",{position:k([1,2,3]),rotation:k([10,20,30]),radius:G(4),thickness:G(.5),angle:G(180)},4]];for(const[a,i,n]of e){const e=D(`zone-${a}`);e.initializers[1]={id:`zone-${a}`,type:a,enabled:!0,params:i};const s=d(W(e),[new o.Vector3]);t(s.compatibility.compatible).toBe(!0),t(s.emitters[0].spawnZoneType).toBe(n)}const a=C(16);t(a).toContain("fn rotateSpawnPoint"),t(a).toContain("if (zoneType == 4u)")}),a("preserves legacy per-update sprite rotation behavior",()=>{const e=D("sprite-rotate");e.behaviours.push({id:"rotate-add",type:"rotateAdd",enabled:!0,params:{rotation:k([0,0,200])}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].spriteRotationStep).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const i=C(16);t(i).toContain("var rotation = particle.color.w;"),t(i).toContain("rotation += emitter.rotationInitialSpriteStep.w * frame.padding1"),e.output=L();const n=d(W(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.emitters[0].meshRotationStep.z).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const s=C(16,!0);t(s).toContain("rotationEuler: vec4<f32>"),t(s).toContain("fn quaternionFromEulerXYZ"),t(s).toContain("emitter.rotationStep.xyz * frame.padding1"),e.initializers.push({id:"random-mesh-rotation",type:"randomRotation",enabled:!0,params:{axis:{type:m.String,value:"Y"}}});const r=d(W(e),[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.emitters[0].randomMeshRotationAxis).toBe(3),t(s).toContain("randomRotationAxis == 1u"),t(s).toContain("randomRotationAxis == 4u")}),a("compiles radial velocity without per-particle auxiliary storage",()=>{const e=D("radial-velocity");e.initializers.push({id:"radial",type:"radialVelocity",enabled:!0,params:{speed:G(4),direction:k([0,1,0]),spread:G(30)}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].hasRadialVelocity).toBe(!0),t(a.emitters[0].radialVelocitySpeed).toBe(4),t(a.emitters[0].radialVelocitySpread).toBeCloseTo(Math.PI/6);const i=C(16);t(i).toContain("fn radialVelocity"),t(i).toContain("emitter.radialMetadata.y > 0.5")}),a("compiles one attraction or repulsion field per emitter",()=>{const e=D("attraction");e.behaviours.push({id:"attract",type:"attraction",enabled:!0,params:{target:k([1,2,3]),force:G(4),radius:G(5)}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].attractionTarget.toArray()).toEqual([1,2,3]),t(a.emitters[0].attractionForce).toBe(4),e.behaviours[0].type="repulsion";const i=d(W(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].attractionForce).toBe(-4);const n=C(16);t(n).toContain("attractionDistanceSq < attractionRadiusSq")}),a("compiles mesh alignment modes into spawn-only parameters",()=>{const e=D("aligned-mesh",L());e.initializers.push({id:"align",type:"alignRotation",enabled:!0,params:{axis:{type:m.String,value:"+X"},mode:{type:m.String,value:"AlongVelocity"},offset:k([10,20,30]),center:k([1,2,3])}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].alignRotationAxis.toArray()).toEqual([1,0,0]),t(a.emitters[0].alignRotationMode).toBe(3),t(a.emitters[0].alignRotationOffset.x).toBeCloseTo(o.MathUtils.degToRad(10));const i=C(16,!0);t(i).toContain("fn quaternionFromUnitVectors"),t(i).toContain("alignRotationMode == 3u")}),a("groups authored render classes deterministically",()=>{const e=d(W(D("a"),D("b"),D("c",_({blendingMode:"normal"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.renderClasses).toHaveLength(2),t(e.emitters.map(e=>e.renderClass)).toEqual([0,0,1])}),a("can lay out emitter allocations contiguously per dynamic effect instance",()=>{const e=W(D("first"),D("second",_({blendingMode:"normal"}))),a=[new o.Vector3,new o.Vector3(1,0,0)],i=d(e,a),n=d(e,a,"instance-major");t(i.emitters.map(e=>e.renderClass)).toEqual([0,0,1,1]),t(n.emitters.map(e=>e.renderClass)).toEqual([0,1,0,1])}),a("reports sparse active-page simulation capacity",async()=>{const e=await y({asset:W(D("paged")),instancePositions:[new o.Vector3,new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:2,assets:{getTexture:async()=>new o.Texture}});e.runtime.setActivePages([0,2]),t(e.runtime.getStats()).toMatchObject({capacity:6,activePageCount:2,simulatedCapacity:4})}),a("compacts active page padding out of the simulation schedule",async()=>{const e=await T.create({asset:W(D("tiny-paged-effect")),maximumInstances:2,particlePageSize:256,assets:{getTexture:async()=>new o.Texture}});e.spawn(new o.Vector3),e.flushActivePages(),t(e.runtime.getStats()).toMatchObject({capacity:512,activePageCount:1,simulatedCapacity:2});const a=C(256);t(a).toContain("let particleIndex = activeParticleIndices[id.x]"),t(a).not.toContain("id.x % pageSize"),e.dispose()}),a("sizes alive indices by logical particles instead of render-class count",async()=>{const e=await y({asset:W(D("additive"),D("normal",_({blendingMode:"normal"}))),instancePositions:[new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:4,assets:{getTexture:async()=>new o.Texture}});t(e.runtime.getStats()).toMatchObject({capacity:8,renderClassCount:2,aliveIndexCapacity:8,aliveIndexBytes:32});const a=C(4);t(a).toContain("aliveIndices[indirect[renderClass].firstInstance + outputIndex]"),t(a).not.toContain("renderClass * u32(frame.capacity)")}),a("allocates and recycles multi-page dynamic effect instances",async()=>{const e=D("paged-instance");e.position=[2,0,0];const a=await T.create({asset:W(e),maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),i=a.spawn(new o.Vector3);t(a.runtime.emitters[0].position.x).toBe(2),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),a.update(.1),t(a.scheduler.getInstance(i.handle)).not.toBeNull(),t(a.play(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!1),t(a.setTimescale(i,2)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(2),t(a.stop(i)).toBe(!0),t(a.runtime.emitters[0].stopTime).toBeGreaterThanOrEqual(0),t(a.runtime.emitters[0].expireWithin).toBe(-1),t(i.pages).toHaveLength(2),a.flushActivePages(),t(a.runtime.getStats()).toMatchObject({activePageCount:2,simulatedCapacity:2}),t(a.destroy(i)).toBe(!0);const n=a.spawn(new o.Vector3(1,0,0));t(new Set(n.pages)).toEqual(new Set(i.pages)),t(n.handle.generation).not.toBe(i.handle.generation),a.dispose()}),a("caps GPU particle drain time for expire and kill lifecycle behavior",async()=>{const e=await T.create({asset:W(D("expiring")),maximumInstances:2,assets:{getTexture:async()=>new o.Texture}}),a=e.spawn(new o.Vector3);t(e.stop(a,.5)).toBe(!0),t(e.runtime.emitters[0].expireWithin).toBe(.5);for(let t=0;t<4;t++)e.update(.1);t(e.scheduler.getInstance(a.handle)).not.toBeNull(),e.update(.1),t(e.scheduler.getInstance(a.handle)).toBeNull();const i=e.spawn(new o.Vector3);t(e.stop(i,0)).toBe(!0),e.update(.001),t(e.scheduler.getInstance(i.handle)).toBeNull(),e.dispose()}),a("rejects a template pool before allocation when its storage budget is exceeded",async()=>{const e=await T.create({asset:W(D("over-budget")),maximumInstances:128,particlePageSize:1,maximumStorageBytes:1,assets:{getTexture:async()=>new o.Texture}});t(e).toBeNull()}),a("compiles delayed and finite continuous emission timing",()=>{const e=D("delayed");e.rate={type:"once",count:2,delay:.25};const a=D("finite");a.rate={type:"continuous",count:2,time:.1,delay:0,duration:1};const i=d(W(e,a),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate).toMatchObject({type:"once",delay:.25}),t(i.emitters[1].rate).toMatchObject({type:"continuous",delay:0,duration:1})}),a("treats zero continuous duration as unlimited like the CPU rate",()=>{const e=D("unlimited");e.rate={type:"continuous",count:2,time:.1,delay:0,duration:0};const a=d(W(e),[new o.Vector3]);t(a.compatibility.issues.map(e=>e.code)).not.toContain("rate-duration")}),a("compiles bound lifetime metadata but falls back for unsupported input targets",()=>{const e=W(D("input-test"));e.vfx.inputs=[{id:"speed",name:"Speed",type:"number",defaultValue:1}],t(d(e,[new o.Vector3]).compatibility.compatible).toBe(!0),e.vfx.emitters[0].initializers[0].params.duration.binding={type:"vfxInput",inputId:"speed"};const a=d(e,[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.inputBindings).toEqual([t.objectContaining({inputId:"speed",emitterIndex:0,target:"initializer:lifetime:duration"})]),e.vfx.emitters[0].initializers.push({id:"bound-velocity",type:"velocity",enabled:!0,params:{velocity:{type:m.Vector3,value:[0,1,0],binding:{type:"vfxInput",inputId:"speed"}}}}),t(d(e,[new o.Vector3]).compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding")}),a("falls back for a bound continuous lifetime until particle capacity can grow",()=>{const e=D("continuous-bound-life");e.rate={type:"continuous",count:1,time:.1,delay:0,duration:0},e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"};const a=W(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:2}];const i=d(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding"),t(i.inputBindings).toEqual([])}),a("caches compiled templates while keeping instance emitter state independent",async()=>{const e=W(D("cached-template")),a=new M;let i=0;const n={assets:{getTexture:async()=>(i+=1,new o.Texture)}},s=await a.resolve(e,n),r=await a.resolve(e,n);t(r.template).toBe(s.template),t(i).toBe(1),t(a.getStats()).toMatchObject({builds:1,hits:1});const l=I(s.template,[new o.Vector3(1,0,0),new o.Vector3(2,0,0)]);t(l[0].position).not.toBe(l[1].position),l[0].position.x=100,t(l[1].position.x).toBe(2),t(s.template.emitters[0].position.x).toBe(0),a.invalidate(e),await a.resolve(e,n),t(a.getStats()).toMatchObject({builds:2,hits:1,invalidations:1}),await a.resolve(e,{...n,revision:2}),t(a.getStats()).toMatchObject({builds:3,hits:1,invalidations:2})}),a("applies a bound lifetime only to the addressed scheduled instance",async()=>{const e=D("bound-life");e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"},e.initializers[1].params.radius.binding={type:"vfxInput",inputId:"radius"},e.initializers.push({id:"velocity",type:"velocity",enabled:!0,params:{velocity:{type:m.Vector3,value:[0,0,0],binding:{type:"vfxInput",inputId:"velocity"}}}});const a=W(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:1},{id:"radius",name:"Radius",type:"number",defaultValue:1},{id:"velocity",name:"Velocity",type:"vec3",defaultValue:[0,0,0]}];const i=await T.create({asset:a,maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),n=i.spawn(new o.Vector3),s=i.spawn(new o.Vector3);t(i.setInput(n,"life",4)).toBe(!0),t(i.runtime.emitters[0].life).toBe(4),t(i.runtime.emitters[1].life).toBe(2),t(i.setInput(n,"radius",3)).toBe(!0),t(i.runtime.emitters[0].positionRadius).toBe(3),t(i.setInput(n,"velocity",new o.Vector3(1,2,3))).toBe(!0),t(i.runtime.emitters[0].velocityMin.toArray()).toEqual([1,2,3]),t(i.setInput(s,"missing",5)).toBe(!1),i.dispose()}),a("schedules different templates in one arena and merges shared render classes",async()=>{const e=_(),a=await E.create({templates:[{asset:W(D("world-a",e)),maximumInstances:2},{asset:W(D("world-b",{...e})),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},templateCache:new M,renderClassCache:new P,particlePageSize:1}),i=a.spawn(0,new o.Vector3),n=a.spawn(1,new o.Vector3(1,0,0));a.flushActivePages(),t(a.templateCount).toBe(2),t(a.runtime.getStats()).toMatchObject({renderClassCount:1,activePageCount:4}),t(a.scheduler.getStats()).toMatchObject({activeInstances:2,arena:{usedPages:4}}),t(a.destroy(i)).toBe(!0),t(a.destroy(n)).toBe(!0),a.flushActivePages(),t(a.runtime.hasActiveParticles).toBe(!1),t(a.runtime.getStats()).toMatchObject({aliveParticles:0,activePageCount:0,simulatedCapacity:0}),a.dispose()}),a("routes per-instance lifecycle changes through a shared multi-template world",async()=>{const e=D("world-lifecycle");e.position=[2,0,0];const a=await E.create({templates:[{asset:W(e),maximumInstances:1},{asset:W(D("world-neighbour")),maximumInstances:1}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1}),i=a.spawn(0,new o.Vector3(3,0,0));t(a.runtime.emitters[0].position.x).toBe(5),t(a.setPosition(i,new o.Vector3(7,0,0))).toBe(!0),t(a.runtime.emitters[0].position.x).toBe(9),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),t(a.play(i)).toBe(!0),t(a.setTimescale(i,.5)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(.5),t(a.stop(i,0)).toBe(!0),a.update(.001),t(a.has(i)).toBe(!1),t(a.setPosition(i,new o.Vector3)).toBe(!1),a.dispose()}),a("rejects a shared world before runtime allocation when its storage budget is exceeded",async()=>{const e=await E.create({templates:[{asset:W(D("world-over-budget")),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1,maximumStorageBytes:1});t(e).toBeNull()}),a("falls back to CPU for semantic mismatches",()=>{const e=D("soft-local",_({softness:1,space:"local",lockY:!0}));e.behaviours.push({id:"animation",type:"animation",enabled:!0,params:{}});const a=d(W(e),[new o.Vector3]),i=h(a.compatibility);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues.map(e=>e.code)).toEqual(t.arrayContaining(["behaviour:animation"])),t(i.backend).toBe("cpu"),t(h(a.compatibility,"gpu").backend).toBe("gpu")}),a("compiles vortex with pay-for-play previous-velocity state",()=>{const e=D("vortex");e.behaviours.push({id:"vortex",type:"vortex",enabled:!0,params:{axis:k([0,5,0]),amount:G(1.25)}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].vortexAxis.toArray()).toEqual([0,5,0]),t(a.emitters[0].vortexAmount).toBe(1.25),t(C(16,!1,!1)).not.toContain("vortexVelocities");const i=C(16,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("behaviourVelocity -= optionalStates[0u + particleIndex].xyz")}),a("compiles the remaining initializer and motion behaviours",()=>{const e=D("remaining-motion");e.initializers.push({id:"mass",type:"mass",enabled:!0,params:{min:G(2)}}),e.behaviours.push({id:"drift",type:"randomDrift",enabled:!0,params:{drift:k([1,2,3]),delay:G(.25),ease:{type:m.String,value:"OutQuad"}}},{id:"follow",type:"followParent",enabled:!0,params:{speed:G(4)}},{id:"move",type:"moveTo",enabled:!0,params:{target:k([5,6,7]),ease:{type:m.String,value:"InQuad"}}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({mass:2,randomDriftDelay:.25,randomDriftEasing:2,followParentSpeed:4,hasFollowParent:!0,moveToEasing:1,hasMoveTo:!0}),t(a.emitters[0].randomDrift.toArray()).toEqual([1,2,3]),t(a.emitters[0].moveToTarget.toArray()).toEqual([5,6,7])}),a("compiles authored easing independently for visual behavior channels",()=>{const e=D("easing");e.behaviours.push({id:"scale",type:"scale",enabled:!0,params:{a:G(0),b:G(1),ease:{type:m.String,value:"OutBack"}}},{id:"color",type:"changeColor",enabled:!0,params:{a:{type:m.Color,value:"#ff0000"},b:{type:m.Color,value:"#0000ff"},ease:{type:m.String,value:"InCubic"}}},{id:"opacity",type:"changeOpacity",enabled:!0,params:{a:G(0),b:G(1),ease:{type:m.String,value:"BumpSharp"}}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].curveEasing.toArray()).toEqual([18,4,20,0]);const i=C(16);t(i).toContain("fn applyEasing"),t(i).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, colorProgress)"),t(i).toContain("mix(emitter.scales.xy, emitter.scales.zw, scaleProgress)")}),a("compiles disperse into optional shared particle state",()=>{const e=D("disperse");e.behaviours.push({id:"disperse",type:"disperse",enabled:!0,params:{distance:G(3),axis:{type:m.String,value:"XZ"},ease:{type:m.String,value:"InOutSine"}}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].disperseDistance).toBe(3),t(a.emitters[0].dispersePlane).toBe(2),t(a.emitters[0].disperseEasing).toBe(12),t(C(16)).not.toContain("disperseStates");const i=C(16,!1,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("optionalStates[0u + particleIndex].w = disperseProgress")}),a("consolidates every optional simulation feature into the eighth storage binding",()=>{const e=C(16,!0,!0,!0,!0,!1,!1,48,64),a=[...e.matchAll(/@group\(0\) @binding\((\d+)\) var<storage/g)].map(e=>Number(e[1]));t(a).toEqual([1,2,3,4,5,6,7,8]),t(e).toContain("optionalStates[48u + particleIndex]"),t(e).toContain("optionalStates[64u + particleIndex]"),t(e).not.toMatch(/@binding\((9|10|11)\)/)}),a("lays out optional state regions without overlap",()=>{t(g({capacity:16,particleTransforms:!0,vortex:!0,disperse:!0,trailHistoryEntries:20})).toEqual({vortexOffset:48,disperseOffset:64,collisionEventOffset:80,trailOffset:80,entries:100,bytes:1600})}),a("materializes decal outputs as shared unit-box mesh render classes",async()=>{const e=D("decal");e.output={type:"decal",space:"world",colorMap:"",alphaMap:"",color:"#ffffff",unlit:!0,intensity:1,blendingMode:"normal",bloom:!1};const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].meshVertices?.length).toBe(288),t(l.renderClasses[0].material).toBe(r)}),a("materializes generated shape outputs through the shared mesh path",async()=>{const e=D("shape");e.output={type:"shape",shape:"box",params:{},material:"shape-material",materialSource:"material",space:"world"};const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].meshVertices?.length).toBe(288)}),a("forwards the physical particle index to fragment-stage model-matrix graphs",()=>{const e=w(new i({color:s(n(1),1)})),a=v(e,!0);t(a).toContain("color: vec4<f32>, instanceIndex: u32"),t(a).toContain("gpuParticle.color, particleIndex)"),t(a).toContain("@interpolate(flat) instanceIndex: u32"),t(a).toContain("output.instanceIndex = particleIndex")}),a("compiles soft default sprites with a pay-for-play scene-depth requirement",async()=>{const e=D("soft-sprite",_({softness:2})),a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const i=(await u(a.renderClasses,{getTexture:async()=>new o.Texture})).renderClasses[0].material,n=w(i);t(Object.values(n.sceneTextures)).toContain("sceneDepth")}),a("disables depth writes only for transparent custom GPU sprite materials",async()=>{const e=D("graph-sprite",_({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"graph"}})),a=d(W(e),[new o.Vector3]),r=new i({color:s(n(1),.5),transparent:!0}),l=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].material).toBe(r),t(r.depthWrite).toBe(!1),t(w(r).renderState.depthWrite).toBe(!1);const c=new i({color:s(n(1),1)}),p=await u(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>c);t(p.renderClasses[0].material).toBe(c),t(c.depthWrite).toBe(!0),t(w(c).renderState.depthWrite).toBe(!0)}),a("supports local-space sprite render classes through the shared emitter transform table",async()=>{const e=D("local-sprite",_({space:"local"})),a=d(W(e),[new o.Vector3(4,5,6)]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].localSpace).toBe(!0),t(a.emitters[0].position.toArray()).toEqual([0,0,0]);const i=await u(a.renderClasses,{getTexture:async()=>new o.Texture});t(i.renderClasses[0].localSpace).toBe(!0)}),a("falls back before rendering when an authored graph cannot be resolved",async()=>{const e=D("graph",_({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"missing-graph"}})),a=await y({asset:W(e),instancePositions:[new o.Vector3],assets:{getTexture:async()=>new o.Texture}});t(a.backend).toBe("cpu"),t(a.runtime).toBeNull(),t(a.reasons[0]).toContain("GPU material compilation failed")}),a("compiles default flipbook, texture, UV, and particle semantics to WGSL",async()=>{const e=D("flipbook",_({flipbook:{enabled:!0,rows:4,columns:4,fps:12,mode:"loop"}})),a=d(W(e),[new o.Vector3]),i=await u(a.renderClasses,{getTexture:async()=>new o.Texture}),n=w(i.renderClasses[0].material);t(n.fragmentMain).toContain("textureSample"),t(`${n.vertexMain}\n${n.fragmentMain}`).toContain("gpuParticle.positionAge.w"),t(n.varyings.length).toBeGreaterThan(0)}),a("supports component scale-over-life for sprites",()=>{const e=D("stretched-smoke");e.behaviours.push({id:"stretch",type:"scaleComponents",enabled:!0,params:{a:k([.25,1,1]),b:k([2,.5,1]),ease:{type:22,value:"Linear"}}});const a=d(W(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].meshScaleStart.toArray()).toEqual([.25,1,1]),t(a.emitters[0].meshScaleEnd.toArray()).toEqual([2,.5,1])}),a("compiles authored scale and rotation input bindings",()=>{const e=D("input-driven",L());e.initializers.push({id:"scale",type:"scale",enabled:!0,params:{scale:{...G(1),binding:{type:"vfxInput",inputId:"scale-input"}}}},{id:"rotation",type:"rotation",enabled:!0,params:{rotation:{...k([0,-100,0]),binding:{type:"vfxInput",inputId:"rotation-input"}}}}),e.behaviours.push({id:"rotate",type:"rotate",enabled:!0,params:{rotation:{...k([0,-30,0]),binding:{type:"vfxInput",inputId:"step-input"}}}});const a=W(e);a.vfx.inputs=[{id:"scale-input",name:"Scale",type:"number",defaultValue:1},{id:"rotation-input",name:"Rotation",type:"vec3",defaultValue:[0,-100,0]},{id:"step-input",name:"Rotation step",type:"vec3",defaultValue:[0,-30,0]}];const i=d(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.inputBindings.map(e=>e.target)).toEqual(["initializer:scale:scale","initializer:rotation:rotation","behaviour:rotate:rotation"])})});/*
2
2
  * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
3
  * See the LICENSE.md file for details.
4
4
  */
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=materializer-dynamic-transform.test.d.ts.map
@@ -0,0 +1,4 @@
1
+ import{Subject as e}from"rxjs";import{Group as t,Object3D as n,Quaternion as s,Scene as o,Vector3 as a}from"three";import{describe as i,expect as r,it as c,vi as d}from"vitest";d.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&d.fn()),e[t]),set:(e,t,n)=>(e[t]=n,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),d.mock("../gameplay/actors/builtin/index.js",()=>({default:{}})),d.mock("../effects/vfx/vfx-actor.js",()=>({VfxActor:class{}})),d.mock("../effects/vfx/vfx-param.js",()=>({VisualEffect:class{}})),d.mock("../rendering.js",()=>({RenderingView:class{},setRenderingPaused:d.fn()}));import{PhysicsBodyType as f}from"../gameplay/services/physics/physics-system.js";import{SceneMaterializer as l}from"../scene/materializer.js";function p(e){return{name:e.id,...e}}i("SceneMaterializer dynamic transforms",()=>{c("preserves a nested dynamic object world transform when moving it to the scene root",async()=>{const i=new o,c=function(t){const n={getObjects:()=>[],onCreate:()=>{},onUpdate:()=>{},onRemove:()=>{}},s={onCreate:new e,onDelete:new e,onUpdate:new e,getAsset:d.fn(async()=>null),getAssets:d.fn(async()=>[])};return new l(t,n,s,{},{renderer:{shadowMap:{needsUpdate:!1}}},[],[],{create:async()=>null,initActor:async()=>{}})}(i);c.inEditor=!1;const m=[.2,.7,-.1],y=[0,0,0],u=p({id:"parent",type:"group",position:[10,4,-7],rotation:m,scale:[2,3,4],children:[p({id:"dynamic-child",type:"shape_mesh",shape:"box",position:[3,-2,5],rotation:y,scale:[.5,1.5,2],physics:{type:f.dynamic}})]});await c.materialize(u);const g=i.getObjectByName("dynamic-child");r(g?.parent).toBe(i);const h=new t;h.position.set(10,4,-7),h.rotation.set(...m),h.scale.set(2,3,4);const w=new n;w.position.set(3,-2,5),w.rotation.set(...y),w.scale.set(.5,1.5,2),h.add(w);const v=w.getWorldPosition(new a),j=w.getWorldQuaternion(new s),b=w.getWorldScale(new a);r(g.position.distanceTo(v)).toBeLessThan(1e-6),r(1-Math.abs(g.quaternion.dot(j))).toBeLessThan(1e-6),r(g.scale.distanceTo(b)).toBeLessThan(1e-6)})});/*
2
+ * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
+ * See the LICENSE.md file for details.
4
+ */
@@ -1,4 +1,4 @@
1
- import{Emitter as t,Particle as o}from"@hology/nebula";import{Euler as e,Quaternion as r}from"three";import{describe as n,expect as a,test as i}from"vitest";import{WorldSpaceEmitterRotationInitializer as s}from"../effects/vfx/initializsers.js";n("world-space VFX emitter rotation",()=>{i("composes emitter rotation with the particle local rotation once at spawn",()=>{const n=new t;n.rotation.set(0,Math.PI/2,0);const i=new o;i.rotation.set(Math.PI/6,0,0),(new s).init(n,i);const m=(new r).setFromEuler(new e(i.rotation.x,i.rotation.y,i.rotation.z)),l=(new r).setFromEuler(new e(0,Math.PI/2,0)).multiply((new r).setFromEuler(new e(Math.PI/6,0,0)));a(Math.abs(m.dot(l))).toBeCloseTo(1),n.rotation.set(0,Math.PI,0);const w=(new r).setFromEuler(new e(i.rotation.x,i.rotation.y,i.rotation.z));a(Math.abs(w.dot(l))).toBeCloseTo(1)})});/*
1
+ import{Emitter as o,InitializerUtil as t,Particle as e,Position as i}from"@hology/nebula";import{Euler as n,Quaternion as s}from"three";import{describe as r,expect as a,test as l}from"vitest";import{WorldSpaceEmitterTransformInitializer as p}from"../effects/vfx/initializsers.js";import{PointZone as w}from"../effects/vfx/zones.js";r("world-space VFX emitter rotation",()=>{l("composes emitter rotation with the particle local rotation once at spawn",()=>{const t=new o;t.rotation.set(0,Math.PI/2,0);const i=new e;i.rotation.set(Math.PI/6,0,0),i.position.set(0,0,2),new p(!0).init(t,i),a(i.position.x).toBeCloseTo(2),a(i.position.y).toBeCloseTo(0),a(i.position.z).toBeCloseTo(0);const r=(new s).setFromEuler(new n(i.rotation.x,i.rotation.y,i.rotation.z)),l=(new s).setFromEuler(new n(0,Math.PI/2,0)).multiply((new s).setFromEuler(new n(Math.PI/6,0,0)));a(Math.abs(r.dot(l))).toBeCloseTo(1),t.rotation.set(0,Math.PI,0);const w=(new s).setFromEuler(new n(i.rotation.x,i.rotation.y,i.rotation.z));a(Math.abs(w.dot(l))).toBeCloseTo(1)}),l("rotates an authored point before adding the world-space emitter origin",()=>{const n=new o;n.position.set(10,0,20),n.rotation.set(0,Math.PI/2,0);const s=new e;n.addInitializers([new i(new w(0,0,1.8)),new p(!1)]),t.initialize(n,s,n.initializers),a(s.position.x).toBeCloseTo(11.8),a(s.position.y).toBeCloseTo(0),a(s.position.z).toBeCloseTo(20)})});/*
2
2
  * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
3
  * See the LICENSE.md file for details.
4
4
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hology/core",
3
- "version": "0.0.241",
3
+ "version": "0.0.243",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -239,6 +239,6 @@
239
239
  "terser": "^5.25.0",
240
240
  "typescript": "~5.8.2",
241
241
  "vitest": "^4.1.9",
242
- "@hology/webgpu-renderer": "^0.1.8"
242
+ "@hology/webgpu-renderer": "^0.1.9"
243
243
  }
244
244
  }