gaussian-splat-lite 1.4.1 → 1.4.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,13 @@ and this project follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.2] - 2026-10-24
11
+
12
+ ### Changed
13
+
14
+ - Stochastic rendering now uses one triangle per Splat instead of a quad, reducing vertex and triangle counts on WebGL, WebGPU, and the WebGL2 fallback.
15
+ - Neural denoising's `"quality"` mode no longer develops a worm-like pattern on fine detail during slow camera movement; detail there is slightly softer.
16
+
10
17
  ## [1.4.1] - 2026-10-24
11
18
 
12
19
  ### Added
@@ -653,7 +660,8 @@ and this project follows [Semantic Versioning](https://semver.org/).
653
660
 
654
661
  - Initial public release of the Three.js Gaussian Splatting renderer.
655
662
 
656
- [Unreleased]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.4.1...HEAD
663
+ [Unreleased]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.4.2...HEAD
664
+ [1.4.2]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.4.1...v1.4.2
657
665
  [1.4.1]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.4.0...v1.4.1
658
666
  [1.4.0]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.3.2...v1.4.0
659
667
  [1.3.2]: https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/compare/v1.3.1...v1.3.2
@@ -3914,19 +3914,22 @@ function hasViewDependentColor(mapping) {
3914
3914
  }
3915
3915
  const SPLATS_PER_INSTANCE = 128;
3916
3916
  class SplatGeometry extends THREE__namespace.InstancedBufferGeometry {
3917
- constructor() {
3917
+ constructor(triangle = false) {
3918
3918
  super();
3919
- const vertices = new Float32Array(SPLATS_PER_INSTANCE * 12);
3920
- const indices = new Uint16Array(SPLATS_PER_INSTANCE * 6);
3919
+ const corners = triangle ? TRIANGLE_CORNERS : QUAD_CORNERS;
3920
+ const order = triangle ? TRIANGLE_INDICES : QUAD_INDICES;
3921
+ const vertexCount = corners.length / 2;
3922
+ const vertices = new Float32Array(SPLATS_PER_INSTANCE * vertexCount * 3);
3923
+ const indices = new Uint16Array(SPLATS_PER_INSTANCE * order.length);
3921
3924
  for (let splat = 0; splat < SPLATS_PER_INSTANCE; splat++) {
3922
- for (let vertex = 0; vertex < 4; vertex++) {
3923
- const offset = splat * 12 + vertex * 3;
3924
- vertices[offset] = QUAD_VERTICES[vertex * 3];
3925
- vertices[offset + 1] = QUAD_VERTICES[vertex * 3 + 1];
3925
+ for (let vertex = 0; vertex < vertexCount; vertex++) {
3926
+ const offset = (splat * vertexCount + vertex) * 3;
3927
+ vertices[offset] = corners[vertex * 2];
3928
+ vertices[offset + 1] = corners[vertex * 2 + 1];
3926
3929
  vertices[offset + 2] = splat;
3927
3930
  }
3928
- for (let index2 = 0; index2 < 6; index2++)
3929
- indices[splat * 6 + index2] = splat * 4 + QUAD_INDICES[index2];
3931
+ for (let index2 = 0; index2 < order.length; index2++)
3932
+ indices[splat * order.length + index2] = splat * vertexCount + order[index2];
3930
3933
  }
3931
3934
  this.setAttribute("position", new THREE__namespace.BufferAttribute(vertices, 3));
3932
3935
  this.setIndex(new THREE__namespace.BufferAttribute(indices, 1));
@@ -3935,21 +3938,17 @@ class SplatGeometry extends THREE__namespace.InstancedBufferGeometry {
3935
3938
  this.instanceCount = Math.ceil(count / SPLATS_PER_INSTANCE);
3936
3939
  }
3937
3940
  }
3938
- const QUAD_VERTICES = new Float32Array([
3941
+ const QUAD_CORNERS = new Float32Array([-1, -1, 1, -1, 1, 1, -1, 1]);
3942
+ const TRIANGLE_CORNERS = new Float32Array([
3943
+ -Math.sqrt(3),
3939
3944
  -1,
3945
+ Math.sqrt(3),
3940
3946
  -1,
3941
3947
  0,
3942
- 1,
3943
- -1,
3944
- 0,
3945
- 1,
3946
- 1,
3947
- 0,
3948
- -1,
3949
- 1,
3950
- 0
3948
+ 2
3951
3949
  ]);
3952
3950
  const QUAD_INDICES = new Uint16Array([0, 2, 1, 0, 3, 2]);
3951
+ const TRIANGLE_INDICES = new Uint16Array([0, 2, 1]);
3953
3952
  const variants = /* @__PURE__ */ new WeakMap();
3954
3953
  function registerStochasticSplatMaterial(material, createVariant) {
3955
3954
  variants.set(material, createVariant);
@@ -5620,10 +5619,7 @@ class ProjectedSplats {
5620
5619
  constructor(renderer, uniforms) {
5621
5620
  this.renderer = renderer;
5622
5621
  this.uniforms = uniforms;
5623
- this.indirect = new webgpu.IndirectStorageBufferAttribute(
5624
- new Uint32Array([SPLATS_PER_INSTANCE * 6, 0, 0, 0, 0]),
5625
- 1
5626
- );
5622
+ this.indirect = new webgpu.IndirectStorageBufferAttribute(new Uint32Array(5), 1);
5627
5623
  this.error = null;
5628
5624
  this.cache = new ProjectionCache();
5629
5625
  this.visibleCount = new webgpu.StorageBufferAttribute(new Uint32Array(1), 1);
@@ -5663,7 +5659,9 @@ class ProjectedSplats {
5663
5659
  // RADIX_SORT_MODE_IDS value of the current key encoding.
5664
5660
  sortMode: { value: 0 },
5665
5661
  // NDC translation the draw applies to unjittered projections.
5666
- projectionJitter: { value: new THREE__namespace.Vector2() }
5662
+ projectionJitter: { value: new THREE__namespace.Vector2() },
5663
+ // Indices per instance of the drawn geometry, quads or triangles.
5664
+ indexCount: { value: 0 }
5667
5665
  };
5668
5666
  const count = N__namespace.storage(this.visibleCount, "uint").setName("gslVisibleCount").toReadOnly().element(0);
5669
5667
  this.sorter = new WebGPURadixSort(1, this.keys, {
@@ -5675,13 +5673,16 @@ class ProjectedSplats {
5675
5673
  this.limits.maxBufferSize
5676
5674
  )
5677
5675
  });
5678
- const drawCount = N__namespace.storage(this.indirect, "uint").setName("gslDrawIndirect").element(1);
5676
+ const drawArgs = N__namespace.storage(this.indirect, "uint").setName(
5677
+ "gslDrawIndirect"
5678
+ );
5679
5679
  const counter = N__namespace.storage(this.visibleCount, "uint").setName("gslVisibleCount").toAtomic();
5680
5680
  this.resetCount = N__namespace.Fn(() => {
5681
5681
  N__namespace.atomicStore(counter.element(0), N__namespace.uint(0));
5682
5682
  })().compute(1, [1]).setName("Splat reset visible count");
5683
5683
  this.finish = N__namespace.Fn(() => {
5684
- drawCount.assign(
5684
+ drawArgs.element(0).assign(uniformBinding(this.state, "indexCount", "uint"));
5685
+ drawArgs.element(1).assign(
5685
5686
  count.add(SPLATS_PER_INSTANCE - 1).div(N__namespace.uint(SPLATS_PER_INSTANCE))
5686
5687
  );
5687
5688
  })().compute(1, [1]).setName("Splat visible draw arguments");
@@ -5933,6 +5934,7 @@ class ProjectedSplats {
5933
5934
  const monoProjection = !multiView && unjittered && getProjectionJitter(camera.projectionMatrix, unjittered, jitter) ? unjittered : null;
5934
5935
  geometry.setIndirect(this.indirect);
5935
5936
  geometry.setSplatCount(Math.max(1, accumulator.numSplats));
5937
+ this.state.indexCount.value = geometry.index.count;
5936
5938
  const inputs = this.pendingInputs;
5937
5939
  let length = 0;
5938
5940
  inputs[length++] = accumulator;
@@ -5941,6 +5943,7 @@ class ProjectedSplats {
5941
5943
  inputs[length++] = accumulator.viewOrigin.y;
5942
5944
  inputs[length++] = accumulator.viewOrigin.z;
5943
5945
  inputs[length++] = cameras.length;
5946
+ inputs[length++] = geometry.index.count;
5944
5947
  inputs[length++] = stochastic;
5945
5948
  inputs[length++] = !stochastic && radial;
5946
5949
  inputs[length++] = sortMode;
@@ -6127,9 +6130,11 @@ class GaussianSplatRenderer extends THREE__namespace.Mesh {
6127
6130
  depthTest: options.depthTest ?? true,
6128
6131
  depthWrite: options.depthWrite ?? false
6129
6132
  });
6130
- const geometry = new SplatGeometry();
6133
+ const geometries = [new SplatGeometry(), new SplatGeometry(true)];
6134
+ const geometry = geometries[Number(stochastic)];
6131
6135
  const material = backend.selectMaterial(stochastic);
6132
6136
  super(geometry, material);
6137
+ this.geometries = geometries;
6133
6138
  this.materialState = new MaterialState(material);
6134
6139
  this.frameCallbacks = true;
6135
6140
  this.renderSize = new THREE__namespace.Vector2();
@@ -6233,7 +6238,7 @@ class GaussianSplatRenderer extends THREE__namespace.Mesh {
6233
6238
  accumulator.dispose();
6234
6239
  }
6235
6240
  this.accumulators.length = 0;
6236
- this.geometry.dispose();
6241
+ for (const geometry of this.geometries) geometry.dispose();
6237
6242
  }
6238
6243
  /** Native WebGPU sorts on the GPU before drawing; WebGL sorts in a worker. */
6239
6244
  get synchronousSort() {
@@ -6285,13 +6290,11 @@ class GaussianSplatRenderer extends THREE__namespace.Mesh {
6285
6290
  const active = this.backend.kind === "webgpu" || this.display.numSplats === 0 ? this._stochastic : this.display.hasStochasticSeeds;
6286
6291
  if (active === this.stochasticFrame) return;
6287
6292
  this.stochasticFrame = active;
6288
- this.selectMaterial();
6289
- this.setDirty();
6290
- }
6291
- selectMaterial() {
6292
- const material = this.backend.selectMaterial(this.stochasticFrame);
6293
- this.applyMaterialState(this.stochasticFrame, material);
6293
+ this.geometry = this.geometries[Number(active)];
6294
+ const material = this.backend.selectMaterial(active);
6295
+ this.applyMaterialState(active, material);
6294
6296
  this.material = material;
6297
+ this.setDirty();
6295
6298
  }
6296
6299
  applyMaterialState(stochasticActive, material = this.material) {
6297
6300
  this.materialState.sync(material, this.material);
@@ -7215,74 +7218,6 @@ ${CATMULL_ROM_WEIGHTS}
7215
7218
  fn catmullRom(image: texture_2d<f32>, uv: vec2f) -> vec4f {
7216
7219
  return catmullRomSpan(image, uv).value;
7217
7220
  }
7218
-
7219
- // Lanczos-3 without its corner taps, from nine bilinear taps: the middle pair
7220
- // of each axis is one tap and the other four pixels of an axis one each. A
7221
- // history that is resampled once per frame loses fine detail with every pass,
7222
- // and this kernel loses about half of what Catmull-Rom does. Its negative
7223
- // lobes would ring over such a history, so the result is kept inside what the
7224
- // four pixels around the position span. At a pixel's centre it returns the
7225
- // pixel.
7226
- struct LanczosWeights {
7227
- low: vec3f,
7228
- high: vec3f,
7229
- };
7230
- fn lanczosWeights(t: f32) -> LanczosWeights {
7231
- // Integer-shifted sinc taps share their sine. Angle addition supplies the
7232
- // other factor, reducing twelve sine evaluations per axis to three trig calls.
7233
- let x: f32 = 3.14159265 * t;
7234
- let s: f32 = sin(x / 3.0);
7235
- let c: f32 = 0.866025404 * cos(x / 3.0);
7236
- let scale: f32 = 0.303963551 * sin(x);
7237
- let terms: vec3f = scale * vec3f(c - 0.5 * s, -c - 0.5 * s, s);
7238
- let low: vec3f = vec3f(t + 2.0, t + 1.0, t);
7239
- let high: vec3f = vec3f(t - 1.0, t - 2.0, t - 3.0);
7240
- var w: LanczosWeights;
7241
- w.low = terms / max(low * low, vec3f(1e-20));
7242
- w.high = terms / max(high * high, vec3f(1e-20));
7243
- if (abs(x) < 1e-5) { w.low.z = 1.0; }
7244
- if (abs(3.14159265 * (t - 1.0)) < 1e-5) { w.high.x = 1.0; }
7245
- return w;
7246
- }
7247
- struct LanczosKernel {
7248
- base: vec2f,
7249
- centre: vec2f,
7250
- middle: f32,
7251
- wx: vec4f,
7252
- wy: vec4f,
7253
- total: f32,
7254
- }
7255
- fn lanczosKernel(uv: vec2f) -> LanczosKernel {
7256
- let position: vec2f = uv * P.size - 0.5;
7257
- var k: LanczosKernel;
7258
- k.base = floor(position);
7259
- let t: vec2f = position - k.base;
7260
- let x: LanczosWeights = lanczosWeights(t.x);
7261
- let y: LanczosWeights = lanczosWeights(t.y);
7262
- let middle: vec2f = vec2f(x.low.z + x.high.x, y.low.z + y.high.x);
7263
- k.centre = (k.base + vec2f(x.high.x, y.high.x) / middle + 0.5) * P.invSize;
7264
- k.middle = middle.x * middle.y;
7265
- k.wx = vec4f(x.low.xy, x.high.yz) * middle.y;
7266
- k.wy = vec4f(y.low.xy, y.high.yz) * middle.x;
7267
- k.total = k.middle;
7268
- for (var i: i32 = 0; i < 4; i++) { k.total += k.wx[i] + k.wy[i]; }
7269
- return k;
7270
- }
7271
- fn lanczosCross(image: texture_2d<f32>, k: LanczosKernel) -> vec4f {
7272
- var sum: vec4f = textureSampleLevel(image, linearSampler, k.centre, 0.0) * k.middle;
7273
- const offsets = array<f32, 4>(-1.5, -0.5, 2.5, 3.5);
7274
- for (var i: i32 = 0; i < 4; i++) {
7275
- let along: vec2f = (k.base + offsets[i]) * P.invSize;
7276
- sum += textureSampleLevel(image, linearSampler, vec2f(along.x, k.centre.y), 0.0) * k.wx[i];
7277
- sum += textureSampleLevel(image, linearSampler, vec2f(k.centre.x, along.y), 0.0) * k.wy[i];
7278
- }
7279
- let p: vec2i = vec2i(k.base);
7280
- let a: vec4f = textureLoad(image, bounded(p), 0);
7281
- let b: vec4f = textureLoad(image, bounded(p + vec2i(1, 0)), 0);
7282
- let c: vec4f = textureLoad(image, bounded(p + vec2i(0, 1)), 0);
7283
- let d: vec4f = textureLoad(image, bounded(p + vec2i(1, 1)), 0);
7284
- return clamp(sum / k.total, min(min(a, b), min(c, d)), max(max(a, b), max(c, d)));
7285
- }
7286
7221
  `
7287
7222
  );
7288
7223
  const TAPS = (
@@ -7636,7 +7571,7 @@ fn mean3x3(image: texture_2d<f32>, position: vec2f, offset: vec2f) -> vec4f {
7636
7571
  const QUALITY_RESAMPLING = (
7637
7572
  /* wgsl */
7638
7573
  `
7639
- // Full Catmull-Rom for the denoised history, retaining all four corner taps.
7574
+ // Full Catmull-Rom for Quality histories, retaining all four corner taps.
7640
7575
  fn catmullRomNine(image: texture_2d<f32>, uv: vec2f) -> vec4f {
7641
7576
  let position: vec2f = uv * P.size;
7642
7577
  ${CATMULL_ROM_WEIGHTS}
@@ -7954,7 +7889,7 @@ ${groups.map(
7954
7889
  // half-resolution result.
7955
7890
  @group(0) @binding(11) var wideTexture: texture_2d<f32>;
7956
7891
  @group(0) @binding(12) var depthPrevious: texture_2d<f32>;
7957
- ${MEAN_3X3}${weights.compact ? "" : QUALITY_RESAMPLING}${BLOCKS}
7892
+ ${weights.compact ? MEAN_3X3 : QUALITY_RESAMPLING}${BLOCKS}
7958
7893
  // The frame's existing 4x4 mean, used only for rejected surfaces.
7959
7894
  @group(0) @binding(13) var rejectedTexture: texture_2d<f32>;
7960
7895
  ${COARSE}
@@ -8002,13 +7937,10 @@ ${weights.compact ? "" : " let d: vec4f = textureLoad(filteredTexture, p, 0);"}
8002
7937
  var same: f32 = 1.0;
8003
7938
  let sparse: f32 = blockSparse(p);
8004
7939
  var sparseMotion: f32 = 0.0;
8005
- ${weights.movingMean ? ` // Where the history was, for the accumulated path's mean below.
8006
- var historyUv: vec2f = position.xy * P.invSize;` : ""}
8007
7940
  if (P.historyValid > 0.5) {
8008
7941
  let z: f32 = blockDepth(p);
8009
7942
  let h: History = reproject(position.xy, z);
8010
7943
  valid = h.valid;
8011
- ${weights.movingMean ? " historyUv = h.uv;" : ""}
8012
7944
  // The pixel's own motion, or what a turn of the camera sweeps over half
8013
7945
  // the view, whichever is more: at the centre of a turn the first is
8014
7946
  // small while every surface there shows a new face. An orthographic
@@ -8039,9 +7971,9 @@ ${weights.movingMean ? " historyUv = h.uv;" : ""}
8039
7971
  ${weights.compact ? ` // The accumulated history keeps many frames while the camera moves
8040
7972
  // slowly: read bilinearly, it lost detail with every one of them.
8041
7973
  sr = max(catmullRom(previousAccumulated, h.uv), vec4f(0.0));
8042
- dr = max(textureSampleLevel(previousDenoised, linearSampler, h.uv, 0.0), vec4f(0.0));` : ` sr = max(lanczosCross(previousAccumulated, lanczosKernel(h.uv)), vec4f(0.0));
8043
- // The two short histories take Catmull-Rom: what it blurs over a few
8044
- // frames is noise more than detail there.
7974
+ dr = max(textureSampleLevel(previousDenoised, linearSampler, h.uv, 0.0), vec4f(0.0));` : ` // Full Catmull-Rom avoids the Lanczos cross kernel's frequency gain
7975
+ // when periodic surface detail is reprojected over many frames.
7976
+ sr = max(catmullRomNine(previousAccumulated, h.uv), vec4f(0.0));
8045
7977
  dr = max(catmullRomNine(previousDenoised, h.uv), vec4f(0.0));`}
8046
7978
  a = previousStatistics(h.uv);
8047
7979
  }
@@ -8118,24 +8050,7 @@ ${weights.compact ? ` let source: vec4f = shifted;
8118
8050
  let error: f32 = sqrt(max(y2 - ys * ys, 0.0) / (ns + 1e-3) + 1e-10);
8119
8051
  let ramp: f32 = 1.0 - exp(-ns / ${float(weights.gateFrames)});
8120
8052
  let keep: f32 = ramp * (1.0 - clamp(${float(weights.gamma)} * error, 0.0, 1.0)) * trust.w;
8121
- ${weights.movingMean ? ` // While the camera moves, the accumulated path holds few samples of the
8122
- // new view and its grain shows: it is shown from the 3x3 mean of its
8123
- // history and this frame's filtered sample. What it keeps stays sharp, so
8124
- // a camera that stops settles as before. This display-only smoothing does
8125
- // not track moving objects: with a still camera they continue to use the
8126
- // existing history rejection.
8127
- var shownAccumulated: vec4f = s;
8128
- if (P.historyValid > 0.5 && P.cameraStill < 0.5) {
8129
- let held: vec4f = max(mean3x3(previousAccumulated, historyUv * P.size, vec2f(0.0)), vec4f(0.0));
8130
- let moving: f32 = speed * speed / (speed * speed + ${float(weights.movingKnee * weights.movingKnee)});
8131
- let smoothed: vec4f = (held * ws + filtered) / ds;
8132
- // Only where the two differ by what the mean's noise explains: a larger
8133
- // difference is detail, and stays.
8134
- let differ: f32 = luma(abs(smoothed.rgb - s.rgb));
8135
- let spread: f32 = ${float(weights.movingNoise)} * error * error;
8136
- shownAccumulated = mix(s, smoothed, ${float(weights.movingMean)} * moving * spread / (spread + differ * differ + 1e-10));
8137
- }
8138
- let composite: vec4f = mix(dn, shownAccumulated, keep);` : " let composite: vec4f = mix(dn, s, keep);"}
8053
+ let composite: vec4f = mix(dn, s, keep);
8139
8054
  // Stabilization: a predicted blend with the previous image, which may stay
8140
8055
  // as far from this frame's result as half of what it spans around the
8141
8056
  // pixel, plus a slack. Taking that span from this frame's result instead
@@ -10973,7 +10888,7 @@ const quality = {
10973
10888
  }
10974
10889
  };
10975
10890
  const neuralDenoiseModels = {
10976
- quality: { ...quality, movingMean: 1, movingNoise: 16 },
10891
+ quality,
10977
10892
  performance: { ...quality, compact: true, compactMix: [0.5, 0.75] }
10978
10893
  };
10979
10894
  class StochasticCoverage {