The previous six parts built a foundation: the GPU's execution model, the pipeline stages, the type system, address spaces, the dispatch hierarchy, and texture access. This part is the payoff. The Metal standard library is what you actually call in shaders that do real things. It is dense with functions that map directly to hardware instructions, designed so that writing idiomatic MSL and writing fast MSL are largely the same activity.


The standard library header

#include <metal_stdlib>
using namespace metal;

Every shader file starts with this. The header provides all built in types, all standard functions, all attributes. The using namespace metal declaration brings them into scope without prefix. Without it, you write metal::float4, metal::normalize, metal::texture2d. With it, you write float4, normalize, texture2d. Convention uses the namespace; there is no practical reason to omit it.


Common functions

These work on any numeric scalar or vector type:

// Clamping
float c = clamp(value, 0.0, 1.0);   // clamp to [min, max]
float s = saturate(value);           // clamp to [0, 1], shorthand, slightly faster

// Selection
float m = min(a, b);   // smaller of two values
float x = max(a, b);   // larger of two values
float a = abs(value);  // absolute value

// Interpolation
float r = mix(a, b, t);            // linear interpolation: a*(1-t) + b*t
float s = smoothstep(edge0, edge1, x);   // smooth Hermite interpolation
float s = step(edge, x);           // returns 0.0 if x < edge, 1.0 otherwise

// Fractional
float f = floor(x);   // round down
float c = ceil(x);    // round up
float r = round(x);   // round to nearest
float fr = fract(x);  // fractional part: x - floor(x)
float tr = trunc(x);  // truncate toward zero

// Sign
float sg = sign(x);   // -1, 0, or +1

smoothstep is worth knowing well. It is the workhorse of GPU math, appearing in shaders everywhere from fog calculations to shadow softening to procedural noise thresholding:

float s = smoothstep(0.0, 1.0, t);
// For t <= 0.0: returns 0.0
// For t >= 1.0: returns 1.0
// For 0.0 < t < 1.0: returns 3t^2 - 2t^3 (smooth S-curve)

The S-curve derivative is zero at both ends, producing smooth transitions with no discontinuity in the slope. Linear interpolation produces a visible "kink" at the transition boundary in many effects. smoothstep does not.


Math functions

float s = sin(x);
float c = cos(x);
float t = tan(x);
float as = asin(x);
float ac = acos(x);
float at = atan(x);
float at2 = atan2(y, x);   // angle of vector (x,y), range [-pi, pi]

float e = exp(x);      // e^x
float e2 = exp2(x);    // 2^x
float l = log(x);      // natural log
float l2 = log2(x);    // log base 2

float p = pow(base, exp);  // base^exp
float sq = sqrt(x);
float rs = rsqrt(x);   // 1.0 / sqrt(x), hardware-accelerated

float mo = fmod(x, y);    // floating-point remainder
float mod = modf(x, out integer); // split into integer and fractional parts

atan2(y, x) is the two argument arctangent. It computes the angle of a 2D vector, handling the quadrant correctly for all input combinations. Single argument atan(y/x) loses quadrant information when x is negative. Use atan2.

rsqrt is consistently faster than 1.0 / sqrt(x) on GPU hardware. When you need to normalize a vector, normalize(v) calls rsqrt internally. When you need just the reciprocal square root of a dot product for some other purpose, call rsqrt directly rather than computing the square root and dividing.

Fast math and precision. By default, Metal compiles with -fmetal-math-mode=fast. Under fast math, the compiler assumes no NaN or infinity inputs and applies algebraic simplifications that may alter results slightly from strict IEEE 754 behavior. For graphics, the tradeoff is almost always correct. When you need precise behavior (scientific computing, machine learning), you can disable it per file with #pragma METAL fp math_mode(safe).


Geometric functions

The geometric functions operate on floating point vectors and are the foundation of 3D math in shaders:

float d = dot(a, b);        // dot product: a.x*b.x + a.y*b.y + a.z*b.z
float3 c = cross(a, b);     // cross product (float3 and half3 only)
float len = length(a);      // Euclidean length: sqrt(dot(a, a))
float len2 = length_squared(a); // dot(a, a), no square root
float3 n = normalize(a);    // unit vector: a / length(a)
float dist = distance(a, b); // length(a - b)
float3 r = reflect(v, n);   // reflect incident vector v around normal n
float3 ref = refract(v, n, eta); // refract by index ratio eta
float ff = faceforward(n, i, nref); // flip n to face away from i

reflect and refract are hardware optimized and implement the standard physics formulas. Their implementations in terms of dot and scalar arithmetic are what you would write if you derived them by hand; the built in versions let the compiler see the full operation and optimize accordingly.

faceforward(n, i, nref) returns n if dot(nref, i) < 0, else -n. It is used in rendering when a normal might be back facing (pointing away from the camera) due to mesh inconsistency or two sided materials, and you want to ensure the normal used for lighting always faces the viewer.


Fragment only functions: derivatives

Derivatives are available exclusively in fragment shaders. They compute the rate of change of any value across adjacent fragments in the same SIMD group:

float dx = dfdx(value);   // rate of change in screen x
float dy = dfdy(value);   // rate of change in screen y
float fw = fwidth(value); // abs(dfdx(value)) + abs(dfdy(value))

The primary use is edge detection and anti aliasing. A signed distance function (SDF) rendered as a flat threshold produces aliased, jagged edges. Apply smoothstep with a width derived from fwidth and you get one pixel smooth edges regardless of scale or orientation:

fragment float4 sdf_shape(VertexOut in [[stage_in]]) {
    float dist = /* ... compute SDF ... */;
    float fw = fwidth(dist);
    float alpha = smoothstep(fw, -fw, dist);  // smooth edge within ~1px
    return float4(1.0, 1.0, 1.0, alpha);
}

fwidth produces a value proportional to the pixel footprint of the rendered shape. At higher zoom levels where one pixel covers less of the shape, fwidth is smaller and the edge is sharper. As you zoom out and one pixel covers more, fwidth grows and the edge softens accordingly. This is the mechanism behind resolution independent rendering of vector graphics on the GPU.


Synchronization: barriers

Covered in Part 5, but complete here for reference.

threadgroup_barrier(mem_flags::mem_none);       // execution barrier only
threadgroup_barrier(mem_flags::mem_threadgroup); // + threadgroup memory fence
threadgroup_barrier(mem_flags::mem_device);      // + device memory fence
threadgroup_barrier(mem_flags::mem_texture);     // + texture memory fence

Combine flags for multiple memory types:

threadgroup_barrier(mem_flags::mem_threadgroup | mem_flags::mem_device);

A barrier without a memory fence (mem_none) guarantees that all threads in the threadgroup reach the barrier before any proceed. It does not guarantee that memory writes by other threads are visible. The memory fences add those visibility guarantees for specific address spaces. Choose the narrowest fence that satisfies your correctness requirements; broader fences have higher overhead.


Atomic operations

When multiple threads write to the same memory location, you need atomic operations to avoid data races. Atomics in MSL are available on device and threadgroup memory for integer types:

device atomic_uint *counter [[buffer(0)]];
// ...
atomic_fetch_add_explicit(counter, 1, memory_order_relaxed);

The available operations:

atomic_fetch_add_explicit(ptr, val, order)   // add, return old value
atomic_fetch_sub_explicit(ptr, val, order)   // subtract, return old value
atomic_fetch_and_explicit(ptr, val, order)   // bitwise AND, return old value
atomic_fetch_or_explicit(ptr, val, order)    // bitwise OR, return old value
atomic_fetch_xor_explicit(ptr, val, order)   // bitwise XOR, return old value
atomic_fetch_min_explicit(ptr, val, order)   // minimum, return old value
atomic_fetch_max_explicit(ptr, val, order)   // maximum, return old value
atomic_exchange_explicit(ptr, val, order)    // swap, return old value
atomic_load_explicit(ptr, order)             // atomic load
atomic_store_explicit(ptr, val, order)       // atomic store
atomic_compare_exchange_weak_explicit(ptr, expected, desired, succ, fail)

