@displayxr/inline3d 1.47.5 → 1.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +143 -1
- package/README.md +7 -1
- package/call.d.ts +98 -8
- package/camera.d.ts +34 -5
- package/dist/call.js +8 -6
- package/docs/content-lifecycle-rules.md +270 -0
- package/docs/woven-canvas-rules.md +25 -13
- package/js/call/element.js +7 -3
- package/js/call/options.js +20 -3
- package/js/call/transport.js +11 -1
- package/js/call/ui.js +8 -1
- package/js/call/wire.js +163 -6
- package/js/camera/capture.js +107 -20
- package/js/camera/conv-meter.js +152 -0
- package/js/inline3d-call.js +392 -71
- package/js/inline3d-camera.js +29 -6
- package/js/inline3d-model-entry.js +3 -0
- package/js/inline3d-model-playcanvas.js +36 -2
- package/js/inline3d-pc-auto-release.js +191 -0
- package/js/inline3d-pc-release.js +107 -0
- package/js/inline3d-pc-shader-cache.js +172 -0
- package/js/inline3d-playcanvas-engine.js +2 -1
- package/js/inline3d-player.js +8 -4
- package/js/inline3d-splat-deferred.js +2 -0
- package/js/inline3d-splat-diag.js +125 -4
- package/js/inline3d-splat-playcanvas.js +40 -3
- package/js/inline3d-splat.js +2 -0
- package/llms.txt +1 -0
- package/model.d.ts +12 -0
- package/package.json +12 -1
- package/signaling/README.md +289 -0
- package/signaling/dev-server.mjs +320 -0
- package/signaling/gate.mjs +112 -0
- package/signaling/keys.mjs +123 -0
- package/signaling/limits.mjs +221 -0
- package/signaling/room.mjs +322 -0
- package/signaling/turn-budget.mjs +254 -0
- package/splat.d.ts +12 -0
package/js/inline3d-camera.js
CHANGED
|
@@ -431,7 +431,13 @@ class StereoCamera {
|
|
|
431
431
|
* promise rejects only when NO camera opens, with `code` `'camera-busy'` (every device is held),
|
|
432
432
|
* `'permission-denied'`, or `'no-camera'`, and `skipped` listing every device tried.
|
|
433
433
|
*
|
|
434
|
-
*
|
|
434
|
+
* A probe never opens a device whose label marks it as a sensor rather than a camera (a hand-tracking
|
|
435
|
+
* camera, an eye tracker's or depth camera's stream — capture.js CAMERA_EXCLUDE_BUILTIN), nor one
|
|
436
|
+
* matching `exclude` (strings: a label substring, case-insensitive, or an exact deviceId; RegExps:
|
|
437
|
+
* the label). `exclude: false` turns the built-in list off too. Left-out devices are in `skipped`
|
|
438
|
+
* with `excluded: true`. An explicit `deviceId` is the page's choice and is opened regardless.
|
|
439
|
+
*
|
|
440
|
+
* @param {object|string|MediaStream} [opts] `{ prefer, format, calibration, rectify, exclude, debug }` (or just `prefer`)
|
|
435
441
|
*/
|
|
436
442
|
export async function openCamera(opts = {}) {
|
|
437
443
|
const o = typeof opts === 'string' || (opts && typeof opts.getVideoTracks === 'function') ? { prefer: opts } : opts || {};
|
|
@@ -441,7 +447,7 @@ export async function openCamera(opts = {}) {
|
|
|
441
447
|
// `horizontalFovDeg` is the public spelling (RFC 0003 §4); the hello wire field is `hfovDeg`.
|
|
442
448
|
if (cal.horizontalFovDeg > 0 && !(cal.hfovDeg > 0)) cal.hfovDeg = cal.horizontalFovDeg;
|
|
443
449
|
delete cal.horizontalFovDeg;
|
|
444
|
-
const r = await openDevice(prefer, { format: o.format, calibration: cal, mediaDevices: o.mediaDevices, log: log || undefined });
|
|
450
|
+
const r = await openDevice(prefer, { format: o.format, calibration: cal, mediaDevices: o.mediaDevices, log: log || undefined, exclude: o.exclude });
|
|
445
451
|
if (r.format === 'sbs' && typeof o.rectify === 'function') {
|
|
446
452
|
// A calibrated rectification step (plug-in or runtime supplied): its output is a rectified
|
|
447
453
|
// SBS stream, and the record then says so. A throwing hook keeps the raw pair.
|
|
@@ -498,7 +504,12 @@ function sizeFlat(canvas) {
|
|
|
498
504
|
* pair — with `fallbackReason` saying why, and `onRouteChange(route, state)` tells the page (a
|
|
499
505
|
* badge reads `route`). A refused layer is retried a few times; `_reroute(true, wall)` recovers.
|
|
500
506
|
*
|
|
501
|
-
*
|
|
507
|
+
* `aspect: 'fill'` crops each eye to the canvas's own CSS box (read every frame) instead of a fixed
|
|
508
|
+
* aspect, for a page that sizes the tile itself. `rectCover: 'auto'` is the core's rect cover
|
|
509
|
+
* (`addImage`'s `rectCover`) on the woven registration: the tile is covered across a later resize
|
|
510
|
+
* or move until it is woven again.
|
|
511
|
+
*
|
|
512
|
+
* @param {{ mirror?: boolean, autoConverge?: boolean, depth?: number, aspect?: number|'fill', rectCover?: 'auto'|'off'|object, onRouteChange?: Function }} [opts]
|
|
502
513
|
*/
|
|
503
514
|
export function addCameraView(wall, canvas, cam, opts = {}) {
|
|
504
515
|
if (!canvas || typeof canvas.getContext !== 'function') throw new TypeError(`${TAG} addCameraView(wall, canvas, cam, opts) needs a canvas`);
|
|
@@ -516,7 +527,10 @@ class CameraView {
|
|
|
516
527
|
// A self view mirrors; a rear camera shows the world as it is. Unset: mirror unless the track
|
|
517
528
|
// says it faces the environment (an unknown facing is a webcam pointed at the viewer).
|
|
518
529
|
this.mirror = o.mirror === undefined ? cam.facing !== 'environment' : !!o.mirror;
|
|
519
|
-
|
|
530
|
+
// 'fill': the canvas box's own aspect, read at paint (clamped like a number) — `_eyeAspect()`.
|
|
531
|
+
this.aspect = o.aspect === 'fill' ? 'fill' : typeof o.aspect === 'number' && o.aspect > 0.3 && o.aspect < 4 ? o.aspect : 16 / 9;
|
|
532
|
+
// The core's rect cover on the woven registration (undefined = the core's default, off).
|
|
533
|
+
this.rectCover = o.rectCover;
|
|
520
534
|
this.autoConverge = !!o.autoConverge;
|
|
521
535
|
this.conv = createConvergence();
|
|
522
536
|
this.conv.depth = 0;
|
|
@@ -638,7 +652,7 @@ class CameraView {
|
|
|
638
652
|
_registerWoven() {
|
|
639
653
|
if (!this.buffer) this.buffer = document.createElement('canvas');
|
|
640
654
|
this._paintWoven(); // the first frame exists before the layer does
|
|
641
|
-
const handle = this.wall.addImage(this.canvas, this.buffer);
|
|
655
|
+
const handle = this.rectCover === undefined ? this.wall.addImage(this.canvas, this.buffer) : this.wall.addImage(this.canvas, this.buffer, { rectCover: this.rectCover });
|
|
642
656
|
this.handle = handle;
|
|
643
657
|
this._fw = null;
|
|
644
658
|
const fw = handle && handle.firstWoven;
|
|
@@ -749,7 +763,7 @@ class CameraView {
|
|
|
749
763
|
const H = v.videoHeight;
|
|
750
764
|
if (!W || !H || (v.readyState || 0) < 2 || !this.buffer) return;
|
|
751
765
|
const eyeW = W / 2;
|
|
752
|
-
const A = this.
|
|
766
|
+
const A = this._eyeAspect();
|
|
753
767
|
const { w: outW, h: outH } = eyeOutputSize(eyeW, H, A);
|
|
754
768
|
const c = this.buffer;
|
|
755
769
|
if (c.width !== 2 * outW || c.height !== outH) {
|
|
@@ -783,6 +797,15 @@ class CameraView {
|
|
|
783
797
|
}
|
|
784
798
|
}
|
|
785
799
|
|
|
800
|
+
/** The per-eye aspect the pair is cropped to: the number given, or (`'fill'`) the canvas box's. */
|
|
801
|
+
_eyeAspect() {
|
|
802
|
+
if (this.aspect !== 'fill') return this.aspect;
|
|
803
|
+
const w = this.canvas.clientWidth || 0;
|
|
804
|
+
const h = this.canvas.clientHeight || 0;
|
|
805
|
+
if (w > 0 && h > 0) this._fillAspect = Math.max(0.3, Math.min(4, w / h));
|
|
806
|
+
return this._fillAspect || 16 / 9;
|
|
807
|
+
}
|
|
808
|
+
|
|
786
809
|
/**
|
|
787
810
|
* A small grayscale copy of the SOURCE frame (before our shift): ~5 Hz for auto-convergence,
|
|
788
811
|
* ~15 Hz while a depth cursor hovers (it measures the same copy under the pointer).
|
|
@@ -153,6 +153,9 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
|
|
|
153
153
|
depthFactor: 1,
|
|
154
154
|
// Before the backend lands: once it has, the backend's own (PlayCanvas); three has none (0).
|
|
155
155
|
warmEntity: (entity, o) => out.ready.then(() => (out.warmEntity !== stubWarm && typeof out.warmEntity === 'function' ? out.warmEntity(entity, o) : 0)),
|
|
156
|
+
// PlayCanvas replaces this once it has loaded; before that (and on three) there is nothing of
|
|
157
|
+
// the engine's to release.
|
|
158
|
+
releaseEntity: () => ({ programs: 0, container: null }),
|
|
156
159
|
};
|
|
157
160
|
const stubWarm = out.warmEntity;
|
|
158
161
|
out.firstWoven = new Promise((resolve) => {
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import { PlayCanvasSplatViewer, pageRigTRS, pageViewAxis, pageViewRig } from './inline3d-splat-playcanvas.js';
|
|
41
41
|
import { resolveControls, normalizeCameraPose, now, MAX_DT_S, declareViewRig, attachModelStereo, attachComfortDepth, resolveDepthOption, framedSubjectBounds } from './inline3d-splat-shared.js';
|
|
42
42
|
import { displayRig } from './inline3d-three.js';
|
|
43
|
+
import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag, watchContent } from './inline3d-splat-diag.js';
|
|
43
44
|
import {
|
|
44
45
|
NEUTRAL_STUDIO,
|
|
45
46
|
neutralStudioRadiance,
|
|
@@ -479,6 +480,11 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
479
480
|
let handle = null;
|
|
480
481
|
let removed = false;
|
|
481
482
|
let lastPagePose = null;
|
|
483
|
+
// `?dxrdiag` (or `diag`): the splat handle's recorder, for what a model tile can show — frame
|
|
484
|
+
// cadence, and the live GL objects + content-swap leak watch (./inline3d-splat-diag.js).
|
|
485
|
+
const diagCfg = resolveDiag(opts.diag);
|
|
486
|
+
const diag = diagCfg.on ? new DiagRecorder({ switches: diagCfg.switches, label: 'model' }) : null;
|
|
487
|
+
let diagLoop = null;
|
|
482
488
|
|
|
483
489
|
Object.assign(out, {
|
|
484
490
|
backend: 'playcanvas',
|
|
@@ -514,6 +520,8 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
514
520
|
},
|
|
515
521
|
remove() {
|
|
516
522
|
removed = true;
|
|
523
|
+
diagLoop?.remove();
|
|
524
|
+
diag?.dispose();
|
|
517
525
|
depth.dispose();
|
|
518
526
|
handle?.remove();
|
|
519
527
|
viewer.dispose(); // app.destroy(): the model, its textures and any page entities go with it
|
|
@@ -528,6 +536,14 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
528
536
|
await out.ready?.catch?.(() => null);
|
|
529
537
|
return removed ? 0 : viewer.warmEntity(entity ?? out.model, o);
|
|
530
538
|
},
|
|
539
|
+
/**
|
|
540
|
+
* Destroy `entity` (content a page added under handle.engine.root) and release the programs
|
|
541
|
+
* its materials linked, and its container `asset` when nothing else draws from it —
|
|
542
|
+
* PlayCanvasSplatViewer.releaseEntity. Returns { programs, container }.
|
|
543
|
+
*/
|
|
544
|
+
releaseEntity(entity, o) {
|
|
545
|
+
return removed ? { programs: 0, container: null } : viewer.releaseEntity(entity, o);
|
|
546
|
+
},
|
|
531
547
|
});
|
|
532
548
|
|
|
533
549
|
// The per-eye buffer scale in force (the request times the device-limit clamp,
|
|
@@ -550,8 +566,15 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
550
566
|
});
|
|
551
567
|
if (pageMode) depth.ignore("controls:'page' (the page's camera rig)");
|
|
552
568
|
|
|
569
|
+
const onFrame = diag
|
|
570
|
+
? (views, layer, frame) => {
|
|
571
|
+
const t = performance.now();
|
|
572
|
+
viewer.onFrame(views, layer, frame);
|
|
573
|
+
diag.frame(views, t);
|
|
574
|
+
}
|
|
575
|
+
: viewer.onFrame;
|
|
553
576
|
if (wall && wall.supported) {
|
|
554
|
-
handle = wall.addScene(canvas,
|
|
577
|
+
handle = wall.addScene(canvas, onFrame, {
|
|
555
578
|
...(pageMode
|
|
556
579
|
? { viewRig: pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: 2, comfortDepth: ctl.comfortDepth }) }
|
|
557
580
|
: { virtualDisplayHeight }),
|
|
@@ -575,6 +598,12 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
575
598
|
delete out._resolveFirstWoven;
|
|
576
599
|
}
|
|
577
600
|
|
|
601
|
+
if (diag) {
|
|
602
|
+
diagLoop = startDiagLoop(diag, { overlay: !diag.has('nooverlay') && !!handle });
|
|
603
|
+
if (diagLoop?.el) handle?.exclude?.(diagLoop.el);
|
|
604
|
+
registerDiag(diag);
|
|
605
|
+
}
|
|
606
|
+
|
|
578
607
|
for (const [name, args] of pending) {
|
|
579
608
|
if (name === 'remove') {
|
|
580
609
|
out.remove();
|
|
@@ -637,8 +666,13 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
637
666
|
}
|
|
638
667
|
const wanted = PC_DECODER_KINDS.filter((k) => (k === 'meshopt' && meshoptDecoder) || (declared ? declared.has(PC_DECODERS[k].ext) : false));
|
|
639
668
|
|
|
640
|
-
|
|
669
|
+
// `?dxrdiag=keepshaders`: the engine's shader cache as it ships (A/B; see ./inline3d-pc-shader-cache.js).
|
|
670
|
+
const shaderCacheRelease = !diag?.has('keepshaders');
|
|
671
|
+
// `?dxrdiag=keepprograms`: destroyed materials keep their programs (A/B; ./inline3d-pc-auto-release.js).
|
|
672
|
+
const programRelease = !diag?.has('keepprograms');
|
|
673
|
+
const app = await viewer.attachEngine(pc, { preserveDrawingBuffer, antialias, patchSplats: false, shaderCacheRelease, programRelease });
|
|
641
674
|
if (!app || removed) return out;
|
|
675
|
+
if (diag) watchContent(diag, app, viewer);
|
|
642
676
|
out.engine = Object.freeze({ app, root: viewer.content, camera: viewer.eye || null });
|
|
643
677
|
|
|
644
678
|
// Lighting before the model, so the first frame the model is in is lit.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// inline3d-pc-auto-release.js — when a page destroys a material on one of the SDK's PlayCanvas
|
|
2
|
+
// devices, release the programs it linked, without the page asking.
|
|
3
|
+
//
|
|
4
|
+
// Internal. PlayCanvasSplatViewer.attachEngine (./inline3d-splat-playcanvas.js) installs it on
|
|
5
|
+
// every app it creates, for ./splat (engine: 'playcanvas') and ./model. docs/content-lifecycle-rules.md
|
|
6
|
+
// § rule 1 has the why; `?dxrdiag=keepprograms` turns it off (A/B).
|
|
7
|
+
//
|
|
8
|
+
// WHY. `Material.destroy()` empties `material.variants` and destroys no program, and the engine's
|
|
9
|
+
// ProgramLibrary keeps every program it linked for the device's life. A page that swaps content
|
|
10
|
+
// under one live app (`entity.destroy()`, `asset.unload()`, which destroys the container's
|
|
11
|
+
// materials) therefore keeps every program it ever linked: measured 0 → 170 live programs over 28
|
|
12
|
+
// screen changes on a kiosk app, none deleted. `handle.releaseEntity` releases them on request;
|
|
13
|
+
// this does the same for every material the page destroys, so a page that follows the engine's
|
|
14
|
+
// own teardown is bounded too.
|
|
15
|
+
//
|
|
16
|
+
// HOW. Two methods on the engine's Material prototype, wrapped once (the prototype is shared by
|
|
17
|
+
// every app on the page, so both wrappers act only on devices registered here):
|
|
18
|
+
// - getShaderVariant: the one place a material links a program. Records the material (weakly)
|
|
19
|
+
// on its device, so a live material that holds a program keeps it (step 3);
|
|
20
|
+
// - destroy: BEFORE the engine empties `variants`, collects the programs the material and its
|
|
21
|
+
// mesh instances hold, and marks the material destroyed.
|
|
22
|
+
// The sweep runs once per frame that had a destroy, at the app's `frameend` (after render, so
|
|
23
|
+
// content added in the same frame has drawn and holds its programs), with a timer as backup for a
|
|
24
|
+
// stopped app. It destroys each collected program that nothing still uses:
|
|
25
|
+
// 1. no mesh instance under `app.root` or in a layer, and no material asset in the registry
|
|
26
|
+
// (the keep set of ./inline3d-pc-release.js);
|
|
27
|
+
// 2. still registered on the device (not already destroyed, e.g. by releaseEntity);
|
|
28
|
+
// 3. no material that linked it is still alive. ProgramLibrary hands one Shader to every material
|
|
29
|
+
// with the same key, and a material a page keeps off-scene to show again still has it in its
|
|
30
|
+
// `variants`; destroying it under that material would leave a dead program there.
|
|
31
|
+
// `Shader.destroy()` also takes the program out of the library, so a later ask for that variant
|
|
32
|
+
// compiles it again. Cost: content shown again after release recompiles on its first draw
|
|
33
|
+
// (`handle.warmEntity` hides that).
|
|
34
|
+
|
|
35
|
+
import { liveShaders } from './inline3d-pc-release.js';
|
|
36
|
+
|
|
37
|
+
const states = new WeakMap(); // device -> { app, materials:Set<WeakRef>, pending:Set, scheduled, stats }
|
|
38
|
+
let patchedProto = null;
|
|
39
|
+
|
|
40
|
+
const deref = (r) => (typeof r?.deref === 'function' ? r.deref() : r);
|
|
41
|
+
const weak = (x) => (typeof WeakRef === 'function' ? new WeakRef(x) : x);
|
|
42
|
+
|
|
43
|
+
function collect(material) {
|
|
44
|
+
const into = (s) => {
|
|
45
|
+
const st = s && states.get(s.device);
|
|
46
|
+
if (!st) return;
|
|
47
|
+
st.pending.add(s);
|
|
48
|
+
schedule(st);
|
|
49
|
+
};
|
|
50
|
+
material.variants?.forEach?.(into);
|
|
51
|
+
material.meshInstances?.forEach?.((mi) => mi?._shaderCache?.forEach?.((si) => into(si?.shader)));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Programs some still-alive material on `st`'s device has linked. Prunes dead entries. */
|
|
55
|
+
function heldByLiveMaterials(st) {
|
|
56
|
+
const held = new Set();
|
|
57
|
+
for (const r of st.materials) {
|
|
58
|
+
const m = deref(r);
|
|
59
|
+
if (!m || m.__dxrDestroyed) {
|
|
60
|
+
st.materials.delete(r);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
m.variants?.forEach?.((s) => s && held.add(s));
|
|
64
|
+
}
|
|
65
|
+
return held;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function schedule(st) {
|
|
69
|
+
if (st.scheduled) return;
|
|
70
|
+
st.scheduled = true;
|
|
71
|
+
const run = (st.run ||= () => sweep(st));
|
|
72
|
+
try {
|
|
73
|
+
st.app.once?.('frameend', run);
|
|
74
|
+
} catch {
|
|
75
|
+
/* no event system: the timer below */
|
|
76
|
+
}
|
|
77
|
+
st.timer = setTimeout(run, 1000);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Destroy every collected program nothing still uses. Returns how many. Exported for tests. */
|
|
81
|
+
export function sweep(st) {
|
|
82
|
+
if (!st || !st.scheduled) return 0;
|
|
83
|
+
st.scheduled = false;
|
|
84
|
+
clearTimeout(st.timer);
|
|
85
|
+
if (st.run) st.app.off?.('frameend', st.run); // only ours: the page may listen to frameend too
|
|
86
|
+
if (!st.pending.size || st.off) {
|
|
87
|
+
st.pending.clear();
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
let keep;
|
|
91
|
+
try {
|
|
92
|
+
keep = liveShaders(st.app);
|
|
93
|
+
} catch {
|
|
94
|
+
st.pending.clear(); // an app mid-teardown: it frees everything itself
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
const held = heldByLiveMaterials(st);
|
|
98
|
+
let n = 0;
|
|
99
|
+
for (const s of st.pending) {
|
|
100
|
+
if (keep.has(s) || held.has(s) || typeof s.destroy !== 'function') continue;
|
|
101
|
+
const registered = s.device?.shaders;
|
|
102
|
+
if (Array.isArray(registered) && !registered.includes(s)) continue; // already destroyed
|
|
103
|
+
try {
|
|
104
|
+
s.destroy();
|
|
105
|
+
n++;
|
|
106
|
+
} catch {
|
|
107
|
+
/* a program the engine already tore down */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
st.pending.clear();
|
|
111
|
+
st.stats.released += n;
|
|
112
|
+
st.stats.sweeps++;
|
|
113
|
+
st.onSweep?.(n);
|
|
114
|
+
return n;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The prototype has the shape this relies on (2.22.x): `getShaderVariant(params)` with
|
|
119
|
+
* `params.device`, and a `destroy()` that clears `variants`. Returns a reason string, or null.
|
|
120
|
+
*/
|
|
121
|
+
export function materialShapeProblem(proto) {
|
|
122
|
+
if (!proto || typeof proto.getShaderVariant !== 'function' || typeof proto.destroy !== 'function') return 'no Material.getShaderVariant/destroy';
|
|
123
|
+
const d = Function.prototype.toString.call(proto.destroy);
|
|
124
|
+
if (!/variants/.test(d)) return 'Material.destroy no longer clears variants';
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Wrap the engine's Material prototype once. Returns 'patched' | 'already' | 'skipped: …'. */
|
|
129
|
+
export function patchMaterialPrototype(proto) {
|
|
130
|
+
if (patchedProto === proto || proto?.__dxrAutoRelease) return 'already';
|
|
131
|
+
const problem = materialShapeProblem(proto);
|
|
132
|
+
if (problem) return `skipped: ${problem}`;
|
|
133
|
+
const getShaderVariant = proto.getShaderVariant;
|
|
134
|
+
const destroy = proto.destroy;
|
|
135
|
+
proto.getShaderVariant = function (params) {
|
|
136
|
+
const s = getShaderVariant.apply(this, arguments);
|
|
137
|
+
const st = states.get(params?.device ?? s?.device);
|
|
138
|
+
if (st && !this.__dxrTracked) {
|
|
139
|
+
this.__dxrTracked = true;
|
|
140
|
+
st.materials.add(weak(this));
|
|
141
|
+
}
|
|
142
|
+
return s;
|
|
143
|
+
};
|
|
144
|
+
proto.destroy = function () {
|
|
145
|
+
try {
|
|
146
|
+
collect(this);
|
|
147
|
+
} catch {
|
|
148
|
+
/* never let the release break the engine's own teardown */
|
|
149
|
+
}
|
|
150
|
+
this.__dxrDestroyed = true;
|
|
151
|
+
return destroy.apply(this, arguments);
|
|
152
|
+
};
|
|
153
|
+
proto.__dxrAutoRelease = true;
|
|
154
|
+
patchedProto = proto;
|
|
155
|
+
return 'patched';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Release the programs of destroyed materials on `app`'s device. `Material` is the engine's
|
|
160
|
+
* Material class (the one the page's own `import … from 'playcanvas'` also resolves to).
|
|
161
|
+
* The result is kept on `device.__dxrAutoRelease`.
|
|
162
|
+
*/
|
|
163
|
+
export function installAutoProgramRelease(app, Material) {
|
|
164
|
+
const device = app?.graphicsDevice;
|
|
165
|
+
if (!device) return null;
|
|
166
|
+
if (states.has(device)) return device.__dxrAutoRelease;
|
|
167
|
+
const patched = patchMaterialPrototype(Material?.prototype);
|
|
168
|
+
const result = patched === 'already' ? 'patched' : patched; // one prototype for every app on the page
|
|
169
|
+
device.__dxrAutoRelease = result;
|
|
170
|
+
if (result.startsWith('skipped')) return result;
|
|
171
|
+
const st = { app, materials: new Set(), pending: new Set(), scheduled: false, timer: 0, off: false, stats: { released: 0, sweeps: 0 } };
|
|
172
|
+
states.set(device, st);
|
|
173
|
+
app.once?.('destroy', () => {
|
|
174
|
+
st.off = true;
|
|
175
|
+
clearTimeout(st.timer);
|
|
176
|
+
st.pending.clear();
|
|
177
|
+
st.materials.clear();
|
|
178
|
+
});
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Programs released on `app`'s device so far (diagnostics), or null. */
|
|
183
|
+
export function autoReleaseStats(app) {
|
|
184
|
+
const st = app?.graphicsDevice && states.get(app.graphicsDevice);
|
|
185
|
+
return st ? { ...st.stats, pending: st.pending.size, materials: st.materials.size } : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The state for `app` (tests, and diag's per-sweep hook). */
|
|
189
|
+
export function autoReleaseState(app) {
|
|
190
|
+
return (app?.graphicsDevice && states.get(app.graphicsDevice)) || null;
|
|
191
|
+
}
|
|
@@ -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,
|