How Gaussian Splatting composites sorted blobs into a final pixel — and how the backward pass undoes each blend step to propagate gradients.
This entire page unpacks the following Slang shader code, which implements the alpha-blending forward pass and its analytic inverse for backpropagation.
[Differentiable]
float4 preMult(float4 pixel) {
return float4(pixel.rgb * pixel.a, pixel.a);
}
[Differentiable]
float4 alphaBlend(float4 pixel, float4 gval) {
gval = preMult(gval);
return float4(
pixel.rgb + gval.rgb * pixel.a,
pixel.a * (1 - gval.a));
}
float4 undoAlphaBlend(float4 pixel, float4 gval) {
gval = preMult(gval);
var oldPixelAlpha = pixel.a / (1 - gval.a);
return float4(
pixel.rgb - gval.rgb * oldPixelAlpha,
oldPixelAlpha);
}
struct PixelState : IDifferentiable {
float4 value;
uint finalCount;
};
[Differentiable]
PixelState transformPixelState(PixelState pixel, float4 gval) {
var newState = alphaBlend(pixel.value, gval);
if (pixel.value.a < 1.f / 255.f)
return { pixel.value, pixel.finalCount };
return { newState, pixel.finalCount + 1 };
}
PixelState undoPixelState(PixelState nextState, uint index, float4 gval) {
if (index > nextState.finalCount)
return { nextState.value, nextState.finalCount };
return { undoAlphaBlend(nextState.value, gval), nextState.finalCount - 1 };
}
Standard RGBA stores colour and transparency as independent values: (r, g, b, a). Premultiplied alpha folds the alpha into the colour channels: (r·a, g·a, b·a, a).
The preMult function converts straight alpha to premultiplied before every blend step. This matters because the blending formula adds the Gaussian's contribution scaled by remaining transparency — and that formula only produces the right answer when the source colour is already premultiplied.
The practical consequence is visible at transparent edges. Without premultiplying, the blending arithmetic inadvertently mixes in a dark halo from the zeroed-out RGB values at near-zero alpha. With premultiplying, those near-transparent pixels contribute almost nothing and the compositing is clean.
The effect is most dramatic at extreme alpha values — slide alpha below 0.3 or above 0.7 to see the difference clearly.
Left: without premultiplying | Right: with preMult
Each Gaussian splatted onto a pixel is blended in using alphaBlend. The accumulator pixel starts at (0, 0, 0, 1) — black colour with full remaining transparency (not full opacity). As Gaussians are blended in, colour accumulates and the remaining transparency shrinks toward zero.
Build up a stack of Gaussians below. Drag layers to reorder them — because the blending is order-dependent, reordering changes the result.
transformPixelState wraps alphaBlend with an early-exit guard:
[Differentiable]
PixelState transformPixelState(PixelState pixel, float4 gval) {
var newState = alphaBlend(pixel.value, gval);
if (pixel.value.a < 1.f / 255.f)
return { pixel.value, pixel.finalCount };
return { newState, pixel.finalCount + 1 };
}
When pixel.value.a falls below 1/255 ≈ 0.004, the pixel is effectively opaque — no further Gaussians can contribute any visible colour. Blending is halted and finalCount is frozen at the index of the last Gaussian that actually mattered.
Two reasons this matters:
During backpropagation, the renderer needs to recover the accumulator state before each Gaussian was blended — so gradients can be propagated back through each step. undoAlphaBlend inverts the blend analytically.
Deriving the inverse:
Note: old_alpha is divided from new_alpha. If gval.a = 1 then the denominator is zero and old_alpha would be infinite. The termination threshold in transformPixelState prevents the pixel from ever reaching a state that would require such an inversion.
Step through the forward and backward passes below. Each forward step adds a Gaussian; each backward step analytically recovers the state before that Gaussian was blended in.
The backward pass doesn't simply walk every Gaussian in reverse order. It uses finalCount — stored in PixelState — to know exactly how many blends actually happened. Gaussian indices beyond finalCount are silently skipped.
PixelState undoPixelState(PixelState nextState, uint index, float4 gval) {
if (index > nextState.finalCount)
return { nextState.value, nextState.finalCount }; // skip
return { undoAlphaBlend(nextState.value, gval), nextState.finalCount - 1 };
}
A concrete example with finalCount = 7:
The check index > nextState.finalCount is evaluated on the current state, not the original. As each undo decrements finalCount, the guard naturally keeps pace with the walk — no extra bookkeeping needed.
This design also handles the degenerate case where a pixel never reached opacity saturation: finalCount just equals the total number of Gaussians, and every step is undone normally.