Skip to content

Multiple render targets - #189

Draft
iCiaran wants to merge 1 commit into
developfrom
multiple-render-targets
Draft

iCiaran wants to merge 1 commit into
developfrom
multiple-render-targets

Conversation

@iCiaran

@iCiaran iCiaran commented Feb 15, 2026

Copy link
Copy Markdown
Collaborator

Not handwritten - probably not mergeable, pushing as a reference

@iCiaran

iCiaran commented Feb 15, 2026

Copy link
Copy Markdown
Collaborator Author

PGE3 Multiple Render Targets (MRT) Support

This document describes the changes made to olcPixelGameEngine3 to support Multiple Render Targets (MRT). MRT allows a single draw call to write to several framebuffer color attachments simultaneously, enabling techniques like deferred rendering (G-buffer).

The existing engine already had most of the GL plumbing in place (8-attachment array, glDrawBuffers loaded, shared depth RBO) but only ever activated one attachment at a time. These changes expose that capability through the public API.


Summary of Changes

# Area What
1 GL constants Add GL_NONE_X constant
2 Renderer state Add nActiveAttachmentMask member
3 Renderer interface Add DetachTextureTarget() pure virtual
4 Renderer_OGL33 Implement DetachTextureTarget()
5 Renderer_OGL33 Modify AssignTextureTarget() to track and enable all active slots
6 Draw class Add SetTargets() method
7 Draw class Add stale attachment cleanup to SetTarget()

Change 1: GL_NONE_X Constant

Location: GL constants section of the gl class (alongside GL_COLOR_ATTACHMENT0_X, GL_FRAMEBUFFER_X, etc.)

static constexpr GLenum GL_NONE_X = 0;

Why: glDrawBuffers requires GL_NONE (value 0) for inactive attachment slots. Without this, the fragment shader's layout(location = N) outputs won't map correctly to the intended attachments — active slots must appear at their exact index position, with GL_NONE filling the gaps.


Change 2: nActiveAttachmentMask Member

Location: Renderer_OGL33 protected members (alongside nDepthRBO, vCurrentDepthSize, etc.)

uint8_t nActiveAttachmentMask = 0x01; // Bitmask of active MRT color attachment slots

Why: Tracks which of the 8 possible color attachment slots currently have a texture/renderbuffer attached. Bit 0 = slot 0, bit 1 = slot 1, etc. Defaults to 0x01 (slot 0 only) matching the existing single-target behavior. Used by AssignTextureTarget and DetachTextureTarget to rebuild the glDrawBuffers array after any attachment change.


Change 3 & 4: DetachTextureTarget

Interface Declaration

Location: Renderer base class, after AssignTextureTarget

// Detaches any texture from the specified render target slot
virtual bool DetachTextureTarget(const uint32_t slot) = 0;

OGL33 Class Declaration

Location: Renderer_OGL33, after AssignTextureTarget override

bool DetachTextureTarget(const uint32_t slot) override;

OGL33 Implementation

Location: After AssignTextureTarget implementation, before ResolveMSAA

bool Renderer_OGL33::DetachTextureTarget(const uint32_t slot)
{
    // No-op if slot is not active
    if (!(nActiveAttachmentMask & (1 << slot)))
        return true;

    auto& gl = olc::apis::opengl::gl::Get();

    // Detach texture from this attachment slot
    gl.glFramebufferTexture2D(
        gl.GL_FRAMEBUFFER_X,
        gl.GL_COLOR_ATTACHMENT0_X + slot,
        GL_TEXTURE_2D,
        0,
        0
    );

    // Also detach any renderbuffer (MSAA case)
    gl.glFramebufferRenderbuffer(
        gl.GL_FRAMEBUFFER_X,
        gl.GL_COLOR_ATTACHMENT0_X + slot,
        gl.GL_RENDERBUFFER_X,
        0
    );

    // Clear the bit and rebuild draw buffers
    nActiveAttachmentMask &= ~(1 << slot);

    std::array<GLenum, 8> drawBuffers;
    int maxSlot = 0;
    for (int i = 0; i < 8; i++)
    {
        if (nActiveAttachmentMask & (1 << i))
        {
            drawBuffers[i] = gl.GL_COLOR_ATTACHMENT0_X + i;
            maxSlot = i + 1;
        }
        else
        {
            drawBuffers[i] = gl.GL_NONE_X;
        }
    }
    if (maxSlot > 0)
        gl.glDrawBuffers(maxSlot, drawBuffers.data());

    return true;
}

Why: Needed to cleanly tear down MRT state when switching back to single-target rendering. Detaches both the texture (regular path) and renderbuffer (MSAA path) from the specified FBO slot, then rebuilds the glDrawBuffers array to reflect the remaining active attachments.

The no-op guard on the bitmask means calling DetachTextureTarget on an already-detached slot is free, so callers can loop over all 8 slots without cost.


Change 5: Modified AssignTextureTarget

Location: Renderer_OGL33::AssignTextureTarget implementation

5a. Reset mask when binding screen framebuffer

In the texid == 0 branch (binding the default/screen framebuffer), reset the mask before returning:

if (texid == 0)
{
    nActiveAttachmentMask = 0x01;  // <-- NEW
    gl.glBindFramebuffer(gl.GL_FRAMEBUFFER_X, nScreenFBO);
    return true;
}

Why: The screen framebuffer has its own draw buffer configuration managed by the OS/driver. Resetting the mask ensures that subsequent AssignTextureTarget calls for the offscreen FBO start from a clean state.

5b. Replace single-slot glDrawBuffers with bitmask-driven rebuild

The original code selected a single attachment for glDrawBuffers:

// BEFORE (removed):
std::array<GLenum, 8> attachments = { /* GL_COLOR_ATTACHMENT0..7 */ };
GLenum draw = attachments[slot];
gl.glDrawBuffers(1, &draw);

Replaced with:

// AFTER:
// Update active attachment bitmask
nActiveAttachmentMask |= (1 << slot);

// Rebuild draw buffers list from all active slots (GL_NONE for inactive)
std::array<GLenum, 8> drawBuffers;
int maxSlot = 0;
for (int i = 0; i < 8; i++)
{
    if (nActiveAttachmentMask & (1 << i))
    {
        drawBuffers[i] = gl.GL_COLOR_ATTACHMENT0_X + i;
        maxSlot = i + 1;
    }
    else
    {
        drawBuffers[i] = gl.GL_NONE_X;
    }
}
gl.glDrawBuffers(maxSlot, drawBuffers.data());

Why: The original code only ever enabled a single draw buffer, even though the FBO could have multiple textures attached. With MRT, after attaching textures to slots 0, 1, and 2 via successive calls, glDrawBuffers must be told about all three simultaneously. The GL_NONE entries for inactive slots ensure that layout(location = N) in the fragment shader maps to the correct attachment index.

Backward compatibility: When only slot 0 is used (the existing single-target path), the mask is 0x01 and the output is glDrawBuffers(1, {GL_COLOR_ATTACHMENT0}) — identical to the original behavior.


Change 6: Draw::SetTargets

Declaration

Location: Draw class, after SetTarget

// Sets multiple drawing targets (MRT) for this drawing toolbox
void SetTargets(std::initializer_list<olc::Image*> targets);

Implementation

Location: After Draw::SetTarget implementation

