Skip to main content

Command Palette

Search for a command to run...

1.4 - WGSL Built-in Mathematical Functions

Updated
31 min read
1.4 - WGSL Built-in Mathematical Functions

What We're Learning

In the last two articles, we learned the "nouns" and "grammar" of WGSL - how to define data with types and variables, and how to structure logic with functions and control flow. We now have the ability to build the skeleton of a shader. But what about the actual muscle? How do we perform the complex mathematical operations that create waves, blend colors, and calculate lighting?

You could, in theory, build every operation from scratch using basic arithmetic. But this would be incredibly slow and unnecessarily complex. Instead, the GPU provides us with a massive library of built-in functions.

These functions are not like the ones you write yourself. They are highly-optimized, hardware-accelerated operations that run at incredible speeds directly on the GPU's silicon. They are your power tools. Using sin(), dot(), mix(), and normalize() is not just a convenience - it is the key to writing fast, efficient, and powerful shaders. Mastering this library is like a chef learning to use their knives; it is the fundamental skill that enables all advanced techniques.

By the end of this article, you will understand the essential toolkit of WGSL's built-in functions, grouped by their purpose:

  • Trigonometric functions for creating waves, cycles, and rotation.

  • Common math functions for manipulating and shaping values.

  • Interpolation functions for creating smooth gradients and transitions.

  • Vector operation functions for performing the essential 3D math that underpins all lighting and geometry calculations.

Trigonometric Functions: Waves and Cycles

In the world of shaders, anything that moves in a smooth, repeating, or circular pattern owes its existence to trigonometry. The sine and cosine functions are the mathematical heartbeat of animation, creating everything from gentle waves to spinning objects and pulsating glows.

The Full Trigonometric Suite

While sin() and cos() are the workhorses, WGSL provides a full set of trigonometric functions.

  • sin(x), cos(x): The primary functions for creating oscillations.

  • tan(x): The tangent function. Less common for animation, but useful in geometric calculations.

  • asin(x), acos(x), atan(x): The inverse functions ("arc" functions), which take a value (typically between -1 and 1) and give you back the angle that produces it.

  • atan2(y, x): The "two-argument arctangent." This is an incredibly useful function that takes the components of a 2D vector (x, y) and returns the angle in radians that the vector makes with the positive X-axis, correctly handling all four quadrants.

The Core Functions: sin() and cos()

The two most important trigonometric functions are sin(x) and cos(x). For any input x, they produce a smooth, continuous wave that oscillates between -1.0 and 1.0.

  • sin(x): Starts at 0, goes up to 1.0, down to -1.0, and back to 0.

  • cos(x): Starts at 1.0, goes down to -1.0, and back up to 1.0. It is "phase-shifted" from sin(x).

Crucial Detail: Radians, Not Degrees

All trigonometric functions in WGSL (and almost all programming languages) operate in radians. A full circle is radians.

  • 90 degrees = π / 2 radians

  • 180 degrees = π radians

  • 360 degrees = radians (~6.283)

Inverse Functions (acos, asin, atan, atan2): Finding the Angle

While sin and cos take an angle and give you back a ratio (like a coordinate on a circle), the inverse functions do the exact opposite: you give them a ratio, and they give you back the angle in radians that produces it.

acos(x): The Most Important Arc Function

The most critical application of acos in shader programming is to find the actual angle between two 3D vectors. You already know from the previous article that the dot() product of two normalized vectors gives you the cosine of the angle between them. acos is the final step to get the angle itself.

dot(normalize(A), normalize(B))       = cos(angle)
acos(dot(normalize(A), normalize(B))) = angle
// Two direction vectors in 3D space.
let vector_a = normalize(vec3<f32>(1.0, 0.0, 0.0));
let vector_b = normalize(vec3<f32>(0.707, 0.707, 0.0));

// The dot product tells us they are partially aligned.
let alignment = dot(vector_a, vector_b); // Result is ~0.707

// acos gives us the actual angle between them.
let angle_in_radians = acos(alignment); // Result is ~0.785, which is PI / 4.0 (45 degrees)

This is incredibly powerful for effects that need to react to specific angles, such as the Fresnel effect in water rendering or creating a "cone of vision" for an enemy AI.

asin(x) and atan(x)

These are less common in general 3D shader work but have specific uses:

  • asin(x): Use this when you have a ratio representing the sine of an angle (e.g., from a known height y on a unit circle) and need to find the angle.

  • atan(x): Use this when you have a ratio representing a slope (y / x) and need to find the angle. It has limitations in distinguishing between different quadrants, which is why atan2 is almost always preferred.

atan2(y, x): Your Angle-Finding Superpower

While atan(x) can find an angle, it has limitations. atan2(y, x) is far more powerful. It takes the X and Y components of a 2D vector and returns the full angle in radians that the vector makes with the positive X-axis, correctly handling all 360 degrees (from -π to +π).

Why it's better than atan(y/x)

It avoids division-by-zero errors when x is zero, and it correctly distinguishes between angles in all four quadrants (e.g., it knows that (1, 1) and (-1, -1) are 180 degrees apart).

Mathematical Constants in WGSL

To make working with radians and other mathematical formulas easier, Bevy's shader prelude automatically provides several high-precision constants for you. You do not need to define these yourself.

ConstantValueCommon Use Case
PI~3.14159Represents half a turn of a circle (180°).
PI_2~6.28318Represents one full turn of a circle (360°), also known as Tau (τ).
HALF_PI~1.57079Represents a quarter turn of a circle (90°).
E~2.71828The base of the natural logarithm, used in exponential calculations.

Common Patterns and Use Cases

1. Creating a Simple Wave (-1.0 to 1.0)

By feeding a value that increases over time (like material.time) into sin(), you get a smooth oscillation. This is the basis of all wave effects.

let time = material.time; // This value is always increasing
let wave = sin(time); // Oscillates smoothly between -1.0 and 1.0

2. Creating a "Pulse" (0.0 to 1.0)

Often, you don't want a value that goes negative. A simple mathematical trick transforms the [-1, 1] range of sin() into a [0, 1] range, perfect for controlling brightness or blending factors.

// The long way:
let wave = sin(time);            // Starts at [-1, 1]
let wave_shifted = wave + 1.0;   // Shift the range to [0, 2]
let pulse = wave_shifted * 0.5;  // Scale the range to [0, 1]

// The common one-liner:
let pulse = sin(time) * 0.5 + 0.5;

3. Creating Circular Motion

cos() and sin() are the mathematical definitions of a circle. By using them for the X and Y coordinates respectively, you can create perfect circular or orbital motion.

let angle = material.time; // The angle increases over time
let radius = 2.0;

let x = cos(angle) * radius;
let y = sin(angle) * radius;
// The point (x, y) will now orbit the origin in a circle.

4. Controlling Wave Properties

You can modify the input and output of sin() to control the shape of your wave.

let frequency = 5.0;  // How many wave cycles fit into a given space. Higher = more waves.
let amplitude = 0.1;  // How tall the wave is. Higher = more intense effect.
let speed = 2.0;      // How fast the wave moves over time.

// Apply these to a vertex's position to create a physical wave.
let wave_offset = sin(position.x * frequency + time * speed) * amplitude;
let new_y_position = position.y + wave_offset;

Common Math Functions: Shaping Values

This family of functions is your essential toolkit for manipulating individual numbers. They let you control the range, sign, and fractional parts of your values, forming the basis for countless shader effects.

Shaping and Clamping Values

These functions are all about controlling the range and sign of a value. They are your primary tools for safety, ensuring values stay within a desired range, and for creating effects based on a value's sign.

  • abs(x): The absolute value. It makes any negative number positive. abs(-5.0) is 5.0. Perfect for creating "bounce" effects where a sin wave's negative half is mirrored upwards.

  • sign(x): Returns -1.0, 0.0, or 1.0 based on the input's sign. Useful for getting a direction without its magnitude.

  • min(a, b): Returns the smaller of two values.

  • max(a, b): Returns the larger of two values.

  • clamp(x, low, high): Constrains x to be within the range [low, high]. It's a convenient shorthand for max(low, min(x, high)).

// A classic use of `max`: ensuring a lighting calculation doesn't go negative.
let brightness = max(0.0, dot(normal, light_dir));

// Using `clamp` to ensure a value stays within a valid [0, 1] range.
let safe_value = clamp(user_input, 0.0, 1.0);

// Creating a "bounce" animation that goes from 0 to 1 and back.
let bounce = abs(sin(time));

Rounding and Repeating Values

This group of functions is the heart of procedural pattern generation. They all deal with the relationship between the integer and fractional parts of a number, which is the key to creating grids, tiles, and repeating patterns.

  • floor(x): Rounds down to the nearest whole number (e.g., floor(3.7) is 3.0).

  • ceil(x): Rounds up to the nearest whole number (e.g., ceil(3.2) is 4.0).

  • round(x): Rounds to the nearest whole number (e.g. round(3.7) is 4.0, round(3.2) is 3.0).

  • trunc(x): Truncates the number, simply removing the fractional part (e.g., trunc(3.7) is 3.0, trunc(-3.7) is -3.0). Note how this differs from floor for negative numbers (floor(-3.7) is -4.0).

  • fract(x): Returns only the fractional part (e.g., fract(3.7) is 0.7). This function is the king of repetition in shaders.

  • modf(x): Splits a number into its whole and fractional parts. A more advanced function useful when you need both results.

Of these, floor() and fract() are a powerful duo for creating tiled patterns.

  • floor(uv * scale) gives you the integer ID of a grid cell.

  • fract(uv * scale) gives you the coordinate within that grid cell, ranging from 0 to 1.

The fract() function creates a repeating "sawtooth" wave. As the input x increases, the output of fract(x) climbs linearly from 0.0 towards 1.0, and then instantly drops back to 0.0 to start over. This behavior is the foundation of all tiling and repeating patterns in shaders.

This simple, repeating 0.0 to 1.0 gradient is an incredibly powerful building block.

// The most common pattern for creating repeating tiles:
let frequency = 10.0;
let scaled_uv = in.uv * frequency;

// `cell_id` is the integer coordinate of the tile we are in (e.g., [0,0], [0,1], [1,1]...).
let cell_id = floor(scaled_uv);

// `local_uv` is the coordinate *inside* the current tile. Because of `fract()`,
// it always ranges from 0 to 1, giving us a repeating local coordinate system.
let local_uv = fract(scaled_uv);

// We can now draw something, like a circle, in the center of every single tile.
let circle_in_every_tile = step(length(local_uv - 0.5), 0.3);

Exponents, Powers, and Roots

This group of functions deals with non-linear transformations. They are essential for controlling the "curve" or "falloff" of an effect, like how quickly light dissipates or how sharp a highlight is.

  • pow(base, exp): Raises base to the power of exp. This is your main tool for controlling curves.

  • sqrt(x): The square root. Essential for calculations involving the Pythagorean theorem, like length().

  • inverseSqrt(x): Calculates 1.0 / sqrt(x). This is often a hardware-accelerated instruction and is significantly faster than performing the division and square root separately.

  • exp(x) / exp2(x): e^x and 2^x, for exponential growth.

  • log(x) / log2(x): Natural and base-2 logarithms, for logarithmic scales.

// `pow()` is the key to controlling the sharpness of specular highlights in lighting.
// A higher exponent creates a smaller, sharper highlight.
let shininess = 32.0;
let specular_highlight = pow(dot(reflection_dir, view_dir), shininess);

// `pow()` is also used for gamma correction.
let gamma_corrected = pow(linear_color, vec3<f32>(1.0 / 2.2));

// A classic optimization: "fast distance check".
// `length()` uses `sqrt()`, which can be slow. `length(A-B) < radius` is a slow comparison.
// If you only need to *compare* distances, you can use the squared distance to avoid `sqrt`.
// `dot(delta, delta)` is mathematically equivalent to `length(delta) * length(delta)`.
let delta = point_a - point_b;
let distance_squared = dot(delta, delta);
if distance_squared < (radius * radius) {
    // This check is much faster than using length().
}

Creating Smooth Transitions: Interpolation Functions

Much of shader art is about creating smooth, pleasing transitions - fading from one color to another, creating a soft circular glow, or blending textures on a terrain. WGSL provides a powerful set of interpolation functions specifically designed for these tasks.

mix: The Linear Blender

The mix() function performs a linear interpolation between two values. Think of it as a perfectly linear "dimmer switch" or "crossfader." If you ask for a 25% blend, you get exactly 25% of the way from the start to the end. This is often called "lerp" in other contexts.

Syntax: mix(start, end, percentage)

  • The percentage (often called t) is a value from 0.0 to 1.0.

  • When t is 0.0, it returns start.

  • When t is 1.0, it returns end.

  • When t is 0.5, it returns a perfect halfway blend.

// Blend from red to blue.
let red = vec3<f32>(1.0, 0.0, 0.0);
let blue = vec3<f32>(0.0, 0.0, 1.0);
let purple = mix(red, blue, 0.5); // A perfect 50/50 mix -> (0.5, 0.0, 0.5)

// Animate a position over time.
let current_pos = mix(start_pos, end_pos, animation_progress); // where progress is 0.0 to 1.0

mix is your fundamental tool for all basic blending and gradient tasks.

step: The Hard Switch

The step() function is the opposite of smooth. It creates a hard, binary threshold - an instant "on" or "off" switch.

Syntax: step(edge, value)

  • If value is less than edge, it returns 0.0.

  • If value is greater than or equal to edge, it returns 1.0.

// Create a hard edge.
let is_past_halfway = step(0.5, progress); // Returns 0.0 for progress < 0.5, 1.0 otherwise.

// A classic use: creating a hard-edged circle mask.
let dist_from_center = length(uv - vec2(0.5));
// The return value flips from 0.0 to 1.0 exactly at the radius.
let circle = 1.0 - step(0.3, dist_from_center); // 1.0 *inside* the radius, 0.0 *outside*.

Use step when you need a clear, binary decision from a continuous value. Like select(), it is a branchless operation and can be much faster than an equivalent if statement.

smoothstep: The Easing Curve

This is one of the most important functions for creating visually pleasing effects. smoothstep() is a "smart" blend with a built-in ease-in and ease-out S-curve. Instead of moving linearly, the transition starts slowly, accelerates through the middle, and then gently slows down at the end.

Syntax: smoothstep(edge0, edge1, value)

  • If value is less than or equal to edge0, it returns 0.0.

  • If value is greater than or equal to edge1, it returns 1.0.

  • If value is between the two edges, it returns a smoothly interpolated value between 0.0 and 1.0.

// Create a smooth fade-in over time.
let fade_amount = smoothstep(0.0, 1.0, animation_progress);

// Create a circle with a soft, anti-aliased edge.
let dist = length(uv - vec2(0.5));
// The transition will happen smoothly between a radius of 0.2 and 0.3.
let soft_circle = 1.0 - smoothstep(0.2, 0.3, dist);

Why use smoothstep?

Our eyes are highly sensitive to sudden changes in velocity. The abrupt start and end of a linear mix can look artificial or "jarring." The easing provided by smoothstep feels much more natural and polished, making it the preferred choice for almost any visual transition.

Visual Comparison: mix vs. step vs. smoothstep

This visual comparison makes the difference clear. Imagine we have a value x going from 0.0 to 1.0. Here is how each function maps that input to an output:

  • mix(0.0, 1.0, x): A straight, linear line.

  • step(0.5, x): An instant jump from 0 to 1.

  • smoothstep(0.0, 1.0, x): A smooth S-curve.

The smooth, gentle curve of smoothstep is why it is the go-to function for high-quality visual effects.

Vector Operations: The Language of 3D Space

While the previous functions manipulate numbers, this family of functions operates on vectors to solve geometric problems. They are the essential tools that allow you to work with positions, directions, and distances in 2D and 3D space.

Measuring Length and Distance

  • length(v): Calculates the length (or "magnitude") of a vector. If the vector represents a point, this is its distance from the origin (0,0,0).

  • distance(a, b): Calculates the straight-line distance between two points, a and b. This is a convenient shorthand for length(a - b).

