@displayxr/inline3d 1.19.0 → 1.19.2
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 +52 -0
- package/README.md +1 -0
- package/js/inline3d-model-playcanvas.js +33 -478
- package/js/inline3d-pc-look.js +497 -0
- package/js/inline3d-splat-effects.js +88 -5
- package/js/inline3d-splat-playcanvas.js +191 -48
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,58 @@ 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.19.2 — 2026-09-24
|
|
9
|
+
|
|
10
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change and no pixel
|
|
11
|
+
change: every frame of a `crossfade` or `wavefront` is the one 1.19.1 draws, and the end is still a
|
|
12
|
+
plain `cut` (MAE 0.000).
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **The live `wavefront` costs about one photo's draw, not two** (#36).
|
|
17
|
+
- Its depth ridge was an entity-scope work-buffer modifier: a full rewrite of the new photo's
|
|
18
|
+
1.18M-gaussian work buffer and a CPU re-sort, every frame (0.9–1.0 of each per frame, measured).
|
|
19
|
+
It now runs at render time on the eye camera's gsplat manager, as the particle transitions do:
|
|
20
|
+
0 rewrites and 0 sorts in the window.
|
|
21
|
+
- Each photo is drawn only on its side of the front: the new one left of where its commit
|
|
22
|
+
starts, the old one right of where it ends, so the two overlap only in the band. A gaussian
|
|
23
|
+
whose on-screen footprint (bounded the way the engine sizes its quad) lies wholly on the far
|
|
24
|
+
side in every eye gets alpha 0, which the engine culls in the vertex stage. Frames with the
|
|
25
|
+
cull on and off are bit-identical (both eyes, colour and alpha, 5 points of the window).
|
|
26
|
+
- An engine whose managers are not reachable keeps the entity-scope ridge (`_transitionPath`
|
|
27
|
+
says which ran).
|
|
28
|
+
- **No shader links inside a `crossfade` or `wavefront` window.** The overlay's two quads compiled
|
|
29
|
+
on the window's first frames (30–180 ms on the M1). `prepareSource()` now warms them whatever
|
|
30
|
+
transition follows, `prepareSource(src, { transition: 'wavefront' })` also builds the render-time
|
|
31
|
+
ridge and cull, and an unprepared `setSource` warms them while its asset loads.
|
|
32
|
+
|
|
33
|
+
Measured pacing and GPU cost: [`docs/splat-effects.md` § Wavefront: one draw's worth](docs/splat-effects.md#wavefront-one-draws-worth).
|
|
34
|
+
The live `crossfade` still draws both photos in full: at mid-fade every pixel needs both images.
|
|
35
|
+
|
|
36
|
+
## 1.19.1 — 2026-09-24
|
|
37
|
+
|
|
38
|
+
Touches packaging only (`./splat` and `./model`, `engine: 'playcanvas'`). No pixels move: the
|
|
39
|
+
code is moved, not changed.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **A bundled app that uses only `./splat` builds again without `meshoptimizer` installed**
|
|
44
|
+
(#36). This was a regression in 1.16.0.
|
|
45
|
+
- `setRig('display')` lazily imported `inline3d-model-playcanvas.js` for its look helpers. That
|
|
46
|
+
module also contains `import('meshoptimizer/decoder')` for `./model`'s optional meshopt
|
|
47
|
+
decoder.
|
|
48
|
+
- Bundlers (webpack, Turbopack, Vite/rollup, esbuild) follow every `import()` in the graph, so
|
|
49
|
+
the build failed with *Can't resolve 'meshoptimizer/decoder'*.
|
|
50
|
+
- The two generated environments, their yaw constants and `prepareTransmission` now live in a
|
|
51
|
+
new internal module, `js/inline3d-pc-look.js`. The splat adapter imports that module, which
|
|
52
|
+
reaches nothing optional. `inline3d-model-playcanvas.js` re-exports every name it exported
|
|
53
|
+
before.
|
|
54
|
+
- `./model` still needs `meshoptimizer` only for EXT_meshopt_compression assets, as before.
|
|
55
|
+
- A unit test walks the import graph from each package entry. It fails if `./splat` reaches
|
|
56
|
+
meshoptimizer, and also if `./model` stops reaching it.
|
|
57
|
+
- Gate, on a real GPU: `setRig('display')` vs `addModel` is still **MAE 0.0000** (max 0), mono
|
|
58
|
+
and stereo, with `antialias: true`. The renders are pixel-identical to the 1.16 gate's.
|
|
59
|
+
|
|
8
60
|
## 1.19.0 — 2026-09-24
|
|
9
61
|
|
|
10
62
|
Touches the **preview tier** (`./splat` and `./model`, `engine: 'playcanvas'` only). The defaults
|
package/README.md
CHANGED
|
@@ -177,6 +177,7 @@ js/
|
|
|
177
177
|
preview: the `engine: 'playcanvas'` backend of addSplat(), loaded on demand
|
|
178
178
|
inline3d-model-entry.js experimental: addModel() — picks the engine (PlayCanvas default)
|
|
179
179
|
inline3d-model-playcanvas.js the PlayCanvas model backend (Sample-Viewer lighting, engine decoders)
|
|
180
|
+
inline3d-pc-look.js PlayCanvas look helpers for both adapters (environments, transmission)
|
|
180
181
|
inline3d-model.js the three.js model backend (engine:'three'; ./model/three)
|
|
181
182
|
inline3d-playcanvas-engine.js named playcanvas imports for both PlayCanvas adapters (tree-shaking)
|
|
182
183
|
inline3d-splat-effects.js splat shader effects: the runner, the effect registry (PlayCanvas)
|
|
@@ -39,6 +39,38 @@
|
|
|
39
39
|
|
|
40
40
|
import { PlayCanvasSplatViewer, pageRigTRS, pageViewAxis, pageViewRig } from './inline3d-splat-playcanvas.js';
|
|
41
41
|
import { resolveControls, normalizeCameraPose, now, MAX_DT_S } from './inline3d-splat-shared.js';
|
|
42
|
+
import {
|
|
43
|
+
NEUTRAL_STUDIO,
|
|
44
|
+
neutralStudioRadiance,
|
|
45
|
+
neutralStudioRGBE,
|
|
46
|
+
ENV_YAW_DEG,
|
|
47
|
+
useNeutralStudio,
|
|
48
|
+
useLightingSource,
|
|
49
|
+
ROOM_ENVIRONMENT,
|
|
50
|
+
roomRadiance,
|
|
51
|
+
roomEquirect,
|
|
52
|
+
roomRGBE,
|
|
53
|
+
ROOM_YAW_DEG,
|
|
54
|
+
useRoomEnvironment,
|
|
55
|
+
prepareTransmission,
|
|
56
|
+
} from './inline3d-pc-look.js';
|
|
57
|
+
|
|
58
|
+
// The look helpers moved to ./inline3d-pc-look.js (so ./splat never reaches the meshopt decoder
|
|
59
|
+
// below); re-exported so every name this module exported before still resolves here.
|
|
60
|
+
export {
|
|
61
|
+
NEUTRAL_STUDIO,
|
|
62
|
+
neutralStudioRadiance,
|
|
63
|
+
neutralStudioRGBE,
|
|
64
|
+
ENV_YAW_DEG,
|
|
65
|
+
useNeutralStudio,
|
|
66
|
+
ROOM_ENVIRONMENT,
|
|
67
|
+
roomRadiance,
|
|
68
|
+
roomEquirect,
|
|
69
|
+
roomRGBE,
|
|
70
|
+
ROOM_YAW_DEG,
|
|
71
|
+
useRoomEnvironment,
|
|
72
|
+
prepareTransmission,
|
|
73
|
+
};
|
|
42
74
|
|
|
43
75
|
const TAG = '[inline3d/model]';
|
|
44
76
|
|
|
@@ -234,155 +266,7 @@ export function meshoptBufferViewHook(decoder) {
|
|
|
234
266
|
}
|
|
235
267
|
|
|
236
268
|
// ── lighting ────────────────────────────────────────────────────────────────────────────────
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* THE DEFAULT ENVIRONMENT: a neutral photo studio, generated in memory as an equirect radiance
|
|
240
|
-
* map — a floor-to-ceiling brightness profile plus a handful of soft area lights (spherical
|
|
241
|
-
* Gaussians). The parameters were FITTED (least squares, solid-angle weighted) to the glTF Sample
|
|
242
|
-
* Viewer's "Studio Neutral" environment, so that this backend's default lighting matches the
|
|
243
|
-
* Khronos reference; the image itself is ours (a dozen numbers, not a copy of the HDRI), so the
|
|
244
|
-
* SDK ships no asset and carries no attribution requirement. Grey by construction (r = g = b).
|
|
245
|
-
*
|
|
246
|
-
* `profile` is the radiance at evenly spaced polar angles from straight up (0) to straight down
|
|
247
|
-
* (π); `lobes` are [amplitude, sharpness, polar, azimuth] with the direction
|
|
248
|
-
* (sinθ·cosφ, cosθ, sinθ·sinφ) — Y up, the engine's equirect convention. A lobe's amplitude may
|
|
249
|
-
* be negative (a dark backdrop); the result is clamped at 0.
|
|
250
|
-
*/
|
|
251
|
-
export const NEUTRAL_STUDIO = Object.freeze({
|
|
252
|
-
profile: [5.003, 5.049, 3.917, 1.968, 1.378, 0.893, 0.8074, 0.7525, 0.6427, -0.1814],
|
|
253
|
-
lobes: [
|
|
254
|
-
[11.31, 4.869, 0.9712, 6.105],
|
|
255
|
-
[1.759, 3.839, 1.404, 3.558],
|
|
256
|
-
[0.9139, 1.621, 1.719, 6.239],
|
|
257
|
-
[0.572, 7.846, 1.497, 6.066],
|
|
258
|
-
[0.6379, 7.687, 1.42, 6.385],
|
|
259
|
-
[-0.7782, 6.701, 1.13, 6.133],
|
|
260
|
-
[-1.263, 8.179, 1.671, 6.188],
|
|
261
|
-
[-1.456, 7.734, 1.256, 6.568],
|
|
262
|
-
[-6.7, 0.8014, 0.449, 6.171],
|
|
263
|
-
[0.4015, 3.915, 1.621, 5.688],
|
|
264
|
-
[-0.5387, 6.494, 2.828, 5.614],
|
|
265
|
-
[6.623, 1.864, 0.7199, 6.135],
|
|
266
|
-
[0.2623, 6.922, 1.755, 5.903],
|
|
267
|
-
[0.2597, 6.906, 1.566, 5.934],
|
|
268
|
-
[0.1584, 6.954, 1.514, 6.179],
|
|
269
|
-
[0.2272, 6.945, 1.422, 5.891],
|
|
270
|
-
[0.03496, 6.974, 1.414, 6.205],
|
|
271
|
-
[0.3804, 6.979, 1.393, 6.08],
|
|
272
|
-
],
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
/** Lobes as [amplitude, sharpness, mx, my, mz] — the trig done once per env, not per texel. */
|
|
276
|
-
const _lobeCache = new WeakMap();
|
|
277
|
-
function lobesOf(env) {
|
|
278
|
-
let l = _lobeCache.get(env);
|
|
279
|
-
if (!l) {
|
|
280
|
-
l = env.lobes.map(([a, s, th, ph]) => [a, Math.exp(s), Math.sin(th) * Math.cos(ph), Math.cos(th), Math.sin(th) * Math.sin(ph)]);
|
|
281
|
-
_lobeCache.set(env, l);
|
|
282
|
-
}
|
|
283
|
-
return l;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** Radiance of NEUTRAL_STUDIO (or `env`) toward unit direction (x, y, z). */
|
|
287
|
-
export function neutralStudioRadiance(x, y, z, env = NEUTRAL_STUDIO) {
|
|
288
|
-
const prof = env.profile;
|
|
289
|
-
const n = prof.length - 1;
|
|
290
|
-
const theta = Math.acos(Math.max(-1, Math.min(1, y)));
|
|
291
|
-
const t = (theta / Math.PI) * n;
|
|
292
|
-
const i = Math.min(n - 1, Math.floor(t));
|
|
293
|
-
const f = t - i;
|
|
294
|
-
let v = prof[i] * (1 - f) + prof[i + 1] * f;
|
|
295
|
-
for (const [a, k, mx, my, mz] of lobesOf(env)) v += a * Math.exp(k * (x * mx + y * my + z * mz - 1));
|
|
296
|
-
return v > 0 ? v : 0;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** RGBE-encode a grey radiance. */
|
|
300
|
-
function rgbe(v, out, o) {
|
|
301
|
-
if (!(v > 1e-32)) {
|
|
302
|
-
out[o] = out[o + 1] = out[o + 2] = out[o + 3] = 0;
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
let e = Math.ceil(Math.log2(v));
|
|
306
|
-
let m = v / Math.pow(2, e);
|
|
307
|
-
if (m >= 1) {
|
|
308
|
-
m /= 2;
|
|
309
|
-
e += 1;
|
|
310
|
-
}
|
|
311
|
-
const b = Math.min(255, Math.floor(m * 256));
|
|
312
|
-
out[o] = out[o + 1] = out[o + 2] = b;
|
|
313
|
-
out[o + 3] = e + 128;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* The equirect as RGBE bytes, row 0 = straight up. `u` runs with the azimuth φ = 2π·u, which is
|
|
318
|
-
* the convention the fit used; the engine's own equirect orientation is applied through
|
|
319
|
-
* `scene.skyboxRotation` (§ ENV_YAW_DEG), not baked in here.
|
|
320
|
-
*/
|
|
321
|
-
const _rgbeCache = new Map();
|
|
322
|
-
export function neutralStudioRGBE(width = 256, height = 128, env = NEUTRAL_STUDIO) {
|
|
323
|
-
// Every tile on a page gets the same image: build it once (a copy per call, since the engine
|
|
324
|
-
// may keep the array it is handed).
|
|
325
|
-
const key = env === NEUTRAL_STUDIO ? `${width}x${height}` : null;
|
|
326
|
-
if (key && _rgbeCache.has(key)) return _rgbeCache.get(key).slice();
|
|
327
|
-
const out = new Uint8Array(width * height * 4);
|
|
328
|
-
for (let r = 0; r < height; r++) {
|
|
329
|
-
const theta = ((r + 0.5) / height) * Math.PI;
|
|
330
|
-
const st = Math.sin(theta);
|
|
331
|
-
const y = Math.cos(theta);
|
|
332
|
-
for (let c = 0; c < width; c++) {
|
|
333
|
-
const phi = ((c + 0.5) / width) * 2 * Math.PI;
|
|
334
|
-
rgbe(neutralStudioRadiance(st * Math.cos(phi), y, st * Math.sin(phi), env), out, (r * width + c) * 4);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
if (key) _rgbeCache.set(key, out.slice());
|
|
338
|
-
return out;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Yaw (degrees about +Y) applied to the generated studio, as `scene.skyboxRotation`. Calibrated
|
|
343
|
-
* headless against the Sample Viewer at its default `environmentRotation` (90°): a sweep over
|
|
344
|
-
* 0/90/180/270 × mirrored, then ±15/±30, has a clean minimum at 0 on all four reference models
|
|
345
|
-
* (the engine's equirect convention already lines up with the layout the fit used). Kept as a
|
|
346
|
-
* named constant so a future engine that flips its convention is a one-line fix.
|
|
347
|
-
*/
|
|
348
|
-
export const ENV_YAW_DEG = 0;
|
|
349
|
-
|
|
350
|
-
/** Build the default environment on this app and set it as the scene's envAtlas. */
|
|
351
|
-
export function useNeutralStudio(pc, app, yawDeg) {
|
|
352
|
-
return useRgbeEquirect(pc, app, 'inline3d-neutral-studio', neutralStudioRGBE(256, 128), 256, 128, yawDeg);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/** A grey RGBE equirect (row 0 = up) → prefiltered envAtlas on the scene. */
|
|
356
|
-
function useRgbeEquirect(pc, app, name, bytes, W, H, yawDeg) {
|
|
357
|
-
const tex = new pc.Texture(app.graphicsDevice, {
|
|
358
|
-
name,
|
|
359
|
-
width: W,
|
|
360
|
-
height: H,
|
|
361
|
-
format: pc.PIXELFORMAT_RGBA8,
|
|
362
|
-
type: pc.TEXTURETYPE_RGBE,
|
|
363
|
-
projection: pc.TEXTUREPROJECTION_EQUIRECT,
|
|
364
|
-
mipmaps: false,
|
|
365
|
-
addressU: pc.ADDRESS_REPEAT,
|
|
366
|
-
addressV: pc.ADDRESS_CLAMP_TO_EDGE,
|
|
367
|
-
levels: [bytes],
|
|
368
|
-
});
|
|
369
|
-
return useLightingSource(pc, app, tex, true, yawDeg);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/** Any equirect/cube source → prefiltered envAtlas on the scene. */
|
|
373
|
-
function useLightingSource(pc, app, source, own, yawDeg = 0) {
|
|
374
|
-
const lighting = pc.EnvLighting.generateLightingSource(source);
|
|
375
|
-
const atlas = pc.EnvLighting.generateAtlas(lighting);
|
|
376
|
-
lighting.destroy?.();
|
|
377
|
-
if (own) source.destroy?.();
|
|
378
|
-
app.scene.envAtlas = atlas;
|
|
379
|
-
app.scene.skyboxIntensity = 1;
|
|
380
|
-
app.scene.exposure = 1;
|
|
381
|
-
// Always written (identity at 0): a later environment on the same scene must not inherit the
|
|
382
|
-
// previous one's yaw (setRig switches room ↔ neutral on one app).
|
|
383
|
-
if (pc.Quat) app.scene.skyboxRotation = new pc.Quat().setFromEulerAngles(0, yawDeg || 0, 0);
|
|
384
|
-
return atlas;
|
|
385
|
-
}
|
|
269
|
+
// (The generated environments and prepareTransmission are in ./inline3d-pc-look.js.)
|
|
386
270
|
|
|
387
271
|
/** A texture asset from a URL (an equirect .hdr/.png/.jpg for `envMap`). */
|
|
388
272
|
function loadTexture(pc, app, url) {
|
|
@@ -395,241 +279,6 @@ function loadTexture(pc, app, url) {
|
|
|
395
279
|
});
|
|
396
280
|
}
|
|
397
281
|
|
|
398
|
-
// ── the room environment (environment: 'room') ─────────────────────────────────────────────
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* `environment: 'room'` — three.js's procedural RoomEnvironment, regenerated here without three:
|
|
402
|
-
* the same scene (a white room lit by one point light, six grey boxes, six emissive panels), shaded
|
|
403
|
-
* the way three shades it when `PMREMGenerator.fromScene(new RoomEnvironment(), 0.04)` bakes it —
|
|
404
|
-
* which is exactly what `addModel(…, { engine: 'three', environment: 'room' })` lights with.
|
|
405
|
-
*
|
|
406
|
-
* It exists so a page that chose `'room'` on the three path gets that look on this backend. It is
|
|
407
|
-
* NOT the default: the default (`'neutral'`) is matched to the Khronos Sample Viewer, and three's
|
|
408
|
-
* room is a different, brighter room (mean radiance ~0.97 vs ~0.89, most of it from a 100-nit
|
|
409
|
-
* ceiling panel and two 50-nit wall panels).
|
|
410
|
-
*
|
|
411
|
-
* The scene's numbers (positions, rotations, scales, intensities) are three.js's
|
|
412
|
-
* examples/jsm/environments/RoomEnvironment.js (MIT, © three.js authors), itself after
|
|
413
|
-
* model-viewer's EnvironmentScene. No image is shipped: `roomRadiance` ray-casts the boxes per
|
|
414
|
-
* texel. Checked against three r180's own bake (its PMREM, read back through `textureCubeUV`):
|
|
415
|
-
* texel-to-texel ratio median 1.000 (p10 0.989, p90 1.006), solid-angle-weighted MAE 0.03 unblurred
|
|
416
|
-
* and 0.04 with the 0.04-rad blur. Solids are [cx, cy, cz, angleY, sx, sy, sz] (boxes) and
|
|
417
|
-
* [cx, cy, cz, sx, sy, sz, emissive] (panels), BoxGeometry(1) scaled — i.e. full extents.
|
|
418
|
-
*/
|
|
419
|
-
export const ROOM_ENVIRONMENT = Object.freeze({
|
|
420
|
-
light: [0.418, 16.199, 0.3, 900, 28, 2], // point light: position, intensity (cd), cutoff distance, decay
|
|
421
|
-
room: [-0.757, 13.219, 0.717, 31.713, 28.305, 28.591], // seen from inside
|
|
422
|
-
boxes: [
|
|
423
|
-
[-10.906, 2.009, 1.846, -0.195, 2.328, 7.905, 4.651],
|
|
424
|
-
[-5.607, -0.754, -0.758, 0.994, 1.97, 1.534, 3.955],
|
|
425
|
-
[6.167, 0.857, 7.803, 0.561, 3.927, 6.285, 3.687],
|
|
426
|
-
[-2.017, 0.018, 6.124, 0.333, 2.002, 4.566, 2.064],
|
|
427
|
-
[2.291, -0.756, -2.621, -0.286, 1.546, 1.552, 1.496],
|
|
428
|
-
[-2.193, -0.369, -5.547, 0.516, 3.875, 3.487, 2.986],
|
|
429
|
-
],
|
|
430
|
-
panels: [
|
|
431
|
-
[-16.116, 14.37, 8.208, 0.1, 2.428, 2.739, 50],
|
|
432
|
-
[-16.109, 18.021, -8.207, 0.1, 2.425, 2.751, 50],
|
|
433
|
-
[14.904, 12.198, -1.832, 0.15, 4.265, 6.331, 17],
|
|
434
|
-
[-0.462, 8.89, 14.52, 4.38, 5.441, 0.088, 43],
|
|
435
|
-
[3.235, 11.486, -12.541, 2.5, 2.0, 0.1, 20],
|
|
436
|
-
[0.0, 20.0, 0.0, 1.0, 0.1, 1.0, 100],
|
|
437
|
-
],
|
|
438
|
-
blur: 0.04, // radians — the sigma addModel's three path passes to fromScene
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
/** Every solid as [cx, cy, cz, cosY, sinY, hx, hy, hz, kind (0 room, 1 lit box, 2 panel), emissive]. */
|
|
442
|
-
const _roomSolids = new WeakMap();
|
|
443
|
-
function roomSolids(env) {
|
|
444
|
-
let out = _roomSolids.get(env);
|
|
445
|
-
if (out) return out;
|
|
446
|
-
const [rx, ry, rz, sx, sy, sz] = env.room;
|
|
447
|
-
out = [[rx, ry, rz, 1, 0, sx / 2, sy / 2, sz / 2, 0, 0]];
|
|
448
|
-
for (const [px, py, pz, a, bx, by, bz] of env.boxes) out.push([px, py, pz, Math.cos(a), Math.sin(a), bx / 2, by / 2, bz / 2, 1, 0]);
|
|
449
|
-
for (const [px, py, pz, bx, by, bz, e] of env.panels) out.push([px, py, pz, 1, 0, bx / 2, by / 2, bz / 2, 2, e]);
|
|
450
|
-
_roomSolids.set(env, out);
|
|
451
|
-
return out;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Radiance of the room seen from the origin toward unit direction (x, y, z): the nearest solid
|
|
456
|
-
* along the ray. A panel is its emissive value; a wall or box (white, roughness 1, non-metal) is
|
|
457
|
-
* three's direct physical shading from the one point light, unshadowed as in three (the scene casts
|
|
458
|
-
* no shadows) — Lambert plus GGX at alpha 1 (D = 1/π, V = 0.5/(NL+NV), Schlick F0 0.04), with
|
|
459
|
-
* three's inverse-square falloff and its (1 − (d/cutoff)⁴)² window.
|
|
460
|
-
*/
|
|
461
|
-
export function roomRadiance(x, y, z, env = ROOM_ENVIRONMENT) {
|
|
462
|
-
let best = Infinity;
|
|
463
|
-
let nx = 0;
|
|
464
|
-
let ny = 0;
|
|
465
|
-
let nz = 0;
|
|
466
|
-
let emit = -1;
|
|
467
|
-
let found = false;
|
|
468
|
-
for (const b of roomSolids(env)) {
|
|
469
|
-
const c = b[3];
|
|
470
|
-
const s = b[4];
|
|
471
|
-
// The ray in the solid's frame (a rotation about Y): local = Rᵀ(p − centre).
|
|
472
|
-
const ox = -b[0];
|
|
473
|
-
const oy = -b[1];
|
|
474
|
-
const oz = -b[2];
|
|
475
|
-
const o = [c * ox - s * oz, oy, s * ox + c * oz];
|
|
476
|
-
const d = [c * x - s * z, y, s * x + c * z];
|
|
477
|
-
let t0 = -Infinity;
|
|
478
|
-
let t1 = Infinity;
|
|
479
|
-
let a0 = 0;
|
|
480
|
-
let a1 = 0;
|
|
481
|
-
let miss = false;
|
|
482
|
-
for (let i = 0; i < 3; i++) {
|
|
483
|
-
const h = b[5 + i];
|
|
484
|
-
if (Math.abs(d[i]) < 1e-12) {
|
|
485
|
-
if (o[i] < -h || o[i] > h) {
|
|
486
|
-
miss = true;
|
|
487
|
-
break;
|
|
488
|
-
}
|
|
489
|
-
continue;
|
|
490
|
-
}
|
|
491
|
-
let n = (-h - o[i]) / d[i];
|
|
492
|
-
let f = (h - o[i]) / d[i];
|
|
493
|
-
let sn = -1; // the entry face's outward normal is −axis when the ray runs +axis
|
|
494
|
-
if (n > f) {
|
|
495
|
-
const t = n;
|
|
496
|
-
n = f;
|
|
497
|
-
f = t;
|
|
498
|
-
sn = 1;
|
|
499
|
-
}
|
|
500
|
-
if (n > t0) {
|
|
501
|
-
t0 = n;
|
|
502
|
-
a0 = (i + 1) * sn;
|
|
503
|
-
}
|
|
504
|
-
if (f < t1) {
|
|
505
|
-
t1 = f;
|
|
506
|
-
a1 = -(i + 1) * sn;
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
if (miss || t0 > t1 || t1 < 0) continue;
|
|
510
|
-
const inside = b[8] === 0;
|
|
511
|
-
const t = inside ? t1 : t0;
|
|
512
|
-
if (!(t > 0) || t >= best) continue;
|
|
513
|
-
best = t;
|
|
514
|
-
found = true;
|
|
515
|
-
if (b[8] === 2) {
|
|
516
|
-
emit = b[9];
|
|
517
|
-
continue;
|
|
518
|
-
}
|
|
519
|
-
emit = -1;
|
|
520
|
-
const a = inside ? a1 : a0;
|
|
521
|
-
const sg = (inside ? -1 : 1) * Math.sign(a); // the room is seen from inside: its normals point in
|
|
522
|
-
const ax = Math.abs(a) - 1;
|
|
523
|
-
const l0 = ax === 0 ? sg : 0;
|
|
524
|
-
const l2 = ax === 2 ? sg : 0;
|
|
525
|
-
nx = c * l0 + s * l2;
|
|
526
|
-
ny = ax === 1 ? sg : 0;
|
|
527
|
-
nz = -s * l0 + c * l2;
|
|
528
|
-
}
|
|
529
|
-
if (!found) return 0;
|
|
530
|
-
if (emit >= 0) return emit;
|
|
531
|
-
const [lx, ly, lz, I, cutoff, decay] = env.light;
|
|
532
|
-
let tx = lx - x * best;
|
|
533
|
-
let ty = ly - y * best;
|
|
534
|
-
let tz = lz - z * best;
|
|
535
|
-
const dist = Math.hypot(tx, ty, tz);
|
|
536
|
-
tx /= dist;
|
|
537
|
-
ty /= dist;
|
|
538
|
-
tz /= dist;
|
|
539
|
-
const nl = nx * tx + ny * ty + nz * tz;
|
|
540
|
-
if (nl <= 0) return 0;
|
|
541
|
-
const win = Math.max(0, Math.min(1, 1 - Math.pow(dist / cutoff, 4)));
|
|
542
|
-
const E = (I / Math.max(Math.pow(dist, decay), 0.01)) * win * win * nl;
|
|
543
|
-
const nv = Math.max(1e-4, -(nx * x + ny * y + nz * z));
|
|
544
|
-
const hx = tx - x;
|
|
545
|
-
const hy = ty - y;
|
|
546
|
-
const hz = tz - z;
|
|
547
|
-
const vh = Math.max(0, (tx * hx + ty * hy + tz * hz) / Math.hypot(hx, hy, hz));
|
|
548
|
-
const F = 0.04 + 0.96 * Math.pow(1 - vh, 5);
|
|
549
|
-
return (E / Math.PI) * (1 + F * (0.5 / (nl + nv)));
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* The room as a float equirect (row 0 = up, the same convention as neutralStudioRGBE), blurred by a
|
|
554
|
-
* Gaussian of `sigma` radians — separable on the sphere: σ in rows, σ/sinθ in columns (wrapping).
|
|
555
|
-
*/
|
|
556
|
-
export function roomEquirect(W = 256, H = 128, sigma = ROOM_ENVIRONMENT.blur, env = ROOM_ENVIRONMENT) {
|
|
557
|
-
const out = new Float32Array(W * H);
|
|
558
|
-
for (let r = 0; r < H; r++) {
|
|
559
|
-
const th = ((r + 0.5) / H) * Math.PI;
|
|
560
|
-
const st = Math.sin(th);
|
|
561
|
-
const y = Math.cos(th);
|
|
562
|
-
for (let c = 0; c < W; c++) {
|
|
563
|
-
const ph = ((c + 0.5) / W) * 2 * Math.PI;
|
|
564
|
-
out[r * W + c] = roomRadiance(st * Math.cos(ph), y, st * Math.sin(ph), env);
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
if (!(sigma > 0)) return out;
|
|
568
|
-
const perRow = Math.PI / H; // radians per row, and per column at the equator
|
|
569
|
-
const tmp = new Float32Array(W * H);
|
|
570
|
-
for (let r = 0; r < H; r++) {
|
|
571
|
-
const st = Math.max(Math.sin(((r + 0.5) / H) * Math.PI), 1e-3);
|
|
572
|
-
const sg = Math.min(sigma / perRow / st, W / 4);
|
|
573
|
-
const R = Math.ceil(3 * sg);
|
|
574
|
-
const k = [];
|
|
575
|
-
let ks = 0;
|
|
576
|
-
for (let i = -R; i <= R; i++) {
|
|
577
|
-
const v = Math.exp(-(i * i) / (2 * sg * sg));
|
|
578
|
-
k.push(v);
|
|
579
|
-
ks += v;
|
|
580
|
-
}
|
|
581
|
-
for (let c = 0; c < W; c++) {
|
|
582
|
-
let a = 0;
|
|
583
|
-
for (let i = -R; i <= R; i++) a += k[i + R] * out[r * W + (((c + i) % W) + W) % W];
|
|
584
|
-
tmp[r * W + c] = a / ks;
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
const sg = sigma / perRow;
|
|
588
|
-
const R = Math.ceil(3 * sg);
|
|
589
|
-
for (let r = 0; r < H; r++) {
|
|
590
|
-
for (let c = 0; c < W; c++) {
|
|
591
|
-
let a = 0;
|
|
592
|
-
let ks = 0;
|
|
593
|
-
for (let i = -R; i <= R; i++) {
|
|
594
|
-
const rr = r + i;
|
|
595
|
-
if (rr < 0 || rr >= H) continue;
|
|
596
|
-
const v = Math.exp(-(i * i) / (2 * sg * sg));
|
|
597
|
-
a += v * tmp[rr * W + c];
|
|
598
|
-
ks += v;
|
|
599
|
-
}
|
|
600
|
-
out[r * W + c] = a / ks;
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
return out;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/** The room as RGBE bytes (cached per size: every tile on a page gets the same image). */
|
|
607
|
-
const _roomCache = new Map();
|
|
608
|
-
export function roomRGBE(W = 256, H = 128) {
|
|
609
|
-
const key = `${W}x${H}`;
|
|
610
|
-
let bytes = _roomCache.get(key);
|
|
611
|
-
if (!bytes) {
|
|
612
|
-
const f = roomEquirect(W, H);
|
|
613
|
-
bytes = new Uint8Array(W * H * 4);
|
|
614
|
-
for (let i = 0; i < f.length; i++) rgbe(f[i], bytes, i * 4);
|
|
615
|
-
_roomCache.set(key, bytes);
|
|
616
|
-
}
|
|
617
|
-
return bytes.slice();
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* Yaw that lines the generated room up with three's world (its +X panel on +X, the ceiling panel
|
|
622
|
-
* overhead). Calibrated headless against three r180's own `environment: 'room'` render on two
|
|
623
|
-
* catalogue meshes: a sweep over 0/90/180/270 × mirrored has a clean minimum at 90, unmirrored, on
|
|
624
|
-
* both. (The neutral studio's 0 is the Sample Viewer's orientation, a different frame.)
|
|
625
|
-
*/
|
|
626
|
-
export const ROOM_YAW_DEG = 90;
|
|
627
|
-
|
|
628
|
-
/** Build the room environment on this app and set it as the scene's envAtlas. */
|
|
629
|
-
export function useRoomEnvironment(pc, app, yawDeg = ROOM_YAW_DEG) {
|
|
630
|
-
return useRgbeEquirect(pc, app, 'inline3d-room', roomRGBE(256, 128), 256, 128, yawDeg);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
282
|
/** Each environment's tone mapping: the look it was matched to. */
|
|
634
283
|
export const ENVIRONMENT_TONE_MAPPING = Object.freeze({ neutral: 'neutral', room: 'none', studio: 'neutral', none: 'neutral' });
|
|
635
284
|
|
|
@@ -675,100 +324,6 @@ function addStudioLights(pc, app, root) {
|
|
|
675
324
|
app.scene.ambientLight = new pc.Color(amb, amb, amb);
|
|
676
325
|
}
|
|
677
326
|
|
|
678
|
-
// ── KHR_materials_transmission / volume on this engine ─────────────────────────────────────
|
|
679
|
-
|
|
680
|
-
/**
|
|
681
|
-
* The engine reads KHR_materials_transmission (and _volume) into `useDynamicRefraction` +
|
|
682
|
-
* `BLEND_NORMAL`, and a dynamic-refraction material samples the camera's SCENE COLOUR MAP. Three
|
|
683
|
-
* things the engine leaves to the app, all needed for a glass or transmissive glTF to look like
|
|
684
|
-
* it does in the Khronos Sample Viewer (and three):
|
|
685
|
-
*
|
|
686
|
-
* 1. THE GRAB PASS. Nothing renders the scene colour map unless a camera asks for it; without it
|
|
687
|
-
* the material samples an unbound texture — the storm lantern's burner rendered as a MAGENTA
|
|
688
|
-
* blob inside its globe (measured). `viewer.useSceneColor()` asks, on every eye camera.
|
|
689
|
-
* 2. PASS ORDER. Both references draw opaque → transmissive → blended. The engine puts a
|
|
690
|
-
* transmissive material in the ONE back-to-front transparent list with the blended ones, so a
|
|
691
|
-
* blended globe (depthWrite off) can be drawn before the transmissive body behind it, which
|
|
692
|
-
* then paints over it — the burner showed THROUGH the lantern's opaque-alpha globe. A sort
|
|
693
|
-
* distance that puts transmissive draws first (back-to-front among themselves) restores the
|
|
694
|
-
* order: object MAE 10.7 → 10.2 vs three, 11.2 → 10.8 vs the Sample Viewer.
|
|
695
|
-
* 3. STEREO. The engine turns the refracted point's clip position into a grab-texture UV by
|
|
696
|
-
* mapping the VIEW's NDC over the WHOLE target (`getGrabScreenPos`). A stereo tile draws two
|
|
697
|
-
* views side by side into one target, so each eye would sample across both. The patched chunk
|
|
698
|
-
* measures the offset from THIS fragment instead (NDC delta ÷ NDC-per-pixel, from the
|
|
699
|
-
* derivatives), which is exact for any viewport and identical in mono.
|
|
700
|
-
*/
|
|
701
|
-
const GRAB_UV_FN = `
|
|
702
|
-
vec2 inline3dGrabUV(vec4 clipPos) {
|
|
703
|
-
vec4 p0 = matrix_viewProjection * vec4(vPositionW, 1.0);
|
|
704
|
-
vec2 n0 = p0.xy / p0.w;
|
|
705
|
-
vec2 dn = clipPos.xy / clipPos.w - n0;
|
|
706
|
-
vec2 perPx = vec2(dFdx(n0.x), dFdy(n0.y));
|
|
707
|
-
vec2 off = vec2(abs(perPx.x) > 1e-9 ? dn.x / perPx.x : 0.0, abs(perPx.y) > 1e-9 ? dn.y / perPx.y : 0.0);
|
|
708
|
-
return (gl_FragCoord.xy + off) * uScreenSize.zw;
|
|
709
|
-
}
|
|
710
|
-
`;
|
|
711
|
-
const _grabChunk = new WeakMap();
|
|
712
|
-
let _warnedGrabChunk = false;
|
|
713
|
-
|
|
714
|
-
/** The engine's refractionDynamicPS with the per-view grab UV (null if its shape is not the expected one). */
|
|
715
|
-
function stereoRefractionChunk(pc, device) {
|
|
716
|
-
if (_grabChunk.has(device)) return _grabChunk.get(device);
|
|
717
|
-
let out = null;
|
|
718
|
-
try {
|
|
719
|
-
const src = pc.ShaderChunks?.get?.(device, pc.SHADERLANGUAGE_GLSL)?.get?.('refractionDynamicPS');
|
|
720
|
-
const call = /getGrabScreenPos\(\s*projectionPoint\s*\)/;
|
|
721
|
-
const def = /vec3\s+evalRefractionColor\s*\(/;
|
|
722
|
-
if (typeof src === 'string' && call.test(src) && def.test(src)) {
|
|
723
|
-
out = src.replace(def, (m) => `${GRAB_UV_FN}\n${m}`).replace(call, 'inline3dGrabUV(projectionPoint)');
|
|
724
|
-
}
|
|
725
|
-
} catch {
|
|
726
|
-
out = null;
|
|
727
|
-
}
|
|
728
|
-
if (!out && !_warnedGrabChunk) {
|
|
729
|
-
_warnedGrabChunk = true;
|
|
730
|
-
console.warn(`${TAG} this engine's refraction shader has an unexpected shape; transmissive materials keep the engine's grab UV (correct in mono, not per eye in stereo).`);
|
|
731
|
-
}
|
|
732
|
-
_grabChunk.set(device, out);
|
|
733
|
-
return out;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/** Transmissive draws first, back-to-front among themselves (the engine's own key + an offset). */
|
|
737
|
-
function transmissiveFirst(mi, camPos, camFwd) {
|
|
738
|
-
const c = mi.aabb.center;
|
|
739
|
-
return 1e6 + (c.x - camPos.x) * camFwd.x + (c.y - camPos.y) * camFwd.y + (c.z - camPos.z) * camFwd.z;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
/**
|
|
743
|
-
* Apply 1–3 above to every dynamic-refraction mesh under `entity` not already handled (`seen`).
|
|
744
|
-
* Returns how many transmissive mesh instances there are under it (handled now or before) —
|
|
745
|
-
* the caller turns the grab pass on when that is > 0. Cheap to call again: handled ones are skipped.
|
|
746
|
-
*/
|
|
747
|
-
export function prepareTransmission(pc, entity, device, seen = new WeakSet()) {
|
|
748
|
-
const renders = entity?.findComponents ? entity.findComponents('render') : [];
|
|
749
|
-
let n = 0;
|
|
750
|
-
for (const r of renders) {
|
|
751
|
-
for (const mi of r.meshInstances || []) {
|
|
752
|
-
const m = mi.material;
|
|
753
|
-
if (!m?.useDynamicRefraction) continue;
|
|
754
|
-
n++;
|
|
755
|
-
if (seen.has(mi)) continue;
|
|
756
|
-
seen.add(mi);
|
|
757
|
-
mi.calculateSortDistance = transmissiveFirst;
|
|
758
|
-
if (!seen.has(m)) {
|
|
759
|
-
seen.add(m);
|
|
760
|
-
const chunk = device ? stereoRefractionChunk(pc, device) : null;
|
|
761
|
-
if (chunk && typeof m.getShaderChunks === 'function') {
|
|
762
|
-
m.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('refractionDynamicPS', chunk);
|
|
763
|
-
m.shaderChunksVersion = pc.CHUNKAPI_2_8 ?? '2.8';
|
|
764
|
-
m.update?.();
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
return n;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
327
|
// ── framing ─────────────────────────────────────────────────────────────────────────────────
|
|
773
328
|
|
|
774
329
|
/** Union of the render mesh instances' world AABBs (content space, the file's own units). */
|