void Draw::SetTargets(std::initializer_list<olc::Image*> targets)
{
    // Perform any outstanding tasks for current target
    ProcessGPUTasks();

    // MSAA resolve for previous target if needed
    if (pTarget && pTarget->GetConfig().MSAA)
        pRenderer->ResolveMSAA(uint32_t(pTarget->GetGPUID()));

    // Attach each image to its corresponding slot
    olc::vi2d size = {0, 0};
    uint32_t slot = 0;
    for (auto* img : targets)
    {
        PrepareImageForHW(*img);
        if (slot == 0)
            size = img->Size();
        pRenderer->AssignTextureTarget(slot, uint32_t(img->GetGPUID()));
        slot++;
    }

    // Detach any previously-used higher slots
    for (uint32_t i = slot; i < 8; i++)
        pRenderer->DetachTextureTarget(i);

    // Track slot 0 as the "primary" target
    pTarget = *targets.begin();
    WorldReset();
    pRenderer->SetViewport({0, 0}, size);
}

Why: User-facing API for MRT. Takes an initializer list of Image pointers, attaches each to consecutive FBO slots (0, 1, 2, ...), and cleans up any higher slots that may have been active from a previous call. The first image (slot 0) is used as the "primary" target for GetTarget() / GetTargetSize() and determines the viewport dimensions.

Usage example:

draw.SetTargets({&albedo, &normal, &depth});  // attach 3 render targets
draw.Clear(olc::BLACK);                        // clears all 3 + depth buffer
draw.Mesh(...);                                // fragment shader writes to all 3
draw.SetTarget(screen);                        // back to single target (cleans up slots 1-2)

Constraints: All target images must be the same size (OpenGL requirement for FBO completeness). The shared depth renderbuffer is automatically sized to match slot 0's dimensions by AssignTextureTarget.


Change 7: Stale Attachment Cleanup in SetTarget

Location: Draw::SetTarget implementation, after the existing AssignTextureTarget(0, ...) call

// Configure default render target
pRenderer->AssignTextureTarget(0, uint32_t(pTarget->GetGPUID()));
pRenderer->SetViewport({ 0,0 }, pTarget->Size());

// Detach any MRT attachments from a previous SetTargets call
for (uint32_t i = 1; i < 8; i++)
    pRenderer->DetachTextureTarget(i);

Why: Without this, switching from SetTargets({&a, &b, &c}) back to SetTarget(screen) would leave textures b and c attached to FBO slots 1 and 2. While glDrawBuffers would only write to slot 0, the stale attachments keep unnecessary references to those textures and could cause confusing behavior if the FBO is inspected or if a future AssignTextureTarget(0, ...) call doesn't explicitly clear them.

The DetachTextureTarget no-op guard makes this loop essentially free when no MRT attachments exist.


Fragment Shader Authoring

With MRT enabled, fragment shaders that write to multiple targets must declare explicit output locations instead of using the default pixel variable from PS_DefaultHeader():

// Instead of using PS_DefaultHeader (which declares: layout(location = 0) out vec4 pixel)
// write a custom header with multiple outputs:

layout(location = 0) out vec4 outAlbedo;
layout(location = 1) out vec4 outNormal;
layout(location = 2) out vec4 outDistance;

The location index corresponds directly to the slot order passed to SetTargets. The remaining PGE-required uniforms (pgeTargetSizeInPixels, pgeTexture0-3, etc.) must still be declared — copy them from PS_DefaultHeader or include the header and add your outputs separately.

Shaders that only write to a single target (including the default shader) continue to work unchanged — layout(location = 0) out vec4 pixel maps to slot 0.

@Johnnyg63

Copy link
Copy Markdown
Collaborator

Brilliant!!! Just brilliant 👏. I didn't fully understand it, I say I will need to debug it to understand it better

Do you think we should add this to PGE 3? I see you have it setup so that the existing functionality is not affected

Again thank you, impressive work

@iCiaran

iCiaran commented Feb 15, 2026

Copy link
Copy Markdown
Collaborator Author

I can't claim any credit, I do think it was eventually intended to be added though - all the scaffolding was already in place

@Johnnyg63

Copy link
Copy Markdown
Collaborator

I say a lot of the credit is yours, you might of worked off an example but it was you who put all the pieces together to get it running smooth

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants