Skip to main content

Command Palette

Search for a command to run...

1.5 - Your First Complete WGSL Shader in Bevy

Updated
32 min read
1.5 - Your First Complete WGSL Shader in Bevy

What We're Learning

You've come a long way. In the past four articles, you've learned the "what," "where," and "how" of shader fundamentals. You have the blueprints (the graphics pipeline), the raw materials (WGSL types and variables), and the power tools (built-in mathematical functions). You have all the individual pieces.

This article is the assembly line. We are going to take all that theory and put it together, piece by piece, to build our first complete, custom, and interactive Material in Bevy from scratch.

This isn't just another isolated demo; this is the core workflow. The process you learn here is the exact template you will use for almost every custom shader you create in Bevy. We will write the Rust-side setup and the multi-stage WGSL shader, connect them, and see them work together to create a finished, polished effect.

By the end of this article, you will have built a complete, animated toon shader from the ground up, and you will understand:

  • The full end-to-end workflow for creating a custom Bevy Material.

  • How to structure a WGSL file with both @vertex and @fragment stages.

  • The crucial role of input and output structs in passing data between shader stages.

  • The powerful "fragment-only" shortcut for when you only need to change an object's color.

  • How to connect your Rust Material data to your shader's uniforms using the AsBindGroup derive macro.

  • How to supercharge your development with Bevy's hot-reloading for instant visual feedback.

Anatomy of a Complete Shader

Remember the graphics pipeline we explored in our first article? We visualized it as an assembly line: Vertex Shader → Rasterization → Fragment Shader. A complete shader file is where you, the programmer, get to write the code for the programmable stations on that line.

At its core, a .wgsl file that controls both an object's geometry and its color defines two distinct programs that work in a strict sequence:

  1. The Vertex Shader (@vertex): The "Geometry" Stage. Its job is to take a single vertex from a mesh, figure out its final position on the screen, and prepare any surface data (like normals or UVs) for the next stage. It runs once for every vertex in your mesh.

  2. The Fragment Shader (@fragment): The "Coloring" Stage. Its job is to take the data prepared by the vertex shader, which has been smoothly interpolated across the surface of a triangle, and calculate the final color for a single pixel. It runs once for every single pixel your mesh covers on the screen - potentially millions of times per frame.

The key to making them work together is the "contract" between them: a data structure that the vertex shader fills with information. The GPU then takes that package, processes it through the rasterizer, and hands the smoothly blended result off to the fragment shader.

// ----------------------------------------------------
// 1. The Vertex Shader: The "Geometry" Stage
// Its job is to calculate the final 2D screen position for a single vertex.
// ----------------------------------------------------
@vertex
fn vertex(input_from_mesh: VertexInput) -> output_for_fragment_shader: VertexOutput {
    // ... code to transform the vertex's position ...

    // It also prepares and passes along any surface data the
    // fragment shader will need, like normals or UV coordinates.
    return output_data_for_fragment_shader;
}


// ----------------------------------------------------
// 2. The Fragment Shader: The "Coloring" Stage
// Its job is to calculate the final color for a single pixel.
// ----------------------------------------------------
@fragment
fn fragment(input_from_vertex_shader: VertexOutput) -> @location(0) vec4<f32> {
    // The `input` here is the *interpolated* result of what
    // the vertex shader passed out for the triangle's three corners.

    // ... code to calculate the pixel's color ...

    return final_rgba_color;
}

The entire process hinges on meticulously defining those input and output data structures. Let's build them step by step.

Step 1: Define Your Data Structures

Before we can write any logic, we must first define the shape of our data. This involves specifying two key blueprints in our WGSL file: one for the data coming in from Bevy's meshes, and one for the data we're passing out to the fragment shader.

Vertex Input: The Blueprint from the Mesh

When Bevy sends a mesh to the GPU, it provides a stream of data for each vertex. Our VertexInput struct is how we tell the vertex shader to interpret that stream.

struct VertexInput {
    // This built-in tells the shader which instance of the mesh is being drawn.
    // It's essential for getting the correct model transformation matrix.
    @builtin(instance_index) instance_index: u32,

    // The `@location(N)` attributes map directly to the mesh's vertex buffers.
    @location(0) position: vec3<f32>, // The vertex's position in local model space.
    @location(1) normal: vec3<f32>,   // The direction the vertex's surface is facing.
    @location(2) uv: vec2<f32>,       // The 2D texture coordinate.
}

What do @location(N) and @builtin mean?

These are attributes that tell the GPU where to find the data for each field.

  • @location(N): Specifies a data stream coming directly from the mesh's buffers. Bevy's standard meshes follow a consistent layout:

    • @location(0) is always the vertex POSITION.

    • @location(1) is always the vertex NORMAL.

    • @location(2) is always the first set of texture coordinates, UV_0.

You must match these locations to the data your mesh provides.

  • @builtin: Specifies a value automatically generated by the GPU's rendering pipeline itself, not from your mesh data. instance_index is one such value, telling you which copy of a mesh you are currently drawing.

Vertex Output: The Hand-Off to the Fragment Shader

This struct is the "contract" between your vertex shader and your fragment shader. It's the data package that the vertex shader prepares for each corner of a triangle.

