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
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
title: Fragment Stage
description: "Customize the final Gaussian splat fragment color with the gsplatModifyPS shader chunk: per-pixel color modification, GLSL/WGSL, and available shader inputs."
---

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

The `gsplatModifyPS` chunk customizes the final splat color in the fragment stage. It runs **once per covered pixel**, so effects can vary smoothly across a splat's footprint — something the per-splat vertex stage cannot do.

**View Live Example** - Each splat rendered as a ring of its own color, with a highlight wave.

<EngineExample id="gaussian-splatting/shader-rings" title="View Live Example" />

## Overridable Function

The chunk overrides a single function:

<Tabs groupId="shader-language" queryString="lang">
<TabItem value="glsl" label="GLSL">

```glsl
void modifySplatColor(vec2 gaussianUV, inout vec4 color);
```

</TabItem>
<TabItem value="wgsl" label="WGSL">

```wgsl
fn modifySplatColor(gaussianUV: vec2f, color: ptr<function, vec4f>);
```

</TabItem>
</Tabs>

It is called in the forward pass after the gaussian falloff and opacity dither have been evaluated, just before the color is premultiplied and output:

- `gaussianUV` — the fragment's position within the gaussian footprint: `(0,0)` at the splat center, length 1 at the edge where the splat is clipped. `dot(gaussianUV, gaussianUV)` gives the normalized squared radius used by the falloff.
- `color` — `rgb` is the splat color, `a` is the final fragment alpha. Both can be modified; alpha changes affect the blending weight, enabling custom falloffs or per-pixel fades.

## Available Inputs

Inside the chunk you can also use:

- `gl_FragCoord` (GLSL) / `pcPosition` (WGSL) — the fragment's framebuffer position in pixels
- `uScreenSize` — engine-provided `vec4` uniform: `xy` = render target size, `zw` = inverse size
- Your own uniforms and textures, declared in the chunk and driven via material parameters

## Example

This is the chunk used by the live example above. It renders each splat as a ring of its own color: `gaussianUV` provides the position within the splat footprint, and `fwidth` converts the requested ring width from pixels into footprint units, keeping the ring a constant screen-space width at any zoom:

<Tabs groupId="shader-language" queryString="lang">
<TabItem value="glsl" label="GLSL">

```glsl
uniform float uRingWidth;
uniform float uRingAlpha;

void modifySplatColor(vec2 gaussianUV, inout vec4 color) {
// distance from the splat center: 0 at center, 1 at the clipping edge
float radius = length(gaussianUV);

// ring of constant screen-space width at the splat edge - fwidth gives the
// change of radius per screen pixel, converting pixels to radius units
float radiusPerPixel = fwidth(radius);
float innerEdge = 1.0 - uRingWidth * radiusPerPixel;
float ring = smoothstep(innerEdge - radiusPerPixel, innerEdge, radius);
color.a = ring * uRingAlpha;
}
```

</TabItem>
<TabItem value="wgsl" label="WGSL">

```wgsl
uniform uRingWidth: f32;
uniform uRingAlpha: f32;

fn modifySplatColor(gaussianUV: vec2f, color: ptr<function, vec4f>) {
// distance from the splat center: 0 at center, 1 at the clipping edge
let radius = length(gaussianUV);

// ring of constant screen-space width at the splat edge - fwidth gives the
// change of radius per screen pixel, converting pixels to radius units
let radiusPerPixel = fwidth(radius);
let innerEdge = 1.0 - uniform.uRingWidth * radiusPerPixel;
let ring = smoothstep(innerEdge - radiusPerPixel, innerEdge, radius);
*color = vec4f((*color).rgb, ring * uniform.uRingAlpha);
}
```

</TabItem>
</Tabs>

Apply it the same way as the vertex chunk, using the `gsplatModifyPS` key, and drive the uniforms via material parameters:

