3.3 - UV-Based Patterns

What We're Learning
In the last article, we learned how to give a surface a solid color or a smooth gradient. But the real world is full of intricate details - bricks, tiles, fabrics, and complex designs. How do we create these without relying on pre-made image files? The answer lies in procedural patterns, and the canvas for these patterns is the UV coordinate system.
UV coordinates are a 2D blueprint stretched over your 3D model's surface. They provide a precise address for every point, allowing us to generate patterns mathematically, directly on the GPU. This approach is incredibly powerful because these patterns are not static pixels; they are pure math, making them infinitely scalable, easily animated, and remarkably efficient.
Understanding how to manipulate UVs is a foundational skill in shader programming. These techniques are used everywhere:
Creating tiling patterns for floors, walls, and architectural details.
Generating masks to control where effects appear.
Building complex procedural textures like wood grain or marble from scratch.
Driving animations and visual effects across a surface.
Creating debug visualizations to understand how data flows across your mesh.
By the end of this article, you will have mastered the art and science of UV-based patterns. You'll learn:
How to visualize and use UV coordinates, the 2D blueprint for your 3D model's surface.
To craft fundamental patterns like stripes, checkerboards, and circles using a handful of powerful WGSL functions like
fract()andstep().How to manipulate UV space itself - scaling, rotating, and moving your patterns for precise control.
Techniques for layering and blending simple patterns to create complex and unique designs.
How to solve the common problem of pattern stretching on non-square shapes using aspect ratio correction.
To build a complete, interactive procedural pattern generator that combines all these concepts.
Understanding UV Coordinates
Before we can generate patterns, we must understand the canvas we're painting on. In the world of shaders, that canvas is defined by UV coordinates.
What Are UV Coordinates?
Imagine you have a 3D model, like a character or a piece of furniture. To give it a detailed surface, artists need a way to map a flat, 2D image (a texture) onto its complex 3D shape. UV mapping is the process of "unwrapping" the 3D model into a flat, 2D plane, much like unwrapping a gift or creating a flat map of the spherical Earth.
The 2D coordinate system of this unwrapped map is called UV space.
The letters U and V are used instead of X and Y simply to distinguish them from 3D world coordinates.
U typically represents the horizontal axis.
V typically represents the vertical axis.
This unwrapped map provides a 2D "address" for every single point on the 3D model's surface.

Key Properties:
Normalized Range: UV coordinates almost always exist within a
[0.0, 1.0]square. The bottom-left corner is(0.0, 0.0)and the top-right is(1.0, 1.0).Topology, Not Size: A tiny polygon and a huge polygon can occupy the same area in UV space. The coordinates describe the mapping, not the real-world size.
Defined Per-Vertex: In a 3D mesh, each vertex is assigned a UV coordinate. The GPU then automatically interpolates these coordinates across the face of the triangle, giving every fragment a unique, smoothly varying UV value.
How UVs Flow from Bevy to WGSL
When you create a standard mesh in Bevy (like Plane3d or a loaded model), it usually comes with UV coordinates already defined.
In Rust: The list of
[f32; 2]UV coordinates is inserted as a vertex attribute on theMesh. Bevy's default meshes useMesh::ATTRIBUTE_UV_0.In the Vertex Shader: We declare an input that matches this attribute's location. For standard meshes, the UVs are at
@location(2). We then pass this value to an output struct.In the Fragment Shader: The GPU's rasterizer interpolates the UVs from the triangle's vertices. Our fragment shader receives this interpolated value as an input, ready to be used.
Here's the complete data flow in code:
// A standard vertex shader input struct for a mesh with UVs
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>, // <-- UVs from the mesh
}
// Data passed from the vertex to the fragment shader
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) uv: vec2<f32>, // <-- Pass the UVs through
}
@vertex
fn vertex(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
// ... vertex position transformation math ...
out.uv = in.uv; // Simply pass the UVs to the next stage
return out;
}
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// We can now use the interpolated `in.uv` value
// to create our patterns.
// ...
}
Visualizing UV Coordinates
The best way to understand UVs is to see them. Since the U and V components are floats from 0.0 to 1.0, we can plug them directly into the red and green channels of our output color to create a debug visualization.
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// U (horizontal) -> Red channel
// V (vertical) -> Green channel
// Blue channel is always 0.0
return vec4<f32>(in.uv.x, in.uv.y, 0.0, 1.0);
}
This simple shader produces a distinctive and incredibly useful gradient map:

If you ever see this black-red-green-yellow gradient, you know you're looking at a direct visualization of a mesh's UV coordinates. This is your first and most fundamental UV-based pattern.
Basic Patterns with fract() and step()
The smooth, predictable gradient of UV coordinates is our starting point. The challenge is to transform this simple ramp into complex, repeating patterns. fract() gives us repetition, and step() gives us sharp edges.
The fract() Function: The Engine of Repetition
The fract() function is simple: it returns the fractional part of a number, effectively discarding the integer part.
fract(0.3) // returns 0.3
fract(1.7) // returns 0.7
fract(5.2) // returns 0.2
fract(-0.4) // returns 0.6 (handles negatives by wrapping)
If you visualize what fract(x) does as x increases, it creates a repeating sawtooth wave that always stays within the [0.0, 1.0) range.

Why this is essential: fract() is the key to tiling. If we scale our UV coordinates by 10.0, they will range from 0.0 to 10.0. By applying fract() to this scaled value, we transform that single large gradient into ten identical, small gradients, each repeating from 0.0 to 1.0.
The step() Function: The Hard Edge
The step(edge, x) function is a binary switch. It compares x to a threshold value (edge) and returns either 0.0 or 1.0.
If
xis less thanedge, it returns0.0(black).If
xis greater than or equal toedge, it returns1.0(white).