struct VertexOutput {
    // This is the one MANDATORY field for a vertex shader's output.
    // It must be a `@builtin(position)` and contain the final
    // clip-space coordinates of the vertex (the vertex's final 2D position on the screen).
    @builtin(position) clip_position: vec4<f32>,

    // Everything else below is custom data we choose to pass along.
    // The `@location` numbers here are our own custom "channels".
    @location(0) world_position: vec4<f32>,
    @location(1) world_normal: vec3<f32>,
    @location(2) uv: vec2<f32>,
}

The Required Part vs. The Custom Part

  • Required: The @builtin(position) field is non-negotiable. The GPU's rasterizer physically cannot do its job of drawing triangles without this final coordinate.

  • Custom: Everything else is your choice. You can add any data you want to compute in the vertex shader to use later in the fragment shader. Common examples include the vertex's final world position and its transformed normal vector (both essential for lighting), or simply passing the original UV coordinates through unmodified.

The Magic of Interpolation

Here's a critical concept: the fragment shader doesn't receive the raw VertexOutput from just one vertex. The GPU's rasterizer takes the VertexOutput from all three vertices of a triangle and smoothly interpolates every @location field across the triangle's surface.

Think of it like a color gradient. If you have a red vertex, a green vertex, and a blue vertex, the rasterizer will automatically calculate the perfectly blended colors for every single pixel inside that triangle. The fragment shader then receives a unique, perfectly blended version of the VertexOutput data for every pixel it processes.

The Easy Way: Using Bevy's Standard Structs

Defining your own VertexInput and VertexOutput structs is great for learning and gives you maximum control. However, for a huge number of custom materials, your goal is simply to change the color of an object, not its shape. In these cases, you don't actually need to write a custom vertex shader at all!

Bevy's PBR (Physically Based Rendering) shader is highly modular. It provides a set of standard, pre-made structs and functions that you can import and use directly, saving you a tremendous amount of boilerplate code.

Importing Bevy's VertexOutput

Instead of manually defining your own VertexOutput struct, you can import Bevy's standard version, which already contains all the essential fields you'll need.

// Simply add this import at the top of your shader file.
#import bevy_pbr::forward_io::VertexOutput

// Now, your fragment shader can receive this struct directly.
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // Bevy's standard `VertexOutput` struct gives you access to all
    // the essential interpolated data you need for lighting and texturing:
    // - in.world_position: The fragment's position in world space.
    // - in.world_normal: The fragment's normal vector in world space.
    // - in.uv: The fragment's UV coordinates.
    // - ...and several other useful fields for more advanced PBR!

    // Example: Visualize the world normal.
    let color = (in.world_normal + 1.0) * 0.5;
    return vec4<f32>(color, 1.0);
}

This is much simpler and less error-prone than defining the struct yourself.

The Ultimate Shortcut: Skip the Vertex Shader Entirely

If you only need a custom fragment shader, you can let Bevy handle the entire vertex processing stage by using its default PBR vertex shader.

To do this, you simply don't specify a vertex_shader() in your Rust Material implementation.

// In your src/materials/my_material.rs
impl Material for MyMaterial {
    // By omitting `fn vertex_shader()`, we tell Bevy to use its default.

    // We only provide the custom fragment shader.
    fn fragment_shader() -> ShaderRef {
        "shaders/my_material.wgsl".into()
    }
}

Now, your .wgsl file becomes incredibly simple. It only needs the @fragment entry point.

// assets/shaders/my_material.wgsl

// Import the standard data structure that Bevy's vertex shader will provide.
#import bevy_pbr::forward_io::VertexOutput

// (No @vertex function is needed at all!)

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // Your custom coloring logic goes here.
    // All the necessary data (world_position, normal, uv) is already
    // correctly calculated and interpolated for you.
    return vec4<f32>(1.0, 0.0, 0.0, 1.0); // Simple red color
}

Bevy's default vertex shader handles all the complex matrix transformations to get your mesh into the correct position on screen, and it populates the VertexOutput struct with all the standard data your fragment shader is likely to need.

When to Use Each Approach

This gives you a clear choice based on your goal:

Use Bevy's Defaults (Fragment-Only Shader) When...Write a Custom Vertex Shader When...
✓ You only want to define a custom color or appearance.✓ You need to displace or deform the vertices of your mesh (e.g., for water waves or procedural animation).
✓ Your effect is based on lighting, textures, or procedural patterns.✓ You need to compute some special, custom data per-vertex that isn't standard and pass it to the fragment shader.
✓ You are just getting started and want to keep things simple.✓ You have a highly specialized rendering need that requires a completely different transformation pipeline.
(This covers ~80% of custom material use cases!)(This is for advanced effects that modify geometry.)

For beginners (and pros!): Start by writing fragment-only shaders. This is the standard, most common workflow. Only add a custom vertex shader when you have a specific reason to modify the geometry.

Step 2: Write the Vertex Shader

While using Bevy's default vertex shader is the recommended workflow for most materials, understanding how to write one yourself is a fundamental skill. This gives you ultimate control over the geometry of your mesh. The vertex shader's job is to take the raw vertex data from the mesh and produce the final screen position, while also preparing all the necessary surface data for the fragment shader.

Here is a complete, standard vertex shader that mimics the core functionality of Bevy's default.

// We import helper functions and structs provided by Bevy's PBR module.
// These save us from having to manually pass in matrices like the view-projection matrix.
#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::{position_world_to_clip}