```javascript
const sceneMat = app.scene.gsplat.material;

sceneMat.getShaderChunks('glsl').set('gsplatModifyPS', glslFragShader);
sceneMat.getShaderChunks('wgsl').set('gsplatModifyPS', wgslFragShader);
sceneMat.setParameter('uRingWidth', 1);
sceneMat.setParameter('uRingAlpha', 0.25);
sceneMat.update();
```

For an effect that samples a screen-aligned texture at each fragment's screen position, see [Relighting](/user-manual/gaussian-splatting/building/relighting) — it modulates splats by the lighting of a proxy mesh rendered to an offscreen texture.

## See Also

- [Vertex Stage Customization](/user-manual/gaussian-splatting/building/custom-shaders/vertex) — move, scale and tint splats
- [Varying Streams](/user-manual/gaussian-splatting/building/custom-shaders/varyings) — read per-splat values written by the vertex stage
- [Relighting](/user-manual/gaussian-splatting/building/relighting) — light splats using a proxy mesh, built on this hook
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
title: Custom Shaders
description: "Customize Gaussian splat rendering with shader chunks on the scene gsplat material: vertex and fragment stage hooks, how to choose between them, and how to apply them."
---

The PlayCanvas Engine lets you customize how Gaussian Splats are rendered by overriding shader chunks. The chunks are set on the scene-wide gsplat material ([`app.scene.gsplat.material`](https://api.playcanvas.com/engine/classes/GSplatParams.html#material)), so a single custom shader applies to **all** splats in the scene.

There are two customization points, one per shader stage:

| Chunk | Stage | Runs | Purpose |
| --- | --- | --- | --- |
| [`gsplatModifyVS`](/user-manual/gaussian-splatting/building/custom-shaders/vertex) | Vertex | Once per splat | Modify splat position, rotation, scale, color and opacity |
| [`gsplatModifyPS`](/user-manual/gaussian-splatting/building/custom-shaders/fragment) | Fragment | Once per covered pixel | Modify the final color and alpha of each splat fragment |

## Choosing a Stage

**Use the [vertex stage](/user-manual/gaussian-splatting/building/custom-shaders/vertex)** for anything that is uniform across a splat: moving, scaling, rotating, hiding splats, or tinting them based on their position. It runs once per splat, so it is the cheaper option and the only one that can change splat geometry.

**Use the [fragment stage](/user-manual/gaussian-splatting/building/custom-shaders/fragment)** when the effect needs to vary *across* a splat's footprint — for example when sampling a texture. It runs once per covered fragment, so it costs more on heavily overlapping splats.

The two stages can be combined freely — implement either or both. Per-splat values can also be passed from the vertex stage to the fragment stage using [varying streams](/user-manual/gaussian-splatting/building/custom-shaders/varyings) — for example to classify a splat once and pay per-pixel cost only where needed.

## Applying Chunks

Both chunks follow the same pattern: set the chunk source for each shader language (GLSL covers WebGL, WGSL covers WebGPU), then update the material to recompile:

```javascript
const sceneMat = app.scene.gsplat.material;

sceneMat.getShaderChunks('glsl').set('gsplatModifyVS', glslChunk);
sceneMat.getShaderChunks('wgsl').set('gsplatModifyVS', wgslChunk);
sceneMat.update();
```

Custom uniforms declared by your chunks are driven through material parameters each frame:

```javascript
app.on('update', (dt) => {
sceneMat.setParameter('uTime', currentTime);
sceneMat.update();
});
```

## Removing Chunks

To revert to default rendering, delete the chunk override and update the material:

```javascript
const sceneMat = app.scene.gsplat.material;
sceneMat.getShaderChunks('glsl').delete('gsplatModifyVS');
sceneMat.getShaderChunks('wgsl').delete('gsplatModifyVS');
sceneMat.update();
```

## See Also

- [Vertex Stage Customization](/user-manual/gaussian-splatting/building/custom-shaders/vertex) — move, scale and tint splats
- [Fragment Stage Customization](/user-manual/gaussian-splatting/building/custom-shaders/fragment) — per-pixel color modification
- [Varying Streams](/user-manual/gaussian-splatting/building/custom-shaders/varyings) — pass per-splat data from the vertex stage to the fragment stage
- [Relighting](/user-manual/gaussian-splatting/building/relighting) — light splats using a proxy mesh, built on the fragment hook
- [Work Buffer Rendering](/user-manual/gaussian-splatting/rendering-architecture/work-buffer-rendering) — customize the global render pass that draws the sorted splats
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
---
title: Varying Streams
description: "Pass per-splat data from the gsplat vertex stage to the fragment stage using custom varying streams: API, generated set/get functions, and a live clipping example."
---

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

Varying streams pass custom per-splat data from the [vertex stage](/user-manual/gaussian-splatting/building/custom-shaders/vertex) to the [fragment stage](/user-manual/gaussian-splatting/building/custom-shaders/fragment). 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.

<EngineExample id="gaussian-splatting/clipping" title="View Live Example" />

## Adding Streams

Streams are managed via [`app.scene.gsplat.varyings`](https://api.playcanvas.com/engine/classes/GSplatParams.html#varyings):

```javascript
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:

| Function | Available in | Purpose |
| --- | --- | --- |
| `set<Name>(value)` | `gsplatModifyVS` | Write the per-splat value (runs once per splat) |
| `get<Name>()` | `gsplatModifyPS` | Read 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:**

<Tabs groupId="shader-language" queryString="lang">
<TabItem value="glsl" label="GLSL">

```glsl
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) {
}
```

</TabItem>
<TabItem value="wgsl" label="WGSL">

```wgsl
uniform uClipCenter: vec3f;
uniform uClipHalf: vec3f;

fn modifySplatCenter(center: ptr<function, vec3f>) {
}

fn modifySplatRotationScale(originalCenter: vec3f, modifiedCenter: vec3f, rotation: ptr<function, vec4f>, scale: ptr<function, vec3f>) {
// signed distance of the splat center from the clipping box surface (negative inside)
let d = abs(modifiedCenter - uniform.uClipCenter) - uniform.uClipHalf;
let sdf = length(max(d, vec3f(0.0))) + min(max(d.x, max(d.y, d.z)), 0.0);

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

if (sdf < -radius) {
// fully inside the box - clip the whole splat
*scale = vec3f(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);
}
}

fn modifySplatColor(center: vec3f, color: ptr<function, vec4f>) {
}
```

</TabItem>
</Tabs>

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

<Tabs groupId="shader-language" queryString="lang">
<TabItem value="glsl" label="GLSL">

```glsl
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;
}
}
```

</TabItem>
<TabItem value="wgsl" label="WGSL">

```wgsl
uniform uClipCenter: vec3f;
uniform uClipHalf: vec3f;
uniform uInvViewProj: mat4x4f;
uniform uScreenSize: vec4f;

fn modifySplatColor(gaussianUV: vec2f, color: ptr<function, vec4f>) {
// 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)
let uv = pcPosition.xy * uniform.uScreenSize.zw;
let ndc = vec3f(uv.x * 2.0 - 1.0, (1.0 - uv.y) * 2.0 - 1.0, pcPosition.z * 2.0 - 1.0);
let world = uniform.uInvViewProj * vec4f(ndc, 1.0);
let worldPos = world.xyz / world.w;

// clip fragments inside the box
let d = abs(worldPos - uniform.uClipCenter) - uniform.uClipHalf;
if (max(d.x, max(d.y, d.z)) < 0.0) {
*color = vec4f((*color).rgb, 0.0);
}
}
```

</TabItem>
</Tabs>

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

- [Vertex Stage Customization](/user-manual/gaussian-splatting/building/custom-shaders/vertex) — where the values are written
- [Fragment Stage Customization](/user-manual/gaussian-splatting/building/custom-shaders/fragment) — where the values are read
Loading