Custom/extended splat encoding ranges, Premultiplied alpha for hyper-saturated colors (#134)

* Externalized SparkRenderer.maxPixelRadius and .minAlpha settings. Added to documentation. Added to examples/editor under Debug folder, moved sort32 there.

* Added SplatEncoding concept, applied individually to SparkRenderer (rgbMin/Max, lnScaleMinMax), SplatMesh (additionally sh1Min/Max, sh2, sh3). Updated examples/editor to control settings via gui. Updated all related code to pipe along settings where needed.
Added SparkRenderer.premultipliedAlpha to control blend mode and enable hyper-saturated color contributions by default.

* Fix SplatLoader not passing along encoding. Complete renaming to splat encoding.
This commit is contained in:
Andreas Sundquist
2025-07-23 10:04:24 -07:00
committed by GitHub
parent f4b4522c56
commit f60c2b0cf9
19 changed files with 568 additions and 149 deletions
+58 -2
View File
@@ -1,6 +1,10 @@
import * as THREE from "three";
import { PackedSplats } from "./PackedSplats";
import {
DEFAULT_SPLAT_ENCODING,
PackedSplats,
type SplatEncoding,
} from "./PackedSplats";
import { RgbaArray } from "./RgbaArray";
import { SparkViewpoint, type SparkViewpointOptions } from "./SparkViewpoint";
import { type GeneratorMapping, SplatAccumulator } from "./SplatAccumulator";
@@ -8,6 +12,7 @@ import { SplatEdit } from "./SplatEdit";
import { SplatGenerator, SplatModifier } from "./SplatGenerator";
import { SplatGeometry } from "./SplatGeometry";
import { SplatMesh } from "./SplatMesh";
import { LN_SCALE_MAX, LN_SCALE_MIN } from "./defines";
import {
DynoVec3,
DynoVec4,
@@ -85,6 +90,11 @@ export type SparkRendererOptions = {
* rendering and significantly reduces performance.
*/
renderer: THREE.WebGLRenderer;
/**
* Whether to use premultiplied alpha when accumulating splat RGB
* @default true
*/
premultipliedAlpha?: boolean;
/**
* Pass in a THREE.Clock to synchronize time-based effects across different
* systems. Alternatively, you can set the SparkRenderer properties time and
@@ -184,15 +194,22 @@ export type SparkRendererOptions = {
* radial distance or Z-depth)
*/
view?: SparkViewpointOptions;
/**
* Override the default splat encoding ranges for the PackedSplats.
* (default: undefined)
*/
splatEncoding?: SplatEncoding;
};
export class SparkRenderer extends THREE.Mesh {
renderer: THREE.WebGLRenderer;
premultipliedAlpha: boolean;
material: THREE.ShaderMaterial;
uniforms: ReturnType<typeof SparkRenderer.makeUniforms>;
autoUpdate: boolean;
preUpdate: boolean;
needsUpdate: boolean;
originDistance: number;
maxStdDev: number;
maxPixelRadius: number;
@@ -205,6 +222,7 @@ export class SparkRenderer extends THREE.Mesh {
falloff: number;
clipXY: number;
focalAdjustment: number;
splatEncoding: SplatEncoding;
splatTexture: null | {
enable?: boolean;
@@ -270,13 +288,20 @@ export class SparkRenderer extends THREE.Mesh {
constructor(options: SparkRendererOptions) {
const uniforms = SparkRenderer.makeUniforms();
const shaders = getShaders();
const premultipliedAlpha = options.premultipliedAlpha ?? true;
const material = new THREE.ShaderMaterial({
glslVersion: THREE.GLSL3,
vertexShader: shaders.splatVertex,
fragmentShader: shaders.splatFragment,
uniforms,
transparent: true,
blending: THREE.NormalBlending,
blending: premultipliedAlpha
? THREE.CustomBlending
: THREE.NormalBlending,
blendSrc: premultipliedAlpha ? THREE.OneFactor : THREE.SrcAlphaFactor,
blendDst: premultipliedAlpha
? THREE.OneMinusSrcAlphaFactor
: THREE.OneFactor,
depthTest: true,
depthWrite: false,
side: THREE.DoubleSide,
@@ -309,8 +334,10 @@ export class SparkRenderer extends THREE.Mesh {
);
this.modifier = new SplatModifier(modifier);
this.premultipliedAlpha = premultipliedAlpha;
this.autoUpdate = options.autoUpdate ?? true;
this.preUpdate = options.preUpdate ?? false;
this.needsUpdate = false;
this.originDistance = options.originDistance ?? 1;
this.maxStdDev = options.maxStdDev ?? Math.sqrt(8.0);
this.maxPixelRadius = options.maxPixelRadius ?? 512.0;
@@ -323,6 +350,7 @@ export class SparkRenderer extends THREE.Mesh {
this.falloff = options.falloff ?? 1.0;
this.clipXY = options.clipXY ?? 1.4;
this.focalAdjustment = options.focalAdjustment ?? 1.0;
this.splatEncoding = options.splatEncoding ?? { ...DEFAULT_SPLAT_ENCODING };
this.active = new SplatAccumulator();
this.accumulatorCount = 1;
@@ -401,10 +429,14 @@ export class SparkRenderer extends THREE.Mesh {
splatTexMid: { value: 0.0 },
// Gsplat collection to render
packedSplats: { type: "t", value: PackedSplats.getEmpty() },
// Splat encoding ranges
rgbMinMaxLnScaleMinMax: { value: new THREE.Vector4() },
// Time in seconds for time-based effects
time: { value: 0 },
// Delta time in seconds since last frame
deltaTime: { value: 0 },
// Whether to use premultiplied alpha when accumulating splat RGB
premultipliedAlpha: { value: true },
// Whether to encode Gsplat with linear RGB (for environment mapping)
encodeLinear: { value: false },
// Debug flag that alternates each frame
@@ -499,6 +531,20 @@ export class SparkRenderer extends THREE.Mesh {
if (isNewFrame) {
// Keep these uniforms the same for both eyes if in WebXR
const blending = this.premultipliedAlpha
? THREE.CustomBlending
: THREE.NormalBlending;
if (blending !== this.material.blending) {
this.material.blending = blending;
this.material.blendSrc = this.premultipliedAlpha
? THREE.OneFactor
: THREE.SrcAlphaFactor;
this.material.blendDst = this.premultipliedAlpha
? THREE.OneMinusSrcAlphaFactor
: THREE.OneFactor;
this.material.needsUpdate = true;
}
this.uniforms.premultipliedAlpha.value = this.premultipliedAlpha;
this.uniforms.time.value = time;
this.uniforms.deltaTime.value = deltaTime;
// Alternating debug flag that can aid in visual debugging
@@ -598,6 +644,12 @@ export class SparkRenderer extends THREE.Mesh {
const { accumulator, geometry } = this.viewpoint.display;
this.uniforms.numSplats.value = accumulator.splats.numSplats;
this.uniforms.packedSplats.value = accumulator.splats.getTexture();
this.uniforms.rgbMinMaxLnScaleMinMax.value.set(
accumulator.splats.splatEncoding?.rgbMin ?? 0.0,
accumulator.splats.splatEncoding?.rgbMax ?? 1.0,
accumulator.splats.splatEncoding?.lnScaleMin ?? LN_SCALE_MIN,
accumulator.splats.splatEncoding?.lnScaleMax ?? LN_SCALE_MAX,
);
this.geometry = geometry;
} else {
// No Gsplats to display for this viewpoint yet
@@ -691,6 +743,7 @@ export class SparkRenderer extends THREE.Mesh {
const isVisible = object.generator && visibleGenHash.has(object.uuid);
const numSplats = isVisible ? object.numSplats : 0;
if (
this.needsUpdate ||
object.generator !== current?.generator ||
numSplats !== current?.count
) {
@@ -708,9 +761,11 @@ export class SparkRenderer extends THREE.Mesh {
// Check if we need any update at all
const needsUpdate =
this.needsUpdate ||
originUpdate ||
generators.length !== activeMapping.size ||
generators.some((g) => g.version !== activeMapping.get(g)?.version);
this.needsUpdate = false;
let accumulator: SplatAccumulator | null = null;
if (needsUpdate) {
@@ -782,6 +837,7 @@ export class SparkRenderer extends THREE.Mesh {
// Generate the Gsplats according to the mapping that need updating
accumulator.ensureGenerate(maxSplats);
accumulator.splats.splatEncoding = { ...this.splatEncoding };
const generated = accumulator.generateSplats({
renderer: this.renderer,
modifier: this.modifier,