// Calculate the speed of a moving object from its velocity vector.
let speed = length(velocity_vector);

// Check if an enemy is within a certain attack range.
let player_pos = vec3<f32>(10.0, 0.0, 5.0);
let enemy_pos = vec3<f32>(12.0, 0.0, 6.0);
let dist = distance(player_pos, enemy_pos);

if dist < 3.0 {
    // Attack!
}

Getting the Direction: normalize

Often, we care about a vector's direction but not its length. The normalize(v) function is the tool for this. It takes any vector and returns a new vector pointing in the exact same direction, but with a length of exactly 1.0. This is called a unit vector.

Normalizing is crucial for any calculation involving angles or pure direction, as it makes the math consistent and predictable.

// We want to find the direction from the player to the enemy, regardless of distance.
let direction_to_enemy = normalize(enemy_pos - player_pos);

// A surface normal vector *must* be a unit vector for lighting to work correctly.
// Interpolation can change a vector's length, so we always re-normalize in the fragment shader.
let normal = normalize(interpolated_surface_normal);

Safety Note: Normalizing a zero-length vector vec3(0.0) is an invalid operation (division by zero). While GPUs often handle this gracefully by returning a zero vector, it's good practice to guard against it if you suspect a vector might be zero.

The Dot Product: dot

The dot(a, b) product is one of the most versatile tools in shader programming. It takes two vectors and returns a single f32 scalar that tells you how much one vector points along the other.

For two unit vectors, the result of the dot product is the cosine of the angle between them, which gives it a very useful range of values:

  • 1.0: The vectors point in the exact same direction.

  • 0.0: The vectors are perfectly perpendicular (90 degrees apart).

  • -1.0: The vectors point in exact opposite directions.

  • Values in between represent the degree of alignment.

Primary Use Case: Diffuse Lighting

The dot product is the heart of the most common lighting calculation. By taking the dot product of a surface's normal vector and the direction to a light source, we can determine how much that surface is illuminated.

// Both vectors must be normalized!
let surface_normal = normalize(in.normal);
let light_direction = normalize(light_pos - in.world_pos);

// The dot product gives us the brightness factor.
// If the surface faces the light (dot > 0), it's lit.
// If it faces away (dot < 0), it's in shadow.
let brightness = max(0.0, dot(surface_normal, light_direction));

The Cross Product: cross

The cross(a, b) product is a specialized vec3 operation. It takes two vectors and returns a new vec3 that is perpendicular to both of them.

Note: cross() only works with vec3<f32>.

Its most famous use case is to calculate a surface normal from two edges of a triangle. Imagine a triangle in 3D space; the cross product gives you a vector that points directly "out" from the face of that triangle.

// Given three vertex positions of a triangle
let pos1 = vec3<f32>(...);
let pos2 = vec3<f32>(...);
let pos3 = vec3<f32>(...);

// Create two edge vectors that lie on the triangle's plane.
let edge1 = pos2 - pos1;
let edge2 = pos3 - pos1;

// The cross product of the two edges gives us a vector
// pointing perfectly away from the triangle's surface.
let surface_normal = normalize(cross(edge1, edge2));

Advanced Geometric Functions: Simulating Light Bounces

These are specialized functions for effects like reflections and refraction. Think of them as pre-packaged physics calculations that model how a ray of light interacts with a surface. For all of them, the surface normal N must be a unit vector.

reflect(I, N): The Perfect Bounce

This function calculates the direction of a perfect, mirror-like reflection.

  • I: The Incident vector. This is the direction of the incoming ray pointing towards the surface.

  • N: The Normal vector of the surface at the point of impact.

The function returns the reflected direction vector R.

Crucial Detail: In shader programming, our "view" or "camera" direction vector typically points from the surface to the camera. However, the reflect function mathematically expects the incident vector I to point towards the surface. This means you must almost always negate your view direction vector when passing it to reflect.

// This is the vector from the surface point TO the camera.
let view_dir = normalize(camera_pos - surface_pos);

// We need the vector pointing TOWARDS the surface, so we negate it.
let incident_dir = -view_dir;

// Now we can calculate the reflection.
let reflect_dir = reflect(incident_dir, surface_normal);

// This `reflect_dir` can now be used to sample a cubemap to create reflections.

refract(I, N, eta): Bending Through a Surface

This function calculates how a ray of light bends when it passes from one medium to another (e.g., from air into water).

  • I: The same Incident vector as reflect, pointing towards the surface.

  • N: The surface Normal vector.

  • eta: The ratio of indices of refraction. This is a f32 value calculated as (Index of Refraction of Medium 1) / (Index of Refraction of Medium 2).

The function returns the refracted direction vector T.

Calculating eta:

  • When a ray enters a material (e.g., air to glass): eta = IOR_air / IOR_glass -> 1.0 / 1.5 -> 0.667

  • When a ray exits a material (e.g., glass to air): eta = IOR_glass / IOR_air -> 1.5 / 1.0 -> 1.5

let view_dir = normalize(camera_pos - surface_pos);
let incident_dir = -view_dir;

// For a ray entering glass from air
let eta = 1.0 / 1.5; // Ratio of IORs
let refract_dir = refract(incident_dir, surface_normal, eta);

faceforward(N, I, Nref): For Two-Sided Surfaces

