gaussian-splat-lite 1.4.2 → 1.5.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +1 -1
  4. package/bin/build-lod.js +56 -5
  5. package/bin/decoder/cluster.wasm +0 -0
  6. package/bin/rad/cluster.js +756 -0
  7. package/bin/rad/parallel.js +84 -6
  8. package/bin/rad/rad.js +107 -123
  9. package/bin/rad/splats.js +27 -10
  10. package/bin/rad/storage.js +4 -1
  11. package/dist/gaussian-splat-lite.cjs +777 -609
  12. package/dist/gaussian-splat-lite.cjs.map +1 -1
  13. package/dist/gaussian-splat-lite.min.cjs +126 -126
  14. package/dist/gaussian-splat-lite.min.cjs.map +1 -1
  15. package/dist/gaussian-splat-lite.module.js +777 -609
  16. package/dist/gaussian-splat-lite.module.js.map +1 -1
  17. package/dist/gaussian-splat-lite.module.min.js +3391 -3254
  18. package/dist/gaussian-splat-lite.module.min.js.map +1 -1
  19. package/dist/types/data/IndexedSplats.d.cts +12 -4
  20. package/dist/types/data/IndexedSplats.d.ts +12 -4
  21. package/dist/types/data/RadPagedSplats.d.cts +3 -23
  22. package/dist/types/data/RadPagedSplats.d.ts +3 -23
  23. package/dist/types/data/Splats.d.cts +4 -6
  24. package/dist/types/data/Splats.d.ts +4 -6
  25. package/dist/types/data/defines.d.cts +5 -4
  26. package/dist/types/data/defines.d.ts +5 -4
  27. package/dist/types/data/pendingUploads.d.cts +14 -0
  28. package/dist/types/data/pendingUploads.d.ts +14 -0
  29. package/dist/types/data/splatData.d.cts +1 -1
  30. package/dist/types/data/splatData.d.ts +1 -1
  31. package/dist/types/data/splatRange.d.cts +1 -1
  32. package/dist/types/data/splatRange.d.ts +1 -1
  33. package/dist/types/index.d.cts +2 -0
  34. package/dist/types/index.d.ts +2 -0
  35. package/dist/types/loaders/SplatLoader.d.cts +2 -0
  36. package/dist/types/loaders/SplatLoader.d.ts +2 -0
  37. package/dist/types/loaders/loadSplatData.d.cts +3 -0
  38. package/dist/types/loaders/loadSplatData.d.ts +3 -0
  39. package/dist/types/loaders/loadTypes.d.cts +3 -0
  40. package/dist/types/loaders/loadTypes.d.ts +3 -0
  41. package/dist/types/loaders/morton.d.cts +6 -3
  42. package/dist/types/loaders/morton.d.ts +6 -3
  43. package/dist/types/loaders/postDecode/protocol.d.cts +1 -0
  44. package/dist/types/loaders/postDecode/protocol.d.ts +1 -0
  45. package/dist/types/loaders/rad.d.cts +5 -5
  46. package/dist/types/loaders/rad.d.ts +5 -5
  47. package/dist/types/loaders/stream/rad-stream/RadSelectionState.d.cts +2 -2
  48. package/dist/types/loaders/stream/rad-stream/RadSelectionState.d.ts +2 -2
  49. package/dist/types/loaders/stream/rad-stream/RadStreamLoader.d.cts +42 -16
  50. package/dist/types/loaders/stream/rad-stream/RadStreamLoader.d.ts +42 -16
  51. package/dist/types/loaders/stream/rad-stream/RadStreamScheduler.d.cts +6 -5
  52. package/dist/types/loaders/stream/rad-stream/RadStreamScheduler.d.ts +6 -5
  53. package/dist/types/loaders/stream/rad-stream/radFade.d.cts +2 -2
  54. package/dist/types/loaders/stream/rad-stream/radFade.d.ts +2 -2
  55. package/dist/types/loaders/stream/rad-stream/radLod.d.cts +11 -3
  56. package/dist/types/loaders/stream/rad-stream/radLod.d.ts +11 -3
  57. package/dist/types/loaders/stream/rad-stream/workerHandlers.d.cts +9 -3
  58. package/dist/types/loaders/stream/rad-stream/workerHandlers.d.ts +9 -3
  59. package/dist/types/loaders/stream/sog-stream/SogStreamBatch.d.cts +0 -1
  60. package/dist/types/loaders/stream/sog-stream/SogStreamBatch.d.ts +0 -1
  61. package/dist/types/loaders/stream/sog-stream/SogStreamLoader.d.cts +21 -5
  62. package/dist/types/loaders/stream/sog-stream/SogStreamLoader.d.ts +21 -5
  63. package/dist/types/loaders/stream/sog-stream/SogStreamScheduler.d.cts +21 -4
  64. package/dist/types/loaders/stream/sog-stream/SogStreamScheduler.d.ts +21 -4
  65. package/dist/types/loaders/stream/sog-stream/sogLod.d.cts +13 -11
  66. package/dist/types/loaders/stream/sog-stream/sogLod.d.ts +13 -11
  67. package/dist/types/loaders/stream/sog-stream/sogVisibility.d.cts +13 -7
  68. package/dist/types/loaders/stream/sog-stream/sogVisibility.d.ts +13 -7
  69. package/dist/types/loaders/stream/sog-stream/workerHandlers.d.cts +8 -13
  70. package/dist/types/loaders/stream/sog-stream/workerHandlers.d.ts +8 -13
  71. package/dist/types/loaders/stream/streamOptions.d.cts +9 -3
  72. package/dist/types/loaders/stream/streamOptions.d.ts +9 -3
  73. package/dist/types/scene/SplatMesh.d.cts +5 -4
  74. package/dist/types/scene/SplatMesh.d.ts +5 -4
  75. package/docs/BuildRad.md +23 -1
  76. package/docs/RadStreamScheduler.md +34 -9
  77. package/docs/SogStreamScheduler.md +47 -11
  78. package/docs/SplatFileType.md +1 -1
  79. package/docs/SplatLoader.md +1 -0
  80. package/docs/SplatMesh.md +5 -4
  81. package/docs/Splats.md +2 -3
  82. package/package.json +2 -2