step(0.5, 0.3) // returns 0.0 (because 0.3 < 0.5)
step(0.5, 0.7) // returns 1.0 (because 0.7 >= 0.5)
step(0.5, 0.5) // returns 1.0 (because 0.5 >= 0.5)
Why this is essential: step() is how we turn the smooth gradients produced by fract() into solid, high-contrast patterns. It takes a ramp of gray values and flattens it into pure black and white.
Combining fract() and step(): The Core Technique
The magic happens when you chain these two functions together. This is the fundamental recipe for creating stripes, grids, and checkerboards.
Let's create vertical stripes to see it in action:
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// 1. Scale the U coordinate to create 5 repetitions.
let scaled_u = in.uv.x * 5.0;
// 2. Apply fract() to get a repeating 0-to-1 gradient.
let repeating_gradient = fract(scaled_u);
// 3. Apply step() to turn the gradient into black and white bands.
let pattern = step(0.5, repeating_gradient);
// Output the pattern as a grayscale color.
return vec4<f32>(vec3(pattern), 1.0);
}
Let's trace the value as it moves from the left edge (uv.x = 0.0) to the right edge (uv.x = 1.0):
in.uv.x: Starts as a smooth gradient from0.0to1.0.
0.0 ────────────────> 1.0scaled_u: Multiplying by5.0scales the gradient to range from0.0to5.0.
0.0 ──────────────────────────────────> 5.0repeating_gradient:fract()chops the scaled gradient into five identical sawtooth waves.
0↗1, 0↗1, 0↗1, 0↗1, 0↗1pattern:step(0.5, ...)evaluates each of the five mini-gradients. For the first half of each gradient (where the value is< 0.5), it outputs0.0. For the second half (where the value is>= 0.5), it outputs1.0.
00001111, 00001111, 00001111, 00001111, 00001111
The result is five perfect vertical black and white stripes. This simple, powerful combination is the starting point for countless procedural patterns.. This simple, powerful combination is the starting point for countless procedural patterns.
Creating Stripe Patterns
Stripes are the "hello world" of UV patterns. By learning to control their direction, width, and movement, you are learning the core concepts of UV space manipulation that apply to all other patterns.
Vertical Stripes
This is the pattern we built in the last section, now encapsulated in a reusable function. The key is to operate only on the horizontal uv.x coordinate.
fn vertical_stripes(uv: vec2<f32>, frequency: f32) -> f32 {
// 1. Scale the U coordinate by the desired frequency.
let scaled_u = uv.x * frequency;
// 2. Get the repeating 0-to-1 gradient.
let repeating_gradient = fract(scaled_u);
// 3. Threshold the gradient to create a hard edge.
return step(0.5, repeating_gradient);
}
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// Create 10 pairs of black and white stripes.
let stripe_value = vertical_stripes(in.uv, 10.0);
return vec4<f32>(vec3(stripe_value), 1.0);
}
You can change two key values to control the result:
frequency: The number of stripe pairs. A higher value means thinner, more numerous stripes.The
step()threshold: Changing0.5to0.2will make the white stripes much wider than the black ones.
Horizontal Stripes
To change the direction, we apply the exact same logic, but to the vertical uv.y coordinate instead.
fn horizontal_stripes(uv: vec2<f32>, frequency: f32) -> f32 {
// The only change is using .y instead of .x
let scaled_v = uv.y * frequency;
let repeating_gradient = fract(scaled_v);
return step(0.5, repeating_gradient);
}
Variable Width Stripes
What if you want to control the width of the stripe precisely? We can achieve this by changing our step() logic. Instead of a 50/50 split, we can specify an exact width.
fn variable_width_stripes(uv: vec2<f32>, frequency: f32, width: f32) -> f32 {
let repeating_gradient = fract(uv.x * frequency);
// We want a value of 1.0 when the gradient is LESS than our width,
// and 0.0 otherwise. `1.0 - step(width, ...)` achieves this.
return 1.0 - step(width, repeating_gradient);
}
// Usage: Create 10 stripes, where each white stripe
// takes up only 20% of the space.
let stripe = variable_width_stripes(in.uv, 10.0, 0.2);
Diagonal Stripes
To create diagonal stripes, we need to create a gradient that changes along both axes simultaneously. The simplest way is to add the U and V coordinates together.
fn diagonal_stripes(uv: vec2<f32>, frequency: f32) -> f32 {
// Adding U and V creates a gradient that runs from
// bottom-left (0.0) to top-right (2.0).
let diagonal_gradient = uv.x + uv.y;
let repeating_gradient = fract(diagonal_gradient * frequency);
return step(0.5, repeating_gradient);
}
This creates perfect 45-degree stripes. For arbitrary angles, you can apply a 2D rotation formula to the UV coordinates before generating the pattern - a powerful technique we'll explore more later.
Animated Stripes
Creating motion is surprisingly easy. By adding an offset based on time before the fract() call, we can make the pattern scroll across the surface.
fn animated_stripes(uv: vec2<f32>, frequency: f32, time: f32, speed: f32) -> f32 {
// Add a time-based offset to the coordinate.
// This effectively slides the whole coordinate system.
let offset_u = uv.x + time * speed;
let repeating_gradient = fract(offset_u * frequency);
return step(0.5, repeating_gradient);
}
The pattern will now scroll horizontally. A negative speed value will make it scroll in the opposite direction.
Smooth Stripes with smoothstep()
The hard, pixelated edge created by step() is called "aliasing." For a softer, anti-aliased look, we can use a related function: smoothstep().
smoothstep(edge1, edge2, x) is like a blurry version of step(). Instead of jumping instantly from 0.0 to 1.0, it creates a smooth transition between edge1 and edge2.
fn smooth_stripes(uv: vec2<f32>, frequency: f32, smoothness: f32) -> f32 {
let repeating_gradient = fract(uv.x * frequency);
// Define a small transition zone around the 0.5 mark.
let edge1 = 0.5 - smoothness * 0.5;
let edge2 = 0.5 + smoothness * 0.5;
// Create a smooth transition instead of a hard edge.
return smoothstep(edge1, edge2, repeating_gradient);
}
The smoothness parameter controls the width of the blurred edge. A value of 0.0 is identical to step(), while a value like 0.1 will produce a nice, soft anti-aliased line.
Checkerboard Patterns
A checkerboard is simply the combination of vertical and horizontal stripes. The key is how we combine them. If we add them, we get a grid. If we multiply them, we get dots at the intersections. To get a true checkerboard, we need an operation that alternates the pattern, which is where the concept of XOR (eXclusive OR) comes in.
The Logic: An XOR Operation
The logic for a checkerboard is: "If the vertical stripe and horizontal stripe are different, the square is white. If they are the same, the square is black."
Let's visualize this. We'll generate a vertical stripe pattern (u_stripe) and a horizontal one (v_stripe), where 0 is black and 1 is white.
u_stripe (vertical): v_stripe (horizontal):
0 1 0 1 0 0 0 0
0 1 0 1 1 1 1 1
0 1 0 1 0 0 0 0
0 1 0 1 1 1 1 1
Now, we compare them cell by cell. The easiest way to perform an XOR on 0.0/1.0 values in a shader is abs(a - b).
abs(u_stripe - v_stripe):
|0-0| |1-0| |0-0| |1-0| -> 0 1 0 1
|0-1| |1-1| |0-1| |1-1| -> 1 0 1 0
|0-0| |1-0| |0-0| |1-0| -> 0 1 0 1
|0-1| |1-1| |0-1| |1-1| -> 1 0 1 0
The result is a perfect checkerboard pattern.
Basic Checkerboard Shader
Here is that logic implemented in WGSL:
fn checkerboard(uv: vec2<f32>, frequency: f32) -> f32 {
// Create the repeating 0-to-1 gradients in both directions.
let u_gradient = fract(uv.x * frequency);
let v_gradient = fract(uv.y * frequency);
// Threshold them to get binary stripe patterns.
let u_stripe = step(0.5, u_gradient);
let v_stripe = step(0.5, v_gradient);
// Perform the XOR operation to combine them.
return abs(u_stripe - v_stripe);
}
An Alternative: The Grid Coordinate Method
Another popular way to create a checkerboard is to think in terms of integer grid coordinates. This method is slightly less intuitive at first but is very powerful for other grid-based effects.
fn checkerboard_mod(uv: vec2<f32>, frequency: f32) -> f32 {
// 1. Convert continuous UVs into integer grid coordinates.
// floor() discards the fractional part, leaving an integer.
let grid_coords = floor(uv * frequency);
// 2. Add the integer coordinates together.
let sum = grid_coords.x + grid_coords.y;
// 3. Check if the sum is even or odd.
// An even number times 0.5 has a fractional part of 0.0.
// An odd number times 0.5 has a fractional part of 0.5.
// We multiply by 2.0 to map this to 0.0 or 1.0.
let pattern = fract(sum * 0.5) * 2.0;
return pattern;
}
This approach works because the sum of the integer coordinates alternates between even and odd for adjacent squares, just like on a real chessboard.
Diagonal Checkerboard
How do we create a checkerboard that's rotated 45 degrees? Just like with stripes, the easiest way is to rotate the entire UV coordinate system before applying our checkerboard logic.
fn diagonal_checkerboard(uv: vec2<f32>, frequency: f32) -> f32 {
// A simple 45-degree rotation can be done with this transform.
// We multiply by 0.707 (which is 1/√2) to keep the scale consistent.
let rotated_uv = vec2<f32>(
(uv.x - uv.y) * 0.707,
(uv.x + uv.y) * 0.707
);
// Now, run the standard checkerboard function on our new, rotated coordinates.
return checkerboard(rotated_uv, frequency);
}
This concept of "pre-transforming" the UV space is fundamental. It allows you to reuse simple pattern functions to create much more complex results by warping the input coordinates.
Circular Patterns
To create circular patterns, we need to shift our thinking from "what is my uv.x and uv.y coordinate?" to "how far am I from a specific point?". This is a move from Cartesian to Polar-style coordinates. The primary tools for this are the distance() and length() functions.
The Tools: distance() and length()
These two functions are the foundation of all radial patterns. They both measure distance, just with slightly different inputs.
length(v): Measures the distance of a vector v from the origin(0,0).distance(a, b): Measures the distance between two points,aandb.
They are directly related. The distance between a and b is simply the length of the vector that connects them: distance(a, b) is equivalent to length(a - b).
For our purposes, we will almost always want to measure the distance from our current fragment's uv coordinate to the center of the UV space, which is vec2<f32>(0.5, 0.5).
// Center of the UV space
let center = vec2<f32>(0.5, 0.5);
// Calculate the distance of the current fragment from the center.
let dist_from_center = distance(in.uv, center);
This dist_from_center value gives us a beautiful radial gradient, starting at 0.0 in the exact center and increasing outwards towards the corners. This gradient is the raw material for all our circular patterns.
Simple Circle
The easiest way to create a circle is to apply step() to our radial gradient. We are essentially saying, "If the distance from the center is less than my desired radius, color this fragment white. Otherwise, color it black."
fn circle(uv: vec2<f32>, center: vec2<f32>, radius: f32) -> f32 {
let dist = distance(uv, center);
// If our distance is less than the radius, step() will produce 1.0.
// To do this, we flip the arguments.
return 1.0 - step(radius, dist);
}
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let circle_mask = circle(in.uv, vec2<f32>(0.5), 0.3);
return vec4<f32>(vec3(circle_mask), 1.0);
}
This produces a solid white circle on a black background.
Ring Pattern
To create a ring or hollow circle, we can use a clever trick. We generate two circles - a large outer one and a smaller inner one - and then subtract the inner circle from the outer one.
fn ring(uv: vec2<f32>, center: vec2<f32>, radius: f32, thickness: f32) -> f32 {
let dist = distance(uv, center);
let inner_radius = radius - thickness * 0.5;
let outer_radius = radius + thickness * 0.5;
// Create the two circles
let outer_circle = 1.0 - step(outer_radius, dist);
let inner_circle = 1.0 - step(inner_radius, dist);
// Subtract the inner from the outer to get the ring
return outer_circle - inner_circle;
}
Concentric Circles (Bullseye)
How do we create a repeating bullseye pattern? We use the exact same logic as our stripe patterns! We take our radial gradient, scale it up, and apply fract() to create repeating sawtooth waves.
fn concentric_circles(uv: vec2<f32>, center: vec2<f32>, frequency: f32) -> f32 {
// 1. Get the radial gradient.
let dist = distance(uv, center);
// 2. Scale and repeat it with fract().
let repeating_gradient = fract(dist * frequency);
// 3. Threshold it to create hard-edged rings.
return step(0.5, repeating_gradient);
}
Dots Grid
This is a classic and incredibly useful pattern that combines both Cartesian and radial logic. The goal is to draw a circle inside every cell of a grid.
First, we use
fract(uv * frequency)to create a grid of repeating UV spaces, where each cell has its own local[0,1]coordinate system.Then, within each of these cells, we calculate the distance from the cell's center
(0.5, 0.5)to create a circle.
fn dots_grid(uv: vec2<f32>, frequency: f32, dot_size: f32) -> f32 {
// 1. Create the repeating grid cells.
let grid_uv = fract(uv * frequency);
// 2. In each cell, calculate distance from its local center.
let dist_from_cell_center = distance(grid_uv, vec2<f32>(0.5));
// 3. Create a circle in each cell.
// The radius is half the dot size.
return 1.0 - step(dot_size * 0.5, dist_from_cell_center);
}
This powerful technique of using fract() to create a local coordinate space is fundamental to many advanced procedural textures.
Combining Multiple Patterns
Think of your pattern functions as layers in a photo editing program. You can stack them, blend them, and use one to mask another. In shaders, we do this not with a layer panel, but with simple arithmetic.
Additive Blending: Combining Light
The simplest way to combine two patterns is to add their values together. This is analogous to shining two projectors onto the same screen; where the beams overlap, the image gets brighter.
fn combined_grid(uv: vec2<f32>) -> f32 {
// Generate two separate stripe patterns.
let vertical = vertical_stripes(uv, 10.0);
let horizontal = horizontal_stripes(uv, 10.0);
// Add them together. The result can range from 0.0 to 2.0.
// We clamp it to [0.0, 1.0] to keep it in the visible range.
let result = clamp(vertical + horizontal, 0.0, 1.0);
return result;
}
Result: This creates a grid. The lines themselves are gray (1.0), and the intersections where the patterns overlap are bright white (2.0 before clamping). Additive blending is great for effects where you want to accumulate light or energy, like sparks or overlapping glows.
Multiplicative Blending: Masking and Filtering
Multiplying two patterns is one of the most common and useful techniques. It acts as a mask or filter. The result will only be white (1.0) where both input patterns are white. If either pattern is black (0.0), the result will be black.
This is like shining a light through two projector slides stacked on top of each other; the light must pass through both to be visible.
fn combined_masked_stripes(uv: vec2<f32>) -> f32 {
// Pattern A: A set of diagonal stripes.
let stripes = diagonal_stripes(uv, 20.0);
// Pattern B: A soft circular mask.
let circle_mask = 1.0 - smoothstep(0.4, 0.45, distance(uv, vec2(0.5)));
// Multiply them. The stripes will only appear inside the circle.
return stripes * circle_mask;
}
Result: The diagonal stripes are only visible within a circular area in the center of the screen, fading out softly at the edges. This is the fundamental technique for constraining effects to specific areas.
mix() Blending: Smooth Transitions
What if you don't want to layer patterns, but want to smoothly transition from one to another? This is the perfect job for the mix() function, which performs linear interpolation.
mix(a, b, t) blends from pattern a to pattern b using t as the control factor. When t is 0.0, you get 100% of a. When t is 1.0, you get 100% of b. When t is 0.5, you get an even 50/50 mix.
Crucially, the control factor t can itself be another pattern.
fn combined_mix(uv: vec2<f32>) -> f32 {
// Pattern A: A checkerboard.
let pattern_a = checkerboard(uv, 16.0);
// Pattern B: A dots grid.
let pattern_b = dots_grid(uv, 8.0, 0.4);
// Blend Factor: A simple horizontal gradient across the screen.
let blend_factor = uv.x;
// Blend from the checkerboard on the left to the dots on the right.
return mix(pattern_a, pattern_b, blend_factor);
}
Result: The left side of the screen will show a perfect checkerboard, which will smoothly fade into the dots grid pattern on the right side.
Boolean Operations: Logical Combinations
For crisp, binary patterns, it's often useful to think in terms of logical operations. We can simulate these with min() and max().
Union (A or B):
max(a, b)The result is white if eitheraorbis white.Intersection (A and B):
min(a, b)The result is white only if both a and b are white. (This is equivalent to multiplication for binary0.0/1.0patterns).Subtraction (A not B):
max(a - b, 0.0)This is like using patternbas a "cookie cutter" to remove a shape from patterna.
fn boolean_example(uv: vec2<f32>) -> f32 {
let circle_a = circle(uv, vec2(0.4, 0.5), 0.3);
let circle_b = circle(uv, vec2(0.6, 0.5), 0.3);
// Try swapping this line with min() or max(a-b, 0.0)
// to see the different operations!
return max(circle_a, circle_b); // Union (two overlapping circles)
}
Scaling, Offsetting, and Rotating UVs
Think of your UV coordinate space as a sheet of rubber that you can stretch, slide, and spin. The pattern is drawn on this sheet. By manipulating the sheet first, you manipulate the final appearance of the pattern. This is a powerful, non-destructive way to control your designs.
Scaling UVs (Tiling and Repetition)
Scaling is the most common UV transformation. We do it by simply multiplying the uv vector. This is what we've been doing with our frequency parameter all along, but let's formalize it.
There's a key intuitive flip you need to grasp:
Scaling UVs UP (
* > 1.0) makes the pattern appear SMALLER (more repetitions).Scaling UVs DOWN (
* < 1.0) makes the pattern appear LARGER (fewer repetitions).
Think of it as zooming in or out on the UV coordinate sheet. Zooming in (scaling UVs up) means you see more of the repeating pattern in the same amount of space.
// Scale uniformly in both directions
fn scaled_pattern(uv: vec2<f32>, scale: f32) -> f32 {
let scaled_uv = uv * scale;
// The pattern function now operates in a scaled space.
return checkerboard(scaled_uv, 1.0); // Frequency is now controlled by scale
}
// Scale differently in U and V to stretch the pattern
fn non_uniform_scale(uv: vec2<f32>, scale_u: f32, scale_v: f32) -> f32 {
let scaled_uv = uv * vec2<f32>(scale_u, scale_v);
return checkerboard(scaled_uv, 1.0);
}
Offsetting UVs (Moving and Scrolling)
To move or "pan" a pattern across a surface, you simply add an offset to the UV coordinates. This is the core technique for creating scrolling textures and animations.
fn offset_pattern(uv: vec2<f32>, offset: vec2<f32>) -> f32 {
let offset_uv = uv + offset;
return checkerboard(offset_uv, 8.0);
}
// Animate the offset over time to create scrolling
fn scrolling_pattern(uv: vec2<f32>, time: f32, speed: vec2<f32>) -> f32 {
let offset = time * speed;
let offset_uv = uv + offset;
return checkerboard(offset_uv, 8.0);
}
Rotating UVs
Rotation is more complex because we need to rotate around a specific pivot point - usually the center of the UV space, (0.5, 0.5). The process involves three steps:
Translate the coordinates so the pivot point is at the origin
(0,0).Apply the standard 2D rotation matrix formula.
Translate the coordinates back to their original position.
fn rotate_uv(uv: vec2<f32>, angle_radians: f32, center: vec2<f32>) -> vec2<f32> {
// 1. Translate to origin
let translated_uv = uv - center;
// 2. Apply rotation
let cos_a = cos(angle_radians);
let sin_a = sin(angle_radians);
let rotated_uv = vec2<f32>(
translated_uv.x * cos_a - translated_uv.y * sin_a,
translated_uv.x * sin_a + translated_uv.y * cos_a
);
// 3. Translate back
return rotated_uv + center;
}
fn rotated_pattern(uv: vec2<f32>, angle_radians: f32) -> f32 {
let center = vec2<f32>(0.5);
let rotated_uv = rotate_uv(uv, angle_radians, center);
return checkerboard(rotated_uv, 8.0);
}
The Importance of Order
When you combine these transformations, the order in which you apply them matters significantly. The standard, most intuitive order is Scale, then Rotate, then Translate (SRT).
Scale first to set the pattern's size.
Rotate the scaled pattern around its center.
Translate the final scaled and rotated pattern to its desired position.
Changing this order will produce different results. For example, translating before rotating will cause the pattern to orbit around the pivot point instead of spinning in place.
Aspect Ratio Correction
So far, we've assumed we're working on a perfectly square plane where the UV space [0,1] maps to a surface of equal width and height. In the real world, you'll be working with viewports, windows, and meshes of all different shapes and sizes.
The Problem: Unwanted Stretching
If you apply a pattern designed in square UV space directly to a rectangular surface, the pattern will be distorted.
Imagine our circle() function. It measures distance uniformly in U and V. If the surface is twice as wide as it is tall, a unit of distance in U will be stretched to cover twice the screen space as a unit in V. Your perfect circle will be squashed into a wide ellipse.