The memory_order parameter corresponds to C++ memory model ordering. memory_order_relaxed imposes no ordering constraints beyond atomicity. memory_order_acquire, memory_order_release, and memory_order_acq_rel synchronize with other operations. memory_order_seq_cst provides full sequential consistency.

For GPU use, memory_order_relaxed is sufficient in most scenarios where you simply need a concurrent counter, histogram bucket, or global accumulator without needing the result to synchronize with other unrelated writes.

Practical example: parallel histogram:

kernel void build_histogram(
    device const uchar *image [[buffer(0)]],
    device atomic_uint *histogram [[buffer(1)]],  // 256 bins
    uint tid [[thread_position_in_grid]],
    constant uint &count [[buffer(2)]]
) {
    if (tid >= count) return;
    uint bucket = image[tid];
    atomic_fetch_add_explicit(&histogram[bucket], 1, memory_order_relaxed);
}

Every thread increments one bin atomically. Without atomics, concurrent writes to the same bucket produce undefined results. With atomic_fetch_add_explicit, every increment lands safely.

The performance cost of global atomic operations under high contention is significant: many threads writing to the same memory location creates a serialization bottleneck. The optimized pattern combines a local threadgroup histogram with a final atomic accumulation into the global histogram, reducing contention by a factor of the threadgroup size.


Noise and randomness

The Metal standard library does not provide a built in random number generator or noise function. For procedural generation, shaders implement their own. These are the most common:

Integer hash (good for per thread randomness):

uint hash(uint x) {
    x ^= x >> 16;
    x *= 0x45d9f3b;
    x ^= x >> 16;
    x *= 0x45d9f3b;
    x ^= x >> 16;
    return x;
}

float random(uint seed) {
    return float(hash(seed)) / float(UINT_MAX);
}

Value noise (interpolated random values on a grid):

float noise2D(float2 p) {
    float2 i = floor(p);
    float2 f = fract(p);
    float2 u = f * f * (3.0 - 2.0 * f);  // smoothstep curve

    // Hash four corners
    uint h00 = hash(uint(i.x) + uint(i.y) * 1997);
    uint h10 = hash(uint(i.x + 1) + uint(i.y) * 1997);
    uint h01 = hash(uint(i.x) + uint(i.y + 1) * 1997);
    uint h11 = hash(uint(i.x + 1) + uint(i.y + 1) * 1997);

    float v00 = float(h00) / float(UINT_MAX);
    float v10 = float(h10) / float(UINT_MAX);
    float v01 = float(h01) / float(UINT_MAX);
    float v11 = float(h11) / float(UINT_MAX);

    return mix(mix(v00, v10, u.x), mix(v01, v11, u.x), u.y);
}

Fractional Brownian motion (layered octaves of noise):

float fbm(float2 p, int octaves) {
    float value = 0.0;
    float amplitude = 0.5;
    float frequency = 1.0;
    for (int i = 0; i < octaves; i++) {
        value += amplitude * noise2D(p * frequency);
        amplitude *= 0.5;
        frequency *= 2.0;
    }
    return value;
}

FBM adds multiple noise samples at increasing frequency and decreasing amplitude. The result has detail at multiple scales simultaneously, the characteristic look of natural textures like clouds, terrain, and water surface roughness.


Putting it together: a procedural terrain shader

A fragment shader that renders procedural terrain using the tools covered across this series:

#include <metal_stdlib>
using namespace metal;

// --- Noise functions omitted for brevity, same as above ---

struct VertexOut {
    float4 position [[position]];
    float2 uv;
    float3 worldPos;
    float3 worldNormal;
};

struct TerrainParams {
    float time;
    float3 lightDir;
    float3 cameraPos;
};

