z-freeze and Mario Golf's Disappearing Menu

While working on Gecko, my GameCube and Wii emulator, I noticed that the upper parts of the player selection signs were missing in Mario Golf: Toadstool Tour. Everything below them looked fine.

Look at the portraits just below “MAIN MENU”:

The same Mario Golf scene after the fix: all four signs are complete.
Mario Golf before the fix: the sky covers the tops of the four player selection signs.

The signs had their full geometry, but a later draw was painting the sky over them because Gecko hadn’t implemented z-freeze, a feature of the GameCube’s graphics processor, GX. Dolphin’s January 2015 report also touches on this topic, I recommend giving it a read!

A bit of background

A framebuffer stores each pixel’s color, whereas a depth buffer, or Z-buffer, stores how far away the surface at that pixel is.

The same scene's depth buffer: Mario, the nearby item boxes and the road are dark, while distant racers and scenery are lighter.
Mario driving between nearby item boxes, with other racers and hills farther along Luigi Circuit.
Mario Kart Wii: darker is nearer and lighter is farther away, with contrast expanded.

If we draw a red rectangle followed by a larger blue one, blue doesn’t automatically cover red because GX runs a depth test, comparing its depth with the value already stored at each pixel.

For these examples, smaller means nearer and we’ll use the comparison “less than or equal”:

incoming depthstored depth\text{incoming depth} \le \text{stored depth}

The new pixel passes if this is true, whereas the old pixel stays if it is false.

Blue replaces red if it’s nearer or equally far away and also updates the stored depth if depth writes are enabled. Testing depth and writing depth are separate settings.

Normally, a triangle’s own position determines its depth, but z-freeze lets a later draw use a depth plane established by an earlier triangle instead.

Borrowing depth

To see what changes, draw two panels with these steps, enabling z-freeze only for the final draw in the right panel:

  1. Draw a red rectangle at depth 0.50, writing both color and depth.
  2. Draw a tiny triangle at depth 0.75, with color writes and depth writes disabled.
  3. Draw a larger blue rectangle at depth 0.25, with color and depth writes enabled again.

The tiny triangle doesn’t change any pixels, but it still establishes a depth plane for the blue rectangle to reuse.

On the left, blue uses its own depth of 0.25, so 0.250.500.25 \le 0.50 passes and it covers red completely. On the right, blue borrows 0.75 from the reference triangle, so 0.750.500.75 \le 0.50 fails and red stays visible. Outside red’s outline, blue still passes against the background’s cleared far depth.

Freezing a plane

Our reference triangle has the same depth at every corner, but a tilted triangle needs a plane equation instead of a single value:

depth(x,y)=Ax+By+C\operatorname{depth}(x,y) = Ax + By + C

In this equation, x and y are screen coordinates, A and B describe the horizontal and vertical slopes and C sets the depth at the origin. The coefficients come from the triangle’s three vertices after they’re transformed to screen coordinates: the equation must give the right depth at every corner.

For example, take these three corners, written as (x,y,depth)(x,y,\operatorname{depth}):

(0,0,0.20)(0, 0, 0.20)
C=0.20C = 0.20
(100,0,0.30)(100, 0, 0.30)
100A+0.20=0.30100A + 0.20 = 0.30
A=0.300.20100=0.001A = \dfrac{0.30 - 0.20}{100} = 0.001
(0,100,0.40)(0, 100, 0.40)
100B+0.20=0.40100B + 0.20 = 0.40
B=0.400.20100=0.002B = \dfrac{0.40 - 0.20}{100} = 0.002
depth(x,y)=0.001x+0.002y+0.20\operatorname{depth}(x,y) = 0.001x + 0.002y + 0.20

At (100,50)(100,50), that gives 0.10+0.10+0.20=0.400.10 + 0.10 + 0.20 = 0.40. With z-freeze enabled, the next shape uses this same rule, including for pixels beyond the original triangle’s edges. Any three corners determine a unique plane this way unless they collapse into a line or point on screen.

New triangles can replace the reference plane while z-freeze is off, whereas enabling it preserves the last plane. The next draw still has its own outline, colors and texture coordinates, but gets its depth from the saved equation.

Try switching between a flat and sloped reference in the graph below, which follows the rectangle’s center row with depth increasing downward.

0.00.51.0depth (near → far)0.00.51.0screen x
IncomingStoredReference

Blue uses the saved depth 0.75 and fails against red's 0.50.

With the sloped reference, blue is nearer on the left and farther away on the right because the frozen equation still gives a different result at each pixel. Depth tests and writes continue to work as usual.

Back to Mario Golf

The game’s menu does something similar to the rectangle example:

  1. Draw a small shape to establish a depth plane.
  2. Enable z-freeze.
  3. Draw the sky over the top 112 framebuffer rows using that saved plane.

The signs take the role of our red rectangle and the sky takes the role of blue, but Gecko used the sky’s own near depth and let it cover the signs. That draw ended at row 112, explaining the straight cutoff.

In this capture, the saved plane was approximately:

Z24=2.650141y+16’755’356Z_{24} = 2.650141y + \text{16'755'356}

Z24Z_{24} is the console’s 24-bit depth value, ranging from 0 to 16'777'215. At pixel (100,80)(100,80) on Mario’s sign, the depth buffer contained 16’641’130, whereas the sky’s own depth was 0. Without z-freeze, 016’641’1300 \le \text{16'641'130} passed, so the sky covered the sign.

With z-freeze enabled, the sky uses the saved equation instead. The center of that pixel is at y=80.5y = 80.5, giving us:

Z24=2.650141×80.5+16’755’35616’755’569\begin{aligned} Z_{24} &= 2.650141 \times 80.5 \\ &\quad + \text{16'755'356} \\ &\approx \text{16'755'569} \end{aligned}

Since 16’755’569>16’641’130\text{16'755'569} > \text{16'641'130}, the sky fails the depth test and the sign stays visible.

On our earlier 0-1 scale, the sign is at about 0.9919 and the sky at 0.9987, so the sky is farther away. At a background pixel on the same row, the stored depth was 16'777'215, so 16’755’56916’777’215\text{16'755'569} \le \text{16'777'215} still passes. The sky fills the background while leaving the sign alone.

Decals in Mario Power Tennis

The fix also stopped the floor decals flickering on Mario Power Tennis’s Plaza court. A decal is a detail drawn over a surface, like a sticker on a floor, whose depth is often nearly identical to the surface beneath it. Rounding differences can then make them take turns winning the depth test, causing Z-fighting, whereas sharing a depth plane keeps the comparison consistent.

Watch the missing bands in the floor logos on the left.

Implementing it

Homebrew using libogc enables the feature with GX_SetCoPlanar(GX_ENABLE). You can try the red and blue example yourself by building the standalone test with make z-freeze and running build/z-freeze.dol.

Gecko’s plane calculation and depth shader contain the implementation. I’ve also written about the triangle stretching out of Luigi’s hand in the vertex-skip post.

All that trouble over a triangle that doesn’t draw any pixels :^)

Thank you

A big thank you to Zayd for taking the time to proofread this post. He’s the creator of beanwii and also makes some really cool videos about emulation on his YouTube channel. If you enjoyed this post, go check them out!