This is a utility function, not a physics simulation. Its job is to ensure a normal vector is always pointing towards the camera, which is useful for objects that have two visible sides (like a leaf or a sheet of paper).

  • N: The normal vector you want to orient. This is the value that will be returned, either as-is or flipped.

  • I: The Incident vector, pointing towards the surface (just like in reflect and refract). This is typically your negated view direction.

  • Nref: The Reference normal. This is the vector used to decide if N needs to be flipped. The function checks the sign of dot(I, Nref).

How it works:

  • If dot(I, Nref) < 0.0 (they are pointing generally towards each other), it means the surface is facing the camera. The function returns N unchanged.

  • If dot(I, Nref) >= 0.0 (they are pointing generally away from each other), it means you are looking at a back-face. The function returns -N (a flipped version of N).

In the most common use case, you pass the same geometric normal for both N and Nref:

let view_dir = normalize(camera_pos - surface_pos);
let incident_dir = -view_dir;
let geometric_normal = normalize(in.normal);

// This ensures `final_normal` is always pointing towards the camera,
// even if we are looking at the back of a polygon.
let final_normal = faceforward(geometric_normal, incident_dir, geometric_normal);

// Now you can perform two-sided lighting calculations with `final_normal`.
let brightness = max(0.0, dot(final_normal, light_dir));

Practical Combinations

The true power of these built-in functions is revealed when you start combining them to build more complex effects. A finished shader is rarely just a single function call; it's a creative sequence of operations where the output of one function becomes the input for the next.

Let's look at a few common recipes.

Soft, Anti-Aliased Circle

This is a classic pattern you'll use constantly for masks, UI elements, and effects. It combines distance calculation with smooth interpolation to avoid the harsh, pixelated edges of a "perfect" mathematical circle.

  • The Goal: Draw a circle with a soft, faded edge.

  • The Tools: distance and smoothstep.

fn soft_circle(uv: vec2<f32>, center: vec2<f32>, radius: f32, softness: f32) -> f32 {
    // 1. Calculate the distance of the current fragment from the circle's center.
    let dist = distance(uv, center);

    // 2. Define the start and end of our smooth transition.
    let inner_edge = radius - softness;
    let outer_edge = radius + softness;

    // 3. Use `smoothstep` to create the blend.
    // The value will be 0.0 beyond the outer edge and 1.0 inside the inner edge.
    // We subtract from 1.0 to invert the result, making the inside of the
    // circle white (1.0) and the outside black (0.0).
    return 1.0 - smoothstep(inner_edge, outer_edge, dist);
}

Animated Pulse Effect

This pattern is perfect for creating a "heartbeat" or "throb" effect, often used for UI feedback or glowing objects. It shapes a standard sine wave to make it feel more dynamic.

  • The Goal: Create a value that smoothly pulses from 0.0 to 1.0, but with a more pronounced peak than a simple sine wave.

  • The Tools: sin, pow.

fn pulse(time: f32, frequency: f32) -> f32 {
    // 1. Create a basic 0.0 to 1.0 oscillation.
    let wave = sin(time * frequency) * 0.5 + 0.5;

    // 2. Use `pow()` to shape the curve. Raising the wave to a power
    // (greater than 1.0) squashes the lower values and sharpens the peak,
    // creating a more distinct "pulse" instead of a uniform wave.
    let pulse_curve = pow(wave, 4.0);

    return pulse_curve;
}

Animated Radial "Rainbow" Burst

This combines trigonometric and vector functions to create a dynamic, colorful effect emanating from a central point.

  • The Goal: Create a circular pattern where the color changes with the angle and the pattern animates over time.

  • The Tools: atan2, length, sin, and a color space conversion function.

// Note: Don't worry about the exact math inside hsv_to_rgb. It's a standard
// utility function for converting Hue/Saturation/Value color into the Red/Green/Blue
// color needed for the screen. Our focus is on how we generate the input for it.
fn hsv_to_rgb(hsv: vec3<f32>) -> vec3<f32> {
    let c = hsv.z * hsv.y;
    let h_prime = hsv.x * 6.0;
    let x = c * (1.0 - abs(fract(h_prime / 2.0) * 2.0 - 1.0));
    var rgb_temp: vec3<f32>;

    let h_segment = floor(h_prime);
    if h_segment < 1.0 { rgb_temp = vec3(c, x, 0.0); }
    else if h_segment < 2.0 { rgb_temp = vec3(x, c, 0.0); }
    else if h_segment < 3.0 { rgb_temp = vec3(0.0, c, x); }
    else if h_segment < 4.0 { rgb_temp = vec3(0.0, x, c); }
    else if h_segment < 5.0 { rgb_temp = vec3(x, 0.0, c); }
    else { rgb_temp = vec3(c, 0.0, x); }

    return rgb_temp + vec3(hsv.z - c);
}

fn radial_burst(uv: vec2<f32>, time: f32) -> vec3<f32> {
    let centered_uv = uv - 0.5;

    // 1. Get the angle of the current fragment relative to the center using atan2.
    let angle = atan2(centered_uv.y, centered_uv.x);

    // 2. Get the distance from the center.
    let dist = length(centered_uv);

    // 3. Create a pattern by combining the angle, distance, and time with sine.
    // This creates a complex, swirling interference pattern.
    let pattern = sin(angle * 10.0 + dist * 20.0 - time * 3.0);

    // 4. Map the [-1, 1] pattern to a [0, 1] range and use it as a color hue.
    let hue = pattern * 0.5 + 0.5;

    return hsv_to_rgb(vec3<f32>(hue, 0.8, 1.0));
}

