@driftengine/splats 3.61.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/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +56 -0
- package/dist/half.d.ts +32 -0
- package/dist/half.js +88 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +38 -0
- package/dist/shaders/generated/splat.wgsl.d.ts +89 -0
- package/dist/shaders/generated/splat.wgsl.js +95 -0
- package/dist/shaders/splat.d.ts +25 -0
- package/dist/shaders/splat.js +337 -0
- package/dist/splat.d.ts +26 -0
- package/dist/splat.js +63 -0
- package/dist/splatBudget.d.ts +40 -0
- package/dist/splatBudget.js +45 -0
- package/dist/splatCapture.d.ts +76 -0
- package/dist/splatCapture.js +108 -0
- package/dist/splatCull.d.ts +25 -0
- package/dist/splatCull.js +80 -0
- package/dist/splatData.d.ts +177 -0
- package/dist/splatData.js +223 -0
- package/dist/splatGl.d.ts +49 -0
- package/dist/splatGl.js +176 -0
- package/dist/splatGpu.d.ts +50 -0
- package/dist/splatGpu.js +180 -0
- package/dist/splatLayout.d.ts +52 -0
- package/dist/splatLayout.js +75 -0
- package/dist/splatMatrix.d.ts +29 -0
- package/dist/splatMatrix.js +68 -0
- package/dist/splatPass.d.ts +83 -0
- package/dist/splatPass.js +206 -0
- package/dist/splatPly.d.ts +14 -0
- package/dist/splatPly.js +242 -0
- package/dist/splatSog.d.ts +110 -0
- package/dist/splatSog.js +285 -0
- package/dist/splatSogDecoder.d.ts +26 -0
- package/dist/splatSogDecoder.js +29 -0
- package/dist/splatSort.d.ts +137 -0
- package/dist/splatSort.js +199 -0
- package/dist/splatSortWorker.d.ts +14 -0
- package/dist/splatSortWorker.js +137 -0
- package/dist/splatSorter.d.ts +112 -0
- package/dist/splatSorter.js +231 -0
- package/dist/splatView.d.ts +52 -0
- package/dist/splatView.js +115 -0
- package/package.json +56 -0
- package/src/fixtures/README.md +36 -0
- package/src/fixtures/cloud.sog +0 -0
- package/src/fixtures/cloud.texels.json +27 -0
- package/src/fixtures/cloud.truth.json +582 -0
- package/src/half.ts +92 -0
- package/src/index.ts +55 -0
- package/src/shaders/generated/splat.wgsl.ts +98 -0
- package/src/shaders/splat.ts +344 -0
- package/src/splat.ts +75 -0
- package/src/splatBudget.ts +48 -0
- package/src/splatCapture.ts +154 -0
- package/src/splatCull.ts +91 -0
- package/src/splatData.ts +398 -0
- package/src/splatGl.ts +262 -0
- package/src/splatGpu.ts +259 -0
- package/src/splatLayout.ts +86 -0
- package/src/splatMatrix.ts +81 -0
- package/src/splatPass.ts +324 -0
- package/src/splatPly.ts +283 -0
- package/src/splatSog.ts +375 -0
- package/src/splatSogDecoder.ts +33 -0
- package/src/splatSort.ts +296 -0
- package/src/splatSortWorker.ts +155 -0
- package/src/splatSorter.ts +285 -0
- package/src/splatView.ts +147 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/** The scheduler: one sort in flight, newest view wins, and the worker is a default not a mechanism. */
|
|
2
|
+
import { createSplatSortScratch, sortSplatsByDepth } from './splatSort.js';
|
|
3
|
+
import { createSplatSortWorker } from './splatSortWorker.js';
|
|
4
|
+
/**
|
|
5
|
+
* How far the view may turn before the order is worth recomputing.
|
|
6
|
+
*
|
|
7
|
+
* A dot product, so 0.999 is about 2.6 degrees. Below that the change in ordering is a handful of
|
|
8
|
+
* adjacent swaps among splats that overlap anyway, and re-sorting for it would mean a sort every
|
|
9
|
+
* frame at any camera speed — which is the cost this whole arrangement exists to avoid. **A still
|
|
10
|
+
* camera sorts zero times a second**, which is the property to preserve.
|
|
11
|
+
*/
|
|
12
|
+
const RESORT_DOT = 0.999;
|
|
13
|
+
/**
|
|
14
|
+
* How far the camera may move, as a fraction of the capture's own radius, before a re-sort.
|
|
15
|
+
*
|
|
16
|
+
* **A fraction rather than a distance, because a distance means different things for a room and
|
|
17
|
+
* for a landscape.** Only consulted when there is a budget: see `frame`.
|
|
18
|
+
*/
|
|
19
|
+
const RESORT_MOVE_FRACTION = 0.1;
|
|
20
|
+
/**
|
|
21
|
+
* The sort, on the main thread, right now.
|
|
22
|
+
*
|
|
23
|
+
* The fallback when a worker cannot be built, and the implementation a test injects. Synchronous
|
|
24
|
+
* inside a promise: there is nothing to await, and pretending otherwise would hide that this
|
|
25
|
+
* blocks.
|
|
26
|
+
*
|
|
27
|
+
* **The scratch is cached across calls and grown when it has to be.** It is 384 KB of histogram
|
|
28
|
+
* plus four bytes a splat, and allocating that per sort would make the fallback path generate more
|
|
29
|
+
* garbage than the work it is doing. Sharing one scratch between two captures is safe because this
|
|
30
|
+
* is synchronous: no second sort can start while one is running on this thread.
|
|
31
|
+
*/
|
|
32
|
+
let mainThreadScratch = null;
|
|
33
|
+
export function sortOnMainThread(request) {
|
|
34
|
+
if (mainThreadScratch === null || mainThreadScratch.keys.length < request.count) {
|
|
35
|
+
mainThreadScratch = createSplatSortScratch(request.count);
|
|
36
|
+
}
|
|
37
|
+
const count = sortSplatsByDepth(request, mainThreadScratch);
|
|
38
|
+
return Promise.resolve({ order: request.out, count });
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The shipped capability: a worker over the same sort this module runs on the main thread.
|
|
42
|
+
*
|
|
43
|
+
* `sortSplatsByDepth.toString()` is what goes into the worker, so there is one implementation of
|
|
44
|
+
* the decision and not two — the 2026-08-17 rule, which is about exactly this. It works because
|
|
45
|
+
* that function closes over nothing: its scratch and its two bucket counts all arrive as
|
|
46
|
+
* arguments.
|
|
47
|
+
*/
|
|
48
|
+
export function createDefaultSplatSort() {
|
|
49
|
+
return createSplatSortWorker(sortSplatsByDepth.toString());
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Hold the newest order, ask for a new one when the view has changed enough, and never queue.
|
|
53
|
+
*
|
|
54
|
+
* **At most one sort in flight, and a request while one is running is dropped rather than
|
|
55
|
+
* queued.** The newest view is the only one worth sorting for: a queue would sort for a camera
|
|
56
|
+
* position the player has already left, and then sort again, so it converts a busy moment into a
|
|
57
|
+
* backlog that never catches up.
|
|
58
|
+
*
|
|
59
|
+
* **Two buffers, ping-ponged.** One is being filled by the sorter and the other is what the pass
|
|
60
|
+
* draws from, so a sort landing mid-frame never rewrites the order under a draw call.
|
|
61
|
+
*/
|
|
62
|
+
export class SplatSorter {
|
|
63
|
+
splats;
|
|
64
|
+
sort;
|
|
65
|
+
budget;
|
|
66
|
+
moveFraction;
|
|
67
|
+
ready;
|
|
68
|
+
/** How many splats the standing order was computed over, so an arrival is a reason to re-sort. */
|
|
69
|
+
sortedReady = 0;
|
|
70
|
+
/** Half the diagonal of the capture's own bounds. The scale the move gate is a fraction of. */
|
|
71
|
+
radius;
|
|
72
|
+
buffers;
|
|
73
|
+
/** Which buffer the sorter owns. The other is the one a caller may read. */
|
|
74
|
+
filling = 0;
|
|
75
|
+
inFlight = false;
|
|
76
|
+
/** The view the standing order was computed for. Meaningless until `hasSorted`. */
|
|
77
|
+
sortedDirX = 0;
|
|
78
|
+
sortedDirY = 0;
|
|
79
|
+
sortedDirZ = 0;
|
|
80
|
+
sortedOriginX = 0;
|
|
81
|
+
sortedOriginY = 0;
|
|
82
|
+
sortedOriginZ = 0;
|
|
83
|
+
hasSorted = false;
|
|
84
|
+
/** How many indices the standing order actually holds, which a budget makes smaller. */
|
|
85
|
+
kept = 0;
|
|
86
|
+
/** Bumped by every sort that lands, so a caller can tell a new order from the one it has. */
|
|
87
|
+
generation = 0;
|
|
88
|
+
constructor(options) {
|
|
89
|
+
this.splats = options.splats;
|
|
90
|
+
/*
|
|
91
|
+
* The worker is the default and the main thread is the fallback, and neither is the mechanism:
|
|
92
|
+
* a caller that supplies its own never reaches either. Built once per sorter rather than once
|
|
93
|
+
* per module, so two captures do not share one worker and serialise behind each other.
|
|
94
|
+
*/
|
|
95
|
+
this.sort = options.sort ?? createDefaultSplatSort();
|
|
96
|
+
this.budget = options.budget ?? 0;
|
|
97
|
+
this.moveFraction = options.moveFraction ?? RESORT_MOVE_FRACTION;
|
|
98
|
+
this.ready = options.ready ?? (() => options.splats.count);
|
|
99
|
+
const { boundsMin, boundsMax } = options.splats;
|
|
100
|
+
this.radius =
|
|
101
|
+
0.5 *
|
|
102
|
+
Math.hypot((boundsMax[0] ?? 0) - (boundsMin[0] ?? 0), (boundsMax[1] ?? 0) - (boundsMin[1] ?? 0), (boundsMax[2] ?? 0) - (boundsMin[2] ?? 0));
|
|
103
|
+
const size = Math.max(1, options.splats.count);
|
|
104
|
+
this.buffers = [new Uint32Array(size), new Uint32Array(size)];
|
|
105
|
+
}
|
|
106
|
+
/** The standing order, or null until the first sort lands. */
|
|
107
|
+
get order() {
|
|
108
|
+
return this.hasSorted ? this.buffers[1 - this.filling] : null;
|
|
109
|
+
}
|
|
110
|
+
/** How many entries of `order` are real. Below the capture's count when a budget bit. */
|
|
111
|
+
get drawCount() {
|
|
112
|
+
return this.kept;
|
|
113
|
+
}
|
|
114
|
+
/** How many orders have landed. A caller uploads when this changes and not otherwise. */
|
|
115
|
+
get version() {
|
|
116
|
+
return this.generation;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Whether a sort is in flight.
|
|
120
|
+
*
|
|
121
|
+
* **What a measuring page waits on, and it has to wait on more than `version`.** With a budget
|
|
122
|
+
* the sort chooses *which* splats are drawn and not merely their order, so a capture
|
|
123
|
+
* photographed at the first landed sort is a capture photographed at whichever sort the worker
|
|
124
|
+
* happened to finish — and two backends that reach the held frame at different wall-clock times
|
|
125
|
+
* then draw different splats. A held camera is settled when this is false and the standing
|
|
126
|
+
* `version` has been uploaded: at a fixed direction `frame` asks for nothing further, so that
|
|
127
|
+
* state is reached and then keeps.
|
|
128
|
+
*/
|
|
129
|
+
get sorting() {
|
|
130
|
+
return this.inFlight;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Offer the current view, in the capture's own space. Starts a sort if one is wanted.
|
|
134
|
+
*
|
|
135
|
+
* `resolveSplatView` is what turns a camera and a model matrix into this. `force` is for the
|
|
136
|
+
* first frame and for a capture whose transform moved, where the view in its own space may be
|
|
137
|
+
* unchanged and the order is stale anyway.
|
|
138
|
+
*
|
|
139
|
+
* **Turning always matters; moving only matters when there is a budget.** Depth is measured
|
|
140
|
+
* along the view axis, so translating the camera shifts every splat's depth by the same amount
|
|
141
|
+
* and leaves the ordering exactly as it was — which is why this gate did not exist until a
|
|
142
|
+
* budget did. A budget keeps the splats largest on screen, which is the extent over the
|
|
143
|
+
* *distance*, so a camera that walks across a capture without turning changes which splats are
|
|
144
|
+
* drawn. What that costs is a sort every tenth of a capture-radius while a viewer moves; what
|
|
145
|
+
* would make it wrong is a budget so generous that nothing is ever dropped, where the gate spends
|
|
146
|
+
* sorts to reach the same answer.
|
|
147
|
+
*/
|
|
148
|
+
frame(local, force = false) {
|
|
149
|
+
const count = Math.max(0, Math.min(this.ready(), this.splats.count));
|
|
150
|
+
if (count <= 0)
|
|
151
|
+
return;
|
|
152
|
+
if (this.inFlight)
|
|
153
|
+
return;
|
|
154
|
+
/*
|
|
155
|
+
* **An arrival is a reason to sort even from a camera that has not moved.** The order this
|
|
156
|
+
* sorter holds is an order over fewer splats than the capture now has, so it is not the order
|
|
157
|
+
* it wants — and a viewer watching a capture load is exactly the viewer who is standing
|
|
158
|
+
* still. Without this a stream stops densifying the moment nobody moves.
|
|
159
|
+
*/
|
|
160
|
+
let wanted = force || !this.hasSorted || count !== this.sortedReady;
|
|
161
|
+
if (!wanted) {
|
|
162
|
+
const turned = local.dirX * this.sortedDirX + local.dirY * this.sortedDirY + local.dirZ * this.sortedDirZ <
|
|
163
|
+
RESORT_DOT;
|
|
164
|
+
wanted = turned;
|
|
165
|
+
}
|
|
166
|
+
if (!wanted && this.budget > 0 && this.budget < count && this.radius > 0) {
|
|
167
|
+
const moved = Math.hypot(local.originX - this.sortedOriginX, local.originY - this.sortedOriginY, local.originZ - this.sortedOriginZ);
|
|
168
|
+
wanted = moved > this.moveFraction * this.radius;
|
|
169
|
+
}
|
|
170
|
+
if (!wanted)
|
|
171
|
+
return;
|
|
172
|
+
this.inFlight = true;
|
|
173
|
+
const target = this.buffers[this.filling];
|
|
174
|
+
/*
|
|
175
|
+
* Snapshotted before the call, because `local` belongs to the caller and is rewritten in place
|
|
176
|
+
* every frame — so reading it again when the promise settles would record the view the camera
|
|
177
|
+
* has *now* as the one this order was computed for, and the re-sort gates would then compare
|
|
178
|
+
* against a lie. Six numbers on the stack rather than a copy of the object, so nothing is
|
|
179
|
+
* allocated for it.
|
|
180
|
+
*/
|
|
181
|
+
const dirX = local.dirX;
|
|
182
|
+
const dirY = local.dirY;
|
|
183
|
+
const dirZ = local.dirZ;
|
|
184
|
+
const originX = local.originX;
|
|
185
|
+
const originY = local.originY;
|
|
186
|
+
const originZ = local.originZ;
|
|
187
|
+
void this.sort({
|
|
188
|
+
positions: this.splats.positions,
|
|
189
|
+
extents: this.splats.extents,
|
|
190
|
+
count,
|
|
191
|
+
dirX,
|
|
192
|
+
dirY,
|
|
193
|
+
dirZ,
|
|
194
|
+
originX,
|
|
195
|
+
originY,
|
|
196
|
+
originZ,
|
|
197
|
+
budget: this.budget,
|
|
198
|
+
out: target,
|
|
199
|
+
})
|
|
200
|
+
.then((result) => {
|
|
201
|
+
/*
|
|
202
|
+
* The buffer that comes back is the one that was handed out — a worker transfers it away
|
|
203
|
+
* and transfers it back, so the array identity may differ even though the storage is the
|
|
204
|
+
* same. Store what arrived rather than what was sent.
|
|
205
|
+
*/
|
|
206
|
+
this.buffers[this.filling] = result.order;
|
|
207
|
+
this.filling = 1 - this.filling;
|
|
208
|
+
this.sortedDirX = dirX;
|
|
209
|
+
this.sortedDirY = dirY;
|
|
210
|
+
this.sortedDirZ = dirZ;
|
|
211
|
+
this.sortedOriginX = originX;
|
|
212
|
+
this.sortedOriginY = originY;
|
|
213
|
+
this.sortedOriginZ = originZ;
|
|
214
|
+
this.kept = result.count;
|
|
215
|
+
this.sortedReady = count;
|
|
216
|
+
this.hasSorted = true;
|
|
217
|
+
this.generation++;
|
|
218
|
+
})
|
|
219
|
+
.catch(() => {
|
|
220
|
+
/*
|
|
221
|
+
* **A rejected sort leaves the previous order drawing**, which is the honest degradation:
|
|
222
|
+
* a capture one camera step out of order is very slightly wrong at some silhouettes, and a
|
|
223
|
+
* capture with no order at all is not drawn. Swallowed rather than rethrown because this
|
|
224
|
+
* is reached from a frame and the loop may not throw.
|
|
225
|
+
*/
|
|
226
|
+
})
|
|
227
|
+
.finally(() => {
|
|
228
|
+
this.inFlight = false;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Where the camera is, and which way it faces, expressed in one capture's own space. */
|
|
2
|
+
/**
|
|
3
|
+
* The camera as the sorter needs it: in the capture's coordinates rather than the world's.
|
|
4
|
+
*
|
|
5
|
+
* Mutable and filled in place, because this is resolved once a frame per batch and the engine's
|
|
6
|
+
* rule about per-frame allocation binds a package exactly as it binds the renderer.
|
|
7
|
+
*/
|
|
8
|
+
export interface SplatViewLocal {
|
|
9
|
+
/** The camera's forward in the capture's own space, unit length. */
|
|
10
|
+
dirX: number;
|
|
11
|
+
dirY: number;
|
|
12
|
+
dirZ: number;
|
|
13
|
+
/** The camera's position in the capture's own space. */
|
|
14
|
+
originX: number;
|
|
15
|
+
originY: number;
|
|
16
|
+
originZ: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function createSplatViewLocal(): SplatViewLocal;
|
|
19
|
+
/**
|
|
20
|
+
* Fill `out` with the camera, in the space the capture's own positions are written in.
|
|
21
|
+
*
|
|
22
|
+
* **One sorter serves one batch, and this is what makes that affordable.** A batch has a model
|
|
23
|
+
* matrix so that two captures can compose in one scene; its splat positions are in its own frame
|
|
24
|
+
* and the sort reads them there, so asking the sort for a *world* direction would mean
|
|
25
|
+
* transforming a million positions every time the view turned. Transforming the camera instead is
|
|
26
|
+
* six numbers.
|
|
27
|
+
*
|
|
28
|
+
* **The direction is the model's transpose and not its inverse, and the two disagree exactly where
|
|
29
|
+
* it matters.** What the sort needs is an ordering that matches the depth those splats really have
|
|
30
|
+
* once the model has moved them, and the world depth of a capture-space point `p` is
|
|
31
|
+
* `dot(M p + t - c, d)`, which rearranges to `dot(p, Mᵀd)` plus a constant. So `Mᵀd` is the
|
|
32
|
+
* direction that orders correctly for *any* invertible model, including one with a non-uniform
|
|
33
|
+
* scale; `M⁻¹d` is the direction that would be right if the transform were a rotation, and a
|
|
34
|
+
* plausible-looking answer everywhere else. `splatView.test.ts` asserts the ordering rather than
|
|
35
|
+
* the arithmetic, which is why the model it uses is stretched.
|
|
36
|
+
*
|
|
37
|
+
* The origin is the inverse, because that genuinely is a point: `M⁻¹(c − t)` is where the camera
|
|
38
|
+
* sits in the capture's frame, and it is what the budget's distances are measured from. It also
|
|
39
|
+
* happens to be exactly the point whose projection along `Mᵀd` cancels the constant above, so the
|
|
40
|
+
* two halves agree by construction rather than by arrangement.
|
|
41
|
+
*
|
|
42
|
+
* **What this gives up**: the ordering is along the view *axis* rather than by distance to the
|
|
43
|
+
* camera point, so two splats at equal depth and far apart across the frame are ordered by a plane
|
|
44
|
+
* rather than by a sphere. That is the ordering every splat renderer uses and it costs nothing
|
|
45
|
+
* until a capture wraps around the viewer. What would make it wrong is exactly that case —
|
|
46
|
+
* standing inside a capture at a wide field of view, where the error shows at the frame's corners.
|
|
47
|
+
*
|
|
48
|
+
* **A singular model falls back to the world rather than to `NaN`.** This is reached from a frame
|
|
49
|
+
* and the loop may not throw; a `NaN` direction is a sort in which every comparison is false,
|
|
50
|
+
* which is silently input order rather than a visible fault.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolveSplatView(view: ArrayLike<number>, model: ArrayLike<number>, out: SplatViewLocal): void;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** Where the camera is, and which way it faces, expressed in one capture's own space. */
|
|
2
|
+
export function createSplatViewLocal() {
|
|
3
|
+
return { dirX: 0, dirY: 0, dirZ: -1, originX: 0, originY: 0, originZ: 0 };
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Fill `out` with the camera, in the space the capture's own positions are written in.
|
|
7
|
+
*
|
|
8
|
+
* **One sorter serves one batch, and this is what makes that affordable.** A batch has a model
|
|
9
|
+
* matrix so that two captures can compose in one scene; its splat positions are in its own frame
|
|
10
|
+
* and the sort reads them there, so asking the sort for a *world* direction would mean
|
|
11
|
+
* transforming a million positions every time the view turned. Transforming the camera instead is
|
|
12
|
+
* six numbers.
|
|
13
|
+
*
|
|
14
|
+
* **The direction is the model's transpose and not its inverse, and the two disagree exactly where
|
|
15
|
+
* it matters.** What the sort needs is an ordering that matches the depth those splats really have
|
|
16
|
+
* once the model has moved them, and the world depth of a capture-space point `p` is
|
|
17
|
+
* `dot(M p + t - c, d)`, which rearranges to `dot(p, Mᵀd)` plus a constant. So `Mᵀd` is the
|
|
18
|
+
* direction that orders correctly for *any* invertible model, including one with a non-uniform
|
|
19
|
+
* scale; `M⁻¹d` is the direction that would be right if the transform were a rotation, and a
|
|
20
|
+
* plausible-looking answer everywhere else. `splatView.test.ts` asserts the ordering rather than
|
|
21
|
+
* the arithmetic, which is why the model it uses is stretched.
|
|
22
|
+
*
|
|
23
|
+
* The origin is the inverse, because that genuinely is a point: `M⁻¹(c − t)` is where the camera
|
|
24
|
+
* sits in the capture's frame, and it is what the budget's distances are measured from. It also
|
|
25
|
+
* happens to be exactly the point whose projection along `Mᵀd` cancels the constant above, so the
|
|
26
|
+
* two halves agree by construction rather than by arrangement.
|
|
27
|
+
*
|
|
28
|
+
* **What this gives up**: the ordering is along the view *axis* rather than by distance to the
|
|
29
|
+
* camera point, so two splats at equal depth and far apart across the frame are ordered by a plane
|
|
30
|
+
* rather than by a sphere. That is the ordering every splat renderer uses and it costs nothing
|
|
31
|
+
* until a capture wraps around the viewer. What would make it wrong is exactly that case —
|
|
32
|
+
* standing inside a capture at a wide field of view, where the error shows at the frame's corners.
|
|
33
|
+
*
|
|
34
|
+
* **A singular model falls back to the world rather than to `NaN`.** This is reached from a frame
|
|
35
|
+
* and the loop may not throw; a `NaN` direction is a sort in which every comparison is false,
|
|
36
|
+
* which is silently input order rather than a visible fault.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveSplatView(view, model, out) {
|
|
39
|
+
/*
|
|
40
|
+
* The camera's forward in the world is the third row of the view rotation, negated: the view
|
|
41
|
+
* matrix takes world to camera and a camera looks down its own -z.
|
|
42
|
+
*/
|
|
43
|
+
const forwardX = -(view[2] ?? 0);
|
|
44
|
+
const forwardY = -(view[6] ?? 0);
|
|
45
|
+
const forwardZ = -(view[10] ?? 0);
|
|
46
|
+
/* The camera's position is -Rᵀt, which for a rigid view matrix is an exact inverse and costs
|
|
47
|
+
nine multiplies rather than a general inversion. */
|
|
48
|
+
const tx = view[12] ?? 0;
|
|
49
|
+
const ty = view[13] ?? 0;
|
|
50
|
+
const tz = view[14] ?? 0;
|
|
51
|
+
const cameraX = -((view[0] ?? 0) * tx + (view[1] ?? 0) * ty + (view[2] ?? 0) * tz);
|
|
52
|
+
const cameraY = -((view[4] ?? 0) * tx + (view[5] ?? 0) * ty + (view[6] ?? 0) * tz);
|
|
53
|
+
const cameraZ = -((view[8] ?? 0) * tx + (view[9] ?? 0) * ty + (view[10] ?? 0) * tz);
|
|
54
|
+
/*
|
|
55
|
+
* **Named by row then column, because binding these the other way is a transposed inverse and a
|
|
56
|
+
* plausible picture.** `model` is column-major, so the element at row `r` and column `c` is
|
|
57
|
+
* `model[c * 4 + r]` — while the cofactor formulas below are written the way every reference
|
|
58
|
+
* writes them, in rows. Reading three consecutive array entries as a row looks like a matching
|
|
59
|
+
* pattern and quietly inverts the transpose; it did here, and the ordering test caught it by a
|
|
60
|
+
* sign.
|
|
61
|
+
*/
|
|
62
|
+
const a00 = model[0] ?? 0;
|
|
63
|
+
const a10 = model[1] ?? 0;
|
|
64
|
+
const a20 = model[2] ?? 0;
|
|
65
|
+
const a01 = model[4] ?? 0;
|
|
66
|
+
const a11 = model[5] ?? 0;
|
|
67
|
+
const a21 = model[6] ?? 0;
|
|
68
|
+
const a02 = model[8] ?? 0;
|
|
69
|
+
const a12 = model[9] ?? 0;
|
|
70
|
+
const a22 = model[10] ?? 0;
|
|
71
|
+
/*
|
|
72
|
+
* Mᵀd. Transposing turns the matrix's columns into rows, and a column of a column-major array is
|
|
73
|
+
* three adjacent entries — so this reads as three plain dot products against the array as
|
|
74
|
+
* stored, which is the one place the layout helps rather than hinders.
|
|
75
|
+
*/
|
|
76
|
+
const rawX = a00 * forwardX + a10 * forwardY + a20 * forwardZ;
|
|
77
|
+
const rawY = a01 * forwardX + a11 * forwardY + a21 * forwardZ;
|
|
78
|
+
const rawZ = a02 * forwardX + a12 * forwardY + a22 * forwardZ;
|
|
79
|
+
const length = Math.hypot(rawX, rawY, rawZ);
|
|
80
|
+
/* Cofactors of the first row, which give the determinant and a third of the inverse at once. */
|
|
81
|
+
const c00 = a11 * a22 - a12 * a21;
|
|
82
|
+
const c01 = a12 * a20 - a10 * a22;
|
|
83
|
+
const c02 = a10 * a21 - a11 * a20;
|
|
84
|
+
const determinant = a00 * c00 + a01 * c01 + a02 * c02;
|
|
85
|
+
if (length === 0 ||
|
|
86
|
+
determinant === 0 ||
|
|
87
|
+
!Number.isFinite(length) ||
|
|
88
|
+
!Number.isFinite(determinant)) {
|
|
89
|
+
out.dirX = forwardX;
|
|
90
|
+
out.dirY = forwardY;
|
|
91
|
+
out.dirZ = forwardZ;
|
|
92
|
+
out.originX = cameraX;
|
|
93
|
+
out.originY = cameraY;
|
|
94
|
+
out.originZ = cameraZ;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
out.dirX = rawX / length;
|
|
98
|
+
out.dirY = rawY / length;
|
|
99
|
+
out.dirZ = rawZ / length;
|
|
100
|
+
const relativeX = cameraX - (model[12] ?? 0);
|
|
101
|
+
const relativeY = cameraY - (model[13] ?? 0);
|
|
102
|
+
const relativeZ = cameraZ - (model[14] ?? 0);
|
|
103
|
+
const inverse = 1 / determinant;
|
|
104
|
+
/* M⁻¹ = adj(M)/det, and the adjugate is the *transpose* of the cofactor matrix — which is why
|
|
105
|
+
c01 and c02 open the second and third rows here rather than the first. */
|
|
106
|
+
out.originX =
|
|
107
|
+
(c00 * relativeX + (a02 * a21 - a01 * a22) * relativeY + (a01 * a12 - a02 * a11) * relativeZ) *
|
|
108
|
+
inverse;
|
|
109
|
+
out.originY =
|
|
110
|
+
(c01 * relativeX + (a00 * a22 - a02 * a20) * relativeY + (a02 * a10 - a00 * a12) * relativeZ) *
|
|
111
|
+
inverse;
|
|
112
|
+
out.originZ =
|
|
113
|
+
(c02 * relativeX + (a01 * a20 - a00 * a21) * relativeY + (a00 * a11 - a01 * a10) * relativeZ) *
|
|
114
|
+
inverse;
|
|
115
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@driftengine/splats",
|
|
3
|
+
"version": "3.61.0",
|
|
4
|
+
"description": "Gaussian splat captures: readers, an off-frame sort, and a pass that composes into the scene",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"drift-source": "./src/index.ts",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json",
|
|
16
|
+
"./*": "./*"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*.test.mjs",
|
|
23
|
+
"!src/**/__snapshots__",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"NOTICE"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@driftengine/core": "3.61.0"
|
|
31
|
+
},
|
|
32
|
+
"author": "Drift Technologies",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/drftrun/driftengine.git",
|
|
36
|
+
"directory": "packages/splats"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/drftrun/driftengine#readme",
|
|
39
|
+
"bugs": "https://github.com/drftrun/driftengine/issues",
|
|
40
|
+
"keywords": [
|
|
41
|
+
"driftengine",
|
|
42
|
+
"3d",
|
|
43
|
+
"webgl",
|
|
44
|
+
"webgpu",
|
|
45
|
+
"typescript",
|
|
46
|
+
"gaussian-splatting",
|
|
47
|
+
"radiance-field",
|
|
48
|
+
"point-cloud"
|
|
49
|
+
],
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=22.12.0"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# The `.sog` fixture, and where each of its three files came from
|
|
2
|
+
|
|
3
|
+
**Nothing here was written by hand, and the chain matters more than the files.** Track O withdrew
|
|
4
|
+
its mock capability providers on the argument that a mock is a second implementation of a contract
|
|
5
|
+
with no first implementation to check it against, so anything it agrees with is itself. A `.sog`
|
|
6
|
+
fixture invented in this repository would have exactly that shape: the reader would agree with it
|
|
7
|
+
by construction, and the encoding it agreed on could be anybody's.
|
|
8
|
+
|
|
9
|
+
So the chain has an independent encoder and an independent decoder in it, and neither is ours:
|
|
10
|
+
|
|
11
|
+
1. **`cloud.truth.json`** — sixty-four Gaussians with values chosen to be distinguishable: positions
|
|
12
|
+
on a curve, scales spread over an order of magnitude, a rotation that turns through three
|
|
13
|
+
radians, opacities from 0.15 to 0.95. These were written as a `.ply`, in the encodings a training
|
|
14
|
+
run writes — scale as its logarithm, opacity as its logit, rotation as `wxyz`.
|
|
15
|
+
2. **`cloud.sog`** — that `.ply` through **`@playcanvas/splat-transform` 3.3.3**, which is the tool
|
|
16
|
+
a consumer with a capture actually runs and the reference implementation of the container. It is
|
|
17
|
+
a real bundle: a ZIP written streaming, so every local header carries a size of zero and the real
|
|
18
|
+
sizes are in the central directory, which is the detail a reader gets wrong first.
|
|
19
|
+
3. **`cloud.texels.json`** — the five WebP images decoded to RGBA by **Pillow 10.2**, base64'd.
|
|
20
|
+
Committed because Node has no WebP decoder and this engine will not vendor one: `readSplatSog`
|
|
21
|
+
takes the decoder as a parameter, so the test supplies these and the browser supplies its own.
|
|
22
|
+
|
|
23
|
+
The test therefore compares values this repository chose against values this repository decoded out
|
|
24
|
+
of a file somebody else's encoder wrote and somebody else's decoder read. Nothing in that loop
|
|
25
|
+
checks itself.
|
|
26
|
+
|
|
27
|
+
`scripts/sog-check.mjs` closes the last gap by decoding the same bundle with the browser's own WebP
|
|
28
|
+
decoder, which is what `browserWebpDecoder` actually calls.
|
|
29
|
+
|
|
30
|
+
## Reproducing it
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
node scripts/sogFixture.mjs cloud.ply packages/splats/src/fixtures/cloud.truth.json
|
|
34
|
+
npx @playcanvas/splat-transform@3.3.3 cloud.ply packages/splats/src/fixtures/cloud.sog
|
|
35
|
+
python3 scripts/sogTexels.py packages/splats/src/fixtures/cloud.sog packages/splats/src/fixtures/cloud.texels.json
|
|
36
|
+
```
|
|
Binary file
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"means_l.webp": {
|
|
3
|
+
"width": 8,
|
|
4
|
+
"height": 8,
|
|
5
|
+
"rgba": "00Lt/wmN3v8fpCD/3k+i/4P25f8RjLb/X9bX//2Ogv9HJ3z/f79O/w4AAP+BAwH/3MpT/x5W9f9Kqen/YMgv/2K5yP9QhLf/LDT+//bWnv+wepz/WjX7/0C1l//k4sH/R4hd/2Dab/8pIQL/mLwg//Ugv/+AXO7//xGO/1u9LP9mHDv/1cwX/54D3P+36qv/GqGz/75BMv+jaNP/t40z/wFVnv97ROn/n+Bx/+2wsP8gYDn/3zll//X9bf8AAP7/fqPW/wnxXv+kSpX/TvV6/wgnDP9/PbD/fWnd//C3BP8iUEH/0glK/668Mf+cWsD/4GQ4/7Sfx/+e9fP/K//W/w=="
|
|
6
|
+
},
|
|
7
|
+
"means_u.webp": {
|
|
8
|
+
"width": 8,
|
|
9
|
+
"height": 8,
|
|
10
|
+
"rgba": "zwIA/8wEAf/IBwP/vxAG/7sVCP/ECwT/rSwR/7YcC/+yJA7/0wAA/9cAAP/aAAD/3QAA/+ECAP/kBAH/5wcD/+oLBP/tEAb/8BYI//IcC//1JA7/+C0R/6g2Ff+iQRn/nU4e/5dcI/+RbSn/in8v//o3Ff/9Qhn//08e/3yiPf91sUX/br1O/2jJV/9i0mL/Xdtu/1fjfP+DkjX/Te+X/0n0ov9E+Kz/UumK/zv9vv9A+7b/N//G/zP/zf8AgP//ApL//wWi//8Hsf7/Cr39/w3J/P8s/9n/Jfvj/yj93/8i+Oj/D9P6/xLb+P8V4/X/HvTs/xvv7/8Y6fL/MP/T/w=="
|
|
11
|
+
},
|
|
12
|
+
"scales.webp": {
|
|
13
|
+
"width": 8,
|
|
14
|
+
"height": 8,
|
|
15
|
+
"rgba": "moVj/5iEYv+Xg2H/koBe/49/Xf+UgmD/iHpa/419XP+Le1v/nIZk/52HZv+eiWf/n4po/6CMaf+hjWr/oo5s/6OQbf+kkW7/pZNv/6aVcP+nlnL/qJdz/4Z4WP+FdVf/g3FW/4FuVf9+a1T/fGhS/6mYdf+qmXb//5t3/3ViUP9vX0//alxO/2ZZTP9hVkv/XFNK/1dQSf95ZVH/TkpG/0lHRf9ERET/Uk1I/zo+Qv8/QUP/NjtA/zE4P/8AFir/BRcs/woZLf8OGi7/EBww/xIeMf8oMj3/Iyw6/yUvPP8gKTn/FB8y/xUhM/8WIjT/Hic4/xsmN/8YJDb/LDU+/w=="
|
|
16
|
+
},
|
|
17
|
+
"quats.webp": {
|
|
18
|
+
"width": 8,
|
|
19
|
+
"height": 8,
|
|
20
|
+
"rgba": "zuDN/9LfzP/V3sv/3dvJ/9rI4PzZ3cr/1sTb/NnH3/zXxt38yuHO/8biz//C48//vuTQ/7rl0f+25tH/sufS/67o0/+p6NP/penU/6Hp1P+d6tT/merV/9TD2vzSwtj80cDW/M+/1PzNvtP8zLzR/JTq1f+Q69X/jOvV/8i5zfzGuMv8xLbI/MK1xvzAs8T8vrHC/LywwPzKu8/8t6y7/LWqufyzqLb8uq69/K6lsfyxp7T8rKOv/KqhrPx/f3/8goGC/ISDhPyHhYf8iYeK/IyJjfylnaf8oJmi/KKbpfydl6D8jouP/JGNkvyTj5X8m5Wd/JmTmvyWkZj8p5+q/A=="
|
|
21
|
+
},
|
|
22
|
+
"sh0.webp": {
|
|
23
|
+
"width": 8,
|
|
24
|
+
"height": 8,
|
|
25
|
+
"rgba": "iCJavoYkWruEJlq3fihasXwrWq6BJ1q0dDFapHotWqt3L1qnix9awY4cWsSQG1rIkhpay5UYWs6YFFrRmRJa1ZoQWtidD1rbng1a3p8KWuKhCFrlowZa6HIyWqFwNFqebjZammw4WpdqOlqUaDxakaUEWuunAlrv/wBa8mRbWopiXVqHYF9ahF5hWoBcYlp9PmNaejxlWndmPVqNN2lacDVrWm0zbFpqOWdacy5vWmMxbVpnLHFaYCpzWl0Al1omA5RaKQWRWiwHkFovCY9aMw2NWjYmd1pWIXtaUCN5WlMdfVpND4paORGHWjwThVpAG39aSRqDWkYWhFpDJ3VaWg=="
|
|
26
|
+
}
|
|
27
|
+
}
|