@displayxr/inline3d 1.6.0 → 1.7.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 +136 -0
- package/README.md +1 -0
- package/index.d.ts +9 -0
- package/js/inline3d-model.js +3 -0
- package/js/inline3d-sog.js +295 -0
- package/js/inline3d-splat-perf.js +255 -0
- package/js/inline3d-splat-rig.js +330 -0
- package/js/inline3d-splat.js +484 -8
- package/js/inline3d-viewer.js +146 -4
- package/js/inline3d.js +138 -6
- package/package.json +4 -1
- package/splat.d.ts +164 -0
- package/viewer.d.ts +22 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// inline3d-splat-perf.js — cut a Gaussian splat's OVERDRAW, for `./splat`.
|
|
2
|
+
//
|
|
3
|
+
// EXPERIMENTAL. Internal to `./splat`, which re-exports `applySplatPerf` and takes `perf` as an
|
|
4
|
+
// option. Not covered by the SDK's 1.x semver promise — see docs/sdk-stability.md.
|
|
5
|
+
//
|
|
6
|
+
// WHAT COSTS WHAT. On a 1.18M-gaussian photo-lifted scene the per-fragment composite is 75–85 %
|
|
7
|
+
// of the frame (measured natively on an M1 Pro, one eye at 1920×1080), and it is OVERDRAW, not
|
|
8
|
+
// resolution: a handful of enormous, nearly transparent sky splats cover the frame many times
|
|
9
|
+
// over. Splat COUNT is the axis everyone reaches for first and it is the wrong one — decimating
|
|
10
|
+
// this asset to 25 % breaks it visibly (bright stipple on near lit surfaces) while removing far
|
|
11
|
+
// less fill than the two changes below, which remove none of the picture at all.
|
|
12
|
+
//
|
|
13
|
+
// The two that matter, in the native renderer's words: **shrink each splat's quad to the radius
|
|
14
|
+
// where its alpha falls below 1/255**, instead of a fixed 3σ, and **cull gaussians whose peak
|
|
15
|
+
// opacity is already below 1/255**. This module is both of those on Spark.
|
|
16
|
+
//
|
|
17
|
+
// WHY THE FIRST ONE IS FREE. Spark draws every splat as a quad of `maxStdDev` σ (default √8 ≈
|
|
18
|
+
// 2.83σ) and its fragment shader then discards any fragment whose alpha has fallen under
|
|
19
|
+
// `minAlpha` — so for a splat of peak alpha `a` every fragment beyond
|
|
20
|
+
//
|
|
21
|
+
// r = sqrt(2 · ln(a / minAlpha))
|
|
22
|
+
//
|
|
23
|
+
// is ALREADY being discarded. It is rasterised, interpolated, shaded, and thrown away. Shrinking
|
|
24
|
+
// the quad to exactly that radius therefore removes fragments that contributed nothing: the
|
|
25
|
+
// output is bit-identical (see `alphaRadius` below for the two conditions), and the saving is
|
|
26
|
+
// biggest on exactly the splats that dominate the cost — a haze splat of a = 0.02 needs 1.81σ,
|
|
27
|
+
// not 2.83σ, which is 2.4× fewer fragments for the same pixels.
|
|
28
|
+
//
|
|
29
|
+
// Spark 2.1.0 has no option for that (`maxStdDev` is ONE global uniform, and nothing in the
|
|
30
|
+
// shader derives a radius from alpha), so this patches the vertex shader — through the supported
|
|
31
|
+
// `vertexShader` surface, and by REWRITING SPARK'S OWN SOURCE off the live material rather than
|
|
32
|
+
// shipping a copy of it, so a Spark upgrade brings its shader fixes with it instead of silently
|
|
33
|
+
// pinning this SDK to a fork of a 2.1.0 file. If the anchors ever stop matching, the patch
|
|
34
|
+
// declines with one warning and everything still renders.
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Where a splat's alpha has to fall before its quad may be cut, as a fraction of full opacity.
|
|
38
|
+
*
|
|
39
|
+
* 1/255 is the native renderer's threshold and the point below which an 8-bit framebuffer cannot
|
|
40
|
+
* represent the contribution at all. Note Spark's own `minAlpha` default is HALF of this
|
|
41
|
+
* (0.5/255) — deliberately, so its discard is a shade conservative — which is why `minAlpha` is
|
|
42
|
+
* a knob here and not an assumption.
|
|
43
|
+
*/
|
|
44
|
+
const ALPHA_FLOOR = 1 / 255;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Presets — chosen from measurements on this exact asset class, not from first principles.
|
|
48
|
+
*
|
|
49
|
+
* What the measurement said (1.18M-gaussian SHARP capture, M1 Pro, Chrome/ANGLE-Metal, GPU timer
|
|
50
|
+
* queries, configs interleaved PER FRAME so clock drift cannot bias one against another; full
|
|
51
|
+
* table in docs/authoring-inline-3d.md):
|
|
52
|
+
*
|
|
53
|
+
* - **Splat COUNT is not the cost.** 50 % and 25 % decimations of the same scene measured
|
|
54
|
+
* within noise of the full one (+5 %, +1 % at 1920×1080). Decimation drops the small
|
|
55
|
+
* gaussians; the few enormous ones that cover the frame survive it, and they are the bill.
|
|
56
|
+
* A decimated asset is a download and memory win, not a render-cost win.
|
|
57
|
+
* - **Quad extent is the cost.** `maxStdDev` √8→√6 is −5…−20 % and √8→√4 is −22 %.
|
|
58
|
+
* - **The bit-exact `alphaRadius` buys ~nothing HERE**, because it has nothing to shrink: 86 %
|
|
59
|
+
* of this asset's gaussians are near-opaque (mean peak alpha 0.86; Spark doubles the stored
|
|
60
|
+
* alpha on top), and an opaque splat's own 1/255 radius is 3.53σ, wider than the √8 ≈ 2.83σ
|
|
61
|
+
* it is already drawn at. It stays available and stays exact — a scene of large, low-alpha
|
|
62
|
+
* haze is exactly where it pays, and that is the scene the native renderer was tuned on.
|
|
63
|
+
*
|
|
64
|
+
* So the presets are honest about which axis works, and nothing is applied unless a caller asks:
|
|
65
|
+
* `addSplat` with no `perf` leaves every Spark default exactly where Spark put it.
|
|
66
|
+
*/
|
|
67
|
+
export const SPLAT_PERF_PRESETS = {
|
|
68
|
+
/**
|
|
69
|
+
* The bit-exact one. No measurable win on a mostly-opaque capture; real on a scene whose cost is
|
|
70
|
+
* large low-alpha splats. Costs a little vertex ALU, so on a scene with nothing to shrink it can
|
|
71
|
+
* read as a wash or a shade slower.
|
|
72
|
+
*/
|
|
73
|
+
exact: {
|
|
74
|
+
alphaRadius: true,
|
|
75
|
+
minAlpha: ALPHA_FLOOR,
|
|
76
|
+
},
|
|
77
|
+
/** −5…−20 % measured. Truncates every splat's tail at 2.45σ instead of 2.83σ. */
|
|
78
|
+
balanced: {
|
|
79
|
+
minAlpha: ALPHA_FLOOR,
|
|
80
|
+
maxStdDev: Math.sqrt(6),
|
|
81
|
+
},
|
|
82
|
+
/** −22 % measured. 2σ, plus the sub-pixel cull. For a phone, not for a hero. */
|
|
83
|
+
aggressive: {
|
|
84
|
+
minAlpha: ALPHA_FLOOR,
|
|
85
|
+
maxStdDev: 2,
|
|
86
|
+
// Both eigenaxes under a pixel. On the reference capture at 1280×720 and 1920×1080 this
|
|
87
|
+
// changed ZERO channel bytes — a quad that small usually covers no sample point at all — but
|
|
88
|
+
// it is framing-dependent by nature, so it is here and not in `balanced`.
|
|
89
|
+
minPixelRadius: 1,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Fields that are plain properties on SparkRenderer, copied into uniforms every frame. */
|
|
94
|
+
const SPARK_FIELDS = [
|
|
95
|
+
'minAlpha',
|
|
96
|
+
'maxStdDev',
|
|
97
|
+
'minPixelRadius',
|
|
98
|
+
'maxPixelRadius',
|
|
99
|
+
'falloff',
|
|
100
|
+
// LOD budget knobs. Live, but inert unless the MESH was loaded with LOD data — see
|
|
101
|
+
// splatPerfMeshOptions().
|
|
102
|
+
'lodSplatCount',
|
|
103
|
+
'lodSplatScale',
|
|
104
|
+
'lodRenderScale',
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
// The two anchors the shader patch needs, quoted from Spark 2.1.0's `splatVertex.glsl`.
|
|
108
|
+
//
|
|
109
|
+
// `vRgba.a = rgba.a;` is the line AFTER the anti-aliasing blur has been folded into the alpha
|
|
110
|
+
// (`rgba.a *= blurAdjust`) — so at that point `rgba.a` is exactly the alpha the fragment shader
|
|
111
|
+
// will interpolate, which is what makes the derived radius exact rather than approximate. It is
|
|
112
|
+
// also after the eigen-decomposition's inputs are final and BEFORE `scale1`/`scale2` are taken
|
|
113
|
+
// from `adjustedStdDev`, which is the only window where writing it has any effect.
|
|
114
|
+
const ANCHOR_ALPHA = ' vRgba.a = rgba.a;\n';
|
|
115
|
+
const ANCHOR_UNIFORM = 'uniform float minAlpha;\n';
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Rewrite Spark's splat vertex shader so each quad is only as big as its own alpha justifies.
|
|
119
|
+
*
|
|
120
|
+
* `falloff` is read (not assumed) because the whole argument rests on the fragment shader's
|
|
121
|
+
* `a·exp(−z²/2)` decay: at `falloff < 1` the alpha does NOT decay across the quad, nothing is
|
|
122
|
+
* being discarded, and cutting the quad would cut the picture. The uniform is shared between the
|
|
123
|
+
* two stages, so the vertex shader only has to declare it.
|
|
124
|
+
*
|
|
125
|
+
* @returns {boolean} whether the patch went in.
|
|
126
|
+
*/
|
|
127
|
+
function patchAlphaRadius(material) {
|
|
128
|
+
const src = material?.vertexShader;
|
|
129
|
+
if (typeof src !== 'string') return false;
|
|
130
|
+
if (src.includes('dxrAlphaRadius')) return true; // idempotent — a shared material, or a re-apply
|
|
131
|
+
if (!src.includes(ANCHOR_ALPHA) || !src.includes(ANCHOR_UNIFORM)) {
|
|
132
|
+
console.warn(
|
|
133
|
+
'[inline3d/splat] perf.alphaRadius: this build of Spark does not have the shader lines ' +
|
|
134
|
+
'this patch rewrites, so the quad-shrink is SKIPPED (everything else still applies, and ' +
|
|
135
|
+
'the picture is unchanged). Report the Spark version — the anchors are versioned in ' +
|
|
136
|
+
'js/inline3d-splat-perf.js.',
|
|
137
|
+
);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
material.vertexShader = src
|
|
141
|
+
.replace(
|
|
142
|
+
ANCHOR_UNIFORM,
|
|
143
|
+
`${ANCHOR_UNIFORM}uniform float falloff;\nuniform bool dxrAlphaRadius;\nuniform float dxrAlphaFloor;\n`,
|
|
144
|
+
)
|
|
145
|
+
.replace(
|
|
146
|
+
ANCHOR_ALPHA,
|
|
147
|
+
`${ANCHOR_ALPHA}
|
|
148
|
+
// @displayxr/inline3d: shrink the quad to where this splat's own alpha reaches minAlpha.
|
|
149
|
+
// Every fragment outside that radius is discarded by the fragment shader anyway, so this
|
|
150
|
+
// removes work and not pixels. Guarded on falloff == 1, which is what makes that true.
|
|
151
|
+
if (dxrAlphaRadius && (falloff == 1.0) && (rgba.a <= 1.0)) {
|
|
152
|
+
float floorA = max(dxrAlphaFloor > 0.0 ? dxrAlphaFloor : minAlpha, 1e-6);
|
|
153
|
+
adjustedStdDev = min(adjustedStdDev, sqrt(max(0.0, 2.0 * log(rgba.a / floorA))));
|
|
154
|
+
vSplatUv = position.xy * adjustedStdDev;
|
|
155
|
+
}
|
|
156
|
+
`,
|
|
157
|
+
);
|
|
158
|
+
material.uniforms.dxrAlphaRadius = { value: true };
|
|
159
|
+
// 0 means "use minAlpha", which is the bit-exact cut. See `alphaFloor`.
|
|
160
|
+
material.uniforms.dxrAlphaFloor = { value: 0 };
|
|
161
|
+
material.needsUpdate = true;
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Apply a perf profile to a live `SparkRenderer`.
|
|
167
|
+
*
|
|
168
|
+
* Exported for pages that build their own Spark renderer instead of going through `addSplat`
|
|
169
|
+
* — the knobs are all live, so this can be called at any time (a quality menu, a battery-saver
|
|
170
|
+
* toggle) and takes effect on the next frame.
|
|
171
|
+
*
|
|
172
|
+
* | option | default (Spark 2.1.0) | effect | safety |
|
|
173
|
+
* |---|---|---|---|
|
|
174
|
+
* | `alphaRadius` | — (no such thing) | quad shrunk to the splat's own `alphaFloor` radius | **bit-exact** at the default floor, see below |
|
|
175
|
+
* | `alphaFloor` | — (= `minAlpha`) | the alpha the tail may be cut at, PER SPLAT | lossy above `minAlpha`, and gently: it spends radius where the splat is opaque and takes it where it is not |
|
|
176
|
+
* | `minAlpha` | `0.5/255` | splats and fragments under this alpha are dropped | lossy under 1 LSB |
|
|
177
|
+
* | `maxStdDev` | `√8` | quad extent in σ, globally | lossy: truncates opaque tails |
|
|
178
|
+
* | `minPixelRadius` | `0` | drop splats under this size in px | lossy: drops fine grain |
|
|
179
|
+
* | `maxPixelRadius` | `512` | clamp on quad size in px | lossy, and it SQUASHES: the profile is compressed into the smaller quad, not clipped |
|
|
180
|
+
* | `falloff` | `1` | 1 = Gaussian, 0 = flat | NOT a perf knob — 0 makes the fragment discard stop firing, which costs MORE |
|
|
181
|
+
*
|
|
182
|
+
* BIT-EXACT, and the two conditions on that word. `alphaRadius` only removes fragments the
|
|
183
|
+
* fragment shader was already discarding, so the composited image does not change — provided
|
|
184
|
+
* `falloff` is 1 (the shader guards this itself) and `minPixelRadius` is 0. With a non-zero
|
|
185
|
+
* `minPixelRadius` the shrunken quad can fall under it and the splat is then dropped outright,
|
|
186
|
+
* which is a real (small) change; that is why `balanced` leaves it at 0. At the discard boundary
|
|
187
|
+
* itself the two sides can disagree by one float ULP, where the fragment's own contribution is
|
|
188
|
+
* below 1/255 by construction — invisible in 8 bits, but "bit-exact" is stated with that caveat
|
|
189
|
+
* rather than without it.
|
|
190
|
+
*
|
|
191
|
+
* @param {object} spark a SparkRenderer.
|
|
192
|
+
* @param {true|'balanced'|'aggressive'|object} perf
|
|
193
|
+
* @returns {object|null} the profile actually applied.
|
|
194
|
+
*/
|
|
195
|
+
export function applySplatPerf(spark, perf) {
|
|
196
|
+
if (!spark || !perf) return null;
|
|
197
|
+
let profile;
|
|
198
|
+
if (perf === true) profile = SPLAT_PERF_PRESETS.balanced;
|
|
199
|
+
else if (typeof perf === 'string') profile = SPLAT_PERF_PRESETS[perf];
|
|
200
|
+
else if (typeof perf === 'object') profile = perf;
|
|
201
|
+
if (!profile) {
|
|
202
|
+
console.warn(
|
|
203
|
+
`[inline3d/splat] unknown perf preset "${perf}" — ignored. ` +
|
|
204
|
+
`Known: ${Object.keys(SPLAT_PERF_PRESETS).join(', ')}, or an options object.`,
|
|
205
|
+
);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const applied = {};
|
|
210
|
+
for (const key of SPARK_FIELDS) {
|
|
211
|
+
if (typeof profile[key] === 'number' && Number.isFinite(profile[key])) {
|
|
212
|
+
spark[key] = profile[key];
|
|
213
|
+
applied[key] = profile[key];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// `alphaFloor` is meaningless on its own — it is the floor the shrink cuts at — so asking for
|
|
217
|
+
// one asks for the shrink, unless the caller said otherwise in the same breath.
|
|
218
|
+
const wantRadius = profile.alphaRadius ?? (profile.alphaFloor !== undefined ? true : undefined);
|
|
219
|
+
if (wantRadius) patchAlphaRadius(spark.material);
|
|
220
|
+
const u = spark.material?.uniforms;
|
|
221
|
+
if (u?.dxrAlphaRadius && wantRadius !== undefined) {
|
|
222
|
+
// Set the value every time rather than relying on the patch: the patch is idempotent, so a
|
|
223
|
+
// second call asking to turn it back ON would otherwise return early and leave it off.
|
|
224
|
+
u.dxrAlphaRadius.value = !!wantRadius;
|
|
225
|
+
applied.alphaRadius = !!wantRadius;
|
|
226
|
+
}
|
|
227
|
+
if (u?.dxrAlphaFloor && profile.alphaFloor !== undefined) {
|
|
228
|
+
u.dxrAlphaFloor.value = Number.isFinite(profile.alphaFloor) ? profile.alphaFloor : 0;
|
|
229
|
+
applied.alphaFloor = u.dxrAlphaFloor.value;
|
|
230
|
+
}
|
|
231
|
+
return applied;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The half of a perf profile that has to go into the `SplatMesh` CONSTRUCTOR rather than onto the
|
|
236
|
+
* renderer — Spark builds level-of-detail data at load time or not at all.
|
|
237
|
+
*
|
|
238
|
+
* LOD is the splat-COUNT axis: Spark keeps a merged, decimated pyramid and picks a level against
|
|
239
|
+
* a budget (2.5M splats on desktop, 1M on Android) and a minimum on-screen splat size. It is
|
|
240
|
+
* genuinely lossy — it substitutes merged splats — and it is inert unless the mesh was loaded
|
|
241
|
+
* with it, which is why it cannot be switched on later from `applySplatPerf`.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} perf
|
|
244
|
+
* @returns {object} extra SplatMesh options (empty when LOD was not asked for).
|
|
245
|
+
*/
|
|
246
|
+
export function splatPerfMeshOptions(perf) {
|
|
247
|
+
const profile =
|
|
248
|
+
perf === true
|
|
249
|
+
? SPLAT_PERF_PRESETS.balanced
|
|
250
|
+
: typeof perf === 'string'
|
|
251
|
+
? SPLAT_PERF_PRESETS[perf]
|
|
252
|
+
: perf;
|
|
253
|
+
if (!profile || !profile.lod) return {};
|
|
254
|
+
return { lod: profile.lod === 'quality' ? 'quality' : true };
|
|
255
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// inline3d-splat-rig.js — how a splat decides which rig it is on, what lens it was taken with,
|
|
2
|
+
// and what it is looking at. The WATERFALL, and the arithmetic under it.
|
|
3
|
+
//
|
|
4
|
+
// EXPERIMENTAL. Internal to `./splat`. Not covered by the SDK's 1.x semver promise.
|
|
5
|
+
//
|
|
6
|
+
// Three questions have to be answered before a splat can be drawn, and each of them has a good
|
|
7
|
+
// answer, a worse answer and a last resort. Writing that as a waterfall — with the step that
|
|
8
|
+
// answered it recorded next to the value — is the whole design:
|
|
9
|
+
//
|
|
10
|
+
// RIG caller › the block's `rig` › (a block at all ? camera : display)
|
|
11
|
+
// INTRINSICS the block › caller › ESTIMATED from the cloud › 28 mm-eq
|
|
12
|
+
// FOCUS caller › the block's `focus.point` › MEDIAN DISPARITY › 2 m
|
|
13
|
+
//
|
|
14
|
+
// The two capitalised steps are the interesting ones, and they exist because the fallbacks
|
|
15
|
+
// underneath them are bad in a specific, silent way. A splat with no intrinsics rendered through
|
|
16
|
+
// a guessed lens is drawn at the wrong SIZE — a splat built at focal f_s and viewed at f_v is
|
|
17
|
+
// scaled by f_v/f_s about the frame centre, nothing else changes, so there is no artefact to
|
|
18
|
+
// notice, only a picture that "feels zoomed out". And a focus picked as the middle of the
|
|
19
|
+
// measured bounds lands ~40 m away on an open scene (sky and ground are in those bounds),
|
|
20
|
+
// which puts every bit of actual subject in front of the glass.
|
|
21
|
+
//
|
|
22
|
+
// Everything here is PLAIN ARITHMETIC on numbers and arrays — no three.js types — so the whole
|
|
23
|
+
// waterfall is unit-testable without a GPU, a canvas or a renderer. The caller does the one
|
|
24
|
+
// thing that needs the library: walking the cloud once.
|
|
25
|
+
|
|
26
|
+
/** Rotate `v` by the CONJUGATE of quaternion `q` (xyzw) — i.e. world → the frame `q` defines. */
|
|
27
|
+
export function unrotate(q, v) {
|
|
28
|
+
const [x, y, z, w] = q;
|
|
29
|
+
// q* · v · q, expanded. Conjugating is negating the vector part.
|
|
30
|
+
const ix = -x;
|
|
31
|
+
const iy = -y;
|
|
32
|
+
const iz = -z;
|
|
33
|
+
const tx = 2 * (iy * v[2] - iz * v[1]);
|
|
34
|
+
const ty = 2 * (iz * v[0] - ix * v[2]);
|
|
35
|
+
const tz = 2 * (ix * v[1] - iy * v[0]);
|
|
36
|
+
return [
|
|
37
|
+
v[0] + w * tx + (iy * tz - iz * ty),
|
|
38
|
+
v[1] + w * ty + (iz * tx - ix * tz),
|
|
39
|
+
v[2] + w * tz + (ix * ty - iy * tx),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Rotate `v` BY quaternion `q` (xyzw) — the frame `q` defines → world. */
|
|
44
|
+
export function rotate(q, v) {
|
|
45
|
+
const [x, y, z, w] = q;
|
|
46
|
+
const tx = 2 * (y * v[2] - z * v[1]);
|
|
47
|
+
const ty = 2 * (z * v[0] - x * v[2]);
|
|
48
|
+
const tz = 2 * (x * v[1] - y * v[0]);
|
|
49
|
+
return [
|
|
50
|
+
v[0] + w * tx + (y * tz - z * ty),
|
|
51
|
+
v[1] + w * ty + (z * tx - x * tz),
|
|
52
|
+
v[2] + w * tz + (x * ty - y * tx),
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A model-space point in the rest camera's own frame (OpenCV: +x right, +y down, +z forward). */
|
|
57
|
+
export function toRestSpace(rest, p) {
|
|
58
|
+
const t = rest.position;
|
|
59
|
+
return unrotate(rest.rotation, [p[0] - t[0], p[1] - t[1], p[2] - t[2]]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A point `d` metres straight ahead of the rest camera, back in model space. */
|
|
63
|
+
export function aheadOfRest(rest, d) {
|
|
64
|
+
const f = rotate(rest.rotation, [0, 0, 1]);
|
|
65
|
+
return [rest.position[0] + f[0] * d, rest.position[1] + f[1] * d, rest.position[2] + f[2] * d];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Distance from the rest camera to a model-space point, along the view axis (the PLANE). */
|
|
69
|
+
export function planeDistance(rest, p) {
|
|
70
|
+
return toRestSpace(rest, p)[2];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `p`-th percentile of an ALREADY SORTED array, linearly interpolated. */
|
|
74
|
+
function pct(sorted, p) {
|
|
75
|
+
if (!sorted.length) return NaN;
|
|
76
|
+
const i = (sorted.length - 1) * p;
|
|
77
|
+
const lo = Math.floor(i);
|
|
78
|
+
const hi = Math.ceil(i);
|
|
79
|
+
return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Half the 35 mm frame's diagonal, in mm — the constant behind every "35 mm equivalent". */
|
|
83
|
+
const HALF_DIAGONAL_35MM = Math.hypot(36, 24) / 2;
|
|
84
|
+
|
|
85
|
+
/** The 35 mm-equivalent focal implied by a pair of half-tangents. */
|
|
86
|
+
export function focalEq35(hTan, vTan) {
|
|
87
|
+
const diag = Math.hypot(hTan, vTan);
|
|
88
|
+
return diag > 0 ? HALF_DIAGONAL_35MM / diag : Infinity;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Sanity band on an ESTIMATED lens, in 35 mm-equivalent mm.
|
|
93
|
+
*
|
|
94
|
+
* Outside it the estimate is not a lens, it is a statement about the CLOUD: a scene that wraps
|
|
95
|
+
* around the camera (a 360 capture, a scan the viewer is inside) has an angular extent of most
|
|
96
|
+
* of a sphere and implies a sub-fisheye focal, while a single distant object subtends almost
|
|
97
|
+
* nothing and implies a telescope. Neither is the camera the picture was taken with, so both
|
|
98
|
+
* fall through to the default rather than being believed.
|
|
99
|
+
*/
|
|
100
|
+
const FOCAL_EQ_MIN_MM = 14;
|
|
101
|
+
const FOCAL_EQ_MAX_MM = 85;
|
|
102
|
+
|
|
103
|
+
/** What the fallback claims when the cloud cannot be trusted: a phone's main camera. */
|
|
104
|
+
const DEFAULT_FOCAL_EQ_MM = 28;
|
|
105
|
+
|
|
106
|
+
/** The pixel height every estimated lens is expressed against. Only ratios matter. */
|
|
107
|
+
const NOMINAL_HEIGHT_PX = 1000;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build intrinsics from four tangent limits (left/right/top/bottom, in the OpenCV frame where
|
|
111
|
+
* +y is DOWN).
|
|
112
|
+
*
|
|
113
|
+
* Pixels come out square by construction — `width` is chosen from the tangent aspect — because
|
|
114
|
+
* nothing in a point cloud distinguishes a non-square pixel from a differently shaped frame, and
|
|
115
|
+
* inventing one would be a claim the data does not support.
|
|
116
|
+
*/
|
|
117
|
+
export function intrinsicsFromTangents(txLo, txHi, tyLo, tyHi) {
|
|
118
|
+
const dtx = txHi - txLo;
|
|
119
|
+
const dty = tyHi - tyLo;
|
|
120
|
+
if (!(dtx > 0) || !(dty > 0)) return null;
|
|
121
|
+
const height = NOMINAL_HEIGHT_PX;
|
|
122
|
+
const width = Math.max(1, Math.round((height * dtx) / dty));
|
|
123
|
+
const fy = height / dty;
|
|
124
|
+
const fx = width / dtx;
|
|
125
|
+
return { fx, fy, cx: -txLo * fx, cy: -tyLo * fy, width, height };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Estimate the capture lens from the cloud's ANGULAR EXTENT about the rest camera.
|
|
130
|
+
*
|
|
131
|
+
* A capture's gaussians only exist where the camera could see them, so the cloud's own extent in
|
|
132
|
+
* tangent space IS the frustum that made it — read the edges and you have read the lens. The
|
|
133
|
+
* edges are taken as P1/P99 rather than min/max because a lifted capture always has a few
|
|
134
|
+
* gaussians outside the frame (the refinement hallucinates a little past the edges, and the
|
|
135
|
+
* depth cap scatters some), and one of those would otherwise set the field of view for the whole
|
|
136
|
+
* asset.
|
|
137
|
+
*
|
|
138
|
+
* Validated against a capture whose true half-tangents are ±0.857 horizontal and ±0.482
|
|
139
|
+
* vertical: this returns −0.854/+0.863 and −0.480/+0.505. The asymmetry is real and is why the
|
|
140
|
+
* limits are kept separately rather than symmetrised — it falls straight out as `cx`/`cy`.
|
|
141
|
+
*
|
|
142
|
+
* @param {Float64Array|number[]} tx x/z per sampled splat, in rest-camera space.
|
|
143
|
+
* @param {Float64Array|number[]} ty y/z per sampled splat, same order.
|
|
144
|
+
* @param {number} n how many entries are populated.
|
|
145
|
+
* @returns {{intrinsics:object, focalEqMm:number}|null} null when the cloud says something that
|
|
146
|
+
* is not a camera — see FOCAL_EQ_MIN_MM.
|
|
147
|
+
*/
|
|
148
|
+
export function estimateIntrinsics(tx, ty, n) {
|
|
149
|
+
if (!n || n < 64) return null;
|
|
150
|
+
const sx = Array.prototype.slice.call(tx, 0, n).sort((a, b) => a - b);
|
|
151
|
+
const sy = Array.prototype.slice.call(ty, 0, n).sort((a, b) => a - b);
|
|
152
|
+
const txLo = pct(sx, 0.01);
|
|
153
|
+
const txHi = pct(sx, 0.99);
|
|
154
|
+
const tyLo = pct(sy, 0.01);
|
|
155
|
+
const tyHi = pct(sy, 0.99);
|
|
156
|
+
const intrinsics = intrinsicsFromTangents(txLo, txHi, tyLo, tyHi);
|
|
157
|
+
if (!intrinsics) return null;
|
|
158
|
+
const focalEqMm = focalEq35((txHi - txLo) / 2, (tyHi - tyLo) / 2);
|
|
159
|
+
if (!(focalEqMm >= FOCAL_EQ_MIN_MM) || !(focalEqMm <= FOCAL_EQ_MAX_MM)) return null;
|
|
160
|
+
return { intrinsics, focalEqMm, tangents: { txLo, txHi, tyLo, tyHi } };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The last resort: a 28 mm-equivalent lens, in the orientation the cloud's extent suggests.
|
|
165
|
+
*
|
|
166
|
+
* The aspect is worth keeping even when the focal was refused, because portrait-vs-landscape is
|
|
167
|
+
* the one thing a wildly wrong extent still gets right, and getting it wrong crops the picture
|
|
168
|
+
* along the wrong axis.
|
|
169
|
+
*/
|
|
170
|
+
export function fallbackIntrinsics(aspect = 4 / 3) {
|
|
171
|
+
const a = Number.isFinite(aspect) && aspect > 0 ? aspect : 4 / 3;
|
|
172
|
+
const diag = HALF_DIAGONAL_35MM / DEFAULT_FOCAL_EQ_MM;
|
|
173
|
+
const vTan = diag / Math.hypot(a, 1);
|
|
174
|
+
const hTan = a * vTan;
|
|
175
|
+
return intrinsicsFromTangents(-hTan, hTan, -vTan, vTan);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Focus distance from MEDIAN DISPARITY — the median of 1/z, inverted.
|
|
180
|
+
*
|
|
181
|
+
* Not the median of z, and the difference is the point. Disparity is what a stereo pair actually
|
|
182
|
+
* measures and what the depth was derived from, so its median is the scene's typical depth in the
|
|
183
|
+
* space where the errors are symmetric; in metres the same distribution is a long tail to
|
|
184
|
+
* infinity that drags any average outwards. On the reference capture this gives 2.159 m against
|
|
185
|
+
* the gallery's own 2.138 m from its stored median disparity — the same number by a different
|
|
186
|
+
* route, which is the check that matters.
|
|
187
|
+
*
|
|
188
|
+
* @param {Float64Array|number[]} invz 1/z per sampled splat (z forward, in metres).
|
|
189
|
+
*/
|
|
190
|
+
export function medianDisparityDistance(invz, n) {
|
|
191
|
+
if (!n) return null;
|
|
192
|
+
const s = Array.prototype.slice.call(invz, 0, n).sort((a, b) => a - b);
|
|
193
|
+
const m = s.length % 2 ? s[s.length >> 1] : 0.5 * (s[s.length / 2 - 1] + s[s.length / 2]);
|
|
194
|
+
return m > 0 ? 1 / m : null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Where a focus ends up when there is nothing at all to go on. */
|
|
198
|
+
export const DEFAULT_FOCUS_M = 2.0;
|
|
199
|
+
|
|
200
|
+
/** Sane band on a derived focus distance, in metres. */
|
|
201
|
+
export const FOCUS_MIN_M = 0.2;
|
|
202
|
+
export const FOCUS_MAX_M = 60;
|
|
203
|
+
|
|
204
|
+
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
|
205
|
+
const isVec3 = (v) => Array.isArray(v) && v.length >= 3 && v.slice(0, 3).every(Number.isFinite);
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Run the whole waterfall.
|
|
209
|
+
*
|
|
210
|
+
* @param {object} args
|
|
211
|
+
* @param {object|null} args.camera the parsed `.sog` camera block, or null.
|
|
212
|
+
* @param {object} args.opts the caller's `addSplat` options.
|
|
213
|
+
* @param {object|null} args.cloud `{ tx, ty, invz, n }` from one pass over the splats in
|
|
214
|
+
* rest-camera space, or null if there was nothing to walk.
|
|
215
|
+
* @param {number} args.canvasAspect fallback orientation when the cloud cannot supply one.
|
|
216
|
+
* @returns {object} the resolved rig, every field beside the step that produced it.
|
|
217
|
+
*/
|
|
218
|
+
export function resolveRig({ camera = null, opts = {}, cloud = null, canvasAspect = 4 / 3 }) {
|
|
219
|
+
const rest = camera?.rest ?? { position: [0, 0, 0], rotation: [0, 0, 0, 1] };
|
|
220
|
+
|
|
221
|
+
// ── rig ───────────────────────────────────────────────────────────────────────────────
|
|
222
|
+
let type;
|
|
223
|
+
let typeSource;
|
|
224
|
+
if (opts.rig === 'camera' || opts.rig === 'display') {
|
|
225
|
+
type = opts.rig;
|
|
226
|
+
typeSource = 'caller';
|
|
227
|
+
} else if (camera?.rig) {
|
|
228
|
+
type = camera.rig;
|
|
229
|
+
typeSource = 'block';
|
|
230
|
+
} else {
|
|
231
|
+
// A block at all means a camera was recorded, which only happens for a capture. `rig:
|
|
232
|
+
// "display"` alongside a `rest` is how an asset says "a display rig, opened at this
|
|
233
|
+
// viewpoint" — so the presence of the block is the default, not the override.
|
|
234
|
+
type = camera ? 'camera' : 'display';
|
|
235
|
+
typeSource = camera ? 'block-present' : 'default';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── intrinsics ────────────────────────────────────────────────────────────────────────
|
|
239
|
+
let intrinsics = null;
|
|
240
|
+
let intrinsicsSource = null;
|
|
241
|
+
let focalEqMm = null;
|
|
242
|
+
if (camera?.intrinsics) {
|
|
243
|
+
intrinsics = camera.intrinsics;
|
|
244
|
+
intrinsicsSource = 'block';
|
|
245
|
+
} else if (opts.intrinsics && Number.isFinite(opts.intrinsics.fx)) {
|
|
246
|
+
intrinsics = opts.intrinsics;
|
|
247
|
+
intrinsicsSource = 'caller';
|
|
248
|
+
} else if (cloud) {
|
|
249
|
+
const est = estimateIntrinsics(cloud.tx, cloud.ty, cloud.n);
|
|
250
|
+
if (est) {
|
|
251
|
+
intrinsics = est.intrinsics;
|
|
252
|
+
intrinsicsSource = 'estimated';
|
|
253
|
+
focalEqMm = est.focalEqMm;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (!intrinsics) {
|
|
257
|
+
// Keep the ORIENTATION the cloud implies even when its focal was refused: portrait vs
|
|
258
|
+
// landscape is the part a bad extent still gets right, and getting it wrong crops the
|
|
259
|
+
// picture along the wrong axis.
|
|
260
|
+
let aspect = canvasAspect;
|
|
261
|
+
if (cloud && cloud.n >= 64) {
|
|
262
|
+
const est = intrinsicsFromTangents(
|
|
263
|
+
...(() => {
|
|
264
|
+
const sx = Array.prototype.slice.call(cloud.tx, 0, cloud.n).sort((a, b) => a - b);
|
|
265
|
+
const sy = Array.prototype.slice.call(cloud.ty, 0, cloud.n).sort((a, b) => a - b);
|
|
266
|
+
return [pct(sx, 0.01), pct(sx, 0.99), pct(sy, 0.01), pct(sy, 0.99)];
|
|
267
|
+
})(),
|
|
268
|
+
);
|
|
269
|
+
if (est) aspect = est.width / est.height;
|
|
270
|
+
}
|
|
271
|
+
intrinsics = fallbackIntrinsics(aspect);
|
|
272
|
+
intrinsicsSource = 'fallback-28mm';
|
|
273
|
+
focalEqMm = DEFAULT_FOCAL_EQ_MM;
|
|
274
|
+
}
|
|
275
|
+
if (focalEqMm === null && intrinsics) {
|
|
276
|
+
focalEqMm = focalEq35(intrinsics.width / 2 / intrinsics.fx, intrinsics.height / 2 / intrinsics.fy);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── focus ─────────────────────────────────────────────────────────────────────────────
|
|
280
|
+
//
|
|
281
|
+
// ONE point, and it is the orbit centre, the pivot plane and the convergence distance at
|
|
282
|
+
// once. Resolved as a point in model space; the distance falls out of it, never the reverse.
|
|
283
|
+
let point = null;
|
|
284
|
+
let focusSource = null;
|
|
285
|
+
if (isVec3(opts.focus)) {
|
|
286
|
+
point = opts.focus.slice(0, 3);
|
|
287
|
+
focusSource = 'caller';
|
|
288
|
+
} else if (Number.isFinite(opts.convergence) && opts.convergence > 0) {
|
|
289
|
+
// The scalar shorthand: a focus straight ahead at this distance.
|
|
290
|
+
point = aheadOfRest(rest, opts.convergence);
|
|
291
|
+
focusSource = 'caller-convergence';
|
|
292
|
+
} else if (isVec3(camera?.focus?.point)) {
|
|
293
|
+
point = camera.focus.point.slice(0, 3);
|
|
294
|
+
focusSource = 'block';
|
|
295
|
+
} else if (cloud) {
|
|
296
|
+
const d = medianDisparityDistance(cloud.invz, cloud.n);
|
|
297
|
+
if (d) {
|
|
298
|
+
point = aheadOfRest(rest, clamp(d, FOCUS_MIN_M, FOCUS_MAX_M));
|
|
299
|
+
focusSource = 'median-disparity';
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (!point) {
|
|
303
|
+
point = aheadOfRest(rest, DEFAULT_FOCUS_M);
|
|
304
|
+
focusSource = 'default';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const convergence = planeDistance(rest, point);
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
type,
|
|
311
|
+
typeSource,
|
|
312
|
+
rest,
|
|
313
|
+
intrinsics,
|
|
314
|
+
intrinsicsSource,
|
|
315
|
+
focalEqMm,
|
|
316
|
+
focus: point,
|
|
317
|
+
focusSource,
|
|
318
|
+
// Advisory, straight from the block — a host page's depth budget or HUD may want them.
|
|
319
|
+
focusDistances: camera?.focus
|
|
320
|
+
? { subject_m: camera.focus.subject_m, near_m: camera.focus.near_m, far_m: camera.focus.far_m }
|
|
321
|
+
: null,
|
|
322
|
+
convergence,
|
|
323
|
+
// ABSOLUTE on a camera rig, and they stay absolute: normalising them against the convergence
|
|
324
|
+
// would make the scene's depth breathe every time the viewer re-focused.
|
|
325
|
+
ipdFactor: Number.isFinite(opts.ipdFactor) ? opts.ipdFactor : (camera?.dxr?.ipdFactor ?? 1),
|
|
326
|
+
parallaxFactor: Number.isFinite(opts.parallaxFactor)
|
|
327
|
+
? opts.parallaxFactor
|
|
328
|
+
: (camera?.dxr?.parallaxFactor ?? 1),
|
|
329
|
+
};
|
|
330
|
+
}
|