MSAA Fix: Resolved Framebuffer Reused as Render Target

· nat's blog


Summary #

MSAA was configured correctly and worked during the first rendered frames, but the stable MulleCG window eventually looked identical to NoAA. The problem was not in OpenGL multisampling, nanovg geometry, the nanovg shader, stencil strokes, or the MSAA resolve operation.

The framebuffer reuse cache recycled a single-sample resolved framebuffer and later reused it as the render target for a request that required an MSAA framebuffer. Reuse compatibility checked only the framebuffer dimensions and ignored its texture flags.

The fix makes framebuffer reuse require both:

  1. Matching bitmap dimensions
  2. Matching texture flags, including CGImageFlagMSAA

Rendering Architecture #

The retained/threaded MulleUI rendering path uses two graphics contexts:

For an antialiased window, one frame is intended to follow this sequence:

  1. beginRender requests an offscreen framebuffer whose texture flags contain CGImageFlagMSAA.
  2. MulleCG creates or reuses a four-sample framebuffer backed by a GL_TEXTURE_2D_MULTISAMPLE color texture and multisampled stencil buffer.
  3. nanovg flushes its drawing commands into that framebuffer while GL_MULTISAMPLE is enabled.
  4. endRender resolves the multisampled image with glBlitFramebuffer(..., GL_NEAREST) into a new single-sample framebuffer.
  5. The single-sample resolved framebuffer is handed to the display context.
  6. The display context draws the resolved texture into the visible window.
  7. After presentation, the framebuffer is recycled to the offscreen context so an allocation may be avoided on a later frame.

The resolve target must be single-sample because a multisample texture cannot be sampled by the ordinary nanovg image shader used by the display context. Consequently, the framebuffer returned by endRender is intentionally a single-sample framebuffer even though the original render target was MSAA.

What Was Wrong #

The cache lookup is implemented by -[CGContext framebufferWithSize:textureFlags:nameUTF8String:] in src/CGContext+CGFramebuffer.m.

Before the fix, it repossessed a framebuffer from _scratchRenderedFramebuffer and checked only its size:

 1framebuffer = [self repossessRenderedFramebuffer];
 2oldSize     = [framebuffer bitmapSize];
 3
 4if( ! CGPixelSizeEqualToPixelSize( oldSize, size))
 5{
 6   framebuffer = [CGFramebuffer framebufferWithBitmapSize:size
 7                                              textureFlags:textureFlags
 8                                            nameUTF8String:nameUTF8String
 9                                                    context:self];
10}

This treats all framebuffers with equal dimensions as interchangeable. They are not interchangeable. In particular:

Both may be 128x128, but they have different rendering semantics.

Failure Sequence #

The failure depended on the retained/threaded reuse cycle:

  1. The framebuffer cache was initially empty.
  2. The offscreen context created a proper MSAA framebuffer and rendered into it.
  3. endRender resolved it into a single-sample framebuffer.
  4. The resolved framebuffer was provided to the display thread.
  5. After displaying it, the display thread recycled that single-sample framebuffer into _scratchRenderedFramebuffer.
  6. On a later frame, beginRender requested another MSAA framebuffer.
  7. framebufferWithSize:textureFlags:nameUTF8String: repossessed the old resolved framebuffer.
  8. Its dimensions matched the requested dimensions.
  9. Because texture flags were not compared, the single-sample framebuffer was accepted as the render target.
  10. nanovg then rendered the frame directly into that single-sample FBO.
  11. The displayed result was jagged, even though the context still logged NVG_MSAA and called glEnable(GL_MULTISAMPLE).

Enabling GL_MULTISAMPLE cannot add multisampling to a single-sample framebuffer. The framebuffer attachments themselves must have multiple samples.

The first one or two frames could therefore be correctly antialiased. The stable result became NoAA only after a resolved framebuffer completed the cross-thread recycle cycle. This is why inspecting only context creation or an early frame made the pipeline appear correct.

Why the Initial Suspects Were Innocent #

A pure OpenGL research program in research/msaa/msaa_test.c isolated the individual parts of the pipeline.

At 128x128 it produced:

Mode Gray pixels Result
NoAA 0 Jagged
MSAA renderbuffer 562 Antialiased
MSAA texture 562 Antialiased
MSAA plus stencil stroke passes 562 Antialiased
MSAA plus reduced nanovg shader 526 Antialiased

These tests established that:

The problem therefore required the full retained framebuffer lifecycle and was not reproducible in a one-shot render/resolve test.

apitrace Evidence #

The useful traces were captured with EGL on this system:

1apitrace trace --api egl -o research.trace ./build/msaa_test
2apitrace trace --api egl -o msaa.trace mulle-sde run msaa-test

Tracing with --api gl captured GL calls but did not intercept EGL context and swap creation correctly, producing incomplete or non-replayable traces.

Before the fix #

An early MulleCG frame was correct:

1black=15254 white=604 gray=526

The only intermediate values were the expected four-sample resolve levels:

164, 127, 191

This proved that the real nanovg shader, real nanovg stencil stroke passes, multisample texture, resolve, and screen presentation all worked.

Later in the trace:

1black=15434 white=950 gray=0

That is the same black/white character as the NoAA control and explains the stable jagged window.

After the fix #

The next rendering cycle no longer accepted the recycled resolve framebuffer. The trace instead showed:

Early and later presentation snapshots were identical:

1black=15254 white=604 gray=526

The NoAA control remained:

1black=15434 white=950 gray=0

The Fix #

framebufferWithSize:textureFlags:nameUTF8String: now compares the existing framebuffer's texture flags with the requested flags:

 1CGFramebuffer   *framebuffer;
 2CGPixelSize     oldSize;
 3NSUInteger      oldTextureFlags;
 4
 5framebuffer     = [self repossessRenderedFramebuffer];
 6oldSize         = [framebuffer bitmapSize];
 7oldTextureFlags = [framebuffer textureFlags];
 8
 9if( ! CGPixelSizeEqualToPixelSize( oldSize, size) ||
10    oldTextureFlags != textureFlags)
11{
12   framebuffer = [CGFramebuffer framebufferWithBitmapSize:size
13                                              textureFlags:textureFlags
14                                            nameUTF8String:nameUTF8String
15                                                    context:self];
16}

A single-sample resolve framebuffer therefore cannot satisfy a render request containing CGImageFlagMSAA, even when the dimensions are identical.

Validation #

Validation performed after the fix:

For visual comparison:

1cd research/msaa
2cmake --build build
3./build/msaa_test

and:

1cd demo
2mulle-sde craft
3mulle-sde run msaa-test       # MSAA
4mulle-sde run msaa-test -a    # NoAA

The -a option clears UIWindowStyleAntiAlias; the default mode requests MSAA.

Note About the Old Pixel Callback #

The didRenderFrameCallback diagnostic used glReadPixels against whichever default-buffer stage was current when the callback ran. Its counts varied and did not reliably describe the frame that was subsequently presented.

Those callback counts led to several false hypotheses. The decisive evidence came from apitrace snapshots taken directly:

  1. After the MSAA resolve
  2. After the display-context presentation draw
  3. Again after framebuffer recycling had occurred

The bug was a resource compatibility error in framebuffer reuse, not a timing or synchronization failure.

last updated: