@displayxr/inline3d 1.7.1 → 1.9.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 +86 -0
- package/README.md +22 -0
- package/js/inline3d-splat-perf.js +231 -0
- package/js/inline3d-splat-playcanvas.js +2101 -0
- package/js/inline3d-splat-rig.js +268 -3
- package/js/inline3d-splat-shared.js +318 -0
- package/js/inline3d-splat.js +182 -118
- package/js/inline3d-three.js +43 -9
- package/js/inline3d-viewer.js +27 -41
- package/package.json +8 -2
- package/splat.d.ts +191 -7
- package/three.d.ts +17 -0
|
@@ -0,0 +1,2101 @@
|
|
|
1
|
+
// inline3d-splat-playcanvas.js — `addSplat(…, { engine: 'playcanvas' })`: the same splat window,
|
|
2
|
+
// rendered by the PlayCanvas engine instead of three + Spark.
|
|
3
|
+
//
|
|
4
|
+
// EXPERIMENTAL. Internal to `./splat`, which imports this module DYNAMICALLY and only when a
|
|
5
|
+
// caller asks for `engine: 'playcanvas'` — so a page that never asks never resolves
|
|
6
|
+
// `playcanvas`. Not covered by the SDK's 1.x semver promise; see docs/sdk-stability.md and
|
|
7
|
+
// docs/playcanvas-adapter.md (what differs from the Spark path, and why).
|
|
8
|
+
//
|
|
9
|
+
// WHAT IS THE SAME. The handle surface (`ready`, `remove`, `exclude/unexclude`, `setPose`,
|
|
10
|
+
// `resetPose`, `setFocus`, `getFocus`, `onFocusChange`, `pick`, `rig`, `camera`, `frame`, `perf`,
|
|
11
|
+
// `viewer`, `mesh`), the rig/lens/focus WATERFALL (./inline3d-splat-rig.js, shared verbatim), the
|
|
12
|
+
// `.sog` camera block (./inline3d-sog.js), the pose model (yaw/pitch/zoom/depth/focus, eased the
|
|
13
|
+
// same way, with the same constants), the auto-fit arithmetic, the mono fallback's cameras, the
|
|
14
|
+
// buffer-shape rule (double-width in 3D, 1:1 in mono) and the validate-before-clear +
|
|
15
|
+
// last-good-replay rule (web#12). The views still come from the runtime; nothing here builds an
|
|
16
|
+
// off-axis projection for the 3D path.
|
|
17
|
+
//
|
|
18
|
+
// WHAT IS NOT. The subject does not move — the EYES do. SceneViewer rotates and scales the
|
|
19
|
+
// content under a fixed camera; here the splat entity never moves and the camera's parent carries
|
|
20
|
+
// the INVERSE of that same pivot transform. `RenderView.updateTransforms` multiplies the camera's
|
|
21
|
+
// parent world transform into every view, so the runtime's display-space eye poses are
|
|
22
|
+
// re-expressed in content space for free, and the engine never re-bakes its work buffer for a
|
|
23
|
+
// moving placement. The picture is identical: view × model is the same product either way.
|
|
24
|
+
//
|
|
25
|
+
// ENGINE SHAPE (one set per tile, same as one WebGLRenderer per tile on the Spark path):
|
|
26
|
+
// AppBase (no XrManager, no mouse/keyboard/touch) on our own WebGL2 device
|
|
27
|
+
// └── root
|
|
28
|
+
// ├── splat gsplat component, 180° about X (the OpenCV → GL flip)
|
|
29
|
+
// └── rig ← inverse(pivot), written by THIS module's tick and nothing else
|
|
30
|
+
// └── eye camera, ONE CameraComponent with N `RenderView`s (the engine's own WebXR
|
|
31
|
+
// stereo path, driven without WebXR) — one gsplat manager, one sort, one work
|
|
32
|
+
// buffer for all views.
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
resolveRig,
|
|
36
|
+
planeDistance,
|
|
37
|
+
sampleCloudRestSpace,
|
|
38
|
+
rigNeedsCloud,
|
|
39
|
+
sampleCloudCentres,
|
|
40
|
+
centresVisitor,
|
|
41
|
+
RIG_SAMPLE_CAP,
|
|
42
|
+
RIG_MIN_OPACITY,
|
|
43
|
+
FRAME_SAMPLE_CAP,
|
|
44
|
+
} from './inline3d-splat-rig.js';
|
|
45
|
+
import { readSogMeta, sogCameraFromMeta } from './inline3d-sog.js';
|
|
46
|
+
import {
|
|
47
|
+
playcanvasPerfSettings,
|
|
48
|
+
patchPlayCanvasQuadExtent,
|
|
49
|
+
tileSplatBudget,
|
|
50
|
+
budgetPerManager,
|
|
51
|
+
} from './inline3d-splat-perf.js';
|
|
52
|
+
import { boundsFromPositions } from './inline3d-viewer.js';
|
|
53
|
+
import { cameraRigFromPose } from './inline3d-three.js';
|
|
54
|
+
import {
|
|
55
|
+
clamp,
|
|
56
|
+
finite,
|
|
57
|
+
now,
|
|
58
|
+
toArray3,
|
|
59
|
+
canvasNdc,
|
|
60
|
+
bindFocusGestures,
|
|
61
|
+
DEFAULT_DEPTH_LIMIT,
|
|
62
|
+
IDLE_DELAY_MS,
|
|
63
|
+
FOCUS_EASE,
|
|
64
|
+
DAMP_BASE,
|
|
65
|
+
MAX_DT_S,
|
|
66
|
+
PITCH_LIMIT,
|
|
67
|
+
ORBIT_MAX_DEG,
|
|
68
|
+
ORBIT_TAU_DRAG_S,
|
|
69
|
+
ORBIT_TAU_REST_S,
|
|
70
|
+
WHEEL_LINE_PX,
|
|
71
|
+
WHEEL_PAGE_PX,
|
|
72
|
+
WHEEL_MAX_PX,
|
|
73
|
+
ZOOM_PER_PX,
|
|
74
|
+
ZOOM_MIN,
|
|
75
|
+
ZOOM_MAX,
|
|
76
|
+
MONO_FOV,
|
|
77
|
+
MONO_NEAR,
|
|
78
|
+
MONO_FAR,
|
|
79
|
+
CAPTURE_FAR,
|
|
80
|
+
captureWindow,
|
|
81
|
+
captureVerticalFovDeg,
|
|
82
|
+
engineFormatFor,
|
|
83
|
+
pathOf,
|
|
84
|
+
streamedBytesError,
|
|
85
|
+
} from './inline3d-splat-shared.js';
|
|
86
|
+
|
|
87
|
+
/** The engine release this adapter was built and measured against (npm peer floor). */
|
|
88
|
+
export const PLAYCANVAS_TESTED = '2.22.3';
|
|
89
|
+
|
|
90
|
+
const PICK_CONE_RAD = 0.02;
|
|
91
|
+
|
|
92
|
+
const DEG = Math.PI / 180;
|
|
93
|
+
|
|
94
|
+
// ── pure matrix arithmetic (column-major, the layout XRView, three and the engine all use) ──
|
|
95
|
+
|
|
96
|
+
/** three's `Matrix4.makePerspective(left, right, top, bottom, near, far)`, element for element. */
|
|
97
|
+
export function perspectiveOffAxis(left, right, top, bottom, near, far, out = new Float64Array(16)) {
|
|
98
|
+
const x = (2 * near) / (right - left);
|
|
99
|
+
const y = (2 * near) / (top - bottom);
|
|
100
|
+
const a = (right + left) / (right - left);
|
|
101
|
+
const b = (top + bottom) / (top - bottom);
|
|
102
|
+
const c = -(far + near) / (far - near);
|
|
103
|
+
const d = (-2 * far * near) / (far - near);
|
|
104
|
+
out.fill(0);
|
|
105
|
+
out[0] = x;
|
|
106
|
+
out[5] = y;
|
|
107
|
+
out[8] = a;
|
|
108
|
+
out[9] = b;
|
|
109
|
+
out[10] = c;
|
|
110
|
+
out[11] = -1;
|
|
111
|
+
out[14] = d;
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** three's `PerspectiveCamera.updateProjectionMatrix()` (zoom 1, no film offset, no view). */
|
|
116
|
+
export function perspectiveFov(fovDeg, aspect, near, far, out) {
|
|
117
|
+
const top = near * Math.tan(DEG * 0.5 * fovDeg);
|
|
118
|
+
const height = 2 * top;
|
|
119
|
+
const width = aspect * height;
|
|
120
|
+
const left = -0.5 * width;
|
|
121
|
+
return perspectiveOffAxis(left, left + width, top, top - height, near, far, out);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What a projection matrix says about itself — vertical FOV, aspect, near and far — the same
|
|
126
|
+
* extraction the engine's XrManager does for a headset's views, so LOD and culling see the
|
|
127
|
+
* frustum the views actually have (the engine reads `camera.fov`, not the matrix).
|
|
128
|
+
*/
|
|
129
|
+
export function frustumFromProjection(P) {
|
|
130
|
+
return {
|
|
131
|
+
fov: (2 * Math.atan(1 / P[5])) / DEG,
|
|
132
|
+
aspectRatio: P[5] / P[0],
|
|
133
|
+
nearClip: P[14] / (P[10] - 1),
|
|
134
|
+
// An infinite-far projection has P[10] = -1 exactly (three's and WebXR's reversed/infinite
|
|
135
|
+
// forms both land here), which would put -Infinity/NaN into setXrProperties. Clamp to the
|
|
136
|
+
// same large finite far the capture camera uses.
|
|
137
|
+
farClip: Math.abs(P[10] + 1) < 1e-9 ? CAPTURE_FAR : P[14] / (P[10] + 1),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Rigid pose (position + unit quaternion xyzw) as a column-major 4×4. */
|
|
142
|
+
export function poseMatrix(p, q, out = new Float64Array(16)) {
|
|
143
|
+
const [x, y, z, w] = q;
|
|
144
|
+
const x2 = x + x, y2 = y + y, z2 = z + z;
|
|
145
|
+
const xx = x * x2, xy = x * y2, xz = x * z2;
|
|
146
|
+
const yy = y * y2, yz = y * z2, zz = z * z2;
|
|
147
|
+
const wx = w * x2, wy = w * y2, wz = w * z2;
|
|
148
|
+
out[0] = 1 - (yy + zz); out[1] = xy + wz; out[2] = xz - wy; out[3] = 0;
|
|
149
|
+
out[4] = xy - wz; out[5] = 1 - (xx + zz); out[6] = yz + wx; out[7] = 0;
|
|
150
|
+
out[8] = xz + wy; out[9] = yz - wx; out[10] = 1 - (xx + yy); out[11] = 0;
|
|
151
|
+
out[12] = p[0]; out[13] = p[1]; out[14] = p[2]; out[15] = 1;
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Unit quaternion (xyzw) of the rotation part of a rigid column-major 4×4. */
|
|
156
|
+
export function quatFromMatrix(m) {
|
|
157
|
+
const m11 = m[0], m12 = m[4], m13 = m[8];
|
|
158
|
+
const m21 = m[1], m22 = m[5], m23 = m[9];
|
|
159
|
+
const m31 = m[2], m32 = m[6], m33 = m[10];
|
|
160
|
+
const tr = m11 + m22 + m33;
|
|
161
|
+
let x, y, z, w;
|
|
162
|
+
if (tr > 0) {
|
|
163
|
+
const s = 0.5 / Math.sqrt(tr + 1);
|
|
164
|
+
w = 0.25 / s; x = (m32 - m23) * s; y = (m13 - m31) * s; z = (m21 - m12) * s;
|
|
165
|
+
} else if (m11 > m22 && m11 > m33) {
|
|
166
|
+
const s = 2 * Math.sqrt(1 + m11 - m22 - m33);
|
|
167
|
+
w = (m32 - m23) / s; x = 0.25 * s; y = (m12 + m21) / s; z = (m13 + m31) / s;
|
|
168
|
+
} else if (m22 > m33) {
|
|
169
|
+
const s = 2 * Math.sqrt(1 + m22 - m11 - m33);
|
|
170
|
+
w = (m13 - m31) / s; x = (m12 + m21) / s; y = 0.25 * s; z = (m23 + m32) / s;
|
|
171
|
+
} else {
|
|
172
|
+
const s = 2 * Math.sqrt(1 + m33 - m11 - m22);
|
|
173
|
+
w = (m21 - m12) / s; x = (m13 + m31) / s; y = (m23 + m32) / s; z = 0.25 * s;
|
|
174
|
+
}
|
|
175
|
+
return [x, y, z, w];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** a × b, column-major. */
|
|
179
|
+
export function mat4Mul(a, b, out = new Float64Array(16)) {
|
|
180
|
+
const r = new Float64Array(16);
|
|
181
|
+
for (let c = 0; c < 4; c++) {
|
|
182
|
+
for (let row = 0; row < 4; row++) {
|
|
183
|
+
r[c * 4 + row] =
|
|
184
|
+
a[row] * b[c * 4] + a[4 + row] * b[c * 4 + 1] + a[8 + row] * b[c * 4 + 2] + a[12 + row] * b[c * 4 + 3];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
out.set(r);
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** General 4×4 inverse (column-major); null when singular. */
|
|
192
|
+
export function mat4Invert(m, out = new Float64Array(16)) {
|
|
193
|
+
const [a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33] = m;
|
|
194
|
+
const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10;
|
|
195
|
+
const b03 = a01 * a12 - a02 * a11, b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12;
|
|
196
|
+
const b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30, b08 = a20 * a33 - a23 * a30;
|
|
197
|
+
const b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32;
|
|
198
|
+
const det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
|
|
199
|
+
if (!det) return null;
|
|
200
|
+
const id = 1 / det;
|
|
201
|
+
out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * id;
|
|
202
|
+
out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * id;
|
|
203
|
+
out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * id;
|
|
204
|
+
out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * id;
|
|
205
|
+
out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * id;
|
|
206
|
+
out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * id;
|
|
207
|
+
out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * id;
|
|
208
|
+
out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * id;
|
|
209
|
+
out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * id;
|
|
210
|
+
out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * id;
|
|
211
|
+
out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * id;
|
|
212
|
+
out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * id;
|
|
213
|
+
out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * id;
|
|
214
|
+
out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * id;
|
|
215
|
+
out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * id;
|
|
216
|
+
out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * id;
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** m · (x, y, z, 1), perspective-divided. */
|
|
221
|
+
export function transformPoint(m, x, y, z) {
|
|
222
|
+
const w = m[3] * x + m[7] * y + m[11] * z + m[15];
|
|
223
|
+
return [
|
|
224
|
+
(m[0] * x + m[4] * y + m[8] * z + m[12]) / w,
|
|
225
|
+
(m[1] * x + m[5] * y + m[9] * z + m[13]) / w,
|
|
226
|
+
(m[2] * x + m[6] * y + m[10] * z + m[14]) / w,
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Hamilton product a ⊗ b, xyzw. */
|
|
231
|
+
function qmul(a, b) {
|
|
232
|
+
return [
|
|
233
|
+
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
|
234
|
+
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
|
235
|
+
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
|
236
|
+
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Rotate v by unit quaternion q (xyzw). */
|
|
241
|
+
function qrot(q, v) {
|
|
242
|
+
const [x, y, z, w] = q;
|
|
243
|
+
const tx = 2 * (y * v[2] - z * v[1]);
|
|
244
|
+
const ty = 2 * (z * v[0] - x * v[2]);
|
|
245
|
+
const tz = 2 * (x * v[1] - y * v[0]);
|
|
246
|
+
return [v[0] + w * tx + (y * tz - z * ty), v[1] + w * ty + (z * tx - x * tz), v[2] + w * tz + (x * ty - y * tx)];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The half turn about X that takes an OpenCV-frame splat (+y down, +z forward) into GL axes. */
|
|
250
|
+
const FLIP_Q = [1, 0, 0, 0];
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* SceneViewer's pivot as ONE matrix: content space → display space.
|
|
254
|
+
*
|
|
255
|
+
* M = T(orbitCentre + (0,0,depthOffset)) · Rx(pitch) · Ry(yaw) · S(fit × zoom) · T(−focus)
|
|
256
|
+
*
|
|
257
|
+
* which is exactly the product of its `_pivot` (position, 'XYZ' rotation, scale) and
|
|
258
|
+
* `_centering` (−focus) groups. Kept as a pure function so a test can hold the two together.
|
|
259
|
+
*/
|
|
260
|
+
export function pivotMatrix({ yaw = 0, pitch = 0, scale = 1, focus = [0, 0, 0], orbitCentre = [0, 0, 0], depthOffset = 0 }) {
|
|
261
|
+
const q = pivotQuat(yaw, pitch);
|
|
262
|
+
const R = poseMatrix([0, 0, 0], q);
|
|
263
|
+
const out = new Float64Array(16);
|
|
264
|
+
// columns of R·S
|
|
265
|
+
for (let i = 0; i < 12; i++) out[i] = (i % 4 === 3 ? 0 : R[i] * scale);
|
|
266
|
+
const t = [orbitCentre[0], orbitCentre[1], orbitCentre[2] + depthOffset];
|
|
267
|
+
const rf = qrot(q, focus);
|
|
268
|
+
out[12] = t[0] - scale * rf[0];
|
|
269
|
+
out[13] = t[1] - scale * rf[1];
|
|
270
|
+
out[14] = t[2] - scale * rf[2];
|
|
271
|
+
out[15] = 1;
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** R = Rx(pitch)·Ry(yaw) — three's Euler order 'XYZ' — as a quaternion. */
|
|
276
|
+
function pivotQuat(yawDeg, pitchDeg) {
|
|
277
|
+
const a = (pitchDeg * DEG) / 2;
|
|
278
|
+
const b = (yawDeg * DEG) / 2;
|
|
279
|
+
return qmul([Math.sin(a), 0, 0, Math.cos(a)], [0, Math.sin(b), 0, Math.cos(b)]);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The INVERSE pivot, as the TRS the engine's rig node takes. M⁻¹ = T(f)·S(1/s)·Rᵀ·T(−t), and a
|
|
284
|
+
* uniform scale commutes with a rotation, so it is position f − Rᵀt/s, rotation Rᵀ, scale 1/s.
|
|
285
|
+
*/
|
|
286
|
+
export function pivotInverseTRS({ yaw = 0, pitch = 0, scale = 1, focus = [0, 0, 0], orbitCentre = [0, 0, 0], depthOffset = 0 }) {
|
|
287
|
+
const q = pivotQuat(yaw, pitch);
|
|
288
|
+
const qi = [-q[0], -q[1], -q[2], q[3]];
|
|
289
|
+
const t = [orbitCentre[0], orbitCentre[1], orbitCentre[2] + depthOffset];
|
|
290
|
+
const rt = qrot(qi, t);
|
|
291
|
+
const inv = 1 / scale;
|
|
292
|
+
return {
|
|
293
|
+
position: [focus[0] - rt[0] * inv, focus[1] - rt[1] * inv, focus[2] - rt[2] * inv],
|
|
294
|
+
rotation: qi,
|
|
295
|
+
scale: inv,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* SceneViewer.fitTo's scale, as a function. Same arithmetic, same order, same clamps — see the
|
|
301
|
+
* reasoning there (swept width, 'height' guard, depth backstop).
|
|
302
|
+
*/
|
|
303
|
+
export function fitScale({ extent, fit = 'contain', margin = 0.8, vH = 0.24, aspect = 1, fitSweep = true, depthLimit = DEFAULT_DEPTH_LIMIT }) {
|
|
304
|
+
if (fit === 'none') return 1;
|
|
305
|
+
const vW = vH * aspect;
|
|
306
|
+
const ex = Math.max(extent[0], 1e-6);
|
|
307
|
+
const ey = Math.max(extent[1], 1e-6);
|
|
308
|
+
const ez = Math.max(extent[2], 1e-6);
|
|
309
|
+
const horiz = fitSweep ? Math.hypot(ex, ez) : ex;
|
|
310
|
+
let s;
|
|
311
|
+
if (fit === 'cover') s = Math.max((margin * vH) / ey, (margin * vW) / horiz);
|
|
312
|
+
else if (fit === 'contain') s = Math.min((margin * vH) / ey, (margin * vW) / horiz);
|
|
313
|
+
else {
|
|
314
|
+
s = (margin * vH) / ey;
|
|
315
|
+
if (horiz * s > vW) s = vW / horiz;
|
|
316
|
+
}
|
|
317
|
+
const sz = (depthLimit * vH) / ez;
|
|
318
|
+
if (sz < s) s = sz;
|
|
319
|
+
return s;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The capture camera's mono projection — applyCaptureCamera's off-axis window, principal point
|
|
324
|
+
* honoured, vertical kept and horizontal fitted to the canvas aspect.
|
|
325
|
+
*/
|
|
326
|
+
export function captureProjection(intrinsics, aspect, near = MONO_NEAR, far = CAPTURE_FAR, out, captureFit = 'height') {
|
|
327
|
+
const w = captureWindow(intrinsics, aspect, near, captureFit);
|
|
328
|
+
return perspectiveOffAxis(w.left, w.right, w.top, w.bottom, near, far, out);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The capture camera's POSE in content space — applyCaptureCamera's two half-turns: the OpenCV →
|
|
333
|
+
* GL camera convention on the right always, the content flip on the left under `flipY`.
|
|
334
|
+
*/
|
|
335
|
+
export function capturePose(rest, flipY) {
|
|
336
|
+
let p = rest.position.slice(0, 3);
|
|
337
|
+
let q = rest.rotation.slice(0, 4);
|
|
338
|
+
if (flipY) {
|
|
339
|
+
p = qrot(FLIP_Q, p);
|
|
340
|
+
q = qmul(FLIP_Q, q);
|
|
341
|
+
}
|
|
342
|
+
q = qmul(q, FLIP_Q);
|
|
343
|
+
return { position: p, rotation: q, matrix: poseMatrix(p, q) };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The shader override for the engine's non-square-pixel footprint bug.
|
|
348
|
+
*
|
|
349
|
+
* `gsplatCornerVS` derives ONE focal length from the viewport WIDTH and uses it for both axes of
|
|
350
|
+
* the projection Jacobian, which is only right when pixels are square. A side-by-side eye is
|
|
351
|
+
* half-width over a full-height frustum, so every splat was drawn at half its true height:
|
|
352
|
+
* visible vertical stipple, MAE 11.5 against a square-pixel reference in the P0 spike, 1.7 with
|
|
353
|
+
* this. On square pixels (the mono view) it changes nothing (MAE 2.5e-6). Upstream fix pending
|
|
354
|
+
* (epic #36, P4d); until then the anchors are regexes (the npm build re-indents chunks with
|
|
355
|
+
* tabs) and a miss warns once and renders unpatched rather than failing.
|
|
356
|
+
*
|
|
357
|
+
* @returns {{src:string, ok:boolean}}
|
|
358
|
+
*/
|
|
359
|
+
export function patchGsplatFootprint(src) {
|
|
360
|
+
if (typeof src !== 'string') return { src, ok: false };
|
|
361
|
+
if (src.includes('dxrFocalY')) return { src, ok: true };
|
|
362
|
+
const r1 = /vec2\s+J2\s*=\s*-J1\s*\/\s*vp\.z\s*\*\s*vp\.xy\s*;/;
|
|
363
|
+
const r2 = /0\.0\s*,\s*J1\s*,\s*J2\.y\s*,/;
|
|
364
|
+
if (!r1.test(src) || !r2.test(src)) return { src, ok: false };
|
|
365
|
+
return {
|
|
366
|
+
src: src
|
|
367
|
+
.replace(
|
|
368
|
+
r1,
|
|
369
|
+
'float J1y = (viewport_size.y * matrix_projection[1][1]) / vp.z; /* dxrFocalY */ ' +
|
|
370
|
+
'vec2 J2 = vec2(-J1 / vp.z * vp.x, -J1y / vp.z * vp.y);',
|
|
371
|
+
)
|
|
372
|
+
.replace(r2, '0.0, J1y, J2.y,'),
|
|
373
|
+
ok: true,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let warnedFootprint = false;
|
|
378
|
+
let warnedQuadExtent = false;
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Which way the engine gets N views into one canvas. `renderview` — one camera, N `RenderView`s
|
|
382
|
+
* — is the design; `cameras` — N cameras with `rect` + `calculateProjection`, all public API,
|
|
383
|
+
* bit-identical in the P0 spike at N× the gsplat memory — is the fallback for an engine build
|
|
384
|
+
* that lacks the XR view plumbing.
|
|
385
|
+
*/
|
|
386
|
+
export function pickViewPath(pc, forced) {
|
|
387
|
+
if (forced === 'cameras' || forced === 'renderview') return forced;
|
|
388
|
+
const ok = typeof pc?.RenderView === 'function' && !!pc?.Camera?.prototype && 'xrViews' in pc.Camera.prototype;
|
|
389
|
+
return ok ? 'renderview' : 'cameras';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Source routing lives in ./inline3d-splat-shared.js (./splat also needs it, synchronously, to
|
|
393
|
+
// refuse a format this engine cannot read at call time); re-exported for the tests.
|
|
394
|
+
export { engineFormatFor, isStreamedUrl, streamedBytesError } from './inline3d-splat-shared.js';
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The URL the engine loads for a streamed source: a directory URL gets `lod-meta.json` appended
|
|
398
|
+
* (before any query/hash, which are kept); a `lod-meta.json` URL is returned as is. The engine
|
|
399
|
+
* resolves every chunk relative to this URL's directory, so it must name the file itself.
|
|
400
|
+
*/
|
|
401
|
+
export function streamedEntryUrl(src) {
|
|
402
|
+
if (typeof src !== 'string') return src;
|
|
403
|
+
const m = /^([^?#]*)(.*)$/.exec(src);
|
|
404
|
+
const path = m[1];
|
|
405
|
+
const tail = m[2];
|
|
406
|
+
return path.endsWith('/') ? `${path}lod-meta.json${tail}` : src;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The pick fallback on flat arrays: the gaussian whose CENTRE is nearest the ray — by angle,
|
|
411
|
+
* then nearest along the ray inside a small cone. Same rule as the Spark path's
|
|
412
|
+
* nearestGaussianToRay, on content-space centres.
|
|
413
|
+
*
|
|
414
|
+
* @param {Float32Array} xyz centres, in whatever space `origin`/`dir` are in.
|
|
415
|
+
* @param {Uint8Array} [alpha8] optional peak opacity per splat, 0–255; below RIG_MIN_OPACITY skipped.
|
|
416
|
+
* @returns {number[]|null} a point in the same space.
|
|
417
|
+
*/
|
|
418
|
+
export function nearestCentreToRay(xyz, origin, dir, coneRad = PICK_CONE_RAD, alpha8 = null, count = Math.floor(xyz.length / 3)) {
|
|
419
|
+
const n = count;
|
|
420
|
+
let bestInCone = null;
|
|
421
|
+
let bestInConeT = Infinity;
|
|
422
|
+
let bestAngle = Infinity;
|
|
423
|
+
let bestAny = null;
|
|
424
|
+
const minA = Math.ceil(RIG_MIN_OPACITY * 255);
|
|
425
|
+
for (let i = 0; i < n; i++) {
|
|
426
|
+
if (alpha8 && alpha8[i] < minA) continue; // haze is not what was clicked
|
|
427
|
+
const rx = xyz[i * 3] - origin[0];
|
|
428
|
+
const ry = xyz[i * 3 + 1] - origin[1];
|
|
429
|
+
const rz = xyz[i * 3 + 2] - origin[2];
|
|
430
|
+
const t = rx * dir[0] + ry * dir[1] + rz * dir[2];
|
|
431
|
+
if (!(t > 0)) continue;
|
|
432
|
+
const perp = Math.sqrt(Math.max(0, rx * rx + ry * ry + rz * rz - t * t));
|
|
433
|
+
const angle = perp / t;
|
|
434
|
+
if (angle <= coneRad) {
|
|
435
|
+
if (t < bestInConeT) {
|
|
436
|
+
bestInConeT = t;
|
|
437
|
+
bestInCone = [xyz[i * 3], xyz[i * 3 + 1], xyz[i * 3 + 2]];
|
|
438
|
+
}
|
|
439
|
+
} else if (!bestInCone && angle < bestAngle) {
|
|
440
|
+
bestAngle = angle;
|
|
441
|
+
bestAny = [xyz[i * 3], xyz[i * 3 + 1], xyz[i * 3 + 2]];
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return bestInCone || bestAny;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── the viewer: pose state + frame loop + the engine ────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The PlayCanvas counterpart of SceneViewer, as far as ./splat uses it: the same pose model and
|
|
451
|
+
* public methods, with the engine behind them instead of three. Not a SceneViewer subclass and
|
|
452
|
+
* not field-compatible with one (docs/playcanvas-adapter.md §Divergences).
|
|
453
|
+
*/
|
|
454
|
+
export class PlayCanvasSplatViewer {
|
|
455
|
+
constructor(canvas, opts = {}) {
|
|
456
|
+
const {
|
|
457
|
+
virtualDisplayHeight = 0.24,
|
|
458
|
+
fit = 'contain',
|
|
459
|
+
margin = 0.8,
|
|
460
|
+
depthLimit = DEFAULT_DEPTH_LIMIT,
|
|
461
|
+
fitSweep = true,
|
|
462
|
+
orbit = true,
|
|
463
|
+
idleSpin = 0,
|
|
464
|
+
renderScale = 1,
|
|
465
|
+
pitchLimit = PITCH_LIMIT,
|
|
466
|
+
orbitMaxDeg = ORBIT_MAX_DEG,
|
|
467
|
+
orbitEase = {},
|
|
468
|
+
feather = 0,
|
|
469
|
+
captureFit = 'height',
|
|
470
|
+
} = opts;
|
|
471
|
+
this.canvas = canvas;
|
|
472
|
+
// The tilt-and-relax orbit (./inline3d-splat-shared.js §ORBIT): drag tilts up to ±orbitMaxDeg
|
|
473
|
+
// from where the press started, easing with τ = orbitEase.drag; release relaxes back with
|
|
474
|
+
// τ = orbitEase.rest. `_orbitMode` is 'drag' | 'rest' | null (null = ordinary damping).
|
|
475
|
+
this.orbitMaxDeg = orbitMaxDeg;
|
|
476
|
+
this.orbitEase = { drag: orbitEase.drag ?? ORBIT_TAU_DRAG_S, rest: orbitEase.rest ?? ORBIT_TAU_REST_S };
|
|
477
|
+
this._orbitMode = null;
|
|
478
|
+
this.featherPx = feather > 0 ? feather : 0;
|
|
479
|
+
this.captureFit = captureFit;
|
|
480
|
+
/** Called when the capture camera's vertical FOV changes (captureFit 'cover' on a resize). */
|
|
481
|
+
this.onCaptureFov = null;
|
|
482
|
+
/** Per-frame hooks, `(tMs) => boolean` — return false to be removed. setSource's crossfade. */
|
|
483
|
+
this._hooks = [];
|
|
484
|
+
this.vH = virtualDisplayHeight;
|
|
485
|
+
this.fit = fit;
|
|
486
|
+
this.margin = margin;
|
|
487
|
+
this.depthLimit = depthLimit;
|
|
488
|
+
this.fitSweep = fitSweep;
|
|
489
|
+
this.renderScale = renderScale;
|
|
490
|
+
this.pitchLimit = pitchLimit;
|
|
491
|
+
this.idleSpin = idleSpin;
|
|
492
|
+
this.flipY = opts.flipY !== false;
|
|
493
|
+
|
|
494
|
+
this._fitScale = 1;
|
|
495
|
+
this._zoom = 1;
|
|
496
|
+
this._targetZoom = 1;
|
|
497
|
+
this._depthOffset = 0;
|
|
498
|
+
this._subjectHalf = [0, 0, 0];
|
|
499
|
+
this._yaw = 0;
|
|
500
|
+
this._pitch = 0;
|
|
501
|
+
this._targetYaw = 0;
|
|
502
|
+
this._targetPitch = 0;
|
|
503
|
+
this._focus = { x: 0, y: 0, z: 0 };
|
|
504
|
+
this._targetFocus = { x: 0, y: 0, z: 0 };
|
|
505
|
+
this._orbitCentre = { x: 0, y: 0, z: 0 };
|
|
506
|
+
this._focusRecentres = true;
|
|
507
|
+
this._focusSettled = true;
|
|
508
|
+
this.onFocusChange = null;
|
|
509
|
+
this.onTick = null;
|
|
510
|
+
this._lastInput = now();
|
|
511
|
+
this._lastTick = 0;
|
|
512
|
+
this._monoRaf = 0;
|
|
513
|
+
this._mode = '3d';
|
|
514
|
+
this._disposed = false;
|
|
515
|
+
this._resizePending = false;
|
|
516
|
+
this._lastGood = null;
|
|
517
|
+
this._vps = [];
|
|
518
|
+
this._reduceMotion =
|
|
519
|
+
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
520
|
+
|
|
521
|
+
// The mono camera, as numbers. `pose` is display space (the viewer's world), `proj` is
|
|
522
|
+
// recomputed on resize; `capture` swaps in the camera rig's off-axis window.
|
|
523
|
+
this.mono = {
|
|
524
|
+
fov: MONO_FOV,
|
|
525
|
+
near: MONO_NEAR,
|
|
526
|
+
far: MONO_FAR,
|
|
527
|
+
pose: poseMatrix([0, 0, 1], [0, 0, 0, 1]),
|
|
528
|
+
proj: new Float64Array(16),
|
|
529
|
+
capture: null, // intrinsics when on a camera rig
|
|
530
|
+
};
|
|
531
|
+
this._placeMonoForFit();
|
|
532
|
+
|
|
533
|
+
// Engine objects — null until attachEngine().
|
|
534
|
+
this.pc = null;
|
|
535
|
+
this.app = null;
|
|
536
|
+
this.eye = null; // camera entity
|
|
537
|
+
this.rigNode = null;
|
|
538
|
+
this.splat = null; // splat entity
|
|
539
|
+
this._views = []; // RenderViews (renderview path) or camera entities (cameras path)
|
|
540
|
+
this._viewPath = null;
|
|
541
|
+
this._frustumKey = '';
|
|
542
|
+
|
|
543
|
+
// Per-frame splat accounting (handle.stats()). `resident` is what the engine put in the
|
|
544
|
+
// tile's work buffer on the LAST tick — the budget-enforced, LOD-selected set every view of
|
|
545
|
+
// the tile draws from (before per-view frustum culling). `firstFrameAt` is performance.now()
|
|
546
|
+
// (ms since navigation start) of the first tick that drew a non-empty set.
|
|
547
|
+
this.resident = 0;
|
|
548
|
+
this.peakResident = 0;
|
|
549
|
+
this.firstFrameAt = null;
|
|
550
|
+
this._tileBudget = undefined; // undefined = the engine's own default
|
|
551
|
+
this._budgetViews = 1;
|
|
552
|
+
|
|
553
|
+
this._onResize = () => this._scheduleResize();
|
|
554
|
+
this._ro = typeof ResizeObserver === 'function' ? new ResizeObserver(this._onResize) : null;
|
|
555
|
+
if (this._ro) this._ro.observe(canvas);
|
|
556
|
+
else if (typeof addEventListener === 'function') addEventListener('resize', this._onResize);
|
|
557
|
+
|
|
558
|
+
if (orbit) this._bindOrbit();
|
|
559
|
+
this._resize();
|
|
560
|
+
|
|
561
|
+
this.onFrame = this.onFrame.bind(this);
|
|
562
|
+
this.onLayerLost = this.onLayerLost.bind(this);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── public surface (the SceneViewer methods ./splat and pages use) ──
|
|
566
|
+
|
|
567
|
+
get is3D() {
|
|
568
|
+
return this._mode === '3d';
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
get depthOffset() {
|
|
572
|
+
return this._depthOffset;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
set depthOffset(m) {
|
|
576
|
+
this._depthOffset = finite(m, this._depthOffset);
|
|
577
|
+
this._applyTransform();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
fitTo(center, extent) {
|
|
581
|
+
const c = Array.isArray(center) ? center : [center.x, center.y, center.z];
|
|
582
|
+
const e = Array.isArray(extent) ? extent : [extent.x, extent.y, extent.z];
|
|
583
|
+
this.setFocus(c, { snap: true });
|
|
584
|
+
this._subjectHalf = [Math.abs(e[0]) / 2, Math.abs(e[1]) / 2, Math.abs(e[2]) / 2];
|
|
585
|
+
const box = this.canvas.getBoundingClientRect();
|
|
586
|
+
this._fitScale = fitScale({
|
|
587
|
+
extent: e,
|
|
588
|
+
fit: this.fit,
|
|
589
|
+
margin: this.margin,
|
|
590
|
+
vH: this.vH,
|
|
591
|
+
aspect: box.height > 0 ? box.width / box.height : 1,
|
|
592
|
+
fitSweep: this.fitSweep,
|
|
593
|
+
depthLimit: this.depthLimit,
|
|
594
|
+
});
|
|
595
|
+
this._applyTransform();
|
|
596
|
+
this._placeMonoForFit();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
setPose({ yaw, pitch, zoom, depthOffset } = {}) {
|
|
600
|
+
// A snap, as on SceneViewer; it also ends an orbit relax (the page is driving now).
|
|
601
|
+
if (yaw !== undefined || pitch !== undefined) this._orbitMode = null;
|
|
602
|
+
if (yaw !== undefined) this._targetYaw = this._yaw = yaw;
|
|
603
|
+
if (pitch !== undefined) {
|
|
604
|
+
this._targetPitch = this._pitch = clamp(pitch, this.pitchLimit[0], this.pitchLimit[1]);
|
|
605
|
+
}
|
|
606
|
+
if (zoom !== undefined) this._targetZoom = this._zoom = clamp(zoom, ZOOM_MIN, ZOOM_MAX);
|
|
607
|
+
if (depthOffset !== undefined) this._depthOffset = finite(depthOffset, this._depthOffset);
|
|
608
|
+
this._applyTransform();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
getPose({ target = false } = {}) {
|
|
612
|
+
return {
|
|
613
|
+
yaw: target ? this._targetYaw : this._yaw,
|
|
614
|
+
pitch: target ? this._targetPitch : this._pitch,
|
|
615
|
+
zoom: target ? this._targetZoom : this._zoom,
|
|
616
|
+
depthOffset: this._depthOffset,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
resetPose() {
|
|
621
|
+
this.setPose({ yaw: 0, pitch: 0, zoom: 1, depthOffset: 0 });
|
|
622
|
+
this._lastInput = now();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** SceneViewer.getSubjectBounds, same arithmetic. */
|
|
626
|
+
getSubjectBounds() {
|
|
627
|
+
const s = this._fitScale * this._zoom;
|
|
628
|
+
const [hx, hy, hz] = this._subjectHalf;
|
|
629
|
+
// SceneViewer's exact arithmetic (degrees → radians as `(d * Math.PI) / 180`), so the two
|
|
630
|
+
// viewers report bit-identical boxes (pinned by the behavioural-trace test).
|
|
631
|
+
const p = (this._pitch * Math.PI) / 180;
|
|
632
|
+
const y = (this._yaw * Math.PI) / 180;
|
|
633
|
+
const cp = Math.cos(p), sp = Math.sin(p), cy = Math.cos(y), sy = Math.sin(y);
|
|
634
|
+
const ex = s * (Math.abs(cy) * hx + Math.abs(sy) * hz);
|
|
635
|
+
const ey = s * (Math.abs(sp * sy) * hx + Math.abs(cp) * hy + Math.abs(sp * cy) * hz);
|
|
636
|
+
const ez = s * (Math.abs(cp * sy) * hx + Math.abs(sp) * hy + Math.abs(cp * cy) * hz);
|
|
637
|
+
const cz = this._depthOffset;
|
|
638
|
+
return {
|
|
639
|
+
center: { x: 0, y: 0, z: cz },
|
|
640
|
+
extent: { x: 2 * ex, y: 2 * ey, z: 2 * ez },
|
|
641
|
+
front: cz + ez,
|
|
642
|
+
back: cz - ez,
|
|
643
|
+
scale: s,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
setFocus(point, { snap = false, recentre } = {}) {
|
|
648
|
+
if (recentre !== undefined) this._focusRecentres = !!recentre;
|
|
649
|
+
const p = point == null ? [0, 0, 0] : Array.isArray(point) ? point : [point.x, point.y, point.z];
|
|
650
|
+
this._targetFocus = { x: finite(p[0], 0), y: finite(p[1], 0), z: finite(p[2], 0) };
|
|
651
|
+
this._focusSettled = false;
|
|
652
|
+
if (snap) {
|
|
653
|
+
this._focus = { ...this._targetFocus };
|
|
654
|
+
this._focusSettled = true;
|
|
655
|
+
this._applyFocus();
|
|
656
|
+
this._applyTransform();
|
|
657
|
+
this.onFocusChange?.(this._focus);
|
|
658
|
+
}
|
|
659
|
+
return this;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
getFocus({ target = false } = {}) {
|
|
663
|
+
const v = target ? this._targetFocus : this._focus;
|
|
664
|
+
return { x: v.x, y: v.y, z: v.z };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Pose + lens the mono camera as the recording camera (applyCaptureCamera's counterpart). */
|
|
668
|
+
useCaptureCamera(rig) {
|
|
669
|
+
const pose = capturePose(rig.rest, this.flipY);
|
|
670
|
+
this.mono.pose = pose.matrix;
|
|
671
|
+
this.mono.capture = rig.intrinsics;
|
|
672
|
+
this.mono.fov = captureVerticalFovDeg(rig.intrinsics, NaN, this.mono.near, 'height');
|
|
673
|
+
this.mono.far = Math.max(this.mono.far, CAPTURE_FAR);
|
|
674
|
+
this._updateMonoProjection();
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Forget the auto-fit (scale 1, no subject box) — the camera rig's framing. */
|
|
678
|
+
resetFit() {
|
|
679
|
+
this._fitScale = 1;
|
|
680
|
+
this._subjectHalf = [0, 0, 0];
|
|
681
|
+
this._applyTransform();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Back to the display rig's mono camera (setSource from a photo lift to an object). */
|
|
685
|
+
useDisplayCamera() {
|
|
686
|
+
this.mono.capture = null;
|
|
687
|
+
this.mono.fov = MONO_FOV;
|
|
688
|
+
this.mono.far = MONO_FAR;
|
|
689
|
+
this._placeMonoForFit();
|
|
690
|
+
this._updateMonoProjection();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
onLayerLost() {
|
|
694
|
+
if (this._disposed) return;
|
|
695
|
+
this.startMono();
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** wall.addScene's frame callback. Validate BEFORE drawing; replay the last good frame else. */
|
|
699
|
+
onFrame(views, layer) {
|
|
700
|
+
if (this._disposed) return;
|
|
701
|
+
if (this._mode !== '3d') this.stopMono();
|
|
702
|
+
this._tick();
|
|
703
|
+
if (!views || views.length < 2) {
|
|
704
|
+
this._replayLastGood();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const vps = this._vps;
|
|
708
|
+
vps.length = 0;
|
|
709
|
+
for (const view of views) {
|
|
710
|
+
const vp = layer && typeof layer.getViewport === 'function' ? layer.getViewport(view) : null;
|
|
711
|
+
if (!vp || !(vp.width > 0) || !(vp.height > 0)) {
|
|
712
|
+
this._replayLastGood();
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
vps.push(vp);
|
|
716
|
+
}
|
|
717
|
+
this._cacheGood(views, vps);
|
|
718
|
+
this._drawEntries(this._lastGood.entries, this._lastGood);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
startMono() {
|
|
722
|
+
if (this._monoRaf || this._disposed) return;
|
|
723
|
+
this._mode = 'mono';
|
|
724
|
+
this._resize();
|
|
725
|
+
const loop = () => {
|
|
726
|
+
if (this._disposed) return;
|
|
727
|
+
this._monoRaf = requestAnimationFrame(loop);
|
|
728
|
+
this._tick();
|
|
729
|
+
this._drawMono();
|
|
730
|
+
};
|
|
731
|
+
this._monoRaf = requestAnimationFrame(loop);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
stopMono() {
|
|
735
|
+
if (this._monoRaf) cancelAnimationFrame(this._monoRaf);
|
|
736
|
+
this._monoRaf = 0;
|
|
737
|
+
this._mode = '3d';
|
|
738
|
+
this._resize();
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
dispose() {
|
|
742
|
+
this._disposed = true;
|
|
743
|
+
this._resizePending = false;
|
|
744
|
+
this._lastGood = null;
|
|
745
|
+
if (this._monoRaf) cancelAnimationFrame(this._monoRaf);
|
|
746
|
+
this._monoRaf = 0;
|
|
747
|
+
if (this._ro) this._ro.disconnect();
|
|
748
|
+
else if (typeof removeEventListener === 'function') removeEventListener('resize', this._onResize);
|
|
749
|
+
this._unbindOrbit();
|
|
750
|
+
try {
|
|
751
|
+
this.app?.destroy();
|
|
752
|
+
} catch (err) {
|
|
753
|
+
console.warn('[inline3d/splat] PlayCanvas app.destroy() threw', err);
|
|
754
|
+
}
|
|
755
|
+
this.app = null;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ── engine ──
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Build the engine on this canvas. Async only because `createGraphicsDevice` is.
|
|
762
|
+
*
|
|
763
|
+
* @param {object} pc the `playcanvas` module namespace.
|
|
764
|
+
* @param {object} o
|
|
765
|
+
* @param {boolean} [o.preserveDrawingBuffer=false] the knob for the weave's zero-copy read
|
|
766
|
+
* race on large canvases (browser-pvt#24); off by default, like three's path.
|
|
767
|
+
* @param {object} o.perf the resolved playcanvasPerfSettings().
|
|
768
|
+
* @param {string} [o.viewPath] force 'cameras' | 'renderview' (diagnostics).
|
|
769
|
+
*/
|
|
770
|
+
async attachEngine(pc, { preserveDrawingBuffer = false, perf, viewPath } = {}) {
|
|
771
|
+
this.pc = pc;
|
|
772
|
+
const device = await pc.createGraphicsDevice(this.canvas, {
|
|
773
|
+
deviceTypes: [pc.DEVICETYPE_WEBGL2],
|
|
774
|
+
alpha: true,
|
|
775
|
+
premultipliedAlpha: true,
|
|
776
|
+
antialias: false,
|
|
777
|
+
xrCompatible: false,
|
|
778
|
+
preserveDrawingBuffer,
|
|
779
|
+
});
|
|
780
|
+
if (this._disposed) {
|
|
781
|
+
device.destroy?.();
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
const opts = new pc.AppOptions();
|
|
785
|
+
opts.graphicsDevice = device;
|
|
786
|
+
// No xr (AppBase constructs XrManager only when asked, and XrManager is what probes and
|
|
787
|
+
// can request immersive sessions), no mouse/keyboard/touch: the SDK owns input.
|
|
788
|
+
opts.componentSystems = [pc.CameraComponentSystem, pc.GSplatComponentSystem];
|
|
789
|
+
// TextureHandler: a bundled .sog is a zip of webp planes the loader registers as textures.
|
|
790
|
+
opts.resourceHandlers = [pc.TextureHandler, pc.GSplatHandler];
|
|
791
|
+
const app = new pc.AppBase(this.canvas);
|
|
792
|
+
app.init(opts);
|
|
793
|
+
// The SDK sizes the buffer (double-width in 3D, 1:1 in mono), so the engine must never
|
|
794
|
+
// resize it. RESOLUTION_FIXED is AppBase's DEFAULT, and with it `updateCanvasSize()` is a
|
|
795
|
+
// no-op. Deliberately NOT calling setCanvasResolution/setCanvasFillMode: without explicit
|
|
796
|
+
// sizes both write the canvas (a NaN buffer width → 0, and inline `style.width`), which
|
|
797
|
+
// would break the page's own responsive CSS.
|
|
798
|
+
//
|
|
799
|
+
// INVARIANT: the canvas buffer is written ONLY by this module's _resize (canvas.width/height,
|
|
800
|
+
// already in device pixels). Never route a size through the engine: setCanvasResolution →
|
|
801
|
+
// device.resizeCanvas multiplies by DPR a second time (a 2560×720 SBS buffer would become
|
|
802
|
+
// 5120×1440 on a DPR-2 display while the SDK still believes 2560×720). If a future engine
|
|
803
|
+
// changes the default away from FIXED, say so rather than "fixing" it through the engine.
|
|
804
|
+
if (app.resolutionMode !== pc.RESOLUTION_FIXED) {
|
|
805
|
+
console.warn(
|
|
806
|
+
'[inline3d/splat] engine:playcanvas — AppBase.resolutionMode is not RESOLUTION_FIXED on ' +
|
|
807
|
+
'this engine build; the engine may resize the canvas behind the SDK. Tested: ' +
|
|
808
|
+
PLAYCANVAS_TESTED + '.',
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
// The SDK's frame drives the engine: no second rAF. `tick` is the engine's own loop body.
|
|
812
|
+
app.requestAnimationFrame = () => {};
|
|
813
|
+
this.app = app;
|
|
814
|
+
|
|
815
|
+
// Footprint fix (§ patchGsplatFootprint) and the perf quad-extent cap, both as chunk
|
|
816
|
+
// overrides. The gsplat chunks are registered by GSplatComponentSystem during init, so
|
|
817
|
+
// after it; before any gsplat material compiles.
|
|
818
|
+
const chunks = pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_GLSL);
|
|
819
|
+
const corner = patchGsplatFootprint(chunks.get('gsplatCornerVS'));
|
|
820
|
+
if (corner.ok) chunks.set('gsplatCornerVS', corner.src);
|
|
821
|
+
else if (!warnedFootprint) {
|
|
822
|
+
warnedFootprint = true;
|
|
823
|
+
console.warn(
|
|
824
|
+
'[inline3d/splat] engine:playcanvas — this engine build does not have the gsplatCornerVS ' +
|
|
825
|
+
'lines the non-square-pixel footprint fix rewrites, so it is SKIPPED: side-by-side ' +
|
|
826
|
+
'splats will draw at half height (vertical stipple). Report the engine version; the ' +
|
|
827
|
+
'anchors are in js/inline3d-splat-playcanvas.js.',
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
this.footprintPatched = corner.ok;
|
|
831
|
+
if (perf?.quadExtent) {
|
|
832
|
+
const q = patchPlayCanvasQuadExtent(chunks.get('gsplatCommonVS'), perf.quadExtent);
|
|
833
|
+
if (q.ok) chunks.set('gsplatCommonVS', q.src);
|
|
834
|
+
else if (!warnedQuadExtent) {
|
|
835
|
+
warnedQuadExtent = true;
|
|
836
|
+
console.warn(
|
|
837
|
+
'[inline3d/splat] perf.maxStdDev on engine:playcanvas: the gsplatCommonVS anchor is ' +
|
|
838
|
+
'missing, so the quad extent is left at the engine default (everything else applies).',
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
for (const [k, v] of Object.entries(perf?.settings || {})) {
|
|
843
|
+
if (k !== 'splatBudget') app.scene.gsplat[k] = v;
|
|
844
|
+
}
|
|
845
|
+
// The budget goes through setTileBudget: it is a PER-TILE contract, and the N-camera
|
|
846
|
+
// fallback has to split it (SPLAT_BUDGET_MODEL).
|
|
847
|
+
this.setTileBudget(perf?.settings?.splatBudget);
|
|
848
|
+
|
|
849
|
+
// EVERY Entity gets its app EXPLICITLY. The constructor's default is the engine's global
|
|
850
|
+
// "current app" (`getApplication()`), which is whichever AppBase last init'ed or ticked — so
|
|
851
|
+
// with two tiles on a page, the first tile's splat silently joined the second tile's scene
|
|
852
|
+
// and rendered nowhere (found by running two tiles, not by reading).
|
|
853
|
+
this.rigNode = new pc.Entity('inline3d-rig', app);
|
|
854
|
+
app.root.addChild(this.rigNode);
|
|
855
|
+
this._viewPath = pickViewPath(pc, viewPath);
|
|
856
|
+
if (this._viewPath === 'renderview') {
|
|
857
|
+
this.eye = this._makeCamera('inline3d-eye', null);
|
|
858
|
+
}
|
|
859
|
+
if (this.featherPx > 0) this._makeFeather();
|
|
860
|
+
this._applyTransform();
|
|
861
|
+
app.start();
|
|
862
|
+
return app;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* `feather`: fade each eye's edges to transparent — EdgeFeather's pass, engine-native.
|
|
867
|
+
*
|
|
868
|
+
* A clip-space quad in the UI layer (drawn after the World layer's splats), rendered by the
|
|
869
|
+
* same camera, so the engine draws it once per view into THAT view's viewport: each eye fades
|
|
870
|
+
* all four of ITS OWN edges, which a CSS mask on the canvas cannot do. Blend ZERO/SRC_ALPHA on
|
|
871
|
+
* colour and alpha multiplies whatever is there by the ramp (dst *= ramp), exactly
|
|
872
|
+
* EdgeFeather's blend; the ramp is the same `smoothstep` in both uv axes, sized in BUFFER px
|
|
873
|
+
* per eye viewport so the fade is px-uniform on screen despite the side-by-side squeeze.
|
|
874
|
+
*/
|
|
875
|
+
_makeFeather() {
|
|
876
|
+
const pc = this.pc;
|
|
877
|
+
const device = this.app.graphicsDevice;
|
|
878
|
+
const mesh = new pc.Mesh(device);
|
|
879
|
+
mesh.setPositions(new Float32Array([-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]));
|
|
880
|
+
mesh.setUvs(0, new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]));
|
|
881
|
+
mesh.setIndices([0, 1, 2, 0, 2, 3]);
|
|
882
|
+
mesh.update();
|
|
883
|
+
const mat = new pc.ShaderMaterial({
|
|
884
|
+
uniqueName: 'inline3dEdgeFeather',
|
|
885
|
+
attributes: { vertex_position: pc.SEMANTIC_POSITION, vertex_texCoord0: pc.SEMANTIC_TEXCOORD0 },
|
|
886
|
+
vertexGLSL: `
|
|
887
|
+
attribute vec3 vertex_position;
|
|
888
|
+
attribute vec2 vertex_texCoord0;
|
|
889
|
+
varying vec2 vUv;
|
|
890
|
+
void main() { vUv = vertex_texCoord0; gl_Position = vec4(vertex_position.xy, 0.0, 1.0); }`,
|
|
891
|
+
fragmentGLSL: `
|
|
892
|
+
varying vec2 vUv;
|
|
893
|
+
uniform float dxrFeatherFx;
|
|
894
|
+
uniform float dxrFeatherFy;
|
|
895
|
+
void main() {
|
|
896
|
+
float ax = smoothstep(0.0, dxrFeatherFx, vUv.x) * smoothstep(0.0, dxrFeatherFx, 1.0 - vUv.x);
|
|
897
|
+
float ay = smoothstep(0.0, dxrFeatherFy, vUv.y) * smoothstep(0.0, dxrFeatherFy, 1.0 - vUv.y);
|
|
898
|
+
gl_FragColor = vec4(1.0, 1.0, 1.0, ax * ay);
|
|
899
|
+
}`,
|
|
900
|
+
});
|
|
901
|
+
mat.blendState = new pc.BlendState(
|
|
902
|
+
true,
|
|
903
|
+
pc.BLENDEQUATION_ADD,
|
|
904
|
+
pc.BLENDMODE_ZERO,
|
|
905
|
+
pc.BLENDMODE_SRC_ALPHA,
|
|
906
|
+
pc.BLENDEQUATION_ADD,
|
|
907
|
+
pc.BLENDMODE_ZERO,
|
|
908
|
+
pc.BLENDMODE_SRC_ALPHA,
|
|
909
|
+
);
|
|
910
|
+
mat.depthTest = false;
|
|
911
|
+
mat.depthWrite = false;
|
|
912
|
+
mat.cull = pc.CULLFACE_NONE;
|
|
913
|
+
mat.setParameter('dxrFeatherFx', 0.1);
|
|
914
|
+
mat.setParameter('dxrFeatherFy', 0.1);
|
|
915
|
+
mat.update();
|
|
916
|
+
const mi = new pc.MeshInstance(mesh, mat, new pc.GraphNode('inline3d-feather'));
|
|
917
|
+
mi.cull = false;
|
|
918
|
+
this.app.scene.layers.getLayerById(pc.LAYERID_UI).addMeshInstances([mi]);
|
|
919
|
+
this._feather = { mat, mi };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Size the feather ramp to this frame's eye viewport (buffer px → uv fraction, per axis). */
|
|
923
|
+
_updateFeather(w, h) {
|
|
924
|
+
if (!this._feather) return;
|
|
925
|
+
// 3D ONLY, as on the Spark path: SceneViewer runs EdgeFeather in onFrame (the woven eyes)
|
|
926
|
+
// and not in its flat mono loop, where a page styles the canvas box itself.
|
|
927
|
+
this._feather.mi.visible = this._mode === '3d';
|
|
928
|
+
this._feather.mat.setParameter('dxrFeatherFx', Math.min(0.5, this.featherPx / Math.max(1, w)));
|
|
929
|
+
this._feather.mat.setParameter('dxrFeatherFy', Math.min(0.5, this.featherPx / Math.max(1, h)));
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* The tile's splat budget, all views included (SPLAT_BUDGET_MODEL). `undefined` leaves the
|
|
934
|
+
* engine's own default alone. On the RenderView path it is the scene budget as is (one manager
|
|
935
|
+
* for all views); on the N-camera fallback it is split across the N per-camera managers.
|
|
936
|
+
*/
|
|
937
|
+
setTileBudget(budget) {
|
|
938
|
+
this._tileBudget = Number.isFinite(budget) && budget > 0 ? Math.floor(budget) : undefined;
|
|
939
|
+
this._applyBudget();
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
get tileBudget() {
|
|
943
|
+
if (this._tileBudget !== undefined) return this._tileBudget;
|
|
944
|
+
return this.app ? this.app.scene.gsplat.splatBudget : undefined;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
_applyBudget() {
|
|
948
|
+
if (!this.app || this._tileBudget === undefined) return;
|
|
949
|
+
const per = budgetPerManager(this._tileBudget, this._viewPath, this._budgetViews);
|
|
950
|
+
if (this.app.scene.gsplat.splatBudget !== per) this.app.scene.gsplat.splatBudget = per;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** Read back what the tick just did. Cheap: two numbers off the renderer. */
|
|
954
|
+
_afterTick() {
|
|
955
|
+
const n = this.app?.renderer?._gsplatCount ?? 0;
|
|
956
|
+
this.resident = n;
|
|
957
|
+
if (n > this.peakResident) this.peakResident = n;
|
|
958
|
+
if (n > 0 && this.firstFrameAt === null) this.firstFrameAt = now();
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** A camera entity under the rig node. Tonemapping OFF: splat colours are already display-referred. */
|
|
962
|
+
_makeCamera(name, rect) {
|
|
963
|
+
const pc = this.pc;
|
|
964
|
+
const e = new pc.Entity(name, this.app);
|
|
965
|
+
e.addComponent('camera', {
|
|
966
|
+
clearColor: new pc.Color(0, 0, 0, 0),
|
|
967
|
+
nearClip: MONO_NEAR,
|
|
968
|
+
farClip: MONO_FAR,
|
|
969
|
+
fov: MONO_FOV,
|
|
970
|
+
...(rect ? { rect } : {}),
|
|
971
|
+
});
|
|
972
|
+
// The engine's default camera tonemap is LINEAR, which routes every splat colour through
|
|
973
|
+
// decodeGamma → toneMap → gammaCorrectOutput. Spark writes the stored colour straight out;
|
|
974
|
+
// NONE is the same thing here (GAMMA_SRGB alone leaves a gamma-space colour untouched).
|
|
975
|
+
e.camera.toneMapping = pc.TONEMAP_NONE;
|
|
976
|
+
this.rigNode.addChild(e);
|
|
977
|
+
return e;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** Add the loaded asset as the splat entity. */
|
|
981
|
+
addSplatAsset(asset) {
|
|
982
|
+
const pc = this.pc;
|
|
983
|
+
const e = new pc.Entity('inline3d-splat', this.app);
|
|
984
|
+
e.addComponent('gsplat', { asset });
|
|
985
|
+
if (this.flipY) e.setLocalEulerAngles(180, 0, 0);
|
|
986
|
+
this.content.addChild(e);
|
|
987
|
+
this.splat = e;
|
|
988
|
+
return e;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* The content root: the entity the splat hangs under, in the splat's CONTENT space (engine
|
|
993
|
+
* world — the flip lives on each splat entity, not here). A page may add its own entities under
|
|
994
|
+
* it through `handle.engine.root`; they are destroyed with the app.
|
|
995
|
+
*/
|
|
996
|
+
get content() {
|
|
997
|
+
if (!this._content && this.app) {
|
|
998
|
+
this._content = new this.pc.Entity('inline3d-content', this.app);
|
|
999
|
+
this.app.root.addChild(this._content);
|
|
1000
|
+
}
|
|
1001
|
+
return this._content;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// ── internals ──
|
|
1005
|
+
|
|
1006
|
+
_placeMonoForFit() {
|
|
1007
|
+
// SceneViewer.fitTo: distance to make the frustum exactly vH tall at z = 0, looking down −z.
|
|
1008
|
+
if (this.mono.capture) return;
|
|
1009
|
+
const d = (0.5 * this.vH) / Math.tan((this.mono.fov * DEG) / 2);
|
|
1010
|
+
this.mono.pose = poseMatrix([0, 0, d], [0, 0, 0, 1]);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
_updateMonoProjection() {
|
|
1014
|
+
const box = this.canvas.getBoundingClientRect();
|
|
1015
|
+
const aspect = box.height > 0 ? box.width / box.height : 1;
|
|
1016
|
+
if (this.mono.capture) {
|
|
1017
|
+
captureProjection(this.mono.capture, aspect, this.mono.near, this.mono.far, this.mono.proj, this.captureFit);
|
|
1018
|
+
if (this.captureFit !== 'height') {
|
|
1019
|
+
const fov = captureVerticalFovDeg(this.mono.capture, aspect, this.mono.near, this.captureFit);
|
|
1020
|
+
if (fov !== this.mono.fov) {
|
|
1021
|
+
this.mono.fov = fov;
|
|
1022
|
+
this.onCaptureFov?.();
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
} else perspectiveFov(this.mono.fov, aspect, this.mono.near, this.mono.far, this.mono.proj);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
_drawMono() {
|
|
1029
|
+
const c = this.canvas;
|
|
1030
|
+
this._monoEntry ||= [{ proj: this.mono.proj, pose: this.mono.pose, x: 0, y: 0, width: 0, height: 0 }];
|
|
1031
|
+
const e = this._monoEntry[0];
|
|
1032
|
+
e.proj = this.mono.proj;
|
|
1033
|
+
e.pose = this.mono.pose;
|
|
1034
|
+
e.width = c.width;
|
|
1035
|
+
e.height = c.height;
|
|
1036
|
+
this._drawEntries(this._monoEntry, null);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Put these views on the canvas: N RenderViews on the one camera (or N cameras), then one
|
|
1041
|
+
* engine tick. Every entry is {proj, pose, x, y, width, height} in BUFFER pixels.
|
|
1042
|
+
*/
|
|
1043
|
+
_drawEntries(entries, cache) {
|
|
1044
|
+
const app = this.app;
|
|
1045
|
+
if (!app || !this.pc) return false;
|
|
1046
|
+
const pc = this.pc;
|
|
1047
|
+
const el = this.canvas;
|
|
1048
|
+
const sx = cache && cache.bufW > 0 && el.width ? el.width / cache.bufW : 1;
|
|
1049
|
+
const sy = cache && cache.bufH > 0 && el.height ? el.height / cache.bufH : 1;
|
|
1050
|
+
const rect = (e) =>
|
|
1051
|
+
sx !== 1 || sy !== 1
|
|
1052
|
+
? [Math.round(e.x * sx), Math.round(e.y * sy), Math.max(1, Math.round(e.width * sx)), Math.max(1, Math.round(e.height * sy))]
|
|
1053
|
+
: [e.x, e.y, e.width, e.height];
|
|
1054
|
+
|
|
1055
|
+
if (this._viewPath === 'renderview') {
|
|
1056
|
+
const rvs = this._views;
|
|
1057
|
+
if (rvs.length !== entries.length) {
|
|
1058
|
+
rvs.length = 0;
|
|
1059
|
+
for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
|
|
1060
|
+
this.eye.camera.camera.xrViews = rvs.slice();
|
|
1061
|
+
}
|
|
1062
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1063
|
+
const e = entries[i];
|
|
1064
|
+
rvs[i].setView(e.proj, e.pose);
|
|
1065
|
+
const [x, y, w, h] = rect(e);
|
|
1066
|
+
rvs[i].setViewport(x, y, w, h);
|
|
1067
|
+
}
|
|
1068
|
+
// LOD and FOV-compensation read camera.fov/near/far, which under xrViews come from the
|
|
1069
|
+
// XR properties — the frustum the views actually have, as XrManager does it.
|
|
1070
|
+
const f = frustumFromProjection(entries[0].proj);
|
|
1071
|
+
const key = `${f.fov.toFixed(4)}|${f.aspectRatio.toFixed(4)}|${f.nearClip}|${f.farClip}`;
|
|
1072
|
+
if (key !== this._frustumKey) {
|
|
1073
|
+
this._frustumKey = key;
|
|
1074
|
+
this.eye.camera.camera.setXrProperties({ ...f, horizontalFov: false });
|
|
1075
|
+
}
|
|
1076
|
+
// The camera NODE drives the sort direction and LOD distance; the views ignore it (they
|
|
1077
|
+
// compose the node's PARENT with their own pose). Park it on the first eye.
|
|
1078
|
+
placeNode(this.eye, entries[0].pose);
|
|
1079
|
+
} else {
|
|
1080
|
+
// Fallback: one camera per view, `rect` + `calculateProjection`.
|
|
1081
|
+
const cams = this._views;
|
|
1082
|
+
while (cams.length < entries.length) {
|
|
1083
|
+
const cam = this._makeCamera(`inline3d-eye-${cams.length}`, new pc.Vec4(0, 0, 1, 1));
|
|
1084
|
+
cam._dxrProj = new Float64Array(16);
|
|
1085
|
+
cam.camera.calculateProjection = (out) => out.set(cam._dxrProj);
|
|
1086
|
+
// Every camera clears its OWN rect: the WebGL device keeps SCISSOR_TEST on and scissors
|
|
1087
|
+
// to the camera rect, so a clear cannot wipe the neighbouring eye.
|
|
1088
|
+
cams.push(cam);
|
|
1089
|
+
}
|
|
1090
|
+
for (let i = 0; i < cams.length; i++) cams[i].enabled = i < entries.length;
|
|
1091
|
+
if (this._budgetViews !== entries.length) {
|
|
1092
|
+
// One manager per enabled camera here, each reading the scene budget: split it.
|
|
1093
|
+
this._budgetViews = entries.length;
|
|
1094
|
+
this._applyBudget();
|
|
1095
|
+
}
|
|
1096
|
+
const W = el.width || 1;
|
|
1097
|
+
const H = el.height || 1;
|
|
1098
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1099
|
+
const e = entries[i];
|
|
1100
|
+
const cam = cams[i];
|
|
1101
|
+
cam._dxrProj.set(e.proj);
|
|
1102
|
+
const f = frustumFromProjection(e.proj);
|
|
1103
|
+
cam.camera.fov = f.fov;
|
|
1104
|
+
cam.camera.nearClip = f.nearClip;
|
|
1105
|
+
cam.camera.farClip = f.farClip;
|
|
1106
|
+
const [x, y, w, h] = rect(e);
|
|
1107
|
+
cam.camera.rect = new pc.Vec4(x / W, y / H, w / W, h / H);
|
|
1108
|
+
placeNode(cam, e.pose);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
this._updateFeather(entries[0].width * sx, entries[0].height * sy);
|
|
1112
|
+
app.tick(now());
|
|
1113
|
+
this._afterTick();
|
|
1114
|
+
return true;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
_cacheGood(views, vps) {
|
|
1118
|
+
const el = this.canvas;
|
|
1119
|
+
let g = this._lastGood;
|
|
1120
|
+
if (!g || g.entries.length !== views.length) {
|
|
1121
|
+
g = this._lastGood = { entries: [], bufW: 0, bufH: 0 };
|
|
1122
|
+
for (let i = 0; i < views.length; i++) {
|
|
1123
|
+
g.entries.push({ proj: new Float32Array(16), pose: new Float32Array(16), x: 0, y: 0, width: 0, height: 0 });
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
g.bufW = el.width || 0;
|
|
1127
|
+
g.bufH = el.height || 0;
|
|
1128
|
+
for (let i = 0; i < views.length; i++) {
|
|
1129
|
+
const e = g.entries[i];
|
|
1130
|
+
const vp = vps[i];
|
|
1131
|
+
e.proj.set(views[i].projectionMatrix);
|
|
1132
|
+
e.pose.set(views[i].transform.matrix);
|
|
1133
|
+
e.x = vp.x;
|
|
1134
|
+
e.y = vp.y;
|
|
1135
|
+
e.width = vp.width;
|
|
1136
|
+
e.height = vp.height;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
_replayLastGood() {
|
|
1141
|
+
const g = this._lastGood;
|
|
1142
|
+
if (!g || this._disposed) return false;
|
|
1143
|
+
return this._drawEntries(g.entries, g);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
_scheduleResize() {
|
|
1147
|
+
if (this._disposed || this._resizePending) return;
|
|
1148
|
+
this._resizePending = true;
|
|
1149
|
+
const run = () => {
|
|
1150
|
+
if (!this._resizePending) return;
|
|
1151
|
+
this._resizePending = false;
|
|
1152
|
+
this._resize();
|
|
1153
|
+
};
|
|
1154
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
|
|
1155
|
+
else run();
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** SceneViewer._resize: double-width in 3D, 1:1 in mono, non-destructive, repaint after. */
|
|
1159
|
+
_resize() {
|
|
1160
|
+
if (this._disposed) return;
|
|
1161
|
+
const box = this.canvas.getBoundingClientRect();
|
|
1162
|
+
if (box.width < 1 || box.height < 1) return;
|
|
1163
|
+
const dpr = Math.min((typeof window !== 'undefined' && window.devicePixelRatio) || 1, 2) * this.renderScale;
|
|
1164
|
+
const w = Math.max(1, Math.round(box.width * dpr));
|
|
1165
|
+
const h = Math.max(1, Math.round(box.height * dpr));
|
|
1166
|
+
const bufW = this._mode === 'mono' ? w : w * 2;
|
|
1167
|
+
this._updateMonoProjection();
|
|
1168
|
+
const el = this.canvas;
|
|
1169
|
+
if (el.width === bufW && el.height === h) return;
|
|
1170
|
+
el.width = bufW;
|
|
1171
|
+
el.height = h;
|
|
1172
|
+
if (this._mode === 'mono') this._drawMono();
|
|
1173
|
+
else this._replayLastGood();
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
_tick() {
|
|
1177
|
+
const t = now();
|
|
1178
|
+
const dt = this._lastTick ? Math.min((t - this._lastTick) / 1000, MAX_DT_S) : 0;
|
|
1179
|
+
this._lastTick = t;
|
|
1180
|
+
// The idle turntable waits out a drag AND its relax, then the usual idle delay.
|
|
1181
|
+
if (this.idleSpin && !this._reduceMotion && !this._orbitMode && t - this._lastInput > IDLE_DELAY_MS) {
|
|
1182
|
+
this._targetYaw += this.idleSpin * dt;
|
|
1183
|
+
}
|
|
1184
|
+
const k = dt > 0 ? 1 - Math.pow(DAMP_BASE, dt) : 1;
|
|
1185
|
+
// Orbit easing: k = 1 − exp(−dt/τ), τ per phase; no time elapsed, no motion.
|
|
1186
|
+
const tau = this._orbitMode === 'drag' ? this.orbitEase.drag : this._orbitMode === 'rest' ? this.orbitEase.rest : 0;
|
|
1187
|
+
const ko = tau > 0 ? (dt > 0 ? 1 - Math.exp(-dt / tau) : 0) : k;
|
|
1188
|
+
this._yaw += (this._targetYaw - this._yaw) * ko;
|
|
1189
|
+
this._pitch += (this._targetPitch - this._pitch) * ko;
|
|
1190
|
+
if (
|
|
1191
|
+
this._orbitMode === 'rest' &&
|
|
1192
|
+
Math.abs(this._targetYaw - this._yaw) < 0.01 &&
|
|
1193
|
+
Math.abs(this._targetPitch - this._pitch) < 0.01
|
|
1194
|
+
) {
|
|
1195
|
+
this._orbitMode = null; // at rest: the ordinary damping (and the idle turntable) take over
|
|
1196
|
+
}
|
|
1197
|
+
if (Math.abs(this._targetZoom - this._zoom) > 1e-4) {
|
|
1198
|
+
this._zoom *= Math.pow(this._targetZoom / this._zoom, k);
|
|
1199
|
+
} else {
|
|
1200
|
+
this._zoom = this._targetZoom;
|
|
1201
|
+
}
|
|
1202
|
+
this._easeFocus();
|
|
1203
|
+
this._applyTransform();
|
|
1204
|
+
if (this._hooks.length) {
|
|
1205
|
+
// A hook may push another (the crossfade schedules the release): run a snapshot, keep both.
|
|
1206
|
+
const run = this._hooks;
|
|
1207
|
+
this._hooks = [];
|
|
1208
|
+
const keep = run.filter((h) => h(t) !== false);
|
|
1209
|
+
this._hooks = keep.concat(this._hooks);
|
|
1210
|
+
}
|
|
1211
|
+
this.onTick?.();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
_easeFocus() {
|
|
1215
|
+
if (this._focusSettled) return;
|
|
1216
|
+
const f = this._focus;
|
|
1217
|
+
const t = this._targetFocus;
|
|
1218
|
+
const dx = t.x - f.x, dy = t.y - f.y, dz = t.z - f.z;
|
|
1219
|
+
if (dx * dx + dy * dy + dz * dz < 1e-10) {
|
|
1220
|
+
f.x = t.x; f.y = t.y; f.z = t.z;
|
|
1221
|
+
this._focusSettled = true;
|
|
1222
|
+
} else {
|
|
1223
|
+
f.x += dx * FOCUS_EASE; f.y += dy * FOCUS_EASE; f.z += dz * FOCUS_EASE;
|
|
1224
|
+
}
|
|
1225
|
+
this._applyFocus();
|
|
1226
|
+
this.onFocusChange?.(f);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
_applyFocus() {
|
|
1230
|
+
const f = this._focus;
|
|
1231
|
+
const c = this._orbitCentre;
|
|
1232
|
+
c.x = this._focusRecentres ? 0 : f.x;
|
|
1233
|
+
c.y = this._focusRecentres ? 0 : f.y;
|
|
1234
|
+
c.z = this._focusRecentres ? 0 : f.z;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** The pose state as pivotMatrix()'s arguments. */
|
|
1238
|
+
pivotState() {
|
|
1239
|
+
const f = this._focus;
|
|
1240
|
+
const o = this._orbitCentre;
|
|
1241
|
+
return {
|
|
1242
|
+
yaw: this._yaw,
|
|
1243
|
+
pitch: this._pitch,
|
|
1244
|
+
scale: this._fitScale * this._zoom,
|
|
1245
|
+
focus: [f.x, f.y, f.z],
|
|
1246
|
+
orbitCentre: [o.x, o.y, o.z],
|
|
1247
|
+
depthOffset: this._depthOffset,
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* THE ONLY WRITER of the rig node (the 1.7 ownership rule: the tick owns the pivot). The rig
|
|
1253
|
+
* node carries the inverse pivot, so the eyes move and the splat stays put.
|
|
1254
|
+
*/
|
|
1255
|
+
_applyTransform() {
|
|
1256
|
+
if (!this.rigNode) return;
|
|
1257
|
+
const trs = pivotInverseTRS(this.pivotState());
|
|
1258
|
+
this.rigNode.setLocalPosition(trs.position[0], trs.position[1], trs.position[2]);
|
|
1259
|
+
this.rigNode.setLocalRotation(trs.rotation[0], trs.rotation[1], trs.rotation[2], trs.rotation[3]);
|
|
1260
|
+
this.rigNode.setLocalScale(trs.scale, trs.scale, trs.scale);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* The built-in orbit: TILT-AND-RELAX, not SceneViewer's cumulative turntable drag.
|
|
1265
|
+
*
|
|
1266
|
+
* The drag is a FRACTION of the canvas box (dx = Δx / width, dy = Δy / height, measured from
|
|
1267
|
+
* the press), so a tablet thumb-swipe and a mouse agree whatever the tile's pixel size. The
|
|
1268
|
+
* target is ABSOLUTE from the press — rest + clamp(dx · 2·max, ±max) — so a half-width swipe
|
|
1269
|
+
* reaches the cap (orbitMaxDeg, 15° by default). +dx ⇒ +yaw and +dy ⇒ +pitch, SceneViewer's
|
|
1270
|
+
* signs (the near face follows the pointer). Release relaxes back to the rest pose — the pose
|
|
1271
|
+
* the press started from, which is yaw = pitch = 0 for a page that never setPose'd — rather
|
|
1272
|
+
* than snapping. `pitchLimit` still clamps. `setPose` stays a snap, so page-driven easing works.
|
|
1273
|
+
*/
|
|
1274
|
+
_bindOrbit() {
|
|
1275
|
+
const el = this.canvas;
|
|
1276
|
+
if (typeof el.addEventListener !== 'function') return;
|
|
1277
|
+
let dragging = false;
|
|
1278
|
+
let startX = 0;
|
|
1279
|
+
let startY = 0;
|
|
1280
|
+
let restYaw = 0;
|
|
1281
|
+
let restPitch = 0;
|
|
1282
|
+
this._onDown = (ev) => {
|
|
1283
|
+
dragging = true;
|
|
1284
|
+
startX = ev.clientX;
|
|
1285
|
+
startY = ev.clientY;
|
|
1286
|
+
// Rest = where the pose was heading when pressed (an idle turntable's yaw included).
|
|
1287
|
+
restYaw = this._orbitMode ? restYaw : this._targetYaw;
|
|
1288
|
+
restPitch = this._orbitMode ? restPitch : this._targetPitch;
|
|
1289
|
+
this._orbitMode = 'drag';
|
|
1290
|
+
this._lastInput = now();
|
|
1291
|
+
el.setPointerCapture?.(ev.pointerId);
|
|
1292
|
+
};
|
|
1293
|
+
this._onMove = (ev) => {
|
|
1294
|
+
if (!dragging) return;
|
|
1295
|
+
const box = el.getBoundingClientRect();
|
|
1296
|
+
const dx = (ev.clientX - startX) / Math.max(box.width, 1);
|
|
1297
|
+
const dy = (ev.clientY - startY) / Math.max(box.height, 1);
|
|
1298
|
+
const max = this.orbitMaxDeg;
|
|
1299
|
+
this._targetYaw = restYaw + clamp(dx * 2 * max, -max, max);
|
|
1300
|
+
this._targetPitch = clamp(
|
|
1301
|
+
restPitch + clamp(dy * 2 * max, -max, max),
|
|
1302
|
+
this.pitchLimit[0],
|
|
1303
|
+
this.pitchLimit[1],
|
|
1304
|
+
);
|
|
1305
|
+
this._lastInput = now();
|
|
1306
|
+
};
|
|
1307
|
+
this._onUp = (ev) => {
|
|
1308
|
+
if (!dragging) return;
|
|
1309
|
+
dragging = false;
|
|
1310
|
+
this._targetYaw = restYaw;
|
|
1311
|
+
this._targetPitch = clamp(restPitch, this.pitchLimit[0], this.pitchLimit[1]);
|
|
1312
|
+
this._orbitMode = 'rest';
|
|
1313
|
+
this._lastInput = now();
|
|
1314
|
+
el.releasePointerCapture?.(ev.pointerId);
|
|
1315
|
+
};
|
|
1316
|
+
this._onWheel = (ev) => {
|
|
1317
|
+
ev.preventDefault();
|
|
1318
|
+
let px = ev.deltaY;
|
|
1319
|
+
if (ev.deltaMode === 1) px *= WHEEL_LINE_PX;
|
|
1320
|
+
else if (ev.deltaMode === 2) px *= WHEEL_PAGE_PX;
|
|
1321
|
+
px = clamp(px, -WHEEL_MAX_PX, WHEEL_MAX_PX);
|
|
1322
|
+
this._targetZoom = clamp(this._targetZoom * Math.exp(-px * ZOOM_PER_PX), ZOOM_MIN, ZOOM_MAX);
|
|
1323
|
+
this._lastInput = now();
|
|
1324
|
+
};
|
|
1325
|
+
if (el.style) el.style.touchAction = 'none';
|
|
1326
|
+
el.addEventListener('pointerdown', this._onDown);
|
|
1327
|
+
el.addEventListener('pointermove', this._onMove);
|
|
1328
|
+
el.addEventListener('pointerup', this._onUp);
|
|
1329
|
+
el.addEventListener('pointercancel', this._onUp);
|
|
1330
|
+
el.addEventListener('pointerleave', this._onUp);
|
|
1331
|
+
el.addEventListener('wheel', this._onWheel, { passive: false });
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
_unbindOrbit() {
|
|
1335
|
+
const el = this.canvas;
|
|
1336
|
+
if (!this._onDown) return;
|
|
1337
|
+
el.removeEventListener('pointerdown', this._onDown);
|
|
1338
|
+
el.removeEventListener('pointermove', this._onMove);
|
|
1339
|
+
el.removeEventListener('pointerup', this._onUp);
|
|
1340
|
+
el.removeEventListener('pointercancel', this._onUp);
|
|
1341
|
+
el.removeEventListener('pointerleave', this._onUp);
|
|
1342
|
+
el.removeEventListener('wheel', this._onWheel);
|
|
1343
|
+
this._onDown = null;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/** The camera currently on screen: [proj, pose] of the first eye in 3D, the mono camera else. */
|
|
1347
|
+
currentView() {
|
|
1348
|
+
if (this._mode === '3d' && this._lastGood) return this._lastGood.entries[0];
|
|
1349
|
+
return { proj: this.mono.proj, pose: this.mono.pose };
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/** Put an engine node at a rigid display-space pose (position + rotation; no scale). */
|
|
1354
|
+
function placeNode(node, m) {
|
|
1355
|
+
node.setLocalPosition(m[12], m[13], m[14]);
|
|
1356
|
+
const q = quatFromMatrix(m);
|
|
1357
|
+
node.setLocalRotation(q[0], q[1], q[2], q[3]);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// ── the cloud, from the engine's own resource ───────────────────────────────────────────────
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Centres (+ peak opacities where the format has them) from a loaded FLAT gsplat resource, for
|
|
1364
|
+
* the rig waterfall, the auto-frame and the pick. Model space — the file's own frame, before the
|
|
1365
|
+
* flip — which is what Spark's forEachSplat reports too.
|
|
1366
|
+
*
|
|
1367
|
+
* STRIDED AT COPY TIME to at most FRAME_SAMPLE_CAP splats, the largest sample any consumer takes
|
|
1368
|
+
* (the frame pass; the rig pass and the pick stride further, to RIG_SAMPLE_CAP). A copy, because
|
|
1369
|
+
* the engine may hand its own arrays to a sort worker, which detaches them — but only the copy it
|
|
1370
|
+
* needs: on the 1.18M-gaussian bench asset that is 196,608 splats × 16 B ≈ 3.1 MB instead of
|
|
1371
|
+
* 1,179,648 × 16 B ≈ 18.9 MB, and the caller drops it once `ready` has resolved.
|
|
1372
|
+
*
|
|
1373
|
+
* Centres come from `resource.centers` (the engine computes them for its own sort). Opacities
|
|
1374
|
+
* come from the SOG `sh0` plane — alpha is the sigmoid'd opacity in a v2 SOG — through the
|
|
1375
|
+
* engine's texture read (a PBO + fence readback, asynchronous; the full plane is transient), or
|
|
1376
|
+
* from a PLY's `opacity` property.
|
|
1377
|
+
*
|
|
1378
|
+
* @returns {Promise<{xyz:Float32Array, opacity:Float32Array|null, total:number, stride:number,
|
|
1379
|
+
* sourceTotal:number}|null>} `total` is the number of splats IN the sample.
|
|
1380
|
+
*/
|
|
1381
|
+
export async function readCloud(resource) {
|
|
1382
|
+
if (!resource) return null;
|
|
1383
|
+
const centers = resource.centers;
|
|
1384
|
+
const data = resource.gsplatData;
|
|
1385
|
+
const sourceTotal = data?.numSplats || (centers ? Math.floor(centers.length / 3) : 0);
|
|
1386
|
+
if (!centers || !sourceTotal) return null;
|
|
1387
|
+
const stride = Math.max(1, Math.ceil(sourceTotal / FRAME_SAMPLE_CAP));
|
|
1388
|
+
const total = Math.ceil(sourceTotal / stride);
|
|
1389
|
+
const xyz = new Float32Array(total * 3);
|
|
1390
|
+
for (let j = 0, i = 0; j < total; j++, i += stride) {
|
|
1391
|
+
xyz[j * 3] = centers[i * 3];
|
|
1392
|
+
xyz[j * 3 + 1] = centers[i * 3 + 1];
|
|
1393
|
+
xyz[j * 3 + 2] = centers[i * 3 + 2];
|
|
1394
|
+
}
|
|
1395
|
+
let opacity = null;
|
|
1396
|
+
// Full-resolution peak opacity, ONE BYTE per splat, kept for the exact pick (which walks the
|
|
1397
|
+
// engine's own full centre set at pick time): 1.18 MB on the 1.18M bench asset.
|
|
1398
|
+
let alpha8 = null;
|
|
1399
|
+
try {
|
|
1400
|
+
if (data?.isSog && data.sh0?.read) {
|
|
1401
|
+
const px = await data.sh0.read(0, 0, data.sh0.width, data.sh0.height, { mipLevel: 0, face: 0, immediate: true });
|
|
1402
|
+
if (px && px.length >= sourceTotal * 4) {
|
|
1403
|
+
const v2 = data.meta?.version === 2;
|
|
1404
|
+
const mn = data.meta?.sh0?.mins?.[3];
|
|
1405
|
+
const mx = data.meta?.sh0?.maxs?.[3];
|
|
1406
|
+
const op = (i) => {
|
|
1407
|
+
const a = px[i * 4 + 3] / 255;
|
|
1408
|
+
return v2 || mn === undefined ? a : 1 / (1 + Math.exp(-(mn + (mx - mn) * a)));
|
|
1409
|
+
};
|
|
1410
|
+
opacity = new Float32Array(total);
|
|
1411
|
+
for (let j = 0, i = 0; j < total; j++, i += stride) opacity[j] = op(i);
|
|
1412
|
+
alpha8 = new Uint8Array(sourceTotal);
|
|
1413
|
+
if (v2 || mn === undefined) for (let i = 0; i < sourceTotal; i++) alpha8[i] = px[i * 4 + 3];
|
|
1414
|
+
else for (let i = 0; i < sourceTotal; i++) alpha8[i] = Math.round(op(i) * 255);
|
|
1415
|
+
}
|
|
1416
|
+
} else if (typeof data?.getProp === 'function') {
|
|
1417
|
+
const o = data.getProp('opacity');
|
|
1418
|
+
if (o && o.length >= sourceTotal) {
|
|
1419
|
+
opacity = new Float32Array(total);
|
|
1420
|
+
for (let j = 0, i = 0; j < total; j++, i += stride) opacity[j] = 1 / (1 + Math.exp(-o[i]));
|
|
1421
|
+
alpha8 = new Uint8Array(sourceTotal);
|
|
1422
|
+
for (let i = 0; i < sourceTotal; i++) alpha8[i] = Math.round(255 / (1 + Math.exp(-o[i])));
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
} catch (err) {
|
|
1426
|
+
console.warn('[inline3d/splat] could not read splat opacities — the cloud pass runs unfiltered', err);
|
|
1427
|
+
opacity = null;
|
|
1428
|
+
alpha8 = null;
|
|
1429
|
+
}
|
|
1430
|
+
return { xyz, opacity, alpha8, total, stride, sourceTotal };
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/**
|
|
1434
|
+
* A point sample standing in for a Streamed SOG's cloud, from its octree alone: every leaf node
|
|
1435
|
+
* contributes points in proportion to its finest-level splat count, spread deterministically
|
|
1436
|
+
* through its box. That is enough for the SAME percentile framing a flat source gets
|
|
1437
|
+
* (boundsFromPositions), which the octree's root bound cannot give — the root is the raw
|
|
1438
|
+
* container of every chunk, sky shells and floaters included (a captured castle: 391×821×390 m
|
|
1439
|
+
* root against 65×25×76 m measured on the flat file; a museum room: ±240 m around a 2 m statue).
|
|
1440
|
+
*
|
|
1441
|
+
* Coarse by construction (a node is a box, not its splats), so framing lands within a node size
|
|
1442
|
+
* of the flat measurement, not on it. Nothing is downloaded: lod-meta.json already lists every
|
|
1443
|
+
* node's box and count.
|
|
1444
|
+
*
|
|
1445
|
+
* @param {Array<{min:number[], max:number[], count:number}>} nodes
|
|
1446
|
+
* @param {number} [cap] points to emit in total (default 20k).
|
|
1447
|
+
* @returns {Float32Array|null} xyz, model space.
|
|
1448
|
+
*/
|
|
1449
|
+
export function octreeSample(nodes, cap = 20000) {
|
|
1450
|
+
if (!Array.isArray(nodes) || !nodes.length) return null;
|
|
1451
|
+
let total = 0;
|
|
1452
|
+
for (const n of nodes) total += n.count > 0 ? n.count : 0;
|
|
1453
|
+
if (!(total > 0)) return null;
|
|
1454
|
+
const pts = [];
|
|
1455
|
+
let seed = 0x9e3779b9;
|
|
1456
|
+
const rnd = () => {
|
|
1457
|
+
// xorshift32: deterministic, so a test (and a reload) frames the same way every time.
|
|
1458
|
+
seed ^= seed << 13; seed >>>= 0;
|
|
1459
|
+
seed ^= seed >>> 17;
|
|
1460
|
+
seed ^= seed << 5; seed >>>= 0;
|
|
1461
|
+
return seed / 4294967296;
|
|
1462
|
+
};
|
|
1463
|
+
let carry = 0;
|
|
1464
|
+
for (const n of nodes) {
|
|
1465
|
+
if (!(n.count > 0)) continue;
|
|
1466
|
+
carry += (n.count / total) * cap;
|
|
1467
|
+
const k = Math.floor(carry);
|
|
1468
|
+
carry -= k;
|
|
1469
|
+
for (let i = 0; i < k; i++) {
|
|
1470
|
+
pts.push(
|
|
1471
|
+
n.min[0] + (n.max[0] - n.min[0]) * rnd(),
|
|
1472
|
+
n.min[1] + (n.max[1] - n.min[1]) * rnd(),
|
|
1473
|
+
n.min[2] + (n.max[2] - n.min[2]) * rnd(),
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
return pts.length ? Float32Array.from(pts) : null;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/** The engine octree's leaf nodes as octreeSample() input. */
|
|
1481
|
+
function octreeNodes(res) {
|
|
1482
|
+
const nodes = res?.octree?.nodes;
|
|
1483
|
+
if (!Array.isArray(nodes)) return [];
|
|
1484
|
+
const out = [];
|
|
1485
|
+
for (const n of nodes) {
|
|
1486
|
+
const b = n?.bounds;
|
|
1487
|
+
const c = n?.lods?.[0]?.count ?? 0;
|
|
1488
|
+
if (!b || !b.center || !b.halfExtents || !(c > 0)) continue;
|
|
1489
|
+
out.push({
|
|
1490
|
+
min: [b.center.x - b.halfExtents.x, b.center.y - b.halfExtents.y, b.center.z - b.halfExtents.z],
|
|
1491
|
+
max: [b.center.x + b.halfExtents.x, b.center.y + b.halfExtents.y, b.center.z + b.halfExtents.z],
|
|
1492
|
+
count: c,
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
return out;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* What kind of resource the engine handed back, and what can be known about it without a cloud.
|
|
1500
|
+
*
|
|
1501
|
+
* A Streamed SOG (`lod-meta.json`) loads as a `GSplatOctreeResource`: no `centers`, no
|
|
1502
|
+
* `gsplatData`, splats arriving by LOD as the camera asks. Its framing comes from the octree's
|
|
1503
|
+
* leaf boxes (octreeSample → the same percentile bounds a flat cloud gets), else from the root
|
|
1504
|
+
* bound (`resource.aabb`, the lod-meta `tree.bound` — the engine nulls `data.tree` after
|
|
1505
|
+
* reading it); its camera block from the top level of lod-meta.json; its count from the octree's
|
|
1506
|
+
* finest level (`numSplats`; lod-meta's `count` sums every level). What is on screen in a given
|
|
1507
|
+
* frame is `handle.stats().resident`, capped by the tile budget (SPLAT_BUDGET_MODEL); `pick`
|
|
1508
|
+
* searches the chunks currently resident.
|
|
1509
|
+
*
|
|
1510
|
+
* @returns {{kind:'flat'|'streamed'|null, numSplats:number, meta:object|null,
|
|
1511
|
+
* bounds:{center:number[],extent:number[]}|null, boundsSource?:string,
|
|
1512
|
+
* rootBounds?:object|null}} bounds in MODEL space.
|
|
1513
|
+
*/
|
|
1514
|
+
export function describeResource(res) {
|
|
1515
|
+
if (!res) return { kind: null, numSplats: 0, meta: null, bounds: null };
|
|
1516
|
+
if (!res.gsplatData && res.octree !== undefined) {
|
|
1517
|
+
const a = res.aabb;
|
|
1518
|
+
const bounds =
|
|
1519
|
+
a && a.center && a.halfExtents
|
|
1520
|
+
? {
|
|
1521
|
+
center: [a.center.x, a.center.y, a.center.z],
|
|
1522
|
+
extent: [2 * a.halfExtents.x, 2 * a.halfExtents.y, 2 * a.halfExtents.z],
|
|
1523
|
+
}
|
|
1524
|
+
: null;
|
|
1525
|
+
let n = 0;
|
|
1526
|
+
try {
|
|
1527
|
+
n = res.numSplats || 0;
|
|
1528
|
+
} catch {
|
|
1529
|
+
n = 0;
|
|
1530
|
+
}
|
|
1531
|
+
const sample = octreeSample(octreeNodes(res));
|
|
1532
|
+
const sampled = sample ? boundsFromPositions(sample) : null;
|
|
1533
|
+
return {
|
|
1534
|
+
kind: 'streamed',
|
|
1535
|
+
numSplats: n || res.data?.count || 0,
|
|
1536
|
+
meta: res.data || null,
|
|
1537
|
+
bounds: sampled || bounds,
|
|
1538
|
+
boundsSource: sampled ? 'octree-sample' : bounds ? 'octree-root' : null,
|
|
1539
|
+
rootBounds: bounds,
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
return { kind: 'flat', numSplats: res.gsplatData?.numSplats ?? 0, meta: res.gsplatData?.meta || null, bounds: null };
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// ── the handle ──────────────────────────────────────────────────────────────────────────────
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* The per-splat crossfade for `setSource`: a work-buffer modifier multiplying each splat's alpha
|
|
1549
|
+
* by this ENTITY's `dxrFade` parameter.
|
|
1550
|
+
*
|
|
1551
|
+
* Per entity, not per material: the engine's unified renderer composites every splat of a tile
|
|
1552
|
+
* through ONE material and ONE work buffer, so a material uniform cannot tell the outgoing asset
|
|
1553
|
+
* from the incoming one. `setWorkBufferModifier` + `setParameter` is the engine's supported
|
|
1554
|
+
* per-component hook for exactly this; during a fade the work buffer is re-rendered each frame
|
|
1555
|
+
* (WORKBUFFER_UPDATE_ALWAYS), and the modifier is removed when the fade ends.
|
|
1556
|
+
*/
|
|
1557
|
+
const FADE_MODIFIER_GLSL = `
|
|
1558
|
+
uniform float dxrFade;
|
|
1559
|
+
void modifySplatCenter(inout vec3 center) {}
|
|
1560
|
+
void modifySplatRotationScale(vec3 originalCenter, vec3 modifiedCenter, inout vec4 rotation, inout vec3 scale) {}
|
|
1561
|
+
void modifySplatColor(vec3 center, inout vec4 color) { color.a *= dxrFade; }
|
|
1562
|
+
`;
|
|
1563
|
+
|
|
1564
|
+
/** Set (or clear, with null) the crossfade alpha on one splat entity. */
|
|
1565
|
+
function setFade(pc, entity, value) {
|
|
1566
|
+
const g = entity?.gsplat;
|
|
1567
|
+
if (!g) return;
|
|
1568
|
+
if (value === null) {
|
|
1569
|
+
g._dxrFading = false;
|
|
1570
|
+
g.setWorkBufferModifier?.(null);
|
|
1571
|
+
g.deleteParameter?.('dxrFade');
|
|
1572
|
+
if ('workBufferUpdate' in g) g.workBufferUpdate = pc.WORKBUFFER_UPDATE_ONCE ?? 1;
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
if (!g._dxrFading) {
|
|
1576
|
+
g._dxrFading = true;
|
|
1577
|
+
g.setWorkBufferModifier?.({ glsl: FADE_MODIFIER_GLSL });
|
|
1578
|
+
if ('workBufferUpdate' in g) g.workBufferUpdate = pc.WORKBUFFER_UPDATE_ALWAYS ?? 2;
|
|
1579
|
+
}
|
|
1580
|
+
g.setParameter?.('dxrFade', value);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Fill `out` — the handle ./splat already returned synchronously — with the PlayCanvas
|
|
1585
|
+
* implementation, and replay whatever the page called on it while this module was loading.
|
|
1586
|
+
*
|
|
1587
|
+
* @param {object} out the handle, holding queueing stubs.
|
|
1588
|
+
* @param {Array} pending [[method, args], …] called on the stubs before this ran.
|
|
1589
|
+
* @returns {Promise<object>} resolves to `out` once loaded and framed (or removed). The caller
|
|
1590
|
+
* owns `out.ready`; this never writes it.
|
|
1591
|
+
*/
|
|
1592
|
+
export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []) {
|
|
1593
|
+
const {
|
|
1594
|
+
virtualDisplayHeight = 0.24,
|
|
1595
|
+
frame = null,
|
|
1596
|
+
flipY = true,
|
|
1597
|
+
idleSpin = 8,
|
|
1598
|
+
orbit = true,
|
|
1599
|
+
fit = 'contain',
|
|
1600
|
+
margin = 0.8,
|
|
1601
|
+
depthLimit = DEFAULT_DEPTH_LIMIT,
|
|
1602
|
+
fitSweep = true,
|
|
1603
|
+
renderScale = 1,
|
|
1604
|
+
feather = 0,
|
|
1605
|
+
perf,
|
|
1606
|
+
rig = 'auto',
|
|
1607
|
+
captureFit = 'height',
|
|
1608
|
+
focusInput = true,
|
|
1609
|
+
observe,
|
|
1610
|
+
preserveDrawingBuffer = false,
|
|
1611
|
+
} = opts;
|
|
1612
|
+
// `sortIntervalMs` is accepted and has no effect here: the engine re-sorts when the camera
|
|
1613
|
+
// ROTATES (one directional sort serves every view), not on a timer — docs/playcanvas-adapter.md.
|
|
1614
|
+
|
|
1615
|
+
const perfResolved = playcanvasPerfSettings(perf);
|
|
1616
|
+
const viewer = new PlayCanvasSplatViewer(canvas, {
|
|
1617
|
+
virtualDisplayHeight,
|
|
1618
|
+
fit,
|
|
1619
|
+
margin,
|
|
1620
|
+
depthLimit,
|
|
1621
|
+
fitSweep,
|
|
1622
|
+
orbit,
|
|
1623
|
+
idleSpin,
|
|
1624
|
+
renderScale,
|
|
1625
|
+
flipY,
|
|
1626
|
+
orbitMaxDeg: opts.orbitMaxDeg,
|
|
1627
|
+
orbitEase: opts.orbitEase,
|
|
1628
|
+
feather,
|
|
1629
|
+
captureFit,
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
let handle = null;
|
|
1633
|
+
let unbindFocusInput = null;
|
|
1634
|
+
let removed = false;
|
|
1635
|
+
/** The asset on screen: its resource, kind, the pick data it keeps. */
|
|
1636
|
+
let current = null; // { asset, entity, res, kind, streamedBounds, alpha8, pickCentres }
|
|
1637
|
+
|
|
1638
|
+
Object.assign(out, {
|
|
1639
|
+
backend: 'playcanvas',
|
|
1640
|
+
engine: null, // { app, root, camera } once the engine has booted — see below
|
|
1641
|
+
viewer,
|
|
1642
|
+
mesh: null,
|
|
1643
|
+
frame: null,
|
|
1644
|
+
camera: null,
|
|
1645
|
+
rig: null,
|
|
1646
|
+
perf: perfResolved.applied,
|
|
1647
|
+
setPose: (p) => viewer.setPose(p),
|
|
1648
|
+
resetPose: () => viewer.resetPose(),
|
|
1649
|
+
getFocus: (o) => {
|
|
1650
|
+
// In MODEL space, like setFocus takes it.
|
|
1651
|
+
const f = viewer.getFocus(o);
|
|
1652
|
+
return contentToModel([f.x, f.y, f.z]);
|
|
1653
|
+
},
|
|
1654
|
+
setFocus(point, o = {}) {
|
|
1655
|
+
if (!out.mesh || !out.rig) return out;
|
|
1656
|
+
const model = point == null ? out.rig.focusDefault : toArray3(point);
|
|
1657
|
+
out.rig.focus = model;
|
|
1658
|
+
out.rig.focusSource = point == null ? out.rig.focusDefaultSource : 'set';
|
|
1659
|
+
out.rig.convergence = planeDistance(out.rig.rest, model);
|
|
1660
|
+
viewer.setFocus(modelToContent(model), o);
|
|
1661
|
+
return out;
|
|
1662
|
+
},
|
|
1663
|
+
pick(clientX, clientY) {
|
|
1664
|
+
return pickModel(clientX, clientY);
|
|
1665
|
+
},
|
|
1666
|
+
setSource,
|
|
1667
|
+
remove() {
|
|
1668
|
+
removed = true;
|
|
1669
|
+
current = null;
|
|
1670
|
+
unbindFocusInput?.();
|
|
1671
|
+
viewer.onFocusChange = null;
|
|
1672
|
+
handle?.remove();
|
|
1673
|
+
viewer.dispose(); // app.destroy(): every entity — ours and any a page added — goes with it
|
|
1674
|
+
},
|
|
1675
|
+
exclude: (el) => handle?.exclude(el),
|
|
1676
|
+
unexclude: (el) => handle?.unexclude(el),
|
|
1677
|
+
stats: () => splatStats(),
|
|
1678
|
+
});
|
|
1679
|
+
// `onFocusChange` is a plain data property of the handle, read at CALL time — so a callback
|
|
1680
|
+
// assigned before this module loaded (on the stub ./splat returned) is the one that fires.
|
|
1681
|
+
if (!('onFocusChange' in out)) out.onFocusChange = null;
|
|
1682
|
+
|
|
1683
|
+
/** Model (the file's own frame) → content (the engine world, after the flip). */
|
|
1684
|
+
function modelToContent(m) {
|
|
1685
|
+
return flipY ? [m[0], -m[1], -m[2]] : [m[0], m[1], m[2]];
|
|
1686
|
+
}
|
|
1687
|
+
function contentToModel(c) {
|
|
1688
|
+
return flipY ? [c[0], -c[1], -c[2]] : [c[0], c[1], c[2]];
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
if (wall && wall.supported) {
|
|
1692
|
+
handle = wall.addScene(canvas, viewer.onFrame, {
|
|
1693
|
+
virtualDisplayHeight,
|
|
1694
|
+
onLayerLost: viewer.onLayerLost,
|
|
1695
|
+
...(observe ? { observe } : {}),
|
|
1696
|
+
});
|
|
1697
|
+
} else {
|
|
1698
|
+
viewer.startMono();
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Replay what the page did before this module arrived — exclude() above all, which a product
|
|
1702
|
+
// page calls on the very next line after addSplat.
|
|
1703
|
+
for (const [name, args] of pending) {
|
|
1704
|
+
if (name === 'remove') {
|
|
1705
|
+
out.remove();
|
|
1706
|
+
break;
|
|
1707
|
+
}
|
|
1708
|
+
out[name]?.(...args);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// ── focus → view rig ──
|
|
1712
|
+
let lastConvergence = Number.NaN;
|
|
1713
|
+
function pushViewRig(force) {
|
|
1714
|
+
if (!out.rig || out.rig.type !== 'camera') return;
|
|
1715
|
+
const pose = viewer.mono.pose;
|
|
1716
|
+
const f = viewer.getFocus();
|
|
1717
|
+
// three looks down −z; convergence is the focus's distance along that axis (the PLANE).
|
|
1718
|
+
const fwd = [-pose[8], -pose[9], -pose[10]];
|
|
1719
|
+
const d = (f.x - pose[12]) * fwd[0] + (f.y - pose[13]) * fwd[1] + (f.z - pose[14]) * fwd[2];
|
|
1720
|
+
if (!force && Math.abs(d - lastConvergence) < 1e-3) return;
|
|
1721
|
+
lastConvergence = d;
|
|
1722
|
+
out.rig.convergence = d;
|
|
1723
|
+
const q = quatFromMatrix(pose);
|
|
1724
|
+
out.viewRig = cameraRigFromPose(
|
|
1725
|
+
{
|
|
1726
|
+
position: { x: pose[12], y: pose[13], z: pose[14] },
|
|
1727
|
+
orientation: { x: q[0], y: q[1], z: q[2], w: q[3] },
|
|
1728
|
+
fov: viewer.mono.fov,
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
convergence: d > 0 ? d : 0,
|
|
1732
|
+
ipdFactor: out.rig.ipdFactor,
|
|
1733
|
+
parallaxFactor: out.rig.parallaxFactor,
|
|
1734
|
+
out: out.viewRig || {},
|
|
1735
|
+
},
|
|
1736
|
+
);
|
|
1737
|
+
handle?.setViewRig?.(out.viewRig);
|
|
1738
|
+
}
|
|
1739
|
+
viewer.onFocusChange = (f) => {
|
|
1740
|
+
pushViewRig(false);
|
|
1741
|
+
const cb = out.onFocusChange;
|
|
1742
|
+
// Second argument: which waterfall step the focus came from ('block', 'nearest-clump', …).
|
|
1743
|
+
if (typeof cb === 'function') cb(contentToModel([f.x, f.y, f.z]), { focusSource: out.rig?.focusSource ?? null });
|
|
1744
|
+
};
|
|
1745
|
+
// captureFit 'cover' re-crops on resize, which changes the rig's vertical FOV: re-declare it.
|
|
1746
|
+
viewer.onCaptureFov = () => pushViewRig(true);
|
|
1747
|
+
|
|
1748
|
+
// ── stats ──
|
|
1749
|
+
function splatStats() {
|
|
1750
|
+
const c = current;
|
|
1751
|
+
const oct = c?.kind === 'streamed' ? c.res?.octree : null;
|
|
1752
|
+
const budget = viewer.tileBudget;
|
|
1753
|
+
return {
|
|
1754
|
+
kind: c?.kind ?? null,
|
|
1755
|
+
resident: viewer.resident,
|
|
1756
|
+
peakResident: viewer.peakResident,
|
|
1757
|
+
budget: Number.isFinite(budget) ? budget : null,
|
|
1758
|
+
numSplats: out.mesh?.numSplats ?? 0,
|
|
1759
|
+
views: viewer._lastGood && viewer.is3D ? viewer._lastGood.entries.length : 1,
|
|
1760
|
+
lodLevels: oct ? oct.lodLevels : null,
|
|
1761
|
+
files: oct ? oct.files.length : null,
|
|
1762
|
+
filesLoaded: oct ? oct.fileResources.size : null,
|
|
1763
|
+
firstFrameMs: viewer.firstFrameAt,
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// ── pick ──
|
|
1768
|
+
/** The ray under a client point, in MODEL space (through the inverse pivot and the flip). */
|
|
1769
|
+
function modelRay(clientX, clientY) {
|
|
1770
|
+
const ndc = canvasNdc(canvas, clientX, clientY);
|
|
1771
|
+
if (!ndc) return null;
|
|
1772
|
+
const v = viewer.currentView();
|
|
1773
|
+
const invP = mat4Invert(v.proj);
|
|
1774
|
+
if (!invP) return null;
|
|
1775
|
+
const Minv = mat4Invert(pivotMatrix(viewer.pivotState()));
|
|
1776
|
+
const toContent = mat4Mul(Minv, v.pose);
|
|
1777
|
+
const a = transformPoint(invP, ndc.x, ndc.y, -1);
|
|
1778
|
+
const b = transformPoint(invP, ndc.x, ndc.y, 1);
|
|
1779
|
+
const o = contentToModel(transformPoint(toContent, a[0], a[1], a[2]));
|
|
1780
|
+
const e = contentToModel(transformPoint(toContent, b[0], b[1], b[2]));
|
|
1781
|
+
const d = [e[0] - o[0], e[1] - o[1], e[2] - o[2]];
|
|
1782
|
+
const len = Math.hypot(d[0], d[1], d[2]) || 1;
|
|
1783
|
+
return { o, d: [d[0] / len, d[1] / len, d[2] / len] };
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* EXACT nearest-centre pick over the FULL centre set, at pick time: the engine keeps every
|
|
1788
|
+
* centre for its own sort (`resource.centers`), and the adapter keeps one opacity byte per
|
|
1789
|
+
* splat to skip haze. A Streamed SOG picks over the chunks currently RESIDENT (no opacity
|
|
1790
|
+
* filter there yet). Returns a MODEL-space point.
|
|
1791
|
+
*/
|
|
1792
|
+
function pickModel(clientX, clientY) {
|
|
1793
|
+
const c = current;
|
|
1794
|
+
if (!c) return null;
|
|
1795
|
+
const ray = modelRay(clientX, clientY);
|
|
1796
|
+
if (!ray) return null;
|
|
1797
|
+
if (c.kind === 'streamed') {
|
|
1798
|
+
let best = null;
|
|
1799
|
+
let bestT = Infinity;
|
|
1800
|
+
const res = c.res?.octree?.fileResources;
|
|
1801
|
+
if (res && typeof res.values === 'function') {
|
|
1802
|
+
for (const chunk of res.values()) {
|
|
1803
|
+
const xyz = chunk?.centers;
|
|
1804
|
+
if (!xyz || !xyz.length) continue;
|
|
1805
|
+
const p = nearestCentreToRay(xyz, ray.o, ray.d);
|
|
1806
|
+
if (!p) continue;
|
|
1807
|
+
const t = (p[0] - ray.o[0]) * ray.d[0] + (p[1] - ray.o[1]) * ray.d[1] + (p[2] - ray.o[2]) * ray.d[2];
|
|
1808
|
+
if (t < bestT) {
|
|
1809
|
+
bestT = t;
|
|
1810
|
+
best = p;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
return best;
|
|
1815
|
+
}
|
|
1816
|
+
const full = c.res?.centers;
|
|
1817
|
+
if (full && full.length) return nearestCentreToRay(full, ray.o, ray.d, undefined, c.alpha8);
|
|
1818
|
+
// The engine released its centres: fall back to the strided set kept at load.
|
|
1819
|
+
return c.pickCentres ? nearestCentreToRay(c.pickCentres, ray.o, ray.d) : null;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function bindFocusInput() {
|
|
1823
|
+
if (focusInput === false || unbindFocusInput) return;
|
|
1824
|
+
unbindFocusInput = bindFocusGestures(canvas, {
|
|
1825
|
+
onDoubleClick: (e) => {
|
|
1826
|
+
const m = pickModel(e.clientX, e.clientY);
|
|
1827
|
+
if (!m) return false;
|
|
1828
|
+
out.rig.focus = m;
|
|
1829
|
+
out.rig.focusSource = 'picked';
|
|
1830
|
+
out.rig.convergence = planeDistance(out.rig.rest, out.rig.focus);
|
|
1831
|
+
viewer.setFocus(modelToContent(m));
|
|
1832
|
+
return true;
|
|
1833
|
+
},
|
|
1834
|
+
onReset: () => out.setFocus(null),
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// ── load ──
|
|
1839
|
+
let byteSeqLocal = 0;
|
|
1840
|
+
/** Fetch/parse one source into an engine asset, and read what the waterfall needs from it. */
|
|
1841
|
+
async function loadOne(pc, app, source) {
|
|
1842
|
+
let bytes = null;
|
|
1843
|
+
if (typeof source !== 'string') {
|
|
1844
|
+
const buf = source instanceof Blob ? await source.arrayBuffer() : source;
|
|
1845
|
+
bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
1846
|
+
}
|
|
1847
|
+
if (bytes) {
|
|
1848
|
+
// Also caught synchronously by ./splat for Uint8Array/ArrayBuffer; a Blob, and setSource,
|
|
1849
|
+
// only arrive here.
|
|
1850
|
+
const streamedErr = streamedBytesError(bytes, opts.fileName);
|
|
1851
|
+
if (streamedErr) throw new Error(`[inline3d/splat] ${streamedErr}`);
|
|
1852
|
+
}
|
|
1853
|
+
const fmt = engineFormatFor(source, bytes, opts.fileName, opts.fileType);
|
|
1854
|
+
if (!fmt) {
|
|
1855
|
+
throw new Error(
|
|
1856
|
+
"[inline3d/splat] engine:'playcanvas' reads .sog, .ply and a Streamed-SOG lod-meta.json; " +
|
|
1857
|
+
"this source is none of those. Pass engine:'spark' for .spz/.splat/.ksplat.",
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1860
|
+
// The camera block off the SAME bytes, before the engine takes them (as the Spark path).
|
|
1861
|
+
let camera = null;
|
|
1862
|
+
if (bytes && rig !== 'display') camera = sogCameraFromMeta(await readSogMeta(bytes));
|
|
1863
|
+
|
|
1864
|
+
const url = bytes
|
|
1865
|
+
? `inline3d-bytes-${++byteSeq}-${++byteSeqLocal}.${fmt.ext}`
|
|
1866
|
+
: fmt.streamed
|
|
1867
|
+
? streamedEntryUrl(source)
|
|
1868
|
+
: source;
|
|
1869
|
+
const file = bytes
|
|
1870
|
+
? { url, filename: url, contents: new Response(bytes) }
|
|
1871
|
+
: { url, filename: pathOf(url).split('/').pop() || url };
|
|
1872
|
+
const asset = new pc.Asset(url, 'gsplat', file);
|
|
1873
|
+
app.assets.add(asset);
|
|
1874
|
+
await new Promise((resolve, reject) => {
|
|
1875
|
+
asset.ready(resolve);
|
|
1876
|
+
asset.once('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
|
|
1877
|
+
app.assets.load(asset);
|
|
1878
|
+
});
|
|
1879
|
+
const res = asset.resource;
|
|
1880
|
+
const desc = describeResource(res);
|
|
1881
|
+
// A URL `.sog` carries its meta in the resource (the engine keeps unknown keys); a Streamed
|
|
1882
|
+
// SOG carries it at the top level of lod-meta.json. Both validated by the same reader.
|
|
1883
|
+
if (!bytes && rig !== 'display') camera = sogCameraFromMeta(desc.meta);
|
|
1884
|
+
const cloud = desc.kind === 'flat' ? await readCloud(res) : null;
|
|
1885
|
+
return { asset, res, desc, camera, cloud };
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* THE WATERFALL for one loaded asset: bounds, rig, lens, focus — then frame it. Shared by the
|
|
1890
|
+
* first load and every setSource, so a swapped-in file is judged exactly like a first one.
|
|
1891
|
+
*/
|
|
1892
|
+
function applyLoaded(loaded) {
|
|
1893
|
+
const { cloud, desc } = loaded;
|
|
1894
|
+
const walk = cloud ? centresVisitor(cloud.xyz, cloud.opacity, cloud.total) : null;
|
|
1895
|
+
const local = walk ? boundsFromPositions(sampleCloudCentres(cloud.total, walk) || []) : null;
|
|
1896
|
+
const lift = (b) => ({ center: modelToContent(b.center), extent: b.extent.slice(0, 3) });
|
|
1897
|
+
// Measured first — the Spark path's order (a supplied frame is only a fallback there too).
|
|
1898
|
+
// A Streamed SOG has no cloud: there a caller's `frame` beats the octree-derived bounds
|
|
1899
|
+
// (describeResource: a count-weighted sample of the leaf boxes, else the raw root bound),
|
|
1900
|
+
// because both are coarser than a real measurement.
|
|
1901
|
+
const bounds = local
|
|
1902
|
+
? lift(local)
|
|
1903
|
+
: desc.kind === 'streamed' && frame
|
|
1904
|
+
? lift(frame)
|
|
1905
|
+
: desc.bounds
|
|
1906
|
+
? lift(desc.bounds)
|
|
1907
|
+
: frame
|
|
1908
|
+
? lift(frame)
|
|
1909
|
+
: null;
|
|
1910
|
+
|
|
1911
|
+
const sample =
|
|
1912
|
+
!rigNeedsCloud(loaded.camera)
|
|
1913
|
+
? null
|
|
1914
|
+
: walk
|
|
1915
|
+
? sampleCloudRestSpace(cloud.total, walk, loaded.camera?.rest)
|
|
1916
|
+
: null;
|
|
1917
|
+
const box = canvas.getBoundingClientRect();
|
|
1918
|
+
const resolved = resolveRig({
|
|
1919
|
+
camera: loaded.camera,
|
|
1920
|
+
opts,
|
|
1921
|
+
cloud: sample,
|
|
1922
|
+
canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
|
|
1923
|
+
});
|
|
1924
|
+
resolved.focusDefault = resolved.focus.slice();
|
|
1925
|
+
resolved.focusDefaultSource = resolved.focusSource;
|
|
1926
|
+
out.camera = loaded.camera;
|
|
1927
|
+
out.rig = resolved;
|
|
1928
|
+
out.frame = bounds;
|
|
1929
|
+
// The tile budget, now that the kind is known: the caller's, else the streamed default on a
|
|
1930
|
+
// Streamed SOG, else the engine's (STREAMED_SPLAT_BUDGET, SPLAT_BUDGET_MODEL).
|
|
1931
|
+
const budget = tileSplatBudget(perfResolved, desc.kind);
|
|
1932
|
+
viewer.setTileBudget(budget);
|
|
1933
|
+
if (perfResolved.applied) {
|
|
1934
|
+
out.perf = budget !== undefined ? { ...perfResolved.applied, splatBudget: budget } : perfResolved.applied;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// The strided pick fallback (used only if the engine releases its full centre set).
|
|
1938
|
+
let pickCentres = null;
|
|
1939
|
+
if (walk) {
|
|
1940
|
+
const s = sampleCloudCentres(cloud.total, walk, { cap: RIG_SAMPLE_CAP });
|
|
1941
|
+
pickCentres = s ? s.slice() : null;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
if (resolved.type === 'camera') {
|
|
1945
|
+
if (!('idleSpin' in opts)) viewer.idleSpin = 0;
|
|
1946
|
+
// The camera rig never auto-fits (the capture IS the framing): a scale left by a previous
|
|
1947
|
+
// display-rig asset (setSource) must not shrink this one.
|
|
1948
|
+
viewer.resetFit();
|
|
1949
|
+
viewer.useCaptureCamera(resolved);
|
|
1950
|
+
viewer.setFocus(modelToContent(resolved.focus), { snap: true, recentre: false });
|
|
1951
|
+
lastConvergence = Number.NaN;
|
|
1952
|
+
pushViewRig(true);
|
|
1953
|
+
} else {
|
|
1954
|
+
if (viewer.mono.capture) viewer.useDisplayCamera();
|
|
1955
|
+
if (bounds) viewer.fitTo(bounds.center, bounds.extent);
|
|
1956
|
+
else console.warn('[inline3d/splat] no usable bounds — subject is UNFRAMED (model scale)', src);
|
|
1957
|
+
if (resolved.focusSource === 'caller' || resolved.focusSource === 'block') {
|
|
1958
|
+
viewer.setFocus(modelToContent(resolved.focus), { snap: true, recentre: true });
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
return { pickCentres, alpha8: cloud?.alpha8 || null };
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
let pcModule = null;
|
|
1965
|
+
const booted = (async () => {
|
|
1966
|
+
const pc = opts.playcanvas || (await import('playcanvas'));
|
|
1967
|
+
if (removed) return null;
|
|
1968
|
+
const app = await viewer.attachEngine(pc, {
|
|
1969
|
+
preserveDrawingBuffer,
|
|
1970
|
+
perf: perfResolved,
|
|
1971
|
+
viewPath: opts.playcanvasViewPath,
|
|
1972
|
+
});
|
|
1973
|
+
if (!app || removed) return null;
|
|
1974
|
+
pcModule = pc;
|
|
1975
|
+
/**
|
|
1976
|
+
* ADVANCED, not covered by the semver promise: the engine objects behind this window.
|
|
1977
|
+
* `app` is the tile's `pc.AppBase`; `root` the content root, in the splat's content space
|
|
1978
|
+
* (engine world) — add your own entities under it (a glTF through the engine's container
|
|
1979
|
+
* loader, skinned and animated included); `camera` the eye-rig camera entity. Everything is
|
|
1980
|
+
* destroyed with the app by `remove()`.
|
|
1981
|
+
*/
|
|
1982
|
+
out.engine = Object.freeze({ app, root: viewer.content, camera: viewer.eye || null });
|
|
1983
|
+
return app;
|
|
1984
|
+
})();
|
|
1985
|
+
|
|
1986
|
+
const first = booted.then(async (app) => {
|
|
1987
|
+
if (!app || removed) return null;
|
|
1988
|
+
const loaded = await loadOne(pcModule, app, src);
|
|
1989
|
+
if (removed) return null;
|
|
1990
|
+
const entity = viewer.addSplatAsset(loaded.asset);
|
|
1991
|
+
out.mesh = {
|
|
1992
|
+
numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
|
|
1993
|
+
entity,
|
|
1994
|
+
asset: loaded.asset,
|
|
1995
|
+
resource: loaded.res,
|
|
1996
|
+
};
|
|
1997
|
+
const kept = applyLoaded(loaded);
|
|
1998
|
+
current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
|
|
1999
|
+
bindFocusInput();
|
|
2000
|
+
return out;
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
// ── setSource: swap the asset, optionally crossfading ──
|
|
2004
|
+
let sourceGen = 0;
|
|
2005
|
+
let pendingSwap = null;
|
|
2006
|
+
/**
|
|
2007
|
+
* Load `next` behind the current asset, then crossfade to it over `fadeMs` and release the old
|
|
2008
|
+
* one. The rig waterfall re-runs for the new file (rig, lens, focus, frame; `onFocusChange`
|
|
2009
|
+
* fires). The pose (yaw/pitch/zoom/depth) is kept unless `resetPose: true`. A newer call
|
|
2010
|
+
* supersedes an older one still loading. Resolves to the handle once the fade has finished.
|
|
2011
|
+
*/
|
|
2012
|
+
async function setSource(next, { fadeMs = 0, resetPose = false } = {}) {
|
|
2013
|
+
const gen = ++sourceGen;
|
|
2014
|
+
const app = await booted;
|
|
2015
|
+
await first.catch(() => null); // a failed first asset may be replaced
|
|
2016
|
+
if (!app || removed) return out;
|
|
2017
|
+
const pc = pcModule;
|
|
2018
|
+
const loaded = await loadOne(pc, app, next);
|
|
2019
|
+
if (removed || gen !== sourceGen) {
|
|
2020
|
+
app.assets.remove(loaded.asset);
|
|
2021
|
+
loaded.asset.unload?.();
|
|
2022
|
+
return out;
|
|
2023
|
+
}
|
|
2024
|
+
const prev = current;
|
|
2025
|
+
const entity = viewer.addSplatAsset(loaded.asset);
|
|
2026
|
+
const fade = Number.isFinite(fadeMs) && fadeMs > 0 ? fadeMs : 0;
|
|
2027
|
+
if (fade && prev) setFade(pc, entity, 0);
|
|
2028
|
+
out.mesh = {
|
|
2029
|
+
numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
|
|
2030
|
+
entity,
|
|
2031
|
+
asset: loaded.asset,
|
|
2032
|
+
resource: loaded.res,
|
|
2033
|
+
};
|
|
2034
|
+
const kept = applyLoaded(loaded);
|
|
2035
|
+
current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
|
|
2036
|
+
if (resetPose) viewer.resetPose();
|
|
2037
|
+
|
|
2038
|
+
const release = () => {
|
|
2039
|
+
if (!prev) return;
|
|
2040
|
+
// Disable first, destroy + unload a few frames LATER: the engine's gsplat world keeps the
|
|
2041
|
+
// old placement in its list until its next rebuild, and tearing the resource down in the
|
|
2042
|
+
// same frame throws inside the engine's update (found by running the swap).
|
|
2043
|
+
prev.entity.enabled = false;
|
|
2044
|
+
let frames = 0;
|
|
2045
|
+
viewer._hooks.push(() => {
|
|
2046
|
+
if (++frames < 4) return true;
|
|
2047
|
+
prev.entity.destroy?.();
|
|
2048
|
+
app.assets.remove(prev.asset);
|
|
2049
|
+
prev.asset.unload?.();
|
|
2050
|
+
return false;
|
|
2051
|
+
});
|
|
2052
|
+
};
|
|
2053
|
+
if (!fade || !prev) {
|
|
2054
|
+
release();
|
|
2055
|
+
return out;
|
|
2056
|
+
}
|
|
2057
|
+
pendingSwap?.finish();
|
|
2058
|
+
await new Promise((resolve) => {
|
|
2059
|
+
// The fade clock starts on the SECOND tick after the swap, not now: the first frame that
|
|
2060
|
+
// draws the new asset also builds its work buffer (hundreds of ms for a 1M-splat file), and
|
|
2061
|
+
// a clock started before it would spend the whole fade inside that one frame — a cut, not a
|
|
2062
|
+
// crossfade (found by screenshotting mid-fade).
|
|
2063
|
+
let t0 = null;
|
|
2064
|
+
let ticks = 0;
|
|
2065
|
+
const finish = () => {
|
|
2066
|
+
setFade(pc, entity, null);
|
|
2067
|
+
release();
|
|
2068
|
+
pendingSwap = null;
|
|
2069
|
+
resolve();
|
|
2070
|
+
};
|
|
2071
|
+
pendingSwap = { finish };
|
|
2072
|
+
setFade(pc, prev.entity, 1);
|
|
2073
|
+
viewer._hooks.push((t) => {
|
|
2074
|
+
if (removed) return (resolve(), false);
|
|
2075
|
+
if (pendingSwap?.finish !== finish) return false; // superseded: already finished
|
|
2076
|
+
if (++ticks < 2) return true;
|
|
2077
|
+
if (t0 === null) t0 = t;
|
|
2078
|
+
const k = Math.min(1, Math.max(0, (t - t0) / fade));
|
|
2079
|
+
setFade(pc, entity, k);
|
|
2080
|
+
setFade(pc, prev.entity, 1 - k);
|
|
2081
|
+
if (k >= 1) {
|
|
2082
|
+
finish();
|
|
2083
|
+
return false;
|
|
2084
|
+
}
|
|
2085
|
+
return true;
|
|
2086
|
+
});
|
|
2087
|
+
});
|
|
2088
|
+
return out;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// The load promise is RETURNED, never written to `out.ready`: ./splat's addSplat owns that
|
|
2092
|
+
// field (one promise, one owner).
|
|
2093
|
+
return first
|
|
2094
|
+
.then((r) => r || out)
|
|
2095
|
+
.catch((err) => {
|
|
2096
|
+
console.warn('[inline3d/splat] failed to load (engine:playcanvas)', src, err);
|
|
2097
|
+
throw err;
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
let byteSeq = 0;
|