Skip to main content

Command Palette

Search for a command to run...

3.4 - Gradients and Interpolation

Updated
25 min read
3.4 - Gradients and Interpolation

What We're Learning

In the real world, colors are rarely static. The sky shifts from deep azure to pale cyan; a metal pipe has a bright highlight that fades into shadow; a sunset paints the clouds in bands of orange, pink, and purple. To replicate this in a shader, we need to master gradients.

At their core, gradients are about interpolation: the math of smoothly blending from one value to another. In previous articles, we used interpolation to move things. Now, we apply those same principles to color.

In this article, you will learn:

  • The Logic of mix(): How to blend any two values - colors, positions, or numbers - using a control factor.

  • Linear Gradients: Using UV coordinates to create horizontal, vertical, and diagonal color ramps.

  • smoothstep() Basics: How to create buttery-smooth transitions that feel organic rather than mechanical.

  • Radial and Angled Gradients: Moving beyond simple lines to create circles, cones, and sweeping angles.

  • Banding and Dithering: Why gradients sometimes look "stepped" on monitors and how to fix it with noise.

  • The Aurora Shader: A complete project blending noise and gradients to create a dynamic northern lights effect.

The Foundation: Linear Interpolation

Before we paint a sunset, we must understand the brush. In WGSL, that brush is the mix() function.

In mathematics, this operation is often called Linear Interpolation (or lerp). It calculates a value between a start point and an end point based on a percentage t.

Understanding mix()

The function signature is:

let result = mix(start, end, t);

Think of t as a slider or a percentage from 0.0 to 1.0.

  • When t is 0.0, you get 100% of the start value.

  • When t is 1.0, you get 100% of the end value.

  • When t is 0.5, you get a perfect 50/50 blend.

Mathematically, the GPU performs this calculation:
result = start * (1.0 - t) + end * t

Visualizing the Slider

Interpolating Colors

Because colors in WGSL are just vectors (vec3<f32> or vec4<f32>), mix() works on them exactly the same way it works on single numbers. It blends the Red, Green, and Blue channels independently.

let red = vec3<f32>(1.0, 0.0, 0.0);
let blue = vec3<f32>(0.0, 0.0, 1.0);

// 50% Red + 50% Blue = Purple
let purple = mix(red, blue, 0.5); 
// Result: vec3<f32>(0.5, 0.0, 0.5)

A Note on Color Mud:
Standard RGB interpolation is "linear," but human eyes don't perceive color linearly. If you mix pure Red and pure Green, the mathematical midpoint is a dark, murky olive color, not the bright Yellow you might expect. For simple gradients, this is fine, but if your gradients look "muddy" in the middle, it's often because of this limitation in the RGB color space.

Linear Gradients with UV Coordinates

The most common "slider" we have in a fragment shader is the UV coordinate system. Since UVs naturally range from 0.0 to 1.0 across the mesh, they plug directly into the t parameter of mix().

Horizontal Gradient

To create a gradient from left to right, we use the U coordinate (uv.x).

#import bevy_pbr::forward_io::VertexOutput

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let color_left = vec3<f32>(1.0, 0.0, 0.0);  // Red
    let color_right = vec3<f32>(0.0, 0.0, 1.0); // Blue

    // Use U coordinate as our slider (0.0 at left, 1.0 at right)
    let t = in.uv.x;

    let color = mix(color_left, color_right, t);

    return vec4<f32>(color, 1.0);
}

Result:

Vertical Gradient

To go from bottom to top, we use the V coordinate (uv.y).

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let color_bottom = vec3<f32>(0.2, 0.2, 0.2); // Dark Gray
    let color_top = vec3<f32>(0.5, 0.7, 1.0);    // Sky Blue

    // Use V coordinate (0.0 at bottom, 1.0 at top)
    let t = in.uv.y;

    let color = mix(color_bottom, color_top, t);

    return vec4<f32>(color, 1.0);
}

Diagonal Gradient

A diagonal gradient requires a t value that increases as we move both right and up. We can achieve this by averaging the coordinates.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let color_a = vec3<f32>(1.0, 0.0, 0.0);
    let color_b = vec3<f32>(0.0, 0.0, 1.0);

    // Average U and V. 
    // Bottom-Left (0,0) -> 0.0
    // Top-Right (1,1) -> 1.0
    let t = (in.uv.x + in.uv.y) * 0.5;

    let color = mix(color_a, color_b, t);

    return vec4<f32>(color, 1.0);
}

Controlling the Range

What if you want the gradient to start 20% of the way in and finish 80% of the way across? We need to manipulate our t value before plugging it into mix.