Complete Example: Math Function Visualizer

Theory and small examples are useful, but the best way to build intuition for these mathematical functions is to see them in action. We will now create an interactive material that visualizes several of the functions we've discussed.

Our Goal

We will build a single shader with multiple "modes." Each mode will use a different set of mathematical functions to generate a unique, animated pattern on the surface of a sphere. By cycling through these modes, you'll be able to directly observe the visual output of functions like sin, smoothstep, dot, and atan2.

What This Project Demonstrates

  • Practical Application: See how abstract math functions are used to create concrete visual effects like waves, pulses, and lighting.

  • Code Organization: The shader uses if/else if statements to create distinct modes, a common pattern for creating versatile materials.

  • Animation: A time uniform is used throughout to bring the mathematical patterns to life.

  • Visual Comparison: The "Smoothstep vs Step" mode provides a side-by-side comparison that makes the difference between the two functions instantly clear.

The Shader (assets/shaders/math_demo.wgsl)

This WGSL file is the core of our visualizer. It defines the MathDemoMaterial uniform, which receives the current demo_mode and time from Bevy. The @fragment function contains a large if/else if chain that acts as a router. Based on the demo_mode, it executes a different block of code, calling the appropriate math functions to generate a color pattern.

#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip
#import bevy_pbr::forward_io::VertexOutput

struct MathDemoMaterial {
    demo_mode: u32,
    time: f32,
}

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

@vertex
fn vertex(
    @builtin(instance_index) instance_index: u32,
    @location(0) position: vec3<f32>,
    @location(1) normal: vec3<f32>,
) -> VertexOutput {
    var out: VertexOutput;

    let world_from_local = mesh_functions::get_world_from_local(instance_index);
    let world_position = mesh_functions::mesh_position_local_to_world(
        world_from_local,
        vec4<f32>(position, 1.0)
    );

    out.position = position_world_to_clip(world_position.xyz);
    out.world_normal = mesh_functions::mesh_normal_local_to_world(normal, instance_index);
    out.world_position = world_position;

    return out;
}

// Simple HSV to RGB conversion
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> vec3<f32> {
    let c = v * s;
    let x = c * (1.0 - abs((h * 6.0) % 2.0 - 1.0));
    let m = v - c;

    var rgb = vec3<f32>(0.0);
    let h_segment = (h * 6.0) % 6.0;

    if h_segment < 1.0 {
        rgb = vec3(c, x, 0.0);
    } else if h_segment < 2.0 {
        rgb = vec3(x, c, 0.0);
    } else if h_segment < 3.0 {
        rgb = vec3(0.0, c, x);
    } else if h_segment < 4.0 {
        rgb = vec3(0.0, x, c);
    } else if h_segment < 5.0 {
        rgb = vec3(x, 0.0, c);
    } else {
        rgb = vec3(c, 0.0, x);
    }

    return rgb + m;
}

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let uv = in.world_position.xy;
    let normal = normalize(in.world_normal);
    var color = vec3<f32>(0.0);

    // Mode 0: Sin/Cos waves
    if material.demo_mode == 0u {
        let wave_x = sin(uv.x * 3.0 + material.time * 2.0);
        let wave_y = cos(uv.y * 3.0 + material.time * 2.0);
        let pattern = (wave_x + wave_y) * 0.5 + 0.5;
        color = vec3(pattern);
    }

    // Mode 1: Smoothstep vs Step
    else if material.demo_mode == 1u {
        let dist = length(uv);

        // Left half: hard step
        if uv.x < 0.0 {
            color = vec3(step(0.5, dist));
        }
        // Right half: smooth step
        else {
            color = vec3(smoothstep(0.4, 0.6, dist));
        }
    }

    // Mode 2: Mix (color blending)
    else if material.demo_mode == 2u {
        let red = vec3(1.0, 0.0, 0.0);
        let blue = vec3(0.0, 0.0, 1.0);
        let t = (sin(material.time) + 1.0) * 0.5;
        color = mix(red, blue, t);
    }

    // Mode 3: Dot product visualization
    else if material.demo_mode == 3u {
        let light_dir = normalize(vec3(cos(material.time), sin(material.time), 1.0));
        let brightness = max(0.0, dot(normal, light_dir));
        color = vec3(brightness);
    }

    // Mode 4: Length/Distance circles
    else if material.demo_mode == 4u {
        let dist = length(uv);
        let rings = fract(dist * 5.0 - material.time);
        color = vec3(rings);
    }

    // Mode 5: Atan2 angle visualization
    else if material.demo_mode == 5u {
        let angle = atan2(uv.y, uv.x);
        let hue = (angle / 6.28318 + 0.5);  // Normalize to 0-1
        color = hsv_to_rgb(hue, 1.0, 1.0);
    }

    // Mode 6: Pow for contrast
    else if material.demo_mode == 6u {
        let base = (normal.y + 1.0) * 0.5;
        let power = (sin(material.time) + 1.0) * 2.0 + 0.5;
        let enhanced = pow(base, power);
        color = vec3(enhanced);
    }

    // Mode 7: Reflect demonstration
    else if material.demo_mode == 7u {
        let view_dir = normalize(vec3(0.0, 0.0, 1.0));
        let reflect_dir = reflect(-view_dir, normal);
        color = (reflect_dir + 1.0) * 0.5;  // Map to color
    }

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

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

