@hology/core 0.0.261 → 0.0.262

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/gameplay/index.d.ts +1 -0
  2. package/dist/gameplay/index.js +1 -1
  3. package/dist/gameplay/initiate.js +1 -1
  4. package/dist/gameplay/inject.d.ts +8 -0
  5. package/dist/gameplay/inject.js +1 -1
  6. package/dist/gameplay/services/asset-loader.d.ts +3 -0
  7. package/dist/gameplay/services/asset-loader.js +1 -1
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.js +1 -1
  10. package/dist/rendering/graphics-runtime.d.ts +1 -1
  11. package/dist/rendering/graphics-runtime.js +1 -1
  12. package/dist/rendering/node-material-webgpu-resolver.js +1 -1
  13. package/dist/rendering.d.ts +6 -1
  14. package/dist/rendering.js +1 -1
  15. package/dist/scene/asset-resource-loader.d.ts +3 -0
  16. package/dist/scene/asset-resource-loader.js +1 -1
  17. package/dist/scene/custom-param-deserialize.js +1 -1
  18. package/dist/scene/custom-param-runtime-types.js +1 -1
  19. package/dist/scene/lattice-deformation.d.ts +28 -0
  20. package/dist/scene/lattice-deformation.js +4 -0
  21. package/dist/scene/materializer.d.ts +12 -3
  22. package/dist/scene/materializer.js +1 -1
  23. package/dist/scene/model-ref.d.ts +7 -0
  24. package/dist/scene/model-ref.js +4 -0
  25. package/dist/scene/model.d.ts +2 -1
  26. package/dist/scene/model.js +1 -1
  27. package/dist/shader/parameter.d.ts +2 -1
  28. package/dist/test/graphics-runtime.test.js +1 -1
  29. package/dist/test/lattice-deformation.test.d.ts +2 -0
  30. package/dist/test/lattice-deformation.test.js +4 -0
  31. package/dist/test/materializer-prefetch.test.js +1 -1
  32. package/docs/actor-persistence.md +87 -87
  33. package/package.json +1 -1
  34. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,7 @@
1
+ import type { AssetId } from './model.js';
2
+ /** Stable identity of a model asset; load an instance when it is needed. */
3
+ export declare class ModelRef {
4
+ readonly id: AssetId;
5
+ constructor(id: AssetId);
6
+ }
7
+ //# sourceMappingURL=model-ref.d.ts.map
@@ -0,0 +1,4 @@
1
+ export class ModelRef{constructor(o){this.id=o}}/*
2
+ * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
+ * See the LICENSE.md file for details.
4
+ */
@@ -51,7 +51,8 @@ export declare enum SerializedParamType {
51
51
  Struct = 29,
52
52
  DataAsset = 30,
53
53
  SequenceData = 31,
54
- Scene = 32
54
+ Scene = 32,
55
+ ModelRef = 33
55
56
  }
