Skip to main content

Command Palette

Search for a command to run...

1.3 - WGSL Fundamentals - Functions & Control Flow

Updated
23 min read
1.3 - WGSL Fundamentals - Functions & Control Flow

What We're Learning

In the last article, we learned the "nouns" of the WGSL language - the data types like vec3<f32> and mat4x4<f32> that describe the digital clay of our 3D world. We have the materials, but data on its own is static. To create anything dynamic, responsive, or visually interesting, we need to perform operations, make decisions, and repeat tasks. It's time to learn the "verbs" and "grammar" of WGSL.

This article is about giving your shader logic. We'll start with functions, the cornerstone of organization. Functions allow us to package complex operations into clean, reusable tools. Instead of a single, monolithic block of code, we'll build a custom toolkit of functions that makes our shaders modular, readable, and powerful.

With functions as our building blocks, we'll introduce control flow. This is what gives your shader intelligence. Using if/else, your shader can finally ask questions: "Is this fragment close to a light source?", "Should this pixel be part of a stripe or a solid color?". Using loops, it can perform the repetitive work essential for complex effects, like layering multiple waves for a water ripple or accumulating light from several sources.

Finally, we'll uncover a unique and powerful tool exclusive to fragment shaders: the discard keyword, which lets us create actual see-through holes and cutouts in our geometry.

By the end of this article, you'll understand how to:

  • Organize your code into clean, reusable tools with functions.

  • Give your shaders decision-making abilities with conditional logic (if/else).

  • Build complex, layered effects by repeating actions with loops.

  • Take direct control of shader execution with return and the powerful discard keyword.

Functions: The Building Blocks of Shader Logic

As your shader effects become more complex, writing all your code inside the main @fragment or @vertex entry points will quickly become unmanageable. The key to writing clean, powerful, and reusable shader code is to break down your logic into functions. This practice, known as abstraction, transforms your main shader logic from a giant wall of math into a clear, high-level sequence of steps.

Function Syntax in WGSL

The function syntax in WGSL is nearly identical to Rust's, using the fn keyword, typed parameters, and an arrow (->) to specify the return type.

// A function to calculate perceived brightness (luminance) of a linear RGB color.
// The magic numbers are standardized weights that model the human eye's
// sensitivity to red, green, and blue light.
fn get_luminance(color: vec3<f32>) -> f32 {
    let weights = vec3<f32>(0.2126, 0.7152, 0.0722);
    return dot(color, weights);
}

// A function to blend between two colors. WGSL's built-in `mix()` function
// is a highly optimized way to perform this linear interpolation.
fn blend_colors(a: vec3<f32>, b: vec3<f32>, factor: f32) -> vec3<f32> {
    // `mix(a, b, t)` is equivalent to `a * (1.0 - t) + b * t`.
    return mix(a, b, factor);
}

An Important Rule: No Function Overloading

Unlike some other languages, WGSL keeps its function resolution simple: every function must have a unique name. You cannot define two functions with the same name, even if they have different parameter types. This is known as "function overloading," and it is not supported.

// ✗ ILLEGAL in WGSL - this will cause a compile error.
fn scale(v: vec2<f32>, s: f32) -> vec2<f32> { /* ... */ }
fn scale(v: vec3<f32>, s: f32) -> vec3<f32> { /* ... */ } // Error: name `scale` is already in use.

// ✓ The CORRECT way in WGSL is to use unique, descriptive names.
fn scale_vec2(v: vec2<f32>, s: f32) -> vec2<f32> { /* ... */ }
fn scale_vec3(v: vec3<f32>, s: f32) -> vec3<f32> { /* ... */ }

This design choice favors explicitness and clarity, ensuring that a function call is never ambiguous.

Making Decisions: Conditional Logic

To create dynamic and responsive visuals, your shader needs to be able to ask questions and change its behavior based on the answers. This is the role of conditional logic.

if/else if/else Statements

The if statement is the most fundamental tool for decision-making. It allows you to execute a block of code only when a certain condition is true. The syntax is identical to Rust and many other languages.