This Rust file defines the MathDemoMaterial struct. Its layout perfectly mirrors the uniform struct in the shader. The AsBindGroup derive macro handles the work of making this data available to the GPU, and the Material implementation tells Bevy which shader file to use.

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

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct MathDemoMaterial {
    #[uniform(0)]
    pub demo_mode: u32,
    #[uniform(0)]
    pub time: f32,
}

impl Material for MathDemoMaterial {
    fn fragment_shader() -> ShaderRef {
        "shaders/math_demo.wgsl".into()
    }

    fn vertex_shader() -> ShaderRef {
        "shaders/math_demo.wgsl".into()
    }
}

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

// ... other materials
pub mod math_demo;

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

This Rust module sets up our Bevy scene. It spawns a sphere with our custom MathDemoMaterial. Critically, it contains two systems: update_time passes the app's elapsed time into the material's time field every frame, and cycle_demo listens for keyboard input to modify the demo_mode field on the material.

use crate::materials::math_demo::MathDemoMaterial;
use bevy::prelude::*;

pub fn run() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::<MathDemoMaterial>::default())
        .add_systems(Startup, setup)
        .add_systems(Update, (rotate_camera, cycle_demo, update_time))
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<MathDemoMaterial>>,
) {
    // Spawn a sphere
    commands.spawn((
        Mesh3d(meshes.add(Sphere::new(1.0).mesh().uv(32, 18))),
        MeshMaterial3d(materials.add(MathDemoMaterial {
            demo_mode: 0,
            time: 0.0,
        })),
    ));

    // Light
    commands.spawn((
        PointLight {
            shadows_enabled: true,
            ..default()
        },
        Transform::from_xyz(4.0, 8.0, 4.0),
    ));

    // Camera
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(-2.5, 4.5, 9.0).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    // UI
    commands.spawn((
        Text::new("Press SPACE to cycle math demos\nMode 0: Sin/Cos Waves"),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(10.0),
            left: Val::Px(10.0),
            ..default()
        },
    ));
}

fn rotate_camera(time: Res<Time>, mut camera_query: Query<&mut Transform, With<Camera3d>>) {
    for mut transform in camera_query.iter_mut() {
        let radius = 9.0;
        let angle = time.elapsed_secs() * 0.5;
        transform.translation.x = angle.cos() * radius;
        transform.translation.z = angle.sin() * radius;
        transform.look_at(Vec3::ZERO, Vec3::Y);
    }
}

fn update_time(time: Res<Time>, mut materials: ResMut<Assets<MathDemoMaterial>>) {
    for (_, material) in materials.iter_mut() {
        material.time = time.elapsed_secs();
    }
}

fn cycle_demo(
    keyboard: Res<ButtonInput<KeyCode>>,
    mut materials: ResMut<Assets<MathDemoMaterial>>,
    mut text_query: Query<&mut Text>,
) {
    if keyboard.just_pressed(KeyCode::Space) {
        for (_, material) in materials.iter_mut() {
            material.demo_mode = (material.demo_mode + 1) % 8;

            for mut text in text_query.iter_mut() {
                let mode_name = match material.demo_mode {
                    0 => "Mode 0: Sin/Cos Waves",
                    1 => "Mode 1: Smoothstep vs Step (left=hard, right=smooth)",
                    2 => "Mode 2: Mix (color blending animation)",
                    3 => "Mode 3: Dot Product (lighting)",
                    4 => "Mode 4: Length (distance rings)",
                    5 => "Mode 5: Atan2 (angle to color)",
                    6 => "Mode 6: Pow (animated contrast)",
                    7 => "Mode 7: Reflect (reflection direction)",
                    _ => "Unknown",
                };
                **text = format!("Press SPACE to cycle math demos\n{}", mode_name);
            }
        }
    }
}

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

// ... other demoss
pub mod math_demo;

And register it in src/main.rs:

Demo {
    number: "1.4",
    title: "WGSL Built-in Mathematical Functions",
    run: demos::math_demo::run,
},

Running the Demo

Run the project and press SPACE to cycle through the different visualization modes. Each one is designed to give you an intuitive feel for a specific mathematical concept.

Controls

ControlAction
SPACECycle to the next visualization mode.

What You're Seeing

Each mode corresponds to a different branch in our fragment shader's logic.

ModeFunction(s) DemonstratedWhat You're Seeing
0: Sin/Cos Wavessin(), cos()This mode creates two perpendicular sine and cosine waves based on the world-space X and Y coordinates and animates them over time. The result is a shifting interference pattern, showing how simple waves can be combined to create complex visuals.
1: Smoothstep vs Stepstep(), smoothstep()This provides a direct, side-by-side comparison. On the left half of the sphere, a step() function creates a hard, aliased ring. On the right half, smoothstep() creates a ring with a soft, anti-aliased edge. The visual difference is dramatic.
2: Mixmix()This demonstrates linear interpolation by smoothly blending the entire sphere's color between red and blue. The blend factor is driven by an animated sin() wave, showcasing a classic use case for mix() in creating timed transitions.
3: Dot Productdot(), max(), normalize()This is a real-time visualization of a basic diffuse lighting model. The dot product calculates the alignment between the sphere's surface normal and an animated light direction vector, creating a highlight that moves realistically across the surface.
4: Lengthdistance(), fract(), smoothstep()This mode uses distance() to create a value based on proximity to the center axis. This is fed into fract() to create concentric rings that expand over time, demonstrating how distance functions are used for radial patterns.
5: Atan2atan2()This mode uses atan2() to calculate the angle of each pixel around the central axis. This angle is then mapped to the "Hue" component of a color, creating a perfect, 360-degree color wheel projected onto the sphere.
6: Powpow()This mode demonstrates how pow() can be used to control contrast. A simple gradient is raised to a power that animates over time. You will see the gradient shift from being very soft and washed out (exponent < 1.0) to being very sharp and high-contrast (exponent > 1.0).
7: Reflectreflect()This visualizes the direction of a mirror-like reflection. It calculates the reflection of a fixed view vector off the sphere's curved surface. The resulting reflection vector is then mapped to an RGB color, allowing you to "see" the reflection directions in real-time.

