From b578399f0e238ab603d924dae21d95cb1cbbbc07 Mon Sep 17 00:00:00 2001 From: Yingliang Zhang Date: Wed, 15 Jul 2026 10:20:35 +0800 Subject: [PATCH] fix(renderer): cull splats by projected footprint --- src/SparkRenderer.ts | 8 +++---- src/shaders/splatVertex.glsl | 29 +++++++++++++++-------- test/splatVertex.test.ts | 45 ++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 test/splatVertex.test.ts diff --git a/src/SparkRenderer.ts b/src/SparkRenderer.ts index 1e24ef64..46f4e37f 100644 --- a/src/SparkRenderer.ts +++ b/src/SparkRenderer.ts @@ -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; @@ -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 }, diff --git a/src/shaders/splatVertex.glsl b/src/shaders/splatVertex.glsl index ffbf93a0..2ed8615b 100644 --- a/src/shaders/splatVertex.glsl +++ b/src/shaders/splatVertex.glsl @@ -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; @@ -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); @@ -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; diff --git a/test/splatVertex.test.ts b/test/splatVertex.test.ts new file mode 100644 index 00000000..e2cf8778 --- /dev/null +++ b/test/splatVertex.test.ts @@ -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/); +});