// This function quantizes a brightness value into one of three distinct levels.
fn classify_brightness(value: f32) -> vec3<f32> {
    // If the value is greater than 0.7, return bright white.
    if value > 0.7 {
        return vec3<f32>(1.0); // Bright white
    }
    // Otherwise, if it's greater than 0.3, return medium gray.
    else if value > 0.3 {
        return vec3<f32>(0.5); // Medium gray
    }
    // In all other cases, return black.
    else {
        return vec3<f32>(0.0); // Black
    }
}

You can combine conditions using standard logical operators (&&, ||, !) and comparison operators (==, !=, <, >).

Performance Note: if statements can be surprisingly expensive on a GPU. GPUs achieve their speed by having many cores execute the same instruction in lockstep. If an if condition is true for some pixels in a group but false for others, the group experiences "thread divergence." The GPU must execute both the if and the else blocks, with each core "masking off" the instructions that don't apply to it. This serializes the execution and negates the GPU's parallel advantage. For simple choices, a branchless alternative is often much faster.

The select() Function: A Branchless Alternative

Many languages have a "ternary operator" like condition ? value_if_true : value_if_false. WGSL does not have the ? : syntax.

The WGSL equivalent is the powerful, built-in select() function.

// select(value_if_false, value_if_true, condition);

// Example:
let is_bright = brightness > 0.5;
let color = select(
    vec3<f32>(0.0), // Value if `is_bright` is false
    vec3<f32>(1.0), // Value if `is_bright` is true
    is_bright       // The boolean condition
);

This is a branchless operation. The GPU evaluates both the true and false values in parallel and then simply selects the correct one based on the condition. This completely avoids the performance penalty of thread divergence for simple assignments.

Let's compare the two approaches:

// Using an `if` statement (potential for divergence):
var color_if: vec3<f32>;
if brightness > 0.5 {
    color_if = vec3<f32>(1.0); // White
} else {
    color_if = vec3<f32>(0.0); // Black
}

// Using `select()` (branchless, concise, and often faster):
let color_select = select(
    vec3<f32>(0.0),      // Black (if condition is false)
    vec3<f32>(1.0),      // White (if condition is true)
    brightness > 0.5     // The condition
);

A Critical Warning About select() and Function Calls

It is syntactically valid to pass the results of function calls into select(), but you must understand the performance trap: select() always evaluates both arguments.

// ✗ DANGEROUS: Both expensive functions are called for every pixel!
let final_color = select(
    calculate_complex_shadows(),  // This ALWAYS runs.
    calculate_direct_lighting(),  // This ALWAYS runs too.
    is_in_shadow
);

// ✓ BETTER: Use `if` for expensive, mutually exclusive branches.
var final_color: vec3<f32>;
if is_in_shadow {
    final_color = calculate_complex_shadows(); // Only runs when needed.
} else {
    final_color = calculate_direct_lighting(); // Only runs when needed.
}
  • Use select() with function calls only if the functions are cheap. If they are simple one-liners or trivial calculations, the benefit of avoiding a branch is worth it.

  • NEVER use select() with expensive function calls. You will be forcing the GPU to do twice the work necessary for every single pixel. In this scenario, an if/else statement is far more performant, even with the risk of thread divergence, because the total amount of computation is significantly lower.

Rule of Thumb: Use if statements for complex logic or when choosing between the results of expensive function calls. Use select() for simple, single-line value assignments based on a boolean condition.

Repeating Actions: Loops

Loops are essential for creating complex, layered effects that would be tedious or impossible to write by hand. They allow you to repeat a block of code multiple times, which is perfect for tasks like summing up light contributions, generating procedural noise, or applying a series of blurs.

WGSL provides three types of loops, each suited to different situations.

for Loops: The Standard Workhorse

The for loop is the most common and structured way to repeat an action a specific number of times. Its predictable structure - initializer, condition, and update step - makes it the ideal choice for most shader tasks.

Syntax: for ( <initializer>; <condition>; <update> ) { ... }

// A classic `for` loop that executes 8 times, with `i` taking values from 0 through 7.
// Note: The loop counter `i` must be declared as a `var`.
for (var i: i32 = 0; i < 8; i = i + 1) {
    // Loop body executes here
}

