@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.
- package/CHANGELOG.md +75 -0
- package/README.md +7 -1
- package/dist/call.js +5 -5
- package/docs/content-lifecycle-rules.md +270 -0
- package/js/camera/capture.js +31 -13
- 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-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 +5 -1
- package/splat.d.ts +12 -0
|
@@ -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 =
|
|
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
|
-
|
|
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',
|
package/js/inline3d-splat.js
CHANGED
|
@@ -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.
|
|
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",
|