@displayxr/inline3d 1.47.4 → 1.47.6

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.
@@ -0,0 +1,107 @@
1
+ // inline3d-pc-release.js — handle.releaseEntity: take content a page added under
2
+ // `handle.engine.root` out of a PlayCanvas tile and give back what it held on the GPU.
3
+ //
4
+ // Internal. PlayCanvasSplatViewer.releaseEntity (./inline3d-splat-playcanvas.js) calls it, for
5
+ // both ./splat (engine: 'playcanvas') and ./model. docs/content-lifecycle-rules.md has the why.
6
+ //
7
+ // WHY. `entity.destroy()` and `asset.unload()` free meshes, textures and materials, but NOT the
8
+ // shader programs those materials linked: the engine's ProgramLibrary caches every variant for
9
+ // the device's life, and `Material.destroy()` only empties `material.variants`. A page that swaps
10
+ // glTF content in and out (a product carousel, a catalogue) therefore keeps every program it ever
11
+ // linked, and a desktop driver keeps the linked program's compiled IR behind each one (about 5 MB
12
+ // per program, measured on one Linux driver). `Shader.destroy()` is the engine's own explicit
13
+ // release: it deletes the program and drops it from the ProgramLibrary cache, so a later ask for
14
+ // the same variant compiles it again instead of getting a dead one.
15
+ //
16
+ // ORDER (each step needs the one before):
17
+ // 1. collect the programs the entity's materials hold (`variants`, and each mesh instance's own
18
+ // shader cache) BEFORE anything is destroyed: destroying a material empties `variants`;
19
+ // 2. destroy the entity;
20
+ // 3. the container (`asset`, optional): unloaded and taken out of the registry when nothing
21
+ // left under `app.root` draws with one of its meshes or materials, after its own materials'
22
+ // programs are collected too;
23
+ // 4. destroy each collected program that nothing still in the app uses: any mesh instance under
24
+ // `app.root` (enabled or not), any layer's mesh instances, any material asset still in the
25
+ // registry. ProgramLibrary returns one Shader for one key, so two materials with the same
26
+ // options share a program; that is why this check exists.
27
+
28
+ /** Every program `materials` hold in `variants`, added to `into`. */
29
+ export function materialShaders(materials, into = new Set()) {
30
+ for (const m of materials) m?.variants?.forEach?.((s) => s && into.add(s));
31
+ return into;
32
+ }
33
+
34
+ /** The mesh instances of every render / model component under `entity` (itself included). */
35
+ function meshInstancesUnder(entity) {
36
+ const out = [];
37
+ if (!entity?.findComponents) return out;
38
+ for (const kind of ['render', 'model']) {
39
+ let comps = [];
40
+ try {
41
+ comps = entity.findComponents(kind) || [];
42
+ } catch {
43
+ comps = []; // a component system the app does not have
44
+ }
45
+ for (const c of comps) for (const mi of c.meshInstances || []) if (mi) out.push(mi);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** A mesh instance's programs: its material's variants and its own shader cache. */
51
+ function noteMeshInstance(mi, into) {
52
+ mi.material?.variants?.forEach?.((s) => s && into.add(s));
53
+ mi._shaderCache?.forEach?.((si) => si?.shader && into.add(si.shader));
54
+ }
55
+
56
+ /** Every program something still in `app` may draw with (step 4's keep set). */
57
+ export function liveShaders(app) {
58
+ const live = new Set();
59
+ for (const mi of meshInstancesUnder(app.root)) noteMeshInstance(mi, live);
60
+ for (const layer of app.scene?.layers?.layerList || []) for (const mi of layer.meshInstances || []) noteMeshInstance(mi, live);
61
+ for (const a of app.assets?.list?.() || []) if (a?.type === 'material' && a.resource) materialShaders([a.resource], live);
62
+ return live;
63
+ }
64
+
65
+ /** Does anything left under `app.root` draw with one of `container`'s meshes or materials? */
66
+ function containerInUse(app, container) {
67
+ const mats = new Set((container?.materials || []).map((a) => a?.resource).filter(Boolean));
68
+ const meshes = new Set();
69
+ for (const r of container?.renders || []) for (const m of r?.resource?.meshes || []) meshes.add(m);
70
+ if (!mats.size && !meshes.size) return false;
71
+ for (const mi of meshInstancesUnder(app.root)) if (mats.has(mi.material) || meshes.has(mi.mesh)) return true;
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Destroy `entity` and release what it held. With `asset` (the container it was instantiated
77
+ * from), the container too, unless something else in the app still draws from it.
78
+ * Returns { programs, container } — programs destroyed, and 'unloaded' | 'in-use' | null.
79
+ */
80
+ export function releaseEntity(app, entity, { asset = null } = {}) {
81
+ const result = { programs: 0, container: null };
82
+ if (!app || !app.root) return result;
83
+ const retired = new Set();
84
+ if (entity) {
85
+ for (const mi of meshInstancesUnder(entity)) noteMeshInstance(mi, retired);
86
+ entity.destroy?.();
87
+ }
88
+ if (asset) {
89
+ const container = asset.resource;
90
+ if (container && containerInUse(app, container)) {
91
+ result.container = 'in-use';
92
+ } else {
93
+ materialShaders((container?.materials || []).map((a) => a?.resource), retired);
94
+ app.assets?.remove?.(asset);
95
+ asset.unload?.();
96
+ result.container = 'unloaded';
97
+ }
98
+ }
99
+ if (!retired.size) return result;
100
+ const live = liveShaders(app);
101
+ for (const s of retired) {
102
+ if (live.has(s) || typeof s.destroy !== 'function') continue;
103
+ s.destroy();
104
+ result.programs++;
105
+ }
106
+ return result;
107
+ }
@@ -0,0 +1,172 @@
1
+ // inline3d-pc-shader-cache.js — give PlayCanvas's compiled WebGL shaders back when the last
2
+ // program that uses them is destroyed.
3
+ //
4
+ // Internal. The PlayCanvas viewer (./inline3d-splat-playcanvas.js, shared by ./splat and ./model)
5
+ // calls installShaderCacheRelease(device) on every device it creates.
6
+ //
7
+ // WHY. The engine (2.22.3, src/platform/graphics/webgl/webgl-shader.js) keeps every compiled
8
+ // vertex and fragment shader in a module-private CompiledShaderCache, keyed by source, until the
9
+ // DEVICE is destroyed: `WebglShader.destroy()` deletes the program and nothing else. A page that
10
+ // releases what a content swap replaces (`Shader.destroy()` on the outgoing variants, see
11
+ // docs/content-lifecycle-rules.md) therefore still keeps two compiled shaders per program it
12
+ // ever linked, with the driver's compiled IR behind each one. Over a long-lived page that is
13
+ // unbounded growth in the browser's GPU process.
14
+ //
15
+ // WHAT THIS DOES. The engine is the page's own peer dependency (an import map, a bundler, a CDN
16
+ // build), never a file this package ships, so it cannot be patched on disk. Instead this replaces
17
+ // two methods on the engine's WebglShader prototype, reached through the first shader the device
18
+ // creates:
19
+ // _compileShaderSource — the same lookup the engine does, in a per-device cache of its own
20
+ // (keyed by source, as the engine's), plus one count per program that
21
+ // took the shader;
22
+ // destroy — the engine's destroy (deletes the program), then one release per
23
+ // shader. The last program out deletes the shader and drops the entry, so
24
+ // the next ask for that source compiles it again.
25
+ // Context loss clears the cache (the GL objects are gone; the engine recompiles on restore), and
26
+ // device destroy deletes whatever is still cached, as the engine's own cache does. Shaders the
27
+ // engine compiled BEFORE the patch stay in the engine's cache and are left alone.
28
+ //
29
+ // ACROSS ENGINE BUMPS. The patch installs only when the prototype has the 2.22.x shape (an
30
+ // `_compileShaderSource(device, src, isVertexShader)` that reads a `.map` keyed by source, and a
31
+ // `destroy` that does not already delete shaders), and otherwise steps aside and says why
32
+ // (`device.__dxrShaderCache`, and the diagnostics overlay). On an engine bump: read the new
33
+ // webgl-shader.js; if CompiledShaderCache still never deletes a shader before device destroy, keep
34
+ // this and update the shape check if the method moved; if upstream releases shaders itself, the
35
+ // `deleteShader` check makes this a no-op — delete the module. test/pc-shader-cache.test.mjs
36
+ // runs this against a copy of the 2.22.3 class shape.
37
+
38
+ const PATCHED = Symbol.for('@displayxr/inline3d.shaderCacheRelease');
39
+
40
+ /** The per-device cache: source → shader, per stage, and a count per shader. */
41
+ const caches = new WeakMap();
42
+
43
+ function cacheFor(device) {
44
+ let c = caches.get(device);
45
+ if (!c) {
46
+ c = { v: new Map(), f: new Map(), refs: new Map(), compiled: 0, deleted: 0 };
47
+ caches.set(device, c);
48
+ device.on?.('devicelost', () => {
49
+ c.v.clear();
50
+ c.f.clear();
51
+ c.refs.clear();
52
+ });
53
+ device.on?.('destroy', () => {
54
+ const gl = device.gl;
55
+ if (gl && !gl.isContextLost?.()) for (const s of c.refs.keys()) gl.deleteShader(s);
56
+ c.v.clear();
57
+ c.f.clear();
58
+ c.refs.clear();
59
+ });
60
+ }
61
+ return c;
62
+ }
63
+
64
+ function release(device, map, src, glShader) {
65
+ const c = caches.get(device);
66
+ if (!c || !glShader || !c.refs.has(glShader)) return false; // not ours (compiled before the patch)
67
+ const n = c.refs.get(glShader) - 1;
68
+ if (n > 0) {
69
+ c.refs.set(glShader, n);
70
+ return true;
71
+ }
72
+ c.refs.delete(glShader);
73
+ if (map.get(src) === glShader) map.delete(src);
74
+ device.gl?.deleteShader(glShader);
75
+ c.deleted++;
76
+ return true;
77
+ }
78
+
79
+ /**
80
+ * Why `proto` cannot take the patch, or null when it can. Exported for the test.
81
+ * @param {object} proto the engine's WebglShader.prototype
82
+ */
83
+ export function shaderCacheShapeProblem(proto) {
84
+ if (!proto || typeof proto._compileShaderSource !== 'function' || typeof proto.destroy !== 'function') return 'no WebglShader._compileShaderSource/destroy';
85
+ if (proto._compileShaderSource.length !== 3) return 'WebglShader._compileShaderSource has a different signature';
86
+ const compile = Function.prototype.toString.call(proto._compileShaderSource);
87
+ if (!/\.map\.get\(/.test(compile) || !/createShader\(/.test(compile)) return 'WebglShader._compileShaderSource has a different body';
88
+ const destroy = Function.prototype.toString.call(proto.destroy);
89
+ if (/deleteShader/.test(destroy)) return 'the engine already releases shaders (WebglShader.destroy deletes them)';
90
+ return null;
91
+ }
92
+
93
+ /** Patch the engine's WebglShader prototype once (the prototype is shared by every device). */
94
+ export function patchWebglShaderPrototype(proto) {
95
+ if (proto[PATCHED]) return 'already';
96
+ const problem = shaderCacheShapeProblem(proto);
97
+ if (problem) return `skipped: ${problem}`;
98
+ const engineDestroy = proto.destroy;
99
+ proto._compileShaderSource = function (device, src, isVertexShader) {
100
+ const gl = device.gl;
101
+ if (gl.isContextLost()) return null;
102
+ const c = cacheFor(device);
103
+ const map = isVertexShader ? c.v : c.f;
104
+ let glShader = map.get(src);
105
+ if (!glShader) {
106
+ glShader = gl.createShader(isVertexShader ? gl.VERTEX_SHADER : gl.FRAGMENT_SHADER);
107
+ gl.shaderSource(glShader, src);
108
+ gl.compileShader(glShader);
109
+ map.set(src, glShader);
110
+ c.compiled++;
111
+ }
112
+ c.refs.set(glShader, (c.refs.get(glShader) || 0) + 1);
113
+ return glShader;
114
+ };
115
+ proto.destroy = function (shader) {
116
+ engineDestroy.call(this, shader);
117
+ const device = shader?.device;
118
+ const c = device && caches.get(device);
119
+ if (!c) return;
120
+ if (release(device, c.v, this._vsource, this.glVertexShader)) this.glVertexShader = null;
121
+ if (release(device, c.f, this._fsource, this.glFragmentShader)) this.glFragmentShader = null;
122
+ };
123
+ Object.defineProperty(proto, PATCHED, { value: true });
124
+ return 'patched';
125
+ }
126
+
127
+ /**
128
+ * Install the release on `device`'s shader class. Idempotent; WebGL devices only. The result is
129
+ * also kept on `device.__dxrShaderCache` ('patched' | 'already' | 'skipped: …' | 'pending').
130
+ * The prototype is reached through the first shader impl the device creates (the class is not
131
+ * exported), so this wraps `createShaderImpl` until it has one.
132
+ */
133
+ export function installShaderCacheRelease(device) {
134
+ if (!device || device.__dxrShaderCache) return device?.__dxrShaderCache ?? null;
135
+ if (!device.isWebGL2 || typeof device.createShaderImpl !== 'function') {
136
+ device.__dxrShaderCache = 'skipped: not a WebGL2 device';
137
+ return device.__dxrShaderCache;
138
+ }
139
+ const existing = device.shaders?.find?.((s) => s?.impl);
140
+ if (existing) {
141
+ device.__dxrShaderCache = patchWebglShaderPrototype(Object.getPrototypeOf(existing.impl));
142
+ return device.__dxrShaderCache;
143
+ }
144
+ device.__dxrShaderCache = 'pending';
145
+ const create = device.createShaderImpl;
146
+ const ownBefore = Object.hasOwn(device, 'createShaderImpl');
147
+ device.createShaderImpl = function (shader) {
148
+ // The prototype is only reachable from an impl, and the impl's constructor compiles: this
149
+ // first pair lands in the engine's own cache (kept for the device's life, as before); every
150
+ // later shader goes through the counted one.
151
+ if (device.__dxrShaderCache === 'pending') {
152
+ device.__dxrShaderCache = 'probing';
153
+ let impl;
154
+ try {
155
+ impl = create.call(this, shader);
156
+ } finally {
157
+ if (ownBefore) device.createShaderImpl = create;
158
+ else delete device.createShaderImpl; // the prototype's method again
159
+ }
160
+ device.__dxrShaderCache = patchWebglShaderPrototype(Object.getPrototypeOf(impl));
161
+ return impl;
162
+ }
163
+ return create.call(this, shader);
164
+ };
165
+ return device.__dxrShaderCache;
166
+ }
167
+
168
+ /** Shaders compiled / deleted through the patched cache on `device` (diagnostics), or null. */
169
+ export function shaderCacheStats(device) {
170
+ const c = device && caches.get(device);
171
+ return c ? { compiled: c.compiled, deleted: c.deleted, cached: c.refs.size } : null;
172
+ }
@@ -42,7 +42,8 @@ export {
42
42
  LAYERID_IMMEDIATE,
43
43
  // cursor: 'depth' (./inline3d-cursor-depth-playcanvas.js, loaded only on opt-in)
44
44
  Picker,
45
- // materials / meshes (the edge feather)
45
+ // materials / meshes (the edge feather; Material: the program release, ./inline3d-pc-auto-release.js)
46
+ Material,
46
47
  Mesh,
47
48
  MeshInstance,
48
49
  ShaderMaterial,
@@ -126,6 +126,8 @@ export function addSplatDeferred(wall, canvas, src, opts) {
126
126
  },
127
127
  // Mesh-program warm before the adapter has loaded: runs once it has (it needs the engine).
128
128
  warmEntity: (entity, o) => out.ready.then(() => out.warmEntity(entity, o)),
129
+ // Nothing a page added can exist before the engine has: nothing to release yet.
130
+ releaseEntity: () => ({ programs: 0, container: null }),
129
131
  setEffect: (name, params) => {
130
132
  validateEffectCall(name, params, 'set');
131
133
  pending.push(['setEffect', [name, params]]);
@@ -34,6 +34,19 @@
34
34
  // located them, and a live outgoing photo goes back on the pre-1.24 node chain (which
35
35
  // scales its disparity by the two rigs' window ratio at the swap). A/B for "the
36
36
  // outgoing photo jumps when the incoming rig is declared".
37
+ // keepshaders — leave the engine's compiled-shader cache as it ships (no per-program release,
38
+ // ./inline3d-pc-shader-cache.js). Page-wide: the engine's shader class is shared.
39
+ // keepprograms — a material the page destroys keeps the programs it linked, as the engine ships
40
+ // (./inline3d-pc-auto-release.js). Per tile.
41
+ //
42
+ // LIVE GL OBJECTS (docs/content-lifecycle-rules.md): programs and shaders created / deleted on the
43
+ // tile's context since diag attached, and the live difference, on the overlay and in the dump.
44
+ // Sampled at every content swap the SDK can see: each setSource, each glTF container the tile's
45
+ // asset registry loads (a page swapping meshes under handle.engine.root), each
46
+ // handle.releaseEntity. When the live program count has not dropped across the last LEAK_SWAPS
47
+ // swaps of one kind and rose at more than 60% of them, one console warning says so. A page that
48
+ // releases what a swap replaces goes up and down, or plateaus once its own transitions are
49
+ // compiled (a few rises, then flat); one that leaks climbs.
37
50
  //
38
51
  // Per frame, `rigAt` / `rigIn` / `rigOut` say which declared rig the views were located for
39
52
  // (the tracker's id; null = none matched), which rig the current photo was drawn through
@@ -52,8 +65,28 @@
52
65
  // - the settle itself: the SDK's settle work in ms, then how long the SAME task kept running
53
66
  // after it (the page's continuation of `await setSource`).
54
67
 
68
+ import { autoReleaseStats, autoReleaseState } from './inline3d-pc-auto-release.js';
69
+
55
70
  /** The switches `diag` / `?dxrdiag` understand, besides the plain on values. */
56
- export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick', 'nolayerrig', 'oldrig']);
71
+ export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick', 'nolayerrig', 'oldrig', 'keepshaders', 'keepprograms']);
72
+ /**
73
+ * The leak watch's window: content swaps of one kind with no drop in live programs, more than 60%
74
+ * of them a rise. Wide enough that a first pass through the transitions (each compiles its own
75
+ * programs once: four rises for cut / crossfade / wavefront / flip) does not trip it.
76
+ */
77
+ export const LEAK_SWAPS = 10;
78
+
79
+ /** The leak watch's rule on one kind's samples (oldest first): the warning's condition. */
80
+ export function looksLikeLeak(samples, n = LEAK_SWAPS) {
81
+ if (samples.length < n + 1) return false;
82
+ const w = samples.slice(-(n + 1));
83
+ let rises = 0;
84
+ for (let i = 1; i < w.length; i++) {
85
+ if (w[i] < w[i - 1]) return false;
86
+ if (w[i] > w[i - 1]) rises++;
87
+ }
88
+ return rises > n * 0.6;
89
+ }
57
90
  const ON_TOKENS = new Set(['1', 'on', 'true', 'yes']);
58
91
 
59
92
  /**
@@ -207,6 +240,12 @@ export class DiagRecorder {
207
240
  this._listeners = new Set();
208
241
  this._lafo = null;
209
242
  this._gl = null; // the wrapped context + its originals, for dispose()
243
+ /** GL object counts on the tile's context (instrumentGl): programs / shaders created + deleted. */
244
+ this.glObjects = { programs: { created: 0, deleted: 0 }, shaders: { created: 0, deleted: 0 } };
245
+ this.shaderCacheState = null; // () => the engine shader-cache patch state (./inline3d-pc-shader-cache.js)
246
+ this.programReleaseState = null; // () => { state, released, … } (./inline3d-pc-auto-release.js)
247
+ this._swapLive = new Map(); // kind → live programs at each content swap of that kind (the leak watch)
248
+ this._leakWarned = false;
210
249
  this._burst = null; // the pick burst of the current task
211
250
  }
212
251
 
@@ -266,6 +305,25 @@ export class DiagRecorder {
266
305
  */
267
306
  instrumentGl(gl) {
268
307
  if (!gl || this._gl) return;
308
+ // Object counts first (no timing): what a content swap leaves behind.
309
+ const objs = this.glObjects;
310
+ const counters = {
311
+ createProgram: (r) => r && objs.programs.created++,
312
+ deleteProgram: (r, a) => a[0] && objs.programs.deleted++,
313
+ createShader: (r) => r && objs.shaders.created++,
314
+ deleteShader: (r, a) => a[0] && objs.shaders.deleted++,
315
+ };
316
+ const counted = [];
317
+ for (const [name, count] of Object.entries(counters)) {
318
+ const orig = gl[name];
319
+ if (typeof orig !== 'function') continue;
320
+ gl[name] = function (...a) {
321
+ const r = orig.apply(this, a);
322
+ count(r, a);
323
+ return r;
324
+ };
325
+ counted.push(name);
326
+ }
269
327
  const LINK = 0x8b82;
270
328
  const COMPLETION = 0x91b1;
271
329
  const kinds = {
@@ -294,7 +352,37 @@ export class DiagRecorder {
294
352
  };
295
353
  saved.push(name);
296
354
  }
297
- this._gl = { gl, saved };
355
+ this._gl = { gl, saved: [...counted, ...saved] };
356
+ }
357
+
358
+ /** Live programs / shaders on the tile's context since instrumentGl (created − deleted). */
359
+ liveGl() {
360
+ const { programs: p, shaders: s } = this.glObjects;
361
+ return { programs: p.created - p.deleted, shaders: s.created - s.deleted };
362
+ }
363
+
364
+ /**
365
+ * A content swap happened (`kind`: 'setSource' at its start, 'container' at a glTF load,
366
+ * 'release' after a releaseEntity): sample the live program count, and warn once when the
367
+ * samples of that kind look like a leak (looksLikeLeak).
368
+ */
369
+ contentSwap(kind) {
370
+ if (!this._gl) return null;
371
+ const live = this.liveGl();
372
+ let w = this._swapLive.get(kind);
373
+ if (!w) this._swapLive.set(kind, (w = []));
374
+ w.push(live.programs);
375
+ if (w.length > LEAK_SWAPS + 1) w.shift();
376
+ this._event('swap', { kind, ...live }, this.now());
377
+ if (!this._leakWarned && looksLikeLeak(w)) {
378
+ this._leakWarned = true;
379
+ console.warn(
380
+ `[inline3d/${this.label.split('#')[0]}] diag: live WebGL programs never dropped across the last ${LEAK_SWAPS} content swaps ` +
381
+ `and rose at most of them (${kind}: ${w.join(' → ')}). Something each swap replaces is not released — see docs/content-lifecycle-rules.md ` +
382
+ '(handle.releaseEntity for content under handle.engine.root). Warned once.',
383
+ );
384
+ }
385
+ return live;
298
386
  }
299
387
 
300
388
  /** One GL call of `kind` that took `ms`, at `t` (instrumentGl's wrappers; tests call it directly). */
@@ -578,6 +666,7 @@ export class DiagRecorder {
578
666
  label: this.label,
579
667
  switches: [...this.switches],
580
668
  rigLocked: this.rigLocked,
669
+ glObjects: { ...this.glObjects, live: this.liveGl(), shaderCache: safe(() => this.shaderCacheState?.()) ?? null, programRelease: safe(() => this.programReleaseState?.()) ?? null },
581
670
  transitions: this.transitions,
582
671
  events: this.events,
583
672
  frames: this.frames,
@@ -794,7 +883,8 @@ export function startDiagLoop(rec, { overlay = true, doc = typeof document !== '
794
883
  `dxr-diag ${rec.label} phase ${rec.phase}` +
795
884
  (Number.isFinite(rec.callAt) ? ` +${Math.round(now - rec.callAt)} ms since setSource` : '') +
796
885
  (rec.switches.size ? ` [${[...rec.switches].join(',')}]` : '') +
797
- (last ? `\nframe dt ${last.dt} ms held ${last.held ? 'YES' : 'no'} img ${last.img}${last.img !== 'none' ? ' ' + last.imgW : ''}` : '');
886
+ (last ? `\nframe dt ${last.dt} ms held ${last.held ? 'YES' : 'no'} img ${last.img}${last.img !== 'none' ? ' ' + last.imgW : ''}` : '') +
887
+ (rec._gl ? `\n${glObjectsLine(rec)}` : '');
798
888
  strip(rec.frames, 0, now - WINDOW_MS, now, rec.callAt);
799
889
  if (frozen && g) {
800
890
  strip(frozen.window.map((f) => ({ ...f })), STRIP_H + 18, -PRE_MS, WINDOW_MS - PRE_MS, 0);
@@ -818,6 +908,37 @@ export function startDiagLoop(rec, { overlay = true, doc = typeof document !== '
818
908
  };
819
909
  }
820
910
 
911
+ /**
912
+ * The live-GL-object watch on a PlayCanvas tile: count the context's programs and shaders, show
913
+ * the engine shader-cache patch state, and sample at the content swaps the SDK can see (a
914
+ * container load in the tile's registry, a releaseEntity). setSource samples itself.
915
+ */
916
+ export function watchContent(rec, app, viewer) {
917
+ rec.instrumentGl(app?.graphicsDevice?.gl);
918
+ rec.shaderCacheState = () => app?.graphicsDevice?.__dxrShaderCache ?? null;
919
+ rec.programReleaseState = () => {
920
+ const state = app?.graphicsDevice?.__dxrAutoRelease ?? null;
921
+ const st = autoReleaseStats(app);
922
+ return state ? { state, ...(st || {}) } : null;
923
+ };
924
+ const ar = autoReleaseState(app);
925
+ if (ar) ar.onSweep = (n) => n && rec.contentSwap('auto-release');
926
+ if (viewer) viewer.onContentReleased = () => rec.contentSwap('release');
927
+ app?.assets?.on?.('load', (a) => a?.type === 'container' && rec.contentSwap('container'));
928
+ }
929
+
930
+ /** The overlay's GL objects line: live programs and shaders (created / deleted), the cache patch. */
931
+ export function glObjectsLine(rec) {
932
+ const { programs: p, shaders: s } = rec.glObjects;
933
+ const cache = safe(() => rec.shaderCacheState?.());
934
+ const rel = safe(() => rec.programReleaseState?.());
935
+ return (
936
+ `gl programs ${p.created - p.deleted} live (+${p.created} −${p.deleted}) shaders ${s.created - s.deleted} live (+${s.created} −${s.deleted})` +
937
+ (cache ? ` cache ${cache}` : '') +
938
+ (rel ? ` release ${rel.state}${rel.released !== undefined ? ` (−${rel.released})` : ''}` : '')
939
+ );
940
+ }
941
+
821
942
  /**
822
943
  * `window.__dxrDiag`: every diag tile on the page. `dump()` is the JSON to paste back;
823
944
  * `last` the most recent tile's recorder; `transitions` every tile's transition summaries.
@@ -839,7 +960,7 @@ export function registerDiag(rec) {
839
960
  },
840
961
  };
841
962
  }
842
- rec.label = `splat#${g.tiles.length + 1}`;
963
+ rec.label = `${rec.label || 'splat'}#${g.tiles.length + 1}`;
843
964
  g.tiles.push(rec);
844
965
  const dispose = rec.dispose.bind(rec);
845
966
  rec.dispose = () => {
@@ -73,7 +73,7 @@ import { viewerEaseFor, frameTrackingState } from './inline3d-viewer-ease.js';
73
73
  import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR, eyeSplit, EYE_SPLIT_UNIFORM, makeSbsMaterial } from './inline3d-splat-video.js';
74
74
  import { LayerRigCameras, validateLayerRig, validateLayerRigOptions, DEFAULT_VIEWER_DISTANCE_M } from './inline3d-splat-layer-rig.js';
75
75
  import { RigTracker, remapViews, nodePose, sameRig } from './inline3d-splat-rig-map.js';
76
- import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag, DIAG_SWITCHES } from './inline3d-splat-diag.js';
76
+ import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag, watchContent, DIAG_SWITCHES } from './inline3d-splat-diag.js';
77
77
  import {
78
78
  glBufferLimits,
79
79
  probeBufferLimits,
@@ -127,6 +127,9 @@ import {
127
127
  framedSubjectBounds,
128
128
  } from './inline3d-splat-shared.js';
129
129
  import { resolveCursorOption } from './inline3d-cursor-option.js';
130
+ import { installShaderCacheRelease } from './inline3d-pc-shader-cache.js';
131
+ import { installAutoProgramRelease } from './inline3d-pc-auto-release.js';
132
+ import { releaseEntity as releaseEntityIn } from './inline3d-pc-release.js';
130
133
 
131
134
  /**
132
135
  * The component systems the tile's `AppBase` registers. Camera + GSplat draw the splat; Render,
@@ -1284,6 +1287,18 @@ export class PlayCanvasSplatViewer {
1284
1287
  return shaders.size;
1285
1288
  }
1286
1289
 
1290
+ /**
1291
+ * Destroy `entity` (content a page added under `engine.root`) and release the programs its
1292
+ * materials linked, plus its glTF container (`asset`) when nothing else draws from it —
1293
+ * ./inline3d-pc-release.js, docs/content-lifecycle-rules.md. Returns { programs, container }.
1294
+ */
1295
+ releaseEntity(entity, { asset = null } = {}) {
1296
+ if (!this.app || this._disposed) return { programs: 0, container: null };
1297
+ const r = releaseEntityIn(this.app, entity, { asset });
1298
+ this.onContentReleased?.(r);
1299
+ return r;
1300
+ }
1301
+
1287
1302
  useSceneColor(on = true) {
1288
1303
  this.sceneColor = !!on;
1289
1304
  const cams = [this.eye, ...(this._viewPath === 'cameras' ? this._views : [])];
@@ -1415,7 +1430,7 @@ export class PlayCanvasSplatViewer {
1415
1430
  * @param {object} o.perf the resolved playcanvasPerfSettings().
1416
1431
  * @param {string} [o.viewPath] force 'cameras' | 'renderview' (diagnostics).
1417
1432
  */
1418
- async attachEngine(pc, { preserveDrawingBuffer = false, perf, viewPath, antialias = false, patchSplats = true } = {}) {
1433
+ async attachEngine(pc, { preserveDrawingBuffer = false, perf, viewPath, antialias = false, patchSplats = true, shaderCacheRelease = true, programRelease = true } = {}) {
1419
1434
  this.pc = pc;
1420
1435
  const device = await pc.createGraphicsDevice(this.canvas, {
1421
1436
  deviceTypes: [pc.DEVICETYPE_WEBGL2],
@@ -1431,6 +1446,11 @@ export class PlayCanvasSplatViewer {
1431
1446
  device.destroy?.();
1432
1447
  return null;
1433
1448
  }
1449
+ // The engine keeps every compiled shader until the device dies, even once its program is
1450
+ // destroyed: count them per program and free the last one out (./inline3d-pc-shader-cache.js).
1451
+ // `?dxrdiag=keepshaders` leaves the engine's cache as it ships (A/B; page-wide, first tile wins).
1452
+ if (shaderCacheRelease) installShaderCacheRelease(device);
1453
+ else device.__dxrShaderCache ??= 'off (keepshaders)';
1434
1454
  const opts = new pc.AppOptions();
1435
1455
  opts.graphicsDevice = device;
1436
1456
  // No xr (AppBase constructs XrManager only when asked, and XrManager is what probes and
@@ -1442,6 +1462,11 @@ export class PlayCanvasSplatViewer {
1442
1462
  const app = new pc.AppBase(this.canvas);
1443
1463
  app.init(opts);
1444
1464
  guardDuplicateSystems(app);
1465
+ // A material the page destroys (entity.destroy, a container's unload) keeps every program it
1466
+ // linked for the device's life: release them a frame later, when nothing else uses them
1467
+ // (./inline3d-pc-auto-release.js). `?dxrdiag=keepprograms` leaves the engine as it ships (A/B).
1468
+ if (programRelease) installAutoProgramRelease(app, pc.Material);
1469
+ else device.__dxrAutoRelease ??= 'off (keepprograms)';
1445
1470
  // The SDK sizes the buffer (double-width in 3D, 1:1 in mono), so the engine must never
1446
1471
  // resize it. RESOLUTION_FIXED is AppBase's DEFAULT, and with it `updateCanvasSize()` is a
1447
1472
  // no-op. Deliberately NOT calling setCanvasResolution/setCanvasFillMode: without explicit
@@ -3583,6 +3608,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3583
3608
  await first.catch(() => null);
3584
3609
  return removed ? 0 : viewer.warmEntity(entity, o);
3585
3610
  },
3611
+ /**
3612
+ * Destroy `entity` (content under handle.engine.root) and release the programs its materials
3613
+ * linked, and its container `asset` when nothing else draws from it — viewer.releaseEntity.
3614
+ * Returns { programs, container }.
3615
+ */
3616
+ releaseEntity(entity, o) {
3617
+ return removed ? { programs: 0, container: null } : viewer.releaseEntity(entity, o);
3618
+ },
3586
3619
  setStereo,
3587
3620
  setDepthEnvelope,
3588
3621
  /**
@@ -4745,12 +4778,15 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4745
4778
  perf: perfResolved,
4746
4779
  viewPath: opts.playcanvasViewPath,
4747
4780
  antialias: antialias === true,
4781
+ shaderCacheRelease: !diag?.has('keepshaders'),
4782
+ programRelease: !diag?.has('keepprograms'),
4748
4783
  });
4749
4784
  if (!app || removed) return null;
4750
4785
  pcModule = pc;
4751
4786
  // diag: count the GL calls that can block (compile, link, status queries, readbacks, syncs),
4752
4787
  // from before the first asset's compile on
4753
- if (diag) diag.instrumentGl(app.graphicsDevice?.gl);
4788
+ // + the live program / shader counts and the content-swap leak watch
4789
+ if (diag) watchContent(diag, app, viewer);
4754
4790
  /**
4755
4791
  * ADVANCED, not covered by the semver promise: the engine objects behind this window.
4756
4792
  * `app` is the tile's `pc.AppBase`; `root` the content root, in the splat's content space
@@ -5335,6 +5371,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
5335
5371
  const gen = ++sourceGen;
5336
5372
  if (diag) {
5337
5373
  rigLockArmed = true;
5374
+ diag.contentSwap('setSource'); // the previous swap's outgoing asset is released by now
5338
5375
  diag.setPhase('swap', {
5339
5376
  transition: plan.sequence ? `${plan.sequence.name}(${plan.sequence.out.effect}>${plan.sequence.in.effect})` : plan.transition,
5340
5377
  outgoing: plan.sequence ? 'none (one splat)' : plan.outgoing || 'default',
@@ -447,6 +447,8 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
447
447
  },
448
448
  /** PlayCanvas-backend feature (its renderer links synchronously); nothing to warm here. */
449
449
  warmEntity: () => Promise.resolve(0),
450
+ /** PlayCanvas-backend feature: three.js content is the page's to dispose (docs/content-lifecycle-rules.md). */
451
+ releaseEntity: () => ({ programs: 0, container: null }),
450
452
  stopEffect() {
451
453
  throw effectsNotOnSpark('stopEffect()');
452
454
  },
package/llms.txt CHANGED
@@ -15,6 +15,7 @@ detail, a cover that always comes off). The SDK's defaults do the rest.
15
15
  ## Reference
16
16
  - docs/authoring-inline-3d.md — every API, both view rigs, comfort maths
17
17
  - docs/woven-canvas-rules.md — what the browser does to a woven canvas; CSS, remount and cover rules
18
+ - docs/content-lifecycle-rules.md — swapping content in a window: release programs, textures, containers (handle.releaseEntity)
18
19
  - docs/porting-three-js-apps.md — moving an existing three.js app over
19
20
  - docs/authoring-motion-and-effects.md, docs/splat-effects.md — motion, reveals, depth envelopes
20
21
  - index.d.ts, three.d.ts, viewer.d.ts, model.d.ts, splat.d.ts — the public API with JSDoc
package/model.d.ts CHANGED
@@ -283,6 +283,18 @@ export interface ModelHandle {
283
283
  * docs/playcanvas-adapter.md §warmEntity.
284
284
  */
285
285
  warmEntity(entity?: unknown, opts?: { timeoutMs?: number }): Promise<number>;
286
+ /**
287
+ * `engine: 'playcanvas'` only (a no-op returning `{ programs: 0, container: null }` on three).
288
+ * Destroy `entity` — content you added under `handle.engine.root` — and release what it held on
289
+ * the GPU: the shader programs its materials linked (PlayCanvas never frees a program by itself:
290
+ * its ProgramLibrary caches every variant for the device's life, and `Material.destroy()` does
291
+ * not destroy them), and, with `asset`, the glTF container it was instantiated from (unloaded
292
+ * and taken out of the registry unless something else in the tile still draws from it). A
293
+ * program another material still uses is kept. Call it for whatever a content swap replaces.
294
+ * Returns the number of programs destroyed and what happened to the container.
295
+ * docs/content-lifecycle-rules.md.
296
+ */
297
+ releaseEntity(entity: unknown, opts?: { asset?: unknown }): { programs: number; container: 'unloaded' | 'in-use' | null };
286
298
  unexclude(el: Element): void;
287
299
  /**
288
300
  * The core window's `TileHandle.firstWoven`: when it is safe to reveal the canvas. Resolves
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.47.4",
3
+ "version": "1.47.6",
4
4
  "description": "Turn any HTML <canvas> into a 3D window on a spatial display, inside an ordinary web page, for the DisplayXR Browser. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
@@ -74,6 +74,7 @@
74
74
  "llms.txt",
75
75
  "docs/making-3d-look-right.md",
76
76
  "docs/woven-canvas-rules.md",
77
+ "docs/content-lifecycle-rules.md",
77
78
  "js/inline3d.js",
78
79
  "js/inline3d-undock.js",
79
80
  "js/inline3d-display-metrics.js",
@@ -108,6 +109,9 @@
108
109
  "js/inline3d-model-entry.js",
109
110
  "js/inline3d-model-playcanvas.js",
110
111
  "js/inline3d-pc-look.js",
112
+ "js/inline3d-pc-auto-release.js",
113
+ "js/inline3d-pc-release.js",
114
+ "js/inline3d-pc-shader-cache.js",
111
115
  "js/inline3d-playcanvas-engine.js",
112
116
  "js/inline3d-player.js",
113
117
  "js/inline3d-camera.js",