// (We assume our `VertexInput` and `VertexOutput` structs from Step 1 are defined here)

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

    // --- The Core Logic: The Coordinate Space Journey ---

    // 1. Get the Model Matrix
    // The `get_world_from_local` function is a Bevy helper that fetches the
    // transformation matrix (position, rotation, scale) for the specific
    // object instance we are currently drawing. This matrix is our tool
    // to move the vertex from Local Space to World Space.
    let world_from_local = mesh_functions::get_world_from_local(vertex.instance_index);

    // 2. Transform Position from Local to World Space
    // We apply the model matrix to the vertex's original position. This gives
    // us the vertex's coordinate in the shared global space of our scene.
    // We convert the `vec3` position to a `vec4` to do matrix math.
    let world_position = mesh_functions::mesh_position_local_to_world(
        world_from_local,
        vec4<f32>(vertex.position, 1.0)
    );

    // 3. Transform Position from World to Clip Space
    // This is the final, mandatory step for the vertex's position. Bevy's
    // `position_world_to_clip` helper function applies the camera's View and
    // Projection matrices, transforming the vertex into the final on-screen coordinate.
    // This `vec4` is what we MUST assign to our `@builtin(position)` output field.
    out.clip_position = position_world_to_clip(world_position.xyz);

    // --- Preparing Data for the Fragment Shader ---

    // The vertex shader is also a pre-calculation stage for the fragment shader.
    // It's much more efficient to do these transformations once per vertex
    // than to do them for every single pixel.

    // Transform the normal vector to world space (normals need special handling).
    out.world_normal = mesh_functions::mesh_normal_local_to_world(
        vertex.normal,
        vertex.instance_index
    );

    // Pass along the world position and UVs for the fragment shader to use.
    out.world_position = world_position;
    out.uv = vertex.uv;

    return out;
}

Deconstructing the Process

This shader is a perfect, practical implementation of the "Coordinate Space Journey." It takes the vertex.position in its local space and shepherds it all the way to out.clip_position in clip space, using Bevy's helper functions to handle the complex matrix math.

At the same time, it acts as a pre-calculation stage. It prepares the world_normal and world_position so that the fragment shader receives them already correctly transformed and ready for lighting calculations. Doing this work once per vertex is vastly more efficient than repeating it for every single pixel on the triangle's surface. This is precisely the heavy lifting that Bevy's default vertex shader handles for you when you use the fragment-only approach.

Step 3: Write the Fragment Shader

If the vertex shader is the architect that builds the structure, the fragment shader is the interior designer that chooses the colors and textures. This is where your material's appearance is defined and all the creative work happens. It's the "Photo Editor" stage of our pipeline analogy.

// (We assume the `VertexOutput` struct is defined and received as `in`)

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // 1. Prepare Your Input Data
    // The data arriving in the `in` struct has been smoothly interpolated
    // across the triangle's surface by the rasterizer. However, this
    // interpolation process means that a normalized vector (like our normal)
    // is no longer guaranteed to have a length of 1.0.
    // For any calculation that relies on direction, especially lighting,
    // the first step is ALWAYS to re-normalize the normal vector.
    let normal = normalize(in.world_normal);

    // 2. Perform Calculations to Determine Color
    // With our data prepared, we can now implement our coloring logic.
    // This is where you can do anything you want! For this example, let's create a
    // "normal visualizer" that maps the direction of the normal vector to an RGB color.
    // The normal's components are in the [-1, 1] range. To display them as a
    // color, we need to remap them to the visible [0, 1] range. A common
    // trick for this is `(value + 1.0) * 0.5`.
    let color = (normal + 1.0) * 0.5;

    // 3. Return the Final Color
    // The final output of a fragment shader must be a `vec4<f32>` representing
    // an RGBA color. We construct our output by taking our calculated RGB `vec3`
    // and adding a fixed alpha component of 1.0 for full opacity.
    return vec4<f32>(color, 1.0);
}

The Core Logic

The fragment shader's workflow is almost always the same simple sequence:

  1. Receive the interpolated data from the in struct.

  2. Prepare the data for calculations. As we've just seen, this almost always includes re-normalizing the interpolated normal vector with normalize(). Failing to do this is one of the most common causes of incorrect lighting artifacts.

  3. Calculate a final RGB color based on your desired logic.

  4. Return that color as a vec4<f32> with an alpha value.

This is the stage where you will implement all the creative techniques you've learned: procedural patterns, color mixing, lighting models, and, as we'll see in later articles, texture sampling.

Step 4: Add Material Uniforms

So far, our shader is self-contained. It can't be configured from our Rust application. To pass in data like colors, animation speed, or other parameters, we need uniforms.

A uniform is a piece of data that remains constant for all vertices and fragments processed in a single draw call. It is our primary bridge from the CPU (Bevy/Rust) to the GPU (WGSL).

To use uniforms, we first define a struct in our WGSL code that will hold all our material's properties.

// This struct defines the data we want to send from Rust to our shader.
// The name can be whatever you like, but it should match your Rust struct.
struct CustomMaterial {
    color: vec4<f32>,
    intensity: f32,
    // Note: We'll discuss the memory layout and padding of this struct
    // in a later article. For now, Bevy helps manage this.
}

