Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/SparkRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,9 @@ export interface SparkRendererOptions {
*/
falloff?: number;
/**
* X/Y clipping boundary factor for Gsplat centers against view frustum.
* 1.0 clips any centers that are exactly out of bounds, while 1.4 clips
* centers that are 40% beyond the bounds.
* X/Y clipping boundary factor, in NDC, for projected Gsplat footprints.
* 1.0 retains footprints overlapping the view bounds, while 1.4 retains
* footprints overlapping bounds expanded by 40%.
* @default 1.4
*/
clipXY?: number;
Expand Down Expand Up @@ -649,7 +649,7 @@ export class SparkRenderer extends THREE.Mesh {
// Modulate Gaussian kernal falloff. 0 means "no falloff, flat shading",
// 1 is normal e^-x^2 falloff.
falloff: { value: 1.0 },
// Clip Gsplats that are clipXY times beyond the +-1 frustum bounds
// Cull projected Gsplat footprints outside clipXY times the +-1 frustum bounds
clipXY: { value: 1.4 },
// Debug renderSize scale factor
focalAdjustment: { value: 1.0 },
Expand Down
29 changes: 19 additions & 10 deletions src/shaders/splatVertex.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,6 @@ void main() {
return;
}

// Discard splats more than clipXY times outside the XY frustum
float clip = clipXY * clipCenter.w;
if (abs(clipCenter.x) > clip || abs(clipCenter.y) > clip) {
return;
}

vRgba = rgba;
vSplatUv = position.xy * adjustedStdDev;
Expand All @@ -153,6 +148,8 @@ void main() {
vec4 viewQuaternion = quatQuat(renderToViewQuat, quaternion);

if (enable2DGS && any(zeroScales)) {
// This path has no projected-covariance bound. Defer XY clipping to GPU
// primitive clipping rather than reject an overlapping 2DGS by its center.
vec3 offset;
if (zeroScales.z) {
offset = vec3(vSplatUv.xy * scales.xy, 0.0);
Expand Down Expand Up @@ -256,12 +253,24 @@ void main() {
return;
}

// Compute the NDC coordinates for the ellipsoid's diagonal axes.
vec2 pixelOffset = position.x * eigenVec1 * scale1 + position.y * eigenVec2 * scale2;
vec2 ndcOffset = (2.0 / scaledRenderSize) * pixelOffset;

// Compute NDC center of the splat
// Compute an axis-aligned NDC bound for the rendered ellipse. Its eigen axes
// already include adjustedStdDev, blur, focal adjustment, and pixel-radius clamps.
vec2 axis1 = eigenVec1 * scale1;
vec2 axis2 = eigenVec2 * scale2;
vec2 ndcScale = 2.0 / scaledRenderSize;
vec2 ndcExtent = abs(ndcScale) * sqrt(axis1 * axis1 + axis2 * axis2);
// Pad by at least one projected pixel; the floor covers extreme scales where
// float32 rounding could otherwise turn a boundary overlap into a rejection.
vec2 ndcCullPadding = max(abs(ndcScale), vec2(1e-6));

// Discard only when the complete projected footprint misses the XY boundary.
vec3 ndcCenter = clipCenter.xyz / clipCenter.w;
if (any(greaterThan(abs(ndcCenter.xy), vec2(clipXY) + ndcExtent + ndcCullPadding))) {
return;
}

vec2 pixelOffset = position.x * axis1 + position.y * axis2;
vec2 ndcOffset = ndcScale * pixelOffset;
vec3 ndc = vec3(ndcCenter.xy + ndcOffset, ndcCenter.z);

vNdc = ndc;
Expand Down
45 changes: 45 additions & 0 deletions test/splatVertex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { test } from "node:test";

function assertSourceOrder(source: string, snippets: string[]) {
let previousIndex = -1;
for (const snippet of snippets) {
const index = source.indexOf(snippet);
assert.notEqual(index, -1, `Missing shader invariant: ${snippet}`);
assert.ok(
index > previousIndex,
`Shader invariant is out of order: ${snippet}`,
);
previousIndex = index;
}
}

test("splat vertex XY culling uses the final projected ellipse footprint", async () => {
const source = await readFile(
new URL("../src/shaders/splatVertex.glsl", import.meta.url),
"utf8",
);

assertSourceOrder(source, [
"adjustedStdDev = maxStdDev;",
"if (abs(clipCenter.z) >= clipCenter.w)",
"if (enable2DGS && any(zeroScales))",
"vec2 scaledRenderSize = renderSize * focalAdjustment;",
"if (isOrthographic)",
"mat3 cov2D = transpose(J) * cov3D * J;",
"a += preBlurAmount;",
"a += fullBlurAmount;",
"float eigen1 = eigenAvg + eigenDelta;",
"float scale1 = min(maxPixelRadius, adjustedStdDev * sqrt(eigen1));",
"vec2 axis1 = eigenVec1 * scale1;",
"vec2 axis2 = eigenVec2 * scale2;",
"vec2 ndcScale = 2.0 / scaledRenderSize;",
"vec2 ndcExtent = abs(ndcScale) * sqrt(axis1 * axis1 + axis2 * axis2);",
"vec2 ndcCullPadding = max(abs(ndcScale), vec2(1e-6));",
"if (any(greaterThan(abs(ndcCenter.xy), vec2(clipXY) + ndcExtent + ndcCullPadding)))",
"vec2 pixelOffset = position.x * axis1 + position.y * axis2;",
]);

assert.doesNotMatch(source, /clipXY\s*\*\s*clipCenter\.w/);
});