We remap the range using simple math: (value - start) / (end - start).

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let color_bg = vec3<f32>(0.0, 0.0, 0.0); // Black background
    let color_fg = vec3<f32>(1.0, 1.0, 0.0); // Yellow foreground

    // Define the start and end of the gradient in UV space
    let start = 0.2;
    let end = 0.8;

    // Remap uv.x to our new 0.0-1.0 range
    let t_raw = (in.uv.x - start) / (end - start);

    // Essential! Clamp the value.
    // Without clamp, mix() would extrapolate beyond colors A and B,
    // potentially creating negative colors or hyper-bright values.
    let t = clamp(t_raw, 0.0, 1.0);

    let color = mix(color_bg, color_fg, t);

    return vec4<f32>(color, 1.0);
}

Repeating Gradients

In the previous article, we used fract() to repeat patterns. We can do the same for gradients to create soft, repeating bands.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let color_a = vec3<f32>(0.0, 0.0, 0.0);
    let color_b = vec3<f32>(1.0, 1.0, 1.0);

    // Scale UVs by 5.0, then take the fractional part.
    // This creates a sawtooth wave: 0->1, 0->1, 0->1... five times.
    let t = fract(in.uv.x * 5.0);

    let color = mix(color_a, color_b, t);

    return vec4<f32>(color, 1.0);
}

The smoothstep() Function

Linear interpolation is useful, but it often looks mechanical or "robotic." In the real world, transitions rarely happen at a constant speed; they accelerate and decelerate. Shadows have soft edges, and lights have a "hotspot" that fades out gradually.

To achieve this organic look, we use the smoothstep() function. It is arguably the most important function in procedural shader programming after mix().

Understanding smoothstep

The function takes three arguments:

let result = smoothstep(edge0, edge1, x);

It returns a value between 0.0 and 1.0, based on where x lies relative to edge0 and edge1.

  1. Below the Lower Edge: If x is less than edge0, it returns 0.0.

  2. Above the Upper Edge: If x is greater than edge1, it returns 1.0.

  3. In Between: If x is between the edges, it performs a smooth Hermite interpolation (an S-shaped curve). It starts slow, speeds up in the middle, and slows down at the end.

Visual Comparison

Imagine we are fading from black to white as UV.x goes from 0.0 to 1.0.

Linear (t = x): The change is constant. It looks like a stiff, perfect ramp.
0.0 ➔ 0.25 ➔ 0.5 ➔ 0.75 ➔ 1.0

Smoothstep (t = smoothstep(0.0, 1.0, x)): The change eases in and eases out. It creates more contrast in the middle and softer transitions at the ends.
0.0 ➔ 0.11 ➔ 0.5 ➔ 0.89 ➔ 1.0

Using smoothstep with mix

The real power comes when you use the result of smoothstep as the t (control) value for your mix function.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let color_a = vec3<f32>(0.0, 0.0, 0.0);
    let color_b = vec3<f32>(1.0, 1.0, 1.0);

    // Define the transition zone: from 30% to 70% of the screen
    let edge0 = 0.3;
    let edge1 = 0.7;

    // Calculate the curve
    let t = smoothstep(edge0, edge1, in.uv.x);

    // Blend colors using the curved t
    let color = mix(color_a, color_b, t);

    return vec4<f32>(color, 1.0);
}

What happens here:

  • Left 30%: Pure color_a (Black).

  • Right 30%: Pure color_b (White).

  • Middle 40%: A smooth, contrast-rich transition from Black to White.

Useful Patterns

1. Inverting the Control

Often you want 1.0 at the start and 0.0 at the end (like a light fading out). You can do this in two ways:

  • The Math Way: Generate a normal 0-to-1 curve, then subtract it from 1.0.
let t = 1.0 - smoothstep(0.0, 1.0, x);
  • The Shorthand Way: Simply swap the edges.
let t = smoothstep(1.0, 0.0, x);

Both lines of code do exactly the same thing. Use whichever one makes more sense to you!

2. Hard Edges

If edge0 and edge1 are very close together, the smooth transition tightens until it looks like a hard line. This is how we do anti-aliasing!

// A sharp, but slightly anti-aliased line at 0.5
let line = smoothstep(0.49, 0.51, in.uv.x);

Radial Gradients

Now that we understand interpolation and smoothing, let's leave the grid behind. Radial gradients emanate from a center point, creating circles, glows, and vignettes.

The Math: Distance

The engine of a radial gradient is the distance() function (or length()). We measure how far the current pixel (UV) is from a center point.

let center = vec2<f32>(0.5, 0.5);
let dist = distance(in.uv, center); // or length(in.uv - center)

In the center, dist is 0.0. At the edges of a square (0.5 units away), dist is 0.5. In the corners, dist is roughly 0.707 (the square root of 0.5² + 0.5²).

Basic Radial Gradient

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let center = vec2<f32>(0.5, 0.5);

    // Calculate distance
    let dist = distance(in.uv, center);

    // 1. Simple Linear Falloff
    // We want the center to be White (1.0) and the edge to be Black (0.0).
    // Since dist goes 0->0.5, we multiply by 2.0 to map it to 0->1 range.
    // Then we invert it (1.0 - val) to make the center bright.
    let brightness = 1.0 - (dist * 2.0);

    let color = vec3<f32>(brightness); // Grayscale result

    return vec4<f32>(color, 1.0);
}