// This is the "address" where the GPU will find our material data.
@group(2) @binding(0)
var<uniform> material: CustomMaterial;


// --- Now, we can use these values in our shader ---

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // Access the fields of the uniform struct using the `material` variable.
    let base_color = material.color.rgb;
    let final_color = base_color * material.intensity;

    return vec4<f32>(final_color, material.color.a);
}

Understanding @group and @binding

These attributes are essential. They tell the GPU's pipeline exactly where to look for the data it needs. Think of it as a pre-arranged filing system. Bevy has a standard organization for these "groups":

GroupReserved ForContents
@group(0)View-levelGlobal data, same for the whole frame (camera matrices, viewport size, time).
@group(1)Mesh-levelData specific to the object being drawn (its model transformation matrix).
@group(2)Material-levelThis is yours. Custom uniforms, textures, and data that define your material.

The @binding(N) number then specifies the "slot" within that group. By convention:

  • @binding(0) is used for the main uniform struct.

  • @binding(1), @binding(2), etc., are used for textures and other resources.

So, @group(2) @binding(0) is the standard, conventional address for your custom material's uniform data in Bevy.

Can You Use Bevy's Reserved Groups?

Yes, and this is an incredibly powerful feature. While we'll cover this in more detail later, you can import and use the data structs that Bevy provides for its reserved groups.

For example, to access the global elapsed time for animations, you can import Bevy's View uniform struct and bind it in your shader:

// Import the standard struct definition from Bevy's PBR module.
#import bevy_pbr::view_bindings::View

// Bind the View struct to its standard, reserved location.
@group(0) @binding(0)
var<uniform> view: View;

// Now you can access its fields in your shader logic.
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // The `view.time` field contains the elapsed time in seconds.
    let pulse = sin(view.time * 5.0) * 0.5 + 0.5;

    let color = vec3<f32>(pulse, 0.0, 0.0); // Pulsing red
    return vec4<f32>(color, 1.0);
}

This is how you access Bevy's built-in "global" variables. For now, we will focus on our own custom material data in @group(2), but it's important to know that these other groups exist and are accessible to you.

Step 5: Integrate with Bevy

Now we need to create the Rust-side equivalent of our WGSL CustomMaterial struct. This is where we bridge the gap between our Bevy application and our shader. The goal is to define a Rust struct whose memory layout perfectly matches what the GPU expects.

This process involves two key derives: AsBindGroup and ShaderType.

The Correct Pattern: AsBindGroup with a ShaderType Struct

The AsBindGroup derive macro is the magic that connects our Rust data to the @group(2) binding in our shader. To do this robustly, we use a two-struct pattern.

A Quick Note: Why Is This Separation So Important?

You might wonder why we need two structs. The reason is that AsBindGroup and ShaderType have different, conflicting jobs.

  • ShaderType is a low-level tool that guarantees a struct's memory layout is valid for a GPU buffer. This means it can only contain "plain old data" (like floats and vectors).

  • Our main Material struct, however, often needs to hold high-level Bevy resources, like a Handle<Image> for a texture. A Handle is a complex Rust type, not plain data, so deriving ShaderType on a struct with a Handle would fail.

By separating them, we create a clean design: the Uniforms struct is our pure, GPU-ready data block, and the main Material struct is our high-level Bevy asset that organizes all the resources - both uniforms and textures - that our shader will use. This is the standard, scalable pattern.

1. The Uniforms Struct (deriving ShaderType)

First, we create a struct that contains the actual data fields we want to send to the GPU. This struct gets #[derive(ShaderType)]. This derive macro ensures the data is laid out in memory according to the GPU's strict alignment rules - a complex topic of padding and sizing that we will explore in detail in a later article.

You'll notice in the code below that we place our ShaderType struct inside a nested uniforms module. This is a common Bevy convention for a practical reason. The ShaderType derive macro generates a check function that the compiler may flag with a dead_code warning if it's not used. Placing the struct in its own module allows us to use #![allow(dead_code)] to cleanly silence this specific warning without affecting the rest of our material's code.

mod uniforms {
    use bevy::prelude::*;
    use bevy::render::render_resource::ShaderType;

    // This struct will be sent to the GPU.
    #[derive(ShaderType, Debug, Clone)]
    pub struct CustomMaterialUniforms {
        pub color: LinearRgba,
        pub intensity: f32,
        // We might need padding here to ensure the struct meets GPU alignment rules.
        // This is a topic we'll cover in depth in Article 1.7. For now, know
        // that this pattern is the correct way to manage it.
    }
}

2. The Material Struct (deriving AsBindGroup)

Next, we create our main Material struct. This struct will contain a single field of our Uniforms type, marked with #[uniform(0)].

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

// ... (the `uniforms` module from above) ...

// Re-export the uniform type for easier access elsewhere.
pub use uniforms::CustomMaterialUniforms;

// This is the main material struct that our Bevy app will interact with.
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct CustomMaterial {
    // This `#[uniform(0)]` attribute tells Bevy to take the data from the
    // `uniforms` field and make it available to the shader at `@binding(0)`.
    #[uniform(0)]
    pub uniforms: uniforms::CustomMaterialUniforms,

    // If we had a texture, it would be another field here:
    // #[texture(1)]
    // #[sampler(2)]
    // pub color_texture: Handle<Image>,
}

