@displayxr/inline3d 1.1.0 → 1.2.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 +150 -0
- package/README.md +9 -0
- package/index.d.ts +12 -0
- package/js/inline3d-model.js +382 -8
- package/js/inline3d-three.js +23 -2
- package/js/inline3d-viewer.js +208 -7
- package/js/inline3d.js +33 -1
- package/model.d.ts +48 -1
- package/package.json +2 -2
- package/three.d.ts +12 -0
- package/viewer.d.ts +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,156 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
|
|
|
5
5
|
`./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
|
|
6
6
|
which tier they touch, because that is what tells you whether an upgrade can move your pixels.
|
|
7
7
|
|
|
8
|
+
## 1.2.0 — 2026-08-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`./model` lights meshes with an image-based environment by default (`environment: 'room'`).**
|
|
13
|
+
The previous default, `studio`, is three punctual lights and nothing else. A punctual light
|
|
14
|
+
contributes a specular highlight but does not fill a metallic BRDF, so a `metalness: 1` surface
|
|
15
|
+
sampled an empty environment and resolved to **black** — chrome rendering as a dark disc, glass
|
|
16
|
+
lenses as opaque holes. The failure reads as a corrupt asset rather than a lighting choice, and
|
|
17
|
+
it cost real debugging time: two perfectly good models were discarded as broken before the cause
|
|
18
|
+
was found.
|
|
19
|
+
|
|
20
|
+
`environment` now takes `'room' | 'studio' | 'none'` and defaults to `'room'`, which bakes a
|
|
21
|
+
PMREM from three's procedural `RoomEnvironment` — generated in memory, so this buys IBL with no
|
|
22
|
+
HDRI to fetch and keeps an offline or kiosk build free of a CDN in its critical path. `'studio'`
|
|
23
|
+
remains for wholly dielectric matte content, and an explicit `envMap` still overrides both. Only
|
|
24
|
+
meshes are affected: splats carry their own baked radiance and never enter this path.
|
|
25
|
+
|
|
26
|
+
**This moves your pixels.** Metal and glass gain reflections they should always have had, and
|
|
27
|
+
dielectric surfaces pick up a softer ambient. Pass `environment: 'studio'` to keep 1.1.1's look.
|
|
28
|
+
*(preview tier — changed default)*
|
|
29
|
+
|
|
30
|
+
- **`./model` loads compressed glTF — Draco, meshopt and KTX2/Basis.** `addModel()` resolved
|
|
31
|
+
`GLTFLoader` and called a bare `new Loader().loadAsync(src)`, so any asset carrying a compression
|
|
32
|
+
extension threw outright: `"No DRACOLoader instance provided."` for
|
|
33
|
+
`KHR_draco_mesh_compression`, `"setMeshoptDecoder must be called before loading compressed
|
|
34
|
+
files"` for `EXT_meshopt_compression`, and a failed texture for `KHR_texture_basisu`. These are
|
|
35
|
+
hard failures, not degraded loads, and Draco is close to universal in catalogue GLBs — so the
|
|
36
|
+
module's own premise, that a retailer's existing glTF renders unchanged, was false for most real
|
|
37
|
+
files.
|
|
38
|
+
|
|
39
|
+
`addModel` now reads the asset's `extensionsUsed` / `extensionsRequired` **before** parsing and
|
|
40
|
+
attaches exactly the decoders it declares. The inspection is a single `fetch` whose bytes go
|
|
41
|
+
straight to `loader.parse()`, so it replaces the loader's own request rather than adding one, and
|
|
42
|
+
an asset that declares no compression imports nothing, constructs nothing and costs exactly what
|
|
43
|
+
it did in 1.1.1 (measured: zero decoder requests, one request for the asset). Decoders are shared
|
|
44
|
+
across tiles and ref-counted by configuration, so a grid of twelve products stands up one Draco
|
|
45
|
+
worker pool and one tile's `remove()` cannot tear down the pool the other eleven are decoding on.
|
|
46
|
+
*(preview tier — additive)*
|
|
47
|
+
|
|
48
|
+
- **`decoderPath`, and the reason it is not a CDN.** three ships the Draco decoder and the Basis
|
|
49
|
+
transcoder as *runtime* files that `DRACOLoader` / `KTX2Loader` fetch at decode time, so
|
|
50
|
+
something has to say where they are. The default is **your own origin** —
|
|
51
|
+
`{draco:'/draco/', basis:'/basis/'}`, the layout `cp -r node_modules/three/examples/jsm/libs/…`
|
|
52
|
+
produces — and there is deliberately **no CDN fallback**: pages built on this SDK include offline
|
|
53
|
+
kiosk builds, and a default that reached for `cdn.jsdelivr.net` the first time someone opened a
|
|
54
|
+
compressed product would make the page's offline story depend on one asset's compression setting.
|
|
55
|
+
A string is a parent directory holding both; an object overrides either key.
|
|
56
|
+
`EXT_meshopt_compression` needs nothing served — its decoder is pure JS. *(preview tier —
|
|
57
|
+
additive)*
|
|
58
|
+
|
|
59
|
+
- **`DRACOLoader` / `KTX2Loader` / `meshoptDecoder` injection**, mirroring the existing
|
|
60
|
+
`GLTFLoader` escape. A **class** is constructed for you and pointed at `decoderPath`; an
|
|
61
|
+
**instance** is used exactly as you configured it and its decoder path is left alone — rewriting
|
|
62
|
+
it would defeat the one thing people inject for. `KTX2Loader.detectSupport()` is called for you
|
|
63
|
+
with the tile's own `WebGLRenderer` (`SceneViewer` owns it, and it exists synchronously by the
|
|
64
|
+
time the asset lands), and re-called per tile on a shared instance. *(preview tier — additive)*
|
|
65
|
+
|
|
66
|
+
- **A failure message aimed at the page author.** A missing or mis-served decoder rejects
|
|
67
|
+
`handle.ready` with an error naming the **glTF extension**, the **option that fixes it** and the
|
|
68
|
+
**path it actually looked in** — plus `err.gltfExtension` / `err.decoder` for programmatic
|
|
69
|
+
handling. three's own message names a class the page never mentions and says nothing about the
|
|
70
|
+
two things that resolve it. The 404 case reads: *needs the "KHR_draco_mesh_compression" decoder
|
|
71
|
+
(Draco mesh compression) and it could not be used … Currently looking in "/draco/" — check that
|
|
72
|
+
it is actually served … Underlying error: fetch for ".../draco_wasm_wrapper.js" responded with
|
|
73
|
+
404*. *(preview tier — additive)*
|
|
74
|
+
|
|
75
|
+
- **KTX2's silent failure is now loud.** `GLTFLoader` swallows a texture-load rejection, so a
|
|
76
|
+
mis-served Basis transcoder resolved a model with **zero textures and no error** — seven
|
|
77
|
+
compressed textures became none and `ready` resolved (measured on three r180). `addModel` now
|
|
78
|
+
loads the transcoder eagerly via `KTX2Loader.init()` once an asset declares
|
|
79
|
+
`KHR_texture_basisu`, which converts that into the named rejection above. *(preview tier)*
|
|
80
|
+
|
|
81
|
+
- **`samples/model/` gains a Draco tile**, with three's Draco decoder served out of this repo at
|
|
82
|
+
`vendor/draco/` and the sample passing `decoderPath` — because the site is hosted under a path
|
|
83
|
+
prefix, which is exactly the case where the absolute default 404s. The tile is the same
|
|
84
|
+
`addModel` call as the others; only the asset differs. Duck (CC0, Khronos glTF-Sample-Assets).
|
|
85
|
+
|
|
86
|
+
### Fixed
|
|
87
|
+
|
|
88
|
+
- **`remove()` releases compressed textures.** `disposeTree` disposed geometries and materials but
|
|
89
|
+
not the materials' texture maps, which is real GPU memory as soon as KTX2 is in play and a
|
|
90
|
+
catalogue churns through it. *(preview tier)*
|
|
91
|
+
|
|
92
|
+
## 1.1.1 — 2026-08-20
|
|
93
|
+
|
|
94
|
+
### Fixed
|
|
95
|
+
|
|
96
|
+
- **`./viewer` validates a frame before it clears the canvas — the dark blink under GPU load
|
|
97
|
+
(web#12).** `SceneViewer.onFrame` cleared unconditionally and then rendered whatever it could.
|
|
98
|
+
Under load the session hands the callback a **short view list** — one view, or none, a per-frame
|
|
99
|
+
mono fallback — and the old loop turned that into a cleared buffer with a single origin-camera
|
|
100
|
+
view drawn into it whose content is entirely near-plane-clipped: a fully transparent
|
|
101
|
+
side-by-side buffer, i.e. **one dark woven tile**. The blink was the viewer's, not the weave's;
|
|
102
|
+
it was reported as a compositor fault (glTF and splat tiles blinking on a busy box) with the
|
|
103
|
+
whole submit/match path provably healthy.
|
|
104
|
+
|
|
105
|
+
Now every disqualifying condition — a short view list, a `null` or degenerate
|
|
106
|
+
`layer.getViewport(view)`, a disposed viewer — is checked **while the canvas still holds the
|
|
107
|
+
last good image**, and only a frame that will draw is allowed to clear. A frame that cannot
|
|
108
|
+
draw **replays the last good one** from per-eye `Float32Array(16)` copies of
|
|
109
|
+
`projectionMatrix` / `transform.matrix` plus the viewport rects (copies, because an `XRView` is
|
|
110
|
+
valid only inside its own frame callback), rather than skipping the commit — the SDK's
|
|
111
|
+
every-frame-repaint invariant is real, and an un-redrawn canvas can drop out of the aggregated
|
|
112
|
+
frame and leave the weave reading a stale sub-rect. A one-frame-stale eye pose is
|
|
113
|
+
imperceptible; a black frame and a smear are not. Before the first good frame there is nothing
|
|
114
|
+
to replay, and the frame simply returns without clearing.
|
|
115
|
+
|
|
116
|
+
**This changes pixels only on frames that were previously black.** A frame that passed
|
|
117
|
+
validation renders byte-for-byte as it did in 1.1.0 — same clear, same viewports, same
|
|
118
|
+
matrices, same order. *(preview tier)*
|
|
119
|
+
|
|
120
|
+
- **A no-op resize no longer blanks the tile (web#12).** `renderer.setSize()` writes
|
|
121
|
+
`canvas.width`/`canvas.height` unconditionally, and writing either — *including the same value*
|
|
122
|
+
— reallocates and clears the drawing buffer. `ResizeObserver` fires on things that leave the
|
|
123
|
+
buffer's dimensions exactly where they were (a sub-pixel reflow, a scrollbar coming and going, a
|
|
124
|
+
sibling settling), and its callback runs after rAF and before paint, so each one committed a
|
|
125
|
+
black frame with nothing on the way to repaint it. `_resize` now compares against
|
|
126
|
+
`renderer.domElement.width/height` and returns early when nothing moved; a real change resizes
|
|
127
|
+
and then **immediately** re-renders from the replay cache (rects scaled to the new buffer), so
|
|
128
|
+
the cleared store never reaches the compositor. Observer bursts coalesce to one animation frame,
|
|
129
|
+
matching what the core already does for its own windows. *(preview tier)*
|
|
130
|
+
|
|
131
|
+
- **`SceneViewer` without `useEyeCamera()` says so instead of rendering nothing.** With no
|
|
132
|
+
`./three` glue the 3D path had no eye camera, so it cleared and drew nothing every frame,
|
|
133
|
+
forever, in silence — and this module's own header example omitted the call, making the failure
|
|
134
|
+
reachable by copy-paste. It now warns once and renders the **mono camera** into both eye
|
|
135
|
+
viewports (flat, but visible), and the example passes `EyeCamera`. `./splat` and `./model` were
|
|
136
|
+
never affected — they supply the glue for you. *(preview tier)*
|
|
137
|
+
|
|
138
|
+
### Added
|
|
139
|
+
|
|
140
|
+
- **`EyeCamera.setFromMatrices(projectionMatrix, transformMatrix)`** — the same two matrices an
|
|
141
|
+
`XRView` carries, handed over separately, for re-drawing a frame you have already drawn.
|
|
142
|
+
`setFromView` is now a one-line forward to it, so a replay path can never drift from the live
|
|
143
|
+
one. *(core tier — additive)*
|
|
144
|
+
- **`handle.stats()` → `{ frames, monoFrames }`** on the handle every `add*()` returns. For scene
|
|
145
|
+
windows, `monoFrames` counts the deliveries that carried fewer than two views — the
|
|
146
|
+
load-induced fallback that used to be invisible from the page, since nothing throws and nothing
|
|
147
|
+
logs. A rising ratio is the machine telling you the session is degrading before it becomes a bug
|
|
148
|
+
report about "blinking"; one throttled `console.debug` (the first, then 1-in-300) names the
|
|
149
|
+
rate. The core's own contract is unchanged: the view list is passed to `onFrame` exactly as
|
|
150
|
+
reported, filtered by nothing and synthesised from nothing. *(core tier — additive)*
|
|
151
|
+
- **Unit tests.** `test/*.test.mjs` under `node --test`, with the DOM and three.js stubbed by
|
|
152
|
+
hand (`test/stubs.mjs`) so the test run needs no dependency either. They pin the rules above:
|
|
153
|
+
zero `clear()` calls for an empty view list, a one-eye list, a null viewport and a missing
|
|
154
|
+
layer; a replay that renders the cached matrices and survives the UA recycling the views it
|
|
155
|
+
cached from; no `setSize` on a no-op resize; an immediate repaint after a real one. 13 of the
|
|
156
|
+
15 fail against 1.1.0. Wired into CI as a second job.
|
|
157
|
+
|
|
8
158
|
## 1.1.0 — 2026-08-19
|
|
9
159
|
|
|
10
160
|
### Added
|
package/README.md
CHANGED
|
@@ -24,6 +24,9 @@ npm install @displayxr/inline3d
|
|
|
24
24
|
import { createInline3D } from '@displayxr/inline3d';
|
|
25
25
|
import { EyeCamera, EdgeFeather } from '@displayxr/inline3d/three'; // optional three.js glue
|
|
26
26
|
import { addSplat } from '@displayxr/inline3d/splat'; // experimental: 3DGS in a tile
|
|
27
|
+
import { addModel } from '@displayxr/inline3d/model'; // experimental: glTF/GLB in a tile
|
|
28
|
+
// (Draco / meshopt / KTX2 too —
|
|
29
|
+
// you serve the decoder files)
|
|
27
30
|
import { SceneViewer } from '@displayxr/inline3d/viewer'; // experimental: framing + orbit
|
|
28
31
|
```
|
|
29
32
|
|
|
@@ -78,12 +81,18 @@ samples/
|
|
|
78
81
|
windows/ mixed 3D windows — still photos + a live video + a real-time three.js scene,
|
|
79
82
|
each woven with one SDK call, all on one session
|
|
80
83
|
splat/ a 3D Gaussian splat in a tile, auto-framed, with a 2D price plate over it
|
|
84
|
+
model/ a glTF mesh, a mesh+splat scene, and a Draco-COMPRESSED glTF in three tiles
|
|
85
|
+
composition/ the 14-case 2D/3D overlap matrix — demo AND standing hardware regression
|
|
86
|
+
surface; red cases ship red (see samples/README.md)
|
|
87
|
+
vendor/draco/ three's Draco decoder, served for samples/model (compressed glTF needs it)
|
|
81
88
|
js/
|
|
82
89
|
inline3d.js the SDK: createInline3D() → { addImage, addVideo, addScene }, feature-detect,
|
|
83
90
|
SBS buffer management, and a lazy create/close lifecycle for many windows
|
|
84
91
|
inline3d-three.js optional three.js helper (EyeCamera: off-axis projection from the session's eyes)
|
|
85
92
|
inline3d-viewer.js experimental: SceneViewer — framing, orbit, idle turntable, mono fallback
|
|
86
93
|
inline3d-splat.js experimental: addSplat() — a Gaussian splat window via Spark
|
|
94
|
+
inline3d-model.js experimental: addModel() — a glTF/GLB window; wires Draco / meshopt / KTX2
|
|
95
|
+
from what the asset declares (you serve the decoder files — see the guide)
|
|
87
96
|
docs/
|
|
88
97
|
authoring-inline-3d.md the authoring guide
|
|
89
98
|
```
|
package/index.d.ts
CHANGED
|
@@ -52,6 +52,18 @@ export interface TileHandle {
|
|
|
52
52
|
* @deprecated See {@link TileHandle.exclude} — no-op on browsers with draw-order occlusion.
|
|
53
53
|
*/
|
|
54
54
|
unexclude(el: Element): void;
|
|
55
|
+
/**
|
|
56
|
+
* Per-window frame counters, for diagnosing the load-induced mono fallback.
|
|
57
|
+
*
|
|
58
|
+
* `frames` counts `onFrame` deliveries; `monoFrames` counts the ones that carried fewer than
|
|
59
|
+
* two views — a session under GPU pressure reporting a single view where it normally reports
|
|
60
|
+
* two. `./viewer` replays its last good stereo frame for those rather than clearing (web#12);
|
|
61
|
+
* a rising ratio is the machine telling you the session is falling back, and is worth
|
|
62
|
+
* surfacing before it turns into a bug report about "blinking".
|
|
63
|
+
*
|
|
64
|
+
* Scene windows only — image/video windows always report `{ frames: 0, monoFrames: 0 }`.
|
|
65
|
+
*/
|
|
66
|
+
stats(): { frames: number; monoFrames: number };
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
/** An open inline-3D session you add weaved windows to. Returned by {@link createInline3D}. */
|
package/js/inline3d-model.js
CHANGED
|
@@ -21,10 +21,29 @@
|
|
|
21
21
|
// "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
|
|
22
22
|
//
|
|
23
23
|
// You can also hand the class in directly (`opts.GLTFLoader`) and skip the specifier entirely.
|
|
24
|
+
//
|
|
25
|
+
// ── COMPRESSED ASSETS ───────────────────────────────────────────────────────────────────────
|
|
26
|
+
// "Render the catalogue unchanged" is only true if the catalogue's actual files load, and a real
|
|
27
|
+
// e-commerce GLB is nearly always Draco-compressed. A bare GLTFLoader cannot decode any of the
|
|
28
|
+
// three compression extensions — it throws, it does not degrade — so this module inspects the
|
|
29
|
+
// asset's `extensionsUsed` before parsing and attaches exactly the decoders it declares:
|
|
30
|
+
//
|
|
31
|
+
// KHR_draco_mesh_compression → DRACOLoader (needs decoder files SERVED by your page)
|
|
32
|
+
// KHR_texture_basisu → KTX2Loader (needs transcoder files SERVED by your page)
|
|
33
|
+
// EXT_meshopt_compression → MeshoptDecoder (pure JS, nothing to serve)
|
|
34
|
+
//
|
|
35
|
+
// Nothing is imported or instantiated for an asset that declares none of them — an uncompressed
|
|
36
|
+
// GLB costs exactly what it did before. The decoder BINARIES are deliberately not fetched from a
|
|
37
|
+
// CDN: pages that ship this SDK include offline kiosk builds, so the default looks for them on
|
|
38
|
+
// your own origin (`/draco/`, `/basis/`) and `opts.decoderPath` moves that. See
|
|
39
|
+
// docs/authoring-inline-3d.md#compressed-gltf.
|
|
24
40
|
|
|
25
41
|
import * as THREE from 'three';
|
|
26
42
|
import { EyeCamera, EdgeFeather } from './inline3d-three.js';
|
|
27
43
|
import { SceneViewer } from './inline3d-viewer.js';
|
|
44
|
+
// Procedural — built in memory, no asset to serve. Imported eagerly rather than lazily because it
|
|
45
|
+
// is the default lighting path, so deferring it would only add a frame of unlit content.
|
|
46
|
+
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
|
28
47
|
|
|
29
48
|
/** Cached across calls so a grid of models resolves the loader module once. */
|
|
30
49
|
let _GLTFLoader = null;
|
|
@@ -38,6 +57,246 @@ async function resolveLoader(injected) {
|
|
|
38
57
|
return _GLTFLoader;
|
|
39
58
|
}
|
|
40
59
|
|
|
60
|
+
// ── the three compression extensions, and everything needed to talk about them ───────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One entry per decoder: the glTF extension that demands it, where its class lives, where its
|
|
64
|
+
* runtime files live, and which option overrides each. The error messages are generated from
|
|
65
|
+
* this table, so a message can never name an option that does not exist.
|
|
66
|
+
*/
|
|
67
|
+
const DECODERS = {
|
|
68
|
+
draco: {
|
|
69
|
+
ext: 'KHR_draco_mesh_compression',
|
|
70
|
+
label: 'Draco mesh compression',
|
|
71
|
+
module: 'three/addons/loaders/DRACOLoader.js',
|
|
72
|
+
exportName: 'DRACOLoader',
|
|
73
|
+
option: 'DRACOLoader',
|
|
74
|
+
pathKey: 'draco',
|
|
75
|
+
files: 'three/examples/jsm/libs/draco/',
|
|
76
|
+
attach: (loader, d) => loader.setDRACOLoader(d),
|
|
77
|
+
},
|
|
78
|
+
ktx2: {
|
|
79
|
+
ext: 'KHR_texture_basisu',
|
|
80
|
+
label: 'KTX2 / Basis Universal textures',
|
|
81
|
+
module: 'three/addons/loaders/KTX2Loader.js',
|
|
82
|
+
exportName: 'KTX2Loader',
|
|
83
|
+
option: 'KTX2Loader',
|
|
84
|
+
pathKey: 'basis',
|
|
85
|
+
files: 'three/examples/jsm/libs/basis/',
|
|
86
|
+
attach: (loader, d) => loader.setKTX2Loader(d),
|
|
87
|
+
},
|
|
88
|
+
meshopt: {
|
|
89
|
+
ext: 'EXT_meshopt_compression',
|
|
90
|
+
label: 'meshopt compression',
|
|
91
|
+
module: 'three/addons/libs/meshopt_decoder.module.js',
|
|
92
|
+
exportName: 'MeshoptDecoder',
|
|
93
|
+
option: 'meshoptDecoder',
|
|
94
|
+
pathKey: null, // pure JS + inlined wasm; nothing for the page to serve
|
|
95
|
+
files: null,
|
|
96
|
+
attach: (loader, d) => loader.setMeshoptDecoder(d),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const DECODER_KINDS = /** @type {const} */ (['draco', 'ktx2', 'meshopt']);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Where the decoder binaries are expected on YOUR origin. Not a CDN, on purpose — see the header.
|
|
104
|
+
* `/draco/` and `/basis/` are the paths three's own examples use, and the ones every "copy these
|
|
105
|
+
* two folders into public/" recipe on the web produces.
|
|
106
|
+
*/
|
|
107
|
+
const DEFAULT_DECODER_PATH = { draco: '/draco/', basis: '/basis/' };
|
|
108
|
+
|
|
109
|
+
/** `'/vendor/'` → `{draco:'/vendor/draco/', basis:'/vendor/basis/'}`; an object overrides per-key. */
|
|
110
|
+
function normalizeDecoderPath(v) {
|
|
111
|
+
if (!v) return { ...DEFAULT_DECODER_PATH };
|
|
112
|
+
if (typeof v === 'string') {
|
|
113
|
+
const base = v.endsWith('/') ? v : `${v}/`;
|
|
114
|
+
return { draco: `${base}draco/`, basis: `${base}basis/` };
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
draco: v.draco || DEFAULT_DECODER_PATH.draco,
|
|
118
|
+
basis: v.basis || v.ktx2 || DEFAULT_DECODER_PATH.basis,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Decoders are SHARED across tiles, ref-counted by configuration key. A DRACOLoader owns a worker
|
|
124
|
+
* pool; a catalogue grid of twelve products must not stand up twelve of them, and one tile's
|
|
125
|
+
* `remove()` must not tear down the pool the other eleven are decoding on. Injected decoders never
|
|
126
|
+
* enter this map — they belong to the caller, who disposes them.
|
|
127
|
+
*/
|
|
128
|
+
const _shared = new Map(); // key -> { refs, p: Promise<decoder> }
|
|
129
|
+
|
|
130
|
+
function acquireShared(key, make) {
|
|
131
|
+
let e = _shared.get(key);
|
|
132
|
+
if (!e) {
|
|
133
|
+
e = { refs: 0, p: make() };
|
|
134
|
+
// A failed build must not poison every later attempt with a rejected promise.
|
|
135
|
+
e.p.catch(() => _shared.delete(key));
|
|
136
|
+
_shared.set(key, e);
|
|
137
|
+
}
|
|
138
|
+
e.refs++;
|
|
139
|
+
return e.p;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function releaseShared(key) {
|
|
143
|
+
const e = _shared.get(key);
|
|
144
|
+
if (!e || --e.refs > 0) return;
|
|
145
|
+
_shared.delete(key);
|
|
146
|
+
e.p.then((d) => d?.dispose?.()).catch(() => {});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Import (or accept) a decoder and configure it. `injected` may be a class OR a ready instance —
|
|
151
|
+
* MeshoptDecoder is a namespace object rather than a class, and a caller who already holds a
|
|
152
|
+
* configured DRACOLoader should be able to hand THAT in rather than a constructor.
|
|
153
|
+
*
|
|
154
|
+
* `decoderPath` is applied only to instances WE construct. An instance you hand in is used exactly
|
|
155
|
+
* as you configured it: silently rewriting its decoder path would make injection useless for the
|
|
156
|
+
* one thing people inject for, which is pointing it somewhere unusual.
|
|
157
|
+
*/
|
|
158
|
+
async function buildDecoder(kind, paths, injected) {
|
|
159
|
+
const spec = DECODERS[kind];
|
|
160
|
+
let thing = injected;
|
|
161
|
+
if (!thing) {
|
|
162
|
+
const mod = await import(spec.module);
|
|
163
|
+
thing = mod[spec.exportName];
|
|
164
|
+
if (!thing) throw new Error(`${spec.module} has no export "${spec.exportName}"`);
|
|
165
|
+
}
|
|
166
|
+
if (typeof thing !== 'function') return thing; // already an instance (or the meshopt namespace)
|
|
167
|
+
const d = new thing();
|
|
168
|
+
if (kind === 'draco') d.setDecoderPath?.(paths.draco);
|
|
169
|
+
if (kind === 'ktx2') d.setTranscoderPath?.(paths.basis);
|
|
170
|
+
return d;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @returns {Promise<{decoder:object, key:string|null}>} `key` is set when the tile took a share. */
|
|
174
|
+
async function getDecoder(kind, paths, injected, renderer) {
|
|
175
|
+
let decoder;
|
|
176
|
+
let key = null;
|
|
177
|
+
if (injected) {
|
|
178
|
+
decoder = await buildDecoder(kind, paths, injected);
|
|
179
|
+
} else {
|
|
180
|
+
const p = DECODERS[kind].pathKey;
|
|
181
|
+
key = `${kind}|${p ? paths[p] : ''}`;
|
|
182
|
+
decoder = await acquireShared(key, () => buildDecoder(kind, paths, null));
|
|
183
|
+
}
|
|
184
|
+
if (kind === 'ktx2') {
|
|
185
|
+
// MUST run with the renderer that will sample the texture: detectSupport reads the context's
|
|
186
|
+
// compressed-texture extensions to pick a transcode target. Re-run per tile because the shared
|
|
187
|
+
// instance may have been built against a sibling's context; it is a cheap flag assignment.
|
|
188
|
+
decoder.detectSupport?.(renderer);
|
|
189
|
+
// And then force the transcoder to load NOW, because KTX2 is the one decoder that fails
|
|
190
|
+
// SILENTLY: GLTFLoader swallows a texture-load rejection, so a mis-served transcoder resolves
|
|
191
|
+
// a model with ZERO textures and no error at all (measured on three r180 — 7 textures became
|
|
192
|
+
// 0, `ready` resolved). init() turns that into a rejection naming the URL it could not fetch.
|
|
193
|
+
await decoder.init?.();
|
|
194
|
+
}
|
|
195
|
+
return { decoder, key };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── asset inspection ─────────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
const GLB_MAGIC = 0x46546c67; // 'glTF', little-endian
|
|
201
|
+
const GLB_CHUNK_JSON = 0x4e4f534a; // 'JSON'
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Fetch the asset ONCE and read its glTF JSON header. The bytes are handed to `loader.parse()`
|
|
205
|
+
* afterwards, so inspecting costs no extra request — this replaces the loader's own fetch rather
|
|
206
|
+
* than adding to it.
|
|
207
|
+
*/
|
|
208
|
+
async function fetchAndInspect(src) {
|
|
209
|
+
const res = await fetch(src);
|
|
210
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} for ${src}`);
|
|
211
|
+
const buffer = await res.arrayBuffer();
|
|
212
|
+
return { buffer, json: gltfJsonOf(buffer) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function gltfJsonOf(buffer) {
|
|
216
|
+
const view = new DataView(buffer);
|
|
217
|
+
const text = new TextDecoder();
|
|
218
|
+
if (buffer.byteLength >= 20 && view.getUint32(0, true) === GLB_MAGIC) {
|
|
219
|
+
const len = view.getUint32(12, true);
|
|
220
|
+
if (view.getUint32(16, true) !== GLB_CHUNK_JSON) return null;
|
|
221
|
+
return JSON.parse(text.decode(new Uint8Array(buffer, 20, len)));
|
|
222
|
+
}
|
|
223
|
+
return JSON.parse(text.decode(new Uint8Array(buffer))); // a .gltf is plain JSON
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** `extensionsUsed` ∪ `extensionsRequired`. Draco appears in both; meshopt sometimes only in one. */
|
|
227
|
+
function declaredExtensions(json) {
|
|
228
|
+
const out = new Set();
|
|
229
|
+
for (const key of ['extensionsUsed', 'extensionsRequired']) {
|
|
230
|
+
const list = json?.[key];
|
|
231
|
+
if (Array.isArray(list)) for (const name of list) out.add(name);
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function urlBaseOf(src) {
|
|
237
|
+
const extract = THREE.LoaderUtils?.extractUrlBase;
|
|
238
|
+
if (extract) return extract(src);
|
|
239
|
+
const i = src.lastIndexOf('/');
|
|
240
|
+
return i < 0 ? './' : src.slice(0, i + 1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── errors that tell a page author what to do ────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* three's own messages for these failures ("No DRACOLoader instance provided.") name a class the
|
|
247
|
+
* page never mentions and say nothing about the two things that actually fix it: which option to
|
|
248
|
+
* pass, and which files to serve. This builds the message that does.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} kind a key of DECODERS
|
|
251
|
+
* @param {string} src
|
|
252
|
+
* @param {{draco:string,basis:string}} paths
|
|
253
|
+
* @param {unknown} cause
|
|
254
|
+
* @param {boolean} inspected whether we managed to read the asset's extension list
|
|
255
|
+
*/
|
|
256
|
+
function decoderError(kind, src, paths, cause, inspected) {
|
|
257
|
+
const spec = DECODERS[kind];
|
|
258
|
+
const lines = [
|
|
259
|
+
`[inline3d/model] ${src} needs the "${spec.ext}" decoder (${spec.label}) and it could not be used.`,
|
|
260
|
+
];
|
|
261
|
+
if (spec.files) {
|
|
262
|
+
const dir = spec.pathKey;
|
|
263
|
+
const where = paths[dir];
|
|
264
|
+
lines.push(
|
|
265
|
+
`Serve three's decoder files from your own origin and point addModel at them:`,
|
|
266
|
+
` cp -r node_modules/${spec.files} <web-root>${where.startsWith('/') ? where : `/${dir}/`}`,
|
|
267
|
+
` addModel(wall, canvas, src, { decoderPath: { ${dir}: '${where}' } })`,
|
|
268
|
+
`Currently looking in "${where}" — check that it is actually served (a 404 there fails exactly like this).`,
|
|
269
|
+
);
|
|
270
|
+
} else {
|
|
271
|
+
lines.push(
|
|
272
|
+
`Nothing to serve for this one — the meshopt decoder is pure JS, so this is a module-resolution failure.`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
lines.push(
|
|
276
|
+
`Or hand the decoder in: addModel(…, { ${spec.option}: X }) where X is ${spec.exportName} from '${spec.module}' (a class or a ready instance).`,
|
|
277
|
+
`On a bare importmap, '${spec.module}' additionally needs a "three/addons/" prefix mapping.`,
|
|
278
|
+
);
|
|
279
|
+
if (!inspected) {
|
|
280
|
+
lines.push(
|
|
281
|
+
`(The asset's extension list could not be read up front, so the decoder was not attached automatically.)`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
lines.push(`Underlying error: ${cause?.message || cause}`);
|
|
285
|
+
const err = new Error(lines.join('\n'));
|
|
286
|
+
err.cause = cause;
|
|
287
|
+
err.decoder = kind;
|
|
288
|
+
err.gltfExtension = spec.ext;
|
|
289
|
+
return err;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Which decoder is a raw three.js failure about? Used when inspection failed and three threw. */
|
|
293
|
+
function kindFromMessage(msg) {
|
|
294
|
+
if (/DRACOLoader|draco/i.test(msg)) return 'draco';
|
|
295
|
+
if (/KTX2Loader|basisu|basis/i.test(msg)) return 'ktx2';
|
|
296
|
+
if (/MeshoptDecoder|meshopt/i.test(msg)) return 'meshopt';
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
41
300
|
/**
|
|
42
301
|
* Load a glTF/GLB into an inline-3D window.
|
|
43
302
|
*
|
|
@@ -45,11 +304,26 @@ async function resolveLoader(injected) {
|
|
|
45
304
|
* @param {HTMLCanvasElement} canvas
|
|
46
305
|
* @param {string} src URL of a .glb / .gltf.
|
|
47
306
|
* @param {object} [opts] every option ./splat takes, plus:
|
|
48
|
-
* @param {'studio'|'none'} [opts.environment='
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
307
|
+
* @param {'room'|'studio'|'none'} [opts.environment='room'] how the mesh is lit. Meshes arrive
|
|
308
|
+
* unlit otherwise — unlike splats, which carry their own baked appearance.
|
|
309
|
+
* - `room` (default) bakes an image-based environment from three's procedural
|
|
310
|
+
* RoomEnvironment. Metal and glass NEED this: a punctual light contributes a specular
|
|
311
|
+
* dot but does not fill a metallic BRDF, so under `studio` a `metalness: 1` surface
|
|
312
|
+
* samples an empty environment and resolves to black. Procedural, so it costs no HDRI
|
|
313
|
+
* fetch and an offline build stays offline.
|
|
314
|
+
* - `studio` is the older three-point punctual rig. Cheaper, and fine for wholly dielectric
|
|
315
|
+
* matte content, but it is what makes chrome render as a dark hole.
|
|
316
|
+
* @param {object} [opts.envMap] a PMREM-processed environment texture of your own. Overrides
|
|
317
|
+
* `environment` entirely — pass this when you want the product lit by a specific room.
|
|
52
318
|
* @param {unknown} [opts.GLTFLoader] hand in the class instead of resolving `three/addons/`.
|
|
319
|
+
* @param {string|{draco?:string,basis?:string}} [opts.decoderPath] where YOUR PAGE serves three's
|
|
320
|
+
* Draco decoder and Basis transcoder (default `{draco:'/draco/', basis:'/basis/'}`). A
|
|
321
|
+
* string is treated as a parent directory holding `draco/` and `basis/`. Never a CDN by
|
|
322
|
+
* default: an offline build must not depend on one.
|
|
323
|
+
* @param {unknown} [opts.DRACOLoader] DRACOLoader class or instance, instead of `three/addons/`.
|
|
324
|
+
* @param {unknown} [opts.KTX2Loader] KTX2Loader class or instance. `detectSupport()` is called
|
|
325
|
+
* for you with this tile's renderer.
|
|
326
|
+
* @param {unknown} [opts.meshoptDecoder] MeshoptDecoder namespace, instead of `three/addons/`.
|
|
53
327
|
* @returns {object} the same handle shape as addSplat: a TileHandle plus `viewer`, `model`,
|
|
54
328
|
* `setPose`, `resetPose`, `frame`, and `ready`.
|
|
55
329
|
*/
|
|
@@ -65,12 +339,19 @@ export function addModel(wall, canvas, src, opts = {}) {
|
|
|
65
339
|
fitSweep = true,
|
|
66
340
|
renderScale = 1,
|
|
67
341
|
feather = 0,
|
|
68
|
-
environment = '
|
|
342
|
+
environment = 'room',
|
|
69
343
|
envMap = null,
|
|
70
344
|
GLTFLoader: injectedLoader = null,
|
|
345
|
+
decoderPath = null,
|
|
346
|
+
DRACOLoader: injectedDraco = null,
|
|
347
|
+
KTX2Loader: injectedKtx2 = null,
|
|
348
|
+
meshoptDecoder: injectedMeshopt = null,
|
|
71
349
|
observe,
|
|
72
350
|
} = opts;
|
|
73
351
|
|
|
352
|
+
const paths = normalizeDecoderPath(decoderPath);
|
|
353
|
+
const injected = { draco: injectedDraco, ktx2: injectedKtx2, meshopt: injectedMeshopt };
|
|
354
|
+
|
|
74
355
|
const viewer = new SceneViewer(THREE, canvas, {
|
|
75
356
|
virtualDisplayHeight,
|
|
76
357
|
fit,
|
|
@@ -84,8 +365,12 @@ export function addModel(wall, canvas, src, opts = {}) {
|
|
|
84
365
|
}).useEyeCamera(EyeCamera, EdgeFeather);
|
|
85
366
|
|
|
86
367
|
if (envMap) viewer.scene.environment = envMap;
|
|
368
|
+
else if (environment === 'room') addRoomEnvironment(viewer);
|
|
87
369
|
else if (environment === 'studio') addStudioLights(viewer.scene);
|
|
88
370
|
|
|
371
|
+
/** Shared-decoder keys this tile holds a reference to, released in remove(). */
|
|
372
|
+
const held = [];
|
|
373
|
+
|
|
89
374
|
const out = {
|
|
90
375
|
viewer,
|
|
91
376
|
model: null,
|
|
@@ -96,6 +381,7 @@ export function addModel(wall, canvas, src, opts = {}) {
|
|
|
96
381
|
handle?.remove();
|
|
97
382
|
viewer.dispose();
|
|
98
383
|
if (out.model) disposeTree(out.model);
|
|
384
|
+
while (held.length) releaseShared(held.pop());
|
|
99
385
|
},
|
|
100
386
|
exclude: (el) => handle?.exclude(el),
|
|
101
387
|
unexclude: (el) => handle?.unexclude(el),
|
|
@@ -115,7 +401,49 @@ export function addModel(wall, canvas, src, opts = {}) {
|
|
|
115
401
|
|
|
116
402
|
out.ready = (async () => {
|
|
117
403
|
const Loader = await resolveLoader(injectedLoader);
|
|
118
|
-
const
|
|
404
|
+
const loader = new Loader();
|
|
405
|
+
|
|
406
|
+
// Read the header before parsing, so decoders are attached from what the asset DECLARES
|
|
407
|
+
// rather than from a guess or from a failure. If this can't be done (an exotic URL scheme,
|
|
408
|
+
// a CORS setup fetch dislikes) we fall back to the loader's own fetch and rely on the
|
|
409
|
+
// message-sniffing catch below — the guidance survives, the laziness does not.
|
|
410
|
+
let inspected = null;
|
|
411
|
+
try {
|
|
412
|
+
inspected = await fetchAndInspect(src);
|
|
413
|
+
} catch (err) {
|
|
414
|
+
console.debug('[inline3d/model] could not inspect', src, '— falling back to loadAsync', err);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const declared = inspected?.json ? declaredExtensions(inspected.json) : null;
|
|
418
|
+
const wanted = DECODER_KINDS.filter(
|
|
419
|
+
(k) => injected[k] || (declared ? declared.has(DECODERS[k].ext) : false),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
for (const kind of wanted) {
|
|
423
|
+
let got;
|
|
424
|
+
try {
|
|
425
|
+
got = await getDecoder(kind, paths, injected[kind], viewer.renderer);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
throw decoderError(kind, src, paths, err, !!declared);
|
|
428
|
+
}
|
|
429
|
+
if (got.key) held.push(got.key);
|
|
430
|
+
DECODERS[kind].attach(loader, got.decoder);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let gltf;
|
|
434
|
+
try {
|
|
435
|
+
gltf = inspected
|
|
436
|
+
? await new Promise((res, rej) => loader.parse(inspected.buffer, urlBaseOf(src), res, rej))
|
|
437
|
+
: await loader.loadAsync(src);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
// A decoder that was attached can still fail at decode time — almost always because its
|
|
440
|
+
// files 404 at `decoderPath`, and three's message then carries the URL it could not fetch,
|
|
441
|
+
// which is why the message is asked first. Attributing by elimination is only safe with a
|
|
442
|
+
// single candidate; with none or several, the original error is the honest answer.
|
|
443
|
+
const kind = kindFromMessage(String(err?.message || err)) || (wanted.length === 1 ? wanted[0] : null);
|
|
444
|
+
throw kind ? decoderError(kind, src, paths, err, !!declared) : err;
|
|
445
|
+
}
|
|
446
|
+
|
|
119
447
|
out.model = gltf.scene;
|
|
120
448
|
viewer.content.add(gltf.scene);
|
|
121
449
|
|
|
@@ -144,6 +472,43 @@ function boundsOf(object3d) {
|
|
|
144
472
|
return { center: [c.x, c.y, c.z], extent: [Math.max(e.x, 1e-6), Math.max(e.y, 1e-6), Math.max(e.z, 1e-6)] };
|
|
145
473
|
}
|
|
146
474
|
|
|
475
|
+
/**
|
|
476
|
+
* Bake an image-based environment from three's procedural RoomEnvironment.
|
|
477
|
+
*
|
|
478
|
+
* This is the default because the alternative is silently wrong. `addStudioLights` is punctual
|
|
479
|
+
* only, and a punctual light contributes a specular highlight without filling a metallic BRDF —
|
|
480
|
+
* so a `metalness: 1` surface has nothing to reflect and resolves to BLACK. Chrome bells render as
|
|
481
|
+
* a dark disc, glass lenses as opaque holes, and the result reads as a corrupt asset rather than a
|
|
482
|
+
* lighting choice. It has cost real debugging time more than once.
|
|
483
|
+
*
|
|
484
|
+
* RoomEnvironment is generated in memory — a small box of emissive panels — so this buys IBL with
|
|
485
|
+
* no HDRI to fetch and no CDN in the critical path, which an offline or kiosk build depends on.
|
|
486
|
+
*
|
|
487
|
+
* Deliberately deferred behind the viewer: a PMREM is baked against one renderer's GL context and
|
|
488
|
+
* cannot be shared across renderers, so this cannot be hoisted into a module-level constant even
|
|
489
|
+
* though every tile bakes an identical one. The generator is disposed immediately; the resulting
|
|
490
|
+
* texture is owned by the scene and released with it.
|
|
491
|
+
*
|
|
492
|
+
* Splats never come through here — they carry baked radiance and no PBR material, and an
|
|
493
|
+
* environment would only wash them out.
|
|
494
|
+
*/
|
|
495
|
+
function addRoomEnvironment(viewer) {
|
|
496
|
+
const renderer = viewer?.renderer;
|
|
497
|
+
// No renderer means no context to bake against (a headless or not-yet-realised viewer). Fall
|
|
498
|
+
// back rather than throw: unlit-but-visible beats a tile that fails to appear at all.
|
|
499
|
+
if (!renderer) return addStudioLights(viewer.scene);
|
|
500
|
+
|
|
501
|
+
const pmrem = new THREE.PMREMGenerator(renderer);
|
|
502
|
+
try {
|
|
503
|
+
// Low blur on purpose. These are product shots, so a tighter environment keeps the highlight a
|
|
504
|
+
// travelling band rather than a broad wash — and a highlight that travels as the viewer moves
|
|
505
|
+
// is most of what separates an object from a picture of one on a head-tracked display.
|
|
506
|
+
viewer.scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
507
|
+
} finally {
|
|
508
|
+
pmrem.dispose();
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
147
512
|
/**
|
|
148
513
|
* A neutral three-point rig. Not a substitute for a real environment map on metal or glass, but
|
|
149
514
|
* it has no external dependency and no download, which matters for a tile that may be one of
|
|
@@ -160,10 +525,19 @@ function addStudioLights(scene) {
|
|
|
160
525
|
}
|
|
161
526
|
|
|
162
527
|
function disposeTree(root) {
|
|
528
|
+
const seen = new Set();
|
|
529
|
+
const dropTextures = (mat) => {
|
|
530
|
+
if (!mat || seen.has(mat)) return;
|
|
531
|
+
seen.add(mat);
|
|
532
|
+
// Compressed (KTX2/Basis) textures are real GPU memory and a catalogue churns through them,
|
|
533
|
+
// so a tile's remove() has to give them back — the material's own dispose() does not.
|
|
534
|
+
for (const v of Object.values(mat)) if (v && v.isTexture) v.dispose?.();
|
|
535
|
+
mat.dispose?.();
|
|
536
|
+
};
|
|
163
537
|
root.traverse((o) => {
|
|
164
538
|
o.geometry?.dispose?.();
|
|
165
539
|
const m = o.material;
|
|
166
|
-
if (Array.isArray(m)) m.forEach(
|
|
167
|
-
else m
|
|
540
|
+
if (Array.isArray(m)) m.forEach(dropTextures);
|
|
541
|
+
else dropTextures(m);
|
|
168
542
|
});
|
|
169
543
|
}
|
package/js/inline3d-three.js
CHANGED
|
@@ -60,10 +60,31 @@ export class EyeCamera {
|
|
|
60
60
|
|
|
61
61
|
/** Set the camera's projection + world pose from an XRView (call once per eye per frame). */
|
|
62
62
|
setFromView(view) {
|
|
63
|
+
return this.setFromMatrices(view.projectionMatrix, view.transform.matrix);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Set the camera from RAW matrices — the same two an XRView carries, handed over
|
|
68
|
+
* separately.
|
|
69
|
+
*
|
|
70
|
+
* WHY THIS EXISTS AND NOT JUST setFromView. An `XRView` is valid only inside the frame
|
|
71
|
+
* callback that produced it: hold one and its matrices are live views onto memory the UA
|
|
72
|
+
* recycles. So a renderer that wants to re-draw a frame it has ALREADY drawn — because
|
|
73
|
+
* this frame's view list arrived short, or because the backing store was just reallocated
|
|
74
|
+
* and cleared — cannot keep the view; it has to keep a COPY of the two matrices and feed
|
|
75
|
+
* them back here. `./viewer`'s last-good replay does exactly that (see SceneViewer.onFrame).
|
|
76
|
+
*
|
|
77
|
+
* Deliberately the single implementation of both: setFromView is a one-line forward, so
|
|
78
|
+
* the replay path can never drift from the live one.
|
|
79
|
+
*
|
|
80
|
+
* @param {ArrayLike<number>} projectionMatrix 16 floats, column-major (view.projectionMatrix).
|
|
81
|
+
* @param {ArrayLike<number>} transformMatrix 16 floats, column-major (view.transform.matrix).
|
|
82
|
+
*/
|
|
83
|
+
setFromMatrices(projectionMatrix, transformMatrix) {
|
|
63
84
|
const cam = this.camera;
|
|
64
|
-
cam.projectionMatrix.fromArray(
|
|
85
|
+
cam.projectionMatrix.fromArray(projectionMatrix);
|
|
65
86
|
cam.projectionMatrixInverse.copy(cam.projectionMatrix).invert();
|
|
66
|
-
cam.matrix.fromArray(
|
|
87
|
+
cam.matrix.fromArray(transformMatrix);
|
|
67
88
|
cam.matrixWorld.copy(cam.matrix);
|
|
68
89
|
cam.matrixWorldInverse.copy(cam.matrixWorld).invert();
|
|
69
90
|
return cam;
|
package/js/inline3d-viewer.js
CHANGED
|
@@ -20,9 +20,11 @@
|
|
|
20
20
|
//
|
|
21
21
|
// import * as THREE from 'three';
|
|
22
22
|
// import { createInline3D } from '@displayxr/inline3d';
|
|
23
|
+
// import { EyeCamera } from '@displayxr/inline3d/three';
|
|
23
24
|
// import { SceneViewer } from '@displayxr/inline3d/viewer';
|
|
24
25
|
//
|
|
25
26
|
// const viewer = new SceneViewer(THREE, canvas, { virtualDisplayHeight: 0.18 });
|
|
27
|
+
// viewer.useEyeCamera(EyeCamera); // REQUIRED for stereo; ./splat and ./model do it
|
|
26
28
|
// viewer.content.add(myMesh);
|
|
27
29
|
// viewer.fitTo(center, extent); // model-space bounds of the subject
|
|
28
30
|
// const wall = await createInline3D();
|
|
@@ -226,6 +228,12 @@ export class SceneViewer {
|
|
|
226
228
|
this._monoRaf = 0;
|
|
227
229
|
this._mode = '3d'; // drives the backing-store shape; see _resize
|
|
228
230
|
this._disposed = false;
|
|
231
|
+
this._resizePending = false;
|
|
232
|
+
// Last frame this viewer actually DREW, as raw matrices + viewport rects — never XRViews,
|
|
233
|
+
// which are only valid inside their own frame callback. See _cacheGood / _replayLastGood.
|
|
234
|
+
this._lastGood = null;
|
|
235
|
+
this._vps = []; // scratch, reused per frame so validation allocates nothing
|
|
236
|
+
this._warnedNoEye = false;
|
|
229
237
|
|
|
230
238
|
this._reduceMotion =
|
|
231
239
|
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
@@ -238,7 +246,10 @@ export class SceneViewer {
|
|
|
238
246
|
// plane to be in focus at, so we just look at the framed subject from the front.
|
|
239
247
|
this.monoCamera = new THREE.PerspectiveCamera(35, 1, 0.001, 1000);
|
|
240
248
|
|
|
241
|
-
|
|
249
|
+
// Coalesced: ResizeObserver and window resize both fire in BURSTS during a drag-resize or a
|
|
250
|
+
// zoom, and every genuine resize reallocates (and clears) the backing store. One rAF per
|
|
251
|
+
// burst, exactly as the core does for its own windows (inline3d.js _onBoxChange).
|
|
252
|
+
this._onResize = () => this._scheduleResize();
|
|
242
253
|
this._ro = typeof ResizeObserver === 'function' ? new ResizeObserver(this._onResize) : null;
|
|
243
254
|
if (this._ro) this._ro.observe(canvas);
|
|
244
255
|
else addEventListener('resize', this._onResize);
|
|
@@ -348,6 +359,22 @@ export class SceneViewer {
|
|
|
348
359
|
/**
|
|
349
360
|
* The per-frame callback for `wall.addScene`. Renders the scene once per eye into the
|
|
350
361
|
* side-by-side halves the layer reports.
|
|
362
|
+
*
|
|
363
|
+
* VALIDATE BEFORE YOU CLEAR — the dark-blink rule (web#12). `r.clear()` is the point of no
|
|
364
|
+
* return: after it the canvas is transparent-black, and if the frame then fails to draw
|
|
365
|
+
* anything over it, that empty buffer is what the weave consumes. Under GPU load the session
|
|
366
|
+
* can hand this callback a SHORT view list (one view, or none — a per-frame mono fallback),
|
|
367
|
+
* and the old loop cleared first and rendered what it could: a single origin-camera view whose
|
|
368
|
+
* content is entirely near-plane-clipped, i.e. a fully transparent side-by-side buffer, i.e.
|
|
369
|
+
* one dark woven tile. The blink was ours, not the weave's.
|
|
370
|
+
*
|
|
371
|
+
* So: everything that can disqualify a frame is checked while the canvas still holds the last
|
|
372
|
+
* good image, and only a frame that WILL draw is allowed to clear. A frame that cannot draw
|
|
373
|
+
* REPLAYS the last good one instead (see _replayLastGood) rather than skipping the commit —
|
|
374
|
+
* the SDK's every-frame-repaint invariant is real (inline3d.js `_frame`: a canvas that isn't
|
|
375
|
+
* redrawn can have its layer dropped from the aggregated frame and the weave then reads a
|
|
376
|
+
* stale sub-rect, which smears). A one-frame-stale eye pose is imperceptible; a smear and a
|
|
377
|
+
* black frame are not.
|
|
351
378
|
*/
|
|
352
379
|
onFrame(views, layer) {
|
|
353
380
|
if (this._disposed) return;
|
|
@@ -355,23 +382,62 @@ export class SceneViewer {
|
|
|
355
382
|
// after a scroll-away/scroll-back). Take the buffer back to the SBS shape when that happens
|
|
356
383
|
// — otherwise the first 3D frames render into a 1:1 store and each eye is half a subject.
|
|
357
384
|
if (this._mode !== '3d') this.stopMono();
|
|
385
|
+
// Before the validation gate on purpose: a replayed frame still damps and still turns on the
|
|
386
|
+
// turntable, so only the EYE pose is one frame stale, not the whole scene.
|
|
358
387
|
this._tick();
|
|
359
|
-
|
|
388
|
+
|
|
389
|
+
// 1. A short view list is the load-induced mono fallback. Stereo needs two.
|
|
390
|
+
if (!views || views.length < 2) {
|
|
391
|
+
this._replayLastGood();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 2. No ./three glue: the 3D path has no eye camera to build. This used to clear and draw
|
|
396
|
+
// NOTHING, silently, forever — and this module's own header example omitted
|
|
397
|
+
// useEyeCamera() until now, so the failure was reachable by copy-paste. Both ends are
|
|
398
|
+
// fixed: the example passes it, and this says so once and renders the mono camera, which
|
|
399
|
+
// at least shows the subject (flat, both halves the same) instead of a dark tile.
|
|
360
400
|
const eye = this._ensureEye();
|
|
401
|
+
if (!eye && !this._warnedNoEye) {
|
|
402
|
+
this._warnedNoEye = true;
|
|
403
|
+
console.warn(
|
|
404
|
+
'[inline3d] SceneViewer.onFrame without useEyeCamera(): falling back to the mono camera. ' +
|
|
405
|
+
'Pass the ./three glue — viewer.useEyeCamera(EyeCamera, EdgeFeather) — for real ' +
|
|
406
|
+
'off-axis stereo. (./splat and ./model do this for you.)',
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 3. Every eye must have a viewport to render into. A missing or degenerate one means this
|
|
411
|
+
// frame cannot fill the buffer, so it must not empty it either.
|
|
412
|
+
const vps = this._vps;
|
|
413
|
+
vps.length = 0;
|
|
414
|
+
for (const view of views) {
|
|
415
|
+
const vp = layer && typeof layer.getViewport === 'function' ? layer.getViewport(view) : null;
|
|
416
|
+
if (!vp || !(vp.width > 0) || !(vp.height > 0)) {
|
|
417
|
+
this._replayLastGood();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
vps.push(vp);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Validated: this frame WILL draw over everything it clears.
|
|
424
|
+
const r = this.renderer;
|
|
361
425
|
r.clear();
|
|
362
426
|
r.setScissorTest(true);
|
|
363
|
-
for (
|
|
364
|
-
const vp =
|
|
365
|
-
if (!vp) continue;
|
|
427
|
+
for (let i = 0; i < views.length; i++) {
|
|
428
|
+
const vp = vps[i];
|
|
366
429
|
r.setViewport(vp.x, vp.y, vp.width, vp.height);
|
|
367
430
|
r.setScissor(vp.x, vp.y, vp.width, vp.height);
|
|
368
431
|
if (eye) {
|
|
369
|
-
eye.setFromView(
|
|
432
|
+
eye.setFromView(views[i]);
|
|
370
433
|
r.render(this.scene, eye.camera);
|
|
434
|
+
} else {
|
|
435
|
+
r.render(this.scene, this.monoCamera);
|
|
371
436
|
}
|
|
372
437
|
if (this._feather) this._feather.render(r, vp);
|
|
373
438
|
}
|
|
374
439
|
r.setScissorTest(false);
|
|
440
|
+
this._cacheGood(views, vps, !eye);
|
|
375
441
|
}
|
|
376
442
|
|
|
377
443
|
/**
|
|
@@ -418,6 +484,8 @@ export class SceneViewer {
|
|
|
418
484
|
|
|
419
485
|
dispose() {
|
|
420
486
|
this._disposed = true;
|
|
487
|
+
this._resizePending = false;
|
|
488
|
+
this._lastGood = null;
|
|
421
489
|
this.stopMono();
|
|
422
490
|
if (this._ro) this._ro.disconnect();
|
|
423
491
|
else removeEventListener('resize', this._onResize);
|
|
@@ -432,6 +500,127 @@ export class SceneViewer {
|
|
|
432
500
|
return this._eye;
|
|
433
501
|
}
|
|
434
502
|
|
|
503
|
+
/**
|
|
504
|
+
* Remember the frame just drawn, so a frame that CANNOT draw has something to put on the
|
|
505
|
+
* canvas instead of a clear (web#12).
|
|
506
|
+
*
|
|
507
|
+
* COPIES, never references. An `XRView` — and the `projectionMatrix` / `transform.matrix`
|
|
508
|
+
* hanging off it — is valid only inside the frame callback that delivered it; the UA is free
|
|
509
|
+
* to recycle that memory afterwards. Retaining one would give a replay that reads whatever
|
|
510
|
+
* the next frame happened to write there, which is a worse bug than the blink. So each eye
|
|
511
|
+
* gets two `Float32Array(16)` copies, allocated once and overwritten in place: the cache
|
|
512
|
+
* costs 128 bytes an eye and zero allocations per frame.
|
|
513
|
+
*
|
|
514
|
+
* The buffer dimensions go in too, so a replay after a resize can scale the rects (the SBS
|
|
515
|
+
* split is proportional, so the scaling is exact).
|
|
516
|
+
*/
|
|
517
|
+
_cacheGood(views, vps, mono) {
|
|
518
|
+
const el = this.renderer.domElement || this.canvas;
|
|
519
|
+
let g = this._lastGood;
|
|
520
|
+
if (!g || g.entries.length !== views.length) {
|
|
521
|
+
g = this._lastGood = { entries: [], mono, bufW: 0, bufH: 0 };
|
|
522
|
+
for (let i = 0; i < views.length; i++) {
|
|
523
|
+
g.entries.push({
|
|
524
|
+
proj: new Float32Array(16),
|
|
525
|
+
pose: new Float32Array(16),
|
|
526
|
+
x: 0,
|
|
527
|
+
y: 0,
|
|
528
|
+
width: 0,
|
|
529
|
+
height: 0,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
g.mono = mono;
|
|
534
|
+
g.bufW = el.width || 0;
|
|
535
|
+
g.bufH = el.height || 0;
|
|
536
|
+
for (let i = 0; i < views.length; i++) {
|
|
537
|
+
const e = g.entries[i];
|
|
538
|
+
const vp = vps[i];
|
|
539
|
+
if (!mono) {
|
|
540
|
+
const view = views[i];
|
|
541
|
+
e.proj.set(view.projectionMatrix);
|
|
542
|
+
e.pose.set(view.transform.matrix);
|
|
543
|
+
}
|
|
544
|
+
e.x = vp.x;
|
|
545
|
+
e.y = vp.y;
|
|
546
|
+
e.width = vp.width;
|
|
547
|
+
e.height = vp.height;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Re-render the last good frame from the cached matrices. Returns false when there is no
|
|
553
|
+
* cache yet — and the caller must then do NOTHING, not clear: before the first good frame
|
|
554
|
+
* the canvas holds either the page's own initial state or the mono fallback's output, both
|
|
555
|
+
* of which are better than black.
|
|
556
|
+
*/
|
|
557
|
+
_replayLastGood() {
|
|
558
|
+
const g = this._lastGood;
|
|
559
|
+
if (!g || this._disposed) return false;
|
|
560
|
+
const r = this.renderer;
|
|
561
|
+
const eye = g.mono ? null : this._ensureEye();
|
|
562
|
+
const el = this.renderer.domElement || this.canvas;
|
|
563
|
+
// A resize between the cache and the replay changes the buffer, not the split.
|
|
564
|
+
const sx = g.bufW > 0 && el.width ? el.width / g.bufW : 1;
|
|
565
|
+
const sy = g.bufH > 0 && el.height ? el.height / g.bufH : 1;
|
|
566
|
+
const scaled = sx !== 1 || sy !== 1;
|
|
567
|
+
r.clear();
|
|
568
|
+
r.setScissorTest(true);
|
|
569
|
+
for (const e of g.entries) {
|
|
570
|
+
const vp = scaled
|
|
571
|
+
? {
|
|
572
|
+
x: Math.round(e.x * sx),
|
|
573
|
+
y: Math.round(e.y * sy),
|
|
574
|
+
width: Math.max(1, Math.round(e.width * sx)),
|
|
575
|
+
height: Math.max(1, Math.round(e.height * sy)),
|
|
576
|
+
}
|
|
577
|
+
: e;
|
|
578
|
+
r.setViewport(vp.x, vp.y, vp.width, vp.height);
|
|
579
|
+
r.setScissor(vp.x, vp.y, vp.width, vp.height);
|
|
580
|
+
if (eye) {
|
|
581
|
+
eye.setFromMatrices(e.proj, e.pose);
|
|
582
|
+
r.render(this.scene, eye.camera);
|
|
583
|
+
} else {
|
|
584
|
+
r.render(this.scene, this.monoCamera);
|
|
585
|
+
}
|
|
586
|
+
if (this._feather) this._feather.render(r, vp);
|
|
587
|
+
}
|
|
588
|
+
r.setScissorTest(false);
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** One rAF per burst of observer callbacks. See the _onResize comment. */
|
|
593
|
+
_scheduleResize() {
|
|
594
|
+
if (this._disposed || this._resizePending) return;
|
|
595
|
+
this._resizePending = true;
|
|
596
|
+
const run = () => {
|
|
597
|
+
if (!this._resizePending) return;
|
|
598
|
+
this._resizePending = false;
|
|
599
|
+
this._resize();
|
|
600
|
+
};
|
|
601
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
|
|
602
|
+
else run();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Put the last good frame back on a buffer that was just cleared, NOW — not on the next
|
|
607
|
+
* animation frame. A ResizeObserver callback runs after rAF and before paint, so the frame
|
|
608
|
+
* that reallocated the buffer is the frame that gets committed: without this the tile weaves
|
|
609
|
+
* one black frame per box change, with nothing on the way to repaint it. Mirrors the core's
|
|
610
|
+
* "repaint NOW: setting canvas.width cleared the buffer" (inline3d.js _onBoxChange).
|
|
611
|
+
*/
|
|
612
|
+
_repaintAfterResize() {
|
|
613
|
+
if (this._disposed) return;
|
|
614
|
+
if (this._mode === 'mono') {
|
|
615
|
+
const r = this.renderer;
|
|
616
|
+
r.clear();
|
|
617
|
+
r.setViewport(0, 0, this.canvas.width, this.canvas.height);
|
|
618
|
+
r.render(this.scene, this.monoCamera);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
this._replayLastGood();
|
|
622
|
+
}
|
|
623
|
+
|
|
435
624
|
_applyTransform() {
|
|
436
625
|
const s = this._fitScale * this._zoom;
|
|
437
626
|
this._pivot.scale.setScalar(s);
|
|
@@ -454,6 +643,13 @@ export class SceneViewer {
|
|
|
454
643
|
* getViewport() splits canvas.width in half for the two eyes — the browser squashing that
|
|
455
644
|
* 2:1 buffer into the 1:1 CSS box IS the side-by-side squeeze, and the weave un-squeezes it.
|
|
456
645
|
* In mono it must stay 1:1 or the flat render is stretched.
|
|
646
|
+
*
|
|
647
|
+
* NON-DESTRUCTIVE (web#12). `setSize` writes `canvas.width`/`canvas.height` UNCONDITIONALLY,
|
|
648
|
+
* and writing either one reallocates and CLEARS the drawing buffer even when the value does
|
|
649
|
+
* not change. Since a ResizeObserver fires on plenty of things that leave the buffer's
|
|
650
|
+
* dimensions exactly where they were (a sub-pixel reflow, a scrollbar appearing and going, a
|
|
651
|
+
* sibling settling), the old unconditional call meant a black frame for every no-op. So:
|
|
652
|
+
* compare first, and when it IS a real change, put the picture back before the frame commits.
|
|
457
653
|
*/
|
|
458
654
|
_resize() {
|
|
459
655
|
if (this._disposed) return;
|
|
@@ -462,9 +658,14 @@ export class SceneViewer {
|
|
|
462
658
|
const dpr = Math.min(window.devicePixelRatio || 1, 2) * this.renderScale;
|
|
463
659
|
const w = Math.max(1, Math.round(box.width * dpr));
|
|
464
660
|
const h = Math.max(1, Math.round(box.height * dpr));
|
|
465
|
-
this.
|
|
661
|
+
const bufW = this._mode === 'mono' ? w : w * 2;
|
|
662
|
+
// Cheap and always correct to refresh, whether or not the backing store moves.
|
|
466
663
|
this.monoCamera.aspect = box.width / box.height;
|
|
467
664
|
this.monoCamera.updateProjectionMatrix();
|
|
665
|
+
const el = this.renderer.domElement || this.canvas;
|
|
666
|
+
if (el.width === bufW && el.height === h) return; // observer fired, geometry didn't move
|
|
667
|
+
this.renderer.setSize(bufW, h, false);
|
|
668
|
+
this._repaintAfterResize();
|
|
468
669
|
}
|
|
469
670
|
|
|
470
671
|
/** Damping + idle turntable. Called once per rendered frame, 3D or mono. */
|
package/js/inline3d.js
CHANGED
|
@@ -528,6 +528,9 @@ class Inline3D {
|
|
|
528
528
|
if (!el || !win.excluded.delete(el)) return;
|
|
529
529
|
this._dropExclusion(win, el);
|
|
530
530
|
},
|
|
531
|
+
// Read-only counters, for pages that want to see the load-induced mono fallback rather
|
|
532
|
+
// than wait for a bug report about "blinking". Scene windows only; 0/0 elsewhere.
|
|
533
|
+
stats: () => ({ frames: win.frames, monoFrames: win.monoFrames }),
|
|
531
534
|
};
|
|
532
535
|
}
|
|
533
536
|
|
|
@@ -574,6 +577,10 @@ class Inline3D {
|
|
|
574
577
|
// Box/dpr watch, live only while the window is (see _startSizeWatch).
|
|
575
578
|
sizeObserver: null,
|
|
576
579
|
resizePending: false,
|
|
580
|
+
// Scene diagnostics (web#12), read back through the handle's stats(). frames counts
|
|
581
|
+
// onFrame deliveries; monoFrames counts the ones that carried fewer than two views.
|
|
582
|
+
frames: 0,
|
|
583
|
+
monoFrames: 0,
|
|
577
584
|
};
|
|
578
585
|
this._windows.set(canvas, win);
|
|
579
586
|
if (this._lazy && this._observer) {
|
|
@@ -1020,7 +1027,32 @@ class Inline3D {
|
|
|
1020
1027
|
for (const win of this._windows.values()) {
|
|
1021
1028
|
if (!win.layer) continue;
|
|
1022
1029
|
if (win.kind === 'scene') {
|
|
1023
|
-
if (views && win.onFrame)
|
|
1030
|
+
if (views && win.onFrame) {
|
|
1031
|
+
// Count the SHORT view lists and hand them over unchanged. Under GPU load the session
|
|
1032
|
+
// can report a single view (a per-frame mono fallback) where it normally reports two,
|
|
1033
|
+
// and a renderer that clears before it validates turns that into a dark tile
|
|
1034
|
+
// (web#12 — ./viewer now validates first and replays its last good frame instead).
|
|
1035
|
+
//
|
|
1036
|
+
// The core deliberately does NOT filter or synthesise: the contract is "here is what
|
|
1037
|
+
// the frame reported", and a window that can do something sensible with one view
|
|
1038
|
+
// (a mono preview, say) must be allowed to. What the core owes you is VISIBILITY —
|
|
1039
|
+
// this is otherwise invisible from the page, since nothing throws and nothing logs.
|
|
1040
|
+
win.frames++;
|
|
1041
|
+
if (views.length < 2) {
|
|
1042
|
+
win.monoFrames++;
|
|
1043
|
+
// 1-in-300 so a sustained rate is reported without the log itself becoming the load;
|
|
1044
|
+
// `% 300 === 1` also names the FIRST one immediately.
|
|
1045
|
+
if (win.monoFrames % 300 === 1 && typeof console !== 'undefined' && console.debug) {
|
|
1046
|
+
const pct = ((100 * win.monoFrames) / Math.max(1, win.frames)).toFixed(1);
|
|
1047
|
+
console.debug(
|
|
1048
|
+
`[inline3d] scene window: ${win.monoFrames} non-stereo view lists in ` +
|
|
1049
|
+
`${win.frames} frames (${pct}%). The viewer replays its last good stereo ` +
|
|
1050
|
+
'frame for these; a rising rate means the session is falling back under load.',
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
win.onFrame(views, win.layer, f);
|
|
1055
|
+
}
|
|
1024
1056
|
} else {
|
|
1025
1057
|
// Repaint image AND video every frame. The weave reads each window's
|
|
1026
1058
|
// composited canvas quad per frame; a canvas that isn't redrawn can have
|
package/model.d.ts
CHANGED
|
@@ -25,11 +25,51 @@ export interface ModelOptions {
|
|
|
25
25
|
renderScale?: number;
|
|
26
26
|
feather?: number;
|
|
27
27
|
/** Built-in three-point lighting. Meshes arrive unlit; splats do not need this. */
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* How the mesh is lit. `room` (default) bakes IBL from three's procedural RoomEnvironment —
|
|
30
|
+
* metal and glass need it, because a punctual rig leaves a `metalness: 1` surface with nothing
|
|
31
|
+
* to reflect and it renders black. `studio` is the older three-point punctual rig.
|
|
32
|
+
*/
|
|
33
|
+
environment?: 'room' | 'studio' | 'none';
|
|
29
34
|
/** A PMREM-processed environment texture. Better than `environment` for metal; overrides it. */
|
|
30
35
|
envMap?: object;
|
|
31
36
|
/** Hand in the GLTFLoader class instead of resolving it from `three/addons/`. */
|
|
32
37
|
GLTFLoader?: unknown;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where **your page** serves three's Draco decoder and Basis (KTX2) transcoder.
|
|
41
|
+
*
|
|
42
|
+
* `addModel` reads the asset's `extensionsUsed` before parsing and attaches only the decoders it
|
|
43
|
+
* declares, so an uncompressed model never touches any of this. But a decoder that IS needed has
|
|
44
|
+
* to come from somewhere, and the default is deliberately **not a CDN** — a page shipping an
|
|
45
|
+
* offline build must not acquire a network dependency by loading a compressed file. Copy the
|
|
46
|
+
* files out of `three` and serve them yourself:
|
|
47
|
+
*
|
|
48
|
+
* ```sh
|
|
49
|
+
* cp -r node_modules/three/examples/jsm/libs/draco/ public/draco/
|
|
50
|
+
* cp -r node_modules/three/examples/jsm/libs/basis/ public/basis/
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* A string is a parent directory holding `draco/` and `basis/`; an object overrides either key.
|
|
54
|
+
* `EXT_meshopt_compression` needs nothing served — its decoder is pure JS.
|
|
55
|
+
*
|
|
56
|
+
* @default {draco:'/draco/', basis:'/basis/'}
|
|
57
|
+
*/
|
|
58
|
+
decoderPath?: string | { draco?: string; basis?: string };
|
|
59
|
+
/**
|
|
60
|
+
* DRACOLoader class **or** a ready instance, instead of resolving `three/addons/`. A class is
|
|
61
|
+
* constructed and pointed at `decoderPath`; an instance is used exactly as you configured it.
|
|
62
|
+
*/
|
|
63
|
+
DRACOLoader?: unknown;
|
|
64
|
+
/**
|
|
65
|
+
* KTX2Loader class or instance. `detectSupport()` is called for you with this tile's renderer,
|
|
66
|
+
* and the transcoder is loaded eagerly so a mis-served path throws instead of silently
|
|
67
|
+
* resolving a model with no textures.
|
|
68
|
+
*/
|
|
69
|
+
KTX2Loader?: unknown;
|
|
70
|
+
/** The `MeshoptDecoder` namespace, instead of resolving `three/addons/`. Nothing to serve. */
|
|
71
|
+
meshoptDecoder?: unknown;
|
|
72
|
+
|
|
33
73
|
/** Element whose visibility gates the lazy create/close lifecycle. */
|
|
34
74
|
observe?: Element;
|
|
35
75
|
}
|
|
@@ -55,6 +95,13 @@ export interface ModelHandle {
|
|
|
55
95
|
/**
|
|
56
96
|
* Load a glTF/GLB into an inline-3D window. Safe to call with an unsupported wall — it renders a
|
|
57
97
|
* flat, orbitable view instead, so pages need no branch.
|
|
98
|
+
*
|
|
99
|
+
* Compressed assets (Draco, meshopt, KTX2/Basis) load too: the asset's declared extensions decide
|
|
100
|
+
* which decoders are imported, and nothing is imported for an asset that declares none. Draco and
|
|
101
|
+
* KTX2 additionally need their runtime files served by your page — see {@link ModelOptions.decoderPath}.
|
|
102
|
+
* When a decoder is needed and unavailable, `ready` rejects with an Error naming the glTF
|
|
103
|
+
* extension, the option that fixes it and the path it looked in; the extension is also on the
|
|
104
|
+
* error as `gltfExtension`.
|
|
58
105
|
*/
|
|
59
106
|
export function addModel(
|
|
60
107
|
wall: object | null | undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
},
|
|
84
84
|
"scripts": {
|
|
85
85
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
86
|
-
"test": "
|
|
86
|
+
"test": "tsc --noEmit -p tsconfig.json && node --test \"test/*.test.mjs\""
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
|
89
89
|
"typescript": "^5.4.0",
|
package/three.d.ts
CHANGED
|
@@ -15,6 +15,18 @@ export class EyeCamera {
|
|
|
15
15
|
readonly camera: unknown;
|
|
16
16
|
/** Set the camera's projection + world pose straight from an XRView (call once per eye). */
|
|
17
17
|
setFromView(view: XRView): void;
|
|
18
|
+
/**
|
|
19
|
+
* Set the camera from the two raw matrices an XRView carries, handed over separately.
|
|
20
|
+
*
|
|
21
|
+
* For re-drawing a frame you have already drawn: an `XRView` is valid only inside its own
|
|
22
|
+
* frame callback, so a renderer that wants to repaint (a short view list, a backing store
|
|
23
|
+
* just reallocated and cleared) must keep COPIES of these two matrices, not the view.
|
|
24
|
+
* `setFromView` forwards to this, so both paths are the same code.
|
|
25
|
+
*/
|
|
26
|
+
setFromMatrices(
|
|
27
|
+
projectionMatrix: ArrayLike<number>,
|
|
28
|
+
transformMatrix: ArrayLike<number>,
|
|
29
|
+
): void;
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
/**
|
package/viewer.d.ts
CHANGED
|
@@ -80,7 +80,14 @@ export declare class SceneViewer {
|
|
|
80
80
|
setPose(pose?: OrbitPose): void;
|
|
81
81
|
resetPose(): void;
|
|
82
82
|
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Pass straight to `wall.addScene(canvas, viewer.onFrame, …)`. Pre-bound.
|
|
85
|
+
*
|
|
86
|
+
* Validates before it clears: a frame that cannot draw both eyes (a short view list from a
|
|
87
|
+
* session falling back under load, a missing viewport) re-renders the last good frame from
|
|
88
|
+
* cached matrices instead of clearing the canvas to black — the canvas is committed every
|
|
89
|
+
* frame either way, so the tile never goes dark and never smears (web#12).
|
|
90
|
+
*/
|
|
84
91
|
onFrame(views: readonly XRView[], layer: object): void;
|
|
85
92
|
|
|
86
93
|
/** Supply the ./three glue so the 3D path can build its eye camera. Returns `this`. */
|