@@ -9,13 +9,12 @@ import {
9
9
  import { Lookup, NONE } from "./grid.js";
10
10
  import { HALF, halfBits } from "./math.js";
11
11
  import {
12
- EMPTY,
13
12
  INVALID,
14
- KEEP,
15
13
  LATER,
16
14
  Merger,
17
15
  SH_DIMS,
18
16
  Similarities,
17
+ filterRecord,
19
18
  refresh,
20
19
  validRecord,
21
20
  } from "./splats.js";
@@ -60,6 +59,10 @@ const WORKER_BYTES = 40 * 1024 ** 2;
60
59
  const ENCODER_BYTES = 52 * 1024 ** 2;
61
60
  const MAIN_BYTES = 64 * 1024 ** 2;
62
61
  const CHUNKS_BYTES = 24 * 1024 ** 2;
62
+ // What an SH clustering worker thread takes besides its kernel and jobs,
63
+ // and the cache that clustering keeps: it reads rows in order.
64
+ const CLUSTER_BYTES = 32 * 1024 ** 2;
65
+ const STREAM_BYTES = 64 * 1024 ** 2;
63
66
  export const YOUNG_MB = 8;
64
67
 
65
68
  // Worker threads with `bytes` each: at most 8, using at most `share` of the
@@ -88,6 +91,79 @@ export function encoders(workspace, chunks) {
88
91
  return count;
89
92
  }
90
93
 
94
+ // Makes room for SH clustering worker threads that take `bytes` each besides
95
+ // the thread itself, and `extra` bytes on this thread, and returns how many
96
+ // there are, at most `jobs`. They only compute, so they may use every other
97
+ // core and all the room but a small cache.
98
+ export function clusterers(workspace, bytes, extra, jobs) {
99
+ workspace.settle();
100
+ const each = CLUSTER_BYTES + bytes;
101
+ const count = Math.max(
102
+ 0,
103
+ Math.min(
104
+ availableParallelism() - 1,
105
+ jobs,
106
+ Math.floor((workspace.room - extra - STREAM_BYTES) / each),
107
+ ),
108
+ );
109
+ workspace.expect(count * each + extra);
110
+ return count;
111
+ }
112
+
113
+ // Ordered request/reply workers for clustering and encoding. Their callers
114
+ // schedule the jobs and close the workers; without workers, requests use the
115
+ // local handler. Keep workers referenced so exit events can reject requests.
116
+ export class WorkerGroup {
117
+ constructor(url, count, workerData, local, name) {
118
+ this.local = local;
119
+ this.workers = Array.from({ length: count }, () => {
120
+ const worker = new Worker(url, {
121
+ workerData,
122
+ resourceLimits: {
123
+ maxOldGenerationSizeMb: 64,
124
+ maxYoungGenerationSizeMb: YOUNG_MB,
125
+ },
126
+ });
127
+ worker.waiting = [];
128
+ worker.on("message", (result) => worker.waiting.shift().resolve(result));
129
+ // A failed worker fails its pending and later requests.
130
+ const fail = (error) => {
131
+ worker.failed ??= error;
132
+ for (const job of worker.waiting.splice(0)) job.reject(worker.failed);
133
+ };
134
+ worker.on("error", fail);
135
+ worker.on("exit", (code) =>
136
+ fail(new Error(`${name} worker exited (${code})`)),
137
+ );
138
+ return worker;
139
+ });
140
+ }
141
+
142
+ get threads() {
143
+ return this.workers.length ? this.workers : [undefined];
144
+ }
145
+
146
+ request(worker, message, transfer = []) {
147
+ if (!worker) return Promise.resolve(this.local.handle(message));
148
+ if (worker.failed) return Promise.reject(worker.failed);
149
+ return new Promise((resolve, reject) => {
150
+ worker.waiting.push({ resolve, reject });
151
+ worker.postMessage(message, transfer);
152
+ });
153
+ }
154
+
155
+ broadcast(message) {
156
+ return Promise.all(
157
+ this.threads.map((worker) => this.request(worker, message)),
158
+ );
159
+ }
160
+
161
+ // Resolves once the workers have stopped and their memory is free.
162
+ close() {
163
+ return Promise.all(this.workers.map((worker) => worker.terminate()));
164
+ }
165
+ }
166
+
91
167
  const shared = (Type, size) =>
92
168
  new Type(new SharedArrayBuffer(size * Type.BYTES_PER_ELEMENT));
93
169
 
@@ -231,14 +307,15 @@ export class Pool {
231
307
  this.run(COPY, count, 1024);
232
308
  }
233
309
 
234
- // Refreshes and checks `count` splats from `base` in place, setting KEEP,
235
- // INVALID, EMPTY or LATER in `results`; see Splats.retain().
236
- check(splats, base, count) {
310
+ // Refreshes, checks and optionally crops `count` splats from `base` in
311
+ // place, storing their filter states in `results`; see Splats.retain().
312
+ check(splats, base, count, withinDist) {
237
313
  this.share({
238
314
  splatRecords: splats.records.directory,
239
315
  splatSh: splats.sh
240
316
  .slice(0, splats.maxSh)
241
317
  .map((column) => column.directory),
318
+ withinDist,
242
319
  });
243
320
  Atomics.store(this.control, BASE, base);
244
321
  this.run(CHECK, count, 1024);
@@ -370,6 +447,7 @@ class Task {
370
447
  if (state.column)
371
448
  this.column = new SharedColumn(views, state.column, Uint16Array);
372
449
  if (state.splatRecords) {
450
+ this.withinDist = state.withinDist;
373
451
  this.splatRecords = new SharedColumn(
374
452
  views,
375
453
  state.splatRecords,
@@ -450,7 +528,7 @@ class Task {
450
528
  for (let d = 0; d < dims; d++)
451
529
  if (!Number.isFinite(HALF[row[at + d]])) return INVALID;
452
530
  }
453
- return record[3] > 0 && record[14] > 0 ? KEEP : EMPTY;
531
+ return filterRecord(record, this.withinDist);
454
532
  }
455
533
 
456
534
  copy(from, to) {
package/bin/rad/rad.js CHANGED
@@ -10,20 +10,22 @@ import {
10
10
  statSync,
11
11
  } from "node:fs";
12
12
  import path from "node:path";
13
- import {
14
- Worker,
15
- isMainThread,
16
- parentPort,
17
- workerData,
18
- } from "node:worker_threads";
13
+ import { isMainThread, parentPort, workerData } from "node:worker_threads";
19
14
  import { deflateRawSync } from "node:zlib";
20
15
  import { HALF, half, halfBits, opacityRadius } from "./math.js";
21
- import { YOUNG_MB, encoders } from "./parallel.js";
16
+ import { WorkerGroup, encoders } from "./parallel.js";
22
17
  import { Progress } from "./progress.js";
23
18
  import { SH_DIMS } from "./splats.js";
24
19
  import { readAll, removePath, writeAll } from "./storage.js";
25
20
 
26
21
  const CHUNK = 65536;
22
+ // Fixed s8 range with SPZ's 1/128 SH step; -1 clips to -127/128.
23
+ const SH_MAX = 127 / 128;
24
+ function encodeSh(value) {
25
+ const magnitude = Math.min(127, Math.round(Math.abs(value) * 128));
26
+ return (value < 0 ? -magnitude : magnitude) & 255;
27
+ }
28
+ const SH_CODES = Uint8Array.from(HALF, encodeSh);
27
29
  function header(magic, meta) {
28
30
  const json = Buffer.from(JSON.stringify(meta));
29
31
  const result = Buffer.alloc(8 + Math.ceil(json.length / 8) * 8);
@@ -99,18 +101,15 @@ function alpha(value) {
99
101
  ? half(Math.min(2, 1 + 0.25 * (opacityRadius(value) - 1)))
100
102
  : value;
101
103
  }
102
- // What the encoding ranges depend on, counted from gathered chunks.
104
+ // The base attributes that determine the encoding ranges.
103
105
  class Counts {
104
106
  constructor() {
105
107
  this.colors = new Histogram();
106
108
  this.scales = new Histogram();
107
- this.sh = SH_DIMS.map(() => new Histogram());
108
109
  this.alphaF16 = false;
109
110
  }
110
- add(chunk) {
111
- const { records, count } = chunk;
112
- for (let i = 0; i < count; i++) {
113
- const offset = i * 16;
111
+ add(records) {
112
+ for (let offset = 0; offset < records.length; offset += 16) {
114
113
  this.alphaF16 ||= alpha(records[offset + 3]) > 1;
115
114
  for (let d = 4; d <= 6; d++)
116
115
  this.colors.add(halfBits(records[offset + d]));
@@ -122,19 +121,15 @@ class Counts {
122
121
  );
123
122
  this.scales.add(halfBits(Math.max(a, b, c)));
124
123
  }
125
- for (let band = 0; band < chunk.sh.length; band++)
126
- for (const bits of chunk.sh[band]) this.sh[band].add(bits);
127
124
  }
128
125
  merge(other) {
129
126
  this.colors.merge(other.colors);
130
127
  this.scales.merge(other.scales);
131
- for (let band = 0; band < SH_DIMS.length; band++)
132
- this.sh[band].merge(other.sh[band]);
133
128
  this.alphaF16 ||= other.alphaF16;
134
129
  }
135
130
  }
136
131
 
137
- function encoding(counts, maxSh) {
132
+ function encoding(counts) {
138
133
  const { colors, scales } = counts;
139
134
  const rgbMin = Math.min(0, colors.percentile(0.01));
140
135
  const rgbMax = Math.max(1, colors.percentile(0.99));
@@ -143,69 +138,67 @@ function encoding(counts, maxSh) {
143
138
  Math.max(Math.log(1e-30), scales.percentile(0.01)),
144
139
  );
145
140
  const lnScaleMax = Math.max(9, scales.percentile(0.99));
146
- const limits = [1, 1, 1];
147
- for (let band = 0; band < maxSh; band++) {
148
- const histogram = counts.sh[band];
149
- limits[band] = Math.max(
150
- 1,
151
- Math.abs(histogram.percentile(0.05)),
152
- Math.abs(histogram.percentile(0.95)),
153
- );
154
- }
155
- // The s8 code of each half value, per band.
156
- const sh = limits.map((limit) => {
157
- const codes = new Uint8Array(65536);
158
- for (let bits = 0; bits < 65536; bits++)
159
- codes[bits] =
160
- signedRound(Math.max(-127, Math.min(127, (HALF[bits] / limit) * 127))) &
161
- 255;
162
- return codes;
163
- });
164
141
  return {
165
142
  alphaF16: counts.alphaF16,
166
143
  rgbF16: rgbMin < -1 || rgbMax > 2,
167
- sh,
168
144
  scaleF16: lnScaleMax - lnScaleMin > 25,
169
- limits,
170
145
  meta: {
171
146
  rgbMin,
172
147
  rgbMax,
173
148
  lnScaleMin,
174
149
  lnScaleMax,
175
- sh1Max: limits[0],
176
- sh2Max: limits[1],
177
- sh3Max: limits[2],
150
+ sh1Max: SH_MAX,
151
+ sh2Max: SH_MAX,
152
+ sh3Max: SH_MAX,
178
153
  lodOpacity: true,
179
154
  },
180
155
  };
181
156
  }
182
157
  const unorm = (value, min, max) =>
183
158
  Math.round(Math.max(0, Math.min(255, ((value - min) / (max - min)) * 255)));
184
- const signedRound = (value) =>
185
- value < 0 ? -Math.round(-value) : Math.round(value);
186
159
 
187
- // The columns of the splats from `base` on that encodeChunk() reads, and
188
- // Counts without the children.
189
- function gather(splats, base, count, children = true) {
160
+ // The columns encodeChunk() reads. With a codebook, labels replace the SH.
161
+ function gather(splats, base, count, codebook) {
190
162
  return {
191
163
  base,
192
164
  count,
165
+ maxSh: splats.maxSh,
193
166
  records: splats.records.read(base * 16, new Float32Array(count * 16)),
194
- sh: splats.sh
195
- .slice(0, splats.maxSh)
196
- .map((column, band) =>
197
- column.read(
198
- base * SH_DIMS[band],
199
- new Uint16Array(count * SH_DIMS[band]),
200
- ),
201
- ),
202
- ...(children && {
203
- counts: splats.childCount.read(base, new Uint32Array(count)),
204
- starts: splats.childStart.read(base, new Float64Array(count)),
167
+ sh: codebook
168
+ ? []
169
+ : splats.sh
170
+ .slice(0, splats.maxSh)
171
+ .map((column, band) =>
172
+ column.read(
173
+ base * SH_DIMS[band],
174
+ new Uint16Array(count * SH_DIMS[band]),
175
+ ),
176
+ ),
177
+ ...(codebook && {
178
+ labels: codebook.labels.read(base, new Uint16Array(count)),
205
179
  }),
180
+ counts: splats.childCount.read(base, new Uint32Array(count)),
181
+ starts: splats.childStart.read(base, new Float64Array(count)),
206
182
  };
207
183
  }
208
184
 
185
+ // The codebook's entries as s8 codes of each band, coefficient by coefficient.
186
+ function encodeCodes(codebook, maxSh) {
187
+ const { count, dims, codes } = codebook;
188
+ const bands = [];
189
+ let offset = 0;
190
+ for (let band = 0; band < maxSh; band++) {
191
+ const size = SH_DIMS[band];
192
+ const bytes = new Uint8Array(count * size);
193
+ for (let d = 0; d < size; d++)
194
+ for (let c = 0; c < count; c++)
195
+ bytes[d * count + c] = encodeSh(codes[c * dims + offset + d]);
196
+ bands.push(bytes);
197
+ offset += size;
198
+ }
199
+ return bands;
200
+ }
201
+
209
202
  // Raw property bytes, reused: each property is compressed before the next.
210
203
  const scratch = [Buffer.alloc(0), Buffer.alloc(0)];
211
204
  function raw(size, slot = 0) {
@@ -326,18 +319,25 @@ function encodeChunk(chunk, encoding) {
326
319
  bytes[i * 3 + 2] = unorm(theta, 0, Math.PI);
327
320
  }
328
321
  property("orientation", "oct88r8", bytes);
322
+ if (chunk.labels) {
323
+ // The first chunk holds the codebook for all.
324
+ for (let band = 0; chunk.codes && band < chunk.maxSh; band++)
325
+ property(`sh${band + 1}_code`, "s8", chunk.codes[band], [
326
+ -SH_MAX,
327
+ SH_MAX,
328
+ ]);
329
+ bytes = raw(count * 2);
330
+ for (let i = 0; i < count; i++) bytes.writeUInt16LE(chunk.labels[i], i * 2);
331
+ property("sh_label", "u16", bytes);
332
+ }
329
333
  for (let band = 0; band < chunk.sh.length; band++) {
330
334
  const dims = SH_DIMS[band];
331
335
  const values = chunk.sh[band];
332
- const codes = encoding.sh[band];
333
336
  bytes = raw(count * dims);
334
337
  for (let d = 0; d < dims; d++)
335
338
  for (let i = 0; i < count; i++)
336
- bytes[d * count + i] = codes[values[i * dims + d]];
337
- property(`sh${band + 1}`, "s8", bytes, [
338
- -encoding.limits[band],
339
- encoding.limits[band],
340
- ]);
339
+ bytes[d * count + i] = SH_CODES[values[i * dims + d]];
340
+ property(`sh${band + 1}`, "s8", bytes, [-SH_MAX, SH_MAX]);
341
341
  }
342
342
  const counts = raw(count * 2);
343
343
  const starts = raw(count * 4, 1);
@@ -352,7 +352,7 @@ function encodeChunk(chunk, encoding) {
352
352
  version: 1,
353
353
  base,
354
354
  count,
355
- maxSh: chunk.sh.length,
355
+ maxSh: chunk.maxSh,
356
356
  lodTree: true,
357
357
  splatEncoding: encoding.meta,
358
358
  payloadBytes,
@@ -369,8 +369,8 @@ class Coder {
369
369
  this.counts = new Counts();
370
370
  this.format = undefined;
371
371
  }
372
- handle({ type, chunk, format }) {
373
- if (type === "count") this.counts.add(chunk);
372
+ handle({ type, records, chunk, format }) {
373
+ if (type === "count") this.counts.add(records);
374
374
  else if (type === "counts") return this.counts;
375
375
  else if (type === "format") this.format = format;
376
376
  else return encodeChunk(chunk, this.format);
@@ -379,62 +379,33 @@ class Coder {
379
379
  }
380
380
 
381
381
  // Spreads counting and encoding over worker threads.
382
- class Encoder {
382
+ class Encoder extends WorkerGroup {
383
383
  // Encodes `chunks` with workers when there is more than one and the
384
384
  // workspace has room for them.
385
385
  constructor(workspace, chunks) {
386
+ const count = encoders(workspace, chunks);
387
+ super(
388
+ new URL(import.meta.url),
389
+ count,
390
+ { coder: true },
391
+ count ? undefined : new Coder(),
392
+ "Encoding",
393
+ );
386
394
  this.next = 0;
387
- this.coder = new Coder();
388
- this.workers = Array.from({ length: encoders(workspace, chunks) }, () => {
389
- const worker = new Worker(new URL(import.meta.url), {
390
- workerData: { coder: true },
391
- resourceLimits: {
392
- maxOldGenerationSizeMb: 64,
393
- maxYoungGenerationSizeMb: YOUNG_MB,
394
- },
395
- });
396
- worker.unref();
397
- worker.waiting = [];
398
- worker.on("message", (result) => worker.waiting.shift().resolve(result));
399
- // A failed worker fails its pending and later requests.
400
- const fail = (error) => {
401
- worker.failed ??= error;
402
- for (const job of worker.waiting.splice(0)) job.reject(worker.failed);
403
- };
404
- worker.on("error", fail);
405
- worker.on("exit", (code) =>
406
- fail(new Error(`Encoding worker exited (${code})`)),
407
- );
408
- return worker;
409
- });
410
395
  }
411
396
  get size() {
412
397
  return this.workers.length;
413
398
  }
414
- request(worker, message, transfer = []) {
415
- if (!worker) return Promise.resolve(this.coder.handle(message));
416
- if (worker.failed) return Promise.reject(worker.failed);
417
- return new Promise((resolve, reject) => {
418
- worker.waiting.push({ resolve, reject });
419
- worker.postMessage(message, transfer);
420
- });
421
- }
422
- // Sends a gathered chunk to the next worker.
423
- send(type, chunk) {
399
+ // Sends the next job to the next worker.
400
+ send(message, transfer) {
424
401
  return this.request(
425
402
  this.workers[this.next++ % this.workers.length],
426
- { type, chunk },
427
- [
428
- chunk.records.buffer,
429
- ...chunk.sh.map((values) => values.buffer),
430
- ...(chunk.counts ? [chunk.counts.buffer, chunk.starts.buffer] : []),
431
- ],
403
+ message,
404
+ transfer,
432
405
  );
433
406
  }
434
- // Sends `message` to every worker, or to this thread's coder without any.
435
- broadcast(message) {
436
- const all = this.workers.length ? this.workers : [undefined];
437
- return Promise.all(all.map((worker) => this.request(worker, message)));
407
+ count(records) {
408
+ return this.send({ type: "count", records }, [records.buffer]);
438
409
  }
439
410
  async counts() {
440
411
  const counts = new Counts();
@@ -446,13 +417,16 @@ class Encoder {
446
417
  return this.broadcast({ type: "format", format });
447
418
  }
448
419
  async encode(chunk) {
449
- const bytes = await this.send("encode", chunk);
420
+ const bytes = await this.send({ type: "encode", chunk }, [
421
+ chunk.records.buffer,
422
+ ...chunk.sh.map((values) => values.buffer),
423
+ ...(chunk.labels ? [chunk.labels.buffer] : []),
424
+ ...(chunk.codes ?? []).map((values) => values.buffer),
425
+ chunk.counts.buffer,
426
+ chunk.starts.buffer,
427
+ ]);
450
428
  return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length);
451
429
  }
452
- // Resolves once the workers have stopped and their memory is free.
453
- close() {
454
- return Promise.all(this.workers.map((worker) => worker.terminate()));
455
- }
456
430
  }
457
431
 
458
432
  if (!isMainThread && workerData?.coder) {
@@ -471,7 +445,8 @@ if (!isMainThread && workerData?.coder) {
471
445
  });
472
446
  }
473
447
 
474
- export async function writeRad(splats, output, chunked, lodBase) {
448
+ // With a codebook from clusterSh(), splats store SH as its labels.
449
+ export async function writeRad(splats, output, chunked, lodBase, codebook) {
475
450
  const chunks = [];
476
451
  const stem = chunkStem(output);
477
452
  const spool = chunked ? undefined : splats.workspace.file();
@@ -529,26 +504,34 @@ export async function writeRad(splats, output, chunked, lodBase) {
529
504
  let countedChunks = 0;
530
505
  for (let base = 0; base < splats.length; base += CHUNK) {
531
506
  splats.workspace.check();
507
+ const count = Math.min(CHUNK, splats.length - base);
508
+ const records = splats.records.read(
509
+ base * 16,
510
+ new Float32Array(count * 16),
511
+ );
532
512
  const counted = encoder
533
- .send(
534
- "count",
535
- gather(splats, base, Math.min(CHUNK, splats.length - base), false),
536
- )
513
+ .count(records)
537
514
  .then(() => progress.update(++countedChunks));
538
515
  counted.catch(() => {});
539
516
  pending.push(counted);
540
517
  if (pending.length > encoder.size + 1) await pending.shift();
541
518
  }
542
519
  while (pending.length) await pending.shift();
543
- const format = encoding(await encoder.counts(), splats.maxSh);
520
+ const format = encoding(await encoder.counts());
544
521
  await encoder.use(format);
545
522
  progress.finish();
546
523
  progress = new Progress("Encoding RAD chunks", chunkCount);
547
524
  for (let base = 0; base < splats.length; base += CHUNK) {
548
525
  splats.workspace.check();
549
- const bytes = encoder.encode(
550
- gather(splats, base, Math.min(CHUNK, splats.length - base)),
526
+ const chunk = gather(
527
+ splats,
528
+ base,
529
+ Math.min(CHUNK, splats.length - base),
530
+ codebook,
551
531
  );
532
+ if (codebook && base === 0)
533
+ chunk.codes = encodeCodes(codebook, splats.maxSh);
534
+ const bytes = encoder.encode(chunk);
552
535
  bytes.catch(() => {});
553
536
  pending.push(bytes);
554
537
  if (pending.length > encoder.size + 1) store(await pending.shift());
@@ -565,6 +548,7 @@ export async function writeRad(splats, output, chunked, lodBase) {
565
548
  allChunkBytes: total,
566
549
  chunks,
567
550
  splatEncoding: format.meta,
551
+ ...(codebook && { shCodeCount: codebook.count }),
568
552
  comment: `Gaussian Splat Lite quality LOD (base ${lodBase})`,
569
553
  });
570
554
  const writing = new Progress(
package/bin/rad/splats.js CHANGED
@@ -20,8 +20,9 @@ export const SH_DIMS = [9, 15, 21];
20
20
  export const KEEP = 0;
21
21
  export const INVALID = 1;
22
22
  export const EMPTY = 2;
23
+ export const CROPPED = 3;
23
24
  // A worker found a page that is not cached.
24
- export const LATER = 3;
25
+ export const LATER = 4;
25
26
 
26
27
  // Sets a record's feature size and area from its scales and opacity.
27
28
  export function refresh(record) {
@@ -45,6 +46,16 @@ export function validRecord(record) {
45
46
  return true;
46
47
  }
47
48
 
49
+ // Classifies a valid record after refresh(), including optional cropping.
50
+ export function filterRecord(record, withinDist) {
51
+ if (record[3] <= 0 || record[14] <= 0) return EMPTY;
52
+ if (!withinDist) return KEEP;
53
+ const [x, y, z, radius] = withinDist;
54
+ return Math.hypot(record[0] - x, record[1] - y, record[2] - z) > radius
55
+ ? CROPPED
56
+ : KEEP;
57
+ }
58
+
48
59
  export class Splats {
49
60
  constructor(workspace, maxSh) {
50
61
  this.workspace = workspace;
@@ -52,6 +63,7 @@ export class Splats {
52
63
  this.inputSh = 0;
53
64
  this.length = 0;
54
65
  this.invalid = 0;
66
+ this.cropped = 0;
55
67
  this.records = new PagedArray(workspace, Float32Array);
56
68
  this.sh = SH_DIMS.map(() => new PagedArray(workspace, Uint16Array));
57
69
  // The two children of the i-th merged splat are entries 2i and 2i + 1 of
@@ -127,9 +139,11 @@ export class Splats {
127
139
 
128
140
  // Skips empty splats and counts invalid ones in `invalid`: NaN or infinite
129
141
  // values, including a zero rotation, which the decoder normalizes to NaN.
130
- // Workers refresh and check blocks of splats in place; the main thread
142
+ // Optionally keeps only centers within [x, y, z, radius], counting the
143
+ // otherwise usable splats outside this sphere in `cropped`.
144
+ // Workers refresh, check and crop blocks of splats; the main thread
131
145
  // checks those they leave, and moves kept splats after skipped ones.
132
- retain(pool) {
146
+ retain(pool, withinDist) {
133
147
  const progress = new Progress("Filtering splats", this.length);
134
148
  const { results } = pool;
135
149
  const block = results.length;
@@ -143,6 +157,7 @@ export class Splats {
143
157
  ];
144
158
  let count = 0;
145
159
  this.invalid = 0;
160
+ this.cropped = 0;
146
161
  for (let base = 0; base < this.length; base += block) {
147
162
  const size = Math.min(block, this.length - base);
148
163
  for (const [column, stride] of columns)
@@ -151,24 +166,26 @@ export class Splats {
151
166
  (base + size) * stride,
152
167
  column === this.records,
153
168
  );
154
- pool.check(this, base, size);
169
+ pool.check(this, base, size, withinDist);
155
170
  for (let i = 0; i < size; i++) {
156
171
  const index = base + i;
157
172
  let state = results[i];
158
- if (state === LATER) {
173
+ const later = state === LATER;
174
+ if (later) {
159
175
  this.read(index, record);
160
176
  refresh(record);
161
177
  state = !this.valid(index, record, rows)
162
178
  ? INVALID
163
- : record[3] > 0 && record[14] > 0
164
- ? KEEP
165
- : EMPTY;
166
- if (state === KEEP) this.records.write(index * 16, record);
179
+ : filterRecord(record, withinDist);
167
180
  }
168
181
  if (state === INVALID) this.invalid++;
182
+ if (state === CROPPED) this.cropped++;
169
183
  if (state !== KEEP) continue;
184
+ if (count !== index || later) {
185
+ if (!later) this.read(index, record);
186
+ this.records.write(count * 16, record);
187
+ }
170
188
  if (count !== index) {
171
- this.records.write(count * 16, this.read(index, record));
172
189
  for (let band = 0; band < this.maxSh; band++) {
173
190
  const dims = SH_DIMS[band];
174
191
  this.sh[band].read(index * dims, rows[band]);
@@ -129,7 +129,10 @@ export class Workspace {
129
129
  shrink() {
130
130
  if (this.slots.length * PAGE_BYTES <= this.capacity) return;
131
131
  const slots = Math.floor(this.capacity / this.arenaBytes) * this.arenaPages;
132
- for (const page of this.slots.slice(slots)) if (page) this.drop(page);
132
+ // Without a copy of the slots: a copy kept the released pages alive
133
+ // through the collection below, and their memory returned only later.
134
+ for (let slot = slots; slot < this.slots.length; slot++)
135
+ if (this.slots[slot]) this.drop(this.slots[slot]);
133
136
  this.slots.length = slots;
134
137
  this.reuse.length = slots;
135
138
  this.free = this.free.filter((slot) => slot < slots);