3. Implement the Material Trait

Finally, we implement the Material trait, telling Bevy which shader file to use.

// ...

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

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

What About a Simpler, Single-Struct Pattern?

For simple materials or rapid prototyping where you only have uniform data and no textures, you can combine everything into a single struct.

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone, ShaderType)]
pub struct SimpleMaterial {
    // The struct itself is now the uniform buffer.
    pub color: LinearRgba,
    pub intensity: f32,
}

In this case, since the entire struct is the uniform data, you don't use the #[uniform(0)] attribute. While this is simpler, the two-struct pattern is the recommended default because it scales cleanly when you eventually need to add textures or other resources.

How This Connects to WGSL

Now the link between Rust and WGSL is clear and direct:

  • Your CustomMaterial struct with #[uniform(0)] corresponds to @group(2) @binding(0).

  • The fields inside your Rust CustomMaterialUniforms struct (color, intensity) must match the fields inside your WGSL CustomMaterial struct in both name, type, and order.

Bevy and AsBindGroup handle these common type conversions for you:

RustWGSL
f32f32
LinearRgba / Colorvec4<f32>
Vec2vec2<f32>
Vec3vec3<f32>
Vec4vec4<f32>
u32u32
i32i32
Mat4mat4x4<f32>

Using Your Material in Bevy

With the material defined, there are two final steps to see it on screen.

1. Register the MaterialPlugin

You must tell Bevy's renderer about your new material type by adding its MaterialPlugin to your App. Without this, Bevy won't know how to render your material.

// In your main function
use my_game::materials::CustomMaterial; // Import your material

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        // Register your custom material with the rendering system.
        .add_plugins(MaterialPlugin::<CustomMaterial>::default())
        // ... add systems, etc.
        .run();
}

2. Spawn an Entity with the Material

Now you can use your material just like any other asset. In your setup system, you request the Assets<CustomMaterial> resource, add a new instance of it, and spawn an entity.

// In your setup system
fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<CustomMaterial>>, // Use your new material type
) {
    commands.spawn((
        Mesh3d(meshes.add(Sphere::new(1.0))),
        // Create an instance of your custom material and add it to the assets.
        MeshMaterial3d(materials.add(CustomMaterial {
            uniforms: uniforms::CustomMaterialUniforms {
                color: LinearRgba::RED,
                intensity: 1.5,
            },
        })),
    ));
}

With these steps complete, you have a fully integrated, custom material that you can configure from Rust and render with your own WGSL shader logic.

Common Issues and Solutions

When you're starting out, your first few shaders will almost certainly have bugs. This is a normal part of the process! Most issues fall into a few common categories.

IssuePossible Causes & Solutions
Object is Black or InvisibleIncorrect Alpha: Ensure your final return is vec4(color, 1.0).
Negative/Zero Color: Use max(0.0, ...) on lighting. For debugging, return vec4(1.0, 0.0, 1.0, 1.0); to see if the geometry is even there.
Incorrect Clip Position: If using a custom vertex shader, ensure your matrix math is correct and you're outputting to @builtin(position).
Shader Compilation Errors in Console'=' cannot be used in a constant expression: You used let for a variable you need to change. Use var instead.
No matching overload for call to ...: You're passing the wrong type to a function (e.g., vec3 instead of f32).
entry point must return a '@builtin(position)': Your vertex shader's output struct is missing the mandatory clip_position field.
Strange or Incorrect ColorsUn-normalized Normals: Your lighting looks blocky or wrong. ALWAYS normalize() the normal vector at the start of your fragment shader.
Incorrect Data Range: You're trying to display a value from [-1, 1] (like a normal) as a color. Remap it to [0, 1] first: (value + 1.0) * 0.5.
Z-Fighting or Flickering ArtifactsDivision by Zero: Guard against normalizing a zero-length vector.
NaN / Inf Values: Invalid math (sqrt(-1.0)) can produce "Not a Number" values. Use max() or clamp() to keep inputs in valid ranges for functions like sqrt or acos.

Testing and Debugging Your Shader

You can't println! a value from the GPU. Instead, the most powerful and immediate debugging tool you have is the screen itself. The core technique is to temporarily turn your intermediate data into colors.

The Core Technique: Visual Debugging

The process is simple: find a value in your shader you want to inspect, and at the end of your fragment shader, return that value as the final color.

1. Debugging a vec3 (like a Normal Vector)

To see what your normal vectors look like, map their [-1, 1] range to the visible [0, 1] color range.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let normal = normalize(in.world_normal);

    // ... lots of other code ...

    // DEBUG: Temporarily override the final output to visualize the normal.
    // The XYZ components of the normal vector are mapped to the RGB channels.
    return vec4<f32>((normal + 1.0) * 0.5, 1.0);
}

If your object turns into a smooth, multi-colored gradient, you know your normals are correct.

2. Debugging a vec2 (like UV Coordinates)

To check your UV mapping, output the two components into the red and green channels.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let uv = in.uv;
    // DEBUG: Visualize the UV coordinates.
    // U -> Red, V -> Green
    return vec4<f32>(uv.x, uv.y, 0.0, 1.0);
}

You should see a smooth gradient from black (at 0,0) to red (at 1,0), green (at 0,1), and yellow (at 1,1).