56
57
  export type VfxInputBinding = {
57
58
  type: 'vfxInput';
@@ -1,4 +1,4 @@
1
- export var DetailTier;!function(e){e[e.low=0]="low",e[e.medium=1]="medium",e[e.high=2]="high"}(DetailTier||(DetailTier={}));export var SerializedParamType;!function(e){e[e.FloatNode=0]="FloatNode",e[e.Number=1]="Number",e[e.Texture=2]="Texture",e[e.Sampler2DNode=3]="Sampler2DNode",e[e.Boolean=4]="Boolean",e[e.BooleanNode=5]="BooleanNode",e[e.Vector2=6]="Vector2",e[e.Vec2Node=7]="Vec2Node",e[e.Vector3=8]="Vector3",e[e.Vec3Node=9]="Vec3Node",e[e.Color=10]="Color",e[e.RgbNode=11]="RgbNode",e[e.String=12]="String",e[e.BaseActor=13]="BaseActor",e[e.Euler=14]="Euler",e[e.Object3D=15]="Object3D",e[e.Material=16]="Material",e[e.AudioBuffer=17]="AudioBuffer",e[e.Vector4=18]="Vector4",e[e.Vec4Node=19]="Vec4Node",e[e.VisualEffect=20]="VisualEffect",e[e.Array=21]="Array",e[e.Curve=22]="Curve",e[e.ColorLayer=23]="ColorLayer",e[e.MaskLayer=24]="MaskLayer",e[e.Prefab=25]="Prefab",e[e.PrefabActor=26]="PrefabActor",e[e.AnimationClip=27]="AnimationClip",e[e.Sequence=28]="Sequence",e[e.Struct=29]="Struct",e[e.DataAsset=30]="DataAsset",e[e.SequenceData=31]="SequenceData",e[e.Scene=32]="Scene"}(SerializedParamType||(SerializedParamType={}));/*
1
+ export var DetailTier;!function(e){e[e.low=0]="low",e[e.medium=1]="medium",e[e.high=2]="high"}(DetailTier||(DetailTier={}));export var SerializedParamType;!function(e){e[e.FloatNode=0]="FloatNode",e[e.Number=1]="Number",e[e.Texture=2]="Texture",e[e.Sampler2DNode=3]="Sampler2DNode",e[e.Boolean=4]="Boolean",e[e.BooleanNode=5]="BooleanNode",e[e.Vector2=6]="Vector2",e[e.Vec2Node=7]="Vec2Node",e[e.Vector3=8]="Vector3",e[e.Vec3Node=9]="Vec3Node",e[e.Color=10]="Color",e[e.RgbNode=11]="RgbNode",e[e.String=12]="String",e[e.BaseActor=13]="BaseActor",e[e.Euler=14]="Euler",e[e.Object3D=15]="Object3D",e[e.Material=16]="Material",e[e.AudioBuffer=17]="AudioBuffer",e[e.Vector4=18]="Vector4",e[e.Vec4Node=19]="Vec4Node",e[e.VisualEffect=20]="VisualEffect",e[e.Array=21]="Array",e[e.Curve=22]="Curve",e[e.ColorLayer=23]="ColorLayer",e[e.MaskLayer=24]="MaskLayer",e[e.Prefab=25]="Prefab",e[e.PrefabActor=26]="PrefabActor",e[e.AnimationClip=27]="AnimationClip",e[e.Sequence=28]="Sequence",e[e.Struct=29]="Struct",e[e.DataAsset=30]="DataAsset",e[e.SequenceData=31]="SequenceData",e[e.Scene=32]="Scene",e[e.ModelRef=33]="ModelRef"}(SerializedParamType||(SerializedParamType={}));/*
2
2
  * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
3
  * See the LICENSE.md file for details.
4
4
  */
@@ -7,11 +7,12 @@ import type { VisualEffect } from '../effects/vfx/vfx-param.js';
7
7
  import type { Prefab, PrefabOf } from '../scene/objects/prefab.js';
8
8
  import type { DataAssetRef } from '../scene/objects/data-asset.js';
9
9
  import type { SceneRef } from '../scene/scene-ref.js';
10
+ import type { ModelRef } from '../scene/model-ref.js';
10
11
  import type { Curve2 } from '../utils/curve.js';
11
12
  import type { ColorLayer, MaskLayer } from './color-layer.js';
12
13
  import type { Sequence } from '../effects/sequence/sequence-data.js';
13
14
  import type { ShaderGraphTarget } from './graph/model.js';
14
- export type KnownParameterType = Type<FloatNode | Number | Texture | Sampler2DNode | Boolean | BooleanNode | Vector2 | Vec2Node | Vector3 | Vec3Node | Vector4 | Vec4Node | Color | RgbNode | String | BaseActor | Euler | Object3D | Material | AnimationClip | VisualEffect | Sequence | Prefab | PrefabOf<BaseActor> | DataAssetRef | SceneRef | Curve2 | ColorLayer | MaskLayer>;
15
+ export type KnownParameterType = Type<FloatNode | Number | Texture | Sampler2DNode | Boolean | BooleanNode | Vector2 | Vec2Node | Vector3 | Vec3Node | Vector4 | Vec4Node | Color | RgbNode | String | BaseActor | Euler | Object3D | Material | AnimationClip | VisualEffect | Sequence | Prefab | PrefabOf<BaseActor> | DataAssetRef | SceneRef | ModelRef | Curve2 | ColorLayer | MaskLayer>;
15
16
  export type ParameterType = Type<any> | AbstractType<any>;
16
17
  export type ParameterTypeResolver = () => ParameterType;
17
18
  export type ParameterOption<T> = {
@@ -1,4 +1,4 @@
1
- import{describe as e,expect as a,it as t,vi as i}from"vitest";import{GraphicsRuntime as n}from"../rendering/graphics-runtime.js";e("GraphicsRuntime",()=>{t("shares a backend until the final view releases it",async()=>{const e=new n,t={id:1},l=i.fn(async()=>t),r=i.fn(),s=await e.acquire("webgpu","default",l),u=await e.acquire("webgpu","default",l);a(s).toBe(t),a(u).toBe(t),a(l).toHaveBeenCalledOnce(),e.release("webgpu","default",r),a(r).not.toHaveBeenCalled(),e.release("webgpu","default",r),a(r).toHaveBeenCalledWith(t)})});/*
1
+ import{describe as e,expect as a,it as i,vi as n}from"vitest";import{GraphicsRuntime as t}from"../rendering/graphics-runtime.js";e("GraphicsRuntime",()=>{i("shares a backend until the final view releases it",async()=>{const e=new t,i={id:1},s=n.fn(async()=>i),r=n.fn(),l=await e.acquire("webgpu","default",s),u=await e.acquire("webgpu","default",s);a(l).toBe(i),a(u).toBe(i),a(s).toHaveBeenCalledOnce(),e.release("webgpu","default",r),a(r).not.toHaveBeenCalled(),e.release("webgpu","default",r),a(r).toHaveBeenCalledWith(i)}),i("reuses an idle backend until its retention period expires",async()=>{n.useFakeTimers();try{const e=new t,i={id:1},s=n.fn(async()=>i),r=n.fn();await e.acquire("webgpu","default",s),e.release("webgpu","default",r,12e4),await n.advanceTimersByTimeAsync(6e4),a(await e.acquire("webgpu","default",s)).toBe(i),a(s).toHaveBeenCalledOnce(),e.release("webgpu","default",r,12e4),await n.advanceTimersByTimeAsync(12e4),a(r).toHaveBeenCalledOnce()}finally{n.useRealTimers()}}),i("evicts an idle backend when a different configuration is acquired",async()=>{const e=new t,i=n.fn();await e.acquire("webgpu","first",async()=>({id:1})),e.release("webgpu","first",i,12e4),await e.acquire("webgpu","second",async()=>({id:2})),a(i).toHaveBeenCalledWith({id:1}),e.release("webgpu","second",i)})});/*
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=lattice-deformation.test.d.ts.map
@@ -0,0 +1,4 @@
1
+ import{describe as e,expect as t,it as o,vi as r}from"vitest";import{BoxGeometry as s,BufferGeometry as n,Float32BufferAttribute as a,Group as i,Matrix4 as m,Mesh as f,MeshBasicMaterial as d,PlaneGeometry as l,Vector3 as y}from"three";import{createLatticeDeformation as p,deformLatticeGeometry as c,isValidLattice as g,latticePoint as u,LatticeGeometryCache as w,setLatticeGeometrySourceIdentity as h}from"../scene/lattice-deformation.js";function B(e=new s(2,2,2)){const t=new i,o=new f(e,new d);return t.add(o),{root:t,mesh:o}}function b(e,o){t(e.x).toBeCloseTo(o.x,5),t(e.y).toBeCloseTo(o.y,5),t(e.z).toBeCloseTo(o.z,5)}e("lattice deformation",()=>{o("preserves undeformed geometry, normals and hard edges",()=>{const{root:e,mesh:o}=B(),r=p(e,[3,2,1]),s=c(o.geometry,new m,r);t(Array.from(s.getAttribute("position").array)).toEqual(Array.from(o.geometry.getAttribute("position").array)),t(Array.from(s.getAttribute("normal").array)).toEqual(Array.from(o.geometry.getAttribute("normal").array)),t(s).not.toBe(o.geometry)}),o("transforms positions, normals and tangents under a shear without modifying source buffers",()=>{const e=new n;e.setAttribute("position",new a([0,0,0,1,0,0,0,1,0],3)),e.setAttribute("normal",new a([1,0,0,1,0,0,1,0,0],3)),e.setAttribute("tangent",new a([0,1,0,1,0,1,0,1,0,1,0,1],4));const o=p(new f(new s(2,2,2)),[2,2,2]),r=new y;o.offsets.forEach((e,t)=>{e[0]=.5*u(o,t,r).y});const i=c(e,new m,o);b((new y).fromBufferAttribute(i.getAttribute("position"),2),new y(.5,1,0)),b((new y).fromBufferAttribute(i.getAttribute("normal"),0),new y(1,-.5,0).normalize()),b((new y).fromBufferAttribute(i.getAttribute("tangent"),0),new y(.5,1,0).normalize()),t(Array.from(e.getAttribute("position").array)).toEqual([0,0,0,1,0,0,0,1,0]),t(i.boundingBox.max.x).toBe(1)}),o("uses model local space for transformed child meshes and ignores scene transforms",()=>{const{root:e,mesh:t}=B();t.position.set(3,2,-1),t.rotation.z=Math.PI/2,t.scale.set(2,1,.5),e.position.set(100,200,300),e.scale.setScalar(4);const o=p(e,[1,1,1]);b((new y).fromArray(o.min),new y(2,0,-1.5)),b((new y).fromArray(o.max),new y(4,4,-.5)),o.offsets.forEach(e=>{e[0]=2});const r=c(t.geometry,t.matrix,o);b((new y).fromBufferAttribute(r.getAttribute("position"),0),(new y).fromBufferAttribute(t.geometry.getAttribute("position"),0).add(new y(0,-2,0)))}),o("supports planar models and rejects malformed and oversized cages",()=>{const e=p(new f(new l(2,2)),[1,1,1]);t(g(e)).toBe(!0),t(e.max[2]).toBeGreaterThan(e.min[2]),t(g({...e,offsets:[]})).toBe(!1),t(g({...e,segments:[9,1,1]})).toBe(!1),t(g({...e,min:[NaN,0,0]})).toBe(!1),t(()=>p(new i,[1e4,1e4,1e4])).toThrow("Lattice segments")}),o("reuses identical buffers across copies and restores the original when disabled",()=>{const{root:e,mesh:o}=B(),r=e.clone(),s=r.children[0];r.position.set(9,0,0);const n=o.geometry,a=p(e),i=new w;a.offsets[0][1]=.5,i.apply(e,a),i.apply(r,structuredClone(a)),t(o.geometry).toBe(s.geometry),t(o.geometry).not.toBe(n);const m=o.geometry;i.apply(e,{...a,offsets:a.offsets.map(e=>[e[0],e[1]+1,e[2]])}),t(o.geometry).not.toBe(s.geometry),i.apply(e,a),t(o.geometry).toBe(m),i.apply(e,{...a,enabled:!1}),t(o.geometry).toBe(n),t(s.geometry).toBe(m),i.dispose()}),o("shares independently frozen copies with identical source and pose identities",()=>{const{root:e,mesh:o}=B(),{root:r,mesh:s}=B(o.geometry.clone());h(o.geometry,"same-source-and-pose"),h(s.geometry,"same-source-and-pose");const n=p(e),a=new w;n.offsets[0][0]=.25,a.apply(e,n),a.apply(r,{offsets:n.offsets,max:n.max,min:n.min,segments:n.segments}),t(o.geometry).toBe(s.geometry),a.dispose()}),o("leaves independently authored child objects and editor helpers undeformed",()=>{const{root:e,mesh:o}=B(),r=p(e),s=new i,n=new f(o.geometry);s.userData.src={type:"asset_mesh"},s.add(n),e.add(s);const a=new f(o.geometry);a.name="UCX_model",e.add(a);const m=new f(o.geometry);m.userData.isEditorVisualization=!0,e.add(m);const d=n.geometry,l=new w;r.offsets.forEach(e=>{e[0]=1}),l.apply(e,r),t(o.geometry).not.toBe(d),t(n.geometry).toBe(d),t(m.geometry).toBe(d),t(a.geometry).toBe(d),l.dispose()}),o("isolates geometry variants and disposes idle buffers without disposing live copies",()=>{const{root:e,mesh:o}=B(),s=e.clone(),n=s.children[0],a=p(e),i=new w;a.offsets[0][0]=.25,i.apply(s,a);const m=r.spyOn(n.geometry,"dispose");i.apply(e,a,"painted"),t(o.geometry).not.toBe(n.geometry);for(let t=1;t<20;t++){const o=structuredClone(a);o.offsets[0][0]=t,i.apply(e,o)}t(m).not.toHaveBeenCalled(),i.release(s),i.dispose(),t(m).toHaveBeenCalledOnce()})});/*
2
+ * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
+ * See the LICENSE.md file for details.
4
+ */
@@ -1,4 +1,4 @@
1
- import{describe as e,expect as t,it as a,vi as s}from"vitest";import{Scene as i,Texture as r}from"three";import{Subject as n}from"rxjs";s.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&s.fn()),e[t]),set:(e,t,a)=>(e[t]=a,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),s.mock("../gameplay/actors/builtin/index.js",()=>({default:{}})),s.mock("../effects/vfx/vfx-actor.js",()=>({VfxActor:class{}})),s.mock("../effects/vfx/vfx-param.js",()=>({VisualEffect:class{}})),s.mock("../rendering.js",()=>({RenderingView:class{},setRenderingPaused:s.fn()}));import{SceneMaterializer as d}from"../scene/materializer.js";import{DetailTier as p,SerializedParamType as o}from"../scene/model.js";function m(e,t){const a=s.fn(async(e,t)=>({scene:{},animations:[]})),p=s.fn(async e=>new r),o=s.fn(async e=>({texture:new r,layerIndex:0})),m=s.fn(),f={getObjects:()=>e,onCreate:()=>{},onUpdate:()=>{},onRemove:()=>{}},l={onCreate:new n,onDelete:new n,onUpdate:new n,getAsset:s.fn(async e=>t.get(e)??null),getAssets:s.fn(async()=>Array.from(t.values()))};return{materializer:new d(new i,f,l,{getMesh:a,getTexture:p,getTextureArray:o},{renderer:{initTexture:m},initTexture:m,onLoop:s.fn()},[],[],{create:async()=>null,initActor:async()=>{}}),getMesh:a,getTexture:p,getTextureArray:o,initTexture:m}}function f(e){return{name:e.id,...e}}function l(e,t){return{id:e,name:e,type:"mesh",fileKey:`${e}.glb`,fileFormat:"glb",mesh:t}}function c(e,t){return{id:e,name:e,type:"prefab",prefab:{objects:t}}}function u(e,t){return{id:e,name:e,type:"material",material:{type:"shader",side:0,params:{},shaderParams:{map:{type:o.Texture,value:t}}}}}function h(e){return{id:e,name:e,type:"texture",fileKey:`${e}.png`,fileFormat:"png"}}function y(e){return{color:"#ffffff",materialId:e}}e("SceneMaterializer asset prefetch",()=>{a("prefetches unique materialized asset meshes with the same options used by createFromAsset",async()=>{const e=l("mesh-a"),a=l("mesh-b"),s=l("mesh-c"),i=l("mesh-disabled"),r=l("mesh-high",{detailTier:p.high}),n=c("prefab-a",[f({id:"prefab-mesh-c",type:"asset_mesh",assetId:s.id}),f({id:"prefab-duplicate-mesh-a",type:"asset_mesh",assetId:e.id}),f({id:"prefab-cycle",type:"prefab",assetId:"prefab-a"})]),d=new Map([e,a,s,i,r,n].map(e=>[e.id,e])),o=[f({id:"root",type:"group",children:[f({id:"mesh-a-1",type:"asset_mesh",assetId:e.id}),f({id:"mesh-a-2",type:"asset_mesh",assetId:e.id}),f({id:"disabled-group",type:"group",enabled:!1,children:[f({id:"disabled-mesh",type:"asset_mesh",assetId:i.id})]}),f({id:"prefab-instance",type:"prefab",assetId:n.id}),f({id:"high-detail-mesh",type:"asset_mesh",assetId:r.id})]}),f({id:"mesh-b",type:"asset_mesh",assetId:a.id})],{materializer:u,getMesh:h}=m(o,d);u.detailTier=p.low,await u.preInit(),await u.prefetchAssets(),t(h).toHaveBeenCalledTimes(3),t(new Set(h.mock.calls.map(([e])=>e.id))).toEqual(new Set([e.id,a.id,s.id])),t(h.mock.calls.every(([,e])=>!0===e?.mergeGeomtries)).toBe(!0)}),a("initializes textures from nested groups and prefab assets",async()=>{const e=h("texture-root"),a=h("texture-group"),s=h("texture-prefab"),i=h("texture-skipped"),r=u("material-root",e.id),n=u("material-group",a.id),d=u("material-prefab",s.id),p=u("material-skipped",i.id),o=c("prefab-a",[f({id:"prefab-group",type:"group",children:[f({id:"prefab-shape",type:"shape_mesh",materialAssignments:[y(d.id)]})]}),f({id:"prefab-cycle",type:"prefab",assetId:"prefab-a"})]),l=new Map([e,a,s,i,r,n,d,p,o].map(e=>[e.id,e])),g=[f({id:"root-group",type:"group",children:[f({id:"nested-mesh",type:"asset_mesh",materialAssignments:[y(n.id)]}),f({id:"disabled-group",type:"group",enabled:!1,children:[f({id:"skipped-mesh",type:"asset_mesh",materialAssignments:[y(p.id)]})]}),f({id:"prefab-instance",type:"prefab",assetId:o.id})]}),f({id:"root-shape",type:"shape_mesh",materialAssignments:[y(r.id)]})],{materializer:b,getTexture:x,initTexture:w}=m(g,l);await b.preInit(),await b.initTextures(),t(x).toHaveBeenCalledTimes(3),t(new Set(x.mock.calls.map(([e])=>e.id))).toEqual(new Set([e.id,a.id,s.id])),t(w).toHaveBeenCalledTimes(3)})});/*
1
+ import{describe as e,expect as t,it as a,vi as s}from"vitest";import{Scene as i,Texture as r}from"three";import{Subject as n}from"rxjs";s.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&s.fn()),e[t]),set:(e,t,a)=>(e[t]=a,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),s.mock("../gameplay/actors/builtin/index.js",()=>({default:{}})),s.mock("../effects/vfx/vfx-actor.js",()=>({VfxActor:class{}})),s.mock("../effects/vfx/vfx-param.js",()=>({VisualEffect:class{}})),s.mock("../rendering.js",()=>({RenderingView:class{},setRenderingPaused:s.fn()}));import{SceneMaterializer as d}from"../scene/materializer.js";import{DetailTier as p,SerializedParamType as o}from"../scene/model.js";function m(e,t){const a=s.fn(async(e,t)=>({scene:{},animations:[]})),p=s.fn(async e=>new r),o=s.fn(async e=>({texture:new r,layerIndex:0})),m=s.fn(),l={getObjects:()=>e,onCreate:()=>{},onUpdate:()=>{},onRemove:()=>{}},f={onCreate:new n,onDelete:new n,onUpdate:new n,getAsset:s.fn(async e=>t.get(e)??null),getAssets:s.fn(async()=>Array.from(t.values()))};return{materializer:new d(new i,l,f,{getMesh:a,getTexture:p,getTextureArray:o},{renderer:{initTexture:m},initTexture:m,onLoop:s.fn()},[],[],{create:async()=>null,initActor:async()=>{}}),getMesh:a,getTexture:p,getTextureArray:o,initTexture:m}}function l(e){return{name:e.id,...e}}function f(e,t){return{id:e,name:e,type:"mesh",fileKey:`${e}.glb`,fileFormat:"glb",mesh:t}}function c(e,t){return{id:e,name:e,type:"prefab",prefab:{objects:t}}}function u(e,t){return{id:e,name:e,type:"material",material:{type:"shader",side:0,params:{},shaderParams:{map:{type:o.Texture,value:t}}}}}function h(e){return{id:e,name:e,type:"texture",fileKey:`${e}.png`,fileFormat:"png"}}function y(e){return{color:"#ffffff",materialId:e}}e("SceneMaterializer asset prefetch",()=>{a("prefetches unique materialized asset meshes without creating discarded instances",async()=>{const e=f("mesh-a"),a=f("mesh-b"),s=f("mesh-c"),i=f("mesh-disabled"),r=f("mesh-high",{detailTier:p.high}),n=c("prefab-a",[l({id:"prefab-mesh-c",type:"asset_mesh",assetId:s.id}),l({id:"prefab-duplicate-mesh-a",type:"asset_mesh",assetId:e.id}),l({id:"prefab-cycle",type:"prefab",assetId:"prefab-a"})]),d=new Map([e,a,s,i,r,n].map(e=>[e.id,e])),o=[l({id:"root",type:"group",children:[l({id:"mesh-a-1",type:"asset_mesh",assetId:e.id}),l({id:"mesh-a-2",type:"asset_mesh",assetId:e.id}),l({id:"disabled-group",type:"group",enabled:!1,children:[l({id:"disabled-mesh",type:"asset_mesh",assetId:i.id})]}),l({id:"prefab-instance",type:"prefab",assetId:n.id}),l({id:"high-detail-mesh",type:"asset_mesh",assetId:r.id})]}),l({id:"mesh-b",type:"asset_mesh",assetId:a.id})],{materializer:u,getMesh:h}=m(o,d);u.detailTier=p.low,await u.preInit(),await u.prefetchAssets(),t(h).toHaveBeenCalledTimes(3),t(new Set(h.mock.calls.map(([e])=>e.id))).toEqual(new Set([e.id,a.id,s.id])),t(h.mock.calls.every(([,e])=>!0===e?.mergeGeomtries)).toBe(!0),t(h.mock.calls.every(([,e])=>!1===e?.clone)).toBe(!0)}),a("initializes textures from nested groups and prefab assets",async()=>{const e=h("texture-root"),a=h("texture-group"),s=h("texture-prefab"),i=h("texture-skipped"),r=u("material-root",e.id),n=u("material-group",a.id),d=u("material-prefab",s.id),p=u("material-skipped",i.id),o=c("prefab-a",[l({id:"prefab-group",type:"group",children:[l({id:"prefab-shape",type:"shape_mesh",materialAssignments:[y(d.id)]})]}),l({id:"prefab-cycle",type:"prefab",assetId:"prefab-a"})]),f=new Map([e,a,s,i,r,n,d,p,o].map(e=>[e.id,e])),g=[l({id:"root-group",type:"group",children:[l({id:"nested-mesh",type:"asset_mesh",materialAssignments:[y(n.id)]}),l({id:"disabled-group",type:"group",enabled:!1,children:[l({id:"skipped-mesh",type:"asset_mesh",materialAssignments:[y(p.id)]})]}),l({id:"prefab-instance",type:"prefab",assetId:o.id})]}),l({id:"root-shape",type:"shape_mesh",materialAssignments:[y(r.id)]})],{materializer:b,getTexture:x,initTexture:w}=m(g,f);await b.preInit(),await b.initTextures(),t(x).toHaveBeenCalledTimes(3),t(new Set(x.mock.calls.map(([e])=>e.id))).toEqual(new Set([e.id,a.id,s.id])),t(w).toHaveBeenCalledTimes(3)})});/*
2
2
  * Copyright (©) 2026 Hology Interactive AB. All rights reserved.
3
3
  * See the LICENSE.md file for details.
4
4
  */