// Practical Example: Creating layered sine waves for a ripple effect.
// This is a common technique in procedural generation.
fn layered_waves(x_pos: f32) -> f32 {
    var sum: f32 = 0.0;

    // We add 4 layers ("octaves") of sine waves.
    for (var i: i32 = 0; i < 4; i = i + 1) {
        let i_f32 = f32(i);
        // Each layer has double the frequency and half the amplitude of the last.
        let frequency = pow(2.0, i_f32);
        let amplitude = pow(0.5, i_f32);
        sum += sin(x_pos * frequency) * amplitude;
    }
    return sum;
}

while Loops

A while loop is simpler: it continues to execute as long as its condition remains true. It's useful when you don't know the exact number of iterations in advance, but be cautious, as this can easily lead to the performance issues discussed below.

Syntax: while ( <condition> ) { ... }

fn find_first_step_above_threshold(start_val: f32, threshold: f32) -> i32 {
    var value = start_val;
    var steps: i32 = 0;

    // Keep looping as long as `value` is below the `threshold`.
    while (value < threshold) {
        value *= 1.1; // Increase value by 10%
        steps += 1;
    }
    return steps;
}

loop: The Infinite Loop with break and continue

The loop keyword creates a true infinite loop that relies on internal logic to terminate. Its power comes from using the break and continue keywords to control its execution from within.

  • break: Immediately exits the innermost loop it's in.

  • continue: Immediately stops the current iteration and jumps to the beginning of the next one.

You can also use break and continue in for and while loops, but they are essential for managing an explicit loop. This structure is often used for search algorithms like ray marching, where the exit condition is complex.

Syntax: loop { ... if <exit_condition> { break; } ... }

// A simplified ray marching example to find a sphere at the origin.
fn raymarch_simple_sphere(ray_origin: vec3<f32>, ray_dir: vec3<f32>) -> f32 {
    var current_pos = ray_origin;
    loop {
        let dist_to_surface = length(current_pos) - 1.0; // Distance to sphere of radius 1

        // If we are close enough, we hit the sphere.
        if dist_to_surface < 0.01 {
            break; // Exit the loop, we found it!
        }

        // If we've traveled too far without a hit, we missed.
        // This is a crucial safety break to prevent an infinite loop.
        if length(current_pos - ray_origin) > 100.0 {
            break;
        }

        // Take a "safe" step along the ray.
        current_pos += ray_dir * dist_to_surface;
    }
    return length(current_pos - ray_origin); // Return the total distance traveled
}

A Critical Performance Warning: Keep Loops Simple

This is one of the most important performance rules in shader programming. The GPU achieves its incredible speed by executing instructions in perfect lockstep. Complex, unpredictable loops can break this parallelism.

  • GOOD (Statically Analyzable): A for loop with a constant, hardcoded number of iterations is ideal. The shader compiler can perform a critical optimization called "loop unrolling," essentially pasting the loop's body code N times. This completely eliminates the looping overhead and is extremely fast for the GPU.
// ✓ IDEAL: The compiler knows this runs exactly 8 times. It can be unrolled.
for (var i = 0; i < 8; i = i + 1) { /* ... */ }
  • BAD (Dynamic Loop): A loop whose number of iterations depends on a uniform or a runtime calculation can be significantly slower. The GPU cannot predict how many times it will run, which prevents unrolling and can lead to stalls and inefficient execution.
// ⚠ DANGEROUS: The compiler doesn't know the value of `some_uniform`.
// This prevents optimization and can be much slower.
for (var i = 0; i < some_uniform; i = i + 1) { /* ... */ }

Practical Advice: For performance-critical code, always prefer for loops with a small, constant number of iterations. If you need a variable number of iterations, try to keep the maximum possible number low and consistent.

Controlling Execution Flow

Beyond the structured logic of if and loops, WGSL provides two powerful keywords that give you direct control over a function's execution path: return and discard. They are your tools for bailing out early when further calculation is unnecessary or wasteful.

return: Exiting a Function Early

While return is used at the end of functions to provide the output value, it can be placed anywhere inside a function to exit immediately. This is an incredibly useful pattern for improving both code readability and performance by handling simple "edge cases" at the very top of a function. It allows you to avoid wrapping your main logic in complex, nested if statements.

The "Guard Clause" Pattern

This best practice involves checking for invalid or simple conditions first and exiting early. These checks are called "guard clauses."