Key Takeaways

You've just been introduced to the core mathematical toolkit of WGSL. These built-in functions are the foundation upon which nearly all shader effects are built. Before moving on, let's solidify the key lessons.

  1. These Functions are Your Hardware-Accelerated Power Tools.
    The functions we've discussed - sin, dot, mix, normalize - are not just convenient shortcuts. They are highly optimized operations, often implemented directly in the GPU's hardware. Using them is the key to writing code that is not only powerful but also extremely fast.

  2. Trigonometry is the Language of Cycles.
    Whenever you need something to oscillate, rotate, wave, or repeat in a smooth, natural way, sin() and cos() are your go-to functions. By manipulating their inputs (frequency, time) and outputs (amplitude), you can create an endless variety of organic motion.

  3. smoothstep is the Secret to Professional Polish.
    While mix provides a linear blend, smoothstep provides a smooth, eased transition (an "S-curve"). This is crucial for creating effects that look natural rather than robotic. For any visual change - a fade, a soft edge, an animated blend - defaulting to smoothstep will elevate the quality of your work.

  4. Vector Operations Answer Geometric Questions.
    The core vector functions are best understood as tools for answering fundamental questions about 3D space:

    • length() / distance(): "How far apart are these things?"

    • normalize(): "Which direction is it, regardless of distance?"

    • dot(): "How aligned are these two directions?"

    • cross(): "Which direction is perpendicular to these two directions?"

Mastering these four operations is the key to implementing almost any lighting or geometric effect.

What's Next?

You now have a solid grasp of the "what" and the "how" of WGSL's fundamentals: you know the data types, how to structure logic, and the essential mathematical tools for building effects. You have all the individual pieces.

In the next article, it's time to assemble those pieces into a single, working whole. We will walk through the entire process, step-by-step, of creating a complete, custom Bevy Material from scratch. This means connecting the Rust side of our application to the GPU. We'll cover the necessary Bevy traits like Material and AsBindGroup, and then write a full .wgsl shader file that contains both a @vertex and a @fragment entry point, explaining how they work together to draw something on the screen.

This project will be a crucial milestone for Phase 1. It will take the theory you've learned so far - types, functions, and built-ins - and transform it into a practical, reusable template that we will build upon for the rest of this course.

Next up: 1.5 - Your First Complete WGSL Shader in Bevy


Beyond the Essentials: The Full WGSL Toolkit

In this article, we've explored the greatest hits of WGSL's built-in functions - the essential tools you'll use in almost every shader you write. Think of sin, mix, dot, and normalize as your hammer, screwdriver, and wrench.

However, the full WGSL standard library is a massive hardware store filled with specialized tools for every conceivable task, including advanced bitwise manipulation, matrix operations, and precise texture queries. While covering them all here would be overwhelming, having a complete reference is invaluable for your long-term journey.

For a comprehensive guide to every function available in WGSL, from the common to the esoteric, please refer to our detailed appendix.

See Also: Complete WGSL Built-in Function Reference


Quick Reference

A cheat sheet for WGSL's most common and essential built-in mathematical functions.

Function Families

FamilyPurposeKey Functions
TrigonometricCycles, waves, and rotationsin(), cos(), atan2()
Common MathShaping and repeating valuesabs(), fract(), floor(), pow(), sqrt()
ClampingConstraining valuesmin(), max(), clamp()
InterpolationBlending and smooth transitionsmix(), step(), smoothstep()
Vector Ops3D geometry and lightinglength(), distance(), normalize(), dot(), cross()

Core Function Syntax

FunctionSignatureDescription
Sine Wavesin(x: f32) -> f32Returns a value in [-1, 1] based on input angle in radians.
Mix (Lerp)mix(a, b, t: f32)Linearly interpolates from a to b as t goes from 0.0 to 1.0.
Smoothstepsmoothstep(e0, e1, x)Smoothly interpolates from 0.0 to 1.0 as x goes from e0 to e1.
Stepstep(edge, x)Returns 0.0 if x < edge, otherwise 1.0.
Clampclamp(x, low, high)Constrains x to be within the range [low, high].
Normalizenormalize(v: vecN)Returns a vector of length 1.0 pointing in the same direction as v.
Dot Productdot(a: vecN, b: vecN) -> f32Returns a scalar representing the alignment of two vectors.
Lengthlength(v: vecN) -> f32Returns the magnitude of vector v.

Common One-Liner Patterns

// Create a pulse that oscillates from 0.0 to 1.0.
let pulse = sin(time) * 0.5 + 0.5;

// Create a hard-edged circle mask.
let circle = 1.0 - step(radius, distance(uv, center));

// Create a soft-edged circle mask.
let soft_circle = 1.0 - smoothstep(radius - softness, radius + softness, distance(uv, center));

// Basic diffuse lighting factor.
let brightness = max(0.0, dot(surface_normal, light_direction));