gaussian-splat-lite 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/rad/rad.js CHANGED
@@ -16,7 +16,7 @@ import { HALF, half, halfBits, opacityRadius } from "./math.js";
16
16
  import { WorkerGroup, encoders } from "./parallel.js";
17
17
  import { Progress } from "./progress.js";
18
18
  import { SH_DIMS } from "./splats.js";
19
- import { readAll, removePath, writeAll } from "./storage.js";
19
+ import { PAGE_BYTES, readAll, removePath } from "./storage.js";
20
20
 
21
21
  const CHUNK = 65536;
22
22
  // Fixed s8 range with SPZ's 1/128 SH step; -1 clips to -127/128.
@@ -158,27 +158,34 @@ const unorm = (value, min, max) =>
158
158
  Math.round(Math.max(0, Math.min(255, ((value - min) / (max - min)) * 255)));
159
159
 
160
160
  // The columns encodeChunk() reads. With a codebook, labels replace the SH.
161
- function gather(splats, base, count, codebook) {
161
+ // The records before `kept` come through the cache, where counting left
162
+ // them; the rest of them and the other columns, read once, pass it by.
163
+ function gather(splats, base, count, codebook, kept) {
162
164
  return {
163
165
  base,
164
166
  count,
165
167
  maxSh: splats.maxSh,
166
- records: splats.records.read(base * 16, new Float32Array(count * 16)),
168
+ records: splats.records.scan(
169
+ base * 16,
170
+ new Float32Array(count * 16),
171
+ count * 16,
172
+ kept * 16,
173
+ ),
167
174
  sh: codebook
168
175
  ? []
169
176
  : splats.sh
170
177
  .slice(0, splats.maxSh)
171
178
  .map((column, band) =>
172
- column.read(
179
+ column.stream(
173
180
  base * SH_DIMS[band],
174
181
  new Uint16Array(count * SH_DIMS[band]),
175
182
  ),
176
183
  ),
177
184
  ...(codebook && {
178
- labels: codebook.labels.read(base, new Uint16Array(count)),
185
+ labels: codebook.labels.stream(base, new Uint16Array(count)),
179
186
  }),
180
- counts: splats.childCount.read(base, new Uint32Array(count)),
181
- starts: splats.childStart.read(base, new Float64Array(count)),
187
+ counts: splats.childCount.stream(base, new Uint32Array(count)),
188
+ starts: splats.childStart.stream(base, new Float64Array(count)),
182
189
  };
183
190
  }
184
191
 
@@ -447,15 +454,16 @@ if (!isMainThread && workerData?.coder) {
447
454
 
448
455
  // With a codebook from clusterSh(), splats store SH as its labels.
449
456
  export async function writeRad(splats, output, chunked, lodBase, codebook) {
457
+ const { workspace } = splats;
450
458
  const chunks = [];
451
459
  const stem = chunkStem(output);
452
- const spool = chunked ? undefined : splats.workspace.file();
460
+ const spool = chunked ? undefined : workspace.file();
453
461
  // Write every file completely before publishing chunks, then the header.
454
462
  // Track published names too, so cancellation rolls back this conversion.
455
463
  const staged = [];
456
464
  const created = [];
457
465
  const stage = (file, write) => {
458
- splats.workspace.check();
466
+ workspace.check();
459
467
  const temporary = path.join(
460
468
  path.dirname(file),
461
469
  `.${path.basename(file)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`,
@@ -464,14 +472,15 @@ export async function writeRad(splats, output, chunked, lodBase, codebook) {
464
472
  staged.push({ temporary, file });
465
473
  try {
466
474
  write(fd);
475
+ workspace.check();
467
476
  fsyncSync(fd);
468
- } catch (error) {
477
+ } finally {
469
478
  try {
479
+ workspace.writeback.synced(fd);
480
+ } finally {
470
481
  closeSync(fd);
471
- } catch {}
472
- throw error;
482
+ }
473
483
  }
474
- closeSync(fd);
475
484
  };
476
485
  let total = 0;
477
486
  let progress;
@@ -479,11 +488,11 @@ export async function writeRad(splats, output, chunked, lodBase, codebook) {
479
488
  if (chunked) {
480
489
  const filename = `${stem}-${chunks.length}.radc`;
481
490
  stage(path.join(path.dirname(output), filename), (fd) =>
482
- writeAll(fd, bytes, 0),
491
+ workspace.write(fd, bytes, 0),
483
492
  );
484
493
  chunks.push({ offset: 0, bytes: bytes.length, filename });
485
494
  } else {
486
- writeAll(spool, bytes, total);
495
+ workspace.write(spool, bytes, total);
487
496
  chunks.push({ offset: total, bytes: bytes.length });
488
497
  }
489
498
  total += bytes.length;
@@ -493,21 +502,29 @@ export async function writeRad(splats, output, chunked, lodBase, codebook) {
493
502
  // order. A chunk's failure is thrown when its turn comes, not as an
494
503
  // unhandled rejection while an earlier chunk is awaited.
495
504
  const pending = [];
496
- const encoder = new Encoder(
497
- splats.workspace,
498
- Math.ceil(splats.length / CHUNK),
499
- );
505
+ const encoder = new Encoder(workspace, Math.ceil(splats.length / CHUNK));
500
506
  try {
501
- // Count the encoding ranges in a first pass.
507
+ // Count the encoding ranges in a first pass. The records that the cache
508
+ // holds stay there for encoding; see gather(). Recompute the prefix
509
+ // after measuring worker growth so it fits a cache that has shrunk.
510
+ const keep = () => {
511
+ workspace.adjust();
512
+ return Math.floor(Math.max(0, workspace.capacity - PAGE_BYTES) / 64);
513
+ };
502
514
  const chunkCount = Math.ceil(splats.length / CHUNK);
503
515
  progress = new Progress("Analyzing RAD encoding", chunkCount);
504
516
  let countedChunks = 0;
505
517
  for (let base = 0; base < splats.length; base += CHUNK) {
506
- splats.workspace.check();
518
+ workspace.check();
519
+ // The encoders grow as they work, and reading past the cache loads
520
+ // no pages, which would measure them.
521
+ const kept = keep();
507
522
  const count = Math.min(CHUNK, splats.length - base);
508
- const records = splats.records.read(
523
+ const records = splats.records.scan(
509
524
  base * 16,
510
525
  new Float32Array(count * 16),
526
+ count * 16,
527
+ kept * 16,
511
528
  );
512
529
  const counted = encoder
513
530
  .count(records)
@@ -522,12 +539,14 @@ export async function writeRad(splats, output, chunked, lodBase, codebook) {
522
539
  progress.finish();
523
540
  progress = new Progress("Encoding RAD chunks", chunkCount);
524
541
  for (let base = 0; base < splats.length; base += CHUNK) {
525
- splats.workspace.check();
542
+ workspace.check();
543
+ const kept = keep();
526
544
  const chunk = gather(
527
545
  splats,
528
546
  base,
529
547
  Math.min(CHUNK, splats.length - base),
530
548
  codebook,
549
+ kept,
531
550
  );
532
551
  if (codebook && base === 0)
533
552
  chunk.codes = encodeCodes(codebook, splats.maxSh);
@@ -556,25 +575,28 @@ export async function writeRad(splats, output, chunked, lodBase, codebook) {
556
575
  meta.length + (spool === undefined ? 0 : total),
557
576
  );
558
577
  stage(output, (fd) => {
559
- writeAll(fd, meta, 0);
578
+ workspace.write(fd, meta, 0);
560
579
  if (spool !== undefined) {
561
580
  const buffer = Buffer.allocUnsafe(1024 * 1024);
562
581
  for (let offset = 0; offset < total; offset += buffer.length) {
563
- splats.workspace.check();
582
+ workspace.check();
564
583
  const bytes = buffer.subarray(
565
584
  0,
566
585
  Math.min(buffer.length, total - offset),
567
586
  );
568
587
  readAll(spool, bytes, offset);
569
- writeAll(fd, bytes, meta.length + offset);
588
+ workspace.write(fd, bytes, meta.length + offset);
570
589
  writing.update(meta.length + offset + bytes.length);
571
590
  }
572
591
  }
573
592
  });
574
593
  writing.finish();
594
+ // Closing an output waits only for a sync that includes it; a temporary
595
+ // file's failed writeback must surface before anything is published.
596
+ workspace.writeback.wait();
575
597
  const publishing = new Progress("Publishing RAD files", staged.length);
576
598
  for (const { temporary, file } of staged) {
577
- splats.workspace.check();
599
+ workspace.check();
578
600
  // rename can replace an existing file, so reserve the name exclusively.
579
601
  const fd = openSync(file, "wx");
580
602
  created.push(file);
@@ -582,7 +604,7 @@ export async function writeRad(splats, output, chunked, lodBase, codebook) {
582
604
  renameSync(temporary, file);
583
605
  publishing.update(created.length);
584
606
  }
585
- splats.workspace.check();
607
+ workspace.check();
586
608
  publishing.finish();
587
609
  return meta.length + total;
588
610
  } catch (error) {
package/bin/rad/splats.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  quaternion,
13
13
  } from "./math.js";
14
14
  import { Progress } from "./progress.js";
15
- import { IndexArray, PagedArray, Permutation } from "./storage.js";
15
+ import { IndexArray, PAGE_BYTES, PagedArray, Permutation } from "./storage.js";
16
16
 
17
17
  export const SH_DIMS = [9, 15, 21];
18
18
 
@@ -75,8 +75,15 @@ export class Splats {
75
75
  // Each splat's merge weight, area × opacity, which merge() reads instead
76
76
  // of two records.
77
77
  this.weight = new PagedArray(workspace, Float64Array);
78
+ // Each splat's feature size, for the passes that read nothing else of a
79
+ // record, and the bounds of the centers; see reorder().
80
+ this.sizes = new PagedArray(workspace, Float32Array);
81
+ this.bounds = undefined;
78
82
  // The two child weights of each merged splat, for mergeSh().
79
83
  this.blend = new PagedArray(workspace, Float64Array);
84
+ // Each decoded splat's row in the SH columns: as decoded, and from
85
+ // mergeSh() on, in the order merges read them; see shRow().
86
+ this.shOrder = undefined;
80
87
  this.merged = new Float64Array(16);
81
88
  this.bits = new Uint16Array(0);
82
89
  this.merger = new Merger();
@@ -133,9 +140,6 @@ export class Splats {
133
140
  read(index, output) {
134
141
  return this.records.read(index * 16, output, 16);
135
142
  }
136
- feature(index) {
137
- return this.records.get(index * 16 + 14);
138
- }
139
143
 
140
144
  // Skips empty splats and counts invalid ones in `invalid`: NaN or infinite
141
145
  // values, including a zero rotation, which the decoder normalizes to NaN.
@@ -192,6 +196,8 @@ export class Splats {
192
196
  this.sh[band].write(count * dims, rows[band]);
193
197
  }
194
198
  }
199
+ // The record's page is cached.
200
+ this.sizes.set(count, this.records.get(count * 16 + 14));
195
201
  count++;
196
202
  }
197
203
  progress.update(base + size);
@@ -226,6 +232,7 @@ export class Splats {
226
232
  const index = this.length++;
227
233
  this.records.write(index * 16, out);
228
234
  this.weight.set(index, out[15] * out[3]);
235
+ this.sizes.set(index, out[14]);
229
236
  this.children.push(a);
230
237
  this.children.push(b);
231
238
  // SH coefficients do not affect merging; mergeSh() blends them later.
@@ -236,46 +243,105 @@ export class Splats {
236
243
  return index;
237
244
  }
238
245
 
239
- // Blends the SH coefficients of the splats merge() created from `initial`
240
- // on, in creation order, so children are done before their parents. Going
241
- // band by band keeps the random reads of children within one column.
242
- // Workers blend batches of splats whose children precede the batch; the
243
- // rest wait for their children in the batch.
246
+ // Blend each SH band in creation order, children before parents. Reorder
247
+ // leaves for sequential reads; only merged children need random reads.
248
+ // Batches use at most half the cache for child pages. Workers handle
249
+ // earlier children; children within the batch are blended below in order.
244
250
  mergeSh(initial, pool) {
251
+ if (!this.maxSh) return;
245
252
  const { nodes, entries, later, blends, blended, results } = pool;
253
+ // Each leaf occurs once, as a merge child or the lone root. Replace its
254
+ // decoded SH row with its position in merge-read order.
255
+ const reads = new IndexArray(this.workspace);
256
+ const collecting = new Progress("Collecting SH order", this.length);
257
+ for (let k = 0; k < (this.length - initial) * 2; k++) {
258
+ const child = this.children.get(k);
259
+ if (child < initial) {
260
+ reads.push(this.shOrder.get(child));
261
+ this.shOrder.set(child, reads.length - 1);
262
+ }
263
+ if ((k & 4095) === 0) collecting.update(k / 2);
264
+ }
265
+ if (this.length === initial) {
266
+ reads.push(this.shOrder.get(0));
267
+ this.shOrder.set(0, 0);
268
+ }
269
+ collecting.finish();
270
+ const leafRows = new Permutation(
271
+ this.workspace,
272
+ reads,
273
+ initial,
274
+ "Preparing SH order",
275
+ );
276
+ reads.dispose();
246
277
  // Splats per batch.
247
278
  const size = later.length;
248
279
  const rows = [this.shLeft, this.shRight];
249
280
  for (let band = 0; band < this.maxSh; band++) {
281
+ const dims = SH_DIMS[band];
282
+ const column = leafRows.apply(
283
+ this.sh[band],
284
+ dims,
285
+ pool,
286
+ false,
287
+ `Reordering SH${band + 1}`,
288
+ );
289
+ this.sh[band] = column;
250
290
  const progress = new Progress(
251
291
  `Blending SH${band + 1}`,
252
292
  this.length - initial,
253
293
  );
254
- const dims = SH_DIMS[band];
255
- const column = this.sh[band].share();
256
- for (let start = initial; start < this.length; start += size) {
257
- const count = Math.min(size, this.length - start);
294
+ // The batch that last counted each page of the column.
295
+ const counted = new Float64Array(
296
+ Math.ceil((this.length * dims) / column.items),
297
+ );
298
+ let batch = 0;
299
+ // The row of the next decoded splat that a merge reads.
300
+ let next = 0;
301
+ for (let start = initial; start < this.length; ) {
302
+ const limit = Math.min(size, this.length - start);
303
+ const pages = Math.max(
304
+ 1,
305
+ Math.floor(this.workspace.capacity / PAGE_BYTES / 2),
306
+ );
307
+ batch++;
308
+ let taken = 0;
309
+ let count = 0;
258
310
  // Load the pages of the children's rows for the workers. Consecutive
259
311
  // splats' children often share pages; a page evicted meanwhile is a
260
312
  // miss that the main thread blends.
261
313
  let left = -1;
262
314
  let right = -1;
263
- for (let i = 0; i < count; i++) {
264
- const a = this.children.get((start + i - initial) * 2);
265
- const b = this.children.get((start + i - initial) * 2 + 1);
266
- nodes[i] = a;
267
- entries[i] = b;
268
- blends[i * 2] = this.blend.get((start + i - initial) * 2);
269
- blends[i * 2 + 1] = this.blend.get((start + i - initial) * 2 + 1);
270
- later[i] = Number(a >= start || b >= start);
315
+ while (count < limit) {
316
+ const k = (start + count - initial) * 2;
317
+ const a = this.children.get(k);
318
+ const b = this.children.get(k + 1);
319
+ const ra = a < initial ? next : a;
320
+ const rb = b < initial ? next + Number(a < initial) : b;
321
+ const pa = Math.floor((ra * dims) / column.items);
322
+ const pb = Math.floor((rb * dims) / column.items);
323
+ let added = Number(ra < start && counted[pa] !== batch);
324
+ if (rb < start && counted[pb] !== batch && !(added && pb === pa))
325
+ added++;
326
+ if (count && taken + added > pages) break;
327
+ taken += added;
328
+ if (ra < start) counted[pa] = batch;
329
+ if (rb < start) counted[pb] = batch;
330
+ next += Number(a < initial) + Number(b < initial);
331
+ const i = count++;
332
+ nodes[i] = ra;
333
+ entries[i] = rb;
334
+ blends[i * 2] = this.blend.get(k);
335
+ blends[i * 2 + 1] = this.blend.get(k + 1);
336
+ later[i] = Number(ra >= start || rb >= start);
271
337
  if (later[i]) continue;
272
- if (Math.floor((a * dims) / column.items) !== left) {
273
- left = Math.floor((a * dims) / column.items);
274
- column.page(a * dims);
338
+ if (pa !== left) {
339
+ left = pa;
340
+ column.page(ra * dims);
275
341
  }
276
- if (Math.floor((b * dims) / column.items) !== right) {
277
- right = Math.floor((b * dims) / column.items);
278
- column.page(b * dims);
342
+ if (pb !== right) {
343
+ right = pb;
344
+ column.page(rb * dims);
279
345
  }
280
346
  }
281
347
  pool.blend(column, count, dims);
@@ -297,14 +363,19 @@ export class Splats {
297
363
  );
298
364
  }
299
365
  column.write(start * dims, blended, count * dims);
300
- progress.update(start + count - initial);
366
+ start += count;
367
+ progress.update(start - initial);
301
368
  }
302
369
  progress.finish();
303
370
  }
371
+ leafRows.dispose();
304
372
  this.blend.dispose();
305
373
  }
306
374
 
307
- // Moves row indices[i] of the records and SH to row i.
375
+ // Moves row indices[i] of the records to row i, and keeps `indices` for
376
+ // the SH rows, which move once merging has built the tree; see mergeSh().
377
+ // The splats' sizes and weights, and the bounds of their centers, come
378
+ // from the records while each window of them is cached.
308
379
  reorder(indices, pool) {
309
380
  const permutation = new Permutation(
310
381
  this.workspace,
@@ -312,23 +383,43 @@ export class Splats {
312
383
  this.length,
313
384
  "Preparing splat order",
314
385
  );
386
+ this.sizes.dispose();
387
+ const sizes = this.sizes;
388
+ const bounds = {
389
+ min: [0, 1, 2].map(() => Number.POSITIVE_INFINITY),
390
+ max: [0, 1, 2].map(() => Number.NEGATIVE_INFINITY),
391
+ };
392
+ const record = new Float64Array(16);
315
393
  this.records = permutation.apply(
316
394
  this.records,
317
395
  16,
318
396
  pool,
319
397
  false,
320
398
  "Reordering splats",
399
+ (records, start, end) => {
400
+ for (let i = start; i < end; i++) {
401
+ records.read(i * 16, record, 16);
402
+ for (let d = 0; d < 3; d++) {
403
+ bounds.min[d] = Math.min(bounds.min[d], record[d]);
404
+ bounds.max[d] = Math.max(bounds.max[d], record[d]);
405
+ }
406
+ this.weight.set(i, record[15] * record[3]);
407
+ sizes.set(i, record[14]);
408
+ }
409
+ },
321
410
  );
322
- for (let band = 0; band < this.maxSh; band++)
323
- this.sh[band] = permutation.apply(
324
- this.sh[band],
325
- SH_DIMS[band],
326
- pool,
327
- false,
328
- `Reordering SH${band + 1}`,
329
- );
411
+ this.bounds = bounds;
330
412
  permutation.dispose();
331
413
  this.length = indices.length;
414
+ if (this.maxSh) this.shOrder = indices;
415
+ else indices.dispose();
416
+ }
417
+
418
+ // The row of splat `index` in the SH columns after mergeSh(): merged
419
+ // splats keep theirs, and the decoded ones are in the order merges read
420
+ // them.
421
+ shRow(index) {
422
+ return index < this.shOrder.length ? this.shOrder.get(index) : index;
332
423
  }
333
424
 
334
425
  dispose() {
@@ -339,6 +430,7 @@ export class Splats {
339
430
  this.childCount,
340
431
  this.children,
341
432
  this.weight,
433
+ this.sizes,
342
434
  this.blend,
343
435
  ])
344
436
  column.dispose();