Result: A cone-like gradient. It looks sharp and pointy in the center, like a pyramid viewed from above.

Soft Glow with smoothstep

The linear version usually looks too "pointy." To make it look like a glowing sphere or a spotlight, we use smoothstep.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let center = vec2<f32>(0.5, 0.5);
    let dist = distance(in.uv, center);

    // Create a glow that starts fading immediately (0.0) 
    // and vanishes completely at radius 0.5.
    // Note: We flip the edges! smoothstep(0.5, 0.0, dist)
    // This is a shorthand for: 1.0 - smoothstep(0.0, 0.5, dist)
    let glow = smoothstep(0.5, 0.0, dist);

    let color_center = vec3<f32>(1.0, 0.8, 0.2); // Warm Orange
    let color_edge = vec3<f32>(0.1, 0.0, 0.0);   // Dark Red

    let color = mix(color_edge, color_center, glow);

    return vec4<f32>(color, 1.0);
}

Why flip the smoothstep arguments? smoothstep(0.5, 0.0, dist) is a valid trick.

  • If dist is 0.0 (center), it returns 1.0.

  • If dist is 0.5 (edge), it returns 0.0.
    It saves us from writing 1.0 - ... and often easier to read: "Map 0.5 to 0, and 0.0 to 1".

Off-Center Gradient

You aren't stuck in the middle. By changing the center variable, you can move the "light source."

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // Move center to top-right
    let center = vec2<f32>(0.8, 0.8);
    let dist = distance(in.uv, center);

    // Tighter glow radius (0.4)
    let glow = smoothstep(0.4, 0.0, dist);

    return vec4<f32>(vec3<f32>(glow), 1.0);
}

Animating the Gradient

Since mix and smoothstep are just math, we can animate the parameters over time.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // Animate the center point in a circle
    let center = vec2<f32>(
        0.5 + cos(material.time) * 0.3,
        0.5 + sin(material.time) * 0.3
    );

    let dist = distance(in.uv, center);

    // Pulsate the radius
    let radius = 0.3 + sin(material.time * 5.0) * 0.05;

    let glow = smoothstep(radius, 0.0, dist);

    return vec4<f32>(vec3<f32>(glow), 1.0);
}

Multi-Stop Gradients

So far, we've only mixed two colors. But what if you want a sunset that goes Blue ➔ Pink ➔ Orange? mix() only takes two inputs, so we need to chain them together using logic.

The "if/else" Approach

The simplest way to handle three colors is to split your UV space in half.

  • If we are on the left side (0.0 to 0.5), mix Color A and Color B.

  • If we are on the right side (0.5 to 1.0), mix Color B and Color C.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let color_a = vec3<f32>(0.0, 0.0, 1.0); // Blue
    let color_b = vec3<f32>(1.0, 0.0, 1.0); // Pink
    let color_c = vec3<f32>(1.0, 0.5, 0.0); // Orange

    let t = in.uv.y; // Vertical gradient

    var color: vec3<f32>;

    // We split the screen at 0.5
    if (t < 0.5) {
        // We are in the bottom half.
        // We need to remap t from [0.0, 0.5] to [0.0, 1.0] 
        // so the mix works correctly.
        // formula: t / max_value
        let local_t = t / 0.5; 
        color = mix(color_a, color_b, local_t);
    } else {
        // We are in the top half.
        // Remap t from [0.5, 1.0] to [0.0, 1.0]
        // formula: (t - start) / (end - start)
        let local_t = (t - 0.5) / 0.5;
        color = mix(color_b, color_c, local_t);
    }

    return vec4<f32>(color, 1.0);
}

Alternative: The select() Function

WGSL provides a built-in function called select(false_value, true_value, condition) that picks a value without using an if statement. This is often preferred in shader programming because it is concise and avoids "branching" (splitting the code path), which can sometimes optimize better on GPUs.

We could rewrite the logic above like this:

// Calculate both possibilities
let mix_1 = mix(color_a, color_b, t / 0.5);
let mix_2 = mix(color_b, color_c, (t - 0.5) / 0.5);

// Pick the correct one based on the boolean condition
let color = select(mix_2, mix_1, t < 0.5);

The smoothstep Blending Approach

For a softer, more organic look without hard math logic, you can calculate a "weight" for each color band using smoothstep and add them together.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let t = in.uv.x;

    // Define colors
    let deep_blue = vec3<f32>(0.1, 0.1, 0.4);
    let purple    = vec3<f32>(0.5, 0.0, 0.5);
    let gold      = vec3<f32>(1.0, 0.8, 0.0);

    // Start with the base color
    var final_color = deep_blue;

    // As we pass 0.3, fade in Purple
    let t_purple = smoothstep(0.3, 0.5, t);
    final_color = mix(final_color, purple, t_purple);

    // As we pass 0.6, fade in Gold
    let t_gold = smoothstep(0.6, 0.8, t);
    final_color = mix(final_color, gold, t_gold);

    return vec4<f32>(final_color, 1.0);
}