3. Debugging a single f32 (like a Lighting Value)

To see a single float, output it as a grayscale color by putting it in all three (R, G, B) channels.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let diffuse = max(0.0, dot(normal, light_dir));
    // DEBUG: Visualize the diffuse value as grayscale.
    return vec4<f32>(vec3<f32>(diffuse), 1.0);
}

This renders your object in grayscale, where black is 0.0 and white is 1.0. It's a perfect way to isolate just your lighting calculation.

Your Secret Weapon: Hot Reloading

Shader development is an inherently iterative process. The key to success is being able to make a small change, see the result instantly, and adjust. Bevy has a built-in feature that makes this possible: asset hot-reloading.

When enabled, Bevy watches your assets directory. If you save a .wgsl file, Bevy will recompile it and swap it on the GPU while the application is still running. This "live coding" environment is essential for a fast, creative workflow.

Enabling Hot-Reloading: The Two Essential Steps

1. Cargo.toml: Ensure the file_watcher feature is enabled for Bevy.
[dependencies]
bevy = { version = "0.16", features = ["file_watcher"] }
2. main.rs: Configure the AssetPlugin to watch for changes.
fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(AssetPlugin {
            watch_for_changes_override: Some(true),
            ..default()
        }))
        // ... rest of your app setup
        .run();
}

With this setup, just save your .wgsl file and see your changes appear instantly in the running application window.

Best Practices

  • Start Simple: Always begin a new material with the absolute minimum code that returns a solid, bright color (like magenta). If you see the color, you know your Rust setup is correct.

  • Use Descriptive Names: Shader code is dense with math. let diffuse_intensity = ... is infinitely better than let d = ....

  • Build a Function Toolbox: Don't put all your logic in one giant @fragment function. Break every distinct task into its own small, focused function (calculate_diffuse, create_rim_light, etc.).

  • Comment the "Why," Not the "What":

    • ✓ Good: // Use pow() to sharpen the falloff, creating a tighter highlight.

    • ✗ Bad: // Raise rim_factor to the power of rim_power.


Complete Example: Animated Toon Shader

Let's put all of these concepts into practice by building a complete, interactive toon shader from scratch. This material will apply a "cel-shaded" look to an object, with distinct lighting bands and a subtle rim light, all animated in real-time.

Our Goal

We will build a complete ToonMaterial from the ground up. This involves:

  1. Writing a full WGSL shader with both a vertex and fragment stage to demonstrate the complete pipeline.

  2. Defining the corresponding Rust structs with AsBindGroup and ShaderType.

  3. Creating a Bevy application that allows us to interactively change the shader's parameters in real-time.

What This Project Demonstrates

  • The full, end-to-end workflow of creating a custom material.

  • A complete shader structure with vertex and fragment stages working together.

  • A correctly structured uniform block, defined in both Rust and WGSL.

  • Passing a time uniform from Bevy to the shader to drive animation.

  • Implementing a custom lighting model (toon shading) in the fragment shader.

The Shader (assets/shaders/d01_05_toon_material.wgsl)

This is our WGSL shader. The ToonMaterial struct here must exactly match the ToonMaterialUniforms struct in our Rust code in name, type, and field order. The vertex shader performs the standard model-to-world-to-clip transformations, and the fragment shader uses our uniforms to calculate the final cel-shaded color.

#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

// Material uniforms
struct ToonMaterial {
    base_color: vec4<f32>,
    bands: u32,
    rim_color: vec4<f32>,
    rim_power: f32,
    time: f32,
}

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

// Vertex shader input - matches Bevy's mesh format
struct VertexInput {
    @builtin(instance_index) instance_index: u32,
    @location(0) position: vec3<f32>,
    @location(1) normal: vec3<f32>,
    @location(2) uv: vec2<f32>,
}

// Vertex shader output / Fragment shader input - our custom structure
struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    @location(0) world_position: vec4<f32>,
    @location(1) world_normal: vec3<f32>,
    @location(2) uv: vec2<f32>,
}

// Note: We could skip this entire vertex shader and just import:
// #import bevy_pbr::forward_io::VertexOutput
// But we're showing the full version for learning purposes!

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

    // Get transformation matrix
    let world_from_local = mesh_functions::get_world_from_local(vertex.instance_index);

    // Transform position to world space
    let world_position = mesh_functions::mesh_position_local_to_world(
        world_from_local,
        vec4<f32>(vertex.position, 1.0)
    );

    // Transform to clip space
    out.clip_position = position_world_to_clip(world_position.xyz);

    // Transform normal to world space
    out.world_normal = mesh_functions::mesh_normal_local_to_world(
        vertex.normal,
        vertex.instance_index
    );

    // Pass through
    out.world_position = world_position;
    out.uv = vertex.uv;

    return out;
}