// ✗ Bad: Deeply nested and hard to read. The core logic is buried.
fn calculate_light_nested(intensity: f32, color: vec3<f32>) -> vec3<f32> {
    var final_color = vec3<f32>(0.0);
    if intensity > 0.0 {
        if any(color > vec3<f32>(0.0)) {
            // ... main complex lighting logic here ...
            final_color = color * intensity;
        }
    }
    return final_color;
}

// ✓ Good: Clean, flat, and easy to understand with "guard clauses".
fn calculate_light_guards(intensity: f32, color: vec3<f32>) -> vec3<f32> {
    // Guard clause 1: If there's no light, the result is black. Exit immediately.
    if intensity <= 0.0 {
        return vec3<f32>(0.0);
    }
    // Guard clause 2: If the surface has no color, it can't reflect light. Exit.
    if all(color == vec3<f32>(0.0)) {
        return vec3<f32>(0.0);
    }

    // Main logic is now at the top level, free of extra indentation.
    // ... main complex lighting logic here ...
    return color * intensity;
}

Using guard clauses is a key strategy for writing clean, efficient, and maintainable shader code. It separates your preconditions from your core algorithm.

discard: A Unique Fragment Shader Tool

The discard keyword is a specialized tool with a critical rule: it can only be used in a fragment shader. You cannot use it in a vertex shader or a compute shader.

When a fragment shader executes the discard keyword, it immediately stops all processing for the current fragment. The GPU effectively pretends that pixel of the triangle never existed.

  • The fragment is not written to the screen's color buffer.

  • The fragment is not written to the depth buffer.

  • It's as if that pixel was never rendered, leaving whatever was drawn behind it perfectly visible.

This allows you to create "cutouts" or holes in your geometry, making a solid surface appear transparent in certain areas without the complexities and performance costs of traditional alpha blending.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // Sample a texture that has an alpha channel for the cutout mask.
    let surface_color = textureSample(my_texture, my_sampler, in.uv);

    // If the alpha value in the texture is below a certain threshold...
    if surface_color.a < 0.5 {
        discard; // ...throw this pixel away completely.
    }

    // For all other pixels that were NOT discarded, this code will run.
    return vec4<f32>(surface_color.rgb, 1.0);
}

Common Use Cases for discard

  • Foliage: Rendering leaves on a tree by using a leaf texture on a simple quad and discarding the transparent parts.

  • Fences and Grates: Modeling a complex chain-link fence is difficult. It's much easier to use a simple plane with a fence texture and discard the holes.

  • Procedural Patterns: Creating patterns with actual see-through holes, as we'll demonstrate in our complete example.

Performance Impact

Using discard is a performance trade-off.

  • The Good: It can be faster than traditional alpha blending because you don't need to sort transparent objects. Opaque parts of the mesh can still benefit from depth testing.

  • The Bad: The mere presence of a discard statement in a shader can force the GPU to disable an important optimization called "Early-Z". Normally, the GPU can test if a pixel is hidden behind another surface before running the fragment shader. With discard, it can't know if a pixel will be thrown away until after the shader runs, so it must run the shader for every pixel, even those that will end up being hidden.

For hard-edged cutouts (like fences or leaves), discard is often the right tool for the job.


Complete Example: Interactive Pattern Generator

Theory is great, but the best way to solidify your understanding is to build something. We will now create a complete, interactive material that uses all the concepts we've just learned - functions, if/else, loops, and even discard - to generate a variety of procedural patterns in real-time.

Our Goal

We will build a single, powerful shader that can generate seven different patterns (checkerboards, circles, waves, etc.). We will control which pattern is displayed and how it's scaled using u32 and f32 uniforms sent from our Bevy application.

What This Project Demonstrates

  1. Functions for Reusability: Each of the seven patterns is encapsulated in its own clean, self-contained function (e.g., checkerboard(), circles(), waves()). This is the core of organized shader programming.

  2. if/else for Control Flow: Inside the main @fragment function, we use a chain of if/else if statements to act as a "router," calling the appropriate pattern function based on the pattern_type uniform sent from our Rust code.

  3. Loops for Complex Effects: The "Waves" pattern uses a for loop to layer multiple sine waves, creating a more complex and interesting visual than a single sin() call could achieve.

  4. discard for See-Through Effects: The "Cutout Circles" pattern uses the discard keyword to create actual, physical holes in our sphere, allowing you to see the background through it. This is a powerful, real-world demonstration of discard in action.