This approach essentially layers the gradients on top of each other. It's much cleaner to read and easier to tweak.

Angled Gradients with Vector Math

We can make horizontal, vertical, and radial gradients... but what about a gradient at a 45-degree angle? Or 30 degrees?

To do this, we need a tiny bit of vector math: the Dot Product.

The Logic: Projection

Imagine a line pointing in the direction you want your gradient to go. To color a specific pixel, we need to know: "How far along this line is this pixel?"

The dot(A, B) function answers exactly this question. It "projects" vector A onto vector B.

  1. Direction: We define a direction vector (e.g., pointing 45 degrees up-right).

  2. Position: We take the pixel's UV position relative to the center.

  3. Projection: dot(position, direction) gives us a single number representing how far along that direction the pixel lies.

Implementation

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // 1. Define the angle (in radians)
    // 45 degrees = PI / 4
    let angle = 0.785; 

    // Convert angle to a direction vector (x=cos, y=sin)
    let dir = vec2<f32>(cos(angle), sin(angle));

    // 2. Center the UVs (so 0,0 is in the middle of the mesh)
    let centered_uv = in.uv - 0.5;

    // 3. Project the position onto the direction
    // This returns a value roughly between -0.7 and 0.7
    let projection = dot(centered_uv, dir);

    // 4. Remap to 0.0 - 1.0 range for mixing
    // We add 0.5 to re-center it, and scale it slightly to fit
    let t = clamp(projection + 0.5, 0.0, 1.0);

    // 5. Mix!
    let color = mix(
        vec3<f32>(1.0, 0.0, 0.0), // Red
        vec3<f32>(0.0, 0.0, 1.0), // Blue
        t
    );

    return vec4<f32>(color, 1.0);
}

Why is this powerful?

Because angle is just a number, you can pass it in as a uniform or animate it using material.time. This allows you to create spinning gradients like radar sweeps or rotating lights with zero extra effort.

Dithering Techniques

You might notice that your beautiful smooth gradients sometimes look like a series of ugly bands or steps, especially in dark areas. This is called Color Banding.

Why Banding Happens

Banding occurs because monitors have limited precision. A standard monitor uses 8 bits per color channel, meaning it can only display 256 shades of Red, Green, or Blue.

If you stretch a gradient from Black to Dark Blue (0.0 to 0.1) across 1000 pixels, you only have about 25 shades available to cover that distance. Each shade will span roughly 40 pixels, creating visible "steps."

The Solution: Dithering

Dithering is a trick from the days of print media. By adding controlled noise to the image, we break up the hard edges between bands. Our eyes blend the noise together, creating the illusion of a smoother gradient.

1. Simple Random Dithering

The easiest method is to add a tiny amount of random noise to each pixel.

We first need a "pseudo-random" number generator. Since shaders are deterministic, we use a mathematical function that looks random.

// A standard "hash" function for shaders.
// It takes a 2D coordinate and returns a pseudo-random value between 0.0 and 1.0.
fn hash(p: vec2<f32>) -> f32 {
    let p3 = fract(vec3<f32>(p.x, p.y, p.x) * 0.1031);
    let dot_product = dot(p3, vec3<f32>(p3.y, p3.z, p3.x) + 33.33);
    return fract((p3.x + p3.y) * dot_product);
}

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // Create a smooth gradient susceptible to banding
    let t = in.uv.x;
    let color = mix(
        vec3<f32>(0.0, 0.0, 0.05), // Very dark blue
        vec3<f32>(0.0, 0.0, 0.2),  // Slightly lighter blue
        t
    );

    // Calculate Noise
    // We multiply UV by a large number so the noise is per-pixel, not stretched
    let random_val = hash(in.uv * 1000.0);

    // Scale the noise.
    // 1.0 / 255.0 represents the smallest possible color step on a monitor.
    // We subtract 0.5 so the noise averages out to 0.
    let dither_strength = 1.0 / 255.0;
    let dither = (random_val - 0.5) * dither_strength;

    // Add the noise to the color
    return vec4<f32>(color + dither, 1.0);
}

2. Ordered (Bayer) Dithering

Random noise can sometimes look like "film grain" or static. Ordered Dithering uses a specific grid pattern (a Bayer Matrix) to offset pixels in a checkerboard-like fashion. This looks much cleaner and more stable.