fragment float4 terrain_fragment(
    VertexOut in [[stage_in]],
    constant TerrainParams &params [[buffer(0)]]
) {
    // Sample procedural height
    float height = fbm(in.uv * 4.0, 6);

    // Derive color from height
    float3 snowColor  = float3(0.95, 0.97, 1.0);
    float3 rockColor  = float3(0.45, 0.40, 0.38);
    float3 grassColor = float3(0.28, 0.52, 0.18);
    float3 sandColor  = float3(0.82, 0.73, 0.54);

    float3 terrainColor;
    terrainColor = mix(sandColor,  grassColor, smoothstep(0.15, 0.25, height));
    terrainColor = mix(terrainColor, rockColor, smoothstep(0.55, 0.70, height));
    terrainColor = mix(terrainColor, snowColor, smoothstep(0.75, 0.88, height));

    // Simple Lambertian lighting
    float3 N = normalize(in.worldNormal);
    float3 L = normalize(-params.lightDir);
    float NdotL = saturate(dot(N, L));

    // Ambient + diffuse
    float3 ambient = terrainColor * 0.15;
    float3 diffuse = terrainColor * NdotL;

    // Soft specular on snow regions
    float3 V = normalize(params.cameraPos - in.worldPos);
    float3 H = normalize(L + V);
    float snowAmount = smoothstep(0.75, 0.88, height);
    float specular = pow(saturate(dot(N, H)), 64.0) * snowAmount * 0.8;

    float3 color = ambient + diffuse + specular;
    return float4(color, 1.0);
}

Every function in this shader appeared in one of the previous six parts. fbm calls noise2D which calls hash, floor, fract, mix, and smoothstep. The color blending uses mix and smoothstep again. Lighting uses normalize, dot, saturate, and pow. The structure is: sample procedural data, derive visual properties from it, apply a lighting model, return a color.

This pattern holds for virtually all fragment shaders, complex or simple. The complexity lives in the middle section, the problem specific computation between "read inputs" and "write output," and the standard library provides most of the arithmetic that middle section requires.


The compiler and fast math

One thing the Metal compiler does silently that is worth knowing: under fast math (the default), it may reorder, fuse, and approximate floating point operations. a * b + c may become a single fused multiply add (FMA) instruction. Algebraically equivalent expressions may be transformed. Division by a constant may become multiplication by its reciprocal.

These optimizations almost never produce visible artifacts in graphics. They do occasionally produce surprising results when you are debugging numerical behavior. If a shader produces subtly wrong numbers and you suspect floating point precision, try adding #pragma METAL fp math_mode(safe) at the top of the file and see if the behavior changes.

The pragma can narrow to a block:

#pragma METAL fp math_mode(safe)
{
    // Only this block uses safe math
    float result = precisely_needed_computation();
}
// Back to fast math

Use it surgically. Safe math is slower. The performance difference matters at scale.


What this series has covered

Seven parts, from the physical design of the GPU through to the standard library functions that appear on the fourth line of every real shader. The through line: Metal Shading Language makes explicit the things that CPU programming hides. Memory location. Thread identity. Execution stage. Data width. Synchronization requirements.

That explicitness is the GPU telling you exactly what it needs to run efficiently. Every annotation is a performance claim or a correctness guarantee. constant means "all threads read this together, cache aggressively." threadgroup_barrier means "no thread proceeds until everyone arrives." [[thread_position_in_grid]] means "here is your unique identity in this computation." The language describes the hardware, and the hardware is exactly as described.

The PixelWave simulation used nine point Laplacian stencils, Verlet integration, Fresnel reflections, and adaptive specular highlights, all in 246 lines of MSL. The code stayed short because the language lets you express physics and rendering in the same vocabulary the hardware speaks. A loop the CPU would run sequentially became a grid of 200,000 simultaneous kernel invocations. A mathematical formula became a few lines of vector arithmetic that compiled to a handful of SIMD instructions.

That compression, from idea to hardware through the narrow bridge of the shading language, is what makes GPU programming worth the investment.


Read the rest of the series