This project is a perfect microcosm of real-world shader development: breaking a complex problem into smaller functions and using control flow to combine them into a dynamic and configurable final result.

The Shader (assets/shaders/d01_03_pattern_gen.wgsl)

This WGSL file contains a library of small, single-purpose functions, each designed to generate a specific mathematical pattern. The main @fragment function acts as a controller, using a large if/else if chain to check a uniform value and call the appropriate pattern function based on it.

Focus on the Structure: Don't worry about the exact math inside each pattern function for now. You'll see many unfamiliar built-in functions like floor(), length(), fract(), ... These are part of WGSL's powerful standard library, which we will cover in detail in the very next article. For this example, just focus on how we use functions, if, loop, and discard to structure the shader's logic.

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

struct PatternMaterial {
    pattern_type: u32,
    scale: f32,
}

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

@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;
}

// Function: Create a checkerboard pattern
fn checkerboard(uv: vec2<f32>, scale: f32) -> f32 {
    let scaled = uv * scale;
    let x = i32(floor(scaled.x));
    let y = i32(floor(scaled.y));
    let checker = (x + y) & 1;
    return f32(checker);
}

// Function: Create concentric circles
fn circles(uv: vec2<f32>, scale: f32) -> f32 {
    let dist = length(uv);
    let ring = floor(dist * scale) % 2.0;
    return ring;
}

// Function: Create a grid pattern
fn grid(uv: vec2<f32>, scale: f32) -> f32 {
    let scaled = abs(uv) * scale;
    let grid_x = step(0.9, fract(scaled.x));
    let grid_y = step(0.9, fract(scaled.y));
    return max(grid_x, grid_y);
}

// Function: Create diagonal stripes
fn stripes(uv: vec2<f32>, scale: f32) -> f32 {
    let diagonal = (uv.x + uv.y) * scale;
    let stripe = floor(diagonal) % 2.0;
    return stripe;
}

// Function: Create dots pattern
fn dots(uv: vec2<f32>, scale: f32) -> f32 {
    let scaled = uv * scale;
    let cell = fract(scaled);
    let center = vec2<f32>(0.5, 0.5);
    let dist = length(cell - center);

    // Use step for hard edge, or smoothstep for soft edge
    return 1.0 - step(0.3, dist);
}

// Function: Create a wave pattern using loops
fn waves(uv: vec2<f32>, scale: f32) -> f32 {
    var sum = 0.0;

    // Create layered waves using a loop
    for (var i = 0; i < 5; i = i + 1) {
        let freq = f32(i + 1) * scale;
        let amp = 1.0 / f32(i + 1);
        sum = sum + sin(uv.x * freq) * amp;
    }

    // Normalize to 0-1 range
    return (sum + 1.0) * 0.5;
}

// Function: Create cutout circles (demonstrates discard)
fn cutout_circles(uv: vec2<f32>, scale: f32) -> f32 {
    let scaled = uv * scale;
    let cell = fract(scaled);
    let center = vec2<f32>(0.5, 0.5);
    let dist = length(cell - center);

    // Return distance for discard decision
    return dist;
}

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // Use XY coordinates for 2D patterns
    let uv = in.world_position.xy;

    var pattern: f32 = 0.0;

    // Select pattern based on material type
    if material.pattern_type == 0u {
        pattern = checkerboard(uv, material.scale);
    } else if material.pattern_type == 1u {
        pattern = circles(uv, material.scale);
    } else if material.pattern_type == 2u {
        pattern = grid(uv, material.scale);
    } else if material.pattern_type == 3u {
        pattern = stripes(uv, material.scale);
    } else if material.pattern_type == 4u {
        pattern = dots(uv, material.scale);
    } else if material.pattern_type == 5u {
        pattern = waves(uv, material.scale);
    } else if material.pattern_type == 6u {
        // Cutout circles - demonstrates discard
        let dist = cutout_circles(uv, material.scale);

        // Discard pixels inside circles to create holes
        if dist < 0.3 {
            discard;
        }

        pattern = 1.0;  // Everything else is white
    }

    // Create color from pattern
    let color = vec3<f32>(pattern);

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

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

