Tutorial · Post-process

Add a post-processing effect

Chain full-screen compute passes to apply blur, desaturation and other image effects at the end of the render.

DIFFICULTY Beginner
DURATION ~15 min
API FullScreenCompute

1. What you will build

In this tutorial you will add a full-screen post-processing effect to your render: a Gaussian blur followed by a desaturation. A post-processing pass is a compute effect applied to the whole image, after the color and bloom passes, and before anti-aliasing.

The renderer keeps an ordered list of passes: each one reads the image produced by the previous pass and writes into its own output. The chain is therefore built in the order of your addPostprocessing() calls.

01ColorScene rendering
02BloomBright extraction
03Post-processYour compute chainhere
04Anti-aliasingFXAA / SMAA
Position of the post-processing chain within the frame.

2. Anatomy of a pass

A post-processing pass derives from FullScreenCompute. For simple effects you don't even need to subclass it: just instantiate the class with the name of a compiled compute shader and, optionally, a uniform data block.

Constructor

FullScreenCompute(
    const RendererConfiguration& config,
    vireo::ImageFormat outputFormat,
    const std::string& compShaderName,    // pass identifier
    void* data = nullptr,                 // uniform payload (optional)
    uint32 dataSize = 0,
    const std::string& name = "");

The shader receives a fixed set of inputs through a texture array whose indices are exposed as constants:

ConstantContents
INPUT_BUFFERHDR color image (output of the previous pass)
DEPTH_BUFFERDepth buffer
BLOOM_BUFFERBloom result
OPT1_BUFFER / OPT2_BUFFERFree slots, defined by the pass
Note

The compute local workgroup size is TILE_SIZE = 16. It must stay consistent with your shader (see postprocess.inc.slang).

3. Add the chain

Passes must outlive the Renderer: declare them as persistent members of your scene, never as local variables. The add order determines the apply order.

// Persistent scene members
lysa::BlurData blurData;
lysa::FullScreenCompute blur(
    renderTarget.getRendererConfiguration(),
    renderTarget.getImageFormat(),
    "blur",                 // compiled compute shader in shaders/
    &blurData,              // uniform block
    sizeof(lysa::BlurData));

lysa::FullScreenCompute greyscale(
    renderTarget.getRendererConfiguration(),
    renderTarget.getImageFormat(),
    "greyscale");           // no data: data == nullptr

auto& renderer = renderTarget.getRenderer();
renderer.addPostprocessing(blur);       // applied first
renderer.addPostprocessing(greyscale);  // then on the blur result

On registration, addPostprocessing() opens a command list, calls resize() on the pass to allocate its output image at the current extent, then appends the pointer to the list. The pass is ready on the next frame.

4. Update the parameters

The uniform block pointed to at construction is re-uploaded every frame: it is enough to modify the CPU-side struct. For resolution-dependent parameters — such as the blur radius in pixels — recompute them on the resize event.

void MyScene::onResize(const vireo::Extent& extent) {
    blurData.update(extent, 2.0f);   // radius in pixels
}

The BlurData struct pre-computes the normalized Gaussian weights and the texel size, which are then read directly by the shader.

5. Remove a pass

A pass is identified by its compute shader name. You can remove it by name or by reference to the object:

renderer.removePostprocessing("greyscale");   // by shader name
renderer.removePostprocessing(blur);           // by pass reference
Caution

Because identification is by shader name, two passes sharing the same compute shader cannot be distinguished: removing by name removes all matching passes.

6. Bonus: visualize an attachment

DisplayAttachment is a specialized post-processing pass that copies an internal attachment to the output. Handy for inspecting a G-Buffer layer, ambient occlusion, or a shadow map while debugging.

lysa::DisplayAttachment display(
    renderTarget.getRendererConfiguration(),
    renderTarget.getImageFormat());

renderer.addPostprocessing(display);

// Pick the attachment to visualize
display.setAttachment(
    dynamic_cast<lysa::DeferredRenderer&>(renderer)
        .getAOPass().getColorAttachment(frameIndex));
Takeaways

(1) Passes belong to the caller and must outlive the Renderer. (2) Adding sizes the pass automatically via resize(). (3) The add order fixes the apply order.

Post-process Shader material →