// Returns a value from a 4x4 Bayer Matrix
fn bayer_dither(position: vec4<f32>) -> f32 {
    let x = u32(position.x) % 4u;
    let y = u32(position.y) % 4u;
    let index = y * 4u + x;

    // The 4x4 Bayer Matrix pattern
    var matrix = array<f32, 16>(
         0.0,  8.0,  2.0, 10.0,
        12.0,  4.0, 14.0,  6.0,
         3.0, 11.0,  1.0,  9.0,
        15.0,  7.0, 13.0,  5.0
    );

    return matrix[index] / 16.0;
}

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let t = in.uv.x;
    let color = mix(vec3<f32>(0.0), vec3<f32>(0.1), t);

    // Use screen-space coordinates (in.clip_position) for the dither pattern
    // so the pattern stays crisp even if the object moves.
    let bayer_val = bayer_dither(in.clip_position);

    let dither_strength = 1.0 / 255.0;
    let dither = (bayer_val - 0.5) * dither_strength;

    return vec4<f32>(color + dither, 1.0);
}

Metallic and Iridescent Effects

Gradients aren't just for 2D patterns. In 3D rendering, gradients are fundamental to creating the look of complex materials like metal or oil.

The Fresnel Gradient

Metallic and shiny surfaces look different depending on the angle you view them from. Surfaces facing away from you (the edges of a sphere) tend to be more reflective than surfaces facing directly at you. This is called the Fresnel Effect.

We can calculate this "grazing angle" using the dot product between the surface Normal and the View Direction.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // 1. Get standard vectors
    let normal = normalize(in.world_normal);
    // Calculate direction from camera to pixel
    // (Assuming we pass camera_position as a uniform, or derive it)
    let view_dir = normalize(in.world_position - camera_position); 
    // Note: In real code, View Dir is usually (Camera - Position) to point towards camera.
    // Let's fix that direction:
    let to_camera = normalize(camera_position - in.world_position);

    // 2. Calculate alignment
    // dot() is 1.0 if looking straight at surface, 0.0 if looking at edge.
    let alignment = max(0.0, dot(normal, to_camera));

    // 3. Invert it to get the "Edge Factor"
    // 0.0 at center, 1.0 at edge.
    let fresnel = 1.0 - alignment;

    // 4. Sharpen the gradient
    // Raising to a power (typically 3.0 to 5.0) creates that distinct "rim light" look
    let rim_light = pow(fresnel, 3.0);

    // 5. Mix colors based on viewing angle
    let center_color = vec3<f32>(0.0, 0.0, 0.5); // Dark Blue
    let edge_color = vec3<f32>(0.0, 1.0, 1.0);   // Bright Cyan

    let color = mix(center_color, edge_color, rim_light);

    return vec4<f32>(color, 1.0);
}

Iridescence (Holographic Effects)

By mapping that Fresnel gradient to a spectrum of colors instead of just two, we can simulate iridescent materials like bubbles, oil slicks, or holographic stickers.

fn rainbow_gradient(t: f32) -> vec3<f32> {
    // A cosine-based palette technique (Procedural Color Palette)
    // It cycles R, G, and B waves at different offsets.
    let a = vec3<f32>(0.5, 0.5, 0.5);
    let b = vec3<f32>(0.5, 0.5, 0.5);
    let c = vec3<f32>(1.0, 1.0, 1.0);
    let d = vec3<f32>(0.0, 0.33, 0.67);

    return a + b * cos(6.28318 * (c * t + d));
}

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let normal = normalize(in.world_normal);
    let to_camera = normalize(camera_position - in.world_position);
    let fresnel = 1.0 - max(0.0, dot(normal, to_camera));

    // Use the fresnel factor to sample a rainbow
    let color = rainbow_gradient(fresnel);

    return vec4<f32>(color, 1.0);
}

This creates a surface that shifts color wildly as you move the camera around it.


Complete Example: Aurora Borealis Shader

To bring everything we've learned together, we will build a shader for one of nature's most beautiful gradients: the Aurora Borealis.

Unlike the simple gradients we've made so far, an aurora is dynamic. It moves, shimmers, and folds. To achieve this look without using textures, we need to combine gradients with noise and coordinate distortion.

Our Goal

We want to create a "curtain" of light that hangs in the sky.

  1. Shape: It should look like vertical rays or folds.

  2. Color: It should transition from green at the bottom (oxygen) to purple at the top (nitrogen).

  3. Movement: The curtain should wave slowly, while the internal rays shimmer quickly.

The Shader (assets/shaders/d03_04_aurora.wgsl)

This shader relies on a technique called Domain Distortion. Instead of drawing noise directly on the standard grid, we first "bend" the coordinate system into a curve. When we draw vertical lines on this bent grid, they appear to wind back and forth like a curtain.

The fragment shader creates the effect in five distinct steps:

  1. Curtain Shape: We offset the Y coordinate based on a sine wave of the X coordinate. This creates the winding path.

  2. Ray Generation: We generate noise using a coordinate system that is heavily stretched on the Y-axis. This turns what would be "blobs" of noise into long vertical streaks.

  3. Vertical Masking: We use smoothstep to fade the aurora out at the bottom and top, ensuring it blends naturally into the sky.

  4. Color Gradient: We map the Y (altitude) coordinate to a color gradient, blending from the base color to the top color.

  5. Composition: We multiply the color, the rays, and the mask together to get the final glowing result.