To create truly procedural and robust materials, we must correct for this distortion.
The Solution: Pre-scaling the UVs
The solution is to "pre-squash" the UV coordinates in the longer dimension to counteract the stretching that the hardware will apply. We need to make the UV space have the same aspect ratio as our target surface.
To do this, we need one piece of information from our application: the aspect ratio of the surface (calculated as width / height). This must be passed to the shader as a uniform.
// In your Rust code, when setting up the material:
let aspect_ratio = viewport_width / viewport_height;
material.uniforms.aspect_ratio = aspect_ratio;
// In your WGSL shader
@group(2) @binding(0)
var<uniform> material: MyMaterial; // Contains aspect_ratio
Now, we can write a function to correct the UVs. This function should be the very first thing you do with your UV coordinates, before any other scaling, rotation, or pattern generation.
fn correct_aspect_ratio(uv: vec2<f32>, aspect_ratio: f32) -> vec2<f32> {
var corrected_uv = uv;
// If the surface is wider than it is tall (e.g., 1920/1080 = 1.77)
if (aspect_ratio > 1.0) {
// We need to scale the V coordinate to make it "travel faster"
// to match the U coordinate's speed across the wider screen.
corrected_uv.y *= aspect_ratio;
} else {
// Otherwise, the surface is taller than it is wide.
// Scale the U coordinate.
corrected_uv.x /= aspect_ratio;
}
return corrected_uv;
}
Wait, this looks weird! While this works, it scales the entire UV space, which can be unintuitive. A more common and stable approach is to center the coordinates first, apply the correction, and then move them back.
The Better Solution: Centered Correction
This method preserves the [0,1] range and the center point, which makes composing transformations much easier.
fn correct_aspect_ratio_centered(uv: vec2<f32>, aspect_ratio: f32) -> vec2<f32> {
// 1. Shift coordinates so (0,0) is at the center, ranging from -0.5 to 0.5.
var centered_uv = uv - 0.5;
// 2. Scale the shorter dimension to match the longer one.
if (aspect_ratio > 1.0) { // Wider than tall
centered_uv.y *= aspect_ratio;
} else { // Taller than wide
centered_uv.x /= aspect_ratio;
}
// 3. Shift the coordinates back so (0.5, 0.5) is the center again.
return centered_uv + 0.5;
}
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// Correct the UVs first!
let corrected_uv = correct_aspect_ratio_centered(in.uv, material.aspect_ratio);
// Now all subsequent patterns will be distortion-free.
let pattern = circle(corrected_uv, vec2<f32>(0.5), 0.3);
return vec4<f32>(vec3(pattern), 1.0);
}
Now, your circle will appear as a perfect circle regardless of the window's shape, giving your procedural patterns a professional and robust quality.
Complete Example: Procedural Tile Pattern Generator
It's time to build something tangible. We will create a flexible and interactive pattern generator. This project consists of a single custom material that can generate eight different patterns, transform them independently, and blend them together in four different ways - all in real-time, controlled by your keyboard.
Our Goal
We will create a scene with a single large plane that acts as our canvas. This plane will be rendered with a custom UvPatternMaterial. This material will take two pattern types and a blend mode as input, allowing us to layer and mix everything from stripes and checkerboards to spirals and dot grids on the fly. A UI panel will provide feedback on the current settings.
What This Project Demonstrates
Shader Organization: How to structure a complex shader with a library of helper functions for patterns and blending.
Dynamic Control: How to use uniforms to control shader logic from Rust, switching between different pattern types and blend modes in real-time.
UV Transformations: See the direct, interactive effect of scaling, rotating, and offsetting UV coordinates on a final pattern.
Pattern Composition: Gain an intuitive feel for how different blend modes (Mix, Add, Multiply, XOR) create vastly different results when combining the same two patterns.
Practical Application: This example is a blueprint for creating your own powerful, reusable procedural materials.
The Shader (assets/shaders/d03_03_uv_patterns.wgsl)
The heart of this demo is a single, powerful fragment shader. It is organized into three main parts:
A Library of Pattern Functions: Each of the eight patterns (stripes, circles, etc.) is encapsulated in its own clean, reusable function.
A "Router" Function: A get_pattern function uses a switch statement to select which pattern function to call based on a
u32uniform sent from Rust. This is a very efficient way to manage multiple behaviors in one shader.The Main fragment Function: This is the control center. It transforms the UV coordinates for Pattern A and Pattern B independently, calls the router to generate each one, and then uses another switch statement to blend the results based on the selected blend mode.
Notice how the UvPatternMaterial struct at the top contains all the parameters we need to control the final look from our Rust application.
#import bevy_pbr::forward_io::VertexOutput
#import bevy_pbr::mesh_view_bindings::view
struct UvPatternMaterial {
// Pattern selection (0-6)
pattern_a_type: u32,
pattern_b_type: u32,
// Pattern parameters
frequency_a: f32,
frequency_b: f32,
// Animation
time: f32,
animation_speed: f32,
// Blending
blend_mode: u32, // 0=mix, 1=add, 2=multiply, 3=xor
blend_factor: f32,
// Transform
rotation_a: f32,
rotation_b: f32,
scale_a: f32,
scale_b: f32,
}
@group(2) @binding(0)
var<uniform> material: UvPatternMaterial;
// ============================================================================
// Helper Functions
// ============================================================================
fn rotate_uv(uv: vec2<f32>, angle: f32, center: vec2<f32>) -> vec2<f32> {
let translated = uv - center;
let cos_a = cos(angle);
let sin_a = sin(angle);
let rotated = vec2<f32>(
translated.x * cos_a - translated.y * sin_a,
translated.x * sin_a + translated.y * cos_a
);
return rotated + center;
}
// ============================================================================
// Pattern Functions (return 0.0 to 1.0)
// ============================================================================
// Pattern 0: Vertical Stripes
fn pattern_vertical_stripes(uv: vec2<f32>, frequency: f32, time: f32) -> f32 {
let scaled = (uv.x + time * 0.1) * frequency;
return step(0.5, fract(scaled));
}
// Pattern 1: Horizontal Stripes
fn pattern_horizontal_stripes(uv: vec2<f32>, frequency: f32, time: f32) -> f32 {
let scaled = (uv.y + time * 0.1) * frequency;
return step(0.5, fract(scaled));
}
// Pattern 2: Checkerboard
fn pattern_checkerboard(uv: vec2<f32>, frequency: f32, time: f32) -> f32 {
let animated_uv = uv + vec2<f32>(time * 0.05);
let grid_uv = floor(animated_uv * frequency);
let sum = grid_uv.x + grid_uv.y;
return fract(sum * 0.5) * 2.0;
}
// Pattern 3: Concentric Circles
fn pattern_circles(uv: vec2<f32>, frequency: f32, time: f32) -> f32 {
let center = vec2<f32>(0.5);
let dist = distance(uv, center) + time * 0.05;
return step(0.5, fract(dist * frequency));
}
// Pattern 4: Radial Segments
fn pattern_radial(uv: vec2<f32>, frequency: f32, time: f32) -> f32 {
let center = vec2<f32>(0.5);
let to_center = uv - center;
let angle = atan2(to_center.y, to_center.x);
let normalized_angle = (angle + 3.14159265 + time * 0.5) / (2.0 * 3.14159265);
return step(0.5, fract(normalized_angle * frequency));
}
// Pattern 5: Dots Grid
fn pattern_dots(uv: vec2<f32>, frequency: f32, time: f32) -> f32 {
let grid_uv = fract(uv * frequency);
let center = vec2<f32>(0.5);
let dist = distance(grid_uv, center);
let pulse = 0.3 + sin(time * 2.0) * 0.1;
return step(dist, pulse);
}
// Pattern 6: Diagonal Stripes
fn pattern_diagonal(uv: vec2<f32>, frequency: f32, time: f32) -> f32 {
let diagonal = (uv.x + uv.y + time * 0.1) * frequency;
return step(0.5, fract(diagonal));
}
// Pattern 7: Spiral
fn pattern_spiral(uv: vec2<f32>, frequency: f32, time: f32) -> f32 {
let center = vec2<f32>(0.5);
let to_center = uv - center;
let dist = length(to_center);
let angle = atan2(to_center.y, to_center.x);
let spiral = (dist * 3.0 + angle / (2.0 * 3.14159265) + time * 0.2) * frequency;
return step(0.5, fract(spiral));
}
// ============================================================================
// Pattern Router
// ============================================================================
fn get_pattern(pattern_type: u32, uv: vec2<f32>, frequency: f32, time: f32) -> f32 {
switch pattern_type {
case 0u: { return pattern_vertical_stripes(uv, frequency, time); }
case 1u: { return pattern_horizontal_stripes(uv, frequency, time); }
case 2u: { return pattern_checkerboard(uv, frequency, time); }
case 3u: { return pattern_circles(uv, frequency, time); }
case 4u: { return pattern_radial(uv, frequency, time); }
case 5u: { return pattern_dots(uv, frequency, time); }
case 6u: { return pattern_diagonal(uv, frequency, time); }
case 7u: { return pattern_spiral(uv, frequency, time); }
default: { return 0.0; }
}
}
// ============================================================================
// Blending Modes
// ============================================================================
fn blend_patterns(a: f32, b: f32, mode: u32, factor: f32) -> f32 {
switch mode {
case 0u: { return mix(a, b, factor); } // Mix/Lerp
case 1u: { return clamp(a + b * factor, 0.0, 1.0); } // Add
case 2u: { return a * mix(1.0, b, factor); } // Multiply
case 3u: { return abs(a - b) * factor + a * (1.0 - factor); } // XOR
default: { return a; }
}
}
// ============================================================================
// Vertex Shader
// ============================================================================
@vertex
fn vertex(
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
) -> VertexOutput {
var out: VertexOutput;
// Simple pass-through vertex shader
let world_position = vec4<f32>(position, 1.0);
out.position = view.clip_from_world * world_position;
out.uv = uv;
out.world_position = world_position;
out.world_normal = normal;
return out;
}
// ============================================================================
// Fragment Shader
// ============================================================================
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let time = material.time * material.animation_speed;
// Transform UV for pattern A
var uv_a = rotate_uv(in.uv, material.rotation_a, vec2<f32>(0.5));
uv_a = (uv_a - 0.5) * material.scale_a + 0.5;
// Transform UV for pattern B
var uv_b = rotate_uv(in.uv, material.rotation_b, vec2<f32>(0.5));
uv_b = (uv_b - 0.5) * material.scale_b + 0.5;
// Generate patterns
let pattern_a = get_pattern(
material.pattern_a_type,
uv_a,
material.frequency_a,
time
);
let pattern_b = get_pattern(
material.pattern_b_type,
uv_b,
material.frequency_b,
time
);
// Blend patterns
let final_pattern = blend_patterns(
pattern_a,
pattern_b,
material.blend_mode,
material.blend_factor
);
// Convert to color (grayscale for simplicity, could add color mapping)
let color = vec3<f32>(final_pattern);
return vec4<f32>(color, 1.0);
}
The Rust Material (src/materials/d03_03_uv_patterns.rs)
This Rust module defines the UvPatternMaterial asset. The uniforms field contains a struct that exactly mirrors the layout of the one in our shader, allowing Bevy to correctly send our data to the GPU. We also include helper functions to get human-readable names for our pattern and blend types, which is useful for the UI.
use bevy::prelude::*;
use bevy::render::render_resource::{AsBindGroup, ShaderRef};
mod uniforms {
#![allow(dead_code)]
use bevy::render::render_resource::ShaderType;
#[derive(ShaderType, Debug, Clone)]
pub struct UvPatternMaterial {
pub pattern_a_type: u32,
pub pattern_b_type: u32,
pub frequency_a: f32,
pub frequency_b: f32,
pub time: f32,
pub animation_speed: f32,
pub blend_mode: u32,
pub blend_factor: f32,
pub rotation_a: f32,
pub rotation_b: f32,
pub scale_a: f32,
pub scale_b: f32,
}
impl Default for UvPatternMaterial {
fn default() -> Self {
Self {
pattern_a_type: 0, // Vertical stripes
pattern_b_type: 1, // Horizontal stripes
frequency_a: 10.0,
frequency_b: 10.0,
time: 0.0,
animation_speed: 1.0,
blend_mode: 0, // Mix
blend_factor: 0.5,
rotation_a: 0.0,
rotation_b: 0.0,
scale_a: 1.0,
scale_b: 1.0,
}
}
}
}
pub use uniforms::UvPatternMaterial as UvPatternUniforms;
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct UvPatternMaterial {
#[uniform(0)]
pub uniforms: UvPatternUniforms,
}
impl Material for UvPatternMaterial {
fn vertex_shader() -> ShaderRef {
"shaders/d03_03_uv_patterns.wgsl".into()
}
fn fragment_shader() -> ShaderRef {
"shaders/d03_03_uv_patterns.wgsl".into()
}
}
// Helper function to get pattern name
pub fn get_pattern_name(pattern_type: u32) -> &'static str {
match pattern_type {
0 => "Vertical Stripes",
1 => "Horizontal Stripes",
2 => "Checkerboard",
3 => "Concentric Circles",
4 => "Radial Segments",
5 => "Dots Grid",
6 => "Diagonal Stripes",
7 => "Spiral",
_ => "Unknown",
}
}
pub fn get_blend_mode_name(blend_mode: u32) -> &'static str {
match blend_mode {
0 => "Mix/Lerp",
1 => "Add",
2 => "Multiply",
3 => "XOR",
_ => "Unknown",
}
}
Don't forget to add it to src/materials/mod.rs:
// ... other materials
pub mod d03_03_uv_patterns;
The Demo Module (src/demos/d03_03_uv_patterns.rs)
This is the main application logic. The setup function spawns our camera and the plane mesh with our custom UvPatternMaterial. The bulk of the code is in the handle_input system, which listens for keyboard presses. When a key is pressed, it finds our material asset and directly modifies the values in its uniforms struct. Bevy's rendering engine automatically detects this change and sends the updated data to the GPU for the next frame.
use crate::materials::d03_03_uv_patterns::{
UvPatternMaterial, UvPatternUniforms, get_blend_mode_name, get_pattern_name,
};
use bevy::prelude::*;
pub fn run() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(MaterialPlugin::<UvPatternMaterial>::default())
.add_systems(Startup, setup)
.add_systems(Update, (update_time, handle_input, update_ui))
.run();
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<UvPatternMaterial>>,
) {
// Create a plane to display patterns
commands.spawn((
Mesh3d(meshes.add(Plane3d::default().mesh().size(4.0, 4.0))),
MeshMaterial3d(materials.add(UvPatternMaterial {
uniforms: UvPatternUniforms::default(),
})),
Transform::from_xyz(0.0, 0.0, 0.0),
));
// Camera
commands.spawn((
Camera3d::default(),
Transform::from_xyz(0.0, 5.0, 0.1).looking_at(Vec3::ZERO, Vec3::Y),
));
// UI
commands.spawn((
Text::new(
"[1-8] Pattern A | [Shift+1-8] Pattern B | [Q/E] Frequency A | [R/T] Frequency B\n\
[A/D] Rotate A | [F/G] Rotate B | [Z/C] Scale A | [V/B] Scale B\n\
[Tab] Blend Mode | [Space] Blend Factor | [P] Pause Animation | [L] Reset\n\
\n\
Pattern A: Vertical Stripes (Freq: 10.0)\n\
Pattern B: Horizontal Stripes (Freq: 10.0)\n\
Blend Mode: Mix/Lerp (Factor: 0.50)\n\
Animation: ON",
),
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 update_time(time: Res<Time>, mut materials: ResMut<Assets<UvPatternMaterial>>) {
for (_, material) in materials.iter_mut() {
material.uniforms.time = time.elapsed_secs();
}
}
fn handle_input(
keyboard: Res<ButtonInput<KeyCode>>,
time: Res<Time>,
mut materials: ResMut<Assets<UvPatternMaterial>>,
) {
let delta = time.delta_secs();
for (_, material) in materials.iter_mut() {
let shift = keyboard.pressed(KeyCode::ShiftLeft) || keyboard.pressed(KeyCode::ShiftRight);
// Pattern selection (1-8)
if keyboard.just_pressed(KeyCode::Digit1) {
if shift {
material.uniforms.pattern_b_type = 0;
} else {
material.uniforms.pattern_a_type = 0;
}
}
if keyboard.just_pressed(KeyCode::Digit2) {
if shift {
material.uniforms.pattern_b_type = 1;
} else {
material.uniforms.pattern_a_type = 1;
}
}
if keyboard.just_pressed(KeyCode::Digit3) {
if shift {
material.uniforms.pattern_b_type = 2;
} else {
material.uniforms.pattern_a_type = 2;
}
}
if keyboard.just_pressed(KeyCode::Digit4) {
if shift {
material.uniforms.pattern_b_type = 3;
} else {
material.uniforms.pattern_a_type = 3;
}
}
if keyboard.just_pressed(KeyCode::Digit5) {
if shift {
material.uniforms.pattern_b_type = 4;
} else {
material.uniforms.pattern_a_type = 4;
}
}
if keyboard.just_pressed(KeyCode::Digit6) {
if shift {
material.uniforms.pattern_b_type = 5;
} else {
material.uniforms.pattern_a_type = 5;
}
}
if keyboard.just_pressed(KeyCode::Digit7) {
if shift {
material.uniforms.pattern_b_type = 6;
} else {
material.uniforms.pattern_a_type = 6;
}
}
if keyboard.just_pressed(KeyCode::Digit8) {
if shift {
material.uniforms.pattern_b_type = 7;
} else {
material.uniforms.pattern_a_type = 7;
}
}
// Frequency controls
if keyboard.pressed(KeyCode::KeyQ) {
material.uniforms.frequency_a = (material.uniforms.frequency_a - delta * 5.0).max(1.0);
}
if keyboard.pressed(KeyCode::KeyE) {
material.uniforms.frequency_a = (material.uniforms.frequency_a + delta * 5.0).min(50.0);
}
if keyboard.pressed(KeyCode::KeyR) {
material.uniforms.frequency_b = (material.uniforms.frequency_b - delta * 5.0).max(1.0);
}
if keyboard.pressed(KeyCode::KeyT) {
material.uniforms.frequency_b = (material.uniforms.frequency_b + delta * 5.0).min(50.0);
}
// Rotation controls
if keyboard.pressed(KeyCode::KeyA) {
material.uniforms.rotation_a += delta;
}
if keyboard.pressed(KeyCode::KeyD) {
material.uniforms.rotation_a -= delta;
}
if keyboard.pressed(KeyCode::KeyF) {
material.uniforms.rotation_b += delta;
}
if keyboard.pressed(KeyCode::KeyG) {
material.uniforms.rotation_b -= delta;
}
// Scale controls
if keyboard.pressed(KeyCode::KeyZ) {
material.uniforms.scale_a = (material.uniforms.scale_a - delta * 0.5).max(0.1);
}
if keyboard.pressed(KeyCode::KeyC) {
material.uniforms.scale_a = (material.uniforms.scale_a + delta * 0.5).min(5.0);
}
if keyboard.pressed(KeyCode::KeyV) {
material.uniforms.scale_b = (material.uniforms.scale_b - delta * 0.5).max(0.1);
}
if keyboard.pressed(KeyCode::KeyB) {
material.uniforms.scale_b = (material.uniforms.scale_b + delta * 0.5).min(5.0);
}
// Blend mode
if keyboard.just_pressed(KeyCode::Tab) {
material.uniforms.blend_mode = (material.uniforms.blend_mode + 1) % 4;
}
// Blend factor
if keyboard.pressed(KeyCode::Space) {
material.uniforms.blend_factor += delta * 0.5;
if material.uniforms.blend_factor > 1.0 {
material.uniforms.blend_factor = 0.0;
}
}
// Animation toggle
if keyboard.just_pressed(KeyCode::KeyP) {
if material.uniforms.animation_speed > 0.0 {
material.uniforms.animation_speed = 0.0;
} else {
material.uniforms.animation_speed = 1.0;
}
}
// Reset
if keyboard.just_pressed(KeyCode::KeyL) {
material.uniforms = UvPatternUniforms::default();
}
}
}
fn update_ui(materials: Res<Assets<UvPatternMaterial>>, mut text_query: Query<&mut Text>) {
if !materials.is_changed() {
return;
}
if let Some((_, material)) = materials.iter().next() {
let pattern_a_name = get_pattern_name(material.uniforms.pattern_a_type);
let pattern_b_name = get_pattern_name(material.uniforms.pattern_b_type);
let blend_mode_name = get_blend_mode_name(material.uniforms.blend_mode);
let animation_status = if material.uniforms.animation_speed > 0.0 {
"ON"
} else {
"OFF"
};
for mut text in text_query.iter_mut() {
**text = format!(
"[1-8] Pattern A | [Shift+1-8] Pattern B | [Q/E] Frequency A | [R/T] Frequency B\n\
[A/D] Rotate A | [F/G] Rotate B | [Z/C] Scale A | [V/B] Scale B\n\
[Tab] Blend Mode | [Space] Blend Factor | [P] Pause Animation | [L] Reset\n\
\n\
Pattern A: {} (Freq: {:.1}, Rot: {:.1}°, Scale: {:.1})\n\
Pattern B: {} (Freq: {:.1}, Rot: {:.1}°, Scale: {:.1})\n\
Blend Mode: {} (Factor: {:.2})\n\
Animation: {}",
pattern_a_name,
material.uniforms.frequency_a,
material.uniforms.rotation_a.to_degrees(),
material.uniforms.scale_a,
pattern_b_name,
material.uniforms.frequency_b,
material.uniforms.rotation_b.to_degrees(),
material.uniforms.scale_b,
blend_mode_name,
material.uniforms.blend_factor,
animation_status
);
}
}
}
Don't forget to add it to src/demos/mod.rs:
// ... other demos
pub mod d03_03_uv_patterns;
And register it in src/main.rs:
Demo {
number: "3.3",
title: "UV-Based Patterns",
run: demos::d03_03_uv_patterns::run,
},
Running the Demo
When you run the application, you will see a large plane displaying an animated, blended pattern. Use the keyboard controls listed in the UI and below to explore the vast number of combinations you can create.
Controls
| Key(s) | Action |
| 1-8 | Select Pattern A type. |
| Shift + 1-8 | Select Pattern B type. |
| Q / E | Decrease / Increase Frequency of Pattern A. |
| R / T | Decrease / Increase Frequency of Pattern B. |
| A / D | Rotate Pattern A. |
| F / G | Rotate Pattern B. |
| Z / C | Scale Pattern A (smaller / larger). |
| V / B | Scale Pattern B (smaller / larger). |
| Tab | Cycle through Blend Modes (Mix, Add, Multiply, XOR). |
| Space | Animate the Blend Factor from 0.0 to 1.0. |
| P | Pause / Resume all animations. |
| L | Reset all parameters to their default state. |
What You're Seeing



This interactive playground is designed to give you an intuitive feel for how all the concepts in this article work together.
Pattern Selection: Use the number keys to swap out the base patterns (
A) and the layer patterns (B). Notice how some patterns are Cartesian (stripes, checkerboards) while others are polar (circles, spirals).Transformations: Use the frequency, rotation, and scale keys to manipulate each pattern independently. You can have a large, slow-moving spiral blended with small, static checkerboards.
Blending: This is the most important control. Cycle through the blend modes with Tab to see how they combine the two patterns you've designed.
Mix: Creates a smooth, transparent blend.
Add: Makes overlapping areas brighter, creating a grid or glowing effect.
Multiply: Uses one pattern to mask the other.
XOR: Creates an interference pattern where the patterns are different.
Animation: The subtle, constant motion helps you see how the patterns are constructed. Pause it with P for a static view to analyze a specific combination.
Interesting Combinations to Try
Grid Effect:
Pattern A: Vertical Stripes
Pattern B: Horizontal Stripes
Blend: Add
Result: A perfect grid.
Circular Weave:
Pattern A: Concentric Circles (high frequency)
Pattern B: Radial Segments
Blend: Multiply
Result: A woven, spiderweb-like pattern.
Moiré Effect:
Pattern A: Dots Grid (Freq ~20)
Pattern B: Dots Grid (Freq ~22)
Rotate pattern B slightly with F.
Blend: Mix
Result: Hypnotic, shifting interference patterns.
Dotted Checkerboard:
Pattern A: Checkerboard
Pattern B: Dots Grid
Blend: Multiply
Result: Dots appear only inside the black (or white) squares.
Key Takeaways
You have now built a strong foundation in procedural pattern generation. This is a deep topic, but mastering it begins with the core concepts we've covered. Before moving on, make sure these key ideas are clear:
UVs are Your Canvas: UV coordinates provide a normalized
[0,1]2D coordinate system mapped directly onto the surface of your 3D models. Visualizing them is the first step to understanding any procedural effect.fract()Creates Repetition: This function is the engine of all tiling patterns. By scaling a UV coordinate and taking its fractional part, you create a repeating 0-to-1 gradient.step()andsmoothstep()Create Edges: These functions are the tools you use to turn smooth gradients into hard or soft binary patterns. They are the "ink" for your drawings.Combine Simple Patterns for Complexity: The true power of this technique comes from layering. By adding, multiplying, mixing, or XORing simple patterns like stripes and circles, you can generate an almost infinite variety of complex designs.
Transform the Space, Not the Pattern: The most flexible way to control a pattern's size, position, and orientation is to apply scale, offset, and rotation transformations to the UV coordinates before they are fed into your pattern-generating functions.
Always Correct for Aspect Ratio: To ensure your patterns are not distorted on non-square surfaces, always apply an aspect ratio correction to your UVs as the very first step.
What's Next?
We have mastered the art of creating intricate, dynamic patterns from pure mathematics. But what about incorporating real-world details? How do we blend these procedural techniques with the rich visual information found in image files?
In the next article, we will bridge the gap between procedural generation and traditional texturing. We'll dive into texture sampling, learning how to load image files (like .png or .jpg) into our shaders and map them onto our models using the very same UV coordinates we've been manipulating. We'll explore how to combine sampled textures with procedural patterns to create materials that are both detailed and dynamic.
Next up: 3.4- Gradients and Interpolation
Quick Reference
A concise guide to the functions and formulas used in this article.
Essential WGSL Functions
// Returns the fractional part of x (e.g., fract(3.7) -> 0.7)
let repeating_gradient = fract(x);
// Returns 0.0 if x < edge, else 1.0
let hard_edge = step(edge, x);
// Smoothly transitions from 0.0 to 1.0 as x goes from edge1 to edge2
let soft_edge = smoothstep(edge1, edge2, x);
// Distance between two points
let dist = distance(point_a, point_b);
// Distance from the origin (0,0)
let len = length(vector);
// Angle of a vector in radians (-PI to PI)
let angle = atan2(vector.y, vector.x);
Common Pattern Formulas
Cartesian Patterns
// Vertical Stripes
let stripes_v = step(0.5, fract(uv.x * frequency));
// Checkerboard (XOR method)
let u = step(0.5, fract(uv.x * frequency));
let v = step(0.5, fract(uv.y * frequency));
let checker = abs(u - v);
// Dots Grid
let grid_uv = fract(uv * frequency);
let dots = 1.0 - step(0.4, distance(grid_uv, vec2(0.5)));
Radial Patterns
// Circle
let circle = 1.0 - step(radius, distance(uv, center));
// Ring
let outer = 1.0 - step(radius + thickness, dist);
let inner = 1.0 - step(radius - thickness, dist);
let ring = outer - inner;
// Concentric Rings
let rings = step(0.5, fract(distance(uv, center) * frequency));
UV Transformations
// Scale (makes pattern smaller/more frequent)
let scaled_uv = uv * scale_factor;
// Offset / Pan / Scroll
let offset_uv = uv + vec2(offset_x, offset_y);
// Rotate around a pivot point
fn rotate(uv, angle, pivot) -> vec2<f32> {
let sa = sin(angle);
let ca = cos(angle);
let rotated = mat2x2(ca, -sa, sa, ca) * (uv - pivot);
return rotated + pivot;
}
Blending Operations
// Smooth blend (t from 0.0 to 1.0)
let result = mix(pattern_a, pattern_b, t);
// Additive
let result = clamp(pattern_a + pattern_b, 0.0, 1.0);
// Multiplicative (Masking)
let result = pattern_a * pattern_b;
// Union (A or B)
let result = max(pattern_a, pattern_b);
// Intersection (A and B)
let result = min(pattern_a, pattern_b);
// Subtraction (A not B)
let result = max(pattern_a - pattern_b, 0.0);
// XOR
let result = abs(pattern_a - pattern_b);






