Skip to main content

Varying Streams

Varying streams pass custom per-splat data from the vertex stage to the fragment stage. A value is computed once per splat in the gsplatModifyVS chunk and read by every fragment of that splat in the gsplatModifyPS chunk.

The typical use is classification: decide something about a splat once, then let the fragment stage pay per-pixel cost only where needed.

View Live Example - Splats clipped by an animated box, with per-pixel clipping only on splats intersecting the box surface.

Clipping

Adding Streams

Streams are managed via app.scene.gsplat.varyings:

app.scene.gsplat.varyings.add([
{ name: 'clipState', type: pc.TYPE_UINT32, components: 1 }
]);

// later, to remove
app.scene.gsplat.varyings.remove(['clipState']);

Supported types are TYPE_FLOAT32, TYPE_INT32 and TYPE_UINT32, with 1 to 4 components.

For each stream, two functions are generated and made available to your shader chunks:

FunctionAvailable inPurpose
set<Name>(value)gsplatModifyVSWrite the per-splat value (runs once per splat)
get<Name>()gsplatModifyPSRead the per-splat value for the current fragment

Adding or removing streams rebuilds the gsplat shaders, so configure them at startup rather than toggling them at runtime.

Example

The live example above clips splats by an animated world-space box. The vertex stage classifies each splat against the box once per splat: splats fully inside are clipped entirely, splats fully outside set a flag so their fragments skip all work, and only splats intersecting the box surface run the per-pixel test.

1. Write the per-splat value in the vertex stage chunk:

uniform vec3 uClipCenter;
uniform vec3 uClipHalf;

void modifySplatCenter(inout vec3 center) {
}

void modifySplatRotationScale(vec3 originalCenter, vec3 modifiedCenter, inout vec4 rotation, inout vec3 scale) {
// signed distance of the splat center from the clipping box surface (negative inside)
vec3 d = abs(modifiedCenter - uClipCenter) - uClipHalf;
float sdf = length(max(d, vec3(0.0))) + min(max(d.x, max(d.y, d.z)), 0.0);

// conservative splat radius
float radius = 2.0 * gsplatGetSizeFromScale(scale);

if (sdf < -radius) {
// fully inside the box - clip the whole splat
scale = vec3(0.0);
setClipState(1u);
} else if (sdf > radius) {
// fully outside the box - no per-pixel clipping needed
setClipState(1u);
} else {
// intersects the box surface - clip per pixel in the fragment shader
setClipState(0u);
}
}

void modifySplatColor(vec3 center, inout vec4 color) {
}

2. Read it in the fragment stage chunk and early-out before the expensive per-pixel work:

uniform vec3 uClipCenter;
uniform vec3 uClipHalf;
uniform mat4 uInvViewProj;
uniform vec4 uScreenSize;

void modifySplatColor(vec2 gaussianUV, inout vec4 color) {
// splats fully inside or outside the box were already resolved per splat in the vertex stage
if (getClipState() == 1u) return;

// reconstruct the world position of this fragment (on the splat's depth plane)
vec3 ndc = vec3(gl_FragCoord.xy * uScreenSize.zw, gl_FragCoord.z) * 2.0 - 1.0;
vec4 world = uInvViewProj * vec4(ndc, 1.0);
vec3 worldPos = world.xyz / world.w;

// clip fragments inside the box
vec3 d = abs(worldPos - uClipCenter) - uClipHalf;
if (max(d.x, max(d.y, d.z)) < 0.0) {
color.a = 0.0;
}
}

Both chunks are applied to the scene gsplat material as usual, using the gsplatModifyVS and gsplatModifyPS keys.

Memory Considerations

On some platforms each component is stored in per-splat video memory, so its size scales with the number of rendered splats. Keep the data as compact as possible - prefer fewer components, and consider bit-packing multiple small values into a single uint component instead of using separate streams.

See Also