#import bevy_pbr::forward_io::VertexOutput
#import bevy_pbr::mesh_view_bindings::globals
#import bevy_pbr::mesh_functions::{get_world_from_local, mesh_position_local_to_world}
#import bevy_pbr::view_transformations::position_world_to_clip

struct AuroraMaterial {
    base_color: vec4<f32>,
    top_color: vec4<f32>,
    speed: f32,
    curtain_waviness: f32,
    ray_density: f32,
}

@group(2) @binding(0)
var<uniform> material: AuroraMaterial;

struct Vertex {
    @builtin(instance_index) instance_index: u32,
    @location(0) position: vec3<f32>,
    @location(1) normal: vec3<f32>,
    @location(2) uv: vec2<f32>,
};

@vertex
fn vertex(vertex: Vertex) -> VertexOutput {
    var out: VertexOutput;

    // Standard Bevy vertex transformation
    let model = get_world_from_local(vertex.instance_index);
    let world_position = mesh_position_local_to_world(model, vec4<f32>(vertex.position, 1.0));

    out.world_position = world_position;
    out.world_normal = vertex.normal;
    out.uv = vertex.uv;
    out.position = position_world_to_clip(world_position.xyz);

    return out;
}

// A simple pseudo-random hash function
fn hash(p: vec2<f32>) -> f32 {
    let p3 = fract(vec3<f32>(p.x, p.y, p.x) * 0.1031);
    let dot_product = dot(p3, vec3<f32>(p3.y, p3.z, p3.x) + 33.33);
    return fract((p3.x + p3.y) * dot_product);
}

// Value Noise: Smoothly interpolated random values
fn noise(p: vec2<f32>) -> f32 {
    let i = floor(p);
    let f = fract(p);
    let u = f * f * (3.0 - 2.0 * f); // smoothstep curve

    let a = hash(i);
    let b = hash(i + vec2<f32>(1.0, 0.0));
    let c = hash(i + vec2<f32>(0.0, 1.0));
    let d = hash(i + vec2<f32>(1.0, 1.0));

    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

// Fractal Brownian Motion: Layering noise for detail
fn fbm(p: vec2<f32>) -> f32 {
    var value = 0.0;
    var amp = 0.5;
    var freq = 1.0;
    var p_iter = p;

    // 3 layers of noise
    for (var i = 0; i < 3; i++) {
        value += noise(p_iter) * amp;
        p_iter *= 2.0;
        amp *= 0.5;
    }
    return value;
}

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let time = globals.time * material.speed;

    // 1. CREATE THE CURTAIN SHAPE
    // We distort the UV.x coordinate with large sine waves to make the
    // whole curtain wind back and forth like a snake.
    let curtain_curve = sin(in.uv.x * 2.0 - time * 0.5) * 0.2
                      + sin(in.uv.x * 5.0 + time * 0.2) * 0.1;

    // We define "height" as the distance from this winding curve.
    // This bends our coordinate system.
    let curve_uv = vec2<f32>(in.uv.x, in.uv.y - curtain_curve * material.curtain_waviness);

    // 2. GENERATE VERTICAL RAYS
    // We stretch the noise heavily on the Y axis (multiplying X by density, Y by 1.0)
    // This creates long vertical streaks instead of round blobs.
    let ray_uv = vec2<f32>(
        curve_uv.x * material.ray_density + time * 0.1,
        curve_uv.y
    );
    let rays = fbm(ray_uv);

    // 3. VERTICAL FADE (The "Mask")
    // Auroras fade out at the bottom and top.
    // We use smoothstep on the Y coordinate to create a soft band.
    let bottom_fade = smoothstep(0.0, 0.2, in.uv.y);
    let top_fade = 1.0 - smoothstep(0.6, 1.0, in.uv.y);
    let alpha_mask = bottom_fade * top_fade;

    // 4. COLOR GRADIENT (Altitude)
    // Map color based on height (Y).
    // Green at the bottom (0.0), Purple/Red at the top (1.0).
    let gradient_t = smoothstep(0.2, 0.8, in.uv.y);
    let aurora_color = mix(material.base_color.rgb, material.top_color.rgb, gradient_t);

    // 5. COMBINE
    // Multiply color by the rays and the alpha mask.
    // We boost the brightness (rays * 2.0) to make it glow.
    let final_color = aurora_color * rays * 2.0;
    let final_alpha = rays * alpha_mask;

    return vec4<f32>(final_color, final_alpha);
}

The Rust Material (src/materials/d03_04_aurora.rs)