// Quantize lighting into discrete bands (toon shading)
fn quantize_light(value: f32, bands: u32) -> f32 {
    let band_size = 1.0 / f32(bands);
    return floor(value / band_size) * band_size;
}

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let normal = normalize(in.world_normal);

    // Simple animated light direction
    let light_dir = normalize(vec3<f32>(
        cos(material.time),
        0.5,
        sin(material.time)
    ));

    // Calculate diffuse lighting
    let diffuse = max(0.0, dot(normal, light_dir));

    // Quantize into bands for toon effect
    let banded_diffuse = quantize_light(diffuse, material.bands);

    // Apply lighting to base color
    // Ambient (0.3) + diffuse contribution (0.7)
    var final_color = material.base_color.rgb * (0.3 + banded_diffuse * 0.7);

    // Add subtle rim lighting based on normal facing away from camera
    // Using a simpler approach that works without camera position
    let rim = pow(1.0 - abs(normal.z), material.rim_power);
    final_color = final_color + material.rim_color.rgb * rim * 0.3;

    return vec4<f32>(final_color, material.base_color.a);
}

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

Here, we define our Rust structs. We follow the robust two-struct pattern: ToonMaterialUniforms for the raw GPU data, and ToonMaterial for the high-level Bevy asset that implements AsBindGroup and Material.

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

mod uniforms {
    #![allow(dead_code)]

    use bevy::prelude::*;
    use bevy::render::render_resource::ShaderType;

    // This struct defines the data that will be sent to the GPU.
    // It must derive `ShaderType` for memory layout purposes.
    // Its fields MUST match the `ToonMaterial` struct in the WGSL shader.
    #[derive(ShaderType, Debug, Clone)]
    pub struct ToonMaterialUniforms {
        pub base_color: LinearRgba,
        pub bands: u32,
        pub rim_color: LinearRgba,
        pub rim_power: f32,
    }
}

// Re-export the uniforms struct for easier access in main.rs
pub use uniforms::ToonMaterialUniforms;

// This is the main material asset struct.
// It derives `AsBindGroup` to handle the binding of its fields to the shader.
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct ToonMaterial {
    // We bind the `uniforms` field to binding 0 of our material's bind group.
    #[uniform(0)]
    pub uniforms: ToonMaterialUniforms,
}

