@displayxr/inline3d 0.0.1 → 1.1.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.
@@ -0,0 +1,333 @@
1
+ // inline3d-splat.js — a 3D Gaussian splat as an inline-3D window, in one call.
2
+ //
3
+ // EXPERIMENTAL. Not covered by the SDK's 1.x semver promise — see docs/sdk-stability.md.
4
+ //
5
+ // import { createInline3D } from '@displayxr/inline3d';
6
+ // import { addSplat } from '@displayxr/inline3d/splat';
7
+ //
8
+ // const wall = await createInline3D();
9
+ // const shoe = await addSplat(wall, canvas, 'trail-runner.sog', { virtualDisplayHeight: 0.18 });
10
+ // shoe.exclude(document.getElementById('buy')); // crisp 2D button over the woven 3D
11
+ //
12
+ // Pass the wall whether or not inline-3D is supported: on an ordinary browser this renders a
13
+ // flat, orbitable view of the same asset, so a page needs no branch. Splats are photoreal in a
14
+ // way meshes are not for captured goods — leather grain, knit mesh, foil, glitter — which is
15
+ // exactly the material range that sells a product.
16
+ //
17
+ // Requires `three` (>=0.180, Spark's floor) and `@sparkjsdev/spark` as peers. Both are declared
18
+ // OPTIONAL in package.json: the core SDK stays dependency-free and only pages that import this
19
+ // subpath pay for them.
20
+
21
+ import * as THREE from 'three';
22
+ import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
23
+ import { EyeCamera, EdgeFeather } from './inline3d-three.js';
24
+ import { SceneViewer, boundsFromPositions } from './inline3d-viewer.js';
25
+
26
+ /**
27
+ * Sort at most this often, in ms. THE stereo optimisation in this module.
28
+ *
29
+ * Spark sorts splats back-to-front per render() call, and a stereo frame renders twice — so
30
+ * the default of 0 buys two full sorts per frame. The eyes are ~63 mm apart; that does not
31
+ * meaningfully change back-to-front order for a tabletop-sized subject, so one sort serves
32
+ * both. 16 ms lands it at one per frame at 60 Hz.
33
+ */
34
+ const DEFAULT_SORT_INTERVAL_MS = 16;
35
+
36
+ /** Cap on how many splat centres the fallback framing pass inspects. */
37
+ const FRAME_SAMPLE_CAP = 200000;
38
+
39
+ /**
40
+ * three.js floor for THIS subpath — Spark's own floor, above the package-wide >=0.150 that the
41
+ * core and ./three ask for.
42
+ *
43
+ * npm cannot express a peer range per export, so the manifest has to state the LOWER bound and a
44
+ * consumer on 0.16x installs cleanly, then fails somewhere inside a Spark worker with a message
45
+ * about neither three nor versions. Checking here turns that into one sentence naming the actual
46
+ * problem. Kept as a number: THREE.REVISION is a bare string like "180", not a semver triple.
47
+ */
48
+ const THREE_MIN_REVISION = 180;
49
+
50
+ /**
51
+ * Identify a splat container from its first bytes.
52
+ *
53
+ * Spark resolves a file's format from the URL PATH, and has a magic-byte sniffer it does not
54
+ * apply to the fileBytes route — so bytes arrive as "Unknown file type" unless someone says what
55
+ * they are. That is a trap for exactly the interesting case: a URL ending in `.sog` loads fine
56
+ * while the identical bytes in a Blob do not.
57
+ *
58
+ * Rather than make every caller know Spark's type names (which are not the file extensions —
59
+ * a `.sog` is `pcsogszip`), work it out here.
60
+ */
61
+ function sniffFileType(bytes) {
62
+ if (!bytes || bytes.length < 4) return undefined;
63
+ const [b0, b1, b2, b3] = bytes;
64
+ // PK 03 04 — a PKZip. A .sog from splat-transform is a zip of webp planes + meta.json.
65
+ if (b0 === 0x50 && b1 === 0x4b && b2 === 0x03 && b3 === 0x04) return 'pcsogszip';
66
+ // "ply" — ASCII header
67
+ if (b0 === 0x70 && b1 === 0x6c && b2 === 0x79) return 'ply';
68
+ // gzip — .spz is gzipped
69
+ if (b0 === 0x1f && b1 === 0x8b) return 'spz';
70
+ // "RAD0"
71
+ if (b0 === 0x52 && b1 === 0x41 && b2 === 0x44 && b3 === 0x30) return 'rad';
72
+ // .splat / .ksplat are raw arrays with no magic — indistinguishable by content, which is
73
+ // exactly what `fileName` is for.
74
+ return undefined;
75
+ }
76
+
77
+ /**
78
+ * Load a splat into an inline-3D window.
79
+ *
80
+ * @param {object} wall the manager from createInline3D(), supported or not.
81
+ * @param {HTMLCanvasElement} canvas
82
+ * @param {string} src URL of a .sog / .spz / .ply / .splat / .ksplat.
83
+ * @param {object} [opts]
84
+ * @param {number} [opts.virtualDisplayHeight=0.24] metres of world the tile's height spans.
85
+ * @param {{center:number[],extent:number[]}} [opts.frame] precomputed subject bounds. STRONGLY
86
+ * preferred — see "Framing" below.
87
+ * @param {boolean} [opts.flipY=true] apply the 180° X flip that most splat exports need.
88
+ * @param {number} [opts.idleSpin=8] degrees/second of turntable once idle. 0 to disable.
89
+ * @param {boolean} [opts.orbit=true] drag to spin, wheel to zoom.
90
+ * @param {'contain'|'height'|'cover'|'none'} [opts.fit='contain']
91
+ * @param {number} [opts.margin=0.8] fraction of the tile the subject may occupy — neither its
92
+ * width nor its height exceeds this, whatever its proportions.
93
+ * @param {number} [opts.depthLimit=4.0] backstop on total depth; rarely binds.
94
+ * @param {boolean} [opts.fitSweep=true] fit the horizontal against the box's diagonal, so a
95
+ * long subject still fits once the turntable turns it.
96
+ * @param {number} [opts.renderScale=1] per-eye buffer scale; 0.5–0.7 is usually free.
97
+ * @param {number} [opts.feather=0] edge fade in buffer px.
98
+ * @param {number} [opts.sortIntervalMs=16] see DEFAULT_SORT_INTERVAL_MS.
99
+ * @param {Element} [opts.observe=canvas] element whose visibility gates the lazy lifecycle.
100
+ * @returns {object} a TileHandle (remove/exclude/unexclude) plus `viewer`, `mesh`, `setPose`,
101
+ * `resetPose`, `frame` (the bounds used, null until loaded) and `ready` (a promise).
102
+ * SYNCHRONOUS on purpose — it mirrors addImage, so a caller can wire up overlays and
103
+ * controls immediately instead of awaiting a download first.
104
+ *
105
+ * FRAMING. A splat has no natural "front" or size, so something must decide where the subject
106
+ * is and how big to draw it. Pass `opts.frame` when you can: the native pipeline already
107
+ * computes exactly these bounds with an opacity-weighted voxel flood-fill that separates the
108
+ * subject from an air-gapped background, and baking that into a sidecar at conversion time
109
+ * costs the page nothing. Without it we fall back to trimmed percentile bounds computed here —
110
+ * good enough for a clean, isolated capture, weaker on a scene with a background wall.
111
+ */
112
+ export function addSplat(wall, canvas, src, opts = {}) {
113
+ // Fail here, synchronously, and not through `ready`: a peer too old is an install-time mistake
114
+ // in the page's dependencies, not a condition of this asset, and it will be true of every call.
115
+ // Surfacing it as a load rejection would let a caller render an "asset unavailable" placeholder
116
+ // over what is really a version problem.
117
+ const rev = parseInt(THREE.REVISION, 10);
118
+ if (Number.isFinite(rev) && rev < THREE_MIN_REVISION) {
119
+ throw new Error(
120
+ `@displayxr/inline3d/splat needs three >= 0.${THREE_MIN_REVISION} (Spark's floor); ` +
121
+ `found 0.${THREE.REVISION}. The package-wide peer range is >=0.150 because the core and ` +
122
+ `./three work there — this subpath does not. Upgrade three, or use ./model for meshes.`,
123
+ );
124
+ }
125
+
126
+ const {
127
+ virtualDisplayHeight = 0.24,
128
+ frame = null,
129
+ flipY = true,
130
+ idleSpin = 8,
131
+ orbit = true,
132
+ fit = 'contain',
133
+ margin = 0.8,
134
+ depthLimit = 4.0,
135
+ fitSweep = true,
136
+ renderScale = 1,
137
+ feather = 0,
138
+ sortIntervalMs = DEFAULT_SORT_INTERVAL_MS,
139
+ fileName,
140
+ fileType,
141
+ observe,
142
+ } = opts;
143
+
144
+ const viewer = new SceneViewer(THREE, canvas, {
145
+ virtualDisplayHeight,
146
+ fit,
147
+ margin,
148
+ depthLimit,
149
+ fitSweep,
150
+ orbit,
151
+ idleSpin,
152
+ renderScale,
153
+ feather,
154
+ }).useEyeCamera(EyeCamera, EdgeFeather);
155
+
156
+ // Spark renders through the ordinary three.js pipeline, so splats and meshes co-exist and
157
+ // sort against each other — which is what lets a product page mix a captured hero with a
158
+ // GLB accessory in one scene.
159
+ const spark = new SparkRenderer({ renderer: viewer.renderer, minSortIntervalMs: sortIntervalMs });
160
+ viewer.scene.add(spark);
161
+
162
+ // THE HANDLE IS DECLARED BEFORE THE LOADER, and that is load-bearing — not style.
163
+ //
164
+ // The loader below is an async IIFE that assigns `out.mesh`. An async function body runs
165
+ // SYNCHRONOUSLY up to its first `await`, and the URL path has no await at all: `init = {url}`,
166
+ // construct, add to the scene, assign. So with `out` declared after it, that assignment lands
167
+ // in `out`'s temporal dead zone and throws ReferenceError — on the URL path only, which is
168
+ // every ordinary page, while the Blob path (which awaits arrayBuffer()) sails through.
169
+ //
170
+ // The failure was near-invisible and cost days: the throw escapes into meshReady, so `ready`
171
+ // rejects while the mesh is ALREADY in the scene from the line above — the splat renders, just
172
+ // never framed, i.e. at raw model scale. A subject that reads "far too large" with no error on
173
+ // the console and a fit pipeline that provably never executed.
174
+ let handle = null;
175
+ const out = {
176
+ viewer,
177
+ // null until the bytes are read and the mesh is constructed; use `ready` to await it.
178
+ mesh: null,
179
+ spark,
180
+ frame: null,
181
+ setPose: (p) => viewer.setPose(p),
182
+ resetPose: () => viewer.resetPose(),
183
+ remove() {
184
+ handle?.remove();
185
+ viewer.dispose();
186
+ out.mesh?.dispose?.();
187
+ },
188
+ exclude: (el) => handle?.exclude(el),
189
+ unexclude: (el) => handle?.unexclude(el),
190
+ };
191
+
192
+ // `src` may be a URL or the bytes themselves.
193
+ //
194
+ // Bytes matter for anything GENERATED rather than fetched: a freshly converted splat lives in
195
+ // a Blob, and the obvious move — URL.createObjectURL() — hands Spark a `blob:…` URL with no
196
+ // extension. Spark infers format partly from the URL, so that fails with "Unknown file type"
197
+ // from inside its worker, which reads like a corrupt file rather than a missing hint. Passing
198
+ // fileBytes lets it sniff the content instead. `fileName` is only needed to disambiguate
199
+ // .splat/.ksplat, which content-sniffing cannot separate.
200
+ let mesh = null;
201
+ const meshReady = (async () => {
202
+ let init;
203
+ if (typeof src === 'string') {
204
+ init = { url: src };
205
+ } else {
206
+ const buf = src instanceof Blob ? await src.arrayBuffer() : src;
207
+ const fileBytes = new Uint8Array(buf);
208
+ const sniffed = fileType || sniffFileType(fileBytes);
209
+ init = {
210
+ fileBytes,
211
+ ...(sniffed ? { fileType: sniffed } : {}),
212
+ ...(fileName ? { fileName } : {}),
213
+ };
214
+ }
215
+ mesh = new SplatMesh(init);
216
+ // Most exporters write splats Y-down (the original 3DGS convention); three.js is Y-up.
217
+ // Without this every capture arrives upside down, which reads as a broken asset rather than
218
+ // a convention mismatch. w=0,x=1 is a half turn about X.
219
+ if (flipY) mesh.quaternion.set(1, 0, 0, 0);
220
+ viewer.content.add(mesh);
221
+ out.mesh = mesh;
222
+ return mesh;
223
+ })();
224
+
225
+ // Create the window NOW and frame it when the asset lands. Waiting for the load first would
226
+ // mean a grid of tiles appears one at a time in download order — and it is how addImage
227
+ // already behaves: return a handle immediately, paint when the source is ready.
228
+ if (wall && wall.supported) {
229
+ handle = wall.addScene(canvas, viewer.onFrame, {
230
+ virtualDisplayHeight,
231
+ ...(observe ? { observe } : {}),
232
+ });
233
+ } else {
234
+ viewer.startMono();
235
+ }
236
+
237
+ // Await the MESH first, then its load. Reading `mesh.initialized` here directly would
238
+ // dereference null: constructing from bytes is async (the Blob has to be read), so `mesh` does
239
+ // not exist yet on this line — only inside meshReady.
240
+ out.ready = meshReady
241
+ .then((m) => m.initialized)
242
+ .then(() => {
243
+ // MEASURE FIRST, always. `frame` is only a fallback.
244
+ //
245
+ // A supplied frame has to survive two coordinate changes to be usable — the converter's
246
+ // space to the file's, and the file's to whatever the loader normalises to internally —
247
+ // and getting either wrong produces a subject that is mis-scaled and off-centre with no
248
+ // error anywhere. That was got wrong twice here. Measuring the splats as they actually
249
+ // sit in the loaded mesh cannot be in the wrong space by construction: it reads the same
250
+ // positions the renderer draws. It costs one pass over (a sample of) the centres at load,
251
+ // which is what the working reference sample has always done.
252
+ const bounds = measureBounds(out.mesh, THREE) || (frame ? liftBounds(frame, out.mesh, THREE) : null);
253
+ if (bounds) {
254
+ out.frame = bounds;
255
+ viewer.fitTo(bounds.center, bounds.extent);
256
+ } else {
257
+ // Unframed means drawn at raw MODEL scale, which for a typical capture is several times
258
+ // the tile. Say so: silence here is what made the same condition read as a fit bug.
259
+ console.warn('[inline3d/splat] no usable bounds — subject is UNFRAMED (model scale)', src);
260
+ }
261
+ return out;
262
+ })
263
+ .catch((err) => {
264
+ // A failed load must not take the page down: `ready` rejects and the caller decides whether
265
+ // that is a placeholder or an error state.
266
+ //
267
+ // Detach the mesh, because failure can happen AFTER it joined the scene — and an unframed
268
+ // mesh is not a blank tile, it is a subject at model scale spilling out of the window. An
269
+ // error state the caller paints over a giant splat is worse than an empty one.
270
+ if (mesh) viewer.content.remove(mesh);
271
+ console.warn('[inline3d/splat] failed to load', src, err);
272
+ throw err;
273
+ });
274
+
275
+ return out;
276
+ }
277
+
278
+ /**
279
+ * Percentile bounds from the loaded splats — the fallback when no sidecar was supplied.
280
+ *
281
+ * Two cheats keep this off the critical path. Near-transparent splats are skipped: they are
282
+ * overwhelmingly haze and floaters, and including them drags the box outwards. And above
283
+ * FRAME_SAMPLE_CAP we stride: percentiles of a uniform subsample of 200k points are
284
+ * indistinguishable from percentiles of two million, at a tenth of the cost.
285
+ *
286
+ * The result is lifted out of the mesh's LOCAL space through its own matrix, because by the
287
+ * time this runs the Y-flip is already on the mesh — and the viewer centres content one level
288
+ * above it. Skip that and every flipped capture frames to a point mirrored through the origin,
289
+ * which looks like the subject drifting off the tile for no reason. Extents ride the matrix
290
+ * columns rather than being re-projected onto world axes: same convention the native
291
+ * ComputeAutoFrame uses, and exact for the axis-aligned flips that actually occur.
292
+ */
293
+ /** Map model-space bounds through a mesh's own transform, matching the native ComputeAutoFrame. */
294
+ function liftBounds(b, mesh, THREE) {
295
+ if (!b || !mesh) return b;
296
+ mesh.updateMatrix();
297
+ const m = mesh.matrix;
298
+ const c = new THREE.Vector3(b.center[0], b.center[1], b.center[2]).applyMatrix4(m);
299
+ const col = new THREE.Vector3();
300
+ const e = [0, 1, 2].map((axis) => col.setFromMatrixColumn(m, axis).length() * b.extent[axis]);
301
+ return { center: [c.x, c.y, c.z], extent: e };
302
+ }
303
+
304
+ export function measureSplatBounds(mesh, three = THREE) {
305
+ return measureBounds(mesh, three);
306
+ }
307
+
308
+ function measureBounds(mesh, THREE) {
309
+ const total = mesh.numSplats || 0;
310
+ if (!total) return null;
311
+ const stride = Math.max(1, Math.ceil(total / FRAME_SAMPLE_CAP));
312
+ const xyz = new Float32Array(Math.ceil(total / stride) * 3);
313
+ let k = 0;
314
+ mesh.forEachSplat((index, center, scales, quaternion, opacity) => {
315
+ if (index % stride !== 0) return;
316
+ if (opacity !== undefined && opacity < 0.05) return;
317
+ if (k + 3 > xyz.length) return;
318
+ xyz[k++] = center.x;
319
+ xyz[k++] = center.y;
320
+ xyz[k++] = center.z;
321
+ });
322
+ const local = boundsFromPositions(xyz.subarray(0, k));
323
+ if (!local) return null;
324
+
325
+ mesh.updateMatrix();
326
+ const m = mesh.matrix;
327
+ const c = new THREE.Vector3(local.center[0], local.center[1], local.center[2]).applyMatrix4(m);
328
+ const col = new THREE.Vector3();
329
+ const e = [0, 1, 2].map(
330
+ (axis) => col.setFromMatrixColumn(m, axis).length() * local.extent[axis],
331
+ );
332
+ return { center: [c.x, c.y, c.z], extent: e };
333
+ }
@@ -0,0 +1,154 @@
1
+ // inline3d-three.js — optional three.js glue for the inline-3D SDK.
2
+ //
3
+ // The core inline3d.js is dependency-free and hands a scene window the two eye XRViews each
4
+ // frame. This module removes the three.js-specific boilerplate: driving a camera from an
5
+ // XRView, and the one non-obvious bit — SCALING the scene to the canvas element's physical
6
+ // size.
7
+ //
8
+ // import * as THREE from 'three';
9
+ // import { createInline3D } from '../js/inline3d.js';
10
+ // import { EyeCamera } from '../js/inline3d-three.js';
11
+ //
12
+ // // TWO bits of renderer setup are load-bearing (see "VIEWPORTS" below):
13
+ // renderer.setPixelRatio(1); // getViewport() is already in device px
14
+ // const dpr = window.devicePixelRatio || 1; // SBS store: DOUBLE-WIDTH, device-res
15
+ // renderer.setSize(canvas.clientWidth * dpr * 2, canvas.clientHeight * dpr, false);
16
+ //
17
+ // const eye = new EyeCamera(THREE); // one reusable off-axis camera
18
+ // wall.addScene(canvas, (views, layer) => { // addScene sets virtualDisplayHeight = 0.24 m
19
+ // renderer.clear();
20
+ // renderer.setScissorTest(true);
21
+ // for (const view of views) {
22
+ // const vp = layer.getViewport(view);
23
+ // renderer.setViewport(vp.x, vp.y, vp.width, vp.height);
24
+ // renderer.setScissor(vp.x, vp.y, vp.width, vp.height);
25
+ // eye.setFromView(view); // projection + pose straight from the view
26
+ // renderer.render(scene, eye.camera); // author at metre scale; NO scaling here
27
+ // }
28
+ // renderer.setScissorTest(false);
29
+ // });
30
+ //
31
+ // VIEWPORTS — the one trap. layer.getViewport() returns BACKING-STORE pixels, but three.js's
32
+ // setViewport()/setScissor() multiply what you pass them by the renderer's pixelRatio. So
33
+ // setPixelRatio(anything but 1) silently scales every eye viewport: at dpr 2 the left eye
34
+ // covers the WHOLE canvas and overflows vertically, and the weave then shows you a stretched
35
+ // slice of it. The tell is nasty — the scene still head-tracks perfectly (the pose and the
36
+ // off-axis projection are untouched), it is just zoomed and off-centre — so it looks like a
37
+ // projection/rig bug when it is purely a viewport one. Keep pixelRatio at 1 and size the
38
+ // backing store in device pixels yourself.
39
+ //
40
+ // SCENE SCALE IS THE RUNTIME'S JOB (display-rig m2v). The inline-3D views the session reports
41
+ // are already scaled to your scene by the layer's `virtualDisplayHeight` (see addScene) — the
42
+ // runtime places each eye at eye_physical × (virtualDisplayHeight / element_physical_height),
43
+ // so the z=0 plane spans that virtual display. Author your scene in metres for a display that
44
+ // tall (0.24 m by default), put focused content at z=0 (positive z behind the glass, negative
45
+ // in front), and render `eye.camera` directly. No per-frame world scaling — that is the whole
46
+ // point of using the rig instead of re-deriving it in the app, and it mirrors the native
47
+ // reference apps (cube_handle), which supply one scale number and consume render-ready views.
48
+
49
+ /**
50
+ * A reusable three.js camera driven directly by an XRView's matrices. Construct once with
51
+ * your THREE namespace and reuse across frames/windows.
52
+ */
53
+ export class EyeCamera {
54
+ /** @param {object} THREE your imported three.js module namespace. */
55
+ constructor(THREE) {
56
+ this._THREE = THREE;
57
+ this.camera = new THREE.PerspectiveCamera();
58
+ this.camera.matrixAutoUpdate = false; // matrices come straight from the XRView
59
+ }
60
+
61
+ /** Set the camera's projection + world pose from an XRView (call once per eye per frame). */
62
+ setFromView(view) {
63
+ const cam = this.camera;
64
+ cam.projectionMatrix.fromArray(view.projectionMatrix);
65
+ cam.projectionMatrixInverse.copy(cam.projectionMatrix).invert();
66
+ cam.matrix.fromArray(view.transform.matrix);
67
+ cam.matrixWorld.copy(cam.matrix);
68
+ cam.matrixWorldInverse.copy(cam.matrixWorld).invert();
69
+ return cam;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Fade a rendered eye's edges to transparent, so a 3D window dissolves into the page instead of
75
+ * ending at a hard rectangle. The WebGL counterpart of the SDK's `feather` option for
76
+ * image/video windows (which the SDK bakes itself, since it owns those 2D buffers — for a scene,
77
+ * YOU own the canvas, so the pass has to run here).
78
+ *
79
+ * PER EYE, and that is not a detail: each eye's image spans the WHOLE window, so each needs a
80
+ * fade on all four of ITS OWN edges. A CSS mask/filter on the canvas fades only the element
81
+ * box's outer edges — the left eye would get a fade on its left and none on its right, and the
82
+ * split line would fade when it must not. Same reason cornerRadius is per-eye.
83
+ *
84
+ * Call once per eye, straight after renderer.render(scene, eye.camera), with the SAME viewport
85
+ * still set. Multiplies the framebuffer by an edge ramp (dst *= ramp) via ZeroFactor/SrcAlpha
86
+ * blending, so it works on whatever you drew without knowing anything about it.
87
+ *
88
+ * Requires a transparent canvas to fade INTO: WebGLRenderer({ alpha: true }),
89
+ * renderer.setClearColor(0x000000, 0), and no opaque scene.background.
90
+ *
91
+ * const feather = new EdgeFeather(THREE, { px: 28 });
92
+ * ...
93
+ * renderer.render(scene, eye.camera);
94
+ * feather.render(renderer, vp); // vp = layer.getViewport(view)
95
+ */
96
+ export class EdgeFeather {
97
+ /**
98
+ * @param {object} THREE your imported three.js module namespace.
99
+ * @param {object} [opts]
100
+ * @param {number} [opts.px=24] fade width in BUFFER px (the same units getViewport reports).
101
+ */
102
+ constructor(THREE, { px = 24 } = {}) {
103
+ this._THREE = THREE;
104
+ this.px = px;
105
+ this._cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
106
+ this._mat = new THREE.ShaderMaterial({
107
+ uniforms: { fx: { value: 0.1 }, fy: { value: 0.1 } },
108
+ vertexShader: `
109
+ varying vec2 vUv;
110
+ void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
111
+ `,
112
+ fragmentShader: `
113
+ varying vec2 vUv;
114
+ uniform float fx;
115
+ uniform float fy;
116
+ void main() {
117
+ // 1 inside, ramping to 0 at each edge. smoothstep gives a soft, banding-free falloff.
118
+ float ax = smoothstep(0.0, fx, vUv.x) * smoothstep(0.0, fx, 1.0 - vUv.x);
119
+ float ay = smoothstep(0.0, fy, vUv.y) * smoothstep(0.0, fy, 1.0 - vUv.y);
120
+ gl_FragColor = vec4(1.0, 1.0, 1.0, ax * ay);
121
+ }
122
+ `,
123
+ // dst_new = src*0 + dst*src.a => multiply the framebuffer (colour AND alpha) by the ramp.
124
+ transparent: true,
125
+ depthTest: false,
126
+ depthWrite: false,
127
+ blending: THREE.CustomBlending,
128
+ blendSrc: THREE.ZeroFactor,
129
+ blendDst: THREE.SrcAlphaFactor,
130
+ blendSrcAlpha: THREE.ZeroFactor,
131
+ blendDstAlpha: THREE.SrcAlphaFactor,
132
+ });
133
+ this._quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this._mat);
134
+ this._quad.frustumCulled = false;
135
+ this._scene = new THREE.Scene();
136
+ this._scene.add(this._quad);
137
+ }
138
+
139
+ /**
140
+ * @param {THREE.WebGLRenderer} renderer
141
+ * @param {{x:number,y:number,width:number,height:number}} vp this eye's viewport.
142
+ */
143
+ render(renderer, vp) {
144
+ if (!vp || this.px <= 0) return;
145
+ // Ramp width as a fraction of THIS eye's viewport, so the fade is px-uniform on screen even
146
+ // though the eye is horizontally squeezed (a half-width viewport stretched 2x by the weave).
147
+ this._mat.uniforms.fx.value = Math.min(0.5, this.px / Math.max(1, vp.width));
148
+ this._mat.uniforms.fy.value = Math.min(0.5, this.px / Math.max(1, vp.height));
149
+ const prevAutoClear = renderer.autoClear;
150
+ renderer.autoClear = false;
151
+ renderer.render(this._scene, this._cam);
152
+ renderer.autoClear = prevAutoClear;
153
+ }
154
+ }