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/CHANGELOG.md CHANGED
@@ -7,28 +7,45 @@ 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
+
21
+ ## [1.5.1] - 2026-10-27
22
+
23
+ ### Changed
24
+
25
+ - `gsl-build-lod` SH codebooks now match the original view-dependent color of large models more closely. Conversion takes slightly longer.
26
+
10
27
  ## [1.5.0] - 2026-10-27
11
28
 
12
29
  ### Added
13
30
 
14
- - Added `sources` to `RadStreamScheduler` and `SogStreamScheduler` to stream several inputs with one detail selection. The inputs share the splat budget and loading concurrency; RAD inputs also share both memory budgets, and each uses as much memory per Splat as the highest SH degree among them, limited by `maxSh`. See [RAD streaming](docs/RadStreamScheduler.md#multiple-inputs) and [SOG streaming](docs/SogStreamScheduler.md#multiple-inputs).
31
+ - Added `sources` to `RadStreamScheduler` and `SogStreamScheduler` to stream several inputs with one detail selection. The inputs share the splat budget and loading concurrency; RAD inputs also share both memory budgets; inputs with different SH degrees all use the memory of the highest one, up to `maxSh`. See [RAD streaming](docs/RadStreamScheduler.md#multiple-inputs) and [SOG streaming](docs/SogStreamScheduler.md#multiple-inputs).
15
32
  - Added an `environment` option to `SogStreamScheduler` and each of its `sources`. Set it to `false` to skip that input's environment.
16
- - Added `maxSh` to `RadStreamScheduler` and `SogStreamScheduler` to limit SH decoding, storage, and rendering. Set it at creation to `0`–`3` (default `3`) to reduce SH memory use; `0` keeps only the base color. RAD page budgets account for the reduced storage, and the built-in SOG loader skips higher-order SH assets at `0`.
17
- - Added a loading-time `maxSh` option to `SplatMesh` and `Splats`, and a `SplatLoader.maxSh` setting (`0`–`4`, default `4`). PLY, SPZ, SOG, and RAD loads omit higher SH bands from CPU arrays and GPU source textures.
18
- - Added default SH codebook compression to `gsl-build-lod`: up to 65,536 shared SH entries and a 16-bit index per splat. Compression can reduce file size at the cost of view-dependent detail; small models may grow. Use `--cluster-sh=<n>` to change the default 10 refinement iterations or `--no-cluster-sh` to store coefficients directly. Clustering uses CPU workers within the memory target. See [SH codebook](docs/BuildRad.md#sh-codebook).
33
+ - Added `maxSh` to `RadStreamScheduler` and `SogStreamScheduler` to limit SH decoding, storage, and rendering. Set it at creation to `0`–`3` (default `3`) to reduce memory use; `0` keeps only the base color, and streamed SOG then also skips downloading SH data.
34
+ - Added a loading-time `maxSh` option to `SplatMesh` and `Splats`, and a `SplatLoader.maxSh` setting (`0`–`4`, default `4`). Lower values reduce memory use.
35
+ - Added SH codebook compression to `gsl-build-lod`, on by default: splats share up to 65,536 SH entries. This can make large files much smaller at the cost of some view-dependent detail; small models may grow. Use `--cluster-sh=<n>` to change the default 10 refinement iterations or `--no-cluster-sh` to store SH without a codebook. Clustering stays within `--memory-budget`. See [SH codebook](docs/BuildRad.md#sh-codebook).
19
36
 
20
37
  ### Changed
21
38
 
22
39
  - Raised the minimum Node.js version to 22.12 for the package and `gsl-build-lod`.
23
40
  - `SplatMesh.maxSh` is now read-only and set through its constructor options, matching the streaming schedulers. Assigning `mesh.maxSh` now throws a `TypeError` in strict-mode code, including ES modules. Create a new mesh to change the limit. The viewer's Max SH control has been removed.
24
- - RAD streaming now uses fixed 65,536-splat page slots. Smaller chunks use a full slot; chunks larger than 65,536 splats are rejected during initialization.
41
+ - RAD streaming no longer accepts files with chunks of more than 65,536 splats. Files from `gsl-build-lod` are unaffected.
25
42
  - RAD and SOG schedulers now create their own `THREE.Group`; the `group` constructor option has been removed. Add and transform `streaming.group` instead.
26
- - RAD and SOG streaming upload written texture layers before their batches are first displayed. Each scheduler limits pending source texture uploads to 24 MiB until rendering submits them, preventing skipped renders from accumulating larger uploads. A larger SOG region proceeds alone.
27
- - `gsl-build-lod` now quantizes SH in fixed `1/128` steps instead of using adaptive per-band ranges. Both codebook and direct output clip coefficients to `[-127/128, 127/128]`. With `--no-cluster-sh`, original SPZ splats retain their SH coefficients except `-1`, which clips to `-127/128`.
43
+ - RAD and SOG streamed data now reaches the GPU only when you render. Keep rendering, not just calling `update()`, while waiting for `firstRenderable`.
44
+ - With `--no-cluster-sh`, `gsl-build-lod` now keeps the view-dependent color of SPZ inputs almost exactly.
28
45
 
29
46
  ### Fixed
30
47
 
31
- - Fixed delayed cache memory reclamation in `gsl-build-lod` when reserving memory for worker threads.
48
+ - Fixed `gsl-build-lod` briefly using more memory than `--memory-budget` when starting worker threads.
32
49
 
33
50
  ## [1.4.3] - 2026-10-25
34
51
 
@@ -703,7 +720,10 @@ and this project follows [Semantic Versioning](https://semver.org/).
703
720
 
704
721
  - Initial public release of the Three.js Gaussian Splatting renderer.
705
722
 
706
- [Unreleased]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.4.3...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
725
+ [1.5.1]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.5.0...v1.5.1
726
+ [1.5.0]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.4.3...v1.5.0
707
727
  [1.4.3]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.4.2...v1.4.3
708
728
  [1.4.2]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.4.1...v1.4.2
709
729
  [1.4.1]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.4.0...v1.4.1
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,18 +6,21 @@ 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;
13
13
  // Training reads SAMPLE_SHARE of the splats, and at least SAMPLES_PER_CODE
14
14
  // per entry; larger models train on a sample by weight, and only the final
15
- // assignment reads all. Entries start at evenly spaced training splats,
16
- // best at heavy ones: on Elevator (3.9M splats after LOD) 16 per entry fit
17
- // better than all splats, and on C-NorthSouth (111M) 5% fit 8.5% closer
18
- // than 16 per entry, for 5% more conversion time.
19
- const SAMPLES_PER_CODE = 16;
20
- const SAMPLE_SHARE = 0.05;
15
+ // assignment reads all. Entries start at evenly spaced training splats.
16
+ // With codebooks shrunk to keep 16 per entry at 5%, renders of Elevator and
17
+ // JunkShop came 5–14% closer to the uncompressed SH at 10% than at 5%; on
18
+ // C-NorthSouth (111M splats after LOD) clustering took about 185 s, not 170.
19
+ // On Elevator (3.9M splats after LOD), 64 per entry, here all splats, came
20
+ // 5.8% closer than 16 at full detail, with 41% fewer pixels off by more than
21
+ // 32 levels in LOD views, in 18 s instead of 6 s.
22
+ const SAMPLES_PER_CODE = 64;
23
+ const SAMPLE_SHARE = 0.1;
21
24
  // Splats per job of a thread, and per call of the kernel.
22
25
  const JOB = 4096;
23
26
  const BATCH = 1024;
@@ -320,6 +323,13 @@ class Assigner extends WorkerGroup {
320
323
  }
321
324
  }
322
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
+
323
333
  // Reads splats' SH as rows of half bits, the bands one after another, with
324
334
  // their weights, area × opacity as merging weighs them.
325
335
  class Rows {
@@ -327,16 +337,21 @@ class Rows {
327
337
  this.splats = splats;
328
338
  this.dims = dims;
329
339
  this.length = splats.length;
340
+ this.kept = Number.POSITIVE_INFINITY;
330
341
  this.column = new Uint16Array(JOB * SH_DIMS[SH_DIMS.length - 1]);
331
342
  this.records = new Float32Array(JOB * 16);
332
343
  }
333
344
 
345
+ keep(bytes) {
346
+ this.kept = fitting(bytes, this.dims * 2 + 64, this.splats.maxSh + 1);
347
+ }
348
+
334
349
  read(base, count, bits, weights) {
335
- const { splats, dims, column } = this;
350
+ const { splats, dims, column, kept } = this;
336
351
  let offset = 0;
337
352
  for (let band = 0; band < splats.maxSh; band++) {
338
353
  const size = SH_DIMS[band];
339
- splats.sh[band].read(base * size, column, count * size);
354
+ splats.sh[band].scan(base * size, column, count * size, kept * size);
340
355
  for (let i = 0; i < count; i++)
341
356
  for (let d = 0; d < size; d++)
342
357
  bits[i * dims + offset + d] = column[i * size + d];
@@ -346,10 +361,11 @@ class Rows {
346
361
  }
347
362
 
348
363
  weigh(base, count, weights) {
349
- const records = this.splats.records.read(
364
+ const records = this.splats.records.scan(
350
365
  base * 16,
351
366
  this.records,
352
367
  count * 16,
368
+ this.kept * 16,
353
369
  );
354
370
  for (let i = 0; i < count; i++)
355
371
  weights[i] = records[i * 16 + 15] * records[i * 16 + 3];
@@ -361,13 +377,19 @@ class Sample {
361
377
  constructor(workspace, dims) {
362
378
  this.dims = dims;
363
379
  this.length = 0;
380
+ this.kept = Number.POSITIVE_INFINITY;
364
381
  this.bits = new PagedArray(workspace, Uint16Array);
365
382
  this.weights = new PagedArray(workspace, Float64Array);
366
383
  }
367
384
 
385
+ keep(bytes) {
386
+ this.kept = fitting(bytes, this.dims * 2 + 8, 2);
387
+ }
388
+
368
389
  read(base, count, bits, weights) {
369
- this.bits.read(base * this.dims, bits, count * this.dims);
370
- 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);
371
393
  }
372
394
 
373
395
  dispose() {
@@ -587,8 +609,8 @@ class Grouper {
587
609
  // weighted k-means as Spark does: entries start at evenly spaced splats,
588
610
  // each of `iterations` moves them to the weighted mean of their splats, an
589
611
  // entry without any stays, and a final pass labels each splat with its
590
- // nearest entry. Weights are area × opacity. Training uses 5% of the
591
- // splats, at least 16 per entry, sampled in proportion to their weights;
612
+ // nearest entry. Weights are area × opacity. Training uses 10% of the
613
+ // splats, at least 64 per entry, sampled in proportion to their weights;
592
614
  // with many entries, passes search only groups of them. Returns the
593
615
  // entries, `count` rows of `dims` SH coefficients, and the labels.
594
616
  export async function clusterSh(splats, iterations, module) {
@@ -650,11 +672,21 @@ export async function clusterSh(splats, iterations, module) {
650
672
  extra,
651
673
  Math.ceil(training.length / size),
652
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();
653
684
  const codes = new Float64Array(count * dims);
654
685
  const sums = new Float64Array(count * dims);
655
686
  const totals = new Float64Array(count);
656
687
  const bits = new Uint16Array(dims);
657
688
  const weight = new Float64Array(1);
689
+ // Read seeds past the cached prefix directly, preserving training rows.
658
690
  for (let c = 0; c < count; c++) {
659
691
  // Spark computes the seed rows in single precision.
660
692
  const row = Math.floor(
@@ -702,6 +734,7 @@ export async function clusterSh(splats, iterations, module) {
702
734
  layout(codes, order, dims, center, blocked, norms);
703
735
  await assigner.use("codes", { codes: blocked, norms, center });
704
736
  }
737
+ keep();
705
738
  if (!last) {
706
739
  sums.fill(0);
707
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();