@@ -1,87 +1,87 @@
1
- # Actor persistence
2
-
3
- Persistence is opt-in and storage-independent. Enable it when loading a single-player session:
4
-
5
- ```ts
6
- await application.loadGame(Game, {
7
- sceneName: save.sceneName,
8
- persistence: { data: save.persistence }, // undefined data starts an empty document
9
- })
10
- ```
11
-
12
- `initiateGame()` and React's `HologyScene` accept the same `persistence` option. Games load and migrate their save envelope before loading the scene, then write `inject(ActorPersistence).snapshot()` alongside their other save data. Capture is synchronous and returns detached JSON data; do it before awaiting storage. Sessions without this option do not register actors. Network sessions reject the option.
13
-
14
- ## Actor and component hooks
15
-
16
- ```ts
17
- @Actor({ persistent: true })
18
- class Chest extends BaseActor implements PersistentState<{ opened: boolean }> {
19
- readonly persistenceVersion = 1
20
- opened = false
21
-
22
- onCapturePersistence() { return { opened: this.opened } }
23
- onRestorePersistence(state: { opened: boolean }, context: PersistenceContext) {
24
- if (context.version !== 1 || typeof state?.opened !== 'boolean') {
25
- throw new Error('Unsupported chest state')
26
- }
27
- this.opened = state.opened
28
- }
29
- onPersistenceRestored() { this.updateAppearance() }
30
- }
31
-
32
- @Component()
33
- class Cooldown extends ActorComponent implements PersistentState<{ remaining: number }> {
34
- // Stable within the owning actor, including nested components. Two cooldowns
35
- // need distinct keys, e.g. 'primary-attack' and 'dodge'. Never use array indices.
36
- persistenceKey = 'primary-attack'
37
- readonly persistenceVersion = 1
38
- remaining = 0
39
-
40
- onCapturePersistence() { return { remaining: this.remaining } }
41
- onRestorePersistence(state: { remaining: number }, context: PersistenceContext) {
42
- if (context.version !== 1 || !Number.isFinite(state?.remaining)) {
43
- throw new Error('Unsupported cooldown state')
44
- }
45
- this.remaining = Math.max(0, state.remaining)
46
- }
47
- }
48
- ```
49
-
50
- Override `get persistenceEnabled()` for a policy based on authored parameters (for example, enemy faction). It is evaluated after initialization. A component never opts its actor in. An opted-in actor may have only component hooks, or no hooks if it only needs permanent removal.
51
-
52
- For the common position-and-yaw case, attach `ActorTransformPersistenceComponent` to an opted-in actor. It uses the stable component key `transform` and stores local position and yaw, leaving pitch and roll at their authored values. It restores before begin-play and synchronizes an existing physics body after the restore batch. Attach it only once per actor. `captureActorTransform(actor)` and `restoreActorTransform(actor, state)` provide the same JSON shape for custom hooks; the latter validates the payload and sets the object transform, so custom movement systems must also synchronize their own state and physics. A character movement component, for example, can call `movement.teleport(actor.position, actor.rotation.y)` in the actor's `onPersistenceRestored()` when a saved transform was applied (`transformComponent.restored`).
53
-
54
- Initialize defaults in `onInit()`. Restore runs after all actors/components initialize and references bind, before `onBeginPlay()`, event graphs, game preload and presentation. Do not reset persisted fields in begin-play. Components restore before their actor. `onPersistenceRestored()` runs after the entire batch restores, including actors with no saved entry. Ordinary restore hooks may resolve other actors, but cannot assume those actors have restored yet. Hooks should not spawn participants or mutate the batch.
55
-
56
- Save finite JSON values, not actors, vectors, signals, functions, dates or cyclic objects. Validate or migrate component/actor payload versions in the restore hook. Unknown component entries are retained for later saves. Restore failures stop loading and include identity and component key in the error. Restore by assigning state, without replaying damage, death, item rewards or other gameplay events. Save remaining durations rather than wall-clock deadlines unless the game intentionally advances them while closed.
57
-
58
- ## Identity and removal
59
-
60
- Authored identities are `{ sceneId, path }`: the stable scene metadata ID and the existing object/prefab instance path. Names are not IDs. Renaming does not change identity; recreating an object or moving it across prefab boundaries may require a game save migration.
61
-
62
- Call `persistence.markRemoved(actor)` at the permanent gameplay outcome, such as enemy death, before delayed presentation cleanup. It records removal without destroying the live object. The materializer skips that authored subtree on the next load. For a prefab's exposed actor, this removes the whole exposed placement, including nested prefabs, meshes, collisions and event graphs. Ordinary `world.removeActor()` and unloading capture state but never mark it removed.
63
-
64
- References to removed actors become `null`. Gameplay must handle that possibility. `getIdentity(actor)`, `resolve(identity)` and `isRemoved(identity)` support game save references. Scene parameters can use direct actor references, including arrays; a removed actor resolves to `null` in the corresponding field.
65
-
66
- Scene travel captures outgoing actors and retains unloaded scene records. Game-lifetime actors remain registered and are not restored again when their authored scene is revisited.
67
-
68
- ## Dynamic actors
69
-
70
- The game saves its own spawn descriptors and recreates actors/prefabs explicitly:
71
-
72
- ```ts
73
- await world.spawnActor(Companion, position, rotation, { persistenceId: 'quest-companion' })
74
- await world.spawnPrefab(droppedItemPrefab, position, rotation, { persistenceId: savedDrop.id })
75
- ```
76
-
77
- The actor must still opt in. Dynamic identities are `{ dynamicId, path }`, with an empty path for the main actor and prefab-local paths for other actors. IDs are unique across the session, independent of scene. The game decides which scene to spawn them in. A removed or already active ID is an error; use `isRemoved({ dynamicId, path: '' })` before recreating it. All actors in a prefab restore before any begin-play. References across separately spawned dynamic actors require game-controlled creation order. Synchronous spawning cannot restore persistent state.
78
-
79
- The engine does not recreate dynamically attached components. The game must attach them with stable keys before restoration. Dynamic prefabs are removed as a unit when any persistent member is marked removed.
80
-
81
- ## Cost and testing
82
-
83
- Persistence has no frame callback. Nonparticipating actors receive no persistence record or subscription. Discovery occurs at initialization/capture/restore boundaries, and snapshots traverse registered actors only. Snapshot size and capture frequency remain under game control.
84
-
85
- Core checks: `npm test -- --run src/test/actor-persistence.test.ts src/test/actor-persistence-runtime.test.ts src/test/world-lifecycle.test.ts src/test/world-prefab-spawn.test.ts src/test/prefab-instance-params.test.ts src/test/rendering-host.test.ts` from `packages/core`.
86
-
87
- Maran integration checks, with both repositories side by side: `npm exec -- vitest run --config ../../../legend-of-maran/tests/persistence.config.mts` from `packages/core`.
1
+ # Actor persistence
2
+
3
+ Persistence is opt-in and storage-independent. Enable it when loading a single-player session:
4
+
5
+ ```ts
6
+ await application.loadGame(Game, {
7
+ sceneName: save.sceneName,
8
+ persistence: { data: save.persistence }, // undefined data starts an empty document
9
+ })
10
+ ```
11
+
12
+ `initiateGame()` and React's `HologyScene` accept the same `persistence` option. Games load and migrate their save envelope before loading the scene, then write `inject(ActorPersistence).snapshot()` alongside their other save data. Capture is synchronous and returns detached JSON data; do it before awaiting storage. Sessions without this option do not register actors. Network sessions reject the option.
13
+
14
+ ## Actor and component hooks
15
+
16
+ ```ts
17
+ @Actor({ persistent: true })
18
+ class Chest extends BaseActor implements PersistentState<{ opened: boolean }> {
19
+ readonly persistenceVersion = 1
20
+ opened = false
21
+
22
+ onCapturePersistence() { return { opened: this.opened } }
23
+ onRestorePersistence(state: { opened: boolean }, context: PersistenceContext) {
24
+ if (context.version !== 1 || typeof state?.opened !== 'boolean') {
25
+ throw new Error('Unsupported chest state')
26
+ }
27
+ this.opened = state.opened
28
+ }
29
+ onPersistenceRestored() { this.updateAppearance() }
30
+ }
31
+
32
+ @Component()
33
+ class Cooldown extends ActorComponent implements PersistentState<{ remaining: number }> {
34
+ // Stable within the owning actor, including nested components. Two cooldowns
35
+ // need distinct keys, e.g. 'primary-attack' and 'dodge'. Never use array indices.
36
+ persistenceKey = 'primary-attack'
37
+ readonly persistenceVersion = 1
38
+ remaining = 0
39
+
40
+ onCapturePersistence() { return { remaining: this.remaining } }
41
+ onRestorePersistence(state: { remaining: number }, context: PersistenceContext) {
42
+ if (context.version !== 1 || !Number.isFinite(state?.remaining)) {
43
+ throw new Error('Unsupported cooldown state')
44
+ }
45
+ this.remaining = Math.max(0, state.remaining)
46
+ }
47
+ }
48
+ ```
49
+
50
+ Override `get persistenceEnabled()` for a policy based on authored parameters (for example, enemy faction). It is evaluated after initialization. A component never opts its actor in. An opted-in actor may have only component hooks, or no hooks if it only needs permanent removal.
51
+
52
+ For the common position-and-yaw case, attach `ActorTransformPersistenceComponent` to an opted-in actor. It uses the stable component key `transform` and stores local position and yaw, leaving pitch and roll at their authored values. It restores before begin-play and synchronizes an existing physics body after the restore batch. Attach it only once per actor. `captureActorTransform(actor)` and `restoreActorTransform(actor, state)` provide the same JSON shape for custom hooks; the latter validates the payload and sets the object transform, so custom movement systems must also synchronize their own state and physics. A character movement component, for example, can call `movement.teleport(actor.position, actor.rotation.y)` in the actor's `onPersistenceRestored()` when a saved transform was applied (`transformComponent.restored`).
53
+
54
+ Initialize defaults in `onInit()`. Restore runs after all actors/components initialize and references bind, before `onBeginPlay()`, event graphs, game preload and presentation. Do not reset persisted fields in begin-play. Components restore before their actor. `onPersistenceRestored()` runs after the entire batch restores, including actors with no saved entry. Ordinary restore hooks may resolve other actors, but cannot assume those actors have restored yet. Hooks should not spawn participants or mutate the batch.
55
+
56
+ Save finite JSON values, not actors, vectors, signals, functions, dates or cyclic objects. Validate or migrate component/actor payload versions in the restore hook. Unknown component entries are retained for later saves. Restore failures stop loading and include identity and component key in the error. Restore by assigning state, without replaying damage, death, item rewards or other gameplay events. Save remaining durations rather than wall-clock deadlines unless the game intentionally advances them while closed.
57
+
58
+ ## Identity and removal
59
+
60
+ Authored identities are `{ sceneId, path }`: the stable scene metadata ID and the existing object/prefab instance path. Names are not IDs. Renaming does not change identity; recreating an object or moving it across prefab boundaries may require a game save migration.
61
+
62
+ Call `persistence.markRemoved(actor)` at the permanent gameplay outcome, such as enemy death, before delayed presentation cleanup. It records removal without destroying the live object. The materializer skips that authored subtree on the next load. For a prefab's exposed actor, this removes the whole exposed placement, including nested prefabs, meshes, collisions and event graphs. Ordinary `world.removeActor()` and unloading capture state but never mark it removed.
63
+
64
+ References to removed actors become `null`. Gameplay must handle that possibility. `getIdentity(actor)`, `resolve(identity)` and `isRemoved(identity)` support game save references. Scene parameters can use direct actor references, including arrays; a removed actor resolves to `null` in the corresponding field.
65
+
66
+ Scene travel captures outgoing actors and retains unloaded scene records. Game-lifetime actors remain registered and are not restored again when their authored scene is revisited.
67
+
68
+ ## Dynamic actors
69
+
70
+ The game saves its own spawn descriptors and recreates actors/prefabs explicitly:
71
+
72
+ ```ts
73
+ await world.spawnActor(Companion, position, rotation, { persistenceId: 'quest-companion' })
74
+ await world.spawnPrefab(droppedItemPrefab, position, rotation, { persistenceId: savedDrop.id })
75
+ ```
76
+
77
+ The actor must still opt in. Dynamic identities are `{ dynamicId, path }`, with an empty path for the main actor and prefab-local paths for other actors. IDs are unique across the session, independent of scene. The game decides which scene to spawn them in. A removed or already active ID is an error; use `isRemoved({ dynamicId, path: '' })` before recreating it. All actors in a prefab restore before any begin-play. References across separately spawned dynamic actors require game-controlled creation order. Synchronous spawning cannot restore persistent state.
78
+
79
+ The engine does not recreate dynamically attached components. The game must attach them with stable keys before restoration. Dynamic prefabs are removed as a unit when any persistent member is marked removed.
80
+
81
+ ## Cost and testing
82
+
83
+ Persistence has no frame callback. Nonparticipating actors receive no persistence record or subscription. Discovery occurs at initialization/capture/restore boundaries, and snapshots traverse registered actors only. Snapshot size and capture frequency remain under game control.
84
+
85
+ Core checks: `npm test -- --run src/test/actor-persistence.test.ts src/test/actor-persistence-runtime.test.ts src/test/world-lifecycle.test.ts src/test/world-prefab-spawn.test.ts src/test/prefab-instance-params.test.ts src/test/rendering-host.test.ts` from `packages/core`.
86
+
87
+ Maran integration checks, with both repositories side by side: `npm exec -- vitest run --config ../../../legend-of-maran/tests/persistence.config.mts` from `packages/core`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hology/core",
3
- "version": "0.0.261",
3
+ "version": "0.0.262",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",