// Implement the `Material` trait for our custom material.
impl Material for ToonMaterial {
    fn vertex_shader() -> ShaderRef {
        "shaders/d01_05_toon_material.wgsl".into()
    }

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

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

// ... other materials
pub mod d01_05_toon_material;

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

Finally, we create the Bevy demo. This sets up the scene, registers our MaterialPlugin, spawns an object with the ToonMaterial, and includes systems to animate the time uniform and adjust the bands uniform with keyboard input.

use crate::materials::d01_05_toon_material::{ToonMaterial, ToonMaterialUniforms};
use bevy::prelude::*;

pub fn run() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::<ToonMaterial>::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<ToonMaterial>>,
) {
    // Create sphere with toon shader
    commands.spawn((
        Mesh3d(meshes.add(Sphere::new(1.0).mesh().uv(32, 18))),
        MeshMaterial3d(materials.add(ToonMaterial {
            uniforms: ToonMaterialUniforms {
                // Initialize the nested uniforms struct
                base_color: LinearRgba::rgb(0.2, 0.6, 0.9), // Blue
                rim_color: LinearRgba::rgb(0.8, 0.9, 1.0),  // Light blue
                bands: 3,
                rim_power: 3.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.4;
        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>>, mut materials: ResMut<Assets<ToonMaterial>>) {
    for (_, material) in materials.iter_mut() {
        if keyboard.just_pressed(KeyCode::ArrowUp) {
            material.uniforms.bands = (material.uniforms.bands + 1).min(10);
        }
        if keyboard.just_pressed(KeyCode::ArrowDown) {
            material.uniforms.bands = (material.uniforms.bands.saturating_sub(1)).max(1);
        }
    }
}

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

    if let Some((_, material)) = materials.iter().next() {
        for mut text in text_query.iter_mut() {
            **text = format!(
                "UP/DOWN: Adjust bands\n\
                Bands: {}",
                material.uniforms.bands
            );
        }
    }
}

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

// ... other demoss
pub mod d01_05_toon_material;

And register it in src/main.rs:

Demo {
    number: "1.5",
    title: "Your First Complete WGSL Shader in Bevy",
    run: demos::d01_05_toon_material::run,
},

Running the Demo

Run the project, and you will see a sphere with a distinct, non-photorealistic "cartoon" look. This effect is generated entirely by the custom shader you've just written. The application demonstrates the full end-to-end workflow, from interactive Rust systems to the final custom lighting calculations on the GPU.

Controls

ControlAction
UP ArrowIncreases the number of lighting bands, making the shading smoother.
DOWN ArrowDecreases the number of lighting bands, making the shading more stylized.

What You're Seeing

FeatureConcept Demonstrated
Toon ShadingThe core of the fragment shader. It uses a custom quantize_light() function to snap a smooth diffuse lighting calculation (dot(normal, light_dir)) into discrete, hard-edged bands, creating the classic "cel-shaded" look.
Animated LightingThe time uniform, passed from a Bevy system, is used with sin() and cos() to make the calculated light_dir vector orbit the object. This shows how CPU-side data can drive dynamic effects within the GPU.
Rim LightingA classic Fresnel-like effect that uses the dot product between the surface normal and a view_dir to add a highlight to the object's silhouette, helping it "pop" from the background.
Interactive BandsChanging the bands uniform with the arrow keys directly affects the quantize_light() function in the shader. This demonstrates the power of uniforms for creating materials with configurable, real-time parameters.
Full Data FlowThis project shows the complete journey: the Vertex Shader transforms the mesh, the Fragment Shader receives the interpolated world_normal, and the uniforms passed from Rust control the final color calculation.

The Simplified Version: Embracing Bevy's Defaults

The toon shader we just built is a perfect example of a material that only changes the color of an object, not its shape. The vertex shader we wrote was a standard, boilerplate implementation that did the exact same transformations as Bevy's default PBR vertex shader.

In cases like this, we can dramatically simplify our code by letting Bevy do the heavy lifting. Here's how the same, identical effect can be achieved with a fragment-only shader.

The Simplified Shader

Notice how much shorter this file is. The entire @vertex function, VertexInput struct, and our custom VertexOutput struct have been deleted and replaced with a single import.

#import bevy_pbr::forward_io::VertexOutput

// Uniforms struct remains identical
struct ToonMaterial {
    base_color: vec4<f32>,
    bands: u32,
    rim_color: vec4<f32>,
    rim_power: f32,
    time: f32,
}

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

// Quantize function remains identical
fn quantize_light(value: f32, bands: u32) -> f32 {
    if bands == 0u { return value; }
    let band_size = 1.0 / f32(bands);
    return floor(value / band_size) * band_size;
}

// Fragment shader is almost identical, but now receives Bevy's standard VertexOutput
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // All the logic from here down is exactly the same as before!
    let normal = normalize(in.world_normal);

    let light_dir = normalize(vec3<f32>(
        cos(material.time),
        0.5,
        sin(material.time)
    ));

    let diffuse = max(0.0, dot(normal, light_dir));
    let banded_diffuse = quantize_light(diffuse, material.bands);

    var final_color = material.base_color.rgb * (0.3 + banded_diffuse * 0.7);

    let view_dir = normalize(vec3(0.0, 0.5, 1.0));
    let rim_factor = 1.0 - dot(normal, view_dir);
[[    ]]let rim = pow(rim_factor, material.rim_power);
    final_color += material.rim_color.rgb * rim;

    return vec4<f32>(final_color, material.base_color.a);
}

The Simplified Rust Material

The change on the Rust side is even simpler. We just remove the vertex_shader() method from our Material implementation.

impl Material for ToonMaterial {
    // vertex_shader() is not defined, so Bevy will use its default PBR vertex shader.

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

That's it! Both versions of this project produce the exact same visual result. The simplified version is shorter, cleaner, and less prone to errors because we are leveraging the robust, engine-provided code for all the standard vertex transformations.

This workflow - letting Bevy handle the vertex shader while you focus exclusively on the fragment shader - is the recommended approach for the vast majority of custom materials.

Key Takeaways

You have now built your first complete, custom Bevy Material from scratch. This is a huge milestone!

  1. A Complete Shader has Two Parts: The .wgsl File and the Rust Material.
    The .wgsl file contains the GPU code (@vertex, @fragment), while the Rust file defines the Material trait and the AsBindGroup struct that acts as the bridge, sending your data to the GPU.

  2. The AsBindGroup Two-Struct Pattern is Essential and Scalable.
    To send uniform data robustly, use the two-struct pattern: a main Material struct (AsBindGroup) containing a Uniforms struct (ShaderType). This is the safe, correct way to handle both uniform data and other resources like textures.

  3. Use Bevy's Defaults Whenever Possible.
    You don't need to write a vertex shader just to change an object's color. For the vast majority of materials, you can and should use a fragment-only shader. This is the standard, recommended workflow.

  4. Hot-Reloading is Your Most Powerful Tool.
    By enabling the file_watcher feature, you create a rapid, "live-coding" development loop. This instant feedback is the key to effective learning, debugging, and creative experimentation with shaders.

What's Next?

You are now fully equipped to create and integrate your own custom materials. With this complete workflow in hand, we can now dive deeper into the specifics of the data pipeline.

In the next article, we will take a much closer look at all the @ attributes we've seen so far - @group, @binding, @location, and @builtin. You'll gain a complete understanding of how data is organized and addressed on the GPU, and how it flows from your Rust code all the way to the final pixel.

Next up: 1.6 - Shader Attributes and Data Flow


Quick Reference

The Complete Material Workflow

  1. Rust: Create a Uniforms struct (#[derive(ShaderType)]).

  2. Rust: Create a Material struct (#[derive(AsBindGroup)]) containing your Uniforms.

  3. Rust: Implement the Material trait, pointing to your .wgsl file.

  4. Rust: Add the MaterialPlugin::<YourMaterial>::default() to your App.

  5. WGSL: Create a struct that exactly matches your Rust Uniforms struct.

  6. WGSL: Bind it at @group(2) @binding(0).

  7. WGSL: Write your @fragment (and optionally @vertex) logic.

Minimal Fragment-Only Shader

#import bevy_pbr::forward_io::VertexOutput

// Your material's uniform struct
@group(2) @binding(0) var<uniform> material: MyMaterial;

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // Bevy's default vertex shader provides `in.world_position`,
    // `in.world_normal`, `in.uv`, etc., already calculated for you.
    return vec4<f32>(1.0, 0.0, 0.0, 1.0);
}

Visual Debugging Snippets

// Isolate and view your normal vectors:
return vec4<f32>((normal + 1.0) * 0.5, 1.0);

// Isolate and view a single float value as grayscale:
return vec4<f32>(vec3<f32>(my_float_value), 1.0);