This struct maps our shader uniforms to Rust types. Note the alpha_mode implementation: setting this to AlphaMode::Blend is crucial. Without it, our shader would render as an opaque block, blacking out anything behind it. By enabling blending, we tell the pipeline to mix our output color with the background based on the alpha value we calculated in the shader, creating the ethereal, transparent look of light.

use bevy::prelude::*;
use bevy::render::render_resource::{AsBindGroup, ShaderRef};

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct AuroraMaterial {
    #[uniform(0)]
    pub base_color: LinearRgba,
    #[uniform(0)]
    pub top_color: LinearRgba,
    #[uniform(0)]
    pub speed: f32,
    #[uniform(0)]
    pub curtain_waviness: f32,
    #[uniform(0)]
    pub ray_density: f32,
}

impl Default for AuroraMaterial {
    fn default() -> Self {
        Self {
            base_color: LinearRgba::new(0.0, 1.0, 0.5, 1.0), // Bright Green
            top_color: LinearRgba::new(0.5, 0.0, 1.0, 1.0),  // Purple
            speed: 0.5,
            curtain_waviness: 0.5,
            ray_density: 10.0,
        }
    }
}

impl Material for AuroraMaterial {
    fn vertex_shader() -> ShaderRef {
        "shaders/d03_04_aurora.wgsl".into()
    }

    fn fragment_shader() -> ShaderRef {
        "shaders/d03_04_aurora.wgsl".into()
    }

    // Essential for the "transparent" look of light in the sky
    fn alpha_mode(&self) -> AlphaMode {
        AlphaMode::Blend
    }
}

Don't forget to add it to src/materials/mod.rs:

pub mod d03_04_aurora;

The Demo Module (src/demos/d03_04_aurora.rs)

To properly showcase the effect, we need more than just a quad in empty space. This demo constructs a scene with a sense of scale:

  1. The Aurora: A large quad placed high in the distance, tilted slightly toward the camera.

  2. The Ruins: We generate a ring of large, rectangular stone monoliths in the foreground. These silhouettes create a "Stonehenge-like" frame for the sky, adding parallax and contrast.

  3. The Lighting: We use a very dim AmbientLight so the stones are visible as silhouettes, preserving the ominous night-time atmosphere.

use crate::materials::d03_04_aurora::AuroraMaterial;
use bevy::prelude::*;
use std::f32::consts::TAU;

pub fn run() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::<AuroraMaterial>::default())
        .add_systems(Startup, setup)
        .add_systems(Update, (update_ui, handle_input))
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<AuroraMaterial>>,
    mut standard_materials: ResMut<Assets<StandardMaterial>>,
) {
    // 1. The Aurora Curtain (Background)
    commands.spawn((
        Mesh3d(meshes.add(Rectangle::new(40.0, 20.0))), // Made bigger to fill sky
        MeshMaterial3d(materials.add(AuroraMaterial::default())),
        // Pushed back and tilted
        Transform::from_xyz(0.0, 5.0, -15.0).with_rotation(Quat::from_rotation_x(-0.2)),
    ));

    // 2. The Ground (Foreground)
    commands.spawn((
        Mesh3d(meshes.add(Plane3d::default().mesh().size(50.0, 50.0))),
        MeshMaterial3d(standard_materials.add(StandardMaterial {
            base_color: Color::srgb(0.01, 0.01, 0.02), // Very dark blue-black
            perceptual_roughness: 1.0,
            ..default()
        })),
        Transform::from_xyz(0.0, -2.0, 0.0),
    ));

    // 3. Stonehenge Ring (Silhouettes)
    // We use Cuboids instead of Cylinders for a blockier, ancient stone look
    let stone_mesh = meshes.add(Cuboid::new(1.5, 5.0, 1.0));
    let stone_mat = standard_materials.add(StandardMaterial {
        base_color: Color::srgb(0.02, 0.02, 0.02), // Almost black
        perceptual_roughness: 1.0, // Rough stone
        ..default()
    });

    let radius = 10.0;
    let stone_count = 12;

    for i in 0..stone_count {
        let angle = (i as f32 / stone_count as f32) * TAU;
        let x = angle.cos() * radius;
        let z = angle.sin() * radius;

        commands.spawn((
            Mesh3d(stone_mesh.clone()),
            MeshMaterial3d(stone_mat.clone()),
            Transform::from_xyz(x, 0.5, z)
                // Rotate the stone to face the center of the circle
                .looking_at(Vec3::ZERO, Vec3::Y),
        ));
    }

    // 4. Lighting (Subtle)
    commands.insert_resource(AmbientLight {
        color: Color::srgb(0.05, 0.05, 0.1),
        brightness: 100.0,
        ..default()
    });

    // 5. Camera
    commands.spawn((
        Camera3d::default(),
        // Center of the circle, looking up at the sky through the stones
        Transform::from_xyz(0.0, 0.0, 6.0).looking_at(Vec3::new(0.0, 4.0, -10.0), Vec3::Y),
    ));

    // 6. UI
    commands.spawn((
        Text::new(
            "[Q/A] Speed | [W/S] Ray Density | [E/D] Waviness\n\
             \n\
             Speed: 1.00\n\
             Ray Density: 12.00\n\
             Waviness: 1.00",
        ),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(10.0),
            left: Val::Px(10.0),
            padding: UiRect::all(Val::Px(10.0)),
            ..default()
        },
        TextFont {
            font_size: 16.0,
            ..default()
        },
        TextColor(Color::WHITE),
        BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.7)),
    ));
}

