Tutorial · Custom pass

Write a custom render pass

Inject your own GPU work — pipeline, descriptors, barriers and draw — at a specific phase of the frame.

DIFFICULTY Advanced
DURATION ~30 min
API CustomRenderpass

1. What you will build

You will write a CustomRenderpass: a pass that injects arbitrary GPU work — graphics or compute — at a specific phase of the frame. It is the freest of the three tools: you control the pipeline, the descriptors, the barriers and the draw.

A custom pass runs after the built-in ShaderMaterialPass of the same phase. It has a single obligation: implement render().

2. The interface to implement

class CustomRenderpass : public Renderpass {
public:
    CustomRenderpass(const RendererConfiguration& config,
                     const std::string& name)
        : Renderpass{config, name} {}

    // Called once per frame for the associated phase
    virtual void render(
        const std::shared_ptr<vireo::CommandList>& commandList,
        const std::shared_ptr<vireo::RenderTarget>& colorTarget,
        const std::shared_ptr<vireo::RenderTarget>& depthTarget,
        uint32 frameIndex) = 0;
};

Two hooks inherited from Renderpass are available: resize(extent, commandList) to recreate resolution-dependent resources, and update(frameIndex) to refresh per-frame state. Both are no-ops by default.

3. Build the pipeline and resources

In the constructor, you create a descriptor layout, a graphics pipeline, and a per-frame-in-flight data structure. This example — a full-screen zone outline — reads depth, a camera uniform and a zones storage buffer.

ZoneOutlinePass::ZoneOutlinePass(
    const RendererConfiguration& config,
    vireo::ImageFormat outputFormat) :
    CustomRenderpass{config, "ZoneOutlinePass"} {

    descriptorLayout = ctx().vireo->createDescriptorLayout();
    descriptorLayout->add(BINDING_DEPTH,  vireo::DescriptorType::SAMPLED_IMAGE);
    descriptorLayout->add(BINDING_CAMERA, vireo::DescriptorType::UNIFORM);
    descriptorLayout->add(BINDING_ZONES,  vireo::DescriptorType::DEVICE_STORAGE);
    descriptorLayout->build();

    pipelineConfig.resources = ctx().vireo->createPipelineResources(
        {descriptorLayout, ctx().samplers.getDescriptorLayout()}, {}, name);
    pipelineConfig.colorRenderFormats = {outputFormat};
    pipelineConfig.vertexShader   = loadShader("quad.vert");
    pipelineConfig.fragmentShader = loadShader("zone_outline.frag");
    pipeline = ctx().vireo->createGraphicPipeline(pipelineConfig, name);

    renderingConfig.colorRenderTargets[0].clear = false;  // compose, do not clear

    framesData.resize(ctx().config.framesInFlight);
    for (auto& frame : framesData) {
        frame.descriptorSet = ctx().vireo->createDescriptorSet(descriptorLayout, name);
        frame.cameraBuffer  = ctx().vireo->createBuffer(
            vireo::BufferType::UNIFORM, sizeof(CameraData), 1, name);
        frame.cameraBuffer->map();
        frame.descriptorSet->update(BINDING_CAMERA, frame.cameraBuffer);
    }
}

4. Implement render()

The method receives the phase's color and depth targets. The typical pattern: early-out if there is nothing to draw, update the uniforms, transition barriers, draw, then restore the barriers. Note the barrier pair that moves the color from COMPUTE_READ to RENDER_TARGET_COLOR during the draw.

void ZoneOutlinePass::render(
    const std::shared_ptr<vireo::CommandList>& commandList,
    const std::shared_ptr<vireo::RenderTarget>& colorTarget,
    const std::shared_ptr<vireo::RenderTarget>& depthTarget,
    uint32 frameIndex) {
    if (camera == nullptr) { return; }              // early out
    auto& frame = framesData[frameIndex];

    frame.cameraData.projectionInverse = inverse(camera->projection);
    frame.cameraData.viewInverse       = camera->transform;
    frame.cameraBuffer->write(&frame.cameraData);

    frame.descriptorSet->update(BINDING_DEPTH, depthTarget->getImage());
    renderingConfig.colorRenderTargets[0].renderTarget = colorTarget;

    commandList->barrier(colorTarget,
        vireo::ResourceState::COMPUTE_READ, vireo::ResourceState::RENDER_TARGET_COLOR);
    commandList->barrier(depthTarget,
        vireo::ResourceState::RENDER_TARGET_DEPTH, vireo::ResourceState::SHADER_READ);

    commandList->beginRendering(renderingConfig);
    commandList->bindPipeline(pipeline);
    commandList->bindDescriptors({frame.descriptorSet, ctx().samplers.getDescriptorSet()});
    commandList->draw(3);                            // full-screen triangle
    commandList->endRendering();

    commandList->barrier(depthTarget,
        vireo::ResourceState::SHADER_READ, vireo::ResourceState::RENDER_TARGET_DEPTH);
    commandList->barrier(colorTarget,
        vireo::ResourceState::RENDER_TARGET_COLOR, vireo::ResourceState::COMPUTE_READ);
}
Barriers

Always restore the targets' state at the end of the pass. The next pass expects to find the color in COMPUTE_READ and the depth in RENDER_TARGET_DEPTH.

5. Handle resizing

Override resize() to recreate or record anything that depends on resolution. Here, we simply keep the screen size, used by the shader to reproject each pixel.

void ZoneOutlinePass::resize(const vireo::Extent& extent,
                            const std::shared_ptr<vireo::CommandList>&) {
    currentScreenSize = float2{float(extent.width), float(extent.height)};
}

6. Register the pass

You attach the pass to a phase. The caller retains ownership: the pass must outlive the Renderer. The registration order fixes the execution order within the phase.

ZoneOutlinePass outline(
    renderTarget.getRendererConfiguration(),
    renderTarget.getImageFormat());

auto& renderer = renderTarget.getRenderer();
renderer.addRenderPass(lysa::RenderingPhase::AFTER_COLOR_PASS, outline);

// ... later, to remove it (same phase as the add) ...
renderer.removeRenderPass(lysa::RenderingPhase::AFTER_COLOR_PASS, outline);

On registration, addRenderPass() calls resize() on the pass to size it, then appends it to the phase list. Removal is by reference and requires the same phase as the add; a pass that is not registered is silently ignored.

← Shader material Case study →