gaussian-splat-lite 1.5.1 → 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/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.2] - 2026-10-29
11
+
12
+ ### Changed
13
+
14
+ - `gsl-build-lod` reduces temporary-file reads for models larger than `--memory-budget`.
15
+
16
+ ### Fixed
17
+
18
+ - Neural denoising no longer lets a still view turn grainy again when it keeps rendering for many seconds.
19
+ - `gsl-build-lod` limits pending disk writes within `--memory-budget` to prevent excessive memory use on slow disks.
20
+
10
21
  ## [1.5.1] - 2026-10-27
11
22
 
12
23
  ### Changed
@@ -709,7 +720,8 @@ and this project follows [Semantic Versioning](https://semver.org/).
709
720
 
710
721
  - Initial public release of the Three.js Gaussian Splatting renderer.
711
722
 
712
- [Unreleased]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.5.1...HEAD
723
+ [Unreleased]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.5.2...HEAD
724
+ [1.5.2]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.5.1...v1.5.2
713
725
  [1.5.1]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.5.0...v1.5.1
714
726
  [1.5.0]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.4.3...v1.5.0
715
727
  [1.4.3]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.4.2...v1.4.3
package/bin/build-lod.js CHANGED
@@ -13,6 +13,7 @@ import { buildLod } from "./rad/lod.js";
13
13
  import { Pool } from "./rad/parallel.js";
14
14
  import { checkOutput, writeRad } from "./rad/rad.js";
15
15
  import { GIB, Workspace } from "./rad/storage.js";
16
+ import { syncFiles } from "./rad/writeback.js";
16
17
 
17
18
  const help = `Convert PLY or SPZ files to RAD with levels of detail (LOD).
18
19
 
@@ -37,7 +38,7 @@ Options:
37
38
  -h, --help Show this help
38
39
 
39
40
  SH4 inputs are accepted; coefficients above --max-sh are discarded.
40
- The memory target covers the whole conversion, worker threads included.
41
+ The memory target covers the conversion, worker threads and pending disk writes included.
41
42
  Requires Node.js 22.12 or later. No compilation is required.`;
42
43
 
43
44
  function parse(args) {
@@ -139,7 +140,13 @@ async function convert(input, options, kernel, cancelled) {
139
140
  `${path.basename(input, path.extname(input))}-lod.rad`,
140
141
  );
141
142
  checkOutput(output, options.chunked);
142
- const workspace = new Workspace(options.budget, options.tempDir, cancelled);
143
+ const workspace = new Workspace(
144
+ options.budget,
145
+ (files, state) =>
146
+ parentPort.postMessage({ method: "writeback", args: [files, state] }),
147
+ options.tempDir,
148
+ cancelled,
149
+ );
143
150
  const start = performance.now();
144
151
  console.log(
145
152
  `${input} -> ${output}\nMemory target: ${(options.budget / GIB).toFixed(3)} GiB`,
@@ -222,6 +229,7 @@ async function run(options, cancelled) {
222
229
 
223
230
  // Keep signal handling responsive while the conversion runs synchronous code.
224
231
  // The worker observes cancellation and unwinds its existing catch/finally blocks.
232
+ // Cleanup finishes submitted writeback before closing files and exiting.
225
233
  async function start(options) {
226
234
  const cancelled = new Int32Array(new SharedArrayBuffer(4));
227
235
  let progressLine = "";
@@ -230,6 +238,7 @@ async function start(options) {
230
238
  progressLine = "";
231
239
  };
232
240
  const print = ({ method, args }) => {
241
+ if (method === "writeback") return syncFiles(...args, cancelled);
233
242
  if (method === "progress") {
234
243
  const [line, done] = args;
235
244
  if (process.stdout.isTTY) {
@@ -249,7 +258,9 @@ async function start(options) {
249
258
  const code = signal === "SIGINT" ? 130 : 143;
250
259
  if (Atomics.compareExchange(cancelled, 0, 0, code) === 0) {
251
260
  endProgress();
252
- console.error(`${signal}: cancelling conversion and cleaning up...`);
261
+ console.error(
262
+ `${signal}: cancelling conversion; waiting for active disk I/O before cleanup...`,
263
+ );
253
264
  }
254
265
  };
255
266
  process.on("SIGINT", interrupt);
Binary file
@@ -78,10 +78,6 @@ function __wbg_get_imports() {
78
78
  const ret = new Error(getStringFromWasm0(arg0, arg1));
79
79
  return ret;
80
80
  },
81
- __wbg_new_from_slice_d85ad974cf8f6f35: function(arg0, arg1) {
82
- const ret = new Float32Array(getArrayF32FromWasm0(arg0, arg1));
83
- return ret;
84
- },
85
81
  __wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
86
82
  const ret = arg1.stack;
87
83
  const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
@@ -95,6 +91,11 @@ function __wbg_get_imports() {
95
91
  return ret;
96
92
  },
97
93
  __wbindgen_cast_0000000000000002: function(arg0, arg1) {
94
+ // Cast intrinsic for `Ref(Slice(F32)) -> NamedExternref("Float32Array")`.
95
+ const ret = getArrayF32FromWasm0(arg0, arg1);
96
+ return ret;
97
+ },
98
+ __wbindgen_cast_0000000000000003: function(arg0, arg1) {
98
99
  // Cast intrinsic for `Ref(String) -> Externref`.
99
100
  const ret = getStringFromWasm0(arg0, arg1);
100
101
  return ret;
Binary file
@@ -6,7 +6,7 @@ import { HALF } from "./math.js";
6
6
  import { WorkerGroup, clusterers } from "./parallel.js";
7
7
  import { Progress } from "./progress.js";
8
8
  import { SH_DIMS } from "./splats.js";
9
- import { PagedArray } from "./storage.js";
9
+ import { PAGE_BYTES, PagedArray } from "./storage.js";
10
10
 
11
11
  // Codebook entries at most: RAD labels are 16-bit.
12
12
  const MAX_CODES = 65536;
@@ -323,6 +323,13 @@ class Assigner extends WorkerGroup {
323
323
  }
324
324
  }
325
325
 
326
+ // Rows that fit in the cache, allowing a partial page per column. Repeated
327
+ // scans cache this prefix and bypass the rest to avoid evicting it.
328
+ function fitting(bytes, row, columns) {
329
+ const pages = Math.floor(bytes / PAGE_BYTES) - columns;
330
+ return Math.max(0, Math.floor((pages * PAGE_BYTES) / row));
331
+ }
332
+
326
333
  // Reads splats' SH as rows of half bits, the bands one after another, with
327
334
  // their weights, area × opacity as merging weighs them.
328
335
  class Rows {
@@ -330,16 +337,21 @@ class Rows {
330
337
  this.splats = splats;
331
338
  this.dims = dims;
332
339
  this.length = splats.length;
340
+ this.kept = Number.POSITIVE_INFINITY;
333
341
  this.column = new Uint16Array(JOB * SH_DIMS[SH_DIMS.length - 1]);
334
342
  this.records = new Float32Array(JOB * 16);
335
343
  }
336
344
 
345
+ keep(bytes) {
346
+ this.kept = fitting(bytes, this.dims * 2 + 64, this.splats.maxSh + 1);
347
+ }
348
+
337
349
  read(base, count, bits, weights) {
338
- const { splats, dims, column } = this;
350
+ const { splats, dims, column, kept } = this;
339
351
  let offset = 0;
340
352
  for (let band = 0; band < splats.maxSh; band++) {
341
353
  const size = SH_DIMS[band];
342
- splats.sh[band].read(base * size, column, count * size);
354
+ splats.sh[band].scan(base * size, column, count * size, kept * size);
343
355
  for (let i = 0; i < count; i++)
344
356
  for (let d = 0; d < size; d++)
345
357
  bits[i * dims + offset + d] = column[i * size + d];
@@ -349,10 +361,11 @@ class Rows {
349
361
  }
350
362
 
351
363
  weigh(base, count, weights) {
352
- const records = this.splats.records.read(
364
+ const records = this.splats.records.scan(
353
365
  base * 16,
354
366
  this.records,
355
367
  count * 16,
368
+ this.kept * 16,
356
369
  );
357
370
  for (let i = 0; i < count; i++)
358
371
  weights[i] = records[i * 16 + 15] * records[i * 16 + 3];
@@ -364,13 +377,19 @@ class Sample {
364
377
  constructor(workspace, dims) {
365
378
  this.dims = dims;
366
379
  this.length = 0;
380
+ this.kept = Number.POSITIVE_INFINITY;
367
381
  this.bits = new PagedArray(workspace, Uint16Array);
368
382
  this.weights = new PagedArray(workspace, Float64Array);
369
383
  }
370
384
 
385
+ keep(bytes) {
386
+ this.kept = fitting(bytes, this.dims * 2 + 8, 2);
387
+ }
388
+
371
389
  read(base, count, bits, weights) {
372
- this.bits.read(base * this.dims, bits, count * this.dims);
373
- this.weights.read(base, weights, count);
390
+ const { dims, kept } = this;
391
+ this.bits.scan(base * dims, bits, count * dims, kept * dims);
392
+ this.weights.scan(base, weights, count, kept);
374
393
  }
375
394
 
376
395
  dispose() {
@@ -653,11 +672,21 @@ export async function clusterSh(splats, iterations, module) {
653
672
  extra,
654
673
  Math.ceil(training.length / size),
655
674
  );
675
+ // Measure worker growth between passes: bypass reads do not allocate
676
+ // pages or trigger measurements. Leave room for labels when the final
677
+ // pass shares the training cache.
678
+ const labelPages = sampled ? 0 : Math.ceil((length * 2) / PAGE_BYTES);
679
+ const keep = () => {
680
+ workspace.adjust();
681
+ training.keep(workspace.capacity - labelPages * PAGE_BYTES);
682
+ };
683
+ keep();
656
684
  const codes = new Float64Array(count * dims);
657
685
  const sums = new Float64Array(count * dims);
658
686
  const totals = new Float64Array(count);
659
687
  const bits = new Uint16Array(dims);
660
688
  const weight = new Float64Array(1);
689
+ // Read seeds past the cached prefix directly, preserving training rows.
661
690
  for (let c = 0; c < count; c++) {
662
691
  // Spark computes the seed rows in single precision.
663
692
  const row = Math.floor(
@@ -705,6 +734,7 @@ export async function clusterSh(splats, iterations, module) {
705
734
  layout(codes, order, dims, center, blocked, norms);
706
735
  await assigner.use("codes", { codes: blocked, norms, center });
707
736
  }
737
+ keep();
708
738
  if (!last) {
709
739
  sums.fill(0);
710
740
  totals.fill(0);
package/bin/rad/heap.js CHANGED
@@ -247,7 +247,7 @@ export function sortSplats(splats, pool) {
247
247
  // rank alone keeps splats of equal size in index order.
248
248
  const items = new IndexArray(workspace);
249
249
  for (let i = 0; i < count; i++) {
250
- const key = rank(-splats.feature(i));
250
+ const key = rank(-splats.sizes.get(i));
251
251
  const words = items.page(i * 2, true);
252
252
  const offset = (i * 2) & items.mask;
253
253
  words[offset] = key;
@@ -266,5 +266,4 @@ export function sortSplats(splats, pool) {
266
266
  sorted.dispose();
267
267
  ordering.finish();
268
268
  splats.reorder(order, pool);
269
- order.dispose();
270
269
  }
package/bin/rad/lod.js CHANGED
@@ -49,21 +49,17 @@ export function buildLod(splats, lodBase, pool) {
49
49
  splats.weight.dispose();
50
50
  selection.finish(`${retainedCount} nodes retained`);
51
51
 
52
- // Ordering reads sizes at random; a copy without the rest of each record
53
- // stays cached.
54
- const sizes = new PagedArray(workspace, Float32Array);
55
- const sizing = new Progress("Reading LOD sizes", splats.length);
56
- for (let index = 0; index < splats.length; index++) {
57
- sizes.set(index, splats.feature(index));
58
- if ((index & 4095) === 0) sizing.update(index);
59
- }
60
- sizing.finish();
52
+ // Ordering reads sizes at random, from a column without the rest of each
53
+ // record, and lists them in logical order for chunkTree().
54
+ const sizes = splats.sizes;
61
55
  // A splat's logical index is its position in `order`. Its children get
62
56
  // consecutive logical indices: `first` and the `counts` - 1 after it.
63
57
  const order = new IndexArray(workspace);
64
58
  const first = new IndexArray(workspace);
65
59
  const counts = new IndexArray(workspace);
60
+ const ordered = new PagedArray(workspace, Float32Array);
66
61
  order.push(root);
62
+ ordered.push(sizes.get(root));
67
63
  // The splats to visit, by logical index, splat and size: the frontier of a
68
64
  // pass as triples of words, and a stack within it.
69
65
  let frontier = new IndexArray(workspace);
@@ -120,17 +116,22 @@ export function buildLod(splats, lodBase, pool) {
120
116
  const start = order.length;
121
117
  first.set(logical, start);
122
118
  counts.set(logical, count);
123
- for (let c = 0; c < count; c++) order.push(list[c]);
124
119
  if (top + count > logicals.length) {
125
120
  logicals = grow(logicals, top, top + count);
126
121
  indices = grow(indices, top, top + count);
127
122
  lengths = grow(lengths, top, top + count);
128
123
  }
129
- for (let c = count - 1; c >= 0; c--) {
130
- logicals[top] = start + c;
131
- indices[top] = list[c];
132
- lengths[top++] = sizes.get(list[c]);
124
+ // Each child's size is read once, at random, for both uses.
125
+ for (let c = 0; c < count; c++) {
126
+ const length = sizes.get(list[c]);
127
+ order.push(list[c]);
128
+ ordered.push(length);
129
+ const at = top + count - 1 - c;
130
+ logicals[at] = start + c;
131
+ indices[at] = list[c];
132
+ lengths[at] = length;
133
133
  }
134
+ top += count;
134
135
  }
135
136
  }
136
137
  frontier.dispose();
@@ -143,7 +144,7 @@ export function buildLod(splats, lodBase, pool) {
143
144
  sizes.dispose();
144
145
  kept.dispose();
145
146
  ordering.finish();
146
- chunkTree(splats, order, first, counts, pool);
147
+ chunkTree(splats, order, first, counts, ordered, pool);
147
148
  order.dispose();
148
149
  }
149
150
 
@@ -152,24 +153,8 @@ export function buildLod(splats, lodBase, pool) {
152
153
  function mergeLevels(splats, pool) {
153
154
  const initial = splats.length;
154
155
  const workspace = splats.workspace;
155
- // Merged splats lie within the leaves' bounds. merge() reads the leaves'
156
- // weights.
157
- const bounds = {
158
- min: [0, 1, 2].map(() => Number.POSITIVE_INFINITY),
159
- max: [0, 1, 2].map(() => Number.NEGATIVE_INFINITY),
160
- };
161
- const record = new Float64Array(16);
162
- const preparing = new Progress("Preparing LOD bounds", initial);
163
- for (let i = 0; i < initial; i++) {
164
- splats.read(i, record);
165
- for (let d = 0; d < 3; d++) {
166
- bounds.min[d] = Math.min(bounds.min[d], record[d]);
167
- bounds.max[d] = Math.max(bounds.max[d], record[d]);
168
- }
169
- splats.weight.set(i, record[15] * record[3]);
170
- if ((i & 4095) === 0) preparing.update(i);
171
- }
172
- preparing.finish();
156
+ // Merged splats lie within the leaves' bounds.
157
+ const { bounds, sizes } = splats;
173
158
  const removed = new PagedArray(workspace, Uint8Array);
174
159
  const similarities = new Similarities(splats);
175
160
  let active = new Heap(workspace);
@@ -187,7 +172,7 @@ function mergeLevels(splats, pool) {
187
172
  const added = new Cells(18);
188
173
  const near = new Cells(20);
189
174
  for (
190
- let level = Math.ceil(Math.log2(Math.max(splats.feature(0), 0.000001)));
175
+ let level = Math.ceil(Math.log2(Math.max(sizes.get(0), 0.000001)));
191
176
  ;
192
177
  level++
193
178
  ) {
@@ -199,14 +184,14 @@ function mergeLevels(splats, pool) {
199
184
  let high = initial;
200
185
  while (low < high) {
201
186
  const middle = Math.floor((low + high) / 2);
202
- if (splats.feature(middle) <= step) low = middle + 1;
187
+ if (sizes.get(middle) <= step) low = middle + 1;
203
188
  else high = middle;
204
189
  }
205
190
  const { grid, queue } = Progress.inline(() => {
206
191
  const adding = new Progress(`Preparing level ${level}`, low - frontier);
207
192
  const previous = frontier;
208
193
  while (frontier < low) {
209
- active.push(frontier, -splats.feature(frontier));
194
+ active.push(frontier, -sizes.get(frontier));
210
195
  frontier++;
211
196
  if ((frontier & 4095) === 0) adding.update(frontier - previous);
212
197
  }
@@ -323,7 +308,7 @@ function mergeLevels(splats, pool) {
323
308
  removed.set(other, 1);
324
309
  grid.remove(self);
325
310
  grid.remove(best);
326
- const feature = splats.feature(merged);
311
+ const feature = sizes.get(merged);
327
312
  if (feature > step) next.push(merged, -feature);
328
313
  else {
329
314
  const terms =
@@ -404,8 +389,8 @@ function sort(values, count) {
404
389
 
405
390
  // Lays out the tree in chunks of 65536 splats and moves every column into
406
391
  // that order. Children become consecutive rows: childStart is then the row of
407
- // a splat's first child.
408
- function chunkTree(splats, order, first, counts, pool) {
392
+ // a splat's first child. `sizes` are the splats' sizes in logical order.
393
+ function chunkTree(splats, order, first, counts, sizes, pool) {
409
394
  const workspace = splats.workspace;
410
395
  const nodes = splats.length;
411
396
  for (const column of [splats.childStart, splats.childCount, splats.children])
@@ -426,13 +411,6 @@ function chunkTree(splats, order, first, counts, pool) {
426
411
  "Reordering LOD nodes",
427
412
  );
428
413
  logical.dispose();
429
- const sizes = new PagedArray(workspace, Float32Array);
430
- const sizing = new Progress("Reading chunk sizes", order.length);
431
- for (let index = 0; index < order.length; index++) {
432
- sizes.set(index, splats.feature(index));
433
- if ((index & 4095) === 0) sizing.update(index);
434
- }
435
- sizing.finish();
436
414
  // The row of each splat's first child.
437
415
  const starts = new PagedArray(workspace, Float64Array);
438
416
  const indices = new IndexArray(workspace);
@@ -629,28 +607,31 @@ function chunkTree(splats, order, first, counts, pool) {
629
607
  "Reordering child offsets",
630
608
  );
631
609
  chunked.dispose();
632
- // The SH coefficients are still in node order.
633
- const shOrder = new Progress("Collecting LOD SH order", indices.length);
634
- for (let i = 0; i < indices.length; i++) {
635
- indices.set(i, order.get(indices.get(i)));
636
- if ((i & 4095) === 0) shOrder.update(i);
637
- }
638
- shOrder.finish();
639
- const nodeOrder = new Permutation(
640
- workspace,
641
- indices,
642
- nodes,
643
- "Preparing LOD SH order",
644
- );
645
- for (let band = 0; band < splats.maxSh; band++)
646
- splats.sh[band] = nodeOrder.apply(
647
- splats.sh[band],
648
- SH_DIMS[band],
649
- pool,
650
- false,
651
- `Reordering LOD SH${band + 1}`,
610
+ // The SH coefficients are still in the order of mergeSh().
611
+ if (splats.maxSh) {
612
+ const shOrder = new Progress("Collecting LOD SH order", indices.length);
613
+ for (let i = 0; i < indices.length; i++) {
614
+ indices.set(i, splats.shRow(order.get(indices.get(i))));
615
+ if ((i & 4095) === 0) shOrder.update(i);
616
+ }
617
+ shOrder.finish();
618
+ splats.shOrder.dispose();
619
+ const nodeOrder = new Permutation(
620
+ workspace,
621
+ indices,
622
+ nodes,
623
+ "Preparing LOD SH order",
652
624
  );
653
- nodeOrder.dispose();
625
+ for (let band = 0; band < splats.maxSh; band++)
626
+ splats.sh[band] = nodeOrder.apply(
627
+ splats.sh[band],
628
+ SH_DIMS[band],
629
+ pool,
630
+ false,
631
+ `Reordering LOD SH${band + 1}`,
632
+ );
633
+ nodeOrder.dispose();
634
+ }
654
635
  first.dispose();
655
636
  splats.length = indices.length;
656
637
  indices.dispose();
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) {