This file defines the PatternMaterial struct in Rust. Its layout perfectly mirrors the PatternMaterial uniform struct in the shader, containing a u32 for the pattern type and an f32 for the scale. The AsBindGroup derive macro handles the work of making this data available to the GPU.

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

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct PatternMaterial {
    #[uniform(0)]
    pub pattern_type: u32,
    #[uniform(0)]
    pub scale: f32,
}

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

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

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

// ... other materials
pub mod d01_03_pattern_gen;

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

his Rust module sets up our Bevy scene. It spawns a sphere with our custom PatternMaterial. Critically, it contains the cycle_pattern and adjust_scale systems, which listen for keyboard input and directly modify the pattern_type and scale fields on the material asset, triggering the visual change in the shader on the next frame.

use crate::materials::d01_03_pattern_gen::PatternMaterial;
use bevy::prelude::*;

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

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<PatternMaterial>>,
) {
    // Spawn a sphere
    commands.spawn((
        Mesh3d(meshes.add(Sphere::new(1.0).mesh().uv(32, 18))),
        MeshMaterial3d(materials.add(PatternMaterial {
            pattern_type: 0,
            scale: 3.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(""),
        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 handle_input(
    keyboard: Res<ButtonInput<KeyCode>>,
    time: Res<Time>,
    mut materials: ResMut<Assets<PatternMaterial>>,
) {
    let delta = time.delta_secs();

    for (_, material) in materials.iter_mut() {
        if keyboard.just_pressed(KeyCode::Digit1) {
            material.pattern_type = 0; // Checkerboard
        }
        if keyboard.just_pressed(KeyCode::Digit2) {
            material.pattern_type = 1; // Circles
        }
        if keyboard.just_pressed(KeyCode::Digit3) {
            material.pattern_type = 2; // Grid
        }
        if keyboard.just_pressed(KeyCode::Digit4) {
            material.pattern_type = 3; // Stripes
        }
        if keyboard.just_pressed(KeyCode::Digit5) {
            material.pattern_type = 4; // Dots
        }
        if keyboard.just_pressed(KeyCode::Digit6) {
            material.pattern_type = 5; // Waves
        }
        if keyboard.just_pressed(KeyCode::Digit7) {
            material.pattern_type = 6; // Cutout Circles
        }

        if keyboard.pressed(KeyCode::ArrowUp) {
            material.scale = (material.scale + delta * 5.0).min(10.0);
        }
        if keyboard.pressed(KeyCode::ArrowDown) {
            material.scale = (material.scale - delta * 5.0).max(0.5);
        }
    }
}

fn update_ui(materials: Res<Assets<PatternMaterial>>, mut text_query: Query<&mut Text>) {
    if !materials.is_changed() {
        return;
    }

    if let Some((_, material)) = materials.iter().next() {
        let pattern_name = match material.pattern_type {
            0 => "1 - Checkerboard",
            1 => "2 - Circles",
            2 => "3 - Grid",
            3 => "4 - Stripes",
            4 => "5 - Dots",
            5 => "6 - Waves",
            6 => "7 - Cutout Circles (discard demo)",
            _ => "Unknown",
        };

        for mut text in text_query.iter_mut() {
            **text = format!(
                "[1-6]: Change Pattern | UP/DOWN: Adjust Scale\n\
                Pattern: {}\n\
                Scale: {:.1}",
                pattern_name, material.scale
            );
        }
    }
}

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

// ... other demoss
pub mod d01_03_pattern_gen;

And register it in src/main.rs:

Demo {
    number: "1.3",
    title: "WGSL Fundamentals - Functions & Control Flow",
    run: demos::d01_03_pattern_gen::run,
},

Running the Demo

When you run the project, you will see a sphere covered in a black and white pattern. Use the controls to explore the different procedurally generated patterns. Each pattern is contained in its own function and is chosen using if/else logic, demonstrating the core concepts of this article.

Controls

ControlAction
1-7 KeysInstantly switch to a specific pattern.
UP/DOWN ArrowsAdjust the scale uniform, changing the pattern frequency.

What You're Seeing

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

PatternConcept Demonstrated
CheckerboardUses integer math (floor, bitwise &) inside a dedicated function to create a classic alternating pattern.
CirclesDemonstrates using a distance function (length) combined with modulo (%) to create repeating, concentric rings.
GridShows how step() and fract() can be used to draw thin lines, with max() acting as a logical "OR".
StripesA simple function that uses diagonal coordinates (uv.x + uv.y) to create repeating lines.
DotsA great example of function composition: it uses fract() to create a grid of "cells" and then length() inside each one.
WavesThe key demonstration of loops. A for loop runs 5 times to layer multiple sin() waves together for a complex effect.
Cutout CirclesThe key demonstration of discard. An if statement calls discard for any fragment inside a dot, creating actual see-through holes in the geometry.

A Bonus Insight: World-Space vs. UV-Space

You might notice that the patterns (especially the circles and dots) appear stretched and distorted on the sides of the sphere. This is not a bug! It's a key learning moment. Our shader is generating the patterns based on the object's world_position.xy coordinates, effectively projecting them onto the model like a slide projector. This is different from "UV mapping," where a texture is carefully wrapped around the surface. We will explore UV mapping in great detail in a later phase.

Key Takeaways

You now have the essential tools to build logic and structure into your shaders. Before we move on, let's distill the most important lessons from this article into four core principles.

  1. Build a Toolbox with Functions.
    Don't write monolithic shaders. The key to clean, manageable, and reusable code is to break every distinct task into its own function. Think of functions like get_luminance() or checkerboard() not just as code, but as custom tools you are building. This practice will make your shaders dramatically easier to read, debug, and expand upon.

  2. Use if for Logic, select() for Selection.
    You have two ways to make decisions. Use if/else statements for complex logic where different branches execute entirely different blocks of code or expensive functions. For simple conditional assignments, prefer the branchless select() function - it's more concise and often more performant on the GPU.

  3. The #1 Rule of Loops: Keep Them Simple.
    While loops are powerful, they can be a major performance trap on the GPU. The most critical lesson is to avoid loops with a dynamic or unpredictable number of iterations. Always prefer for loops with a small, constant iteration count (e.g., for (var i=0; i<8; i=i+1)). This allows the compiler to heavily optimize the code, often by "unrolling" it for maximum speed.

  4. Exit Early with return and discard.
    Don't be afraid to exit your logic early. Use return at the top of your functions as "guard clauses" to handle simple cases and flatten your code, making the main logic path clearer. In fragment shaders, remember you have a unique tool: discard. Use it to completely throw away a pixel, which is the perfect and most efficient method for creating hard-edged cutouts like leaves or fences.

What's Next?

You have now mastered the grammar of WGSL. You know how to define data, organize your logic into functions, and control the flow of execution with conditionals and loops. With this knowledge, you can build the skeleton of almost any shader effect.

But what about the actual calculations that create the visual magic? How do you calculate the angle between two vectors for lighting? How do you create a smooth gradient, a perfect circle, or a rippling sine wave?

While you could build these from raw arithmetic, the GPU provides a massive, hardware-accelerated toolkit for exactly these tasks. In the next article, we will dive into WGSL's rich library of built-in mathematical functions. These are the power tools of shader programming - highly optimized functions like dot(), sin(), mix(), length(), and smoothstep() that make complex visual effects possible with a single line of code.

Next up: 1.4 - WGSL Built-in Mathematical Functions


Quick Reference

A cheat sheet for WGSL's core logic and control flow syntax.

Functions

// Syntax: fn name(param: type) -> return_type { ... }
fn add(a: f32, b: f32) -> f32 {
    return a + b;
}

Rule: No function overloading. Function names must be unique.

Control Flow Structures

ConstructSyntax
If/Elseif condition { ... } else if ... { ... } else { ... }
For Loopfor (var i: i32 = 0; i < 8; i = i + 1) { ... }
While Loopwhile condition { ... }
Looploop { if exit_condition { break; } }

Conditional Assignment (Branchless)

// The "ternary" equivalent: select(if_false, if_true, condition)
let color = select(BLACK, WHITE, brightness > 0.5);

Rule: WGSL does not have the ? : operator.

Execution Control Keywords

KeywordScopeEffect
returnFunctionExits the current function, optionally returning a value.
breakLoopImmediately exits the innermost loop.
continueLoopSkips the rest of the current iteration and starts the next.
discardFragment ShaderAborts all rendering for the current pixel.