fn handle_input(
    keyboard: Res<ButtonInput<KeyCode>>,
    mut materials: ResMut<Assets<AuroraMaterial>>,
    time: Res<Time>,
) {
    let delta = time.delta_secs();

    for (_, material) in materials.iter_mut() {
        // Speed Control
        if keyboard.pressed(KeyCode::KeyQ) { material.speed += delta; }
        if keyboard.pressed(KeyCode::KeyA) { material.speed -= delta; }

        // Ray Density (The "Streaks")
        if keyboard.pressed(KeyCode::KeyW) { material.ray_density += delta * 5.0; }
        if keyboard.pressed(KeyCode::KeyS) { material.ray_density -= delta * 5.0; }

        // Curtain Waviness (The "Snake" shape)
        if keyboard.pressed(KeyCode::KeyE) { material.curtain_waviness += delta; }
        if keyboard.pressed(KeyCode::KeyD) { material.curtain_waviness -= delta; }
    }
}

fn update_ui(
    materials: Res<Assets<AuroraMaterial>>,
    mut text_query: Query<&mut Text>,
) {
    if let Some((_, material)) = materials.iter().next() {
        for mut text in text_query.iter_mut() {
            **text = format!(
                "[Q/A] Speed | [W/S] Ray Density | [E/D] Waviness\n\
                 \n\
                 Speed: {:.2}\n\
                 Ray Density: {:.2}\n\
                 Waviness: {:.2}",
                material.speed,
                material.ray_density,
                material.curtain_waviness
            );
        }
    }
}

Don't forget to add it to src/demos/mod.rs:

pub mod d03_04_aurora;

And register it in src/main.rs:

Demo {
    number: "3.4",
    title: "Gradients & Aurora",
    run: demos::d03_04_aurora::run,
},

Running the Demo

When you run the demo, you should see a glowing, shimmering curtain of light with distinct vertical streaks. The bottom should be a vibrant green fading into a deep purple at the top.

Controls

KeyActionEffect
Q / ASpeed +/-Controls how fast the aurora undulates and shimmers.
W / SDensity +/-Increases or decreases the number of vertical rays. High values look like energetic rain; low values look like soft clouds.
E / DWaviness +/-Controls how much the curtain curves. Set to 0 for a straight wall of light, or high for a very twisted path.

What You're Seeing

  • The Curtain Shape: Notice how the entire wall of light moves back and forth. This is the curtain_curve in the shader effectively moving the y coordinate based on the x position.

  • The Rays: The vertical lines are created by the fbm noise. Because we multiply the x coordinate by ray_density (e.g., 10.0) but leave y alone, the noise stretches vertically.

  • The Gradient: The color shift from Green to Purple is purely a function of the Y-axis (altitude), interpolated with smoothstep.

Key Takeaways

  1. mix() is your best friend: Almost every smooth transition in graphics - whether it's color, position, or opacity - is built on linear interpolation.

  2. smoothstep() adds the polish: Replacing linear transitions with S-curves makes procedural effects look organic and high-quality.

  3. Math is Logic: You can build complex logic using simple math functions. A radial gradient is just distance(); an angled gradient is just dot().

  4. Coordinates are Malleable: As seen in the Aurora, you don't have to accept UV coordinates as they are. You can bend, twist, and stretch them to create complex shapes from simple noise.

  5. Dithering matters: When working with subtle dark gradients, always keep banding in mind. A little bit of noise goes a long way.

What's Next?

We've mastered patterns and gradients, but so far everything has been mathematically generated. What if we want to use actual images - photos of bricks, wood, or metal? In the next phase, we unlock the power of Textures, learning how to load images into Bevy and sample them in our shaders.

Next up: 3.5 - Distance Functions


Quick Reference

Linear Interpolation (Lerp)

let result = mix(start, end, t); // t is 0.0 to 1.0

Smooth Transitions

// Returns 0.0 if x < 0.2, 1.0 if x > 0.8, smooth curve in between
let t = smoothstep(0.2, 0.8, x);

Radial Gradient

let dist = distance(uv, center);
let glow = 1.0 - smoothstep(0.0, radius, dist);

Angled Gradient

let dir = vec2<f32>(cos(angle), sin(angle));
let t = dot(uv - 0.5, dir) + 0.5;

Procedural Dithering

let noise = fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453);
let dithered_color = color + (noise - 0.5) / 255.0;