This note covers Metal's fragment function and the fixed-function stages on either side of it.
A fragment is not a pixel
A fragment is the candidate data produced by the fact that some triangle covered some sample point. A pixel's final colour may come from several fragments (transparency layers, MSAA samples) or from none at all (culled, or no geometry there).
The distinction matters because the fragment function runs as many times as triangles cover sample points, not as many times as there are pixels on screen β which is exactly why overdraw costs performance.
MSLfragment float4 fragment_main(VertexOut in [[stage_in]],
constant Params ¶ms [[buffer(12)]])
{
float3 n = normalize(in.normalWS);
float ndotl = saturate(dot(n, params.lightDirection));
return float4(params.baseColor * ndotl, 1.0);
}
Note the normalize(in.normalWS). The vertex function emitted unit normals, but linear interpolation does not preserve length. An interpolated normal inside a triangle is always shorter than unit length, most so near the centre. Skip the renormalize and you get a visible dark patch across every face.
Interpolation qualifiers
By default every member of VertexOut is interpolated with perspective correction. You can change that per member:
MSLstruct VertexOut {
float4 position [[position]];
float3 normalWS; // default: perspective-correct
float2 screenUV [[center_no_perspective]]; // linear, for screen-space work
uint materialID [[flat]]; // not interpolated; takes the first vertex
};
[[flat]]β no interpolation. Integer types (material IDs, instance indices) require it, because there is no sensible way to interpolate an integer.[[center_no_perspective]]β screen-space linear. Correct and cheaper for fullscreen-quad UVs.[[sample_perspective]]β evaluated per sample under MSAA rather than once per fragment. Better quality, at the cost of running the fragment function once per sample.
The quad is the real unit of execution
The GPU never runs a single fragment on its own. The rasterizer dispatches in 2Γ2 quads, even when only one of the four fragments is actually covered by the triangle.
This exists because texture sampling needs derivatives. Choosing a mip level requires knowing how much the UV changes between adjacent pixels, and that difference can only be obtained by comparing UVs across neighbours in the same quad. dfdx / dfdy are implemented exactly this way.
Two consequences follow directly:
- Thin triangles are very expensive. A triangle covering a handful of pixels still runs the fragment function four times for every partially covered quad, and three of those results are thrown away. This is why dense meshes get much slower at distance than you would expect, and one of the reasons mesh LOD exists.
- Sampling inside a branch is dangerous. If the four fragments of a quad take different branches, the derivatives become meaningless. Metal requires texture sampling to happen outside non-uniform control flow. When you must sample in a branch, use an explicit LOD:
texture.sample(s, uv, level(0)).
Depth testing and early-Z
Depth testing is fixed function, configured through an MTLDepthStencilState:
Swiftlet depthDescriptor = MTLDepthStencilDescriptor()
depthDescriptor.depthCompareFunction = .less
depthDescriptor.isDepthWriteEnabled = true
depthState = device.makeDepthStencilState(descriptor: depthDescriptor)
encoder.setDepthStencilState(depthState)
By specification the depth test happens after the fragment function, because the fragment function may in principle modify depth. But as long as the shader does not write [[depth]], does not call discard_fragment(), and does not enable alpha-to-coverage, the hardware performs early-Z and rejects occluded fragments before running the shader at all.
Which means: a shader using discard_fragment() β alpha-tested foliage, say β costs the whole draw call its early-Z. When rendering alpha-tested vegetation it is usually worth grouping it separately and drawing it after the opaque geometry.
If the shader genuinely has to write depth but you can guarantee it only pushes depth further away, declare conservative depth so the hardware keeps part of the optimization:
MSLfragment float4 f(..., float d [[depth(greater)]]) { ... }
Blending
Transparency is configured through blend state, which belongs to the pipeline state rather than the encoder:
Swiftlet attachment = pipelineDescriptor.colorAttachments[0]!
attachment.isBlendingEnabled = true
attachment.rgbBlendOperation = .add
attachment.sourceRGBBlendFactor = .sourceAlpha
attachment.destinationRGBBlendFactor = .oneMinusSourceAlpha
The combinations you actually use:
| Effect | source | destination |
|---|---|---|
| Standard transparency | .sourceAlpha | .oneMinusSourceAlpha |
| Premultiplied alpha | .one | .oneMinusSourceAlpha |
| Additive (glow, fire) | .one | .one |
| Multiply (shadow decals) | .destinationColor | .zero |
The defining constraint of blending is that it is order dependent: a over b is not b over a. So transparent objects have to be drawn back to front, with depth writes disabled (isDepthWriteEnabled = false), or a nearer transparent surface hides what should show through it. Sorting breaks down as soon as objects interpenetrate β which is why transparency is a problem in every engine ever built.
Premultiplied alpha deserves its own sentence: it makes blending robust under filtering and mipmapping, because linearly interpolating two premultiplied colours still yields a correct premultiplied colour, whereas straight alpha produces dark fringes at edges.
Multiple render targets
One fragment function can write several attachments at once. This is the basis of deferred rendering:
MSLstruct GBufferOut {
float4 albedo [[color(0)]];
float4 normal [[color(1)]];
float4 position [[color(2)]];
};
fragment GBufferOut gbuffer_fragment(VertexOut in [[stage_in]]) {
GBufferOut out;
out.albedo = float4(baseColor, 1);
out.normal = float4(normalize(in.normalWS) * 0.5 + 0.5, 1);
out.position = float4(in.positionWS, 1);
return out;
}
On the CPU side you configure the matching number of colour attachments on both the render pass descriptor and the pipeline descriptor, and the pixel formats must agree exactly or pipeline creation fails.
On Apple Silicon there is an additional opportunity here: tile memory. Several attachments can stay entirely on-chip and never be written back to device memory. We look at that in detail in Metal #11 Deferred Rendering.
The next note covers textures.