<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Hexbee's Dev Journal]]></title><description><![CDATA[Hexbee's Dev Journal]]></description><link>https://blog.hexbee.net</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1765712095174/ae989118-2acb-48c9-9f21-6c8592626ad7.png</url><title>Hexbee&apos;s Dev Journal</title><link>https://blog.hexbee.net</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 09 Apr 2026 11:12:46 GMT</lastBuildDate><atom:link href="https://blog.hexbee.net/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[4.3 - Texture Wrapping Modes]]></title><description><![CDATA[What We're Learning
You've learned how to sample textures, and you've learned how to filter them to keep them sharp or smooth. Now we encounter a new problem: what happens when a mesh is bigger than i]]></description><link>https://blog.hexbee.net/4-3-texture-wrapping-modes</link><guid isPermaLink="true">https://blog.hexbee.net/4-3-texture-wrapping-modes</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[Rust]]></category><category><![CDATA[shader]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Mon, 02 Mar 2026 08:18:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1765830499174/8613a316-cbc3-427b-8983-1f6b926cfe18.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>What We're Learning</h2>
<p>You've learned how to sample textures, and you've learned how to filter them to keep them sharp or smooth. Now we encounter a new problem: what happens when a mesh is bigger than its texture?</p>
<p>If you map a 1-meter texture onto a 10-meter wall, do you stretch it? Do you tile it like wallpaper? What happens at the edges?</p>
<p>These behaviors are controlled by <strong>Texture Wrapping Modes</strong> (also known as Address Modes). In this article, you'll master:</p>
<ul>
<li><p><strong>The Address Mode Enum</strong>: Understanding Repeat, MirrorRepeat, ClampToEdge, and ClampToBorder.</p>
</li>
<li><p><strong>Sampler Configuration</strong>: How to change these settings in Bevy without reloading textures.</p>
</li>
<li><p><strong>UV Math</strong>: How to manipulate coordinates in WGSL to create scrolling, rotating, and scaling effects.</p>
</li>
<li><p><strong>Seamless Tiling</strong>: Techniques to make repeated textures look natural.</p>
</li>
</ul>
<h2>Understanding UV Coordinates and Wrapping</h2>
<p>To understand wrapping, we have to look at the math of the <strong>Sampler</strong>.</p>
<p>When your Fragment Shader calls <code>textureSample(texture, sampler, uv)</code>, the GPU looks at the UV coordinates.</p>
<ul>
<li><p><strong>Standard UVs</strong> are between <code>0.0</code> and <code>1.0</code>. <code>(0,0)</code> is usually the top-left (or bottom-left, depending on API), and <code>(1,1)</code> is the opposite corner.</p>
</li>
<li><p><strong>Out-of-Bounds UVs</strong> are anything less than <code>0.0</code> or greater than <code>1.0</code>.</p>
</li>
</ul>
<h3>The Sampler's Job</h3>
<p>The texture image is a finite grid of colored pixels (texels). It has no concept of "infinity." If you ask for the pixel at <code>1.5</code>, the image says "I don't have that."</p>
<p>The <strong>Sampler</strong> is the translator. It takes your request for 1.5, applies a specific <strong>Addressing Rule</strong>, and translates it into a valid coordinate within the <code>[0, 1]</code> range before fetching the color.</p>
<p>If you don't define this rule, the behavior is undefined (though usually defaults to clamping). In Bevy, we define these rules explicitly to get the artistic control we need.</p>
<h2>The Four Primary Wrapping Modes</h2>
<p>There are four standard modes available in WebGPU (and thus Bevy). Let's look at how they handle a UV coordinate of <code>1.5</code> (50% past the edge).</p>
<h3>1. Repeat Mode (<code>Repeat</code>)</h3>
<p>This is the standard "tiling" mode. It ignores the integer part of the coordinate and keeps only the fractional part.</p>
<ul>
<li><p><strong>Logic</strong>: <code>Final UV = fract(Input UV)</code></p>
</li>
<li><p><strong>Result</strong>: <code>1.5</code> becomes <code>0.5</code>. <code>2.5</code> becomes <code>0.5</code>.</p>
</li>
<li><p><strong>Visual</strong>: The image repeats infinitely.</p>
</li>
<li><p><strong>Use Case</strong>: Floors, brick walls, grass, or any surface that needs to cover a large area with a small texture.</p>
</li>
</ul>
<pre><code class="language-plaintext">|   Texture   |   Texture   |   Texture   |
(0.0)-----(1.0)(0.0)-----(1.0)(0.0)-----(1.0)
</code></pre>
<h3>2. Mirror Repeat Mode (<code>MirrorRepeat</code>)</h3>
<p>Similar to Repeat, but it flips the image on every other integer step.</p>
<ul>
<li><p><strong>Logic</strong>: Integers <code>0..1</code> are normal. <code>1..2</code> are flipped. <code>2..3</code> are normal.</p>
</li>
<li><p><strong>Result</strong>: Creates a seamless "ping-pong" effect.</p>
</li>
<li><p><strong>Use Case</strong>: Making non-seamless textures look continuous (e.g., generic noise, marble, or wood grain), though it can look like a kaleidoscope if the texture has distinct features.</p>
</li>
</ul>
<pre><code class="language-plaintext">|   Texture   |  erutxeT    |   Texture   |
(0.0)-----(1.0)(1.0)-----(0.0)(0.0)-----(1.0)
</code></pre>
<h3>3. Clamp to Edge (<code>ClampToEdge</code>)</h3>
<p>The sampler simply refuses to go past <code>0.0</code> or <code>1.0</code>.</p>
<ul>
<li><p><strong>Logic</strong>: <code>Final UV = clamp(Input UV, 0.0, 1.0)</code></p>
</li>
<li><p><strong>Result</strong>: <code>1.5</code> becomes <code>1.0</code>.</p>
</li>
<li><p><strong>Visual</strong>: The pixels at the very edge of the image are stretched infinitely in that direction.</p>
</li>
<li><p><strong>Use Case</strong>:</p>
<ul>
<li><p><strong>UI Elements</strong>: Prevents a button icon from tiling if the quad is slightly too big.</p>
</li>
<li><p><strong>Skyboxes/Panoramas</strong>: Ensures the edges don't bleed into the opposite side.</p>
</li>
<li><p><strong>Texture Atlases</strong>: Vital for preventing neighboring sprites from bleeding into the current one.</p>
</li>
</ul>
</li>
</ul>
<pre><code class="language-plaintext">|   Texture   |
&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;|             |&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;
Stretched  (0.0)-----(1.0)  Stretched
</code></pre>
<h3>4. Clamp to Border (<code>ClampToBorder</code>)</h3>
<p>If the UV is out of bounds, return a specific "border color" instead of sampling the texture.</p>
<ul>
<li><p><strong>Logic</strong>: <code>if (UV &lt; 0 || UV &gt; 1) return BorderColor;</code></p>
</li>
<li><p><strong>Result</strong>: The texture appears once, surrounded by the border color.</p>
</li>
<li><p><strong>Limit</strong>: In Bevy (and WebGPU), the border color is not fully customizable in the high-level API due to hardware constraints. It typically defaults to <strong>Transparent Black</strong> (0,0,0,0).</p>
</li>
<li><p><strong>Use Case</strong>: Decals that shouldn't repeat, or debugging to see exactly where your UVs are going out of bounds.</p>
</li>
</ul>
<h2>Technical Deep Dive</h2>
<h3>Configuring Samplers in Bevy</h3>
<p>In Bevy, the wrapping mode is part of the <code>ImageSampler</code>. By default, Bevy imports textures with <code>ClampToEdge</code> (or sometimes <code>Repeat</code> depending on the file type/loader settings), but explicit configuration is always safer.</p>
<p>You configure this using the <code>ImageSamplerDescriptor</code> struct. You can specify the mode for each axis independently:</p>
<ul>
<li><p><strong>U (Horizontal)</strong>: <code>address_mode_u</code></p>
</li>
<li><p><strong>V (Vertical)</strong>: <code>address_mode_v</code></p>
</li>
<li><p><strong>W (Depth)</strong>: <code>address_mode_w</code> (Only used for 3D textures, like volume data)</p>
</li>
</ul>
<p>Here is how you configure a sampler in a Bevy system:</p>
<pre><code class="language-rust">use bevy::image::{ImageAddressMode, ImageSampler, ImageSamplerDescriptor};

fn configure_tiling_texture(
    mut images: ResMut&lt;Assets&lt;Image&gt;&gt;,
    my_texture_handle: Res&lt;MyTextureHandle&gt;,
) {
    if let Some(image) = images.get_mut(&amp;my_texture_handle.0) {
        image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
            // Repeat horizontally
            address_mode_u: ImageAddressMode::Repeat,
            // Clamp vertically (great for side-scrolling backgrounds)
            address_mode_v: ImageAddressMode::ClampToEdge,
            // Standard filtering settings
            mag_filter: bevy::image::ImageFilterMode::Linear,
            min_filter: bevy::image::ImageFilterMode::Linear,
            ..default()
        });
    }
}
</code></pre>
<blockquote>
<p><strong>Note</strong>: Modifying the sampler on an existing image asset triggers an update on the GPU automatically. You don't need to re-upload the texture data.</p>
</blockquote>
<h3>UV Manipulation in WGSL</h3>
<p>While the Sampler handles the <em>rules</em> for out-of-bounds UVs, you often need to push the UVs out of bounds intentionally to create effects. This is done inside the Fragment Shader.</p>
<h4>1. Tiling (Scaling)</h4>
<p>To repeat a texture, you multiply the UV coordinates.</p>
<ul>
<li><p><strong>Math</strong>: <code>uv * tiling_factor</code></p>
</li>
<li><p><strong>Why</strong>: If UV goes from <code>0</code> to <code>1</code>, multiplying by <code>4.0</code> makes it go from <code>0</code> to <code>4</code>. The Sampler then wraps this <code>4</code> times.</p>
</li>
</ul>
<pre><code class="language-rust">let tiling = vec2&lt;f32&gt;(4.0, 4.0); // Repeat 4x4 times
let tiled_uv = in.uv * tiling;
let color = textureSample(my_texture, my_sampler, tiled_uv);
</code></pre>
<h4>2. Scrolling (Offset)</h4>
<p>To animate a texture (like a conveyor belt or flowing water), you add values to the UVs over time.</p>
<pre><code class="language-rust">// 'time' is a uniform passed from Rust
let scroll_speed = vec2&lt;f32&gt;(0.5, 0.0); 
let scrolled_uv = in.uv + (scroll_speed * time);
let color = textureSample(my_texture, my_sampler, scrolled_uv);
</code></pre>
<h4>3. Rotation</h4>
<p>Rotating UVs is slightly trickier because you need to rotate around a specific pivot point (usually the center, <code>0.5, 0.5</code>).</p>
<pre><code class="language-rust">fn rotate_uv(uv: vec2&lt;f32&gt;, rotation: f32) -&gt; vec2&lt;f32&gt; {
    let pivot = vec2&lt;f32&gt;(0.5, 0.5);
    let s = sin(rotation);
    let c = cos(rotation);
    
    // 1. Move UV so pivot is at (0,0)
    let uv_centered = uv - pivot;
    
    // 2. Rotate
    let rotated = vec2&lt;f32&gt;(
        uv_centered.x * c - uv_centered.y * s,
        uv_centered.x * s + uv_centered.y * c
    );
    
    // 3. Move back
    return rotated + pivot;
}
</code></pre>
<h3>Common Pitfalls</h3>
<ol>
<li><p><strong>Non-Seamless Textures</strong>: If you use <code>Repeat</code> on a photograph or a texture with distinct edges, you will see hard lines where the tiles meet. <strong>MirrorRepeat</strong> is a quick hack to hide this, but the best solution is to author textures that are specifically "tileable" (left edge matches right edge).</p>
</li>
<li><p><strong>Texture Bleeding (Atlas)</strong>: If you use a Texture Atlas (spritesheet), you usually want <code>ClampToEdge</code>. If you use <code>Repeat</code>, your character's head might start displaying their feet from the frame below!</p>
</li>
<li><p><strong>UI Stretching</strong>: A common bug in UI rendering is setting <code>ClampToEdge</code> on a 9-patch image but getting the UVs wrong, resulting in the edge pixels stretching all the way across the button.</p>
</li>
</ol>
<h2>Advanced Techniques &amp; Considerations</h2>
<p>Before we jump into the demo, let's cover a few advanced topics that separate basic texture usage from professional shader work.</p>
<h3>1. The Art of Seamless Textures</h3>
<p>Using Repeat mode reveals the truth about your texture: is it seamless?</p>
<p>A seamless texture is one where the left edge perfectly matches the right edge, and the top matches the bottom. If they don't match, you get visible "seams" - hard lines that ruin the illusion of a continuous surface.</p>
<p>If you don't have a seamless texture, you have two options:</p>
<ol>
<li><p><strong>The "Lazy" Fix</strong>: Switch to MirrorRepeat. By flipping the texture, the edges always meet identical pixels. It eliminates hard lines but can create strange "Rorschach test" patterns.</p>
</li>
<li><p><strong>The "Pro" Fix</strong>: Edit the texture. In tools like Photoshop or GIMP, you "offset" the image by 50% horizontally and vertically so the edges move to the center. You then paint over the visible seams in the center. When you offset it back, the edges are now perfect.</p>
</li>
</ol>
<h3>2. Polar Coordinates</h3>
<p>One of the coolest uses of wrapping modes is converting Cartesian coordinates <code>(x, y)</code> to Polar coordinates <code>(angle, radius)</code>. This allows you to wrap a rectangular texture into a circle or a spiral.</p>
<pre><code class="language-rust">fn cartesian_to_polar(uv: vec2&lt;f32&gt;) -&gt; vec2&lt;f32&gt; {
    // 1. Center the UVs from [0,1] to [-0.5, 0.5]
    let centered = uv - 0.5;
    
    // 2. Calculate Angle and Radius
    let radius = length(centered) * 2.0; // Scale so edge is at 1.0
    let angle = atan2(centered.y, centered.x);
    
    // 3. Normalize Angle from [-PI, PI] to [0, 1]
    let normalized_angle = (angle / (2.0 * 3.14159)) + 0.5;
    
    return vec2&lt;f32&gt;(normalized_angle, radius);
}
</code></pre>
<p>If you use Repeat mode with this function:</p>
<ul>
<li><p>The texture wraps around the center point endlessly.</p>
</li>
<li><p>The "seam" where 0 and 1 meet becomes invisible (if the texture is seamless horizontally).</p>
</li>
</ul>
<h3>3. Hardware Reality: The "Border Color" Limit</h3>
<p>You might wonder: "Can I set the border color to Hot Pink for debugging?"</p>
<p>In older graphics APIs (OpenGL), yes. In modern WebGPU (and thus Bevy's abstraction), <strong>no</strong>. Hardware support for arbitrary border colors is inconsistent across platforms (especially mobile).</p>
<p>In Bevy 0.16, ClampToBorder typically defaults to <strong>Transparent Black</strong> <code>(0, 0, 0, 0)</code>. If you need a specific colored border for a gameplay mechanic (e.g., a red warning zone), you usually have to implement that logic manually in the shader code, as we will do in our demo.</p>
<h3>4. Performance</h3>
<p>Does <code>Repeat</code> cost more than <code>Clamp</code>?</p>
<ul>
<li><p><strong>Generally, no.</strong> Texture wrapping is handled by dedicated hardware units on the GPU. The cost difference is negligible.</p>
</li>
<li><p><strong>Cache Locality</strong>: The only minor performance hit comes from "Dependent Texture Reads." If your UV math gets incredibly complex (randomly jumping from UV <code>0.1</code> to <code>900.5</code>), the GPU's texture cache becomes ineffective because it can't predict which pixels to load next. For standard tiling and scrolling, this is never an issue.</p>
</li>
</ul>
<hr />
<h2>Complete Example</h2>
<p>We are going to build a <strong>Wrapping Mode Playground</strong>. This interactive demo lets you cycle through different wrapping modes in real-time while animating UVs (scrolling, rotating, and scaling). It perfectly illustrates how the sampler behaves when UVs go beyond the standard 0.0 to 1.0 range.</p>
<h3>Our Goal</h3>
<ol>
<li><p><strong>Interactive Sampler</strong>: Change the ImageAddressMode of a texture dynamically using keyboard input.</p>
</li>
<li><p><strong>UV Animation</strong>: Implement tiling, scrolling, and rotation in WGSL to force UVs out of bounds.</p>
</li>
<li><p><strong>Visual Debugging</strong>: Create a shader that can highlight exactly where UVs cross the texture boundaries.</p>
</li>
</ol>
<h3>The Shader (<code>assets/shaders/d04_03_wrapping_demo.wgsl</code>)</h3>
<p>This shader accepts a set of uniforms to transform the UV coordinates. It includes a helper function rotate_uv to handle rotation around the center. It also includes a debug feature: when enabled, it mixes the texture color with a raw UV visualization so you can see the coordinate grid.</p>
<pre><code class="language-rust">#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

struct WrappingMaterial {
    tiling: vec2&lt;f32&gt;,
    offset: vec2&lt;f32&gt;,
    scroll_speed: vec2&lt;f32&gt;,
    time: f32,
    rotation: f32,
    blend_factor: f32,
    _padding: f32,
}

@group(2) @binding(0)
var texture: texture_2d&lt;f32&gt;;

@group(2) @binding(1)
var texture_sampler: sampler;

@group(2) @binding(2)
var&lt;uniform&gt; material: WrappingMaterial;

struct VertexInput {
    @builtin(instance_index) instance_index: u32,
    @location(0) position: vec3&lt;f32&gt;,
    @location(1) normal: vec3&lt;f32&gt;,
    @location(2) uv: vec2&lt;f32&gt;,
}

struct VertexOutput {
    @builtin(position) clip_position: vec4&lt;f32&gt;,
    @location(0) world_position: vec3&lt;f32&gt;,
    @location(1) world_normal: vec3&lt;f32&gt;,
    @location(2) uv: vec2&lt;f32&gt;,
}

@vertex
fn vertex(in: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    let model = mesh_functions::get_world_from_local(in.instance_index);
    let world_position = mesh_functions::mesh_position_local_to_world(
        model,
        vec4&lt;f32&gt;(in.position, 1.0)
    );

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

    return out;
}

// Rotate UV coordinates around center (0.5, 0.5)
fn rotate_uv(uv: vec2&lt;f32&gt;, angle: f32) -&gt; vec2&lt;f32&gt; {
    let center = vec2&lt;f32&gt;(0.5, 0.5);
    let uv_centered = uv - center;

    let cos_a = cos(angle);
    let sin_a = sin(angle);

    let rotated = vec2&lt;f32&gt;(
        uv_centered.x * cos_a - uv_centered.y * sin_a,
        uv_centered.x * sin_a + uv_centered.y * cos_a
    );

    return rotated + center;
}

// Visualize UV coordinates as color (Red = U, Green = V)
fn uv_debug_color(uv: vec2&lt;f32&gt;) -&gt; vec3&lt;f32&gt; {
    return vec3&lt;f32&gt;(fract(uv.x), fract(uv.y), 0.0);
}

// Check if UV is out of bounds
fn is_out_of_bounds(uv: vec2&lt;f32&gt;) -&gt; bool {
    return uv.x &lt; 0.0 || uv.x &gt; 1.0 || uv.y &lt; 0.0 || uv.y &gt; 1.0;
}

@fragment
fn fragment(in: VertexOutput) -&gt; @location(0) vec4&lt;f32&gt; {
    // 1. Start with base UV
    var uv = in.uv;

    // 2. Apply transformations
    uv = rotate_uv(uv, material.rotation);
    uv = uv + material.offset;
    uv = uv + material.scroll_speed * material.time;
    uv = uv * material.tiling;

    // 3. Sample texture
    // The wrapping mode (Repeat, Clamp, etc.) is handled here by the sampler
    var texture_color = textureSample(texture, texture_sampler, uv);

    // 4. Optional: Debug Visualization
    // Mix the texture with the UV grid based on blend_factor
    let debug_color = vec4&lt;f32&gt;(uv_debug_color(uv), 1.0);
    let color = mix(texture_color, debug_color, material.blend_factor);

    // Highlight out-of-bounds regions in Red when in Debug mode
    // This helps visualize where the border actually is.
    if (material.blend_factor &gt; 0.5 &amp;&amp; is_out_of_bounds(uv)) {
        return vec4&lt;f32&gt;(color.rgb + vec3&lt;f32&gt;(0.3, 0.0, 0.0), color.a);
    }

    return color;
}
</code></pre>
<h3>The Rust Material (<code>src/materials/d04_03_wrapping_demo.rs</code>)</h3>
<p>This material struct holds the texture handle and the uniform data. We use the <code>AsBindGroup</code> derive macro to automatically generate the binding layout for Bevy. Note that we define a separate <code>WrappingMaterialUniforms</code> struct that implements <code>ShaderType</code> to ensure our data is strictly aligned for the GPU.</p>
<pre><code class="language-rust">use bevy::prelude::*;
use bevy::render::render_resource::{AsBindGroup, ShaderRef};

mod uniforms {
    #![allow(dead_code)]

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

    #[derive(ShaderType, Debug, Clone, Copy)]
    pub struct WrappingMaterial {
        pub tiling: Vec2,
        pub offset: Vec2,
        pub scroll_speed: Vec2,
        pub time: f32,
        pub rotation: f32,
        pub blend_factor: f32, // 0.0 = texture only, 1.0 = UV debug
        pub _padding: f32,
    }

    impl Default for WrappingMaterial {
        fn default() -&gt; Self {
            Self {
                tiling: Vec2::ONE,
                offset: Vec2::ZERO,
                scroll_speed: Vec2::ZERO,
                time: 0.0,
                rotation: 0.0,
                blend_factor: 0.0,
                _padding: 0.0,
            }
        }
    }
}

pub use uniforms::WrappingMaterial as WrappingMaterialUniforms;

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct WrappingMaterial {
    #[texture(0)]
    #[sampler(1)]
    pub texture: Handle&lt;Image&gt;,

    #[uniform(2)]
    pub uniforms: WrappingMaterialUniforms,
}

impl Material for WrappingMaterial {
    fn vertex_shader() -&gt; ShaderRef {
        "shaders/d04_03_wrapping_demo.wgsl".into()
    }

    fn fragment_shader() -&gt; ShaderRef {
        "shaders/d04_03_wrapping_demo.wgsl".into()
    }
}
</code></pre>
<p>Don't forget to add it to src/materials/<a href="http://mod.rs">mod.rs</a>:</p>
<pre><code class="language-rust">pub mod d04_03_wrapping_demo;
</code></pre>
<h3>The Demo Module (<code>src/demos/d04_03_wrapping_demo.rs</code>)</h3>
<p>This module handles the logic. It spawns a procedurally generated checkerboard texture (easier to see wrapping seams than a photo). The handle_input system listens for key presses and modifies the Image asset's sampler field directly. This updates the GPU resource without needing to reload the texture.</p>
<pre><code class="language-rust">use crate::materials::d04_03_wrapping_demo::{WrappingMaterial, WrappingMaterialUniforms};
use bevy::image::{ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor};
use bevy::prelude::*;
use bevy::render::render_asset::RenderAssetUsages;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
use std::f32::consts::PI;

#[derive(Resource)]
struct DemoState {
    current_mode: WrapMode,
    scrolling_enabled: bool,
    rotation_enabled: bool,
    debug_mode: bool,
}

#[derive(Debug, Clone, Copy, PartialEq)]
enum WrapMode {
    Repeat,
    ClampToEdge,
    MirrorRepeat,
    ClampToBorder,
}

impl WrapMode {
    fn next(&amp;self) -&gt; Self {
        match self {
            WrapMode::Repeat =&gt; WrapMode::ClampToEdge,
            WrapMode::ClampToEdge =&gt; WrapMode::MirrorRepeat,
            WrapMode::MirrorRepeat =&gt; WrapMode::ClampToBorder,
            WrapMode::ClampToBorder =&gt; WrapMode::Repeat,
        }
    }

    fn to_address_mode(&amp;self) -&gt; ImageAddressMode {
        match self {
            WrapMode::Repeat =&gt; ImageAddressMode::Repeat,
            WrapMode::ClampToEdge =&gt; ImageAddressMode::ClampToEdge,
            WrapMode::MirrorRepeat =&gt; ImageAddressMode::MirrorRepeat,
            WrapMode::ClampToBorder =&gt; ImageAddressMode::ClampToBorder,
        }
    }

    fn name(&amp;self) -&gt; &amp;str {
        match self {
            WrapMode::Repeat =&gt; "Repeat",
            WrapMode::ClampToEdge =&gt; "Clamp to Edge",
            WrapMode::MirrorRepeat =&gt; "Mirror Repeat",
            WrapMode::ClampToBorder =&gt; "Clamp to Border",
        }
    }
}

pub fn run() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;WrappingMaterial&gt;::default())
        .insert_resource(DemoState {
            current_mode: WrapMode::Repeat,
            scrolling_enabled: true,
            rotation_enabled: false,
            debug_mode: false,
        })
        .add_systems(Startup, setup)
        .add_systems(Update, (handle_input, update_materials, update_ui))
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    mut materials: ResMut&lt;Assets&lt;WrappingMaterial&gt;&gt;,
    mut images: ResMut&lt;Assets&lt;Image&gt;&gt;,
) {
    let texture_handle = create_checkerboard_texture(&amp;mut images);

    let material_handle = materials.add(WrappingMaterial {
        texture: texture_handle,
        uniforms: WrappingMaterialUniforms {
            tiling: Vec2::new(3.0, 3.0),
            scroll_speed: Vec2::new(0.2, 0.1),
            ..Default::default()
        },
    });

    commands.spawn((
        Mesh3d(meshes.add(Plane3d::default().mesh().size(10.0, 10.0))),
        MeshMaterial3d(material_handle),
        Transform::from_rotation(Quat::from_rotation_x(PI / 2.0)),
    ));

    commands.spawn((
        DirectionalLight {
            illuminance: 10_000.0,
            shadows_enabled: false,
            ..default()
        },
        Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -PI / 4.0, PI / 4.0, 0.0)),
    ));

    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(0.0, 5.0, 8.0).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    commands.spawn((
        Text::new(""),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(10.0),
            left: Val::Px(10.0),
            padding: UiRect::all(Val::Px(10.0)),
            ..default()
        },
        TextColor(Color::WHITE),
        BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
    ));
}

fn create_checkerboard_texture(images: &amp;mut ResMut&lt;Assets&lt;Image&gt;&gt;) -&gt; Handle&lt;Image&gt; {
    let size = 64;
    let mut data = vec![0u8; (size * size * 4) as usize];

    for y in 0..size {
        for x in 0..size {
            let checker = ((x / 8) + (y / 8)) % 2;
            let base_idx = ((y * size + x) * 4) as usize;
            let val = if checker == 0 { 255 } else { 50 };
            data[base_idx] = val;
            data[base_idx + 1] = val;
            data[base_idx + 2] = val;
            data[base_idx + 3] = 255;
        }
    }

    let mut image = Image::new_fill(
        Extent3d {
            width: size,
            height: size,
            depth_or_array_layers: 1,
        },
        TextureDimension::D2,
        &amp;data,
        TextureFormat::Rgba8UnormSrgb,
        RenderAssetUsages::default(),
    );

    image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
        address_mode_u: ImageAddressMode::Repeat,
        address_mode_v: ImageAddressMode::Repeat,
        mag_filter: ImageFilterMode::Nearest,
        min_filter: ImageFilterMode::Nearest,
        ..Default::default()
    });

    images.add(image)
}

fn handle_input(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    mut state: ResMut&lt;DemoState&gt;,
    mut images: ResMut&lt;Assets&lt;Image&gt;&gt;,
    mut materials: ResMut&lt;Assets&lt;WrappingMaterial&gt;&gt;,
) {
    if keyboard.just_pressed(KeyCode::Space) {
        state.current_mode = state.current_mode.next();
        println!("Switching to mode: {:?}", state.current_mode);

        // LOGIC FIX:
        // When using Clamp, we want to center the texture on the quad to see the edges.
        // Math: UV range [0, 1].
        // Offset -0.25 -&gt; [-0.25, 0.75].
        // Scale 2.0 -&gt; [-0.5, 1.5].
        // Result: Texture (0-1) is centered, surrounded by out-of-bounds UVs.

        let (new_tiling, new_offset, enable_scroll) = match state.current_mode {
            WrapMode::ClampToEdge | WrapMode::ClampToBorder =&gt; {
                (Vec2::splat(2.0), Vec2::splat(-0.25), false)
            }
            _ =&gt; (Vec2::splat(3.0), Vec2::ZERO, true),
        };

        state.scrolling_enabled = enable_scroll;
        state.rotation_enabled = enable_scroll; // Also disable rotation for clarity

        // Apply settings to all materials
        for (_, mat) in materials.iter_mut() {
            mat.uniforms.tiling = new_tiling;
            mat.uniforms.offset = new_offset;
        }

        // Apply sampler settings
        for (_, material) in materials.iter() {
            if let Some(image) = images.get_mut(&amp;material.texture) {
                let mode = state.current_mode.to_address_mode();
                image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
                    address_mode_u: mode,
                    address_mode_v: mode,
                    mag_filter: ImageFilterMode::Nearest,
                    min_filter: ImageFilterMode::Nearest,
                    ..Default::default()
                });
            }
        }
    }

    if keyboard.just_pressed(KeyCode::KeyS) {
        state.scrolling_enabled = !state.scrolling_enabled;
    }
    if keyboard.just_pressed(KeyCode::KeyR) {
        state.rotation_enabled = !state.rotation_enabled;
    }
    if keyboard.just_pressed(KeyCode::KeyD) {
        state.debug_mode = !state.debug_mode;
    }
}

fn update_materials(
    time: Res&lt;Time&gt;,
    state: Res&lt;DemoState&gt;,
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    mut materials: ResMut&lt;Assets&lt;WrappingMaterial&gt;&gt;,
) {
    for (_, material) in materials.iter_mut() {
        material.uniforms.time = time.elapsed_secs();

        material.uniforms.rotation = if state.rotation_enabled {
            time.elapsed_secs() * 0.3
        } else {
            0.0
        };

        material.uniforms.scroll_speed = if state.scrolling_enabled {
            Vec2::new(0.2, 0.1)
        } else {
            Vec2::ZERO
        };

        material.uniforms.blend_factor = if state.debug_mode { 0.5 } else { 0.0 };

        if keyboard.pressed(KeyCode::Equal) {
            material.uniforms.tiling *= 1.02;
        }
        if keyboard.pressed(KeyCode::Minus) {
            material.uniforms.tiling *= 0.98;
            material.uniforms.tiling = material.uniforms.tiling.max(Vec2::splat(0.1));
        }
    }
}

fn update_ui(state: Res&lt;DemoState&gt;, mut query: Query&lt;&amp;mut Text&gt;) {
    for mut text in &amp;mut query {
        **text = format!(
            "TEXTURE WRAPPING MODES\n\
             [Space] Mode: {}\n\
             [S] Scroll: {}\n\
             [R] Rotate: {}\n\
             [D] Debug UV: {}\n\
             [+/-] Zoom Tiling",
            state.current_mode.name(),
            if state.scrolling_enabled { "ON" } else { "OFF" },
            if state.rotation_enabled { "ON" } else { "OFF" },
            if state.debug_mode { "ON" } else { "OFF" },
        );
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="language-rust">pub mod d04_03_wrapping_demo;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="language-rust">Demo {
    number: "4.3",
    title: "Texture Wrapping Modes",
    run: demos::d04_03_wrapping_demo::run,
},
</code></pre>
<h3>Running the Demo</h3>
<p>This demo allows you to see exactly how each wrapping mode behaves when the UV coordinates exceed the 0-1 range.</p>
<h4>Controls</h4>
<table style="min-width:50px"><colgroup><col style="min-width:25px"></col><col style="min-width:25px"></col></colgroup><tbody><tr><th><p>Key</p></th><th><p>Action</p></th></tr><tr><td><p><strong>Space</strong></p></td><td><p>Cycle Wrapping Mode (Repeat → ClampToEdge → MirrorRepeat → ClampToBorder)</p></td></tr><tr><td><p><strong>S</strong></p></td><td><p>Toggle Scrolling animation</p></td></tr><tr><td><p><strong>R</strong></p></td><td><p>Toggle Rotation animation</p></td></tr><tr><td><p><strong>D</strong></p></td><td><p>Toggle Debug Mode (Red overlay on out-of-bounds UVs)</p></td></tr><tr><td><p><strong>+ / -</strong></p></td><td><p>Zoom Tiling in and out</p></td></tr></tbody></table>

<h4>What You're Seeing</h4>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1765830669944/fd97391c-9a11-4b6b-9eb0-efc828a05add.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1765830676749/506645d8-3c25-461c-84ce-fd6a56c81d2f.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1765830682650/0a349d16-1675-4038-9763-b7e1aa1cc113.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1765830688831/ac25290a-cb7c-49dc-8aae-ac35fb2421d9.png" alt="" style="display:block;margin:0 auto" />

<ol>
<li><p><strong>Repeat</strong>: The checkerboard pattern continues infinitely.</p>
</li>
<li><p><strong>ClampToEdge</strong>: The edge pixels (black or white squares) are stretched infinitely outwards.</p>
</li>
<li><p><strong>MirrorRepeat</strong>: The pattern reverses at every boundary, creating a seamless (though kaleidoscopic) look.</p>
</li>
<li><p><strong>ClampToBorder</strong>: The texture disappears outside the center, replaced by the border color (transparent black).</p>
<ul>
<li>Note: Press <strong>D</strong> in this mode to see the red debug overlay, which proves that the UVs are indeed out of bounds!</li>
</ul>
</li>
</ol>
<h2>Key Takeaways</h2>
<ol>
<li><p><strong>Samplers Control Edges</strong>: The texture image ends at 1.0. The Sampler decides what happens after that.</p>
</li>
<li><p><strong>Explicit Configuration</strong>: Always configure your ImageSamplerDescriptor explicitly for the effect you want (e.g., Clamp for UI, Repeat for floors).</p>
</li>
<li><p><strong>UVs are Flexible</strong>: You can multiply (tile), add (scroll), and rotate UVs in the shader to create dynamic effects without changing the mesh geometry.</p>
</li>
<li><p><strong>Mirroring Hides Seams</strong>: MirrorRepeat is a useful tool for procedurally texturing surfaces with non-seamless noise textures.</p>
</li>
</ol>
<h2>What's Next?</h2>
<p>We've mastered single textures - sampling, filtering, and wrapping. But most realistic materials aren't made of just one image. A brick wall has color, but it also has bumpiness (normal maps), shininess (roughness maps), and ambient shadows (AO maps).</p>
<p>In the next article, we will combine multiple textures into a single shader to create a complete <strong>PBR Material</strong>.</p>
<p><em>Next up:</em> <em><strong>4.4 - Multi-Texture Materials</strong></em></p>
<hr />
<h2>Quick Reference</h2>
<h3>Wrapping Modes Cheat Sheet</h3>
<table style="min-width:75px"><colgroup><col style="min-width:25px"></col><col style="min-width:25px"></col><col style="min-width:25px"></col></colgroup><tbody><tr><th><p>Mode</p></th><th><p>Behavior</p></th><th><p>Best For</p></th></tr><tr><td><p><strong>Repeat</strong></p></td><td><p>fract(uv)</p></td><td><p>Floors, Walls, Tiling Patterns</p></td></tr><tr><td><p><strong>ClampToEdge</strong></p></td><td><p>clamp(uv, 0.0, 1.0)</p></td><td><p>UI, Skyboxes, Sprites</p></td></tr><tr><td><p><strong>MirrorRepeat</strong></p></td><td><p>Flips every integer</p></td><td><p>Noise, Marble, Seamless-ing</p></td></tr><tr><td><p><strong>ClampToBorder</strong></p></td><td><p>Returns Border Color</p></td><td><p>Decals, Debugging</p></td></tr></tbody></table>

<h3>Bevy Configuration</h3>
<pre><code class="language-rust">// Standard Repeating Sampler
ImageSampler::Descriptor(ImageSamplerDescriptor {
    address_mode_u: ImageAddressMode::Repeat,
    address_mode_v: ImageAddressMode::Repeat,
    // ... filtering settings
    ..default()
})
</code></pre>
]]></content:encoded></item><item><title><![CDATA[4.2 - Texture Filtering and Mipmapping]]></title><description><![CDATA[What We're Learning
You've learned how to sample textures in shaders using UV coordinates. Now comes a critical question: how exactly does the GPU calculate the color between pixels?
When you render a]]></description><link>https://blog.hexbee.net/4-2-texture-filtering-and-mipmapping</link><guid isPermaLink="true">https://blog.hexbee.net/4-2-texture-filtering-and-mipmapping</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[Rust]]></category><category><![CDATA[shader]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Mon, 23 Feb 2026 07:48:28 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1765043219961/f825b74f-b304-4dab-bd7e-30914d4e6471.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>What We're Learning</h2>
<p>You've learned how to sample textures in shaders using UV coordinates. Now comes a critical question: <em>how</em> exactly does the GPU calculate the color between pixels?</p>
<p>When you render a 3D wall, it's rare that one pixel on your screen aligns perfectly with one "pixel" (texel) on the texture image. The texture might be stretched across a huge wall, or shrunk onto a tiny object in the distance.</p>
<p>This is where <strong>texture filtering</strong> comes in. The choices you make here determine whether your game looks like a crisp retro arcade game, a blurry N64 title, or a modern high-end render.</p>
<p>In this article, you'll understand:</p>
<ul>
<li><p><strong>Texels vs. Pixels</strong>: The fundamental disconnect between image data and screen space.</p>
</li>
<li><p><strong>Filtering Modes</strong>: The difference between <code>Nearest</code>, <code>Linear</code>, and <code>Anisotropic</code> filtering.</p>
</li>
<li><p><strong>Mipmapping</strong>: How pre-calculating smaller versions of textures solves shimmering and aliasing.</p>
</li>
<li><p><strong>The Sampler Resource</strong>: How to configure these settings in Bevy and WGSL.</p>
</li>
<li><p><strong>Performance Trade-offs</strong>: Why "better" filtering isn't always free.</p>
</li>
</ul>
<h2>The Texture Sampling Problem</h2>
<p>To understand filtering, we must distinguish between two types of "pixels":</p>
<ol>
<li><p><strong>Pixels</strong>: The physical dots of light on your monitor.</p>
</li>
<li><p><strong>Texels</strong> (Texture Elements): The color data points stored in your texture image.</p>
</li>
</ol>
<p>When the GPU runs your fragment shader, it has to assign a color to a screen <strong>pixel</strong>. It calculates a UV coordinate (e.g., <code>0.501</code>, <code>0.501</code>) and asks the texture for data.</p>
<p>There is almost never a 1:1 relationship.</p>
<ul>
<li><p><strong>Magnification</strong>: The texture is small, but the object is close. One texel covers many screen pixels. The GPU needs to fill the space <em>between</em> texels.</p>
</li>
<li><p><strong>Minification</strong>: The texture is large, but the object is far away. One screen pixel covers many texels. The GPU needs to summarize multiple data points into one color.</p>
</li>
<li><p><strong>Anisotropy</strong>: The surface is angled (like a floor). One screen pixel covers a trapezoid shape of texels - long in one direction, short in the other.</p>
</li>
</ul>
<p>The <strong>Sampler</strong> is the GPU component that answers these questions based on the rules you provide.</p>
<h2>Point Filtering (Nearest Neighbor)</h2>
<p>The simplest rule is: "Just give me the single texel closest to my UV coordinate."</p>
<h3>How It Works</h3>
<p>Imagine a grid of colors. If your UV coordinate falls at (1.1, 1.1), the GPU rounds down to (1, 1) and returns that exact color. It ignores the fact that you are slightly closer to 1.2.</p>
<h3>When To Use It</h3>
<p>In Bevy, this is called ImageFilterMode::Nearest.</p>
<ul>
<li><p><strong>✓ Pixel Art</strong>: Essential for keeping sprites crisp.</p>
</li>
<li><p><strong>✓ Minecraft-style Voxel Games</strong>: Preserves the blocky aesthetic.</p>
</li>
<li><p><strong>✓ Data Textures</strong>: If your texture stores non-color data (like object IDs), you never want to blend values (e.g., blending ID 1 and ID 5 to get ID 3 is wrong).</p>
</li>
</ul>
<h3>Visual Result</h3>
<p>Up close, the texture looks blocky. Each texel appears as a distinct square.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1765043271091/f8e88534-f408-4d74-8071-503e73c6a4eb.png" alt="" />

<h2>Linear Filtering (Bilinear)</h2>
<p>The standard rule for 3D graphics is: "Look at the four closest texels and blend them together."</p>
<h3>How It Works</h3>
<p>If your UV coordinate is (1.5, 1.5), the GPU grabs the texels at <code>(1,1)</code>, <code>(2,1)</code>, <code>(1,2)</code>, and <code>(2,2)</code>. It then performs a weighted average (Linear Interpolation, or "Lerp") based on how close the coordinate is to each center.</p>
<h3>When To Use It</h3>
<p>In Bevy, this is <code>ImageFilterMode::Linear</code>.</p>
<ul>
<li><p><strong>✓ Realistic 3D</strong>: Almost everything in a standard game (skin, rocks, metal) uses this.</p>
</li>
<li><p><strong>✓ UI Elements</strong>: Ensures smooth edges on text and icons.</p>
</li>
</ul>
<h3>Visual Result</h3>
<p>Up close, the texture looks smooth and blurry. You can't distinguish individual texels.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1765043286296/3d70d993-ffb4-4eec-941b-704d0f6592c0.png" alt="" />

<h2>Mipmapping: Solving the Distance Problem</h2>
<p>Linear filtering works great when the texture is close (magnification). It fails miserably when the texture is far away (minification).</p>
<p>Imagine a high-resolution brick wall texture (1024x1024) rendered on an object that is only 10 pixels wide on screen. Each screen pixel effectively covers 100 texels. The GPU can't sample all 100 texels for every pixel - that would be incredibly slow. Instead, it just samples 4 random ones (linear filtering).</p>
<p>As the camera moves, the "4 random pixels" picked will change drastically, causing the surface to sparkle, shimmer, and produce jagged patterns called <strong>Moiré patterns</strong>.</p>
<h3>The Solution: Mipmaps</h3>
<p><strong>Mipmaps</strong> (from the Latin <em>multum in parvo</em>, "much in little") are a sequence of progressively smaller versions of the main image.</p>
<ul>
<li><p><strong>Level 0</strong>: Original (1024x1024)</p>
</li>
<li><p><strong>Level 1</strong>: 512x512</p>
</li>
<li><p><strong>Level 2</strong>: 256x256</p>
</li>
<li><p>...</p>
</li>
<li><p><strong>Level N</strong>: 1x1</p>
</li>
</ul>
<p>When rendering, the GPU calculates how "dense" the UVs are.</p>
<ul>
<li><p>If the object is close, it reads from Level 0.</p>
</li>
<li><p>If the object is far, it reads from Level 3 or 4.</p>
</li>
</ul>
<p>This effectively pre-blends the distant pixels, eliminating shimmering and improving performance (because the GPU reads smaller chunks of memory).</p>
<h3>Trilinear Filtering</h3>
<p>When an object moves from a distance requiring Mip Level 1 to a distance requiring Mip Level 2, you might see a visible "pop" or line where the sharpness changes.</p>
<p><strong>Trilinear Filtering</strong> solves this by blending <em>between</em> the mip levels. It samples Level 1 (bilinear) and Level 2 (bilinear), then blends those two results together.</p>
<p>In Bevy, you control this with the <code>mipmap_filter</code> field in the sampler descriptor.</p>
<h2>Anisotropic Filtering</h2>
<p>Standard Linear/Trilinear filtering assumes the "footprint" of a pixel is a square. But for a floor stretching into the distance, a screen pixel corresponds to a long, thin trapezoid of texels.</p>
<p>If you use standard filtering on a floor, it will look excessively blurry in the distance because the GPU is sampling a square region that includes data "from the sides" that shouldn't be there, while missing data "from the back" that should be included.</p>
<p><strong>Anisotropic Filtering</strong> takes multiple samples along the angle of the surface to recover detail on oblique angles. Bevy exposes this via <code>anisotropy_clamp</code> (typically set to 16 for high quality).</p>
<h2>Configuring Samplers in Bevy</h2>
<p>In Bevy, the texture data and the "rules for reading it" (the Sampler) are bundled together in the <code>Image</code> asset. By default, Bevy sets up images with <code>Linear</code> filtering and <code>Repeat</code> address mode, which is good for general 3D use.</p>
<p>To change this, you modify the sampler field of the Image asset using an <code>ImageSamplerDescriptor</code>.</p>
<h3>The ImageSamplerDescriptor Struct</h3>
<p>This struct is your control panel for texture quality.</p>
<pre><code class="language-rust">use bevy::image::{ImageSampler, ImageSamplerDescriptor, ImageFilterMode};

// 1. Pixel Art / Retro Style
// Sharp pixels, no blending
let pixel_art_sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
    mag_filter: ImageFilterMode::Nearest, // When close (magnified)
    min_filter: ImageFilterMode::Nearest, // When far (minified)
    mipmap_filter: ImageFilterMode::Nearest, // Sharp transitions between mips
    ..default()
});

// 2. Standard 3D (Trilinear)
// Smooth blending everywhere
let standard_sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
    mag_filter: ImageFilterMode::Linear,
    min_filter: ImageFilterMode::Linear,
    mipmap_filter: ImageFilterMode::Linear, // Smooth transitions between mips
    ..default()
});

// 3. High Quality Ground (Anisotropic)
// Crisp details at oblique angles
let ground_sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
    // Anisotropy is an integer (usually 1, 2, 4, 8, or 16)
    anisotropy_clamp: 16, 
    
    // We still use Linear filtering for the base colors
    mag_filter: ImageFilterMode::Linear,
    min_filter: ImageFilterMode::Linear,
    mipmap_filter: ImageFilterMode::Linear,
    ..default()
});
</code></pre>
<h3>Applying Samplers in Systems</h3>
<p>Since textures are assets, you typically modify them in a system after they have loaded, or configure them via <code>ImageLoaderSettings</code> if you are loading them manually.</p>
<p>For the purpose of our custom shaders, here is how you might configure a texture in a startup system:</p>
<pre><code class="language-rust">fn configure_textures(
    mut images: ResMut&lt;Assets&lt;Image&gt;&gt;,
    my_texture_handle: Res&lt;MyTextureHandle&gt;, // Assuming you stored the handle
) {
    if let Some(image) = images.get_mut(&amp;my_texture_handle.0) {
        image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
            mag_filter: ImageFilterMode::Nearest,
            min_filter: ImageFilterMode::Nearest,
            mipmap_filter: ImageFilterMode::Nearest,
            ..default()
        });
    }
}
</code></pre>
<blockquote>
<p><strong>Note</strong>: If you change a sampler on an existing image, Bevy automatically updates the GPU resource for you.</p>
</blockquote>
<h2>Performance vs. Quality</h2>
<p>You might be tempted to just set <code>anisotropy_clamp: 16</code> and <code>Linear</code> on everything. Why not?</p>
<h3>The Cost of Filtering</h3>
<p>Every time your shader calls <code>textureSample()</code>, the GPU performs memory lookups.</p>
<ol>
<li><p><strong>Nearest (Point)</strong>: <strong>1 memory fetch</strong>. The GPU grabs exactly one texel. This is the fastest possible operation.</p>
</li>
<li><p><strong>Bilinear</strong>: <strong>4 memory fetches</strong>. The GPU grabs the four surrounding texels and blends them.</p>
</li>
<li><p><strong>Trilinear</strong>: <strong>8 memory fetches</strong>. The GPU grabs 4 texels from Mip Level X and 4 from Mip Level X+1.</p>
</li>
<li><p><strong>Anisotropic (16x)</strong>: <strong>Up to 128 memory fetches</strong> (in theory, though hardware is highly optimized). It takes many samples along the viewing angle.</p>
</li>
</ol>
<h3>Hardware Reality</h3>
<p>Modern GPUs (even on mobile phones) are incredibly optimized for Bilinear and Trilinear filtering. The hardware has dedicated circuits ("Texture Units") to do this math for free. You will rarely see a frame rate drop going from Bilinear to Trilinear.</p>
<p><strong>However, Anisotropic filtering hits memory bandwidth.</strong> Because it fetches so much data per pixel, using 16x Anisotropic on every surface can slow down games on lower-end hardware or mobile devices.</p>
<h3>Recommended Settings</h3>
<table>
<thead>
<tr>
<th>Surface Type</th>
<th>Filtering Mode</th>
<th>Anisotropy</th>
<th>Why?</th>
</tr>
</thead>
<tbody><tr>
<td><strong>UI / 2D Sprites</strong></td>
<td>Linear or Nearest</td>
<td>1 (Off)</td>
<td>Viewed flat; anisotropy does nothing.</td>
</tr>
<tr>
<td><strong>Walls / Props</strong></td>
<td>Trilinear</td>
<td>1 or 4</td>
<td>Usually viewed head-on; moderate anisotropy is fine.</td>
</tr>
<tr>
<td><strong>Ground / Floors</strong></td>
<td>Trilinear</td>
<td><strong>16</strong></td>
<td>Viewed at steep angles; needs max anisotropy.</td>
</tr>
<tr>
<td><strong>Pixel Art</strong></td>
<td>Nearest</td>
<td>1 (Off)</td>
<td>Blending destroys the art style.</td>
</tr>
</tbody></table>
<h2>Common Pitfall: Filtering at the Edge</h2>
<p>Understanding filtering explains one of the most annoying bugs in graphics programming: <strong>Texture Bleeding</strong>.</p>
<p>Imagine you have a texture atlas (a sprite sheet) where multiple images are packed next to each other. You want to display just one sprite. You calculate your UVs perfectly. But on screen, you see a faint line of color from the <em>neighboring</em> sprite at the edge of your character.</p>
<p><strong>Why does this happen?</strong></p>
<p>It's <code>Linear</code> filtering's fault.</p>
<p>When the GPU renders a pixel at the very edge of your sprite (e.g., UV <code>0.25</code>), <code>Linear</code> filtering asks for the "neighboring" texels to blend with.</p>
<ul>
<li><p>If your UV is <code>0.25</code>, the filter might look at <code>0.249</code> and <code>0.251</code>.</p>
</li>
<li><p>If <code>0.251</code> falls inside the next sprite in the atlas, the GPU happily blends that color in.</p>
</li>
</ul>
<p><strong>The Fixes:</strong></p>
<ol>
<li><p><strong>Padding</strong>: Add empty (transparent) space between sprites in your atlas.</p>
</li>
<li><p><strong>Point Filtering</strong>: Switch to <code>Nearest</code> filtering. Since it doesn't blend, it never looks at the neighbor.</p>
</li>
<li><p><strong>Address Modes</strong>: If it's a single texture (not an atlas), setting <code>AddressMode::ClampToEdge</code> ensures that when the filter hits the edge (1.0), it doesn't try to wrap around to 0.0 to find a blending partner.</p>
</li>
</ol>
<h2>Advanced: Manual Mip Control</h2>
<p>Usually, you want the GPU to calculate mip levels automatically. But sometimes, you need to take control.</p>
<h3>1. Fine-Tuning Sharpness (LOD Bias)</h3>
<p>Sometimes, the GPU plays it too safe. To prevent aliasing, it might switch to a lower-resolution mipmap too early, causing a texture to look blurry when viewed at an angle.</p>
<p>You can force the GPU to "upscale" slightly - using a higher-resolution mip level than it thinks it needs - by applying a <strong>LOD Bias</strong>.</p>
<pre><code class="language-rust">// WGSL
// textureSampleBias(texture, sampler, uv, bias)

// A negative bias forces a sharper (higher res) mip level.
// -1.0 means "use one mip level higher than calculated"
let crisp_color = textureSampleBias(my_texture, my_sampler, in.uv, -1.0);
</code></pre>
<ul>
<li><p><strong>Negative Bias (-0.5 to -1.0)</strong>: Makes textures sharper/crisper. Great for detailed ground textures, but can introduce shimmering (aliasing) if pushed too far.</p>
</li>
<li><p><strong>Positive Bias (+1.0)</strong>: Makes textures softer. Useful for reducing noise on very high-frequency patterns.</p>
</li>
</ul>
<h3>2. Reading Textures in the Vertex Shader</h3>
<p>In <strong>4.1</strong>, we mentioned that <code>textureSample()</code> is forbidden in Vertex Shaders. Now you know why.</p>
<p><code>textureSample()</code> relies on <strong>derivatives</strong> (calculating how UVs change between neighboring pixels) to pick the right mip level.</p>
<ul>
<li><p><strong>Fragment Shader</strong>: Runs on pixels in groups (quads), so it knows about neighbors.</p>
</li>
<li><p><strong>Vertex Shader</strong>: Runs on individual vertices. It has <em>no idea</em> how far away the camera is or how dense the pixels are.</p>
</li>
</ul>
<p>Therefore, the GPU cannot calculate the mip level automatically. If you want to read a texture in a vertex shader (e.g., a heightmap for terrain displacement), you <strong>must</strong> specify the level explicitly:</p>
<pre><code class="language-rust">@vertex
fn vertex(in: VertexInput) -&gt; VertexOutput {
    // ... setup ...
    
    // Explicitly read from Mip Level 0 (Full resolution)
    // Note: We typically use a dedicated sampler for this, or a shared one.
    let height = textureSampleLevel(height_map, my_sampler, in.uv, 0.0).r;
    
    // Displace vertex
    let new_pos = in.position + vec3(0.0, height * 10.0, 0.0);
    
    // ... output ...
}
</code></pre>
<hr />
<h2>Complete Example: The Filtering Comparator</h2>
<p>We are going to build a tool that lets you toggle between different filtering modes in real-time. To make this comparison valid, we need a special texture: one that has high-frequency details (prone to aliasing) and a complete chain of <strong>Mipmaps</strong>.</p>
<p>Since we are generating the texture procedurally, we will also write a helper to generate the mipmaps on the CPU. This ensures that Trilinear and Anisotropic filtering have data to work with.</p>
<h3>Our Goal</h3>
<ol>
<li><p><strong>Procedural Texture</strong>: Generate a "torture test" pattern (checkerboards and concentric circles) with full mipmaps.</p>
</li>
<li><p><strong>Custom Shader</strong>: A shader that can either sample the texture normally or visualize the calculated Mip Level.</p>
</li>
<li><p><strong>Comparator UI</strong>: A system to hot-swap the <strong>Sampler</strong> configuration on the fly.</p>
</li>
</ol>
<h3>The Shader (<code>assets/shaders/d04_02_filtering_comparison.wgsl</code>)</h3>
<p>This shader handles the sampling. Note the "Mip Level Visualization" mode (Mode 5), which uses the <code>dpdx</code> and <code>dpdy</code> derivatives to calculate exactly which mip level the GPU would select for a given pixel.</p>
<pre><code class="language-rust">#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

struct FilteringMaterial {
    display_mode: u32,
    base_color: vec4&lt;f32&gt;,
}

@group(2) @binding(0)
var&lt;uniform&gt; material: FilteringMaterial;

@group(2) @binding(1)
var base_texture: texture_2d&lt;f32&gt;;

@group(2) @binding(2)
var base_sampler: sampler;

struct VertexInput {
    @builtin(instance_index) instance_index: u32,
    @location(0) position: vec3&lt;f32&gt;,
    @location(1) normal: vec3&lt;f32&gt;,
    @location(2) uv: vec2&lt;f32&gt;,
}

struct VertexOutput {
    @builtin(position) clip_position: vec4&lt;f32&gt;,
    @location(0) world_position: vec3&lt;f32&gt;,
    @location(1) world_normal: vec3&lt;f32&gt;,
    @location(2) uv: vec2&lt;f32&gt;,
}

@vertex
fn vertex(in: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    let model = mesh_functions::get_world_from_local(in.instance_index);
    let world_position = mesh_functions::mesh_position_local_to_world(
        model,
        vec4&lt;f32&gt;(in.position, 1.0)
    );

    // Pass data to fragment shader
    out.clip_position = position_world_to_clip(world_position.xyz);
    out.world_position = world_position.xyz;
    out.world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index);
    out.uv = in.uv;

    return out;
}

@fragment
fn fragment(in: VertexOutput) -&gt; @location(0) vec4&lt;f32&gt; {
    var final_color: vec4&lt;f32&gt;;

    // MODE 5: Mip Level Visualization
    if (material.display_mode == 5u) {
        // Get texture dimensions to calculate accurate derivatives
        let dims = vec2&lt;f32&gt;(textureDimensions(base_texture));

        // Calculate the rate of change of UVs relative to texture size
        let dx = dpdx(in.uv * dims);
        let dy = dpdy(in.uv * dims);

        // The max length of the derivative vector determines the mip level
        let delta_max_sq = max(dot(dx, dx), dot(dy, dy));
        let mip_level = 0.5 * log2(delta_max_sq);

        // Color code based on level: Red (0) -&gt; Yellow -&gt; Green -&gt; Blue (High)
        let colors = array&lt;vec3&lt;f32&gt;, 6&gt;(
            vec3(1.0, 0.0, 0.0), // Level 0: Red
            vec3(1.0, 0.5, 0.0), // Level 1: Orange
            vec3(1.0, 1.0, 0.0), // Level 2: Yellow
            vec3(0.0, 1.0, 0.0), // Level 3: Green
            vec3(0.0, 1.0, 1.0), // Level 4: Cyan
            vec3(0.0, 0.0, 1.0)  // Level 5+: Blue
        );

        let idx = u32(clamp(mip_level, 0.0, 5.0));
        final_color = vec4&lt;f32&gt;(colors[idx], 1.0);
    }
    // STANDARD MODES (0-4)
    else {
        final_color = textureSample(base_texture, base_sampler, in.uv) * material.base_color;
    }

    // Apply simple directional lighting
    let N = normalize(in.world_normal);
    let L = normalize(vec3&lt;f32&gt;(1.0, 1.0, 0.5));
    let diffuse = max(dot(N, L), 0.0);
    let ambient = 0.2;

    return vec4&lt;f32&gt;(final_color.rgb * (diffuse + ambient), final_color.a);
}
</code></pre>
<h3>The Rust Material (<code>src/materials/d04_02_filtering_comparison.rs</code>)</h3>
<p>This file includes a helper function <code>create_test_texture</code>.</p>
<p><strong>Why is this function so long?</strong><br />When you load an image from disk (like a PNG), Bevy usually generates mipmaps for you. Since we are creating a texture <em>procedurally</em> (pixel by pixel), we must generate the mipmaps ourselves. We do this by repeatedly downscaling the image (averaging 4 pixels into 1) until we reach a 1x1 size.</p>
<p>Without this step, "Trilinear" filtering would look exactly like "Bilinear" because there would be no lower-resolution levels to blend with!</p>
<pre><code class="language-rust">use bevy::prelude::*;
use bevy::render::render_asset::RenderAssetUsages;
use bevy::render::render_resource::{
    AsBindGroup, Extent3d, ShaderRef, TextureDimension, TextureFormat,
};

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct FilteringMaterial {
    #[uniform(0)]
    pub display_mode: u32,
    #[uniform(0)]
    pub base_color: LinearRgba,

    #[texture(1)]
    #[sampler(2)]
    pub texture: Handle&lt;Image&gt;,
}

impl Material for FilteringMaterial {
    fn fragment_shader() -&gt; ShaderRef {
        "shaders/d04_02_filtering_comparison.wgsl".into()
    }
}

/// Generates a high-frequency test pattern with full mipmaps.
/// This ensures Trilinear and Anisotropic filtering work correctly.
pub fn create_test_texture(size: u32) -&gt; Image {
    // 1. Generate Base Level (Level 0)
    let mut pixels = Vec::with_capacity((size * size * 4) as usize);
    for y in 0..size {
        for x in 0..size {
            let (r, g, b) = generate_pattern_pixel(x, y, size);
            pixels.extend_from_slice(&amp;[r, g, b, 255]);
        }
    }

    // 2. Setup Image
    let mut image = Image::new(
        Extent3d {
            width: size,
            height: size,
            depth_or_array_layers: 1,
        },
        TextureDimension::D2,
        pixels,
        TextureFormat::Rgba8Unorm,
        RenderAssetUsages::default(),
    );

    // 3. Manually Generate Mipmaps (Box Filter)
    let mut current_width = size;
    let mut current_height = size;

    // FIX: Clone the inner Vec&lt;u8&gt; (data is Option&lt;Vec&lt;u8&gt;&gt; in Bevy 0.15+)
    let mut current_data = image.data.clone().expect("Image created without data");

    let mip_levels = (size as f32).log2().floor() as u32 + 1;
    image.texture_descriptor.mip_level_count = mip_levels;

    for _level in 1..mip_levels {
        let next_width = current_width / 2;
        let next_height = current_height / 2;
        let mut next_data = Vec::with_capacity((next_width * next_height * 4) as usize);

        for y in 0..next_height {
            for x in 0..next_width {
                // Average the 4 pixels from the previous level
                let src_x = x * 2;
                let src_y = y * 2;

                let p1 = get_pixel(&amp;current_data, current_width, src_x, src_y);
                let p2 = get_pixel(&amp;current_data, current_width, src_x + 1, src_y);
                let p3 = get_pixel(&amp;current_data, current_width, src_x, src_y + 1);
                let p4 = get_pixel(&amp;current_data, current_width, src_x + 1, src_y + 1);

                next_data
                    .push(((p1[0] as u32 + p2[0] as u32 + p3[0] as u32 + p4[0] as u32) / 4) as u8);
                next_data
                    .push(((p1[1] as u32 + p2[1] as u32 + p3[1] as u32 + p4[1] as u32) / 4) as u8);
                next_data
                    .push(((p1[2] as u32 + p2[2] as u32 + p3[2] as u32 + p4[2] as u32) / 4) as u8);
                next_data.push(255);
            }
        }

        // FIX: Unwrap image.data to append the new mip level
        if let Some(data) = &amp;mut image.data {
            data.extend_from_slice(&amp;next_data);
        }

        // Prepare for next iteration
        current_data = next_data;
        current_width = next_width;
        current_height = next_height;
    }

    image
}

fn get_pixel(data: &amp;[u8], width: u32, x: u32, y: u32) -&gt; [u8; 4] {
    let idx = ((y * width + x) * 4) as usize;
    [data[idx], data[idx + 1], data[idx + 2], data[idx + 3]]
}

fn generate_pattern_pixel(x: u32, y: u32, size: u32) -&gt; (u8, u8, u8) {
    let fx = x as f32;
    let fy = y as f32;

    // High frequency checkerboard (1x1)
    let ultra_fine = (x + y) % 2 == 0;
    // Larger checkerboard (8x8)
    let fine_checker = ((x / 8) + (y / 8)) % 2 == 0;
    // Concentric circles
    let center = size as f32 / 2.0;
    let dist = ((fx - center).powi(2) + (fy - center).powi(2)).sqrt();
    let circles = (dist / 4.0) as u32 % 2 == 0;

    let mut val = 40; // Base dark gray
    if ultra_fine {
        val += 40;
    }
    if fine_checker {
        val += 60;
    }
    if circles {
        val += 80;
    }

    // Color tinting
    let r = val;
    let g = if circles { val } else { val / 2 };
    let b = if fine_checker { val } else { val / 3 };

    (r, g, b)
}
</code></pre>
<p>Don't forget to register it in <code>src/materials/mod.rs</code>:</p>
<pre><code class="language-rust">pub mod d04_02_filtering_comparison;
</code></pre>
<h3>The Demo Module (<code>src/demos/d04_02_filtering_comparison.rs</code>)</h3>
<p>This system sets up the scene and handles the user input. When you press keys 1-4, we generate a new <code>ImageSamplerDescriptor</code> and assign it to the image. Bevy handles updating the GPU resource automatically.</p>
<p>We also implement a simple <strong>Spherical Orbit Camera</strong> so you can easily inspect the ground plane from different angles.</p>
<pre><code class="language-rust">use crate::materials::d04_02_filtering_comparison::{FilteringMaterial, create_test_texture};
use bevy::image::{ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor};
use bevy::pbr::MeshMaterial3d;
use bevy::prelude::*;
use std::f32::consts::PI;

#[derive(Component)]
struct Rotator;

#[derive(Component)]
struct OrbitCamera {
    radius: f32,
    pitch: f32,
    yaw: f32,
    focus: Vec3,
}

impl Default for OrbitCamera {
    fn default() -&gt; Self {
        Self {
            radius: 15.0,
            pitch: 0.5,
            yaw: 0.0,
            focus: Vec3::ZERO,
        }
    }
}

#[derive(Resource)]
struct DemoState {
    texture_handle: Handle&lt;Image&gt;,
    current_mode: usize,
    auto_rotate_obj: bool,
}

pub fn run() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;FilteringMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (handle_input, rotate_objects, update_camera, update_ui),
        )
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    mut materials: ResMut&lt;Assets&lt;FilteringMaterial&gt;&gt;,
    mut images: ResMut&lt;Assets&lt;Image&gt;&gt;,
) {
    // 1. Create and add texture
    let mut image = create_test_texture(512);
    // Start with Nearest (Point) filtering
    image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
        mag_filter: ImageFilterMode::Nearest,
        min_filter: ImageFilterMode::Nearest,
        mipmap_filter: ImageFilterMode::Nearest,
        address_mode_u: ImageAddressMode::Repeat,
        address_mode_v: ImageAddressMode::Repeat,
        ..default()
    });

    let texture_handle = images.add(image);

    // 2. Create Material
    let material = materials.add(FilteringMaterial {
        display_mode: 0,
        base_color: LinearRgba::WHITE,
        texture: texture_handle.clone(),
    });

    // 3. Spawn Scene
    // Large ground plane (shows Anisotropy best)
    commands.spawn((
        Mesh3d(meshes.add(Plane3d::default().mesh().size(50.0, 50.0))),
        MeshMaterial3d(material.clone()),
        Transform::from_xyz(0.0, 0.0, 0.0),
        Rotator,
    ));

    // Light
    commands.spawn((
        DirectionalLight {
            illuminance: 10_000.0,
            ..default()
        },
        Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -1.0, 0.5, 0.0)),
    ));

    // Camera
    commands.spawn((
        Camera3d::default(),
        OrbitCamera::default(),
        Transform::default(), // Will be set by update_camera system
    ));

    // UI
    commands.spawn((
        Text::new(""),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(12.0),
            left: Val::Px(12.0),
            ..default()
        },
    ));

    // Init State
    commands.insert_resource(DemoState {
        texture_handle,
        current_mode: 1,        // Start at mode 1
        auto_rotate_obj: false, // Default to manual control so you can inspect
    });
}

fn handle_input(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    mut state: ResMut&lt;DemoState&gt;,
    mut images: ResMut&lt;Assets&lt;Image&gt;&gt;,
    mut materials: ResMut&lt;Assets&lt;FilteringMaterial&gt;&gt;,
    mat_query: Query&lt;&amp;MeshMaterial3d&lt;FilteringMaterial&gt;&gt;,
) {
    let mut changed = false;

    // Mode Switching
    if keyboard.just_pressed(KeyCode::Digit1) {
        state.current_mode = 1;
        changed = true;
    }
    if keyboard.just_pressed(KeyCode::Digit2) {
        state.current_mode = 2;
        changed = true;
    }
    if keyboard.just_pressed(KeyCode::Digit3) {
        state.current_mode = 3;
        changed = true;
    }
    if keyboard.just_pressed(KeyCode::Digit4) {
        state.current_mode = 4;
        changed = true;
    }
    if keyboard.just_pressed(KeyCode::Digit5) {
        state.current_mode = 5;
        changed = true;
    }

    if keyboard.just_pressed(KeyCode::Space) {
        state.auto_rotate_obj = !state.auto_rotate_obj;
    }

    if changed {
        // Update Uniforms (For Mode 5 visualization)
        for handle in &amp;mat_query {
            if let Some(mat) = materials.get_mut(handle) {
                mat.display_mode = if state.current_mode == 5 { 5 } else { 0 };
            }
        }

        // Update Sampler (For Modes 1-4)
        if let Some(image) = images.get_mut(&amp;state.texture_handle) {
            let desc = match state.current_mode {
                1 =&gt; ImageSamplerDescriptor {
                    // Nearest
                    mag_filter: ImageFilterMode::Nearest,
                    min_filter: ImageFilterMode::Nearest,
                    mipmap_filter: ImageFilterMode::Nearest,
                    ..default()
                },
                2 =&gt; ImageSamplerDescriptor {
                    // Bilinear
                    mag_filter: ImageFilterMode::Linear,
                    min_filter: ImageFilterMode::Linear,
                    mipmap_filter: ImageFilterMode::Nearest,
                    ..default()
                },
                3 =&gt; ImageSamplerDescriptor {
                    // Trilinear
                    mag_filter: ImageFilterMode::Linear,
                    min_filter: ImageFilterMode::Linear,
                    mipmap_filter: ImageFilterMode::Linear,
                    ..default()
                },
                4 =&gt; ImageSamplerDescriptor {
                    // Anisotropic
                    mag_filter: ImageFilterMode::Linear,
                    min_filter: ImageFilterMode::Linear,
                    mipmap_filter: ImageFilterMode::Linear,
                    anisotropy_clamp: 16,
                    ..default()
                },
                _ =&gt; return,
            };

            let mut full_desc = desc;
            full_desc.address_mode_u = ImageAddressMode::Repeat;
            full_desc.address_mode_v = ImageAddressMode::Repeat;
            image.sampler = ImageSampler::Descriptor(full_desc);
        }
    }
}

fn update_camera(
    time: Res&lt;Time&gt;,
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    mut query: Query&lt;(&amp;mut Transform, &amp;mut OrbitCamera)&gt;,
) {
    let dt = time.delta_secs();

    for (mut transform, mut orbit) in &amp;mut query {
        // Manual Orbit Controls
        if keyboard.pressed(KeyCode::ArrowLeft) {
            orbit.yaw += 2.0 * dt;
        }
        if keyboard.pressed(KeyCode::ArrowRight) {
            orbit.yaw -= 2.0 * dt;
        }
        if keyboard.pressed(KeyCode::ArrowUp) {
            orbit.pitch += 1.0 * dt;
        }
        if keyboard.pressed(KeyCode::ArrowDown) {
            orbit.pitch -= 1.0 * dt;
        }

        // Zoom
        if keyboard.pressed(KeyCode::KeyW) {
            orbit.radius -= 10.0 * dt;
        }
        if keyboard.pressed(KeyCode::KeyS) {
            orbit.radius += 10.0 * dt;
        }

        // Clamp
        orbit.pitch = orbit.pitch.clamp(0.05, PI / 2.0 - 0.05); // Don't go below ground or flip over
        orbit.radius = orbit.radius.clamp(2.0, 50.0);

        // Spherical to Cartesian conversion
        // x = r * cos(pitch) * sin(yaw)
        // y = r * sin(pitch)
        // z = r * cos(pitch) * cos(yaw)
        // Note: In Bevy Y is up. So we map Pitch to Y-height and Yaw to XZ plane.
        let r_xz = orbit.radius * orbit.pitch.cos();
        let x = r_xz * orbit.yaw.sin();
        let y = orbit.radius * orbit.pitch.sin();
        let z = r_xz * orbit.yaw.cos();

        transform.translation = orbit.focus + Vec3::new(x, y, z);
        transform.look_at(orbit.focus, Vec3::Y);
    }
}

fn rotate_objects(
    time: Res&lt;Time&gt;,
    state: Res&lt;DemoState&gt;,
    mut query: Query&lt;&amp;mut Transform, With&lt;Rotator&gt;&gt;,
) {
    if state.auto_rotate_obj {
        for mut transform in &amp;mut query {
            transform.rotate_y(time.delta_secs() * 0.1);
        }
    }
}

fn update_ui(state: Res&lt;DemoState&gt;, mut query: Query&lt;&amp;mut Text&gt;) {
    let mode_text = match state.current_mode {
        1 =&gt; "1: Point (Nearest) - Pixelated, shimmer in distance",
        2 =&gt; "2: Bilinear - Smooth close, sharp mip transitions",
        3 =&gt; "3: Trilinear - Smooth everywhere, blurry at angles",
        4 =&gt; "4: Anisotropic 16x - Sharp at angles",
        5 =&gt; "5: Mip Level Viz - Red(0) to Blue(5)",
        _ =&gt; "",
    };

    let rotate_status = if state.auto_rotate_obj { "On" } else { "Off" };

    for mut text in &amp;mut query {
        **text = format!(
            "CONTROLS:\n\
            [1-4] Set Filtering Mode\n\
            [5]   Visualize Mip Levels\n\
            [Arrows] Orbit Camera\n\
            [W/S] Zoom In/Out\n\
            [Space] Rotate Floor: {}\n\
            \n\
            Current: {}",
            rotate_status, mode_text
        );
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="language-rust">pub mod d04_02_filtering_comparison;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="language-rust">Demo {
    number: "4.2",
    title: "Texture Filtering and Mipmapping",
    run: demos::d04_02_filtering_comparison::run,
},
</code></pre>
<h3>Running the Demo</h3>
<p>When you run the demo, look specifically at the <strong>Ground Plane</strong> receding into the distance.</p>
<h4>Controls</h4>
<table>
<thead>
<tr>
<th>Key</th>
<th>Mode</th>
<th>What to Look For</th>
</tr>
</thead>
<tbody><tr>
<td><strong>1</strong></td>
<td><strong>Point</strong></td>
<td><strong>Shimmering/Sparkling</strong> in the distance. The checkerboard looks like a chaotic noise field far away.</td>
</tr>
<tr>
<td><strong>2</strong></td>
<td><strong>Bilinear</strong></td>
<td>The shimmering stops, but you might see horizontal <strong>bands</strong> on the floor where the texture sharpness changes abruptly (Mip 0 vs Mip 1).</td>
</tr>
<tr>
<td><strong>3</strong></td>
<td><strong>Trilinear</strong></td>
<td>The bands disappear. The floor transitions smoothly from sharp to blurry, but becomes <strong>very blurry</strong> at a distance.</td>
</tr>
<tr>
<td><strong>4</strong></td>
<td><strong>Anisotropic</strong></td>
<td>Magic happens. The distant floor becomes <strong>sharp again</strong> without shimmering.</td>
</tr>
<tr>
<td><strong>5</strong></td>
<td><strong>Viz</strong></td>
<td>A heatmap showing which mip level is being used. Red is high-res, Blue is low-res.</td>
</tr>
</tbody></table>
<h4>What You're Seeing</h4>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1765043333186/e1ff4932-a115-4117-a69b-0861f890ee8b.png" alt="" />

<p>This demo proves why Mipmaps and Filtering are essential. Without them (Mode 1), the game looks broken and noisy. With basic filtering (Mode 3), it looks stable but blurry. With Anisotropic filtering (Mode 4), you get the quality of high-res textures with the stability of mipmaps.</p>
<h2>Key Takeaways</h2>
<ol>
<li><p><strong>Mipmaps are Mandatory</strong>: Always generate mipmaps for 3D textures. Without them, you get aliasing (noise) or have to rely on expensive supersampling.</p>
</li>
<li><p><strong>Trilinear is the Standard</strong>: For most objects, Linear min/mag/mipmap filters are the correct choice.</p>
</li>
<li><p><strong>Anisotropy for Ground</strong>: Use anisotropy_clamp: 16 for floors and terrain. It costs a bit more performance but massively improves visual clarity.</p>
</li>
<li><p><strong>Sampler Objects</strong>: In Bevy/WGSL, the sampler is a distinct resource that tells the GPU how to read the texture data. You can swap samplers without reloading the texture.</p>
</li>
</ol>
<h2>What's Next?</h2>
<p>We've covered how to sample a single texture. But real materials use multiple textures combined together, and they often need to tile or clamp at the edges.</p>
<p>In the next article, we will master <strong>Texture Coordinates (UVs)</strong> and <strong>Address Modes</strong> to create complex, scrolling, and tiling materials.</p>
<p><em>Next up:</em> <a href="https://blog.hexbee.net/4-3-texture-wrapping-modes">4.3 - Texture Wrapping Modes</a></p>
<hr />
<h2>Quick Reference</h2>
<h3>1. Filtering Modes Cheat Sheet</h3>
<table>
<thead>
<tr>
<th>Mode</th>
<th>How it works</th>
<th>Visual Look</th>
<th>Best Used For</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Nearest</strong> (Point)</td>
<td>Picks 1 closest pixel</td>
<td>Blocky, sharp edges</td>
<td>Pixel Art, voxel games, debugging</td>
</tr>
<tr>
<td><strong>Bilinear</strong></td>
<td>Blends 4 pixels</td>
<td>Smooth but blurry</td>
<td>2D UI, Sprites, Particles</td>
</tr>
<tr>
<td><strong>Trilinear</strong></td>
<td>Blends 8 pixels (2 mip levels)</td>
<td>Smooth, no popping</td>
<td>Standard 3D objects (Props, Walls)</td>
</tr>
<tr>
<td><strong>Anisotropic</strong></td>
<td>Blends many pixels along slope</td>
<td>Sharp at oblique angles</td>
<td><strong>Ground planes</strong>, Roads, Floors</td>
</tr>
</tbody></table>
<h3>2. Understanding Mipmaps</h3>
<ul>
<li><p><strong>What:</strong> A chain of progressively smaller versions of a texture (50%, 25%, 12.5%...).</p>
</li>
<li><p><strong>Why:</strong> Prevents "shimmering" and aliasing when textures are far away.</p>
</li>
<li><p><strong>Cost:</strong> Increases memory usage by <strong>~33%</strong>.</p>
</li>
<li><p><strong>Rule:</strong> Always enable for 3D objects. Disable only for 2D pixel art or non-color data textures.</p>
</li>
</ul>
<h3>3. Performance Hierarchy</h3>
<p>From cheapest to most expensive (in terms of memory bandwidth):</p>
<ol>
<li><p><strong>Nearest</strong>: 1 Fetch (Fastest)</p>
</li>
<li><p><strong>Bilinear</strong>: 4 Fetches</p>
</li>
<li><p><strong>Trilinear</strong>: 8 Fetches</p>
</li>
<li><p><strong>Anisotropic (16x)</strong>: Up to 128 Fetches (Heavy on bandwidth, use selectively)</p>
</li>
</ol>
<h3>4. Bevy Configuration</h3>
<p><strong>The "Standard 3D" Sampler (Trilinear)</strong>:</p>
<pre><code class="language-rust">ImageSampler::Descriptor(ImageSamplerDescriptor {
    mag_filter: ImageFilterMode::Linear,    // Smooth up close
    min_filter: ImageFilterMode::Linear,    // Smooth far away
    mipmap_filter: ImageFilterMode::Linear, // Smooth transitions between distances
    ..default()
})
</code></pre>
<p><strong>The "High Quality Ground" Sampler (Anisotropic)</strong>:</p>
<pre><code class="language-rust">ImageSampler::Descriptor(ImageSamplerDescriptor {
    mag_filter: ImageFilterMode::Linear,
    min_filter: ImageFilterMode::Linear,
    mipmap_filter: ImageFilterMode::Linear,
    anisotropy_clamp: 16, // Range: 1 (Off) to 16 (Max)
    ..default()
})
</code></pre>
<p><strong>The "Pixel Art" Sampler</strong>:</p>
<pre><code class="language-rust">ImageSampler::Descriptor(ImageSamplerDescriptor {
    mag_filter: ImageFilterMode::Nearest,
    min_filter: ImageFilterMode::Nearest,
    mipmap_filter: ImageFilterMode::Nearest,
    ..default()
})
</code></pre>
]]></content:encoded></item><item><title><![CDATA[4.1 - Texture Sampling Basics]]></title><description><![CDATA[What We're Learning
Up until now, we've been generating colors procedurally - calculating them with math equations directly in our shaders. But most real-world 3D graphics rely on textures: images tha]]></description><link>https://blog.hexbee.net/41-texture-sampling-basics</link><guid isPermaLink="true">https://blog.hexbee.net/41-texture-sampling-basics</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Mon, 16 Feb 2026 11:19:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1764927851970/81e35b09-6e56-4330-aa9a-d4983b7f7fad.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>What We're Learning</h2>
<p>Up until now, we've been generating colors procedurally - calculating them with math equations directly in our shaders. But most real-world 3D graphics rely on <strong>textures</strong>: images that wrap around 3D geometry to provide color, detail, and visual richness. A character's skin, the rough bark of a tree, or the rust on a metal barrel - these are all typically defined by textures.</p>
<p>Textures are fundamental to modern graphics. They allow us to decouple surface detail from geometric complexity. Instead of modeling every pore on a face or every brick in a wall, we simply "paint" them onto a simpler shape.</p>
<p>While the concept seems straightforward - "just paste an image onto a model" - there is surprising depth here. How do 2D flat images map seamlessly onto complex 3D curves? What happens when a texture is viewed from a sharp angle or from miles away? How does the GPU interpret colors between pixels?</p>
<p>By the end of this article, you'll understand:</p>
<ul>
<li><p><strong>Texture vs. Sampler</strong>: Why GPUs separate the image data from the <em>instructions on how to read it</em>.</p>
</li>
<li><p><strong>UV Coordinates</strong>: The coordinate system that bridges 3D geometry and 2D images.</p>
</li>
<li><p><strong>The textureSample() Function</strong>: How to retrieve color data in a fragment shader.</p>
</li>
<li><p><strong>Texture Filtering</strong>: How to control the look of your textures (pixelated retro vs. smooth modern).</p>
</li>
<li><p><strong>Address Modes</strong>: What happens at the edge of a texture (repeat, clamp, mirror).</p>
</li>
<li><p><strong>Bevy Integration</strong>: How to load images and bind them to your custom materials.</p>
</li>
</ul>
<h2>Understanding Textures in WGSL</h2>
<p>Before we write any code, we need to shift our mental model. To a CPU, an image might just be a file on a disk. To a GPU, a <strong>Texture</strong> and a <strong>Sampler</strong> are two distinct, specialized resources.</p>
<h3>What Is a Texture?</h3>
<p>A <strong>texture</strong> is a structured grid of data stored in high-speed GPU memory. While we usually use them for color images, they are just data containers.</p>
<ul>
<li><strong>1D Texture</strong>: A single row of pixels. Useful for gradients or lookup curves.</li>
</ul>
<pre><code class="language-plaintext">[R G B A | R G B A | R G B A | ...]
</code></pre>
<ul>
<li><strong>2D Texture</strong>: A grid of pixels (width × height). This is the standard "image" format.</li>
</ul>
<pre><code class="language-plaintext">[row 0: R G B A | R G B A | ...]
[row 1: R G B A | R G B A | ...]
[row 2: R G B A | R G B A | ...]
</code></pre>
<ul>
<li><strong>3D Texture</strong>: A volume of pixels (width × height × depth). Used for fog, smoke simulations, or MRI data.</li>
</ul>
<pre><code class="language-plaintext">[layer 0][layer 1][layer 2]...
</code></pre>
<ul>
<li><strong>Cube Texture</strong>: A collection of 6 faces forming a cube. Used for skyboxes and reflections.</li>
</ul>
<pre><code class="language-plaintext">[+X face][-X face][+Y][-Y][+Z][-Z]
</code></pre>
<p>In this article, we will focus exclusively on <strong>2D Textures</strong>.</p>
<h3>Texture Formats</h3>
<p>Textures can be stored in various formats to balance precision against memory usage. When you define a texture in WGSL, the type you choose generally corresponds to the return value, not necessarily the storage format on disk.</p>
<p>Common formats you'll encounter:</p>
<ol>
<li><p><code>Rgba8Unorm</code> (Standard): 4 channels (Red, Green, Blue, Alpha). Each channel is 8 bits (0-255). Shader View: The GPU automatically converts the 0-255 integer range to a <strong>0.0 to 1.0</strong> floating-point range when you sample it.</p>
</li>
<li><p><code>R16Float</code> / <code>R32Float</code>: Single channel floating point. Usage: Heightmaps, physics data, or non-color information.</p>
</li>
<li><p><code>Rg8Unorm</code>: Two channels. Usage: Often used for Normal maps (storing X and Y direction), allowing the Z component to be reconstructed mathematically.</p>
</li>
</ol>
<h3>Declaring Textures in WGSL</h3>
<p>In WGSL, we declare a texture as a global variable. Note the specific type syntax:</p>
<pre><code class="language-rust">// A standard 2D texture that returns floating point values (0.0 - 1.0)
@group(2) @binding(0)
var my_texture: texture_2d&lt;f32&gt;;
</code></pre>
<ul>
<li><p><code>texture_2d</code>: Specifies the dimensionality.</p>
</li>
<li><p><code>&lt;f32&gt;</code>: Specifies the return type. Even if the image on disk is 8-bit integers (PNG), the shader reads them as normalized floats. You can also use <code>&lt;i32&gt;</code> for integer textures or <code>&lt;u32&gt;</code> for unsigned integer textures, but these are for specialized use cases (like grid data), not standard images.</p>
</li>
</ul>
<h3>The Sampler: The "How"</h3>
<p>Here is the most important concept to grasp: <strong>A texture does not know how to be read.</strong></p>
<p>A texture is just a blob of data. If you ask for the color at coordinate <code>(0.5, 0.5)</code>, the texture has the data, but it doesn't know:</p>
<ul>
<li><p>Should I blend nearby pixels smoothly?</p>
</li>
<li><p>Should I just return the exact nearest pixel (pixel art style)?</p>
</li>
<li><p>What if you ask for coordinate <code>(1.5, 0.5)</code> - should I wrap around to the start or stop at the edge?</p>
</li>
</ul>
<p>These instructions are provided by a <strong>Sampler</strong>.</p>
<p>By separating the <strong>Texture</strong> (data) from the <strong>Sampler</strong> (logic), modern graphics APIs allow you to reuse the same image in different ways. You could sample a noise texture smoothly for clouds, and then sample the <em>exact same texture</em> with "nearest neighbor" filtering for a glitch effect, without reloading the image.</p>
<h3>Declaring Samplers in WGSL</h3>
<p>Samplers are declared as their own resource type:</p>
<pre><code class="language-rust">// A sampler configuration
@group(2) @binding(1)
var my_sampler: sampler;
</code></pre>
<p>There is also a specialized type called <code>sampler_comparison</code> used for shadow mapping, but for standard materials, <code>sampler</code> is what you need.</p>
<h3>Pairing Textures and Samplers in Bevy</h3>
<p>Because they are separate resources, you need to define both in your Rust material struct. Bevy's <code>AsBindGroup</code> macro handles the boilerplate of binding them to the specific slots.</p>
<pre><code class="language-rust">#[derive(Asset, TypePath, AsBindGroup, Clone)]
pub struct MyMaterial {
    // 1. The Texture Data
    // We bind it to group 2, binding 0
    #[texture(0)] 
    // 2. The Sampler Configuration
    // We bind it to group 2, binding 1
    #[sampler(1)] 
    pub color_image: Handle&lt;Image&gt;,
}
</code></pre>
<p>Notice a convenience here: In Rust, we use a single <code>Handle&lt;Image&gt;</code>. The <code>Image</code> asset in Bevy bundles the pixel data <strong>and</strong> the sampler configuration together for convenience. However, <code>AsBindGroup</code> splits them apart behind the scenes so the shader receives them as two distinct variables:</p>
<pre><code class="language-rust">@group(2) @binding(0) var color_texture: texture_2d&lt;f32&gt;;
@group(2) @binding(1) var color_sampler: sampler;
</code></pre>
<h2>UV Coordinates: The Bridge to 3D</h2>
<p>To paint a 2D image onto a 3D object, we need a translation map. We can't say "put this pixel on that vertex" because vertices move, rotate, and scale. Instead, we use a normalized coordinate system called <strong>UV Coordinates</strong>.</p>
<h3>What Are UV Coordinates?</h3>
<p>UVs act as anchors. They map a specific point on the 3D mesh surface to a specific point on the 2D texture.</p>
<ul>
<li><p><strong>U (Horizontal)</strong>: Corresponds to the X-axis of the image (<code>0.0</code> is Left, <code>1.0</code> is Right).</p>
</li>
<li><p><strong>V (Vertical)</strong>: Corresponds to the Y-axis of the image (<code>0.0</code> is Top, <code>1.0</code> is Bottom).</p>
</li>
</ul>
<blockquote>
<p>Note: While the coordinate system mathematically goes from <code>0.0</code> to <code>1.0</code>, different engines and modeling software treat the vertical origin differently. In WGPU (and thus Bevy), <code>(0, 0)</code> is the <strong>top-left</strong> corner of the texture data.</p>
</blockquote>
<p>Think of it like gift wrapping. The wrapping paper (texture) is flat. You wrap it around a box (mesh). The UV coordinates tell the engine exactly which part of the paper touches each corner of the box.</p>
<h3>UVs in the Graphics Pipeline</h3>
<p>UVs differ from textures in one major way: <strong>UVs are vertex data</strong>. They live on the mesh, not in the material.</p>
<ol>
<li><p><strong>Vertex Input</strong>: Each vertex in your mesh has a position <code>(x, y, z)</code> and a UV coordinate <code>(u, v)</code>.</p>
</li>
<li><p><strong>Interpolation</strong>: This is the magic step. The vertex shader passes the UVs to the rasterizer. When the GPU draws a triangle, it automatically <strong>interpolates</strong> the UVs between the three vertices for every single pixel inside that triangle.</p>
</li>
<li><p><strong>Fragment Input</strong>: By the time the code reaches your fragment shader, the uv variable represents the precise coordinate for that specific pixel on the screen.</p>
</li>
</ol>
<h3>Passing UVs in WGSL</h3>
<p>To use UVs, we must explicitly pass them from the Vertex Shader to the Fragment Shader.</p>
<pre><code class="language-rust">struct VertexInput {
    @location(0) position: vec3&lt;f32&gt;,
    @location(1) normal: vec3&lt;f32&gt;,
    @location(2) uv: vec2&lt;f32&gt;, // Input from Mesh
}

struct VertexOutput {
    @builtin(position) clip_position: vec4&lt;f32&gt;,
    // We create a location to pass UVs to the fragment stage
    @location(0) uv: vec2&lt;f32&gt;, 
}

@vertex
fn vertex(in: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;
    // ... calculate clip_position ...
    
    // Pass the UVs through unchanged
    out.uv = in.uv;
    return out;
}
</code></pre>
<h2>The <code>textureSample()</code> Function</h2>
<p>Now that we have the <strong>Texture</strong> (data), the <strong>Sampler</strong> (rules), and the <strong>UVs</strong> (coordinates), we can finally retrieve a color.</p>
<h3>Basic Usage</h3>
<p>The primary function for this is <code>textureSample()</code>.</p>
<pre><code class="language-rust">@fragment
fn fragment(in: VertexOutput) -&gt; @location(0) vec4&lt;f32&gt; {
    // Syntax: textureSample(texture_variable, sampler_variable, coordinates)
    let color = textureSample(my_texture, my_sampler, in.uv);
    
    return color;
}
</code></pre>
<p><strong>Return Type</strong>: <code>textureSample</code> always returns a <code>vec4&lt;f32&gt;</code>.</p>
<ul>
<li><p>Even if your texture is a JPEG with no transparency, the alpha channel (<code>.a</code>) will be set to <code>1.0</code>.</p>
</li>
<li><p>Even if your texture is black and white, you get a <code>vec4</code> (usually with R=G=B).</p>
</li>
</ul>
<h3>Constraint: Fragment Shader Only</h3>
<p>There is a critical rule you must remember: <code>textureSample()</code> can only be used in the Fragment Shader.</p>
<p>If you try to use it in a Vertex Shader, your code will fail to compile.</p>
<p><strong>Why?</strong> To avoid "shimmering" or "aliasing" on distant objects, GPUs use <strong>Mipmaps</strong> (smaller versions of the texture). The GPU decides which mipmap level to use based on how fast the UV coordinates are changing from pixel to pixel (derivatives).</p>
<ul>
<li><p>In the Fragment Shader, the GPU knows about neighboring pixels and can calculate this.</p>
</li>
<li><p>In the Vertex Shader, vertices are processed in isolation. The GPU has no concept of "neighbors" or "screen density," so it cannot automatically choose a mipmap level.</p>
</li>
</ul>
<blockquote>
<p>Note: If you absolutely must read a texture in the vertex shader - e.g., for a heightmap displacement - you must use <code>textureSampleLevel()</code>, which forces you to manually specify the mipmap level, usually <code>0.0</code>.</p>
</blockquote>
<h3>Swizzling and Channels</h3>
<p>Often, you don't need the full RGBA color. You can use standard vector swizzling to get what you need:</p>
<pre><code class="language-rust">let raw_sample = textureSample(my_texture, my_sampler, in.uv);

// Just the Red channel (useful for grayscale masks)
let roughness = raw_sample.r; 

// Just the RGB color (dropping alpha)
let base_color = raw_sample.rgb;

// Reordering channels (Swap Red and Blue)
let bgr_color = raw_sample.bgr;
</code></pre>
<h2>Texture Filtering: The "Zoom" Problem</h2>
<p>Textures are made of discrete pixels (texels). Screens are made of discrete pixels. Rarely do they align perfectly 1:1.</p>
<p>When a texture is displayed larger than its original size (<strong>Magnification</strong>) or smaller than its original size (<strong>Minification</strong>), the GPU has to make a decision about how to fill the gaps.</p>
<p>This behavior is controlled by the <strong>Filter Mode</strong> setting on the Sampler.</p>
<h3>1. Nearest Neighbor (<code>Nearest</code>)</h3>
<p>This is the simplest method. The GPU simply picks the single texel closest to the UV coordinate.</p>
<ul>
<li><p><strong>Look</strong>: Blocky, pixelated.</p>
</li>
<li><p><strong>Best For</strong>: Pixel art games, Minecraft-style aesthetics, or debugging.</p>
</li>
<li><p><strong>Performance</strong>: Extremely fast.</p>
</li>
</ul>
<h3>2. Linear Interpolation (<code>Linear</code>)</h3>
<p>The GPU takes the 4 closest texels surrounding the UV coordinate and blends them together based on distance (bilinear interpolation).</p>
<ul>
<li><p><strong>Look</strong>: Smooth, slightly blurry at close range.</p>
</li>
<li><p><strong>Best For</strong>: Realistic textures, photos, most standard 3D objects.</p>
</li>
<li><p><strong>Performance</strong>: Standard (hardware optimized).</p>
</li>
</ul>
<h3>Mipmaps and Minification</h3>
<p>When a texture is far away (minification), sampling just one pixel causes "noise" or "shimmering" because you might hit a bright pixel on one frame and a dark pixel on the next, even if the camera moves slightly.</p>
<p>To solve this, we use <strong>Mipmaps</strong>: a chain of progressively smaller versions of the image (100%, 50%, 25%...).</p>
<ul>
<li><p><code>mipmap_filter: Nearest</code>: Switches abruptly between detail levels. You can see a visible "line" where the quality drops.</p>
</li>
<li><p><code>mipmap_filter: Linear</code>: Blends between the two nearest mipmap levels (Trilinear filtering). This is the gold standard for smooth rendering.</p>
</li>
</ul>
<h2>Address Modes: The "Edge" Problem</h2>
<p>UV coordinates are typically <code>0.0</code> to <code>1.0</code>. But what happens if we pass <code>2.5</code> or <code>-0.1</code>? The <strong>Address Mode</strong> (or Wrap Mode) determines this behavior.</p>
<h3>1. <code>Repeat</code></h3>
<p>The texture tiles infinitely. <code>1.1</code> behaves exactly like <code>0.1</code>.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764928249211/1b506265-35dd-4143-a6d7-b8437738baf4.png" alt="" />

<p><strong>Use Case</strong>: Brick walls, grass, tile floors.</p>
<h3>2. <code>ClampToEdge</code></h3>
<p>Any value greater than <code>1.0</code> reads the last pixel on the edge. Any value less than <code>0.0</code> reads the first pixel.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764928256703/233d2cd6-cf56-4cff-bbd2-55896512f7ce.png" alt="" />

<p><strong>Use Case</strong>: UI elements, skyboxes (to prevent seams), or any object that shouldn't tile.</p>
<h3>3. <code>MirrorRepeat</code></h3>
<p>The texture tiles, but flips direction every time (0-1, then 1-0, then 0-1).</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764928263598/824a30fa-9a3b-421c-9995-d0b33306149e.png" alt="" />

<p><strong>Use Case</strong>: Creating seamless patterns from non-seamless images, or weird psychedelic effects.</p>
<h2>Bevy Integration</h2>
<p>In Bevy, textures are loaded as Image assets. The <code>Image</code> struct holds both the pixel data and the sampler configuration.</p>
<h3>Configuring Samplers in Rust</h3>
<p>By default, Bevy loads images with <code>Repeat</code> address mode and <code>Linear</code> filtering. If you want "pixel perfect" rendering or clamping, you need to modify the image asset.</p>
<pre><code class="language-rust">use bevy::image::{ImageSampler, ImageSamplerDescriptor, ImageFilterMode, ImageAddressMode};

fn configure_texture(
    mut images: ResMut&lt;Assets&lt;Image&gt;&gt;,
    my_handle: Res&lt;TextureHandle&gt;, // Assuming you stored the handle
) {
    if let Some(image) = images.get_mut(&amp;my_handle) {
        image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
            // "Pixel Art" settings:
            mag_filter: ImageFilterMode::Nearest, // Sharp pixels when close
            min_filter: ImageFilterMode::Linear,  // Smooth when far away
            
            // Tiling settings:
            address_mode_u: ImageAddressMode::Repeat,
            address_mode_v: ImageAddressMode::Repeat,
            
            ..default()
        });
    }
}
</code></pre>
<h3>Anisotropic Filtering</h3>
<p>There is one more advanced sampler setting: <strong>Anisotropy</strong>.</p>
<p>When you look at a floor texture at a sharp, grazing angle, standard linear filtering makes it look blurry because the "footprint" of the pixel is long and thin, but linear filtering assumes a square.</p>
<p><strong>Anisotropic Filtering</strong> solves this by taking multiple samples along the slope.</p>
<pre><code class="language-rust">image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
    // 16x Anisotropy (High Quality)
    anisotropy_clamp: Some(16), 
    ..default()
});
</code></pre>
<p>This is significantly more expensive but essential for ground planes and roads in first-person games.</p>
<hr />
<h2>Complete Example: Textured Quad with Custom UVs</h2>
<p>We are going to build a demo that demystifies UVs. Instead of using a standard cube, we will manually build a Quad mesh so we can define the UVs ourselves. We will then manipulate these UVs in the shader to scroll, zoom, and tile a texture.</p>
<h3>Our Goal</h3>
<ol>
<li><p>Load an external texture (or generate one).</p>
</li>
<li><p>Display it on a custom mesh.</p>
</li>
<li><p>Use uniforms to control <strong>Tiling</strong> (Zoom) and <strong>Offset</strong> (Pan) in real-time.</p>
</li>
</ol>
<h3>The Shader (<code>assets/shaders/d04_01_textured_quad.wgsl</code>)</h3>
<pre><code class="language-rust">#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

struct TexturedMaterial {
    base_color: vec4&lt;f32&gt;,
    uv_scale: vec2&lt;f32&gt;,
    uv_offset: vec2&lt;f32&gt;,
}

@group(2) @binding(0)
var&lt;uniform&gt; material: TexturedMaterial;

@group(2) @binding(1)
var base_texture: texture_2d&lt;f32&gt;;

@group(2) @binding(2)
var base_sampler: sampler;

struct VertexInput {
    @builtin(instance_index) instance_index: u32,
    @location(0) position: vec3&lt;f32&gt;,
    @location(2) uv: vec2&lt;f32&gt;,
}

struct VertexOutput {
    @builtin(position) clip_position: vec4&lt;f32&gt;,
    @location(0) uv: vec2&lt;f32&gt;,
}

@vertex
fn vertex(in: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    let world_from_local = mesh_functions::get_world_from_local(in.instance_index);
    let world_position = mesh_functions::mesh_position_local_to_world(
        world_from_local,
        vec4&lt;f32&gt;(in.position, 1.0)
    );
    out.clip_position = position_world_to_clip(world_position.xyz);

    // UV Manipulation
    // 1. Scale (Tiling)
    var transformed_uv = in.uv * material.uv_scale;

    // 2. Offset (Panning)
    transformed_uv = transformed_uv + material.uv_offset;

    out.uv = transformed_uv;

    return out;
}

@fragment
fn fragment(in: VertexOutput) -&gt; @location(0) vec4&lt;f32&gt; {
    let texture_color = textureSample(base_texture, base_sampler, in.uv);
    return texture_color * material.base_color;
}
</code></pre>
<h3>The Rust Material (<code>src/materials/d04_01_textured_quad.rs</code>)</h3>
<pre><code class="language-rust">use bevy::prelude::*;
use bevy::render::render_resource::{AsBindGroup, ShaderRef};

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct TexturedMaterial {
    #[uniform(0)]
    pub base_color: LinearRgba,
    #[uniform(0)]
    pub uv_scale: Vec2,
    #[uniform(0)]
    pub uv_offset: Vec2,

    #[texture(1)]
    #[sampler(2)]
    pub base_texture: Handle&lt;Image&gt;,
}

impl Default for TexturedMaterial {
    fn default() -&gt; Self {
        Self {
            base_color: LinearRgba::WHITE,
            uv_scale: Vec2::ONE,
            uv_offset: Vec2::ZERO,
            base_texture: Handle::default(),
        }
    }
}

impl Material for TexturedMaterial {
    fn vertex_shader() -&gt; ShaderRef {
        "shaders/d04_01_textured_quad.wgsl".into()
    }

    fn fragment_shader() -&gt; ShaderRef {
        "shaders/d04_01_textured_quad.wgsl".into()
    }
}
</code></pre>
<p>Don't forget to register it in <code>src/materials/mod.rs</code>:</p>
<pre><code class="language-rust">pub mod d04_01_textured_quad;
</code></pre>
<h3>The Demo Module (<code>src/demos/d04_01_textured_quad.rs</code>)</h3>
<p>We'll procedurally generate a "Checkerboard" texture so you don't need to download any assets to run this demo. We also configure its sampler to use <code>Repeat</code> mode so we can scroll it infinitely.</p>
<pre><code class="language-rust">use bevy::image::{ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor};
use bevy::prelude::*;
use bevy::render::render_asset::RenderAssetUsages;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};

use crate::materials::d04_01_textured_quad::TexturedMaterial;

pub fn run() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;TexturedMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (handle_input, rotate_quads, update_ui, sync_loaded_icon),
        )
        .run();
}

#[derive(Component)]
struct Rotator;

#[derive(Resource)]
struct DemoState {
    procedural_handle: Handle&lt;Image&gt;,
    icon_handle: Handle&lt;Image&gt;,

    // Settings
    filter_mode: ImageFilterMode,
    address_mode: ImageAddressMode,

    // Rotation
    auto_rotate: bool,
    manual_rotation: f32,

    // State tracking
    icon_configured: bool,
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    mut materials: ResMut&lt;Assets&lt;TexturedMaterial&gt;&gt;,
    mut images: ResMut&lt;Assets&lt;Image&gt;&gt;,
    asset_server: Res&lt;AssetServer&gt;,
) {
    // 1. Initial Sampler Settings
    let initial_descriptor = ImageSamplerDescriptor {
        mag_filter: ImageFilterMode::Nearest,
        min_filter: ImageFilterMode::Nearest,
        address_mode_u: ImageAddressMode::Repeat,
        address_mode_v: ImageAddressMode::Repeat,
        ..default()
    };

    // 2. Create Procedural "UV Test" Texture
    // We create a colored gradient so address modes are obvious.
    // Red = X axis, Green = Y axis.
    let size = 256;
    let mut data = Vec::with_capacity((size * size * 4) as usize);
    for y in 0..size {
        for x in 0..size {
            let r = x as u8; // 0 to 255 horizontally
            let g = y as u8; // 0 to 255 vertically
            // Blue checkerboard overlay for detail
            let b = if ((x / 16) + (y / 16)) % 2 == 0 {
                255
            } else {
                0
            };

            data.extend_from_slice(&amp;[r, g, b, 255]);
        }
    }

    let mut proc_image = Image::new(
        Extent3d {
            width: size,
            height: size,
            depth_or_array_layers: 1,
        },
        TextureDimension::D2,
        data,
        TextureFormat::Rgba8Unorm,
        RenderAssetUsages::default(),
    );
    // Apply initial sampler
    proc_image.sampler = ImageSampler::Descriptor(initial_descriptor.clone());
    let proc_handle = images.add(proc_image);

    // 3. Load Icon (Async)
    let icon_handle = asset_server.load("textures/bevy_icon.png");

    // 4. Save State
    commands.insert_resource(DemoState {
        procedural_handle: proc_handle.clone(),
        icon_handle: icon_handle.clone(),
        filter_mode: ImageFilterMode::Nearest,
        address_mode: ImageAddressMode::Repeat,
        auto_rotate: true,
        manual_rotation: 0.0,
        icon_configured: false,
    });

    // 5. Scene Setup
    let quad_handle = meshes.add(Plane3d::default().mesh().size(3.0, 3.0));

    // Left Quad: Procedural
    commands.spawn((
        Mesh3d(quad_handle.clone()),
        MeshMaterial3d(materials.add(TexturedMaterial {
            base_texture: proc_handle,
            ..default()
        })),
        Transform::from_xyz(-2.0, 0.0, 0.0),
        Rotator,
    ));

    // Right Quad: Loaded Icon
    commands.spawn((
        Mesh3d(quad_handle),
        MeshMaterial3d(materials.add(TexturedMaterial {
            base_texture: icon_handle,
            ..default()
        })),
        Transform::from_xyz(2.0, 0.0, 0.0),
        Rotator,
    ));

    // Camera
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(0.0, 4.0, 6.0).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    // UI
    commands.spawn((
        Text::new(""), // Updated in update_ui
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(12.0),
            left: Val::Px(12.0),
            ..default()
        },
    ));
}

fn handle_input(
    time: Res&lt;Time&gt;,
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    mut materials: ResMut&lt;Assets&lt;TexturedMaterial&gt;&gt;,
    mut images: ResMut&lt;Assets&lt;Image&gt;&gt;,
    mut state: ResMut&lt;DemoState&gt;,
) {
    let dt = time.delta_secs();

    // 1. UV Manipulation
    for (_, mat) in materials.iter_mut() {
        if keyboard.pressed(KeyCode::ArrowLeft) {
            mat.uv_offset.x -= dt;
        }
        if keyboard.pressed(KeyCode::ArrowRight) {
            mat.uv_offset.x += dt;
        }
        if keyboard.pressed(KeyCode::ArrowUp) {
            mat.uv_offset.y -= dt;
        }
        if keyboard.pressed(KeyCode::ArrowDown) {
            mat.uv_offset.y += dt;
        }

        if keyboard.pressed(KeyCode::KeyW) {
            mat.uv_scale += dt;
        }
        if keyboard.pressed(KeyCode::KeyS) {
            mat.uv_scale = (mat.uv_scale - dt).max(Vec2::splat(0.1));
        }
    }

    // 2. Rotation Controls
    if keyboard.just_pressed(KeyCode::KeyR) {
        state.auto_rotate = !state.auto_rotate;
    }
    if !state.auto_rotate {
        if keyboard.pressed(KeyCode::KeyA) {
            state.manual_rotation -= dt;
        }
        if keyboard.pressed(KeyCode::KeyD) {
            state.manual_rotation += dt;
        }
    }

    // 3. Sampler Switching
    let mut update_samplers = false;

    if keyboard.just_pressed(KeyCode::Digit1) {
        state.filter_mode = match state.filter_mode {
            ImageFilterMode::Nearest =&gt; ImageFilterMode::Linear,
            _ =&gt; ImageFilterMode::Nearest,
        };
        update_samplers = true;
    }

    if keyboard.just_pressed(KeyCode::Digit2) {
        state.address_mode = match state.address_mode {
            ImageAddressMode::Repeat =&gt; ImageAddressMode::MirrorRepeat,
            ImageAddressMode::MirrorRepeat =&gt; ImageAddressMode::ClampToEdge,
            ImageAddressMode::ClampToEdge =&gt; ImageAddressMode::Repeat,
            _ =&gt; ImageAddressMode::Repeat,
        };
        update_samplers = true;
    }

    if update_samplers {
        update_all_images(&amp;mut images, &amp;mut state);
    }
}

// Helper to apply current settings to all valid images
fn update_all_images(images: &amp;mut Assets&lt;Image&gt;, state: &amp;mut DemoState) {
    let descriptor = ImageSamplerDescriptor {
        mag_filter: state.filter_mode,
        min_filter: state.filter_mode,
        address_mode_u: state.address_mode,
        address_mode_v: state.address_mode,
        ..default()
    };

    // Update procedural
    if let Some(image) = images.get_mut(&amp;state.procedural_handle) {
        image.sampler = ImageSampler::Descriptor(descriptor.clone());
    }

    // Update loaded icon (if loaded)
    if let Some(image) = images.get_mut(&amp;state.icon_handle) {
        image.sampler = ImageSampler::Descriptor(descriptor);
        state.icon_configured = true;
    }
}

// Automatically configures the icon once it finishes loading
fn sync_loaded_icon(mut images: ResMut&lt;Assets&lt;Image&gt;&gt;, mut state: ResMut&lt;DemoState&gt;) {
    // If the icon is loaded but hasn't been configured yet
    if !state.icon_configured &amp;&amp; images.contains(&amp;state.icon_handle) {
        update_all_images(&amp;mut images, &amp;mut state);
    }
}

fn rotate_quads(
    time: Res&lt;Time&gt;,
    mut state: ResMut&lt;DemoState&gt;,
    mut query: Query&lt;&amp;mut Transform, With&lt;Rotator&gt;&gt;,
) {
    if state.auto_rotate {
        state.manual_rotation += time.delta_secs() * 0.2;
    }

    for mut transform in &amp;mut query {
        transform.rotation =
            Quat::from_rotation_y(state.manual_rotation) * Quat::from_rotation_x(0.5);
    }
}

fn update_ui(state: Res&lt;DemoState&gt;, mut query: Query&lt;&amp;mut Text&gt;) {
    let filter_text = match state.filter_mode {
        ImageFilterMode::Nearest =&gt; "Nearest (Pixelated)",
        _ =&gt; "Linear (Smooth)",
    };
    let address_text = match state.address_mode {
        ImageAddressMode::Repeat =&gt; "Repeat (Tile)",
        ImageAddressMode::MirrorRepeat =&gt; "MirrorRepeat (Flip)",
        _ =&gt; "ClampToEdge (Stretch)",
    };
    let rotate_text = if state.auto_rotate {
        "Auto"
    } else {
        "Manual (A/D)"
    };

    for mut text in &amp;mut query {
        **text = format!(
            "CONTROLS:\n\
            [Arrows] Pan UVs\n\
            [W/S]    Zoom UVs\n\
            [1]      Filter: {}\n\
            [2]      Address: {}\n\
            [R]      Rotation: {}",
            filter_text, address_text, rotate_text
        );
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="language-rust">pub mod d04_01_textured_quad;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="language-rust">Demo {
    number: "4.1",
    title: "Texture Sampling Basics",
    run: demos::d04_01_textured_quad::run,
},
</code></pre>
<h3>Running the Demo</h3>
<p>When you run this demo, you will see a large checkerboard spinning slowly in the void.</p>
<h4>Controls</h4>
<table style="min-width:75px"><colgroup><col style="min-width:25px"></col><col style="min-width:25px"></col><col style="min-width:25px"></col></colgroup><tbody><tr><th><p>Key</p></th><th><p>Action</p></th><th><p>Description</p></th></tr><tr><td><p><strong>Arrow Keys</strong></p></td><td><p><strong>Pan</strong></p></td><td><p>Modifies uv_offset. The texture slides across the surface. Because we set AddressMode::Repeat, it never ends.</p></td></tr><tr><td><p><strong>W / S</strong></p></td><td><p><strong>Zoom</strong></p></td><td><p>Modifies uv_scale. Increasing scale makes the tiles smaller (more repetitions). Decreasing it zooms in.</p></td></tr><tr><td><p><strong>Space</strong></p></td><td><p><strong>Reset</strong></p></td><td><p>Resets scale to 1.0 and offset to 0.0.</p></td></tr></tbody></table>

<h4>What You're Seeing</h4>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764928281912/23138a27-c4f3-4dcd-ba99-5f7cf77b803d.png" alt="" />

<ol>
<li><p><strong>Sampler Power</strong>: Even though our procedural texture is tiny (256x256), it looks sharp because of mag_filter: Nearest. If we changed it to Linear, the edges of the checks would be blurry.</p>
</li>
<li><p><strong>Addressing</strong>: As you scroll with the arrow keys, notice how the pattern repeats seamlessly. This is the ImageAddressMode::Repeat setting doing the heavy lifting.</p>
</li>
<li><p><strong>Vertex Shader Efficiency</strong>: We are doing the scrolling math in the vertex shader. Since our plane has only 4 vertices, we only run those additions/multiplications 4 times per frame! The GPU interpolates the result for the thousands of pixels in between.</p>
</li>
</ol>
<h2>Key Takeaways</h2>
<ol>
<li><p><strong>Textures ≠ Samplers</strong>: A texture is raw data. A sampler is the set of rules for reading it. They are separate resources in WGSL (texture_2d vs sampler).</p>
</li>
<li><p><strong>Fragment Only</strong>: You cannot use textureSample() in a vertex shader. You must calculate UVs in the vertex shader and pass them to the fragment shader.</p>
</li>
<li><p><strong>Address Modes Matter</strong>: If you want a texture to tile, you must configure the sampler to Repeat.</p>
</li>
<li><p><strong>UV Math</strong>: Tiling is multiplication (uv * 2.0). Scrolling is addition (uv + offset).</p>
</li>
</ol>
<h2>What's Next?</h2>
<p>We've mastered the single texture. But real materials are rarely just one image. They are composites - a blend of base colors, detail maps, dirt layers, and decals.</p>
<p>In the next article, we will learn how to combine multiple textures, use grayscale textures as masks, and layer effects together.</p>
<p><em>Next up:</em> <a href="https://blog.hexbee.net/4-2-texture-filtering-and-mipmapping"><em>4.2 - Texture Filtering and Mipmapping</em></a></p>
<hr />
<h2>Quick Reference</h2>
<p><strong>WGSL Texture Declaration</strong>:</p>
<pre><code class="language-rust">@group(1) @binding(0) var my_tex: texture_2d&lt;f32&gt;;
@group(1) @binding(1) var my_samp: sampler;
</code></pre>
<p><strong>Sampling</strong>:</p>
<pre><code class="language-rust">let color = textureSample(my_tex, my_samp, uv);
</code></pre>
<p><strong>Common UV Math</strong>:</p>
<pre><code class="language-rust">let tiled_uv = uv * 5.0;         // Repeat 5 times
let scrolled_uv = uv + time;     // Move diagonally
let centered_uv = uv - 0.5;      // Move origin to center
</code></pre>
]]></content:encoded></item><item><title><![CDATA[3.8 - Advanced Color Techniques]]></title><description><![CDATA[What We're Learning
Color is one of the most powerful tools in your graphics arsenal. So far, we've worked with basic color operations - sampling textures, mixing colors, and applying simple lighting. But there's a whole world of advanced color manip...]]></description><link>https://blog.hexbee.net/38-advanced-color-techniques</link><guid isPermaLink="true">https://blog.hexbee.net/38-advanced-color-techniques</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Sun, 08 Feb 2026 12:15:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1764522260408/81b871b8-7271-4e14-9313-cb890a280529.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>Color is one of the most powerful tools in your graphics arsenal. So far, we've worked with basic color operations - sampling textures, mixing colors, and applying simple lighting. But there's a whole world of advanced color manipulation techniques that can transform the look and feel of your renders from "functional" to "cinematic."</p>
<p>Think about photo editing apps or professional color grading tools in film production. These effects aren't magic - they are careful mathematical manipulations of color values. The same techniques used in Hollywood color grading suites can be implemented in real-time fragment shaders.</p>
<p>The difference between basic and advanced color work is like the difference between painting with a single brush and having a full artist's toolkit. Basic operations get you pixels on the screen. Advanced techniques give you creative control - the ability to establish mood, guide attention, create visual coherence, and evoke specific emotions.</p>
<p>By the end of this article, you'll understand:</p>
<ul>
<li><p><strong>Color Correction Fundamentals</strong>: How to mathematically adjust brightness, contrast, saturation, and gamma.</p>
</li>
<li><p><strong>Color Grading with LUTs</strong>: Using 3D Lookup Tables to apply complex cinematic looks cheaply.</p>
</li>
<li><p><strong>Stylized Effects</strong>: Implementing posterization (quantization) for retro or comic-book aesthetics.</p>
</li>
<li><p><strong>Channel Manipulation</strong>: How to isolate and shift specific color channels.</p>
</li>
<li><p><strong>Lens Simulation</strong>: Creating chromatic aberration (color fringing) to simulate physical lens imperfections.</p>
</li>
<li><p><strong>Edge Detection</strong>: Using the <a target="_blank" href="https://en.wikipedia.org/wiki/Sobel_operator">Sobel operator</a> to find outlines based on color differences.</p>
</li>
<li><p><strong>Custom Blend Modes</strong>: Implementing mathematical blends beyond standard alpha mixing (Overlay, Screen, Multiply).</p>
</li>
<li><p><strong>Post-Processing Preview</strong>: Building a complete "Instagram-style" filter system using a render-to-texture pipeline.</p>
</li>
</ul>
<h2 id="heading-color-correction-fundamentals">Color Correction Fundamentals</h2>
<p>Before diving into complex effects, let's master the core color adjustments. These are the "bread and butter" functions you will use constantly, whether you are tweaking a specific material or grading an entire scene.</p>
<h3 id="heading-understanding-color-spaces">Understanding Color Spaces</h3>
<p>Most of our shader math happens in <strong>RGB</strong> space, but understanding how to navigate other representations makes specific tasks much easier.</p>
<ol>
<li><p><strong>RGB (Red, Green, Blue)</strong>:</p>
<ul>
<li><p><strong>Hardware Native</strong>: This is how monitors display light.</p>
</li>
<li><p><strong>Additive</strong>: 100% Red + 100% Green = Yellow.</p>
</li>
<li><p><strong>Good for</strong>: Technical operations, texture storage, and lighting math.</p>
</li>
<li><p><strong>Bad for</strong>: "Make this 10% brighter" or "Shift the hue slightly."</p>
</li>
</ul>
</li>
<li><p><strong>HSV (Hue, Saturation, Value)</strong>:</p>
<ul>
<li><p><strong>Perceptual</strong>: Modeled after how humans describe color.</p>
</li>
<li><p><strong>Cylindrical</strong>: Hue is an angle (0-360°), Saturation is a radius, Value is height.</p>
</li>
<li><p><strong>Good for</strong>: "Make this color more vivid" (Saturation) or "Change this red to blue" (Hue).</p>
</li>
<li><p><strong>Bad for</strong>: Lighting calculations.</p>
</li>
</ul>
</li>
</ol>
<p>We will often convert RGB to HSV, modify it, and convert back to RGB.</p>
<h3 id="heading-brightness-adjustment">Brightness Adjustment</h3>
<p>The simplest correction is adding or subtracting light.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">adjust_brightness</span></span>(color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, amount: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// amount: -1.0 (black) to 1.0 (white)</span>
    <span class="hljs-keyword">return</span> color + amount;
}
</code></pre>
<p><strong>Key Insight</strong>: This is a linear offset. It shifts the entire histogram. While simple, it can wash out blacks (if positive) or crush whites (if negative).</p>
<p><strong>Safety First</strong>: Color operations can easily push values below <code>0.0</code> or above <code>1.0</code>. In a <code>StandardMaterial</code> pipeline, values <code>&gt; 1.0</code> create "bloom" (glow). If that isn't intended, always clamp your final result.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">adjust_brightness_safe</span></span>(color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, amount: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> clamp(color + amount, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>), vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>));
}
</code></pre>
<h3 id="heading-contrast-adjustment">Contrast Adjustment</h3>
<p>Contrast defines the separation between light and dark values. High contrast makes darks darker and lights lighter. Low contrast makes everything gray.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">adjust_contrast</span></span>(color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, contrast: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// contrast = 1.0: Neutral</span>
    <span class="hljs-comment">// contrast &gt; 1.0: High Contrast</span>
    <span class="hljs-comment">// contrast &lt; 1.0: Low Contrast</span>

    <span class="hljs-comment">// We scale the color relative to "Middle Gray" (0.5)</span>
    <span class="hljs-keyword">let</span> midpoint = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>);

    <span class="hljs-keyword">return</span> (color - midpoint) * contrast + midpoint;
}
</code></pre>
<p><strong>Visualizing the Math</strong>:<br />Imagine a color value of 0.6 (slightly bright).</p>
<ul>
<li><p>Subtract 0.5 → 0.1</p>
</li>
<li><p>Multiply by 2.0 (High Contrast) → 0.2</p>
</li>
<li><p>Add 0.5 → 0.7<br />  The value moved from 0.6 to 0.7 (brighter).</p>
</li>
</ul>
<p>Imagine a color value of 0.4 (slightly dark).</p>
<ul>
<li><p>Subtract 0.5 → -0.1</p>
</li>
<li><p>Multiply by 2.0 → -0.2</p>
</li>
<li><p>Add 0.5 → 0.3<br />  The value moved from 0.4 to 0.3 (darker).</p>
</li>
</ul>
<h3 id="heading-saturation-adjustment">Saturation Adjustment</h3>
<p>Saturation controls the intensity of the color. To adjust it, we interpolate between the original color and a grayscale version of itself.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">adjust_saturation</span></span>(color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, saturation: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 0.0 = Grayscale</span>
    <span class="hljs-comment">// 1.0 = Neutral</span>
    <span class="hljs-comment">// &gt;1.0 = Vivid</span>

    <span class="hljs-comment">// 1. Calculate Luminance (Perceived Brightness)</span>
    <span class="hljs-comment">// We use the Rec. 709 luma coefficients (modern standard for sRGB)</span>
    <span class="hljs-comment">// Green contributes most to brightness, Blue contributes least.</span>
    <span class="hljs-keyword">let</span> luminance = dot(color, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.2126</span>, <span class="hljs-number">0.7152</span>, <span class="hljs-number">0.0722</span>));
    <span class="hljs-keyword">let</span> grayscale = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(luminance);

    <span class="hljs-comment">// 2. Mix between gray and color</span>
    <span class="hljs-keyword">return</span> mix(grayscale, color, saturation);
}
</code></pre>
<blockquote>
<p><strong>Note on Coefficients</strong>: You might see <code>0.299, 0.587, 0.114</code> in older tutorials. Those are for SDTV (<a target="_blank" href="https://fr.wikipedia.org/wiki/Rec._601">Rec. 601</a>). Since Bevy and modern computing use sRGB/<a target="_blank" href="https://en.wikipedia.org/wiki/Rec._709">Rec. 709</a>, <code>0.2126, 0.7152, 0.0722</code> is more mathematically accurate, though the visual difference is subtle.</p>
</blockquote>
<h3 id="heading-gamma-adjustment-midtones">Gamma Adjustment (Midtones)</h3>
<p>Gamma allows you to brighten or darken the image without crushing the blacks or blowing out the whites. It specifically targets the <strong>midtones</strong>.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">adjust_gamma</span></span>(color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, gamma: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// gamma = 1.0: Neutral</span>
    <span class="hljs-comment">// gamma &gt; 1.0: Brightens midtones</span>
    <span class="hljs-comment">// gamma &lt; 1.0: Darkens midtones (<span class="hljs-doctag">NOTE:</span> This convention varies!)</span>

    <span class="hljs-comment">// Bevy note: Standard sRGB decoding uses a power of 2.2.</span>
    <span class="hljs-comment">// For artistic control, we often simply use power.</span>
    <span class="hljs-keyword">return</span> pow(color, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span> / gamma));
}
</code></pre>
<h3 id="heading-combined-pipeline">Combined Pipeline</h3>
<p>Order of operations matters. A standard color grading pipeline usually applies operations in this order to mimic how film is processed:</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">color_correct</span></span>(
    color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    brightness: <span class="hljs-built_in">f32</span>,
    contrast: <span class="hljs-built_in">f32</span>,
    saturation: <span class="hljs-built_in">f32</span>,
    gamma: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    var result = color;

    <span class="hljs-comment">// 1. Apply Gamma/Midtones first (shaping the curve)</span>
    result = adjust_gamma(result, gamma);

    <span class="hljs-comment">// 2. Apply Brightness (offsetting the curve)</span>
    result = result + brightness;

    <span class="hljs-comment">// 3. Apply Contrast (expanding the curve around center)</span>
    <span class="hljs-comment">// Doing this after brightness allows you to contrast-stretch the brightened image</span>
    <span class="hljs-keyword">let</span> midpoint = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>);
    result = (result - midpoint) * contrast + midpoint;

    <span class="hljs-comment">// 4. Apply Saturation (final polish)</span>
    <span class="hljs-keyword">let</span> luminance = dot(result, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.2126</span>, <span class="hljs-number">0.7152</span>, <span class="hljs-number">0.0722</span>));
    result = mix(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(luminance), result, saturation);

    <span class="hljs-keyword">return</span> clamp(result, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>), vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>));
}
</code></pre>
<h2 id="heading-color-grading-with-lookup-tables-luts">Color Grading with Lookup Tables (LUTs)</h2>
<p>While mathematical formulas are great for basic adjustments, creating a complex, artistic "film look" (like the famous "Teal and Orange" blockbuster style) purely with math is incredibly difficult.</p>
<p>Enter the <strong>Lookup Table (LUT)</strong>. A LUT is essentially a translation dictionary for color.</p>
<h3 id="heading-what-is-a-lut">What is a LUT?</h3>
<p>Imagine a function f(color) -&gt; new_color. A LUT pre-calculates this function for every possible color (or a representative sample of them) and stores the result in a table.</p>
<ul>
<li><p><strong>Input</strong>: The original pixel color (Red, Green, Blue).</p>
</li>
<li><p><strong>Lookup</strong>: The shader uses the Input RGB values as <strong>coordinates</strong> to look up a position in the table.</p>
</li>
<li><p><strong>Output</strong>: The color found at that position.</p>
</li>
</ul>
<p>This allows artists to use tools like Photoshop or DaVinci Resolve to grade a screenshot, export that grade as a .cube or texture file, and have the game engine replicate that exact look in real-time.</p>
<h3 id="heading-the-3d-texture-approach">The 3D Texture approach</h3>
<p>In modern graphics, we typically use <strong>3D LUTs</strong>.<br />Think of a 3D LUT as a cube of colors:</p>
<ul>
<li><p><strong>X-axis</strong> represents Red (0.0 to 1.0).</p>
</li>
<li><p><strong>Y-axis</strong> represents Green (0.0 to 1.0).</p>
</li>
<li><p><strong>Z-axis</strong> represents Blue (0.0 to 1.0).</p>
</li>
</ul>
<p>To use a LUT, we simply take our pixel's color and use it as the <code>(u, v, w)</code> texture coordinates to sample this 3D texture.</p>
<h3 id="heading-applying-a-3d-lut-in-wgsl">Applying a 3D LUT in WGSL</h3>
<p>The WGSL implementation is surprisingly simple because the hardware does the heavy lifting (interpolation) for us.</p>
<pre><code class="lang-rust">@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">1</span>) var lut_texture: texture_3d&lt;<span class="hljs-built_in">f32</span>&gt;;
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">2</span>) var lut_sampler: sampler;

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_lut</span></span>(original_color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, strength: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Clamp input to valid texture coordinate range [0.0, 1.0]</span>
    <span class="hljs-comment">// If we don't clamp, bright values might wrap around or sample invalid data.</span>
    <span class="hljs-keyword">let</span> lut_coords = clamp(original_color, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>), vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>));

    <span class="hljs-comment">// 2. Sample the 3D texture</span>
    <span class="hljs-comment">// The GPU automatically interpolates between the stored colors</span>
    <span class="hljs-comment">// if our specific color falls between the grid points.</span>
    <span class="hljs-keyword">let</span> graded_color = textureSample(lut_texture, lut_sampler, lut_coords).rgb;

    <span class="hljs-comment">// 3. Mix based on strength (Intensity slider)</span>
    <span class="hljs-keyword">return</span> mix(original_color, graded_color, strength);
}
</code></pre>
<h3 id="heading-generating-a-lut-in-rust">Generating a LUT in Rust</h3>
<p>While you normally load LUTs from assets, generating one programmatically is a great way to understand the data structure. Here is how to create a "Identity LUT" (one that maps every color to itself) in Bevy. You can then modify the math inside the loop to bake complex effects into the texture.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_asset::RenderAssetUsages;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">generate_identity_lut</span></span>(size: <span class="hljs-built_in">u32</span>) -&gt; Image {
    <span class="hljs-comment">// Standard sizes: 16, 32, or 64. </span>
    <span class="hljs-comment">// 32 is a good balance of quality vs memory (32x32x32 pixels).</span>
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> data = <span class="hljs-built_in">Vec</span>::with_capacity((size * size * size * <span class="hljs-number">4</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">usize</span>);

    <span class="hljs-keyword">for</span> z <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..size {
        <span class="hljs-keyword">for</span> y <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..size {
            <span class="hljs-keyword">for</span> x <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..size {
                <span class="hljs-comment">// Normalize coordinates to 0.0 - 1.0 range</span>
                <span class="hljs-keyword">let</span> r = x <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / (size - <span class="hljs-number">1</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span>;
                <span class="hljs-keyword">let</span> g = y <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / (size - <span class="hljs-number">1</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span>;
                <span class="hljs-keyword">let</span> b = z <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / (size - <span class="hljs-number">1</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span>;

                <span class="hljs-comment">// Store the color. For an identity LUT, the Color IS the Coordinate.</span>
                <span class="hljs-comment">// To make a "Sepia" LUT, you would do math on r,g,b here before storing.</span>
                data.push((r * <span class="hljs-number">255.0</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">u8</span>);
                data.push((g * <span class="hljs-number">255.0</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">u8</span>);
                data.push((b * <span class="hljs-number">255.0</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">u8</span>);
                data.push(<span class="hljs-number">255</span>); <span class="hljs-comment">// Alpha</span>
            }
        }
    }

    Image::new(
        Extent3d {
            width: size,
            height: size,
            depth_or_array_layers: size, <span class="hljs-comment">// Depth is the Blue axis</span>
        },
        TextureDimension::D3,
        data,
        TextureFormat::Rgba8Unorm, <span class="hljs-comment">// Standard 8-bit color</span>
        RenderAssetUsages::default(),
    )
}
</code></pre>
<h2 id="heading-stylized-effects-posterization-amp-quantization">Stylized Effects: Posterization &amp; Quantization</h2>
<p>Sometimes you don't want realism - you want a style. <strong>Posterization</strong> (or color quantization) reduces the continuous spectrum of colors into discrete "bands" or steps. This mimics the look of old EGA graphics, comic books, or silk-screen posters.</p>
<h3 id="heading-basic-posterization">Basic Posterization</h3>
<p>The math relies on <code>floor()</code> to snap values to the nearest "step."</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">posterize</span></span>(color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, steps: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// steps: Number of color bands (e.g., 4.0, 8.0, 16.0)</span>

    <span class="hljs-comment">// 1. Scale up (0.0 - 1.0 becomes 0.0 - 4.0)</span>
    <span class="hljs-comment">// 2. Floor (0.0, 1.0, 2.0, 3.0, 4.0)</span>
    <span class="hljs-comment">// 3. Scale down (0.0, 0.25, 0.50, 0.75, 1.0)</span>
    <span class="hljs-keyword">return</span> floor(color * steps) / steps;
}
</code></pre>
<h3 id="heading-dithered-posterization">Dithered Posterization</h3>
<p>The problem with simple posterization is "banding" - large, flat areas of single color that look artificial. <strong>Dithering</strong> adds noise before quantization to break up these edges, creating the illusion of more colors through pixel patterns.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A pseudo-random hash function for noise</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">return</span> fract(sin(dot(uv, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">12.9898</span>, <span class="hljs-number">78.233</span>))) * <span class="hljs-number">43758.5453</span>);
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">posterize_dithered</span></span>(
    color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, 
    steps: <span class="hljs-built_in">f32</span>, 
    uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    dither_strength: <span class="hljs-built_in">f32</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Generate noise between -0.5 and 0.5</span>
    <span class="hljs-keyword">let</span> noise = (hash(uv) - <span class="hljs-number">0.5</span>) * dither_strength;

    <span class="hljs-comment">// Add noise BEFORE quantization</span>
    <span class="hljs-keyword">let</span> noisy_color = color + noise;

    <span class="hljs-keyword">return</span> floor(noisy_color * steps) / steps;
}
</code></pre>
<h2 id="heading-chromatic-aberration">Chromatic Aberration</h2>
<p>Chromatic Aberration is an optical defect where a lens fails to focus all colors to the same convergence point. It manifests as red and blue color fringes along high-contrast edges, especially near the corners of the image.</p>
<p>In games, we add it intentionally to make the camera feel "physical" and imperfect, adding realism or unease.</p>
<h3 id="heading-the-technique">The Technique</h3>
<p>Instead of sampling the texture once at <code>uv</code>, we sample it <strong>three times</strong> - once for each color channel - with slightly different coordinates.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">chromatic_aberration</span></span>(
    base_texture: texture_2d&lt;<span class="hljs-built_in">f32</span>&gt;,
    base_sampler: sampler,
    uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    strength: <span class="hljs-built_in">f32</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Calculate direction from center</span>
    <span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>);
    <span class="hljs-keyword">let</span> dist_vector = uv - center;

    <span class="hljs-comment">// 2. Calculate distortion amount</span>
    <span class="hljs-comment">// Aberration is usually stronger at the edges (squared distance)</span>
    <span class="hljs-keyword">let</span> dist_sq = dot(dist_vector, dist_vector);
    <span class="hljs-keyword">let</span> offset = dist_vector * dist_sq * strength;

    <span class="hljs-comment">// 3. Sample channels separately</span>
    <span class="hljs-comment">// Red channel shifts OUTWARD</span>
    <span class="hljs-keyword">let</span> r = textureSample(base_texture, base_sampler, uv + offset).r;

    <span class="hljs-comment">// Green channel stays centered (or shifts slightly)</span>
    <span class="hljs-keyword">let</span> g = textureSample(base_texture, base_sampler, uv).g;

    <span class="hljs-comment">// Blue channel shifts INWARD</span>
    <span class="hljs-keyword">let</span> b = textureSample(base_texture, base_sampler, uv - offset).b;

    <span class="hljs-keyword">return</span> vec3&lt;<span class="hljs-built_in">f32</span>&gt;(r, g, b);
}
</code></pre>
<h2 id="heading-edge-detection-with-sobel">Edge Detection with Sobel</h2>
<p>Edge detection is fundamental for outline shaders (toon shading) and image analysis. We use the <strong>Sobel Operator</strong>, which looks at a pixel's neighbors to determine if there is a sudden change in brightness.</p>
<p>The Sobel operator uses two "kernels" (grids) to measure the gradient (change) in the X and Y directions.</p>
<h3 id="heading-sobel-implementation">Sobel Implementation</h3>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_luminance</span></span>(color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">return</span> dot(color, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.2126</span>, <span class="hljs-number">0.7152</span>, <span class="hljs-number">0.0722</span>));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sobel_edge_detection</span></span>(
    tex: texture_2d&lt;<span class="hljs-built_in">f32</span>&gt;,
    samp: sampler,
    uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    resolution: vec2&lt;<span class="hljs-built_in">f32</span>&gt; <span class="hljs-comment">// Screen dimensions in pixels</span>
) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> step = <span class="hljs-number">1.0</span> / resolution; <span class="hljs-comment">// Size of one pixel in UV space</span>

    <span class="hljs-comment">// Sample the 8 neighbors + center</span>
    <span class="hljs-comment">// We only need luminance for simple edge detection</span>
    <span class="hljs-keyword">let</span> tl = get_luminance(textureSample(tex, samp, uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(-step.x, -step.y)).rgb);
    <span class="hljs-keyword">let</span> t  = get_luminance(textureSample(tex, samp, uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;( <span class="hljs-number">0.0</span>,    -step.y)).rgb);
    <span class="hljs-keyword">let</span> tr = get_luminance(textureSample(tex, samp, uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;( step.x, -step.y)).rgb);

    <span class="hljs-keyword">let</span> l  = get_luminance(textureSample(tex, samp, uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(-step.x,  <span class="hljs-number">0.0</span>)).rgb);
    <span class="hljs-keyword">let</span> r  = get_luminance(textureSample(tex, samp, uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;( step.x,  <span class="hljs-number">0.0</span>)).rgb);

    <span class="hljs-keyword">let</span> bl = get_luminance(textureSample(tex, samp, uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(-step.x,  step.y)).rgb);
    <span class="hljs-keyword">let</span> b  = get_luminance(textureSample(tex, samp, uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;( <span class="hljs-number">0.0</span>,     step.y)).rgb);
    <span class="hljs-keyword">let</span> br = get_luminance(textureSample(tex, samp, uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;( step.x,  step.y)).rgb);

    <span class="hljs-comment">// Sobel Kernels</span>
    <span class="hljs-comment">// Horizontal (Gx)     Vertical (Gy)</span>
    <span class="hljs-comment">// -1  0  1           -1 -2 -1</span>
    <span class="hljs-comment">// -2  0  2            0  0  0</span>
    <span class="hljs-comment">// -1  0  1            1  2  1</span>

    <span class="hljs-keyword">let</span> gx = (tr + <span class="hljs-number">2.0</span> * r + br) - (tl + <span class="hljs-number">2.0</span> * l + bl);
    <span class="hljs-keyword">let</span> gy = (bl + <span class="hljs-number">2.0</span> * b + br) - (tl + <span class="hljs-number">2.0</span> * t + tr);

    <span class="hljs-comment">// Magnitude of the gradient vector</span>
    <span class="hljs-comment">// High value = Strong Edge</span>
    <span class="hljs-keyword">return</span> sqrt(gx * gx + gy * gy);
}
</code></pre>
<p>If the return value is close to <code>0.0</code>, the area is flat. If it is <code>&gt; 0.5</code>, there is a sharp edge.</p>
<h2 id="heading-custom-blend-modes">Custom Blend Modes</h2>
<p>Finally, let's look at how to blend a filter color with the original image. Standard alpha blending <code>mix(a, b, alpha)</code> is boring. Photoshop-style blend modes offer more artistic control.</p>
<p>These functions take a <code>base</code> color (the image) and a <code>blend</code> color (the filter tint).</p>
<pre><code class="lang-rust"><span class="hljs-comment">// MULTIPLY: Darkens the image. Good for shadows or vignetting.</span>
<span class="hljs-comment">// Formula: A * B</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">blend_multiply</span></span>(base: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, blend: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> base * blend;
}

<span class="hljs-comment">// SCREEN: Lightens the image. Good for glows.</span>
<span class="hljs-comment">// Formula: 1 - (1-A) * (1-B)</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">blend_screen</span></span>(base: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, blend: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>) - (vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>) - base) * (vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>) - blend);
}

<span class="hljs-comment">// OVERLAY: Increases contrast. Combines Multiply (for darks) and Screen (for lights).</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">blend_overlay</span></span>(base: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, blend: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// If base is dark (&lt; 0.5), multiply. If bright, screen.</span>
    <span class="hljs-keyword">let</span> is_bright = step(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>), base);

    <span class="hljs-keyword">let</span> dark_mix = <span class="hljs-number">2.0</span> * base * blend;
    <span class="hljs-keyword">let</span> bright_mix = <span class="hljs-number">1.0</span> - <span class="hljs-number">2.0</span> * (<span class="hljs-number">1.0</span> - base) * (<span class="hljs-number">1.0</span> - blend);

    <span class="hljs-keyword">return</span> mix(dark_mix, bright_mix, is_bright);
}
</code></pre>
<hr />
<h2 id="heading-complete-example-instagram-style-filter-system">Complete Example: Instagram-Style Filter System</h2>
<p>To demonstrate these techniques, we will build a comprehensive "Photo Filter" application. This will include <strong>everything</strong> we discussed: manual color correction (brightness/contrast/saturation), artistic presets, chromatic aberration, posterization, and edge detection.</p>
<p><strong>Important Architecture Note</strong>:<br />To apply these effects to the entire scene at once, we need a technique called <strong>Render-to-Texture (RTT)</strong>.</p>
<ol>
<li><p>We create a special Image asset manually in Rust (our "film roll").</p>
</li>
<li><p>We tell our Main Camera (Layer 0) to render <strong>into that image</strong> instead of to the screen.</p>
</li>
<li><p>We spawn a second "Post-Processing Camera" (Layer 1).</p>
</li>
<li><p>We place a large flat rectangle in front of this second camera.</p>
</li>
<li><p>We apply our custom shader to this rectangle, using the Image from step 1 as a texture.</p>
</li>
</ol>
<p>This is a simplified version of how professional post-processing pipelines work!</p>
<h3 id="heading-the-shader-assetsshadersd0308photofilterwgsl">The Shader (<code>assets/shaders/d03_08_photo_filter.wgsl</code>)</h3>
<p>This shader integrates every concept from this article into a single, unified pipeline. We use linear math for the Chromatic Aberration to ensure the effect is clearly visible.</p>
<pre><code class="lang-rust">#import bevy_pbr::forward_io::VertexOutput

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">PhotoFilterMaterial</span></span> {
    <span class="hljs-comment">// 1. Manual Color Correction</span>
    brightness: <span class="hljs-built_in">f32</span>,
    contrast: <span class="hljs-built_in">f32</span>,
    saturation: <span class="hljs-built_in">f32</span>,

    <span class="hljs-comment">// 2. Artistic Presets</span>
    filter_mode: <span class="hljs-built_in">u32</span>,     <span class="hljs-comment">// 0=None, 1=Vintage, 2=Noir</span>
    filter_strength: <span class="hljs-built_in">f32</span>, <span class="hljs-comment">// Mix amount (0.0 to 1.0)</span>

    <span class="hljs-comment">// 3. Advanced Effects</span>
    aberration_strength: <span class="hljs-built_in">f32</span>, <span class="hljs-comment">// Offset amount</span>
    posterize_steps: <span class="hljs-built_in">f32</span>,     <span class="hljs-comment">// 0.0 = disabled</span>
    edge_show: <span class="hljs-built_in">u32</span>,           <span class="hljs-comment">// 0 = Off, 1 = On</span>

    _padding: <span class="hljs-built_in">f32</span>,            <span class="hljs-comment">// Alignment padding</span>
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>) var&lt;uniform&gt; material: PhotoFilterMaterial;
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">1</span>) var base_texture: texture_2d&lt;<span class="hljs-built_in">f32</span>&gt;;
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">2</span>) var base_sampler: sampler;

<span class="hljs-comment">// --- Helper Math ---</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_luminance</span></span>(color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// Rec. 709 coefficients</span>
    <span class="hljs-keyword">return</span> dot(color, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.2126</span>, <span class="hljs-number">0.7152</span>, <span class="hljs-number">0.0722</span>));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">adjust_contrast</span></span>(color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, contrast: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> (color - <span class="hljs-number">0.5</span>) * contrast + <span class="hljs-number">0.5</span>;
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">adjust_saturation</span></span>(color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, saturation: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> grey = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(get_luminance(color));
    <span class="hljs-keyword">return</span> mix(grey, color, saturation);
}

<span class="hljs-comment">// --- Effects ---</span>

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">chromatic_aberration</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, strength: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>);

    <span class="hljs-comment">// Linear falloff: Effect increases linearly towards edges</span>
    <span class="hljs-comment">// This makes the effect strong and clearly visible</span>
    <span class="hljs-keyword">let</span> offset = (uv - center) * strength;

    <span class="hljs-keyword">let</span> r = textureSample(base_texture, base_sampler, uv - offset).r;
    <span class="hljs-keyword">let</span> g = textureSample(base_texture, base_sampler, uv).g; <span class="hljs-comment">// Green is anchor</span>
    <span class="hljs-keyword">let</span> b = textureSample(base_texture, base_sampler, uv + offset).b;

    <span class="hljs-keyword">return</span> vec3&lt;<span class="hljs-built_in">f32</span>&gt;(r, g, b);
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">posterize</span></span>(color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, steps: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> floor(color * steps) / steps;
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sobel_edge_detection</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> dims = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(textureDimensions(base_texture));
    <span class="hljs-keyword">let</span> step = <span class="hljs-number">1.0</span> / dims;

    <span class="hljs-comment">// Simplified Sobel (Horizontal + Vertical)</span>
    <span class="hljs-keyword">let</span> t = get_luminance(textureSample(base_texture, base_sampler, uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, -step.y)).rgb);
    <span class="hljs-keyword">let</span> b = get_luminance(textureSample(base_texture, base_sampler, uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, step.y)).rgb);
    <span class="hljs-keyword">let</span> l = get_luminance(textureSample(base_texture, base_sampler, uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(-step.x, <span class="hljs-number">0.0</span>)).rgb);
    <span class="hljs-keyword">let</span> r = get_luminance(textureSample(base_texture, base_sampler, uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(step.x, <span class="hljs-number">0.0</span>)).rgb);

    <span class="hljs-keyword">let</span> gy = t - b;
    <span class="hljs-keyword">let</span> gx = l - r;

    <span class="hljs-keyword">return</span> sqrt(gx*gx + gy*gy);
}

<span class="hljs-comment">// --- Filter Presets ---</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">filter_vintage</span></span>(color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    var c = color;
    c = adjust_contrast(c, <span class="hljs-number">1.2</span>);
    c = adjust_saturation(c, <span class="hljs-number">0.6</span>);
    c *= vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.1</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.8</span>); <span class="hljs-comment">// Sepia tint</span>
    <span class="hljs-keyword">return</span> c;
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">filter_noir</span></span>(color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> lum = get_luminance(color);
    var c = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(lum);
    c = adjust_contrast(c, <span class="hljs-number">1.5</span>); <span class="hljs-comment">// High contrast B&amp;W</span>
    <span class="hljs-keyword">return</span> c;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Chromatic Aberration (modifies UV sampling)</span>
    var color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">if</span> material.aberration_strength &gt; <span class="hljs-number">0.0</span> {
        color = chromatic_aberration(<span class="hljs-keyword">in</span>.uv, material.aberration_strength);
    } <span class="hljs-keyword">else</span> {
        color = textureSample(base_texture, base_sampler, <span class="hljs-keyword">in</span>.uv).rgb;
    }

    <span class="hljs-comment">// 2. Manual Color Correction</span>
    color = color + material.brightness;
    color = adjust_contrast(color, material.contrast);
    color = adjust_saturation(color, material.saturation);

    <span class="hljs-comment">// 3. Filter Presets</span>
    <span class="hljs-keyword">if</span> material.filter_mode &gt; <span class="hljs-number">0</span>u {
        var filtered = color;
        switch material.filter_mode {
            case <span class="hljs-number">1</span>u: { filtered = filter_vintage(color); }
            case <span class="hljs-number">2</span>u: { filtered = filter_noir(color); }
            default: { filtered = color; }
        }
        color = mix(color, filtered, material.filter_strength);
    }

    <span class="hljs-comment">// 4. Posterization (Stylized)</span>
    <span class="hljs-keyword">if</span> material.posterize_steps &gt; <span class="hljs-number">0.0</span> {
        color = posterize(color, material.posterize_steps);
    }

    <span class="hljs-comment">// 5. Edge Detection Overlay</span>
    <span class="hljs-keyword">if</span> material.edge_show &gt; <span class="hljs-number">0</span>u {
        <span class="hljs-keyword">let</span> edge = sobel_edge_detection(<span class="hljs-keyword">in</span>.uv);
        <span class="hljs-comment">// Green edges overlaid on top</span>
        color = mix(color, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>), edge * <span class="hljs-number">5.0</span>);
    }

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0308photofilterrs">The Rust Material (<code>src/materials/d03_08_photo_filter.rs</code>)</h3>
<p>We map our Rust struct to the WGSL layout.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">PhotoFilterMaterial</span></span> {
    <span class="hljs-comment">// Manual Correction</span>
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> brightness: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> contrast: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> saturation: <span class="hljs-built_in">f32</span>,

    <span class="hljs-comment">// Artistic Presets</span>
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> filter_mode: <span class="hljs-built_in">u32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> filter_strength: <span class="hljs-built_in">f32</span>,

    <span class="hljs-comment">// Advanced Effects</span>
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> aberration_strength: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> posterize_steps: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> edge_show: <span class="hljs-built_in">u32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> _padding: <span class="hljs-built_in">f32</span>,

    <span class="hljs-comment">// The Render Target</span>
    <span class="hljs-meta">#[texture(1)]</span>
    <span class="hljs-meta">#[sampler(2)]</span>
    <span class="hljs-keyword">pub</span> base_texture: Handle&lt;Image&gt;,
}

<span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> PhotoFilterMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
        <span class="hljs-keyword">Self</span> {
            brightness: <span class="hljs-number">0.0</span>,
            contrast: <span class="hljs-number">1.0</span>,
            saturation: <span class="hljs-number">1.0</span>,
            filter_mode: <span class="hljs-number">0</span>,
            filter_strength: <span class="hljs-number">1.0</span>,
            aberration_strength: <span class="hljs-number">0.0</span>,
            posterize_steps: <span class="hljs-number">0.0</span>,
            edge_show: <span class="hljs-number">0</span>,
            _padding: <span class="hljs-number">0.0</span>,
            base_texture: Handle::default(),
        }
    }
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> PhotoFilterMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d03_08_photo_filter.wgsl"</span>.into()
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_08_photo_filter;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0308photofilterrs">The Demo Module (<code>src/demos/d03_08_photo_filter.rs</code>)</h3>
<p>We've added an OrbitCamera system so you can rotate around the scene using the <strong>Left/Right Arrow keys</strong>. We also use an <strong>Orthographic Projection</strong> for the Post-Processing camera to ensure our filter quad fills the window perfectly.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d03_08_photo_filter::PhotoFilterMaterial;
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::camera::{RenderTarget, ScalingMode};
<span class="hljs-keyword">use</span> bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat, TextureUsages};
<span class="hljs-keyword">use</span> bevy::render::view::RenderLayers;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;PhotoFilterMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (rotate_objects, orbit_camera, handle_input, update_ui),
        )
        .run();
}

<span class="hljs-meta">#[derive(Resource)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">FilterState</span></span> {
    material_handle: Handle&lt;PhotoFilterMaterial&gt;,
}

<span class="hljs-meta">#[derive(Component)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">Rotator</span></span>;

<span class="hljs-meta">#[derive(Component)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">OrbitCamera</span></span> {
    radius: <span class="hljs-built_in">f32</span>,
    angle: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;PhotoFilterMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> standard_materials: ResMut&lt;Assets&lt;StandardMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> images: ResMut&lt;Assets&lt;Image&gt;&gt;,
) {
    <span class="hljs-comment">// 1. Setup Render Target (High Res for quality)</span>
    <span class="hljs-keyword">let</span> size = Extent3d {
        width: <span class="hljs-number">1920</span>,
        height: <span class="hljs-number">1080</span>,
        ..default()
    };

    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> image = Image {
        texture_descriptor: bevy::render::render_resource::TextureDescriptor {
            label: <span class="hljs-literal">Some</span>(<span class="hljs-string">"render_target"</span>),
            size,
            dimension: TextureDimension::D2,
            format: TextureFormat::Rgba8UnormSrgb,
            mip_level_count: <span class="hljs-number">1</span>,
            sample_count: <span class="hljs-number">1</span>,
            usage: TextureUsages::TEXTURE_BINDING
                | TextureUsages::COPY_DST
                | TextureUsages::RENDER_ATTACHMENT,
            view_formats: &amp;[],
        },
        ..default()
    };
    image.resize(size);
    <span class="hljs-keyword">let</span> image_handle = images.add(image);

    <span class="hljs-comment">// 2. Scene Camera (Renders to the Image, Layer 0)</span>
    <span class="hljs-comment">// We attach our OrbitCamera component here</span>
    commands.spawn((
        Camera3d::default(),
        Camera {
            target: RenderTarget::Image(image_handle.clone().into()),
            ..default()
        },
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.5</span>, <span class="hljs-number">3.5</span>).looking_at(Vec3::ZERO, Vec3::Y),
        RenderLayers::layer(<span class="hljs-number">0</span>),
        OrbitCamera {
            radius: <span class="hljs-number">3.5</span>,
            angle: <span class="hljs-number">0.0</span>,
        },
    ));

    <span class="hljs-comment">// 3. Post-Process Camera (Renders to Screen, Layer 1)</span>
    <span class="hljs-comment">// We use Orthographic to make filling the screen easy.</span>
    commands.spawn((
        Camera3d::default(),
        Projection::Orthographic(OrthographicProjection {
            scaling_mode: ScalingMode::FixedVertical {
                viewport_height: <span class="hljs-number">1.0</span>,
            },
            ..OrthographicProjection::default_3d()
        }),
        Camera {
            order: <span class="hljs-number">1</span>, <span class="hljs-comment">// Run after scene camera</span>
            ..default()
        },
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">5.0</span>).looking_at(Vec3::ZERO, Vec3::Y),
        RenderLayers::layer(<span class="hljs-number">1</span>),
    ));

    <span class="hljs-comment">// 4. The Screen Quad (Layer 1)</span>
    <span class="hljs-comment">// Height 1.0 matches Camera height. Width 16/9 matches render target aspect.</span>
    <span class="hljs-keyword">let</span> filter_mat = materials.add(PhotoFilterMaterial {
        base_texture: image_handle,
        ..default()
    });

    commands.spawn((
        Mesh3d(meshes.add(Rectangle::new(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>))),
        MeshMaterial3d(filter_mat.clone()),
        Transform::from_scale(Vec3::new(<span class="hljs-number">16.0</span> / <span class="hljs-number">9.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>)),
        RenderLayers::layer(<span class="hljs-number">1</span>),
    ));

    commands.insert_resource(FilterState {
        material_handle: filter_mat,
    });

    <span class="hljs-comment">// 5. The Scene Content (Layer 0)</span>
    commands.spawn((
        Mesh3d(meshes.add(Cuboid::default())),
        MeshMaterial3d(standard_materials.add(Color::srgb(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.1</span>, <span class="hljs-number">0.1</span>))),
        Transform::from_xyz(-<span class="hljs-number">1.2</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        Rotator,
        RenderLayers::layer(<span class="hljs-number">0</span>),
    ));

    commands.spawn((
        Mesh3d(meshes.add(Sphere::default())),
        MeshMaterial3d(standard_materials.add(Color::srgb(<span class="hljs-number">0.1</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.1</span>))),
        Transform::from_xyz(<span class="hljs-number">1.2</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        Rotator,
        RenderLayers::layer(<span class="hljs-number">0</span>),
    ));

    commands.spawn((
        PointLight {
            intensity: <span class="hljs-number">2_000_000.0</span>,
            ..default()
        },
        Transform::from_xyz(<span class="hljs-number">2.0</span>, <span class="hljs-number">5.0</span>, <span class="hljs-number">3.0</span>),
        RenderLayers::layer(<span class="hljs-number">0</span>),
    ));

    <span class="hljs-comment">// UI</span>
    commands.spawn((
        Text::new(<span class="hljs-string">""</span>), <span class="hljs-comment">// Updated by system</span>
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">12.0</span>),
            left: Val::Px(<span class="hljs-number">12.0</span>),
            ..default()
        },
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rotate_objects</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Transform, With&lt;Rotator&gt;&gt;) {
    <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> t <span class="hljs-keyword">in</span> &amp;<span class="hljs-keyword">mut</span> query {
        t.rotate_y(time.delta_secs());
        t.rotate_x(time.delta_secs() * <span class="hljs-number">0.5</span>);
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">orbit_camera</span></span>(
    time: Res&lt;Time&gt;,
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    <span class="hljs-keyword">mut</span> query: Query&lt;(&amp;<span class="hljs-keyword">mut</span> Transform, &amp;<span class="hljs-keyword">mut</span> OrbitCamera)&gt;,
) {
    <span class="hljs-keyword">let</span> speed = <span class="hljs-number">2.0</span>;
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">mut</span> transform, <span class="hljs-keyword">mut</span> orbit) <span class="hljs-keyword">in</span> &amp;<span class="hljs-keyword">mut</span> query {
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowLeft) {
            orbit.angle -= speed * time.delta_secs();
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowRight) {
            orbit.angle += speed * time.delta_secs();
        }

        <span class="hljs-keyword">let</span> x = orbit.radius * orbit.angle.sin();
        <span class="hljs-keyword">let</span> z = orbit.radius * orbit.angle.cos();

        transform.translation = Vec3::new(x, <span class="hljs-number">1.5</span>, z);
        transform.look_at(Vec3::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>), Vec3::Y);
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    state: Res&lt;FilterState&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;PhotoFilterMaterial&gt;&gt;,
    time: Res&lt;Time&gt;,
) {
    <span class="hljs-keyword">let</span> dt = time.delta_secs() / <span class="hljs-number">2.0</span>;

    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>(mat) = materials.get_mut(&amp;state.material_handle) {
        <span class="hljs-comment">// Presets</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit1) {
            mat.filter_mode = <span class="hljs-number">0</span>;
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit2) {
            mat.filter_mode = <span class="hljs-number">1</span>;
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit3) {
            mat.filter_mode = <span class="hljs-number">2</span>;
        }

        <span class="hljs-comment">// Manual Color Corrections</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyQ) {
            mat.brightness -= dt;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyW) {
            mat.brightness += dt;
        }

        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyA) {
            mat.contrast -= dt;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyS) {
            mat.contrast += dt;
        }

        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyZ) {
            mat.saturation -= dt;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyX) {
            mat.saturation += dt;
        }

        <span class="hljs-comment">// Toggles</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyC) {
            <span class="hljs-comment">// 0.10 (10%) shift is a massive glitch effect!</span>
            mat.aberration_strength = <span class="hljs-keyword">if</span> mat.aberration_strength &gt; <span class="hljs-number">0.0</span> {
                <span class="hljs-number">0.0</span>
            } <span class="hljs-keyword">else</span> {
                <span class="hljs-number">0.10</span>
            };
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyP) {
            mat.posterize_steps = <span class="hljs-keyword">if</span> mat.posterize_steps &gt; <span class="hljs-number">0.0</span> { <span class="hljs-number">0.0</span> } <span class="hljs-keyword">else</span> { <span class="hljs-number">8.0</span> };
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyE) {
            mat.edge_show = <span class="hljs-keyword">if</span> mat.edge_show &gt; <span class="hljs-number">0</span> { <span class="hljs-number">0</span> } <span class="hljs-keyword">else</span> { <span class="hljs-number">1</span> };
        }

        <span class="hljs-comment">// Reset</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyR) {
            mat.brightness = <span class="hljs-number">0.0</span>;
            mat.contrast = <span class="hljs-number">1.0</span>;
            mat.saturation = <span class="hljs-number">1.0</span>;
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(
    state: Res&lt;FilterState&gt;,
    materials: Res&lt;Assets&lt;PhotoFilterMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> text_q: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;,
) {
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>(mat) = materials.get(&amp;state.material_handle) {
        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> &amp;<span class="hljs-keyword">mut</span> text_q {
            <span class="hljs-keyword">let</span> mode = <span class="hljs-keyword">match</span> mat.filter_mode {
                <span class="hljs-number">0</span> =&gt; <span class="hljs-string">"None"</span>,
                <span class="hljs-number">1</span> =&gt; <span class="hljs-string">"Vintage"</span>,
                _ =&gt; <span class="hljs-string">"Noir"</span>,
            };
            **text = <span class="hljs-built_in">format!</span>(
                <span class="hljs-string">"CONTROLS:\n\
                [Left/Right] Orbit Camera\n\
                [1-3] Preset: {}\n\
                [Q/W] Brightness: {:.2}\n\
                [A/S] Contrast:   {:.2}\n\
                [Z/X] Saturation: {:.2}\n\
                [C] Aberration:   {}\n\
                [P] Posterize:    {}\n\
                [E] Edge Detect:  {}\n\
                [R] Reset"</span>,
                mode,
                mat.brightness,
                mat.contrast,
                mat.saturation,
                <span class="hljs-keyword">if</span> mat.aberration_strength &gt; <span class="hljs-number">0.0</span> {
                    <span class="hljs-string">"ON"</span>
                } <span class="hljs-keyword">else</span> {
                    <span class="hljs-string">"OFF"</span>
                },
                <span class="hljs-keyword">if</span> mat.posterize_steps &gt; <span class="hljs-number">0.0</span> {
                    <span class="hljs-string">"ON"</span>
                } <span class="hljs-keyword">else</span> {
                    <span class="hljs-string">"OFF"</span>
                },
                <span class="hljs-keyword">if</span> mat.edge_show &gt; <span class="hljs-number">0</span> { <span class="hljs-string">"ON"</span> } <span class="hljs-keyword">else</span> { <span class="hljs-string">"OFF"</span> }
            );
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_08_photo_filter;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"3.8"</span>,
    title: <span class="hljs-string">"Advanced Color Techniques"</span>,
    run: demos::d03_08_photo_filter::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>When you run this demo, you essentially have a mini-Photoshop running in real-time. The Orthographic camera setup ensures the filter fills your window perfectly.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td></td><td></td><td></td></tr>
</thead>
<tbody>
<tr>
<td>Key</td><td>Action</td><td>Description</td></tr>
<tr>
<td><strong>Left / Right</strong></td><td>Orbit</td><td>Rotate the camera around the scene.</td></tr>
<tr>
<td><strong>1-3</strong></td><td>Presets</td><td>Switch between None, Vintage, and Noir.</td></tr>
<tr>
<td><strong>Q / W</strong></td><td>Brightness</td><td>Increase or Decrease Brightness.</td></tr>
<tr>
<td><strong>A / S</strong></td><td>Contrast</td><td>Increase or Decrease Contrast.</td></tr>
<tr>
<td><strong>Z / X</strong></td><td>Saturation</td><td>Increase or Decrease Saturation.</td></tr>
<tr>
<td><strong>C</strong></td><td>Aberration</td><td>Toggle Chromatic Aberration (Strong Glitch Effect).</td></tr>
<tr>
<td><strong>P</strong></td><td>Posterize</td><td>Toggle 8-bit color quantization.</td></tr>
<tr>
<td><strong>E</strong></td><td>Edge Detect</td><td>Toggle Sobel Edge Detection overlay.</td></tr>
<tr>
<td><strong>R</strong></td><td>Reset</td><td>Reset manual color adjustments.</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764526911848/f2873553-b6d4-45dd-bc88-992c65beaab0.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764526922677/6c246c65-d74c-472a-bcc5-f70b2aca3c24.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764526935279/5778da7c-8580-4557-b1ac-f687aecba652.png" alt class="image--center mx-auto" /></p>
<ol>
<li><p><strong>Orbit Controls</strong>: Use the arrows to fly around. This makes the <strong>Edge Detection</strong> effect much more obvious, as you can see the green outlines reacting dynamically to the changing silhouette of the cube and sphere.</p>
</li>
<li><p><strong>Full Pipeline</strong>: All adjustments happen sequentially in the shader. You can, for example, turn on "Vintage" mode, then manually boost the Contrast using <strong>A</strong>, and then add Chromatic Aberration with <strong>C</strong> for a very stylized "broken camera" look.</p>
</li>
<li><p><strong>Render Target</strong>: Notice the quality of the image. Because we set the render target to 1920x1080, the edges are crisp, even if your actual window is smaller (downsampled) or larger (upsampled).</p>
</li>
</ol>
<hr />
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<ol>
<li><p><strong>Color Space Mastery</strong>: Knowing when to use RGB vs. Luminance vs. HSV is half the battle.</p>
</li>
<li><p><strong>Order Matters</strong>: Applying contrast before saturation yields different results than saturation before contrast. A standard pipeline is Gamma → Exposure → Contrast → Saturation.</p>
</li>
<li><p><strong>Render-To-Texture</strong>: To apply effects to the whole screen (like Chromatic Aberration), you need to capture the scene into a texture first.</p>
</li>
<li><p><strong>Sampling Tricks</strong>: Many cool effects (Aberration, Blur, Edge Detection) come from simply sampling the texture multiple times at slightly different coordinates.</p>
</li>
<li><p><strong>Artistic Control</strong>: Shaders aren't just for realism. Tools like Posterization and LUTs allow you to define a unique visual identity for your game.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>We've mastered color. Now it's time to master surface detail. In the next article, we will explore how to make flat surfaces look bumpy, cracked, and detailed without adding a single polygon.</p>
<p>Next up: <a target="_blank" href="https://blog.hexbee.net/41-texture-sampling-basics"><strong>4.1 - Texture Sampling Basics</strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<p><strong>Chromatic Aberration (Linear)</strong>:</p>
<pre><code class="lang-plaintext">let offset = (uv - 0.5) * strength;
let red    = textureSample(tex, s, uv - offset).r;
let blue   = textureSample(tex, s, uv + offset).b;
</code></pre>
<p><strong>Luminance (Rec. 709)</strong>:</p>
<pre><code class="lang-plaintext">let lum = dot(color, vec3&lt;f32&gt;(0.2126, 0.7152, 0.0722));
</code></pre>
<p><strong>Sobel Edge Logic</strong>:</p>
<pre><code class="lang-plaintext">// Sample neighbors, calculate gradient magnitude
let gx = (top_right + 2*right + bot_right) - (top_left + 2*left + bot_left);
let gy = (bot_left + 2*bot + bot_right) - (top_left + 2*top + top_right);
let edge = sqrt(gx*gx + gy*gy);
</code></pre>
]]></content:encoded></item><item><title><![CDATA[3.7 - Fragment Discard and Transparency]]></title><description><![CDATA[What We're Learning
So far in this series, every fragment we've processed has been drawn to the screen. We've colored them, textured them, and lit them - but we've always drawn them. What if you don't want to draw certain fragments at all? What if yo...]]></description><link>https://blog.hexbee.net/37-fragment-discard-and-transparency</link><guid isPermaLink="true">https://blog.hexbee.net/37-fragment-discard-and-transparency</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Mon, 02 Feb 2026 07:50:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1764276335993/2a531741-3be2-4979-9155-8dd5719b2d2a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>So far in this series, every fragment we've processed has been drawn to the screen. We've colored them, textured them, and lit them - but we've always drawn them. What if you don't want to draw certain fragments at all? What if you want holes, cutouts, or complex silhouettes without adding extra geometry?</p>
<p>This is where the <code>discard</code> statement comes in - one of the most powerful and distinctive tools in a fragment shader. With a single keyword, you can make a fragment disappear as if it never existed. No color written, no depth written, nothing. It is the fragment shader equivalent of an invisibility cloak.</p>
<p>However, transparency isn't just about deleting pixels. There is also <strong>alpha blending</strong> - where fragments partially show through to what's behind them. These two approaches - discard and blending - solve different problems and come with very different trade-offs in the deferred or forward rendering pipelines.</p>
<p>In this article, you will learn:</p>
<ul>
<li><p><strong>The Discard Statement</strong>: How to immediately terminate a pixel's processing.</p>
</li>
<li><p><strong>Alpha Testing</strong>: Creating hard cutouts for foliage, fences, and grates.</p>
</li>
<li><p><strong>Discard vs. Blending</strong>: Why discard gives you correct depth sorting for free, while blending creates sorting headaches.</p>
</li>
<li><p><strong>Performance</strong>: Why "doing nothing" (discarding) can sometimes be slower than drawing.</p>
</li>
<li><p><strong>Bevy's Alpha Modes</strong>: How to configure StandardMaterial or custom materials for different types of transparency.</p>
</li>
</ul>
<h3 id="heading-concept-forward-vs-deferred-rendering">Concept: Forward vs. Deferred Rendering</h3>
<p>To understand why transparency is tricky, we need to understand the two main ways game engines render scenes.</p>
<ol>
<li><p><strong>Forward Rendering</strong>: The classic approach. The GPU draws each object one by one and calculates lighting immediately.</p>
<ul>
<li><p><em>Pros</em>: Handles transparency (blending) well.</p>
</li>
<li><p><em>Cons</em>: Gets very slow if you have hundreds of dynamic lights.</p>
</li>
</ul>
</li>
<li><p><strong>Deferred Rendering</strong>: The modern approach for high-fidelity games. The GPU first renders the <code>geometry</code> data (Position, Normal, Color) of every pixel to a buffer (the "G-Buffer"), and calculates lighting for the entire screen at the end.</p>
<ul>
<li><p><em>Pros</em>: Can handle thousands of lights efficiently.</p>
</li>
<li><p><em>Cons</em>: <strong>Hates transparency.</strong> Because the G-Buffer only stores data for the single closest object per pixel, it can't store "multiple transparent layers" (like glass in front of a car).</p>
</li>
</ul>
</li>
</ol>
<p><strong>Why this matters</strong>: When you use discard, the fragment remains "opaque" - it's either there or it isn't. This works perfectly in <strong>both</strong> pipelines.<br />When you use <strong>alpha blending</strong>, the engine usually has to break out of the Deferred pipeline and draw that specific object using Forward rendering at the very end. This is why "cutout" shaders (discard) are often much faster than "transparent" shaders (blend).</p>
<h2 id="heading-understanding-fragment-discard">Understanding Fragment Discard</h2>
<p>Let's start with the simplest and most dramatic tool: the <code>discard</code> statement.</p>
<h3 id="heading-what-discard-does">What discard Does</h3>
<p>The <code>discard</code> statement immediately terminates the execution of the fragment shader. The fragment is not written to any output - not the color buffer, not the depth buffer, nothing. The GPU simply stops working on that specific pixel for that specific triangle.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Calculate if we should discard based on arbitrary logic</span>
    <span class="hljs-keyword">let</span> should_hide = <span class="hljs-keyword">in</span>.uv.y &gt; <span class="hljs-number">0.5</span>;

    <span class="hljs-keyword">if</span> should_hide {
        discard;  <span class="hljs-comment">// STOP. Do not pass Go. Do not write pixel.</span>
    }

    <span class="hljs-comment">// This code only runs if we didn't discard</span>
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p><strong>Critical properties of</strong> <code>discard</code>:</p>
<ol>
<li><p><strong>Immediate Termination</strong>: Code written after the <code>discard</code> statement inside that branch does not execute.</p>
</li>
<li><p><strong>No Depth Write</strong>: This is the most important feature. If a fragment is discarded, it does not update the depth buffer. Objects behind this fragment will be rendered normally, as if this geometry wasn't there.</p>
</li>
<li><p><strong>Binary Visibility</strong>: A fragment is either there (100% opacity) or it isn't (0% opacity). There is no "50% visible" with discard.</p>
</li>
</ol>
<h3 id="heading-visual-comparison">Visual Comparison</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764510763190/31029c01-2f5e-4723-8a92-c10a3cb81694.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-basic-example-the-checkerboard">Basic Example: The Checkerboard</h3>
<p>The simplest procedural use case is a checkerboard. We don't need a texture for this; we can calculate it using UVs.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Create a 10x10 grid pattern</span>
    <span class="hljs-keyword">let</span> tiles = floor(<span class="hljs-keyword">in</span>.uv * <span class="hljs-number">10.0</span>);

    <span class="hljs-comment">// Check if the sum of x and y indices is even or odd</span>
    <span class="hljs-keyword">let</span> checker = tiles.x + tiles.y;

    <span class="hljs-comment">// modulo 2.0 to alternate</span>
    <span class="hljs-keyword">if</span> (checker % <span class="hljs-number">2.0</span>) &gt; <span class="hljs-number">0.5</span> {
        discard;
    }

    <span class="hljs-comment">// Render the remaining squares as red</span>
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p>This creates a lattice where half the squares are completely transparent - you can see through them to the world behind - and half are solid red.</p>
<h3 id="heading-performance-when-to-discard">Performance: When to Discard</h3>
<p>It is important to understand that <code>discard</code> is not like a <code>return</code>. It aborts the thread.</p>
<p><strong>Optimization Tip</strong>: If you know you are going to discard a pixel, do it as early as possible.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Check discard condition FIRST</span>
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">in</span>.uv.y &lt; <span class="hljs-number">0.1</span> {
        discard;
    }

    <span class="hljs-comment">// 2. Perform expensive lighting/math AFTER</span>
    <span class="hljs-comment">// This code won't run for the bottom 10% of the UVs, saving GPU power.</span>
    <span class="hljs-keyword">let</span> lighting = calculate_complex_pbr_lighting(<span class="hljs-keyword">in</span>);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(lighting, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-discard-and-depth-testing">Discard and Depth Testing</h3>
<p>One of the key features of <code>discard</code> is how it interacts with the depth buffer.</p>
<p>Normally, when a fragment shader runs, the GPU might update the Depth Buffer to say "there is an object here at distance Z." If we <code>discard</code>, that update never happens. This means we don't need to worry about the "draw order" of our triangles.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764510999988/9c17fa46-9477-4453-8d17-7b8ddbd7deee.png" alt class="image--center mx-auto" /></p>
<p>This makes <code>discard</code> the perfect tool for <strong>foliage, chain-link fences, and grates</strong>.</p>
<h2 id="heading-alpha-testing-with-conditional-discard">Alpha Testing with Conditional Discard</h2>
<p>The most common use of <code>discard</code> is <strong>Alpha Testing</strong> (also called "Cutout" transparency). This technique uses a texture's alpha channel to decide which pixels to keep and which to delete.</p>
<h3 id="heading-the-classic-alpha-test-pattern">The Classic Alpha Test Pattern</h3>
<p>Imagine you have a texture of a chain-link fence. The metal parts have an alpha of <code>1.0</code> (opaque), and the gaps have an alpha of <code>0.0</code> (transparent).</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Define bindings for Group 2 (Material Data)</span>
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>) var base_texture: texture_2d&lt;<span class="hljs-built_in">f32</span>&gt;;
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">1</span>) var base_sampler: sampler;

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">AlphaCutoffMaterial</span></span> {
    cutoff_threshold: <span class="hljs-built_in">f32</span>, <span class="hljs-comment">// Usually 0.5</span>
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">2</span>) var&lt;uniform&gt; material: AlphaCutoffMaterial;

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Sample the texture</span>
    <span class="hljs-keyword">let</span> color = textureSample(base_texture, base_sampler, <span class="hljs-keyword">in</span>.uv);

    <span class="hljs-comment">// 2. Alpha Test: Compare texture alpha against our threshold</span>
    <span class="hljs-keyword">if</span> color.a &lt; material.cutoff_threshold {
        discard;
    }

    <span class="hljs-comment">// 3. Render the pixel (opaque)</span>
    <span class="hljs-keyword">return</span> color;
}
</code></pre>
<p><strong>How it works:</strong></p>
<ol>
<li><p>We sample the texture at the current UV.</p>
</li>
<li><p>We check if the alpha value is too low (e.g., &lt; 0.5).</p>
</li>
<li><p>If it is, we discard. If not, we draw the pixel at full opacity.</p>
</li>
</ol>
<h3 id="heading-choosing-the-right-threshold">Choosing the Right Threshold</h3>
<p>The cutoff threshold determines how "strict" the discard is.</p>
<ul>
<li><p><strong>0.5</strong>: The standard default. Balanced.</p>
</li>
<li><p><strong>0.1</strong>: "Generous." Keeps almost everything, even faint wisps. Good for preserving details but might leave "dirty" edges.</p>
</li>
<li><p><strong>0.9</strong>: "Strict." Only keeps the most solid parts. Good for shrinking an object visually.</p>
</li>
</ul>
<pre><code class="lang-plaintext">Alpha Channel Gradient:   0.0 ... 0.3 ... 0.5 ... 0.7 ... 1.0

Threshold 0.5 keeps:                      [------ KEEP -----]
Threshold 0.9 keeps:                                  [KEEP ]
</code></pre>
<h3 id="heading-the-problem-binary-edges">The Problem: Binary Edges</h3>
<p>With standard Alpha Testing, there are only two states: visible or invisible. There is no partial transparency. This creates "jagged" or aliased edges, especially when the camera is close.</p>
<p>To fix this cheaply without enabling expensive Alpha Blending, we can use <strong>Dithering</strong>.</p>
<h3 id="heading-advanced-dithered-alpha-testing">Advanced: Dithered Alpha Testing</h3>
<p>Dithering uses a noise pattern to randomly discard pixels near the edge threshold. This fakes transparency by mixing opaque and discarded pixels in a checkerboard-like pattern.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Helper: 4x4 Bayer Matrix for ordered dithering</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">bayer_dither</span></span>(position: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> x = <span class="hljs-built_in">u32</span>(position.x) % <span class="hljs-number">4</span>u;
    <span class="hljs-keyword">let</span> y = <span class="hljs-built_in">u32</span>(position.y) % <span class="hljs-number">4</span>u;

    <span class="hljs-comment">// Hardcoded Bayer matrix normalized to 0.0-1.0</span>
    <span class="hljs-comment">// This creates a balanced noise pattern</span>
    <span class="hljs-keyword">let</span> index = y * <span class="hljs-number">4</span>u + x;
    var bayer = array&lt;<span class="hljs-built_in">f32</span>, <span class="hljs-number">16</span>&gt;(
        <span class="hljs-number">0.0</span>/<span class="hljs-number">16.0</span>, <span class="hljs-number">8.0</span>/<span class="hljs-number">16.0</span>, <span class="hljs-number">2.0</span>/<span class="hljs-number">16.0</span>, <span class="hljs-number">10.0</span>/<span class="hljs-number">16.0</span>,
        <span class="hljs-number">12.0</span>/<span class="hljs-number">16.0</span>, <span class="hljs-number">4.0</span>/<span class="hljs-number">16.0</span>, <span class="hljs-number">14.0</span>/<span class="hljs-number">16.0</span>, <span class="hljs-number">6.0</span>/<span class="hljs-number">16.0</span>,
        <span class="hljs-number">3.0</span>/<span class="hljs-number">16.0</span>, <span class="hljs-number">11.0</span>/<span class="hljs-number">16.0</span>, <span class="hljs-number">1.0</span>/<span class="hljs-number">16.0</span>, <span class="hljs-number">9.0</span>/<span class="hljs-number">16.0</span>,
        <span class="hljs-number">15.0</span>/<span class="hljs-number">16.0</span>, <span class="hljs-number">7.0</span>/<span class="hljs-number">16.0</span>, <span class="hljs-number">13.0</span>/<span class="hljs-number">16.0</span>, <span class="hljs-number">5.0</span>/<span class="hljs-number">16.0</span>
    );

    <span class="hljs-keyword">return</span> bayer[index];
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> color = textureSample(base_texture, base_sampler, <span class="hljs-keyword">in</span>.uv);

    <span class="hljs-comment">// Get a dither value based on SCREEN position (in.position)</span>
    <span class="hljs-comment">// -0.5 to +0.5 range centered around 0</span>
    <span class="hljs-keyword">let</span> dither_noise = bayer_dither(<span class="hljs-keyword">in</span>.position.xy) - <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// Modulate the threshold slightly per pixel</span>
    <span class="hljs-comment">// "0.1" controls how wide the "fuzzy" edge is</span>
    <span class="hljs-keyword">let</span> noisy_threshold = material.cutoff_threshold + (dither_noise * <span class="hljs-number">0.1</span>);

    <span class="hljs-keyword">if</span> color.a &lt; noisy_threshold {
        discard;
    }

    <span class="hljs-keyword">return</span> color;
}
</code></pre>
<p>This effectively "softens" the hard edge. From a distance, the dithered pixels blend together in your eye, making the edge look smooth.</p>
<h2 id="heading-creating-cutout-effects">Creating Cutout Effects</h2>
<p>Alpha testing is the standard technique for specific types of objects in games.</p>
<h3 id="heading-leaves-and-foliage">Leaves and Foliage</h3>
<p>Leaves are almost always rendered as simple quads (rectangles) with a texture.</p>
<p>A tree might have 10,000 leaves. Sorting them back-to-front for Alpha Blending would crush the CPU. With discard, the Z-buffer handles the sorting automatically.</p>
<h3 id="heading-fences-and-grates">Fences and Grates</h3>
<p>Chain-link fences are just flat planes with a repeating texture.</p>
<p><code>discard</code> creates sharp, metallic edges. Semi-transparent blending would make the metal look like ghost-fence.</p>
<h3 id="heading-procedural-holes">Procedural Holes</h3>
<p>You don't always need a texture. You can punch holes mathematically using discard.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Example: Swiss Cheese Shader</span>
@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Create a repeating grid</span>
    <span class="hljs-keyword">let</span> grid_uv = fract(<span class="hljs-keyword">in</span>.uv * <span class="hljs-number">10.0</span>);

    <span class="hljs-comment">// Calculate distance from center of the grid cell</span>
    <span class="hljs-keyword">let</span> dist = length(grid_uv - vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>));

    <span class="hljs-comment">// If inside the circle radius, discard</span>
    <span class="hljs-keyword">if</span> dist &lt; <span class="hljs-number">0.4</span> {
        discard;
    }

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// Yellow cheese</span>
}
</code></pre>
<h2 id="heading-discard-vs-alpha-blending-trade-offs">Discard vs. Alpha Blending Trade-offs</h2>
<p>Now we reach the critical question: when should you use <strong>Alpha Testing</strong> (<code>discard</code>) versus standard <strong>Alpha Blending</strong>?</p>
<p>In Bevy, you choose between these behaviors using the <code>AlphaMode</code> setting on your material, but understanding what happens under the hood is vital for performance and visual quality.</p>
<h3 id="heading-alpha-blending-recap">Alpha Blending Recap</h3>
<p>Before comparing, let's remember what Alpha Blending is. instead of "deleting" the pixel, the GPU mixes it with the color that is already on the screen behind it.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Alpha Blending (No discard)</span>
@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> color = textureSample(base_texture, base_sampler, <span class="hljs-keyword">in</span>.uv);

    <span class="hljs-comment">// Return color with alpha.</span>
    <span class="hljs-comment">// The GPU hardware handles the mixing automatically.</span>
    <span class="hljs-keyword">return</span> color; 
}
</code></pre>
<p>Equation: <code>FinalColor = (SourceColor * Alpha) + (DestinationColor * (1.0 - Alpha))</code></p>
<h3 id="heading-comparison-table">Comparison Table</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Feature</td><td>Alpha Testing (<code>discard</code>)</td><td>Alpha Blending (<code>mix</code>)</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Edge Quality</strong></td><td>Sharp, jagged edges (Pixelated).</td><td>Smooth, soft edges (Anti-aliased).</td></tr>
<tr>
<td><strong>Transparency</strong></td><td>Binary (On/Off).</td><td>Continuous (0% to 100%).</td></tr>
<tr>
<td><strong>Depth Buffer</strong></td><td><strong>Writes to depth.</strong></td><td><strong>Usually Read-Only.</strong></td></tr>
<tr>
<td><strong>Sorting</strong></td><td><strong>Not Required.</strong></td><td><strong>Required (Back-to-Front).</strong></td></tr>
<tr>
<td><strong>Use Cases</strong></td><td>Foliage, Fences, Grates.</td><td>Glass, Water, Holograms, Smoke.</td></tr>
<tr>
<td><strong>Bevy Mode</strong></td><td>AlphaMode::Mask(0.5)</td><td>AlphaMode::Blend</td></tr>
</tbody>
</table>
</div><h3 id="heading-the-sorting-problem">The Sorting Problem</h3>
<p>The single biggest downside of Alpha Blending is that <strong>draw order matters</strong>.</p>
<p>If you draw a red glass pane in front of a blue glass pane:</p>
<ul>
<li><p><strong>Correct (Back-to-Front)</strong>: Draw Blue, then Draw Red on top. Result: Purple.</p>
</li>
<li><p><strong>Incorrect (Front-to-Back)</strong>: Draw Red. Because it is transparent, it <em>doesn't</em> write to the depth buffer. Then Draw Blue. Since nothing wrote to the depth buffer, Blue draws <em>on top</em> of Red. Result: Blue looks like it is in front of Red.</p>
</li>
</ul>
<p><strong>How Bevy handles this</strong>:<br />Bevy's renderer automatically calculates the distance from the camera to every object with <code>AlphaMode::Blend</code> and sorts them every frame.</p>
<ul>
<li><p><strong>Limitation 1</strong>: It sorts <strong>objects</strong>, not triangles. If a large complex mesh intersects itself, it will glitch.</p>
</li>
<li><p><strong>Limitation 2</strong>: It costs CPU time to sort thousands of objects every frame.</p>
</li>
</ul>
<p><strong>Why discard wins here</strong>:<br />Because <code>discard</code> allows the opaque pixels to write to the depth buffer, <strong>order does not matter</strong>. You can draw a forest of 10,000 trees in any order, and the depth buffer ensures the trees behind are correctly hidden by the trees in front.</p>
<h3 id="heading-performance-considerations">Performance Considerations</h3>
<p>When choosing a transparency mode, it helps to view them in a hierarchy of cost.</p>
<h4 id="heading-1-opaque-alphamodeopaque-the-speed-king">1. Opaque (<code>AlphaMode::Opaque</code>) - The Speed King</h4>
<p>This is the fastest mode. Modern GPUs use "Early-Z" optimization: before running your pixel shader, they check the depth buffer. If a wall is in front of the pixel, the GPU skips the shader entirely.</p>
<p><strong>Verdict</strong>: Use this whenever possible.</p>
<h4 id="heading-2-discard-alpha-mask-alphamodemask-the-optimization-breaker">2. Discard / Alpha Mask (<code>AlphaMode::Mask</code>) - The Optimization Breaker</h4>
<p>You might think: "Discarding pixels means I don't write them, so it should be faster than Opaque!"<br /><strong>Not always!</strong></p>
<p>To know <em>if</em> a pixel should be discarded, the GPU <em>must</em> run the fragment shader. This forces the GPU to disable Early-Z optimization for that object. It calculates every single pixel, even the ones hidden behind other objects, just to check if they need to be deleted.</p>
<p><strong>Verdict</strong>: Slower than Opaque, but much faster than Blending.</p>
<h4 id="heading-3-alpha-blending-alphamodeblend-the-bandwidth-eater">3. Alpha Blending (<code>AlphaMode::Blend</code>) - The Bandwidth Eater</h4>
<p>This is the most expensive mode.</p>
<ul>
<li><p><strong>CPU Cost</strong>: Bevy must calculate distances and sort every object every frame.</p>
</li>
<li><p><strong>GPU Cost</strong>: For every transparent layer (e.g., looking through smoke), the GPU must read the current color from memory, do math, and write it back. This "Read-Modify-Write" loop eats up <strong>Memory Bandwidth</strong>, which is often the bottleneck on mobile devices and integrated graphics.</p>
</li>
</ul>
<p><strong>Verdict</strong>: Use sparingly.</p>
<h4 id="heading-summary-rule-of-thumb">Summary Rule of Thumb</h4>
<ul>
<li><p>If it's solid, use <code>AlphaMode::Opaque</code>.</p>
<ul>
<li><p>If it has holes but hard edges (leaves, fences), use <code>AlphaMode::Mask</code>.</p>
</li>
<li><p>Only use <code>AlphaMode::Blend</code> if you absolutely need partial transparency (glass, water).</p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-bevys-alpha-mode-configuration">Bevy's Alpha Mode Configuration</h2>
<p>In Bevy, you control this behavior directly on your material struct. Whether you use <code>StandardMaterial</code> or a custom <code>Material</code>, the API is the same.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> MyCustomMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/my_shader.wgsl"</span>.into()
    }

    <span class="hljs-comment">// This function tells Bevy's pipeline how to handle your shader</span>
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">alpha_mode</span></span>(&amp;<span class="hljs-keyword">self</span>) -&gt; AlphaMode {
        <span class="hljs-comment">// Option A: Opaque (Default)</span>
        <span class="hljs-comment">// Fastest. Writes Depth. Ignores alpha channel.</span>
        AlphaMode::Opaque

        <span class="hljs-comment">// Option B: Alpha Mask / Testing</span>
        <span class="hljs-comment">// Writes Depth. Enables `discard` behavior in the generic shader.</span>
        <span class="hljs-comment">// The f32 value is the cutoff threshold (usually 0.5).</span>
        AlphaMode::Mask(<span class="hljs-number">0.5</span>)

        <span class="hljs-comment">// Option C: Alpha Blending</span>
        <span class="hljs-comment">// No Depth Write. CPU Sorting enabled. Smooth edges.</span>
        AlphaMode::Blend

        <span class="hljs-comment">// Option D: Additive (Good for glowing particles/fire)</span>
        AlphaMode::Add
    }
}
</code></pre>
<blockquote>
<p><strong>Note</strong>: If you are writing a completely custom fragment shader (like we are in the next section), setting <code>AlphaMode::Mask</code> in Rust <strong>does not</strong> automatically add the <code>discard</code> keyword to your WGSL code. You must write the <code>if (alpha &lt; threshold) { discard; }</code> logic yourself!</p>
</blockquote>
<p>The <code>AlphaMode</code> in Rust primarily tells the render pipeline:</p>
<ol>
<li><p>Whether to enable Depth Writes.</p>
</li>
<li><p>Whether to enable the CPU Sorter.</p>
</li>
<li><p>Which Blend State (Mix, Add, Multiply) to configure on the GPU.</p>
</li>
</ol>
<hr />
<h2 id="heading-complete-example-the-teleportation-chamber">Complete Example: The Teleportation Chamber</h2>
<p>We will build a sci-fi teleportation scene that demonstrates both uses of transparency in a single project. This approach highlights exactly when to use one technique over the other.</p>
<ol>
<li><p><strong>Safety Grates (Alpha Mask)</strong>: We will create a perforated metal floor and wall. Since metal is solid (it's either there or it isn't), we use <strong>Alpha Testing</strong>. We will also disable backface culling so we can see the grates from both sides.</p>
</li>
<li><p><strong>The Hero (Blend + Discard)</strong>: We will create a holographic character. Since holograms are made of light, they are semi-transparent (<strong>Alpha Blending</strong>). However, when the character teleports, they will "dematerialize" using a noise-based <strong>Discard</strong> effect.</p>
</li>
</ol>
<p>This example relies entirely on procedural math (sine waves and hash functions), so no texture assets are required.</p>
<h3 id="heading-1-the-grate-material-alpha-testing">1. The Grate Material (Alpha Testing)</h3>
<p>This material generates a "Perforated Metal" look. We use <code>AlphaMode::Mask</code> because we want the edges of the holes to be razor-sharp.</p>
<h4 id="heading-the-shader-assetsshadersd0307simplegratewgsl">The Shader (<code>assets/shaders/d03_07_simple_grate.wgsl</code>)</h4>
<p>In the fragment shader, we divide the UV space into a grid. Inside each cell, we calculate the distance from the center. If that distance is smaller than our <code>hole_radius</code>, we <code>discard</code> the pixel.</p>
<p>To make it look 3D, we add a fake "bevel" highlight around the rim of the hole using <code>smoothstep</code>. This makes the flat geometry feel like a thick metal plate.</p>
<pre><code class="lang-rust">#import bevy_pbr::forward_io::VertexOutput

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">GrateMaterial</span></span> {
    color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    bar_width: <span class="hljs-built_in">f32</span>, <span class="hljs-comment">// Controls hole size (0.0 to 0.5)</span>
    _padding: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>) var&lt;uniform&gt; material: GrateMaterial;

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Create a repeating grid</span>
    <span class="hljs-comment">// We offset by 0.5 so the center of the UV cell is (0,0)</span>
    <span class="hljs-keyword">let</span> grid_uv = fract(<span class="hljs-keyword">in</span>.uv * <span class="hljs-number">20.0</span>) - <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// 2. Calculate distance from the center of the cell</span>
    <span class="hljs-keyword">let</span> dist = length(grid_uv);

    <span class="hljs-comment">// 3. Alpha Test (The Hole)</span>
    <span class="hljs-comment">// If we are inside the circle radius defined by bar_width, discard.</span>
    <span class="hljs-comment">// This creates "Swiss Cheese" perforated metal.</span>
    <span class="hljs-keyword">if</span> dist &lt; material.bar_width {
        discard;
    }

    <span class="hljs-comment">// 4. Fake "Bevel" Lighting</span>
    <span class="hljs-comment">// We make the pixels right next to the hole brighter.</span>
    <span class="hljs-comment">// This simulates light catching the rim of the drilled hole,</span>
    <span class="hljs-comment">// making the flat plane look like it has thickness.</span>
    <span class="hljs-keyword">let</span> bevel = smoothstep(material.bar_width, material.bar_width + <span class="hljs-number">0.1</span>, dist);

    <span class="hljs-comment">// Mix a dark shadow color with the material color based on the bevel</span>
    <span class="hljs-keyword">let</span> final_color = mix(material.color.rgb * <span class="hljs-number">0.5</span>, material.color.rgb, bevel);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(final_color, material.color.a);
}
</code></pre>
<h4 id="heading-the-rust-code-srcmaterialsd0307simplegraters">The Rust Code (<code>src/materials/d03_07_simple_grate.rs</code>)</h4>
<p>In the Rust material, we do two important things:</p>
<ol>
<li><p>We set <code>AlphaMode::Mask(0.5)</code> to enable the cutouts.</p>
</li>
<li><p>We override the <code>specialize</code> method to disable <code>cull_mode</code>. This tells the GPU to draw the triangles even if they are facing away from the camera, allowing us to see the back of the wall through the front of the wall.</p>
</li>
</ol>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::pbr::{MaterialPipeline, MaterialPipelineKey};
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::mesh::MeshVertexBufferLayoutRef;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{
    AsBindGroup, RenderPipelineDescriptor, ShaderRef, SpecializedMeshPipelineError,
};

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">GrateMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> color: LinearRgba,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> hole_radius: <span class="hljs-built_in">f32</span>, <span class="hljs-comment">// Renamed for clarity (was bar_width)</span>
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> _padding: Vec3,
}

<span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> GrateMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
        <span class="hljs-keyword">Self</span> {
            color: LinearRgba::new(<span class="hljs-number">0.6</span>, <span class="hljs-number">0.6</span>, <span class="hljs-number">0.7</span>, <span class="hljs-number">1.0</span>), <span class="hljs-comment">// Steel Blue-Grey</span>
            hole_radius: <span class="hljs-number">0.35</span>,                          <span class="hljs-comment">// Nice perforation size</span>
            _padding: Vec3::ZERO,
        }
    }
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> GrateMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d03_07_simple_grate.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">alpha_mode</span></span>(&amp;<span class="hljs-keyword">self</span>) -&gt; AlphaMode {
        AlphaMode::Mask(<span class="hljs-number">0.5</span>)
    }

    <span class="hljs-comment">// ENABLE DOUBLE-SIDED RENDERING</span>
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">specialize</span></span>(
        _pipeline: &amp;MaterialPipeline&lt;<span class="hljs-keyword">Self</span>&gt;,
        descriptor: &amp;<span class="hljs-keyword">mut</span> RenderPipelineDescriptor,
        _layout: &amp;MeshVertexBufferLayoutRef,
        _key: MaterialPipelineKey&lt;<span class="hljs-keyword">Self</span>&gt;,
    ) -&gt; <span class="hljs-built_in">Result</span>&lt;(), SpecializedMeshPipelineError&gt; {
        <span class="hljs-comment">// None = Draw both front and back faces</span>
        descriptor.primitive.cull_mode = <span class="hljs-literal">None</span>;
        <span class="hljs-literal">Ok</span>(())
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_07_simple_grate;
</code></pre>
<h3 id="heading-2-the-teleport-material-blend-discard">2. The Teleport Material (Blend + Discard)</h3>
<p>This material renders the "Hero". It combines three distinct effects:</p>
<ol>
<li><p><strong>Hologram Scanlines</strong>: A sine wave moving vertically to simulate projection interference.</p>
</li>
<li><p><strong>Rim Lighting</strong>: A Fresnel calculation to make the edges of the model glow, enhancing the 3D volume.</p>
</li>
<li><p><strong>Teleport Dissolve</strong>: A noise-based <code>discard</code> effect that eats away the geometry.</p>
</li>
</ol>
<h4 id="heading-the-shader-assetsshadersd0307teleportbodywgsl">The Shader (<code>assets/shaders/d03_07_teleport_body.wgsl</code>)</h4>
<p>Note that we calculate the <strong>Fresnel</strong> effect using the dot product between the <code>view</code> vector (camera direction) and the normal vector. This adds that classic sci-fi "edge glow."</p>
<p>The dissolve effect happens <em>before</em> the lighting. If the noise value is below our threshold, we <code>discard</code> immediately. If the pixel survives, we then apply the holographic blending.</p>
<pre><code class="lang-rust">#import bevy_pbr::forward_io::VertexOutput
#import bevy_pbr::mesh_view_bindings::view

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">TeleportMaterial</span></span> {
    color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    dissolve_amount: <span class="hljs-built_in">f32</span>,
    time: <span class="hljs-built_in">f32</span>,
    _padding: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>) var&lt;uniform&gt; material: TeleportMaterial;

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> n = dot(p, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">12.9898</span>, <span class="hljs-number">78.233</span>));
    <span class="hljs-keyword">return</span> fract(sin(n) * <span class="hljs-number">43758.5453</span>);
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Dissolve (Discard)</span>
    <span class="hljs-keyword">let</span> noise = hash(<span class="hljs-keyword">in</span>.uv * <span class="hljs-number">50.0</span> + material.time);
    <span class="hljs-keyword">if</span> noise &lt; material.dissolve_amount {
        discard;
    }

    <span class="hljs-comment">// 2. Hologram Scanlines</span>
    <span class="hljs-keyword">let</span> scanline = sin(<span class="hljs-keyword">in</span>.world_position.y * <span class="hljs-number">50.0</span> - material.time * <span class="hljs-number">5.0</span>);
    <span class="hljs-keyword">let</span> scan_strength = <span class="hljs-number">0.7</span> + <span class="hljs-number">0.3</span> * scanline;

    <span class="hljs-comment">// 3. Fresnel Rim Light (3D effect)</span>
    <span class="hljs-keyword">let</span> view_dir = normalize(view.world_position.xyz - <span class="hljs-keyword">in</span>.world_position.xyz);
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);
    <span class="hljs-keyword">let</span> fresnel = <span class="hljs-number">1.0</span> - max(dot(view_dir, normal), <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> rim_glow = pow(fresnel, <span class="hljs-number">3.0</span>) * <span class="hljs-number">2.0</span>;

    <span class="hljs-comment">// 4. Combine</span>
    <span class="hljs-keyword">let</span> final_alpha = material.color.a * scan_strength + rim_glow;
    <span class="hljs-keyword">let</span> final_color = material.color.rgb + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(rim_glow);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(final_color, final_alpha);
}
</code></pre>
<h4 id="heading-the-rust-code-srcmaterialsd0307teleportbodyrs">The Rust Code (<code>src/materials/d03_07_teleport_body.rs</code>)</h4>
<p>We use <code>AlphaMode::Blend</code>. Even though we use <code>discard</code> inside the shader, we need Blending enabled to make the hologram look like semi-transparent light.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">TeleportMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> color: LinearRgba,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> dissolve_amount: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> _padding: Vec2,
}

<span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> TeleportMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
        <span class="hljs-keyword">Self</span> {
            color: LinearRgba::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.3</span>), <span class="hljs-comment">// Cyan, low opacity</span>
            dissolve_amount: <span class="hljs-number">0.0</span>,
            time: <span class="hljs-number">0.0</span>,
            _padding: Vec2::ZERO,
        }
    }
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> TeleportMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d03_07_teleport_body.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">alpha_mode</span></span>(&amp;<span class="hljs-keyword">self</span>) -&gt; AlphaMode {
        AlphaMode::Blend
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_07_teleport_body;
</code></pre>
<h3 id="heading-3-the-demo-scene-srcdemosd0307teleportdemors">3. The Demo Scene (<code>src/demos/d03_07_teleport_demo.rs</code>)</h3>
<p>This demo sets up the chamber, spawns the hero, and handles the logic. We add an <strong>Orbit Camera</strong> so you can rotate around the scene and inspect the transparency sorting from all angles.</p>
<p>When you press <strong>Space</strong>, we animate the <code>dissolve_amount</code> uniform. The hero dissolves, moves to a new random location while invisible, and then re-materializes.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d03_07_simple_grate::GrateMaterial;
<span class="hljs-keyword">use</span> crate::materials::d03_07_teleport_body::TeleportMaterial;
<span class="hljs-keyword">use</span> bevy::prelude::*;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;GrateMaterial&gt;::default())
        .add_plugins(MaterialPlugin::&lt;TeleportMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (
                handle_teleport_input,
                animate_teleport,
                update_time,
                orbit_camera,
            ),
        )
        .run();
}

<span class="hljs-meta">#[derive(Component)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">Hero</span></span>;

<span class="hljs-meta">#[derive(Component)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">OrbitCamera</span></span> {
    radius: <span class="hljs-built_in">f32</span>,
    angle: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-meta">#[derive(Component)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">TeleportState</span></span> {
    target_pos: Vec3,
    is_teleporting: <span class="hljs-built_in">bool</span>,
    dissolve_progress: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> grate_materials: ResMut&lt;Assets&lt;GrateMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> teleport_materials: ResMut&lt;Assets&lt;TeleportMaterial&gt;&gt;,
) {
    <span class="hljs-comment">// Camera</span>
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">5.0</span>, <span class="hljs-number">12.0</span>).looking_at(Vec3::ZERO, Vec3::Y),
        OrbitCamera {
            radius: <span class="hljs-number">12.0</span>,
            angle: <span class="hljs-number">0.0</span>,
        },
    ));

    <span class="hljs-comment">// Light</span>
    commands.spawn((
        PointLight {
            intensity: <span class="hljs-number">2_000_000.0</span>,
            range: <span class="hljs-number">20.0</span>,
            ..default()
        },
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">10.0</span>, <span class="hljs-number">0.0</span>),
    ));

    <span class="hljs-comment">// 1. The Chamber (Perforated Metal)</span>
    <span class="hljs-keyword">let</span> wall_mesh = meshes.add(Rectangle::new(<span class="hljs-number">8.0</span>, <span class="hljs-number">4.0</span>));
    <span class="hljs-keyword">let</span> grate_mat = grate_materials.add(GrateMaterial::default());

    <span class="hljs-comment">// Back Wall</span>
    commands.spawn((
        Mesh3d(wall_mesh.clone()),
        MeshMaterial3d(grate_mat.clone()),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span>, -<span class="hljs-number">4.0</span>),
    ));
    <span class="hljs-comment">// Front Wall</span>
    commands.spawn((
        Mesh3d(wall_mesh),
        MeshMaterial3d(grate_mat),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span>, <span class="hljs-number">4.0</span>),
    ));

    <span class="hljs-comment">// 2. The Hero (Hologram)</span>
    commands.spawn((
        Mesh3d(meshes.add(Capsule3d::default())),
        MeshMaterial3d(teleport_materials.add(TeleportMaterial::default())),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>),
        Hero,
        TeleportState {
            target_pos: Vec3::ZERO,
            is_teleporting: <span class="hljs-literal">false</span>,
            dissolve_progress: <span class="hljs-number">0.0</span>,
        },
    ));

    <span class="hljs-comment">// UI</span>
    commands.spawn((
        Text::new(
            <span class="hljs-string">"CONTROLS:\n\
             [Space] Teleport Hero\n\
             [Left/Right] Orbit Camera"</span>,
        ),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">12.0</span>),
            left: Val::Px(<span class="hljs-number">12.0</span>),
            ..default()
        },
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_time</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;TeleportMaterial&gt;&gt;) {
    <span class="hljs-keyword">for</span> (_, mat) <span class="hljs-keyword">in</span> materials.iter_mut() {
        mat.time = time.elapsed_secs();
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">orbit_camera</span></span>(
    time: Res&lt;Time&gt;,
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    <span class="hljs-keyword">mut</span> query: Query&lt;(&amp;<span class="hljs-keyword">mut</span> Transform, &amp;<span class="hljs-keyword">mut</span> OrbitCamera)&gt;,
) {
    <span class="hljs-keyword">let</span> speed = <span class="hljs-number">2.0</span>;
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">mut</span> transform, <span class="hljs-keyword">mut</span> orbit) <span class="hljs-keyword">in</span> query.iter_mut() {
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowLeft) {
            orbit.angle -= speed * time.delta_secs();
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowRight) {
            orbit.angle += speed * time.delta_secs();
        }

        <span class="hljs-keyword">let</span> x = orbit.radius * orbit.angle.sin();
        <span class="hljs-keyword">let</span> z = orbit.radius * orbit.angle.cos();

        transform.translation = Vec3::new(x, <span class="hljs-number">5.0</span>, z);
        transform.look_at(Vec3::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>), Vec3::Y);
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_teleport_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    <span class="hljs-keyword">mut</span> query: Query&lt;&amp;<span class="hljs-keyword">mut</span> TeleportState, With&lt;Hero&gt;&gt;,
) {
    <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Space) {
        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> state <span class="hljs-keyword">in</span> query.iter_mut() {
            <span class="hljs-keyword">if</span> !state.is_teleporting {
                state.is_teleporting = <span class="hljs-literal">true</span>;
                state.dissolve_progress = <span class="hljs-number">0.0</span>;
                <span class="hljs-keyword">let</span> x = (rand::random::&lt;<span class="hljs-built_in">f32</span>&gt;() - <span class="hljs-number">0.5</span>) * <span class="hljs-number">6.0</span>;
                state.target_pos = Vec3::new(x, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>);
            }
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">animate_teleport</span></span>(
    time: Res&lt;Time&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;TeleportMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> query: Query&lt;(
        &amp;<span class="hljs-keyword">mut</span> Transform,
        &amp;<span class="hljs-keyword">mut</span> TeleportState,
        &amp;MeshMaterial3d&lt;TeleportMaterial&gt;,
    )&gt;,
) {
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">mut</span> transform, <span class="hljs-keyword">mut</span> state, handle) <span class="hljs-keyword">in</span> query.iter_mut() {
        <span class="hljs-keyword">if</span> state.is_teleporting {
            state.dissolve_progress += time.delta_secs() * <span class="hljs-number">2.0</span>;

            <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>(material) = materials.get_mut(handle) {
                <span class="hljs-keyword">if</span> state.dissolve_progress &lt; <span class="hljs-number">1.0</span> {
                    <span class="hljs-comment">// Dissolve</span>
                    material.dissolve_amount = state.dissolve_progress;
                } <span class="hljs-keyword">else</span> {
                    <span class="hljs-comment">// Move &amp; Reappear</span>
                    <span class="hljs-keyword">if</span> state.dissolve_progress &lt; <span class="hljs-number">1.1</span> {
                        transform.translation = state.target_pos;
                    }
                    material.dissolve_amount = <span class="hljs-number">2.0</span> - state.dissolve_progress;
                }
            }

            <span class="hljs-keyword">if</span> state.dissolve_progress &gt;= <span class="hljs-number">2.0</span> {
                state.is_teleporting = <span class="hljs-literal">false</span>;
                <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>(material) = materials.get_mut(handle) {
                    material.dissolve_amount = <span class="hljs-number">0.0</span>;
                }
            }
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_07_teleport_demo;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"3.7"</span>,
    title: <span class="hljs-string">"Fragment Discard and Transparency"</span>,
    run: demos::d03_07_teleport_demo::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key</td><td>Action</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Space</strong></td><td>Teleport the Hero to a random location</td></tr>
<tr>
<td><strong>Arrow Left/Right</strong></td><td>Orbit the camera around the scene</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764276517987/f930a1b0-c92d-4074-9c73-0010b5120c3f.png" alt class="image--center mx-auto" /></p>
<ol>
<li><p><strong>The Grate (Alpha Mask)</strong>: Notice the sharp, pixel-perfect edges of the holes. As you orbit the camera, look through the holes of the front wall to see the back wall. Because we use <code>discard</code>, the depth buffer works perfectly, and the sorting is correct.</p>
</li>
<li><p><strong>The Hologram (Alpha Blend)</strong>: The hero looks like a volume of light. Notice the scanlines and the rim glow.</p>
</li>
<li><p><strong>The Teleport (Discard)</strong>: When you press Space, the hero doesn't fade out evenly; they "burn away" into static. This demonstrates that you can use <code>discard</code> inside a blended material to create interesting erosion effects.</p>
</li>
</ol>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<ol>
<li><p><strong>Discard is distinct from Alpha</strong>: Transparency (alpha &lt; 1.0) blends colors. Discard deletes pixels.</p>
</li>
<li><p><strong>Use Masks for Structure</strong>: For objects like fences, grates, or foliage, use <code>AlphaMode::Mask</code> (Discard). It's sharp, performant, and handles depth correctly.</p>
</li>
<li><p><strong>Use Blend for Light</strong>: For objects that represent glass, ghosts, or energy, use <code>AlphaMode::Blend</code>.</p>
</li>
<li><p><strong>Combine them for Effects</strong>: You can use <code>discard</code> inside a Blended shader to create dissolve, burn, or teleportation effects.</p>
</li>
<li><p><strong>Disable Culling for Thin Objects</strong>: If you have a single-plane object (like a grate or leaf) that should be visible from both sides, remember to disable backface culling in the <code>specialize</code> method.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>We've mastered how to draw pixels, how to move them, and how to delete them. Now it's time to look at the final stage of the pipeline: effects that happen after the scene is drawn.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/38-advanced-color-techniques"><strong><em>3.8 - Advanced Color Techniques</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<h3 id="heading-transparency-comparison">Transparency Comparison</h3>
<p>Choosing the right <code>AlphaMode</code> is the most important optimization you can make for transparent objects.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Feature</td><td><strong>Opaque</strong></td><td><strong>Alpha Mask (Discard)</strong></td><td><strong>Alpha Blend</strong></td></tr>
</thead>
<tbody>
<tr>
<td><strong>Best For...</strong></td><td>Rocks, Walls, Characters</td><td>Foliage, Fences, Grates</td><td>Glass, Water, Holograms</td></tr>
<tr>
<td><strong>Visuals</strong></td><td>Solid</td><td>Solid with holes (Hard edges)</td><td>See-through (Soft edges)</td></tr>
<tr>
<td><strong>Depth Buffer</strong></td><td><strong>Writes Depth</strong></td><td><strong>Writes Depth</strong> (for opaque pixels)</td><td><strong>Read-Only</strong> (usually)</td></tr>
<tr>
<td><strong>Sorting</strong></td><td>Not Required</td><td>Not Required</td><td><strong>Required</strong> (CPU Intensive)</td></tr>
<tr>
<td><strong>Performance</strong></td><td>🟢 <strong>Fastest</strong> (Uses Early-Z)</td><td>🟡 <strong>Medium</strong> (Breaks Early-Z)</td><td>🔴 <strong>Slowest</strong> (Overdraw + Sorting)</td></tr>
</tbody>
</table>
</div><h3 id="heading-performance-heuristics">Performance Heuristics</h3>
<ul>
<li><p><strong>The Early-Z Penalty</strong>: Using <code>discard</code> in a shader usually disables the GPU's ability to skip hidden pixels (Early-Z). The shader must run for <em>every</em> pixel to decide if it should be deleted.</p>
</li>
<li><p><strong>The Overdraw Penalty</strong>: Alpha Blending requires reading and writing to the same pixel memory multiple times. Too many layers of smoke or glass will bottleneck your GPU's memory bandwidth.</p>
</li>
<li><p><strong>The Sorting Penalty</strong>: Bevy must sort all Blended objects on the CPU every frame. Avoid thousands of transparent objects.</p>
</li>
</ul>
<h3 id="heading-common-math-for-effects">Common Math for Effects</h3>
<ul>
<li><p><strong>Circular Hole</strong>: <code>if length(uv - center) &lt; radius { discard; }</code></p>
</li>
<li><p><strong>Grid</strong>: <code>if step(width, sin(uv * scale)) &lt; 0.5 { discard; }</code></p>
</li>
<li><p><strong>Dissolve</strong>: <code>if noise(uv) &lt; threshold { discard; }</code></p>
</li>
<li><p><strong>Rim Light</strong>: <code>1.0 - dot(view_dir, normal)</code></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[3.6 - Procedural Noise]]></title><description><![CDATA[What We're Learning
So far in this series, we've worked with mathematical functions like sine waves, smooth gradients, and geometric patterns. These are powerful, but they all share a common limitation: they are too perfect. Nature isn't perfectly re...]]></description><link>https://blog.hexbee.net/36-procedural-noise</link><guid isPermaLink="true">https://blog.hexbee.net/36-procedural-noise</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Sun, 25 Jan 2026 10:25:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1764260743422/18dc92f7-b03a-4129-8133-73a8e4be365b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>So far in this series, we've worked with mathematical functions like sine waves, smooth gradients, and geometric patterns. These are powerful, but they all share a common limitation: they are too perfect. Nature isn't perfectly regular - clouds don't form in neat circles, marble doesn't have uniform veins, and terrain doesn't follow mathematical curves. To create convincing organic materials, we need <strong>controlled chaos</strong>.</p>
<p>This is the world of <strong>Procedural Noise</strong>.</p>
<p>Unlike standard random number generators that produce "static," noise functions produce smooth, rolling hills of random values. This distinction is the foundation of almost every organic effect in computer graphics.</p>
<p>In this article, you will learn:</p>
<ul>
<li><p><strong>Random vs. Noise</strong>: Why <code>rand()</code> creates TV static, but Noise creates landscapes.</p>
</li>
<li><p><strong>The Hash Function</strong>: How to generate deterministic pseudo-randomness from coordinates.</p>
</li>
<li><p><strong>Value vs. Gradient Noise</strong>: The evolution from blocky pixels to smooth Perlin-style noise.</p>
</li>
<li><p><strong>Fractal Brownian Motion (FBM)</strong>: Layering noise to create complex detail like coastlines or clouds.</p>
</li>
<li><p><strong>Domain Warping</strong>: Using noise to distort other noise for liquid, gooey effects.</p>
</li>
<li><p><strong>Calculated vs. Texture Noise</strong>: When to compute it on the fly and when to load a texture.</p>
</li>
</ul>
<h2 id="heading-random-vs-noise-understanding-the-difference">Random vs. Noise: Understanding the Difference</h2>
<p>Before writing code, we must distinguish between "randomness" and "noise."</p>
<h3 id="heading-pure-randomness-white-noise">Pure Randomness (White Noise)</h3>
<p>A standard random function (like <code>rand()</code>) returns unrelated values. There is no correlation between an input of <code>0.1</code> and <code>0.2</code>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764433340414/8a681d5b-cd03-4326-8721-00d6766ef0aa.png" alt class="image--center mx-auto" /></p>
<p>In graphics, this looks like "TV static" or snow. It is useful for dissolving pixels or sparkling glitter, but it doesn't look like a physical object.</p>
<h3 id="heading-coherent-noise">Coherent Noise</h3>
<p>Noise functions are <strong>coherent</strong>. This means that if you input two numbers that are close to each other, the return values will also be close to each other.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764433444888/9d145252-12c9-4d91-ba02-5923a4e7fd85.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-visual-comparison">Visual Comparison</h3>
<p>If we visualize these values as a 2D grayscale image, the difference is obvious:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764433599219/25052334-f140-415d-acdf-1eeb51ac437c.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-the-four-properties-of-good-noise">The Four Properties of Good Noise</h3>
<p>To be useful in a shader, our noise function needs four specific traits:</p>
<ol>
<li><p><strong>Deterministic</strong>: The same input coordinate (x, y) must <em>always</em> produce the exact same output value. This ensures the terrain doesn't flicker when we move the camera.</p>
</li>
<li><p><strong>Continuous</strong>: The transition between values must be smooth (no sharp edges).</p>
</li>
<li><p><strong>Apparent Randomness</strong>: While it follows a pattern, it shouldn't <em>look</em> like a repeating pattern to the human eye.</p>
</li>
<li><p><strong>Controllable</strong>: We need to be able to scale the frequency (how wide the features are) and amplitude (how strong the features are).</p>
</li>
</ol>
<h2 id="heading-hash-functions-the-foundation">Hash Functions: The Foundation</h2>
<p>To generate noise, we first need a source of randomness. But in a shader, we don't have a <code>rand()</code> function that remembers previous states. Shaders run in parallel on thousands of pixels simultaneously; they can't share state.</p>
<p>Instead, we use a <strong>Hash Function</strong>.</p>
<p>A hash function takes an input (like a coordinate) and mangles it mathematically to produce a seemingly random number.</p>
<h3 id="heading-1-the-sine-fract-hash">1. The Sine-Fract Hash</h3>
<p>The most common "quick and dirty" hash used in shader tutorials relies on the chaotic nature of the sine wave at high amplitudes.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash_2d</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// 1. Dot Product: Squash the 2D vector into a single float</span>
    <span class="hljs-comment">// The "magic numbers" (127.1, 311.7) are arbitrary primes chosen to avoid patterns.</span>
    <span class="hljs-keyword">let</span> n = dot(p, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">127.1</span>, <span class="hljs-number">311.7</span>));

    <span class="hljs-comment">// 2. Sine Chaos: Apply sine and multiply by a huge number.</span>
    <span class="hljs-comment">// 43758.5453 is chosen because it scrambles the bits effectively.</span>
    <span class="hljs-comment">// 3. Fract: Keep only the decimal part.</span>
    <span class="hljs-keyword">return</span> fract(sin(n) * <span class="hljs-number">43758.5453123</span>);
}
</code></pre>
<p><strong>How it works:</strong></p>
<ol>
<li><p><strong>Dot Product</strong>: We mix <code>x</code> and <code>y</code> together. If we just added them (<code>x+y</code>), the pattern would be diagonal lines. Using different weights breaks that symmetry.</p>
</li>
<li><p><strong>Sine</strong>: <code>sin(n)</code> gives a wave from <code>-1</code> to <code>1</code>.</p>
</li>
<li><p><strong>Amplitude</strong>: Multiplying by <code>43758.54</code> creates a wave that oscillates wildly.</p>
</li>
<li><p><strong>Fract</strong>: By taking the fractional part, we get a value between <code>0.0</code> and <code>1.0</code>. Because the wave is oscillating so fast, moving just <code>0.0001</code> units in space results in a completely different decimal value.</p>
</li>
</ol>
<blockquote>
<p><strong>Production Note</strong>: This sine-based hash is perfect for learning and small scenes, but it has limits. Because floating-point precision drops as numbers get larger, this function starts to show artifacts (patterns or solid blocks) at very large coordinates (e.g., p &gt; 100,000.0). For infinite procedural worlds, developers use <a target="_blank" href="https://jcgt.org/published/0009/03/02/paper.pdf">better hash functions</a> (like <a target="_blank" href="https://www.pcg-random.org/">PCG Hash</a>) which are stable everywhere.</p>
</blockquote>
<h3 id="heading-2-3d-hash-function">2. 3D Hash Function</h3>
<p>If we are working in 3D space (like for a volumetric cloud shader), we simply add a third "magic number" to the dot product.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash_3d</span></span>(p: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> n = dot(p, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">127.1</span>, <span class="hljs-number">311.7</span>, <span class="hljs-number">74.7</span>));
    <span class="hljs-keyword">return</span> fract(sin(n) * <span class="hljs-number">43758.5453123</span>);
}
</code></pre>
<h3 id="heading-visualizing-the-output">Visualizing the Output</h3>
<p>If we output this hash directly to the screen:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> noise = hash_2d(<span class="hljs-keyword">in</span>.uv * <span class="hljs-number">100.0</span>);
<span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(noise), <span class="hljs-number">1.0</span>);
</code></pre>
<p>We get "White Noise" - pure, unconnected static.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764433938199/9aeed5d0-e62b-429f-84f1-8f30a600ab3c.png" alt class="image--center mx-auto" /></p>
<p>This is our raw material. To make it organic, we need to <strong>interpolate</strong> it.</p>
<h2 id="heading-value-noise-interpolating-random-values">Value Noise: Interpolating Random Values</h2>
<p>The simplest form of procedural noise is <strong>Value Noise</strong>.</p>
<p>The concept is straightforward: imagine an infinite grid (like graph paper).</p>
<ol>
<li><p>At every intersection (integer coordinate), we generate a random <strong>value</strong> using our Hash function.</p>
</li>
<li><p>For any point between the intersections, we smoothly blend the values of the four nearest corners.</p>
</li>
</ol>
<h3 id="heading-the-concept">The Concept</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764434046355/9b4038c0-8957-4fe7-9279-0b6de5f99d96.png" alt class="image--center mx-auto" /></p>
<p>To find the value at point P:</p>
<ol>
<li><p>Identify the 4 corners.</p>
</li>
<li><p>Hash them to get the random values (0.3, 0.9, 0.8, 0.2).</p>
</li>
<li><p>Blend based on how close <code>P</code> is to each corner.</p>
</li>
</ol>
<h3 id="heading-implementation">Implementation</h3>
<p>Here is the complete WGSL function for 2D Value Noise.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">value_noise_2d</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// 1. Grid Identification</span>
    <span class="hljs-comment">// floor(p) gives us the integer coordinate of the bottom-left corner.</span>
    <span class="hljs-keyword">let</span> i = floor(p);
    <span class="hljs-comment">// fract(p) gives us the position within the grid square (0.0 to 1.0).</span>
    <span class="hljs-keyword">let</span> f = fract(p);

    <span class="hljs-comment">// 2. Hash the 4 Corners</span>
    <span class="hljs-comment">// We add specific offsets to 'i' to get the 4 neighbor coordinates.</span>
    <span class="hljs-keyword">let</span> v00 = hash_2d(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>)); <span class="hljs-comment">// Bottom-Left</span>
    <span class="hljs-keyword">let</span> v10 = hash_2d(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>)); <span class="hljs-comment">// Bottom-Right</span>
    <span class="hljs-keyword">let</span> v01 = hash_2d(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>)); <span class="hljs-comment">// Top-Left</span>
    <span class="hljs-keyword">let</span> v11 = hash_2d(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>)); <span class="hljs-comment">// Top-Right</span>

    <span class="hljs-comment">// 3. Smooth Interpolation Curve</span>
    <span class="hljs-comment">// If we just used 'f' directly, we'd get linear mixing (pointy mountains).</span>
    <span class="hljs-comment">// We use a "Smoothstep" curve (Hermite interpolation) to round off the edges.</span>
    <span class="hljs-comment">// Formula: 3x^2 - 2x^3</span>
    <span class="hljs-keyword">let</span> u = f * f * (<span class="hljs-number">3.0</span> - <span class="hljs-number">2.0</span> * f);

    <span class="hljs-comment">// 4. Bilinear Interpolation (Mixing)</span>
    <span class="hljs-comment">// First, mix the bottom two corners horizontally</span>
    <span class="hljs-keyword">let</span> mix_bottom = mix(v00, v10, u.x);
    <span class="hljs-comment">// Next, mix the top two corners horizontally</span>
    <span class="hljs-keyword">let</span> mix_top    = mix(v01, v11, u.x);
    <span class="hljs-comment">// Finally, mix those two results vertically</span>
    <span class="hljs-keyword">return</span> mix(mix_bottom, mix_top, u.y);
}
</code></pre>
<h3 id="heading-why-the-smooth-step-matters">Why the "Smooth" Step Matters</h3>
<p>In step 3, we modified our interpolation factor <code>f</code> to create <code>u</code>. Why not just use <code>f</code>?</p>
<ul>
<li><p><strong>Linear Mix (</strong><code>f</code>): Creates sharp, diamond-shaped artifacts. The gradient changes abruptly at grid lines. It looks like a low-poly terrain.</p>
</li>
<li><p><strong>Smooth Mix (</strong><code>u</code>): Creates an "S-curve." It starts slow, speeds up in the middle, and slows down at the end. This ensures the slope of the noise is zero at the grid boundaries, making the transition to the next grid cell invisible.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764434146993/00abd91a-1c85-43d0-b7f2-96168b216477.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-characteristics-of-value-noise">Characteristics of Value Noise</h3>
<ul>
<li><p><strong>Pros</strong>: Very cheap to calculate. Easy to understand.</p>
</li>
<li><p><strong>Cons</strong>: It tends to look "blocky" or "square-ish" because the random values are aligned to a strict grid. Even with smoothing, your eye can often detect the underlying squares.</p>
</li>
</ul>
<p>To fix the "blockiness," we need something smarter: <strong>Gradient Noise</strong>.</p>
<h2 id="heading-gradient-noise-perlin-style-smoothness">Gradient Noise: Perlin-Style Smoothness</h2>
<p>To solve the blocky look of Value Noise, <a target="_blank" href="https://en.wikipedia.org/wiki/Ken_Perlin">Ken Perlin</a> invented <strong>Gradient Noise</strong> (often called <a target="_blank" href="https://en.wikipedia.org/wiki/Perlin_noise">Perlin Noise</a>).</p>
<h3 id="heading-the-key-difference">The Key Difference</h3>
<p>In Value Noise, we assigned a random <strong>height</strong> (0.0 to 1.0) to each grid corner.<br />In Gradient Noise, we assign a random <strong>slope</strong> (direction vector) to each grid corner.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764434287432/9e4ebacc-ec33-4f5b-af3a-8f62c38545fd.png" alt class="image--center mx-auto" /></p>
<p>Instead of simply interpolating the height, we calculate the height based on where the slope "points."</p>
<ul>
<li><p>If the slope at a corner points towards our pixel, the value increases.</p>
</li>
<li><p>If it points <em>away</em>, the value decreases.</p>
</li>
</ul>
<p>This results in a structure that naturally rises and falls like waves, hiding the underlying grid structure much better than Value Noise.</p>
<h3 id="heading-implementation-1">Implementation</h3>
<p>Implementing Gradient Noise is slightly more complex. We need a helper function to turn our hash into a random unit vector.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Helper: Turn a grid coordinate into a random unit vector</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">random_gradient_2d</span></span>(cell: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec2&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Get a random angle (0 to 2PI)</span>
    <span class="hljs-keyword">let</span> random = hash_2d(cell);
    <span class="hljs-keyword">let</span> angle = random * <span class="hljs-number">6.283185307</span>; <span class="hljs-comment">// 2 * PI</span>

    <span class="hljs-comment">// 2. Convert angle to a vector (x, y)</span>
    <span class="hljs-keyword">return</span> vec2&lt;<span class="hljs-built_in">f32</span>&gt;(cos(angle), sin(angle));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">gradient_noise_2d</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// 1. Grid Identification (Same as Value Noise)</span>
    <span class="hljs-keyword">let</span> i = floor(p);
    <span class="hljs-keyword">let</span> f = fract(p);

    <span class="hljs-comment">// 2. Get smooth interpolation weights (The S-Curve)</span>
    <span class="hljs-keyword">let</span> u = f * f * (<span class="hljs-number">3.0</span> - <span class="hljs-number">2.0</span> * f);

    <span class="hljs-comment">// 3. Calculate the 4 corners</span>
    <span class="hljs-comment">// For each corner, we do two things:</span>
    <span class="hljs-comment">//   a. Get the random gradient vector (g)</span>
    <span class="hljs-comment">//   b. Get the distance vector from corner to pixel (d)</span>
    <span class="hljs-comment">//   c. Dot product (g . d) determines the value contribution</span>

    <span class="hljs-comment">// Bottom-Left</span>
    <span class="hljs-keyword">let</span> g00 = random_gradient_2d(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>));
    <span class="hljs-keyword">let</span> d00 = f - vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> v00 = dot(g00, d00);

    <span class="hljs-comment">// Bottom-Right</span>
    <span class="hljs-keyword">let</span> g10 = random_gradient_2d(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>));
    <span class="hljs-keyword">let</span> d10 = f - vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> v10 = dot(g10, d10);

    <span class="hljs-comment">// Top-Left</span>
    <span class="hljs-keyword">let</span> g01 = random_gradient_2d(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> d01 = f - vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
    <span class="hljs-keyword">let</span> v01 = dot(g01, d01);

    <span class="hljs-comment">// Top-Right</span>
    <span class="hljs-keyword">let</span> g11 = random_gradient_2d(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> d11 = f - vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>);
    <span class="hljs-keyword">let</span> v11 = dot(g11, d11);

    <span class="hljs-comment">// 4. Bilinear Interpolation</span>
    <span class="hljs-comment">// Blend the dot product results</span>
    <span class="hljs-keyword">let</span> mix_bottom = mix(v00, v10, u.x);
    <span class="hljs-keyword">let</span> mix_top    = mix(v01, v11, u.x);
    <span class="hljs-keyword">let</span> result     = mix(mix_bottom, mix_top, u.y);

    <span class="hljs-comment">// 5. Normalization</span>
    <span class="hljs-comment">// The result of the dot products is roughly between -0.7 and 0.7.</span>
    <span class="hljs-comment">// We map it to 0.0 -&gt; 1.0 for easier use in colors.</span>
    <span class="hljs-keyword">return</span> result * <span class="hljs-number">0.5</span> + <span class="hljs-number">0.5</span>;
}
</code></pre>
<h3 id="heading-3d-gradient-noise">3D Gradient Noise</h3>
<p>The concept extends perfectly to 3D. Instead of a square with 4 corners, we use a cube with 8 corners. We generate 3D gradient vectors for each corner, calculate dot products, and use trilinear interpolation (mix X, then mix Y, then mix Z).</p>
<p>This is the standard noise used for volumetric clouds, smoke, and marble.</p>
<blockquote>
<p><strong>Pro Tip: Simplex Noise</strong><br />You might hear about "<a target="_blank" href="https://en.wikipedia.org/wiki/Simplex_noise#:~:text=Simplex%20noise%20is%20the%20result,and%20a%20lower%20computational%20overhead.">Simplex Noise</a>." It is an improvement on Perlin Noise that uses a triangular grid (triangles/tetrahedrons) instead of squares/cubes. It is faster in high dimensions and has fewer artifacts. However, the math is significantly harder to implement from scratch. For most game effects, standard Gradient Noise is perfectly sufficient and easier to debug.</p>
</blockquote>
<h2 id="heading-fractal-brownian-motion-fbm">Fractal Brownian Motion (FBM)</h2>
<p>Single-layer noise is smooth, but it looks like "blobs." It lacks the gritty detail of real nature.<br />Real terrain has large mountains, medium boulders, small rocks, and tiny pebbles.</p>
<p>To mimic this, we use <a target="_blank" href="https://thebookofshaders.com/13/"><strong>Fractal Brownian Motion (FBM)</strong></a>.<br />FBM isn't a new type of noise; it's a technique of <strong>layering</strong> noise. We stack multiple layers (called <strong>Octaves</strong>) of noise on top of each other.</p>
<h3 id="heading-the-formula">The Formula</h3>
<p>For each new layer, we change two things:</p>
<ol>
<li><p><strong>Frequency</strong>: We zoom out (multiply the coordinate). Usually doubled (<code>x2</code>).</p>
</li>
<li><p><strong>Amplitude</strong>: We reduce the strength. Usually halved (<code>x0.5</code>).</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764434718720/5a17f4dd-171b-4413-920c-b2542497fd58.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-implementation-2">Implementation</h3>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fbm_noise</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, octaves: <span class="hljs-built_in">u32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    var value = <span class="hljs-number">0.0</span>;
    var amplitude = <span class="hljs-number">0.5</span>;
    var frequency = <span class="hljs-number">1.0</span>;

    <span class="hljs-comment">// Loop through the octaves</span>
    <span class="hljs-keyword">for</span> (var i = <span class="hljs-number">0</span>u; i &lt; octaves; i++) {
        <span class="hljs-comment">// Sample the noise</span>
        value += amplitude * gradient_noise_2d(p * frequency);

        <span class="hljs-comment">// Prepare for next layer</span>
        frequency *= <span class="hljs-number">2.0</span>; <span class="hljs-comment">// "Lacunarity": How fast frequency grows</span>
        amplitude *= <span class="hljs-number">0.5</span>; <span class="hljs-comment">// "Gain": How fast amplitude shrinks</span>
    }

    <span class="hljs-keyword">return</span> value;
}
</code></pre>
<h3 id="heading-fbm-parameters">FBM Parameters</h3>
<p>You can tweak the "feel" of the noise by changing the multipliers:</p>
<ul>
<li><p><strong>Lacunarity (Frequency multiplier)</strong>: Controls how "busy" the detail is. Higher values (e.g., 3.0) make the detail look more scattered.</p>
</li>
<li><p><strong>Gain (Amplitude multiplier)</strong>: Controls "roughness."</p>
<ul>
<li><p><code>0.5</code>: Standard "cloudy" look.</p>
</li>
<li><p><code>0.8</code>: Very rough, noisy look (good for rocks).</p>
</li>
<li><p><code>0.3</code>: Very smooth, subtle detail (good for water).</p>
</li>
</ul>
</li>
</ul>
<h3 id="heading-specialized-fbm-turbulence">Specialized FBM: Turbulence</h3>
<p>If we take the absolute value of the noise <code>abs(noise)</code> before adding it, we turn the smooth valleys into sharp creases. This creates a "Ridged" look, perfect for fire, lightning, or marble veins.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Turbulence: Sharp valleys</span>
value += amplitude * abs(gradient_noise_2d(p * frequency));
</code></pre>
<h2 id="heading-domain-warping">Domain Warping</h2>
<p>If FBM is the tool for adding detail, <strong>Domain Warping</strong> is the tool for adding "flow." It is the secret technique behind liquid, surreal, and highly organic effects.</p>
<p>The concept is meta: <strong>Use the output of one noise function to distort the input coordinates of another.</strong></p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">domain_warp</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// 1. Create a vector based on noise</span>
    <span class="hljs-keyword">let</span> offset = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(
        gradient_noise_2d(p),
        gradient_noise_2d(p + <span class="hljs-number">5.2</span>) <span class="hljs-comment">// Offset to get a different random value</span>
    );

    <span class="hljs-comment">// 2. Distort the original coordinate 'p' by this noisy offset</span>
    <span class="hljs-comment">// The multiplier (4.0) controls the strength of the warp</span>
    <span class="hljs-keyword">let</span> warped_p = p + offset * <span class="hljs-number">4.0</span>;

    <span class="hljs-comment">// 3. Sample noise at the new, warped location</span>
    <span class="hljs-keyword">return</span> gradient_noise_2d(warped_p);
}
</code></pre>
<p><strong>Visualizing the effect:</strong><br />Imagine a pattern of straight stripes.</p>
<ul>
<li><p><strong>Standard Noise</strong>: The stripes get brighter and darker, but stay straight.</p>
</li>
<li><p><strong>Domain Warping</strong>: The stripes bend, twist, and swirl like oil paint on water.</p>
</li>
</ul>
<p>This technique is essential for effects like <strong>smoke, fire, marble, and psychedelic backgrounds</strong>.</p>
<h2 id="heading-texture-based-vs-calculated-noise">Texture-Based vs. Calculated Noise</h2>
<p>In game development, you have two ways to get noise into your shader. Knowing which to choose is a critical optimization skill.</p>
<h3 id="heading-1-calculated-noise-what-we-just-learned">1. Calculated Noise (What we just learned)</h3>
<p>We compute the noise math inside the shader, on the fly, for every pixel.</p>
<ul>
<li><p><strong>✅ Pros</strong>: Infinite resolution. No pixels ever. You can animate it easily by adding <code>time</code> to the coordinates. 3D noise costs no memory.</p>
</li>
<li><p><strong>❌ Cons</strong>: Expensive. Calculating FBM with 6 octaves requires dozens of math operations per pixel. Heavy use can drop your frame rate, especially on mobile GPUs.</p>
</li>
</ul>
<h3 id="heading-2-texture-based-noise">2. Texture-Based Noise</h3>
<p>We generate the noise offline (in Photoshop or code), save it as a seamlessly tiling PNG texture, and sample it.</p>
<ul>
<li><p><strong>✅ Pros</strong>: Extremely fast. It's just one memory lookup (<code>textureSample</code>).</p>
</li>
<li><p><strong>❌ Cons</strong>: Finite resolution. If you get too close, you see pixels (unless you mix in expensive texture filtering). Repetition can be obvious.</p>
</li>
</ul>
<h3 id="heading-the-hybrid-pro-approach">The Hybrid "Pro" Approach</h3>
<p>Triple-A games often combine both:</p>
<ol>
<li><p>Use a <strong>Noise Texture</strong> for the base shape (low frequency). This is fast and covers the bulk of the work.</p>
</li>
<li><p>Add <strong>Calculated Noise</strong> for the tiny details (high frequency) on top.</p>
</li>
</ol>
<p>Since the tiny details are small, you often only need 1 or 2 octaves of calculated noise to make the texture look infinite, saving massive amounts of GPU power compared to calculating the whole thing.</p>
<h2 id="heading-recipes-organic-patterns">Recipes: Organic Patterns</h2>
<p>Before we build the final demo, here are the "recipes" for common materials. These are just logical combinations of the functions we've built.</p>
<h3 id="heading-wood">Wood</h3>
<p>Wood is composed of rings (distance from a center line) distorted by noise.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// 1. Basic Ring Pattern</span>
<span class="hljs-keyword">let</span> dist = length(p.xy);     <span class="hljs-comment">// Distance from center</span>
<span class="hljs-keyword">let</span> rings = sin(dist * <span class="hljs-number">20.0</span>); <span class="hljs-comment">// Concentric rings</span>

<span class="hljs-comment">// 2. Make it Organic</span>
<span class="hljs-comment">// Add noise to the coordinate BEFORE calculating distance</span>
<span class="hljs-keyword">let</span> noise = gradient_noise_3d(p);
<span class="hljs-keyword">let</span> dist = length(p.xy + noise * <span class="hljs-number">0.5</span>); <span class="hljs-comment">// Distorted distance</span>
<span class="hljs-keyword">let</span> wood_grain = sin(dist * <span class="hljs-number">20.0</span>);
</code></pre>
<h3 id="heading-marble">Marble</h3>
<p>Marble is smooth rock with sharp veins. We use <strong>Turbulence</strong> (absolute value of noise) to create the veins, and use the sine function to create layers.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> noise = turbulence(p * <span class="hljs-number">2.0</span>); 
<span class="hljs-comment">// Distortion creates the "veins" through the sine wave layers</span>
<span class="hljs-keyword">let</span> marble = sin(p.x * <span class="hljs-number">10.0</span> + noise * <span class="hljs-number">5.0</span>);
</code></pre>
<h3 id="heading-clouds">Clouds</h3>
<p>Clouds are simply FBM masked by a density threshold.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> density = fbm_noise(p, <span class="hljs-number">6</span>u);
<span class="hljs-comment">// Smoothstep clears out the low-density "wisps" to create empty sky</span>
<span class="hljs-keyword">let</span> cloud = smoothstep(<span class="hljs-number">0.4</span>, <span class="hljs-number">0.8</span>, density);
</code></pre>
<hr />
<h2 id="heading-complete-example-animated-procedural-clouds">Complete Example: Animated Procedural Clouds</h2>
<p>We will build a shader that renders a dynamic, wind-blown sky. It uses:</p>
<ol>
<li><p><strong>Gradient Noise</strong> for the base.</p>
</li>
<li><p><strong>FBM</strong> to create fluffy detail.</p>
</li>
<li><p><strong>Domain Warping</strong> to make the clouds drift and morph organically.</p>
</li>
<li><p><strong>Time-based animation</strong> for wind.</p>
</li>
</ol>
<h3 id="heading-our-goal">Our Goal</h3>
<p>A sky quad where clouds drift, morph, and interact with a "sun" light direction.</p>
<h3 id="heading-the-shader-assetsshadersd0306proceduralcloudswgsl">The Shader (<code>assets/shaders/d03_06_procedural_clouds.wgsl</code>)</h3>
<blockquote>
<p><strong>Note on Imports</strong>: In a real project, you would put the noise functions in a file like <code>assets/shaders/noise_utils.wgsl</code> and import them using <code>#import "shaders/noise_utils.wgsl"::hash_2d</code>. For this tutorial, we include them in the same file to make copy-pasting easier.</p>
</blockquote>
<pre><code class="lang-rust">#import bevy_pbr::forward_io::VertexOutput

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">CloudMaterial</span></span> {
    time: <span class="hljs-built_in">f32</span>,
    cloud_scale: <span class="hljs-built_in">f32</span>,
    cloud_speed: <span class="hljs-built_in">f32</span>,
    density_threshold: <span class="hljs-built_in">f32</span>,
    octaves: <span class="hljs-built_in">u32</span>,
    <span class="hljs-comment">// Colors</span>
    sky_color_a: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    sky_color_b: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    cloud_color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: CloudMaterial;

<span class="hljs-comment">// --- NOISE LIBRARY START ---</span>

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash_2d</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> n = dot(p, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">127.1</span>, <span class="hljs-number">311.7</span>));
    <span class="hljs-keyword">return</span> fract(sin(n) * <span class="hljs-number">43758.5453123</span>);
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">noise_2d</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> i = floor(p);
    <span class="hljs-keyword">let</span> f = fract(p);

    <span class="hljs-comment">// Four corners</span>
    <span class="hljs-keyword">let</span> a = hash_2d(i);
    <span class="hljs-keyword">let</span> b = hash_2d(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>));
    <span class="hljs-keyword">let</span> c = hash_2d(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> d = hash_2d(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));

    <span class="hljs-comment">// Smooth interpolation</span>
    <span class="hljs-keyword">let</span> u = f * f * (<span class="hljs-number">3.0</span> - <span class="hljs-number">2.0</span> * f);

    <span class="hljs-comment">// Mix</span>
    <span class="hljs-keyword">return</span> mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

<span class="hljs-comment">// Simple FBM</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fbm</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, octaves: <span class="hljs-built_in">u32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    var value = <span class="hljs-number">0.0</span>;
    var amp = <span class="hljs-number">0.5</span>;
    var freq = <span class="hljs-number">1.0</span>;

    <span class="hljs-keyword">for</span>(var i = <span class="hljs-number">0</span>u; i &lt; octaves; i++) {
        value += amp * noise_2d(p * freq);
        freq *= <span class="hljs-number">2.0</span>;
        amp *= <span class="hljs-number">0.5</span>;
    }
    <span class="hljs-keyword">return</span> value;
}

<span class="hljs-comment">// --- NOISE LIBRARY END ---</span>

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Correct UVs to be centered and proportional</span>
    <span class="hljs-keyword">let</span> uv = <span class="hljs-keyword">in</span>.uv * material.cloud_scale;

    <span class="hljs-comment">// 2. Animate coordinates (Wind)</span>
    <span class="hljs-keyword">let</span> wind = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(material.time * material.cloud_speed, material.time * material.cloud_speed * <span class="hljs-number">0.2</span>);
    <span class="hljs-keyword">let</span> p = uv + wind;

    <span class="hljs-comment">// 3. Domain Warping</span>
    <span class="hljs-comment">// We use low-frequency noise to distort the input of the high-freq FBM</span>
    <span class="hljs-keyword">let</span> q = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(
        fbm(p, material.octaves),
        fbm(p + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">5.2</span>, <span class="hljs-number">1.3</span>), material.octaves)
    );

    <span class="hljs-comment">// The actual cloud density calculation</span>
    <span class="hljs-comment">// We warp 'p' by adding 'q'</span>
    <span class="hljs-keyword">let</span> density = fbm(p + q * <span class="hljs-number">1.5</span>, material.octaves);

    <span class="hljs-comment">// 4. Shaping the Clouds</span>
    <span class="hljs-comment">// Use smoothstep to create defined cloud shapes vs empty sky</span>
    <span class="hljs-keyword">let</span> cloud_mask = smoothstep(material.density_threshold, material.density_threshold + <span class="hljs-number">0.2</span>, density);

    <span class="hljs-comment">// 5. Coloring</span>
    <span class="hljs-comment">// Gradient sky background</span>
    <span class="hljs-keyword">let</span> sky = mix(material.sky_color_a, material.sky_color_b, <span class="hljs-keyword">in</span>.uv.y);

    <span class="hljs-comment">// Mix sky with cloud color</span>
    <span class="hljs-comment">// We add a subtle highlight based on the density derivative for fake depth</span>
    <span class="hljs-keyword">let</span> final_color = mix(sky, material.cloud_color, cloud_mask);

    <span class="hljs-keyword">return</span> final_color;
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0306proceduralcloudsrs">The Rust Material (<code>src/materials/d03_06_procedural_clouds.rs</code>)</h3>
<p>We map the Rust struct to the WGSL struct. Note that <code>vec4&lt;f32&gt;</code> in WGSL requires 16-byte alignment. Bevy's <code>ShaderType</code> derive macro handles the padding between the <code>u32</code> (octaves) and the first color automatically, ensuring the data aligns correctly in the GPU buffer.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>
    <span class="hljs-keyword">use</span> bevy::prelude::*;
    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">CloudMaterialUniforms</span></span> {
        <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> cloud_scale: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> cloud_speed: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> density_threshold: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> octaves: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> sky_color_a: LinearRgba,
        <span class="hljs-keyword">pub</span> sky_color_b: LinearRgba,
        <span class="hljs-keyword">pub</span> cloud_color: LinearRgba,
    }
}

<span class="hljs-keyword">use</span> uniforms::CloudMaterialUniforms;

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">CloudMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: CloudMaterialUniforms,
}

<span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> CloudMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
        <span class="hljs-keyword">Self</span> {
            uniforms: CloudMaterialUniforms {
                time: <span class="hljs-number">0.0</span>,
                cloud_scale: <span class="hljs-number">3.5</span>,
                cloud_speed: <span class="hljs-number">0.2</span>,
                density_threshold: <span class="hljs-number">0.5</span>,
                octaves: <span class="hljs-number">4</span>,
                sky_color_a: LinearRgba::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.4</span>, <span class="hljs-number">0.8</span>, <span class="hljs-number">1.0</span>), <span class="hljs-comment">// Deep Blue</span>
                sky_color_b: LinearRgba::new(<span class="hljs-number">0.4</span>, <span class="hljs-number">0.7</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>), <span class="hljs-comment">// Cyan</span>
                cloud_color: LinearRgba::new(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>), <span class="hljs-comment">// White</span>
            },
        }
    }
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> CloudMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d03_06_procedural_clouds.wgsl"</span>.into()
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_06_procedural_clouds;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0306proceduralcloudsrs">The Demo Module (<code>src/demos/d03_06_procedural_clouds.rs</code>)</h3>
<p>This demo sets up a live environment where you can tune the noise parameters. Seeing how octaves affects detail or how <code>density_threshold</code> changes the cloud cover in real-time is the best way to understand the math.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d03_06_procedural_clouds::CloudMaterial;
<span class="hljs-keyword">use</span> bevy::prelude::*;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;CloudMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(Update, (update_time, handle_input, update_ui))
        .run();
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;CloudMaterial&gt;&gt;,
) {
    commands.spawn((Camera3d::default(), Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span>)));

    commands.spawn((
        Mesh3d(meshes.add(Rectangle::new(<span class="hljs-number">2.0</span>, <span class="hljs-number">2.0</span>))),
        MeshMaterial3d(materials.add(CloudMaterial::default())),
        Transform::default(),
    ));

    <span class="hljs-comment">// UI Information</span>
    commands.spawn((
        Text::new(<span class="hljs-string">"Procedural Clouds Demo"</span>),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">12.0</span>),
            left: Val::Px(<span class="hljs-number">12.0</span>),
            ..default()
        },
        TextFont {
            font_size: <span class="hljs-number">16.0</span>,
            ..default()
        },
        TextColor(Color::WHITE),
        BackgroundColor(Color::srgba(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.7</span>)),
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_time</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;CloudMaterial&gt;&gt;) {
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        material.uniforms.time = time.elapsed_secs();
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;, <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;CloudMaterial&gt;&gt;) {
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-keyword">let</span> u = &amp;<span class="hljs-keyword">mut</span> material.uniforms;

        <span class="hljs-comment">// Density</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowUp) {
            u.density_threshold = (u.density_threshold + <span class="hljs-number">0.01</span>).min(<span class="hljs-number">1.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowDown) {
            u.density_threshold = (u.density_threshold - <span class="hljs-number">0.01</span>).max(<span class="hljs-number">0.0</span>);
        }

        <span class="hljs-comment">// Scale</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowRight) {
            u.cloud_scale += <span class="hljs-number">0.05</span>;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowLeft) {
            u.cloud_scale = (u.cloud_scale - <span class="hljs-number">0.05</span>).max(<span class="hljs-number">0.1</span>);
        }

        <span class="hljs-comment">// Octaves (Integer steps)</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyO) {
            u.octaves = u.octaves.saturating_sub(<span class="hljs-number">1</span>).max(<span class="hljs-number">1</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyP) {
            u.octaves = (u.octaves + <span class="hljs-number">1</span>).min(<span class="hljs-number">8</span>);
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(materials: Res&lt;Assets&lt;CloudMaterial&gt;&gt;, <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;) {
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, mat)) = materials.iter().next() {
        <span class="hljs-keyword">let</span> u = &amp;mat.uniforms;
        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
            **text = <span class="hljs-built_in">format!</span>(
                <span class="hljs-string">"CONTROLS:\n\
                [Up/Down] Density Threshold: {:.2}\n\
                [Left/Right] Scale: {:.2}\n\
                [O/P] Octaves: {}\n\
                (Watch the detail change with Octaves!)"</span>,
                u.density_threshold, u.cloud_scale, u.octaves
            );
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_06_procedural_clouds;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"3.6a"</span>,
    title: <span class="hljs-string">"Procedural Noise - Clouds"</span>,
    run: demos::d03_06_procedural_clouds::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key</td><td>Action</td><td>Description</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Up / Down</strong></td><td>Adjust Density</td><td>Controls the "cutoff" point. Higher values erode the clouds; lower values fill the sky.</td></tr>
<tr>
<td><strong>Left / Right</strong></td><td>Adjust Scale</td><td>Zooms the noise coordinate system in or out.</td></tr>
<tr>
<td><strong>O / P</strong></td><td>Change Octaves</td><td>Add or remove layers of detail. Watch the FPS if you go too high!</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764260809555/7031a4c0-cd74-4a4b-a03d-ed56bbede969.png" alt class="image--center mx-auto" /></p>
<ul>
<li><p><strong>The Drift</strong>: The clouds move continuously because we add <code>time</code> to the UVs.</p>
</li>
<li><p><strong>The Shape</strong>: It doesn't look like a repeating texture. It looks organic because of <strong>Domain Warping</strong>.</p>
</li>
<li><p><strong>The Edges</strong>: The <code>smoothstep</code> creates soft, fluffy edges instead of hard cutouts.</p>
</li>
<li><p><strong>Detail Levels</strong>: Press 'O' until Octaves = 1. You will see blurry, shapeless blobs. Press 'P' up to 4 or 5, and notice how the tiny, gritty details appear on the edges. That is FBM in action.</p>
</li>
</ul>
<h2 id="heading-bonus-mini-project-procedural-marble">Bonus Mini-Project: Procedural Marble</h2>
<p>While clouds use 2D noise to manipulate UVs, solid materials like marble, wood, or stone use <strong>3D noise</strong> based on <strong>World Position</strong>. This ensures that the texture wraps perfectly around the object without seams, stretching, or UV mapping headaches.</p>
<p>This technique is often called <strong>Solid Texturing</strong>. Imagine carving your object out of a solid block of wood or stone; the grain exists inside the object, not just painted on the surface.</p>
<h3 id="heading-the-shader-assetsshadersproceduralmarblewgsl">The Shader (<code>assets/shaders/procedural_marble.wgsl</code>)</h3>
<pre><code class="lang-rust">#import bevy_pbr::forward_io::VertexOutput

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">MarbleMaterial</span></span> {
    vein_frequency: <span class="hljs-built_in">f32</span>,
    roughness: <span class="hljs-built_in">f32</span>,
    vein_color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    base_color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: MarbleMaterial;

<span class="hljs-comment">// --- 3D NOISE FUNCTIONS ---</span>

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash_3d</span></span>(p: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> n = dot(p, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">127.1</span>, <span class="hljs-number">311.7</span>, <span class="hljs-number">74.7</span>));
    <span class="hljs-keyword">return</span> fract(sin(n) * <span class="hljs-number">43758.5453123</span>);
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">noise_3d</span></span>(p: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> i = floor(p);
    <span class="hljs-keyword">let</span> f = fract(p);

    <span class="hljs-comment">// Smooth interpolation (S-Curve)</span>
    <span class="hljs-keyword">let</span> u = f * f * (<span class="hljs-number">3.0</span> - <span class="hljs-number">2.0</span> * f);

    <span class="hljs-comment">// 8 Corners of the cube</span>
    <span class="hljs-keyword">let</span> a = hash_3d(i + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>));
    <span class="hljs-keyword">let</span> b = hash_3d(i + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>));
    <span class="hljs-keyword">let</span> c = hash_3d(i + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>));
    <span class="hljs-keyword">let</span> d = hash_3d(i + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>));
    <span class="hljs-keyword">let</span> e = hash_3d(i + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> f_ = hash_3d(i + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>)); <span class="hljs-comment">// 'f' is reserved</span>
    <span class="hljs-keyword">let</span> g = hash_3d(i + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> h = hash_3d(i + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));

    <span class="hljs-comment">// Trilinear Mix</span>
    <span class="hljs-keyword">let</span> mix_1 = mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
    <span class="hljs-keyword">let</span> mix_2 = mix(mix(e, f_, u.x), mix(g, h, u.x), u.y);
    <span class="hljs-keyword">return</span> mix(mix_1, mix_2, u.z);
}

<span class="hljs-comment">// Turbulence: Sum of absolute noise values</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">turbulence</span></span>(p: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, octaves: <span class="hljs-built_in">u32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    var value = <span class="hljs-number">0.0</span>;
    var amp = <span class="hljs-number">0.5</span>;
    var freq = <span class="hljs-number">1.0</span>;

    <span class="hljs-keyword">for</span>(var i = <span class="hljs-number">0</span>u; i &lt; octaves; i++) {
        <span class="hljs-comment">// abs() creates the sharp ridges for veins</span>
        value += amp * abs(noise_3d(p * freq));
        freq *= <span class="hljs-number">2.0</span>;
        amp *= <span class="hljs-number">0.5</span>;
    }
    <span class="hljs-keyword">return</span> value;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Solid Texturing: Use world position.</span>
    <span class="hljs-keyword">let</span> p = <span class="hljs-keyword">in</span>.world_position.xyz;

    <span class="hljs-comment">// 1. Calculate Turbulence</span>
    <span class="hljs-keyword">let</span> t = turbulence(p * material.roughness, <span class="hljs-number">4</span>u);

    <span class="hljs-comment">// 2. Domain Warping the Sine Wave</span>
    <span class="hljs-comment">// Standard marble formula: sin(x + turbulence)</span>
    <span class="hljs-comment">// The sine wave creates layers; the turbulence distorts them.</span>
    <span class="hljs-keyword">let</span> pattern = sin(p.x * material.vein_frequency + t * <span class="hljs-number">4.0</span>);

    <span class="hljs-comment">// 3. Map -1.0 to 1.0 range -&gt; 0.0 to 1.0 range</span>
    <span class="hljs-keyword">let</span> mask = pattern * <span class="hljs-number">0.5</span> + <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// 4. Sharpen the veins</span>
    <span class="hljs-keyword">let</span> sharp_mask = pow(mask, <span class="hljs-number">4.0</span>);

    <span class="hljs-keyword">return</span> mix(material.vein_color, material.base_color, sharp_mask);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsproceduralmarblers">The Rust Material (<code>src/materials/procedural_marble.rs</code>)</h3>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>
    <span class="hljs-keyword">use</span> bevy::prelude::*;
    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">MarbleMaterialUniforms</span></span> {
        <span class="hljs-keyword">pub</span> vein_frequency: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> roughness: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> vein_color: LinearRgba,
        <span class="hljs-keyword">pub</span> base_color: LinearRgba,
    }
}

<span class="hljs-keyword">use</span> uniforms::MarbleMaterialUniforms;

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">MarbleMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: MarbleMaterialUniforms,
}

<span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> MarbleMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
        <span class="hljs-keyword">Self</span> {
            uniforms: MarbleMaterialUniforms {
                vein_frequency: <span class="hljs-number">10.0</span>,
                roughness: <span class="hljs-number">1.5</span>,
                vein_color: LinearRgba::new(<span class="hljs-number">0.1</span>, <span class="hljs-number">0.1</span>, <span class="hljs-number">0.1</span>, <span class="hljs-number">1.0</span>),
                base_color: LinearRgba::new(<span class="hljs-number">0.9</span>, <span class="hljs-number">0.9</span>, <span class="hljs-number">0.85</span>, <span class="hljs-number">1.0</span>),
            },
        }
    }
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> MarbleMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/procedural_marble.wgsl"</span>.into()
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> procedural_marble;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosproceduralmarblers">The Demo Module (<code>src/demos/procedural_marble.rs</code>)</h3>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::procedural_marble::MarbleMaterial;
<span class="hljs-keyword">use</span> bevy::prelude::*;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;MarbleMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(Update, (handle_input, update_ui))
        .run();
}

<span class="hljs-meta">#[derive(Component)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">Movable</span></span>;

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;MarbleMaterial&gt;&gt;,
) {
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span>, <span class="hljs-number">4.0</span>).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    <span class="hljs-comment">// Shared material handle ensures settings apply to both objects</span>
    <span class="hljs-keyword">let</span> material = materials.add(MarbleMaterial::default());

    <span class="hljs-comment">// 1. Stationary Cube (The "Base")</span>
    commands.spawn((
        Mesh3d(meshes.add(Cuboid::new(<span class="hljs-number">2.0</span>, <span class="hljs-number">2.0</span>, <span class="hljs-number">2.0</span>))),
        MeshMaterial3d(material.clone()),
        Transform::from_xyz(-<span class="hljs-number">1.2</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
    ));

    <span class="hljs-comment">// 2. Movable Sphere (The "Rover")</span>
    commands.spawn((
        Mesh3d(meshes.add(Sphere::new(<span class="hljs-number">1.2</span>))),
        MeshMaterial3d(material),
        Transform::from_xyz(<span class="hljs-number">1.2</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        Movable,
    ));

    commands.spawn((
        Text::new(<span class="hljs-string">"Marble Demo"</span>),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">12.0</span>),
            left: Val::Px(<span class="hljs-number">12.0</span>),
            ..default()
        },
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    time: Res&lt;Time&gt;,
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;MarbleMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Transform, With&lt;Movable&gt;&gt;,
) {
    <span class="hljs-comment">// Material Controls</span>
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-keyword">let</span> u = &amp;<span class="hljs-keyword">mut</span> material.uniforms;
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyW) {
            u.vein_frequency += <span class="hljs-number">0.1</span>;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyS) {
            u.vein_frequency = (u.vein_frequency - <span class="hljs-number">0.1</span>).max(<span class="hljs-number">0.0</span>);
        }
    }

    <span class="hljs-comment">// Movement Controls</span>
    <span class="hljs-keyword">let</span> speed = <span class="hljs-number">2.0</span> * time.delta_secs();
    <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> transform <span class="hljs-keyword">in</span> query.iter_mut() {
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowLeft) {
            transform.translation.x -= speed;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowRight) {
            transform.translation.x += speed;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowUp) {
            transform.translation.z -= speed;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowDown) {
            transform.translation.z += speed;
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(materials: Res&lt;Assets&lt;MarbleMaterial&gt;&gt;, <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;) {
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, mat)) = materials.iter().next() {
        <span class="hljs-keyword">let</span> u = &amp;mat.uniforms;
        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
            **text = <span class="hljs-built_in">format!</span>(
                <span class="hljs-string">"CONTROLS:\n\
                [Arrow Keys] Move Sphere\n\
                [W/S] Vein Frequency: {:.1}"</span>,
                u.vein_frequency
            );
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> procedural_marble;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"3.6b"</span>,
    title: <span class="hljs-string">"Procedural Noise - Marble"</span>,
    run: demos::d03_06_procedural_clouds::run,
},
</code></pre>
<h3 id="heading-running-the-demo-1">Running the Demo</h3>
<h4 id="heading-controls-1">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key</td><td>Action</td><td>Description</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Arrow Keys</strong></td><td>Move Sphere</td><td>Slide the sphere. Try moving it inside the cube.</td></tr>
<tr>
<td><strong>W / S</strong></td><td>Vein Frequency</td><td>Change the density of the marble layers.</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing-1">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764260847360/66c3f562-f333-4cde-9f7a-d4bb38c37478.png" alt class="image--center mx-auto" /></p>
<ul>
<li><p><strong>The "Swimming" Effect</strong>: As you move the sphere, the veins don't travel with it; they slide across the surface. This visually proves that the veins exist at specific coordinates in the world (<a target="_blank" href="http://in.world"><code>in.world</code></a><code>_position</code>), like a cloud that the object is passing through.</p>
</li>
<li><p><strong>Seamless Intersection</strong>: Move the sphere so it overlaps the cube. Notice how the veins line up perfectly at the junction. This is the superpower of solid texturing - you can jam any number of objects together, and they will look like a single carved block.</p>
</li>
</ul>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<ol>
<li><p><strong>Noise ≠ Random</strong>: Random is static; Noise is a smooth landscape.</p>
</li>
<li><p><strong>Gradient Noise</strong>: The standard for natural looks. It interpolates slopes, not just heights.</p>
</li>
<li><p><strong>FBM (Fractals)</strong>: Realism comes from layering noise at different frequencies (Octaves).</p>
</li>
<li><p><strong>Domain Warping</strong>: Distorting the coordinate space creates liquid, swirling effects.</p>
</li>
<li><p><strong>World Space Texturing</strong>: Using <code>world_position</code> instead of <code>uv</code> allows you to texture objects seamlessly in 3D, creating the effect of solid materials like stone or wood.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>We have mastered shapes with SDFs and textures with Noise. But there is a rendering technique that lets us simply delete pixels to create complex silhouettes like foliage, fences, or grates without extra geometry.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/37-fragment-discard-and-transparency"><strong><em>3.7 - Fragment Discard and Transparency</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<h3 id="heading-concept-map">Concept Map</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Term</td><td>Analogy</td><td>Good For...</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Value Noise</strong></td><td>Minecraft Terrain</td><td>Blocky, retro effects. Cheap.</td></tr>
<tr>
<td><strong>Gradient Noise</strong></td><td>Rolling Hills</td><td>Clouds, water, terrain. The gold standard.</td></tr>
<tr>
<td><strong>FBM</strong></td><td>Coastline</td><td>Adding detail. Makes noise look "crunchy" or "fluffy".</td></tr>
<tr>
<td><strong>Turbulence</strong></td><td>Creased Paper</td><td>Fire, marble veins, lightning (using <code>abs()</code>).</td></tr>
<tr>
<td><strong>Domain Warping</strong></td><td>Oil on Water</td><td>Liquid, psychedelic, or gooey effects.</td></tr>
</tbody>
</table>
</div><h3 id="heading-hash-vs-noise">Hash vs. Noise</h3>
<ul>
<li><p><strong>Hash</strong>: Returns a single, chaotic, unrelated number. (TV Static)</p>
</li>
<li><p><strong>Noise</strong>: Returns a value related to its neighbors. (Rolling Hills)</p>
</li>
<li><p><strong>Value Noise</strong>: Interpolates random <em>heights</em> at grid corners. (Blocky)</p>
</li>
<li><p><strong>Gradient Noise</strong>: Interpolates random <em>slopes</em> at grid corners. (Smooth/Natural)</p>
</li>
</ul>
<h3 id="heading-fbm-fractal-brownian-motion">FBM (Fractal Brownian Motion)</h3>
<p>Layering noise to create detail.</p>
<ul>
<li><p><strong>Octaves</strong>: How many layers you stack. (More = more detail, more cost).</p>
</li>
<li><p><strong>Lacunarity</strong>: How much the <strong>frequency</strong> increases per layer. (Usually x2).</p>
</li>
<li><p><strong>Gain/Persistence</strong>: How much the <strong>amplitude</strong> decreases per layer. (Usually x0.5).</p>
</li>
</ul>
<h3 id="heading-noise-modifiers">Noise Modifiers</h3>
<ul>
<li><p><strong>Turbulence</strong>: <code>abs(noise)</code>. Sharp valleys. Used for: Fire, Veins, Lightning.</p>
</li>
<li><p><strong>Ridged Multifractal</strong>: <code>1.0 - abs(noise)</code>. Inverted valleys (sharp peaks). Used for: Terrain ranges.</p>
</li>
<li><p><strong>Domain Warping</strong>: <code>noise(p + noise(p))</code>. Distorting coordinates. Used for: Liquids, Smoke, Marble.</p>
</li>
</ul>
<h3 id="heading-2d-vs-3d-noise-usage">2D vs 3D Noise usage</h3>
<ul>
<li><p><strong>2D Noise</strong>: Use <code>in.uv</code>. Good for surfaces, clouds, water planes.</p>
</li>
<li><p><strong>3D Noise</strong>: Use <code>in.world_position</code>. Good for solid objects (wood, stone) or volumetric fog.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[3.5 - Distance Functions (SDFs)]]></title><description><![CDATA[What We're Learning
In previous articles, we created patterns using explicit boundaries: "Is this pixel inside the square?" But there is a more powerful way to define shapes in computer graphics: "How far is this pixel from the edge?"
This is the wor...]]></description><link>https://blog.hexbee.net/35-distance-functions-sdfs</link><guid isPermaLink="true">https://blog.hexbee.net/35-distance-functions-sdfs</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Sun, 18 Jan 2026 20:50:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173586595/9ff2bb99-6dc2-4b7e-8229-3a2e21a509fc.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>In previous articles, we created patterns using explicit boundaries: "Is this pixel inside the square?" But there is a more powerful way to define shapes in computer graphics: "How <strong>far</strong> is this pixel from the edge?"</p>
<p>This is the world of <strong>Signed Distance Functions (SDFs)</strong>. Instead of defining shapes as rigid masks, we define them as mathematical fields of distance. This shift unlocks capabilities that are impossible with traditional texture sprites or simple grid logic.</p>
<p>In this article, you will learn:</p>
<ul>
<li><p><strong>The SDF Mindset</strong>: Why treating shapes as distance fields is superior to hard masks.</p>
</li>
<li><p><strong>Primitive Shapes</strong>: How to build circles, rectangles, and polygons using simple math.</p>
</li>
<li><p><strong>Boolean Operations</strong>: Combining shapes using <code>min()</code> and <code>max()</code> instead of if statements.</p>
</li>
<li><p><strong>Smooth Blending</strong>: How to melt shapes together like liquid mercury.</p>
</li>
<li><p><strong>Distance-Based Effects</strong>: Creating free outlines, glows, and soft shadows from a single value.</p>
</li>
<li><p><strong>Infinite Resolution</strong>: Why SDFs look perfectly crisp at any zoom level or rotation.</p>
</li>
</ul>
<h2 id="heading-understanding-distance-fields">Understanding Distance Fields</h2>
<p>Before writing code, we need to understand the fundamental concept.</p>
<h3 id="heading-what-is-a-distance-field">What is a Distance Field?</h3>
<p>In a standard image (like a PNG texture), every pixel stores a <strong>color</strong>.<br />In a distance field, every pixel stores a <strong>number</strong>: the distance to the nearest edge of a shape.</p>
<p>For a circle, the "field" looks like a gradient radiating from the center.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173617053/f56b0f35-f277-42e8-b130-f8ad7d95bced.png" alt /></p>
<h3 id="heading-the-signed-part">The "Signed" Part</h3>
<p>The "Signed" in SDF is crucial. It gives us orientation:</p>
<ul>
<li><p><strong>Negative (d &lt; 0.0)</strong>: Inside the shape.</p>
</li>
<li><p><strong>Zero (d = 0.0)</strong>: Exactly on the surface/edge.</p>
</li>
<li><p><strong>Positive (d &gt; 0.0)</strong>: Outside the shape.</p>
</li>
</ul>
<p>This sign tells us immediately not just where we are, but relationship to the shape.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Example: A Circle SDF</span>
<span class="hljs-comment">// A point at the exact center has a distance of -radius.</span>
<span class="hljs-comment">// A point on the edge has a distance of 0.0.</span>
<span class="hljs-keyword">let</span> dist = length(p - center) - radius;
</code></pre>
<h3 id="heading-visualizing-the-field">Visualizing the Field</h3>
<p>Because an SDF is just a number, we can visualize it directly in the shader to debug our math.</p>
<h4 id="heading-1-the-heatmap-view">1. The "Heatmap" View</h4>
<p>If we output the distance directly as color, the "inside" (negative) becomes black (clamped to 0.0), and the "outside" (positive) becomes a gradient from black to white.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> dist = sdf_circle(uv, center, radius);
<span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(dist), <span class="hljs-number">1.0</span>);
</code></pre>
<h4 id="heading-2-the-contour-view">2. The "Contour" View</h4>
<p>We can use math to draw lines at specific distance intervals, like a topographic map.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> dist = sdf_circle(uv, center, radius);
<span class="hljs-comment">// Create repeating rings every 0.1 units</span>
<span class="hljs-keyword">let</span> contour = step(<span class="hljs-number">0.9</span>, fract(dist * <span class="hljs-number">10.0</span>));
<span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(contour), <span class="hljs-number">1.0</span>);
</code></pre>
<h3 id="heading-why-this-paradigm-is-powerful">Why This Paradigm is Powerful</h3>
<p>Why go through the trouble of calculating distances instead of just drawing a circle?</p>
<ol>
<li><p><strong>Infinite Resolution</strong>: SDFs are mathematical. They have no pixels. You can zoom in 1000x, and the edge will remain mathematically perfect.</p>
</li>
<li><p><strong>Free Effects</strong>: Once you have the <code>dist</code> variable, you can create a stroke (outline) just by checking if <code>abs(dist) &lt; width</code>. You can create a glow by checking <code>dist &lt; glow_radius</code>.</p>
</li>
<li><p><strong>Smooth Operations</strong>: You can blend shapes together smoothly by mathematically interpolating their distance values, something that is incredibly hard to do with standard geometry or textures.</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173645755/d3871a93-a8c0-414c-a18f-f094dc065626.png" alt /></p>
<h2 id="heading-building-primitive-sdfs">Building Primitive SDFs</h2>
<p>An SDF is just a function that takes a position (<code>p</code>) and returns a distance (<code>f32</code>).</p>
<h3 id="heading-the-coordinate-system">The Coordinate System</h3>
<p>To make the math easy, SDF functions usually assume the shape is centered at <code>(0,0)</code>. Since our UV coordinates range from <code>(0,0)</code> to <code>(1,0)</code>, we need to adjust them before passing them to the SDF.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// 1. Center the coordinates</span>
<span class="hljs-comment">// 0.0 to 1.0  --&gt;  -0.5 to 0.5</span>
<span class="hljs-keyword">let</span> p = <span class="hljs-keyword">in</span>.uv - <span class="hljs-number">0.5</span>;

<span class="hljs-comment">// 2. Correct aspect ratio (if your quad isn't a square)</span>
<span class="hljs-comment">// p.x *= aspect_ratio;</span>

<span class="hljs-comment">// 3. Calculate distance</span>
<span class="hljs-keyword">let</span> dist = sdf_circle(p, radius);
</code></pre>
<h3 id="heading-1-the-circle">1. The Circle</h3>
<p>The circle is our baseline. It relies on length(), which is the Euclidean distance formula ($\sqrt{x2+y2}​$).</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sdf_circle</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, radius: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">return</span> length(p) - radius;
}
</code></pre>
<p><strong>How it works:</strong> We calculate the distance from the origin to our pixel. By subtracting the radius, we shift the "zero point" from the center of the circle out to its edge.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173662061/ccabb357-824f-4c1d-9bf9-7704345aed00.png" alt /></p>
<ul>
<li><p>If the pixel is <strong>at the center</strong>, distance is <code>0.0</code>. Result: <code>-radius</code> (Deep inside).</p>
</li>
<li><p>If the pixel is <strong>on the edge</strong>, distance is radius. Result: <code>0.0</code> (On edge).</p>
</li>
<li><p>If the pixel is <strong>far away</strong>, distance is huge. Result: <code>Positive</code> (Outside).</p>
</li>
</ul>
<h3 id="heading-2-the-rectangle">2. The Rectangle</h3>
<p>Rectangles introduce the concept of <strong>component-wise</strong> distance. We analyze the X distance and Y distance separately.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sdf_rectangle</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, half_size: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// 1. Shift origin to the corner of the box</span>
    <span class="hljs-comment">// Inside the box, 'd' is negative. Outside, it's positive.</span>
    <span class="hljs-keyword">let</span> d = abs(p) - half_size;

    <span class="hljs-comment">// 2. Calculate Outside Distance (for points outside the box)</span>
    <span class="hljs-comment">// If d.x or d.y is negative (we are next to an edge), clamp it to 0.</span>
    <span class="hljs-comment">// If both are positive (we are near a corner), length() gives precise distance to corner.</span>
    <span class="hljs-keyword">let</span> outside = length(max(d, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>)));

    <span class="hljs-comment">// 3. Calculate Inside Distance (for points inside the box)</span>
    <span class="hljs-comment">// We want the distance to the CLOSEST edge, which is the larger (less negative) value.</span>
    <span class="hljs-comment">// We clamp to 0 so this doesn't affect outside points.</span>
    <span class="hljs-keyword">let</span> inside = min(max(d.x, d.y), <span class="hljs-number">0.0</span>);

    <span class="hljs-keyword">return</span> outside + inside;
}
</code></pre>
<p><strong>How it works:</strong></p>
<p>Imagine extending the sides of the rectangle to infinity. This creates a grid of 9 zones. The math handles each zone differently.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173678958/88498008-1862-4991-b70c-82817fd370fd.png" alt /></p>
<ol>
<li><p><strong>The Corners (Zones 1, 3, 7, 9):</strong><br /> Here, the pixel is outside both the X range and the Y range.</p>
<ul>
<li><p><code>d.x</code> is positive, <code>d.y</code> is positive.</p>
</li>
<li><p><code>length(d)</code> calculates the diagonal distance to the corner point.</p>
</li>
</ul>
</li>
<li><p><strong>The Edges (Zones 2, 4, 6, 8):</strong><br /> Here, the pixel is aligned with the box on one axis, but outside on the other.</p>
<ul>
<li><p>Example Zone 2: <code>d.x</code> is negative (aligned), <code>d.y</code> is positive (above).</p>
</li>
<li><p><code>max(d, 0.0)</code> clamps the negative X to <code>0.0</code>.</p>
</li>
<li><p>The vector becomes <code>(0.0, d.y)</code>. The length is just <code>d.y</code>. This gives us a straight linear distance.</p>
</li>
</ul>
</li>
<li><p><strong>The Inside (Zone 5):</strong><br /> Here, the pixel is inside the box.</p>
<ul>
<li><p><code>d.x</code> and <code>d.y</code> are both negative.</p>
</li>
<li><p>The outside calculation becomes <code>length(0,0)</code> which is <code>0</code>.</p>
</li>
<li><p>The inside calculation picks the largest value (e.g., -0.1 is larger than -5.0). This ensures we measure distance to the closest edge.</p>
</li>
</ul>
</li>
</ol>
<h3 id="heading-3-the-rounded-box">3. The Rounded Box</h3>
<p>Once you have a rectangle, making it rounded is incredibly simple. You don't change the math of the box; you change the definition of the "edge."</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sdf_rounded_box</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, half_size: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, radius: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// A rounded box is just a standard box...</span>
    <span class="hljs-keyword">let</span> d = sdf_rectangle(p, half_size);

    <span class="hljs-comment">// ...with the edge "inflated" by the radius.</span>
    <span class="hljs-keyword">return</span> d - radius;
}
</code></pre>
<h4 id="heading-how-it-works">How it works</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173690780/f2ba251d-5b73-41de-bba7-42bb18ab4e37.png" alt /></p>
<p>In a sharp box, the value <code>0.0</code> is at the sharp walls.<br />By subtracting <code>radius</code> from the result, the value <code>0.0</code> moves outwards.</p>
<ul>
<li><p>The straight walls move out by <code>radius</code>.</p>
</li>
<li><p>The corners, which previously measured distance from a single point, now measure distance radius away from that point... creating a perfect circular arc.</p>
</li>
</ul>
<h3 id="heading-4-the-line-segment">4. The Line Segment</h3>
<p>Drawing a line between two arbitrary points (<code>A</code> and <code>B</code>) is essential for debug drawing, lasers, or skeletal animation.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sdf_line</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, a: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, b: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, thickness: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> pa = p - a;
    <span class="hljs-keyword">let</span> ba = b - a;

    <span class="hljs-comment">// Project point p onto the line 'ba'.</span>
    <span class="hljs-comment">// Clamp ensures we stop at the endpoints A and B.</span>
    <span class="hljs-keyword">let</span> h = clamp(dot(pa, ba) / dot(ba, ba), <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);

    <span class="hljs-comment">// Distance from P to the closest point on the line</span>
    <span class="hljs-keyword">return</span> length(pa - ba * h) - thickness;
}
</code></pre>
<h4 id="heading-how-it-works-1">How it works</h4>
<p>This uses the <strong>Dot Product</strong> projection technique.</p>
<ol>
<li><p>We project the pixel's position onto the infinite line passing through <code>A</code> and <code>B</code>.</p>
</li>
<li><p>The <code>clamp(..., 0.0, 1.0)</code> restricts that projection to the segment itself. If you project past <code>B</code>, it snaps back to <code>B</code>.</p>
</li>
<li><p>We calculate the distance to that snapped point.</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173711488/00840df6-23be-4d41-9910-189c71c6016b.png" alt /></p>
<h3 id="heading-5-the-cross-union-of-boxes">5. The Cross (Union of Boxes)</h3>
<p>Complex shapes can often be built by combining simple ones. A cross is just a horizontal box merged with a vertical box.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sdf_cross</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, size: <span class="hljs-built_in">f32</span>, thick: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// 1. Horizontal Box</span>
    <span class="hljs-keyword">let</span> rect_a = sdf_box(p, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(size, thick));
    <span class="hljs-comment">// 2. Vertical Box</span>
    <span class="hljs-keyword">let</span> rect_b = sdf_box(p, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(thick, size));

    <span class="hljs-comment">// 3. Union (min)</span>
    <span class="hljs-keyword">return</span> min(rect_a, rect_b);
}
</code></pre>
<h4 id="heading-how-it-works-2">How it works</h4>
<p>We calculate two separate distance fields: one for a wide, short box, and one for a tall, thin box.<br />By taking the <code>min()</code>, we effectively weld them together. Any pixel inside either box is considered inside the cross.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173726472/a0d17e1c-06ff-4e1e-a467-53ccf541194a.png" alt /></p>
<h3 id="heading-6-the-hexagon">6. The Hexagon</h3>
<p>Now we enter the realm of complex shapes. Instead of calculating distance to 6 different lines, we use <strong>Domain Folding</strong>.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sdf_hexagon</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, r: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// 1. Define symmetry constants (sin(60), cos(60), tan(60))</span>
    <span class="hljs-keyword">let</span> k = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(-<span class="hljs-number">0.866025404</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.577350269</span>);

    <span class="hljs-comment">// 2. Fold the space!</span>
    <span class="hljs-comment">// We reflect the coordinate system so all 6 sectors map to 1.</span>
    var p_adj = abs(p);
    p_adj -= <span class="hljs-number">2.0</span> * min(dot(k.xy, p_adj), <span class="hljs-number">0.0</span>) * k.xy;

    <span class="hljs-comment">// 3. Calculate distance to the single remaining edge</span>
    <span class="hljs-keyword">let</span> d = length(p_adj - vec2&lt;<span class="hljs-built_in">f32</span>&gt;(clamp(p_adj.x, -k.z * r, k.z * r), r)) * sign(p_adj.y - r);
    <span class="hljs-keyword">return</span> d;
}
</code></pre>
<h4 id="heading-how-it-works-3">How it works</h4>
<p>Imagine a kaleidoscope. A hexagon has 6 identical triangular sectors.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173737463/b8232f17-cb5a-482a-b5e1-87c40bda77da.png" alt /></p>
<ol>
<li><p><code>abs(p)</code> folds the left side onto the right side. Now we have 3 sectors.</p>
</li>
<li><p>The dot product math reflects the top-left and top-right sectors down.</p>
</li>
<li><p>We have folded the entire 2D plane into a single small wedge. We only need to calculate the distance to one vertical edge in that wedge, and the math applies to all 6 sides automatically.</p>
</li>
</ol>
<h3 id="heading-7-the-star">7. The Star</h3>
<p>To create a star with any number of points ($N$), we usually think in terms of <strong>Polar Domain Repetition</strong>. Instead of defining the whole shape, we define one "pie slice" and repeat it.</p>
<p>However, a naive implementation using <code>atan2</code> produces a <strong>distorted distance field</strong> - distances grow faster as you move away from the center. This ruins effects like outlines and soft shadows.</p>
<p>For a high-quality, sharp star, we combine the concept of repetition with <strong>Folding Geometry</strong>. We fold the space using symmetry lines defined by the star's angles (e.g., $360/5=72360/5=72\textdegree$), then measure the exact Euclidean distance to the edge of the triangle wedge.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sdf_star</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, r: <span class="hljs-built_in">f32</span>, n: <span class="hljs-built_in">f32</span>, m: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// 1. Define the angle of one sector (e.g. 72 degrees for N=5)</span>
    <span class="hljs-keyword">let</span> an = <span class="hljs-number">3.141593</span> / n;
    <span class="hljs-keyword">let</span> en = <span class="hljs-number">3.141593</span> / m;

    <span class="hljs-comment">// 2. Get the angle of the current pixel using Polar Coordinates</span>
    <span class="hljs-keyword">let</span> angle = atan2(p.x, p.y) + an;

    <span class="hljs-comment">// 3. Repeat the sector (The "Pie Slice" Logic)</span>
    <span class="hljs-comment">// This maps the full 360 degrees to a single sector index.</span>
    <span class="hljs-comment">// By using 'fract', a pixel at 350° is treated exactly the same as 10°.</span>
    <span class="hljs-keyword">let</span> sector = floor(angle / (<span class="hljs-number">2.0</span> * an));
    <span class="hljs-keyword">let</span> a = fract(angle / (<span class="hljs-number">2.0</span> * an)) - <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// 4. Rotate pixel into local sector space</span>
    <span class="hljs-comment">// Now every pixel "thinks" it is in the top slice!</span>
    <span class="hljs-keyword">let</span> p_rot = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(cos(a * <span class="hljs-number">2.0</span> * an), sin(a * <span class="hljs-number">2.0</span> * an)) * length(p);

    <span class="hljs-comment">// 5. Calculate exact distance (Folding Logic)</span>
    <span class="hljs-comment">// We now only need to measure distance to ONE line segment.</span>
    <span class="hljs-comment">// Because of step #3, this logic automatically applies to all N points.</span>
    <span class="hljs-comment">// ... (Exact segment math omitted for brevity) ...</span>
}
</code></pre>
<h4 id="heading-how-it-works-4">How it works</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173749647/783adf9b-ec53-4f24-8b1a-226294b089cc.png" alt /></p>
<ol>
<li><p><strong>Polar Coordinates</strong>: We convert the pixel's position from <code>(x,y)</code> to <code>(angle, radius)</code> using <code>atan2</code>.</p>
</li>
<li><p><strong>Slicing the Pie</strong>: We divide the full circle ($2\pi$) by $N$ (e.g., 5). This gives us the width of one sector.</p>
</li>
<li><p><strong>Modulo Arithmetic</strong>: By using <code>fract()</code>, we discard the specific sector number. We are left with a single triangular wedge.</p>
</li>
<li><p><strong>Folding for Precision</strong>: Instead of just using the angle (which distorts distance), we rotate the pixel into that wedge and measure the real geometric distance to the star's edge.</p>
</li>
</ol>
<h2 id="heading-combining-sdfs-boolean-operations">Combining SDFs: Boolean Operations</h2>
<p>This is where SDFs truly shine. In traditional geometry, merging two shapes requires complex mesh generation. In SDFs, it is just min() and max().</p>
<h3 id="heading-1-union-min">1. Union (<code>min</code>)</h3>
<p>To combine two shapes into one compound shape, we simply ask: "What is the distance to the <strong>nearest</strong> edge?"</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">op_union</span></span>(d1: <span class="hljs-built_in">f32</span>, d2: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">return</span> min(d1, d2);
}
</code></pre>
<h4 id="heading-how-it-works-5">How it works</h4>
<p>The <code>min()</code> function effectively stitches the distance fields together.</p>
<ul>
<li><p>Wherever Shape A is closer, <code>d1</code> wins.</p>
</li>
<li><p>Wherever Shape B is closer, <code>d2</code> wins.</p>
</li>
<li><p>At the junction where they intersect, <code>d1 == d2</code>, creating a seamless seam.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173775923/614d5ae9-7dc3-4529-a5cf-f1c15cddf9da.png" alt /></p>
<h3 id="heading-2-intersection-max">2. Intersection (max)</h3>
<p>To find the overlap, we ask: "How far do I have to travel to be inside <strong>both</strong> shapes?"<br />This is the <strong>largest</strong> distance. If you are inside Shape A (<code>-5</code>) but outside Shape B (<code>+2</code>), the max is <code>+2</code> (Outside). You are only "Inside" (negative) if both are negative. The logic is simple: take the <strong>maximum</strong> value.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">op_intersection</span></span>(d1: <span class="hljs-built_in">f32</span>, d2: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">return</span> max(d1, d2);
}
</code></pre>
<h4 id="heading-how-it-works-6">How it works</h4>
<ul>
<li><p>If you are outside both, <code>max()</code> returns the distance to the shape that is <strong>furthest away</strong> (because you have to cross both boundaries to get inside).</p>
</li>
<li><p>If you are inside one but outside the other, <code>max()</code> returns the positive value (Outside).</p>
</li>
<li><p>If you are inside both, <code>max()</code> returns the value closest to <code>0</code> (closest to the edge).</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173786241/6b3e9b22-e15f-41ee-81f2-017e611fa84e.png" alt /></p>
<h3 id="heading-3-subtraction">3. Subtraction</h3>
<p>To carve Shape B out of Shape A (like using a cookie cutter), we use a clever trick involving <strong>inversion</strong>.</p>
<p>In an SDF, "Inside" is negative and "Outside" is positive.<br />If we put a minus sign in front of a distance (<code>-d</code>), we invert the world: Inside becomes Outside, and Outside becomes Inside.</p>
<p>Therefore, "Subtraction" is just the <strong>Intersection</strong> of Shape A and the <strong>Inverse</strong> of Shape B.<br />We want the region that is <strong>Inside A</strong> AND <strong>Outside B</strong>.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">op_subtraction</span></span>(d1: <span class="hljs-built_in">f32</span>, d2: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// "Intersection of d1 and NOT d2"</span>
    <span class="hljs-keyword">return</span> max(d1, -d2);
}
</code></pre>
<h4 id="heading-how-it-works-7">How it works</h4>
<ul>
<li><p><code>d1</code>: Distance to the base shape.</p>
</li>
<li><p><code>-d2</code>: Distance to the "anti-shape".</p>
</li>
<li><p><code>max()</code>: Returns the intersection. The result is only negative (inside) if you are inside A (<code>d1 &lt; 0</code>) AND inside the anti-B (<code>-d2 &lt; 0</code>, which means <code>d2 &gt; 0</code>, i.e., outside B).</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173797063/385646bf-dd2a-4aeb-819e-afc975ae1733.png" alt /></p>
<h3 id="heading-4-smooth-blending-the-liquid-effect">4. Smooth Blending (The "Liquid" Effect)</h3>
<p>The functions above create sharp corners where shapes meet. But because SDFs are continuous fields, we can blend them mathematically.</p>
<p>Instead of a hard <code>min()</code>, we use a polynomial mix to smooth out the junction.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">op_smooth_union</span></span>(d1: <span class="hljs-built_in">f32</span>, d2: <span class="hljs-built_in">f32</span>, k: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// k controls the "goo factor" or blend radius.</span>
    <span class="hljs-comment">// h calculates a weight from 0.0 to 1.0 based on how close d1 and d2 are.</span>
    <span class="hljs-keyword">let</span> h = clamp(<span class="hljs-number">0.5</span> + <span class="hljs-number">0.5</span> * (d2 - d1) / k, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);

    <span class="hljs-comment">// 1. mix(d2, d1, h): Linearly blends the distances (like a bevel)</span>
    <span class="hljs-comment">// 2. - k * h * (1.0 - h): Subtracts a bit more to create the curved "fillet"</span>
    <span class="hljs-keyword">return</span> mix(d2, d1, h) - k * h * (<span class="hljs-number">1.0</span> - h);
}
</code></pre>
<h4 id="heading-visualizing-smooth-union">Visualizing Smooth Union</h4>
<p>Imagine two drops of water touching. They don't just intersect; surface tension pulls them into a single smooth blob. <code>op_smooth_union</code> replicates this perfectly. It is commonly used for organic shapes, biological effects, or gooey UI elements.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173808807/870ddace-bb62-4abd-93db-c3a5a6c324a9.png" alt /></p>
<h3 id="heading-using-sdfs-for-anti-aliasing">Using SDFs for Anti-Aliasing</h3>
<p>One of the greatest superpowers of SDFs is <strong>infinite resolution</strong>. Whether your shape is 10 pixels wide or 1000 pixels wide, the math is the same. However, if we simply cut the shape at 0.0, we get jagged, pixelated edges.</p>
<p>To fix this, we need to blur the edge slightly - but <em>only</em> by the width of one pixel.</p>
<h4 id="heading-1-the-hard-edge-aliased">1. The Hard Edge (Aliased)</h4>
<p>Using <code>step()</code> acts like a binary switch.</p>
<ul>
<li><p>Distance -0.0001 → Color (Inside)</p>
</li>
<li><p>Distance +0.0001 → Black (Outside)</p>
</li>
</ul>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> dist = sdf_circle(p, <span class="hljs-number">0.5</span>);
<span class="hljs-comment">// Result is either 0.0 or 1.0. Nothing in between.</span>
<span class="hljs-keyword">let</span> alpha = step(<span class="hljs-number">0.0</span>, -dist);
</code></pre>
<h4 id="heading-2-the-smooth-edge-anti-aliased">2. The Smooth Edge (Anti-Aliased)</h4>
<p>We want the pixels lying exactly on the boundary to be partially transparent (gray).<br />To do this, we need to map the distance to opacity using <code>smoothstep</code>.</p>
<p>But how wide should the smooth transition be?</p>
<ul>
<li><p>Too narrow? Still jagged.</p>
</li>
<li><p>Too wide? The shape looks blurry.</p>
</li>
</ul>
<p>The answer is <code>fwidth(dist)</code>.</p>
<h4 id="heading-3-the-fwidth-magic">3. The <code>fwidth</code> Magic</h4>
<p><code>fwidth(dist)</code> allows the GPU to peek at the neighbor pixels. It asks: "How much does the <code>dist</code> value change from this pixel to the one next to it?"<br />This value tells us exactly how "wide" one pixel is in terms of distance units.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> dist = sdf_circle(p, <span class="hljs-number">0.5</span>);

<span class="hljs-comment">// Calculate the width of one pixel in distance space</span>
<span class="hljs-keyword">let</span> edge_width = fwidth(dist);

<span class="hljs-comment">// Create a smooth transition exactly 2 pixels wide centered on the edge</span>
<span class="hljs-comment">// smoothstep(lower_bound, upper_bound, value)</span>
<span class="hljs-keyword">let</span> alpha = smoothstep(-edge_width, edge_width, -dist);
</code></pre>
<h4 id="heading-why-this-works">Why this works</h4>
<p>If you zoom in on your shape, <code>dist</code> changes very slowly between pixels. <code>fwidth</code> becomes small. The transition stays sharp (1 pixel wide).<br />If you zoom out, <code>dist</code> changes rapidly. <code>fwidth</code> becomes large. The transition widens to match the pixel size.<br />The result: <strong>perfectly crisp edges at any zoom level.</strong></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173820287/fb3236fa-8ba0-403e-af50-a19a441f9734.png" alt /></p>
<h3 id="heading-distance-field-effects">Distance Field Effects</h3>
<p>Since <code>dist</code> tells us exactly how far we are from the edge, we can map that number to colors to create effects that would be complex with textures.</p>
<h4 id="heading-1-outlines-borders">1. Outlines (Borders)</h4>
<p>To create an outline, we don't care if we are inside or outside. we only care that we are <strong>near</strong> the edge.<br />Mathematically, this means looking at the <strong>Absolute Value</strong> of the distance (<code>abs(dist)</code>).</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173838057/bf55e2b6-fcb6-4ec3-8927-1ee82f3a3e19.png" alt /></p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> dist = sdf_circle(p, <span class="hljs-number">0.5</span>);

<span class="hljs-comment">// Define border thickness</span>
<span class="hljs-keyword">let</span> border_width = <span class="hljs-number">0.02</span>;

<span class="hljs-comment">// Calculate "Distance from Border"</span>
<span class="hljs-comment">// abs(dist) is 0 at the edge, and grows as we move away in EITHER direction.</span>
<span class="hljs-comment">// We effectively create a "V" shape distance field centered on the line.</span>
<span class="hljs-keyword">let</span> d_border = abs(dist);

<span class="hljs-comment">// Create the mask</span>
<span class="hljs-comment">// We use fwidth for anti-aliasing again!</span>
<span class="hljs-keyword">let</span> w = fwidth(dist);
<span class="hljs-keyword">let</span> border_mask = smoothstep(border_width + w, border_width - w, d_border);

<span class="hljs-comment">// Apply color</span>
<span class="hljs-keyword">let</span> final_color = mix(fill_color, border_color, border_mask);
</code></pre>
<h4 id="heading-2-outer-glow">2. Outer Glow</h4>
<p>A glow is just light fading over distance. We take the <strong>positive</strong> distance (outside) and map it to brightness.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173849415/80b3cc81-6555-4eae-ac0b-13e9a5c0a53f.png" alt /></p>
<pre><code class="lang-rust"><span class="hljs-comment">// Option A: Smoothstep Glow (Contained)</span>
<span class="hljs-comment">// Glow starts at edge (0.0) and fades to nothing at 0.2</span>
<span class="hljs-keyword">let</span> glow = smoothstep(<span class="hljs-number">0.2</span>, <span class="hljs-number">0.0</span>, dist);

<span class="hljs-comment">// Option B: Exponential Glow (Natural)</span>
<span class="hljs-comment">// Looks more like a light source.</span>
<span class="hljs-comment">// The multiplier (10.0) controls tightness. Higher = tighter glow.</span>
<span class="hljs-comment">// We max(dist, 0.0) so we don't glow on the inside.</span>
<span class="hljs-keyword">let</span> glow = exp(-<span class="hljs-number">10.0</span> * max(dist, <span class="hljs-number">0.0</span>));

<span class="hljs-comment">// Add glow to color</span>
color += glow_color * glow;
</code></pre>
<h4 id="heading-3-inner-shadow-inset">3. Inner Shadow / Inset</h4>
<p>To create an inner shadow (like a button pressed in), we look at the <strong>negative</strong> distance (inside).</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173858671/dc686bf1-5132-40ac-8396-367839950fad.png" alt /></p>
<pre><code class="lang-rust"><span class="hljs-comment">// Invert distance so "deep inside" is positive</span>
<span class="hljs-keyword">let</span> inside_dist = -dist;

<span class="hljs-comment">// Smoothstep from edge (0.0) to deep inside (0.1)</span>
<span class="hljs-keyword">let</span> shadow = smoothstep(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.1</span>, inside_dist);

<span class="hljs-comment">// Darken the color</span>
color *= (<span class="hljs-number">1.0</span> - shadow * <span class="hljs-number">0.5</span>);
</code></pre>
<hr />
<h2 id="heading-complete-example-animated-shape-morphing">Complete Example: Animated Shape Morphing</h2>
<p>To demonstrate the versatility of distance fields, we will build a "shapeshifter" material. Unlike standard mesh morphing - which requires complex vertex animation - SDF morphing is as simple as blending two numbers.</p>
<h3 id="heading-our-goal">Our Goal</h3>
<p>We want to create a dynamic glyph that:</p>
<ol>
<li><p><strong>Morphs smoothly</strong> between 6 different primitive shapes.</p>
</li>
<li><p><strong>Rendering Effects</strong>: Applies a glowing outline and a distance-based pattern.</p>
</li>
<li><p><strong>Perfect Edges</strong>: Renders with infinite resolution and anti-aliasing.</p>
</li>
</ol>
<h3 id="heading-the-shader-assetsshadersd0305sdfmorphingwgsl">The Shader (<code>assets/shaders/d03_05_sdf_morphing.wgsl</code>)</h3>
<p>This shader acts as our SDF engine. It defines the mathematical formulas for all our primitive shapes - Circle, Box, Hexagon, Cross, and Star.</p>
<p>The <code>fragment</code> entry point orchestrates the effect:</p>
<ol>
<li><p><strong>Coordinate Setup</strong>: It centers the UVs so <code>(0,0)</code> is in the middle of the quad.</p>
</li>
<li><p><strong>Distance Calculation</strong>: It computes the distance field for both the <strong>Start Shape</strong> and the <strong>Target Shape</strong>.</p>
</li>
<li><p><strong>Morphing</strong>: It blends these two distances using <code>mix()</code>. We apply an ease-in-out curve to the time factor so the transition feels organic rather than robotic.</p>
</li>
<li><p><strong>Rendering</strong>: Finally, it uses the blended distance value to generate the anti-aliased fill, the white outline, and the outer glow.</p>
</li>
</ol>
<pre><code class="lang-rust">#import bevy_pbr::forward_io::VertexOutput

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">SdfMaterial</span></span> {
    time: <span class="hljs-built_in">f32</span>,
    morph_factor: <span class="hljs-built_in">f32</span>,
    shape_a: <span class="hljs-built_in">u32</span>,
    shape_b: <span class="hljs-built_in">u32</span>,
    glow_intensity: <span class="hljs-built_in">f32</span>,
    outline_width: <span class="hljs-built_in">f32</span>,
    <span class="hljs-comment">// Colors</span>
    primary_color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    secondary_color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    background_color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    glow_color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    outline_color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: SdfMaterial;

<span class="hljs-comment">// --- 1. SDF PRIMITIVE LIBRARY ---</span>

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sdf_circle</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, r: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">return</span> length(p) - r;
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sdf_box</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, b: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> d = abs(p) - b;
    <span class="hljs-keyword">return</span> length(max(d, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>))) + min(max(d.x, d.y), <span class="hljs-number">0.0</span>);
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sdf_rounded_box</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, b: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, r: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// Note: This effectively inflates the box by r</span>
    <span class="hljs-keyword">return</span> sdf_box(p, b) - r;
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sdf_hexagon</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, r: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> k = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(-<span class="hljs-number">0.866025404</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.577350269</span>);
    var p_adj = abs(p);
    p_adj -= <span class="hljs-number">2.0</span> * min(dot(k.xy, p_adj), <span class="hljs-number">0.0</span>) * k.xy;
    <span class="hljs-keyword">let</span> d = length(p_adj - vec2&lt;<span class="hljs-built_in">f32</span>&gt;(clamp(p_adj.x, -k.z * r, k.z * r), r)) * sign(p_adj.y - r);
    <span class="hljs-keyword">return</span> d;
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sdf_cross</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, size: <span class="hljs-built_in">f32</span>, thick: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> rect_a = sdf_box(p, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(size, thick));
    <span class="hljs-keyword">let</span> rect_b = sdf_box(p, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(thick, size));
    <span class="hljs-keyword">return</span> min(rect_a, rect_b);
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sdf_star</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, r: <span class="hljs-built_in">f32</span>, factor: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> k1 = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.809016994375</span>, -<span class="hljs-number">0.587785252292</span>);
    <span class="hljs-keyword">let</span> k2 = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(-k1.x, k1.y);

    var p_adj = p;
    p_adj.x = abs(p_adj.x);
    p_adj -= <span class="hljs-number">2.0</span> * max(dot(k1, p_adj), <span class="hljs-number">0.0</span>) * k1;
    p_adj -= <span class="hljs-number">2.0</span> * max(dot(k2, p_adj), <span class="hljs-number">0.0</span>) * k2;
    p_adj.x = abs(p_adj.x);
    p_adj.y -= r;

    <span class="hljs-keyword">let</span> ba = factor * vec2&lt;<span class="hljs-built_in">f32</span>&gt;(-k1.y, k1.x) - vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
    <span class="hljs-keyword">let</span> h = clamp(dot(p_adj, ba) / dot(ba, ba), <span class="hljs-number">0.0</span>, r);

    <span class="hljs-keyword">return</span> length(p_adj - ba * h) * sign(p_adj.y * ba.x - p_adj.x * ba.y);
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_dist</span></span>(id: <span class="hljs-built_in">u32</span>, p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> size = <span class="hljs-number">0.30</span>;

    switch id {
        case <span class="hljs-number">0</span>u: { <span class="hljs-keyword">return</span> sdf_circle(p, size); }
        case <span class="hljs-number">1</span>u: { <span class="hljs-keyword">return</span> sdf_box(p, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(size, size)); }
        <span class="hljs-comment">// For rounded box, subtract radius from size so visual size stays ~0.3</span>
        case <span class="hljs-number">2</span>u: { <span class="hljs-keyword">return</span> sdf_rounded_box(p, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(size - <span class="hljs-number">0.1</span>, size - <span class="hljs-number">0.1</span>), <span class="hljs-number">0.1</span>); }
        case <span class="hljs-number">3</span>u: { <span class="hljs-keyword">return</span> sdf_hexagon(p, size); }
        case <span class="hljs-number">4</span>u: { <span class="hljs-keyword">return</span> sdf_cross(p, size, size * <span class="hljs-number">0.35</span>); }
        case <span class="hljs-number">5</span>u: { <span class="hljs-keyword">return</span> sdf_star(p, size + <span class="hljs-number">0.1</span>, <span class="hljs-number">0.45</span>); }
        default: { <span class="hljs-keyword">return</span> sdf_circle(p, size); }
    }
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> p = <span class="hljs-keyword">in</span>.uv - <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// 1. Morph Logic</span>
    <span class="hljs-keyword">let</span> d1 = get_dist(material.shape_a, p);
    <span class="hljs-keyword">let</span> d2 = get_dist(material.shape_b, p);

    <span class="hljs-keyword">let</span> t = smoothstep(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, material.morph_factor);
    <span class="hljs-keyword">let</span> dist = mix(d1, d2, t);

    <span class="hljs-comment">// 2. Rendering Effects</span>
    <span class="hljs-keyword">let</span> aa = fwidth(dist);

    <span class="hljs-comment">// Fill</span>
    <span class="hljs-keyword">let</span> fill_mask = <span class="hljs-number">1.0</span> - smoothstep(-aa, aa, dist);
    <span class="hljs-keyword">let</span> pattern_val = sin(dist * <span class="hljs-number">40.0</span> - material.time * <span class="hljs-number">3.0</span>) * <span class="hljs-number">0.5</span> + <span class="hljs-number">0.5</span>;
    <span class="hljs-keyword">let</span> fill_color = mix(material.primary_color, material.secondary_color, pattern_val);

    <span class="hljs-comment">// Outline</span>
    <span class="hljs-keyword">let</span> outline_mask = smoothstep(material.outline_width + aa, material.outline_width, abs(dist));

    <span class="hljs-comment">// Outer Glow</span>
    <span class="hljs-keyword">let</span> glow_mask = exp(-<span class="hljs-number">5.0</span> * max(dist, <span class="hljs-number">0.0</span>)) * material.glow_intensity;
    <span class="hljs-keyword">let</span> glow = material.glow_color * glow_mask;

    <span class="hljs-comment">// 3. Composition</span>
    var final_color = material.background_color.rgb;

    final_color = mix(final_color, fill_color.rgb, fill_mask);

    <span class="hljs-keyword">if</span> (material.outline_width &gt; <span class="hljs-number">0.001</span>) {
        final_color = mix(final_color, material.outline_color.rgb, outline_mask);
    }

    final_color += glow.rgb;

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(final_color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0305sdfmorphingrs">The Rust Material (<code>src/materials/d03_05_sdf_morphing.rs</code>)</h3>
<p>This file bridges the gap between the CPU and GPU. It defines the <code>SdfUniforms</code> struct, which maps 1:1 to the <code>SdfMaterial</code> struct in our shader.</p>
<p>We use a submodule with <code>#![allow(dead_code)]</code> to encapsulate the uniforms. This is a handy pattern in Bevy shader development to suppress compiler warnings about unused fields or methods generated by the <code>ShaderType</code> derive macro, keeping your build output clean.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>
    <span class="hljs-keyword">use</span> bevy::prelude::*;
    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">SdfUniforms</span></span> {
        <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> morph_factor: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> shape_a: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> shape_b: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> glow_intensity: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> outline_width: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> primary_color: LinearRgba,
        <span class="hljs-keyword">pub</span> secondary_color: LinearRgba,
        <span class="hljs-keyword">pub</span> background_color: LinearRgba,
        <span class="hljs-keyword">pub</span> glow_color: LinearRgba,
        <span class="hljs-keyword">pub</span> outline_color: LinearRgba,
    }
}
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">use</span> uniforms::SdfUniforms;

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">SdfMorphMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: SdfUniforms,
}

<span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> SdfMorphMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
        <span class="hljs-keyword">Self</span> {
            uniforms: SdfUniforms {
                time: <span class="hljs-number">0.0</span>,
                morph_factor: <span class="hljs-number">0.0</span>,
                shape_a: <span class="hljs-number">5</span>, <span class="hljs-comment">// Star</span>
                shape_b: <span class="hljs-number">0</span>, <span class="hljs-comment">// Circle</span>
                glow_intensity: <span class="hljs-number">0.8</span>,
                outline_width: <span class="hljs-number">0.02</span>,
                primary_color: LinearRgba::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.8</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>), <span class="hljs-comment">// Cyan</span>
                secondary_color: LinearRgba::new(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.8</span>, <span class="hljs-number">1.0</span>), <span class="hljs-comment">// Magenta</span>
                background_color: LinearRgba::new(<span class="hljs-number">0.02</span>, <span class="hljs-number">0.02</span>, <span class="hljs-number">0.05</span>, <span class="hljs-number">1.0</span>),
                glow_color: LinearRgba::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.6</span>, <span class="hljs-number">0.8</span>, <span class="hljs-number">1.0</span>),
                outline_color: LinearRgba::WHITE,
            },
        }
    }
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> SdfMorphMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d03_05_sdf_morphing.wgsl"</span>.into()
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_05_sdf_morphing;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0305sdfmorphingrs">The Demo Module (<code>src/demos/d03_05_sdf_morphing.rs</code>)</h3>
<p>This module sets up the interactive environment. It spawns the camera, the quad mesh, and the UI overlay.</p>
<p>The core logic lies in the <code>handle_input</code> and <code>animate_shader</code> systems. Instead of just playing a fixed animation, these systems read the keyboard state and directly modify the properties of the <code>SdfMorphMaterial</code> asset. This allows for real-time control over the morphing speed, the active shapes, and the visual effects.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d03_05_sdf_morphing::SdfMorphMaterial;
<span class="hljs-keyword">use</span> bevy::prelude::*;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;SdfMorphMaterial&gt;::default())
        .insert_resource(AnimationState {
            auto_morph: <span class="hljs-literal">true</span>,
            morph_speed: <span class="hljs-number">0.5</span>,
            manual_t: <span class="hljs-number">0.0</span>,
            ping_pong_direction: <span class="hljs-number">1.0</span>,
        })
        .add_systems(Startup, setup)
        .add_systems(Update, (handle_input, animate_shader, update_ui))
        .run();
}

<span class="hljs-meta">#[derive(Resource)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">AnimationState</span></span> {
    auto_morph: <span class="hljs-built_in">bool</span>,
    morph_speed: <span class="hljs-built_in">f32</span>,
    manual_t: <span class="hljs-built_in">f32</span>,
    ping_pong_direction: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;SdfMorphMaterial&gt;&gt;,
) {
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span>).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    commands.spawn((
        Mesh3d(meshes.add(Rectangle::new(<span class="hljs-number">2.0</span>, <span class="hljs-number">2.0</span>))),
        MeshMaterial3d(materials.add(SdfMorphMaterial::default())),
        Transform::default(),
    ));

    commands.spawn((
        Text::new(<span class="hljs-string">"SDF Morphing Demo"</span>),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">10.0</span>),
            left: Val::Px(<span class="hljs-number">10.0</span>),
            padding: UiRect::all(Val::Px(<span class="hljs-number">10.0</span>)),
            ..default()
        },
        TextFont {
            font_size: <span class="hljs-number">16.0</span>,
            ..default()
        },
        TextColor(Color::WHITE),
        BackgroundColor(Color::srgba(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.8</span>)),
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    <span class="hljs-keyword">mut</span> state: ResMut&lt;AnimationState&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;SdfMorphMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-keyword">let</span> u = &amp;<span class="hljs-keyword">mut</span> material.uniforms;
        <span class="hljs-keyword">let</span> shift = keyboard.pressed(KeyCode::ShiftLeft) || keyboard.pressed(KeyCode::ShiftRight);

        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Space) {
            state.auto_morph = !state.auto_morph;
            <span class="hljs-keyword">if</span> !state.auto_morph {
                state.manual_t = u.morph_factor;
            }
        }

        <span class="hljs-comment">// Shape Selection</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit1) {
            <span class="hljs-keyword">if</span> shift {
                u.shape_b = <span class="hljs-number">0</span>;
            } <span class="hljs-keyword">else</span> {
                u.shape_a = <span class="hljs-number">0</span>;
            }
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit2) {
            <span class="hljs-keyword">if</span> shift {
                u.shape_b = <span class="hljs-number">1</span>;
            } <span class="hljs-keyword">else</span> {
                u.shape_a = <span class="hljs-number">1</span>;
            }
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit3) {
            <span class="hljs-keyword">if</span> shift {
                u.shape_b = <span class="hljs-number">2</span>;
            } <span class="hljs-keyword">else</span> {
                u.shape_a = <span class="hljs-number">2</span>;
            }
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit4) {
            <span class="hljs-keyword">if</span> shift {
                u.shape_b = <span class="hljs-number">3</span>;
            } <span class="hljs-keyword">else</span> {
                u.shape_a = <span class="hljs-number">3</span>;
            }
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit5) {
            <span class="hljs-keyword">if</span> shift {
                u.shape_b = <span class="hljs-number">4</span>;
            } <span class="hljs-keyword">else</span> {
                u.shape_a = <span class="hljs-number">4</span>;
            }
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit6) {
            <span class="hljs-keyword">if</span> shift {
                u.shape_b = <span class="hljs-number">5</span>;
            } <span class="hljs-keyword">else</span> {
                u.shape_a = <span class="hljs-number">5</span>;
            }
        }

        <span class="hljs-comment">// Toggles</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyG) {
            u.glow_intensity = <span class="hljs-keyword">if</span> u.glow_intensity &gt; <span class="hljs-number">0.0</span> { <span class="hljs-number">0.0</span> } <span class="hljs-keyword">else</span> { <span class="hljs-number">0.8</span> };
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyO) {
            u.outline_width = <span class="hljs-keyword">if</span> u.outline_width &gt; <span class="hljs-number">0.0</span> { <span class="hljs-number">0.0</span> } <span class="hljs-keyword">else</span> { <span class="hljs-number">0.02</span> };
        }

        <span class="hljs-comment">// Manual Morph</span>
        <span class="hljs-keyword">if</span> !state.auto_morph {
            <span class="hljs-keyword">let</span> delta = <span class="hljs-number">0.02</span>;
            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowRight) {
                state.manual_t = (state.manual_t + delta).min(<span class="hljs-number">1.0</span>);
                u.morph_factor = state.manual_t;
            }
            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowLeft) {
                state.manual_t = (state.manual_t - delta).max(<span class="hljs-number">0.0</span>);
                u.morph_factor = state.manual_t;
            }
        }

        <span class="hljs-comment">// Speed</span>
        <span class="hljs-keyword">let</span> speed_delta = <span class="hljs-number">0.01</span>;
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowUp) {
            state.morph_speed = (state.morph_speed + speed_delta).min(<span class="hljs-number">5.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowDown) {
            state.morph_speed = (state.morph_speed - speed_delta).max(<span class="hljs-number">0.0</span>);
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">animate_shader</span></span>(
    time: Res&lt;Time&gt;,
    <span class="hljs-keyword">mut</span> state: ResMut&lt;AnimationState&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;SdfMorphMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">if</span> !state.auto_morph {
        <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
            material.uniforms.time = time.elapsed_secs();
        }
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">let</span> delta = time.delta_secs();

    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-keyword">let</span> u = &amp;<span class="hljs-keyword">mut</span> material.uniforms;
        u.time = time.elapsed_secs();

        u.morph_factor += delta * state.morph_speed * state.ping_pong_direction;

        <span class="hljs-keyword">if</span> u.morph_factor &gt;= <span class="hljs-number">1.0</span> {
            u.morph_factor = <span class="hljs-number">1.0</span>;
            state.ping_pong_direction = -<span class="hljs-number">1.0</span>;
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> u.morph_factor &lt;= <span class="hljs-number">0.0</span> {
            u.morph_factor = <span class="hljs-number">0.0</span>;
            state.ping_pong_direction = <span class="hljs-number">1.0</span>;
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_shape_name</span></span>(id: <span class="hljs-built_in">u32</span>) -&gt; &amp;<span class="hljs-symbol">'static</span> <span class="hljs-built_in">str</span> {
    <span class="hljs-keyword">match</span> id {
        <span class="hljs-number">0</span> =&gt; <span class="hljs-string">"Circle"</span>,
        <span class="hljs-number">1</span> =&gt; <span class="hljs-string">"Box"</span>,
        <span class="hljs-number">2</span> =&gt; <span class="hljs-string">"RoundBox"</span>,
        <span class="hljs-number">3</span> =&gt; <span class="hljs-string">"Hexagon"</span>,
        <span class="hljs-number">4</span> =&gt; <span class="hljs-string">"Cross"</span>,
        <span class="hljs-number">5</span> =&gt; <span class="hljs-string">"Star"</span>,
        _ =&gt; <span class="hljs-string">"Unknown"</span>,
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(
    state: Res&lt;AnimationState&gt;,
    materials: Res&lt;Assets&lt;SdfMorphMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;,
) {
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, mat)) = materials.iter().next() {
        <span class="hljs-keyword">let</span> u = &amp;mat.uniforms;
        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
            **text = <span class="hljs-built_in">format!</span>(
                <span class="hljs-string">"CONTROLS:\n\
                [1-6] Set Shape A\n\
                [Shift + 1-6] Set Shape B\n\
                [Space] Auto-Loop: {}\n\
                [Left/Right] Manual Morph\n\
                [Up/Down] Morph Speed\n\
                [G] Glow: {} | [O] Outline: {}\n\
                \n\
                STATUS:\n\
                Morph: {} &lt;---&gt; {}\n\
                Factor: {:.0}% | Speed: {:.2}"</span>,
                <span class="hljs-keyword">if</span> state.auto_morph { <span class="hljs-string">"ON"</span> } <span class="hljs-keyword">else</span> { <span class="hljs-string">"MANUAL"</span> },
                <span class="hljs-keyword">if</span> u.glow_intensity &gt; <span class="hljs-number">0.0</span> { <span class="hljs-string">"ON"</span> } <span class="hljs-keyword">else</span> { <span class="hljs-string">"OFF"</span> },
                <span class="hljs-keyword">if</span> u.outline_width &gt; <span class="hljs-number">0.0</span> { <span class="hljs-string">"ON"</span> } <span class="hljs-keyword">else</span> { <span class="hljs-string">"OFF"</span> },
                get_shape_name(u.shape_a),
                get_shape_name(u.shape_b),
                u.morph_factor * <span class="hljs-number">100.0</span>,
                state.morph_speed
            );
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_05_sdf_morphing;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"3.5"</span>,
    title: <span class="hljs-string">"SDF Morphing"</span>,
    run: demos::d03_05_sdf_morphing::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key</td><td>Action</td><td>Description</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Space</strong></td><td>Toggle Loop</td><td>Switch between automatic ping-pong animation and manual control.</td></tr>
<tr>
<td><strong>1-6</strong></td><td>Set Shape A</td><td>Select the starting shape (Circle, Box, RoundBox, Hexagon, Cross, Star).</td></tr>
<tr>
<td><strong>Shift + 1-6</strong></td><td>Set Shape B</td><td>Select the target shape.</td></tr>
<tr>
<td><strong>Left / Right</strong></td><td>Manual Morph</td><td>Scrub through the morph transition (when Loop is OFF).</td></tr>
<tr>
<td><strong>Up / Down</strong></td><td>Speed</td><td>Adjust the auto-animation speed.</td></tr>
<tr>
<td><strong>G / O</strong></td><td>Effects</td><td>Toggle Glow and Outline.</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764173970807/91b03200-aa4e-4c52-afe0-8267ae1bf77d.png" alt class="image--center mx-auto" /></p>
<ul>
<li><p><strong>Geometric Morphing</strong>: The transitions are now mathematically perfect. The Star (6) is sharp and precise. When morphing to a Circle (1), the points retract and the edges bulge outward until it is perfectly round.</p>
</li>
<li><p><strong>The Outline</strong>: Notice the white outline. It is generated by <code>abs(dist)</code>. Even when the shape is half-morphed and looks like a strange blob, the outline stays perfectly consistent and sharp.</p>
</li>
<li><p><strong>Ripples</strong>: The internal pattern visualizes the distance field itself. The ripples act like contour lines on a map, showing you exactly how the distance value changes from the center to the edge.</p>
</li>
</ul>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<ol>
<li><p><strong>Think in Fields, Not Pixels</strong>: An SDF doesn't define <em>where the shape is</em>. It defines <em>how far every pixel is from the edge</em>. This shift in thinking unlocks powerful effects.</p>
</li>
<li><p><strong>Infinite Resolution</strong>: Because SDFs are mathematical functions, they remain perfectly sharp at any zoom level. By using <code>fwidth()</code>, we can create edges that are always exactly 1 pixel soft, regardless of scale.</p>
</li>
<li><p><strong>Boolean Algebra</strong>: You don't need complex mesh algorithms to combine shapes. You just need <code>min()</code> (Union), <code>max()</code> (Intersection), and <code>max(d1, -d2)</code> (Subtraction).</p>
</li>
<li><p><strong>Free Effects</strong>: Once you have a distance field, adding an outline is just <code>abs(dist)</code>. Adding a glow is just <code>exp(-dist)</code>. The "hard work" of geometry gives you these effects for free.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>We've mastered precise geometric shapes. But nature is rarely perfect. To create clouds, fire, terrain, or water, we need controlled chaos. In the next article, we will dive into <strong>Procedural Noise</strong>, learning how to generate organic textures using randomness and fractals.</p>
<p><em>Next up:</em> <strong><em>3.6 - Procedural Noise</em></strong></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<h3 id="heading-1-the-sdf-sign-convention">1. The SDF Sign Convention</h3>
<p>The most important rule to remember. The sign tells you where you are.</p>
<ul>
<li><p><strong>Negative (</strong><code>&lt;0</code>): Inside the shape.</p>
</li>
<li><p><strong>Zero (</strong><code>=0</code>): Exactly on the edge.</p>
</li>
<li><p><strong>Positive (</strong><code>&gt;0</code>): Outside the shape.</p>
</li>
</ul>
<h3 id="heading-2-boolean-logic-cheat-sheet">2. Boolean Logic Cheat Sheet</h3>
<p>Combine shapes by comparing their distance values.</p>
<ul>
<li><p><strong>Union (Merge)</strong> -&gt; <code>min(a, b)</code></p>
<ul>
<li><em>Logic</em>: "I am closest to shape A or shape B."</li>
</ul>
</li>
<li><p><strong>Intersection (Overlap)</strong> -&gt; <code>max(a, b)</code></p>
<ul>
<li><em>Logic</em>: "I am strictly inside only if I am inside both."</li>
</ul>
</li>
<li><p><strong>Subtraction (Cut)</strong>  -&gt; <code>max(a, -b)</code></p>
<ul>
<li><em>Logic</em>: "Intersect Shape A with the inverse of Shape B."</li>
</ul>
</li>
</ul>
<h3 id="heading-3-the-golden-rule-of-anti-aliasing">3. The Golden Rule of Anti-Aliasing</h3>
<p>Never use <code>step(0.0, dist)</code> if you want smooth edges.<br />The magic formula for perfect edges at any zoom level is to smooth over the width of one pixel:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> aa = fwidth(dist);
<span class="hljs-keyword">let</span> alpha = <span class="hljs-number">1.0</span> - smoothstep(-aa, aa, dist);
</code></pre>
<h3 id="heading-4-creating-effects">4. Creating Effects</h3>
<p>Once you have the distance <code>d</code>, you can derive effects without extra geometry:</p>
<ul>
<li><p><strong>Outline</strong>: <code>abs(d)</code> creates a V-shape field centered on the edge.</p>
</li>
<li><p><strong>Glow</strong>: <code>exp(-d)</code> creates a natural light falloff.</p>
</li>
<li><p><strong>Inset</strong>: <code>-d</code> (inverted distance) lets you render effects inside the shape.</p>
</li>
</ul>
<h3 id="heading-5-domain-manipulation">5. Domain Manipulation</h3>
<p>Don't model complex repetition; warp the space instead.</p>
<ul>
<li><p><strong>Mirror Symmetry</strong>: <code>p.x = abs(p.x)</code> folds the left side onto the right.</p>
</li>
<li><p><strong>Radial Symmetry</strong>: Use <code>atan2</code> to convert to polar coordinates, then repeat the angle.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[3.4 - Gradients and Interpolation]]></title><description><![CDATA[What We're Learning
In the real world, colors are rarely static. The sky shifts from deep azure to pale cyan; a metal pipe has a bright highlight that fades into shadow; a sunset paints the clouds in bands of orange, pink, and purple. To replicate th...]]></description><link>https://blog.hexbee.net/34-gradients-and-interpolation</link><guid isPermaLink="true">https://blog.hexbee.net/34-gradients-and-interpolation</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Sat, 10 Jan 2026 12:17:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1763749105988/c2ea55ef-0ffa-4d8e-9005-db18fc3dc508.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>In the real world, colors are rarely static. The sky shifts from deep azure to pale cyan; a metal pipe has a bright highlight that fades into shadow; a sunset paints the clouds in bands of orange, pink, and purple. To replicate this in a shader, we need to master <strong>gradients</strong>.</p>
<p>At their core, gradients are about <strong>interpolation</strong>: the math of smoothly blending from one value to another. In previous articles, we used interpolation to move things. Now, we apply those same principles to color.</p>
<p>In this article, you will learn:</p>
<ul>
<li><p><strong>The Logic of</strong> <code>mix()</code>: How to blend any two values - colors, positions, or numbers - using a control factor.</p>
</li>
<li><p><strong>Linear Gradients</strong>: Using UV coordinates to create horizontal, vertical, and diagonal color ramps.</p>
</li>
<li><p><strong>smoothstep() Basics</strong>: How to create buttery-smooth transitions that feel organic rather than mechanical.</p>
</li>
<li><p><strong>Radial and Angled Gradients</strong>: Moving beyond simple lines to create circles, cones, and sweeping angles.</p>
</li>
<li><p><strong>Banding and Dithering</strong>: Why gradients sometimes look "stepped" on monitors and how to fix it with noise.</p>
</li>
<li><p><strong>The Aurora Shader</strong>: A complete project blending noise and gradients to create a dynamic northern lights effect.</p>
</li>
</ul>
<h2 id="heading-the-foundation-linear-interpolation">The Foundation: Linear Interpolation</h2>
<p>Before we paint a sunset, we must understand the brush. In WGSL, that brush is the <code>mix()</code> function.</p>
<p>In mathematics, this operation is often called <strong>Linear Interpolation</strong> (or <strong>lerp</strong>). It calculates a value between a <code>start</code> point and an <code>end</code> point based on a percentage <code>t</code>.</p>
<h3 id="heading-understanding-mix">Understanding <code>mix()</code></h3>
<p>The function signature is:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> result = mix(start, end, t);
</code></pre>
<p>Think of <code>t</code> as a slider or a percentage from <strong>0.0</strong> to <strong>1.0</strong>.</p>
<ul>
<li><p>When <code>t</code> is <strong>0.0</strong>, you get 100% of the <code>start</code> value.</p>
</li>
<li><p>When <code>t</code> is <strong>1.0</strong>, you get 100% of the <code>end</code> value.</p>
</li>
<li><p>When <code>t</code> is <strong>0.5</strong>, you get a perfect 50/50 blend.</p>
</li>
</ul>
<p>Mathematically, the GPU performs this calculation:<br /><code>result = start * (1.0 - t) + end * t</code></p>
<h3 id="heading-visualizing-the-slider">Visualizing the Slider</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764432895825/60b87892-03a2-4d4e-b0cd-04e410016e6e.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-interpolating-colors">Interpolating Colors</h3>
<p>Because colors in WGSL are just vectors (<code>vec3&lt;f32&gt;</code> or <code>vec4&lt;f32&gt;</code>), <code>mix()</code> works on them exactly the same way it works on single numbers. It blends the Red, Green, and Blue channels independently.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> red = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
<span class="hljs-keyword">let</span> blue = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);

<span class="hljs-comment">// 50% Red + 50% Blue = Purple</span>
<span class="hljs-keyword">let</span> purple = mix(red, blue, <span class="hljs-number">0.5</span>); 
<span class="hljs-comment">// Result: vec3&lt;f32&gt;(0.5, 0.0, 0.5)</span>
</code></pre>
<p><strong>A Note on Color Mud:</strong><br />Standard RGB interpolation is "linear," but human eyes don't perceive color linearly. If you mix pure <strong>Red</strong> and pure <strong>Green</strong>, the mathematical midpoint is a dark, murky olive color, not the bright Yellow you might expect. For simple gradients, this is fine, but if your gradients look "muddy" in the middle, it's often because of this limitation in the RGB color space.</p>
<h2 id="heading-linear-gradients-with-uv-coordinates">Linear Gradients with UV Coordinates</h2>
<p>The most common "slider" we have in a fragment shader is the UV coordinate system. Since UVs naturally range from 0.0 to 1.0 across the mesh, they plug directly into the <code>t</code> parameter of <code>mix()</code>.</p>
<h3 id="heading-horizontal-gradient">Horizontal Gradient</h3>
<p>To create a gradient from left to right, we use the <strong>U</strong> coordinate (<code>uv.x</code>).</p>
<pre><code class="lang-rust">#import bevy_pbr::forward_io::VertexOutput

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> color_left = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);  <span class="hljs-comment">// Red</span>
    <span class="hljs-keyword">let</span> color_right = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// Blue</span>

    <span class="hljs-comment">// Use U coordinate as our slider (0.0 at left, 1.0 at right)</span>
    <span class="hljs-keyword">let</span> t = <span class="hljs-keyword">in</span>.uv.x;

    <span class="hljs-keyword">let</span> color = mix(color_left, color_right, t);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p><strong>Result</strong>:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764433048260/2308add6-f8b9-4bcf-9926-4d248fdb7c29.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-vertical-gradient">Vertical Gradient</h3>
<p>To go from bottom to top, we use the <strong>V</strong> coordinate (<code>uv.y</code>).</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> color_bottom = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.2</span>, <span class="hljs-number">0.2</span>, <span class="hljs-number">0.2</span>); <span class="hljs-comment">// Dark Gray</span>
    <span class="hljs-keyword">let</span> color_top = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.7</span>, <span class="hljs-number">1.0</span>);    <span class="hljs-comment">// Sky Blue</span>

    <span class="hljs-comment">// Use V coordinate (0.0 at bottom, 1.0 at top)</span>
    <span class="hljs-keyword">let</span> t = <span class="hljs-keyword">in</span>.uv.y;

    <span class="hljs-keyword">let</span> color = mix(color_bottom, color_top, t);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-diagonal-gradient">Diagonal Gradient</h3>
<p>A diagonal gradient requires a <code>t</code> value that increases as we move both right and up. We can achieve this by averaging the coordinates.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> color_a = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> color_b = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);

    <span class="hljs-comment">// Average U and V. </span>
    <span class="hljs-comment">// Bottom-Left (0,0) -&gt; 0.0</span>
    <span class="hljs-comment">// Top-Right (1,1) -&gt; 1.0</span>
    <span class="hljs-keyword">let</span> t = (<span class="hljs-keyword">in</span>.uv.x + <span class="hljs-keyword">in</span>.uv.y) * <span class="hljs-number">0.5</span>;

    <span class="hljs-keyword">let</span> color = mix(color_a, color_b, t);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-controlling-the-range">Controlling the Range</h3>
<p>What if you want the gradient to start 20% of the way in and finish 80% of the way across? We need to manipulate our <code>t</code> value before plugging it into mix.</p>
<p>We remap the range using simple math: <code>(value - start) / (end - start)</code>.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> color_bg = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>); <span class="hljs-comment">// Black background</span>
    <span class="hljs-keyword">let</span> color_fg = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>); <span class="hljs-comment">// Yellow foreground</span>

    <span class="hljs-comment">// Define the start and end of the gradient in UV space</span>
    <span class="hljs-keyword">let</span> start = <span class="hljs-number">0.2</span>;
    <span class="hljs-keyword">let</span> end = <span class="hljs-number">0.8</span>;

    <span class="hljs-comment">// Remap uv.x to our new 0.0-1.0 range</span>
    <span class="hljs-keyword">let</span> t_raw = (<span class="hljs-keyword">in</span>.uv.x - start) / (end - start);

    <span class="hljs-comment">// Essential! Clamp the value.</span>
    <span class="hljs-comment">// Without clamp, mix() would extrapolate beyond colors A and B,</span>
    <span class="hljs-comment">// potentially creating negative colors or hyper-bright values.</span>
    <span class="hljs-keyword">let</span> t = clamp(t_raw, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);

    <span class="hljs-keyword">let</span> color = mix(color_bg, color_fg, t);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-repeating-gradients">Repeating Gradients</h3>
<p>In the previous article, we used <code>fract()</code> to repeat patterns. We can do the same for gradients to create soft, repeating bands.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> color_a = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> color_b = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>);

    <span class="hljs-comment">// Scale UVs by 5.0, then take the fractional part.</span>
    <span class="hljs-comment">// This creates a sawtooth wave: 0-&gt;1, 0-&gt;1, 0-&gt;1... five times.</span>
    <span class="hljs-keyword">let</span> t = fract(<span class="hljs-keyword">in</span>.uv.x * <span class="hljs-number">5.0</span>);

    <span class="hljs-keyword">let</span> color = mix(color_a, color_b, t);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h2 id="heading-the-smoothstep-function">The <code>smoothstep()</code> Function</h2>
<p>Linear interpolation is useful, but it often looks mechanical or "robotic." In the real world, transitions rarely happen at a constant speed; they accelerate and decelerate. Shadows have soft edges, and lights have a "hotspot" that fades out gradually.</p>
<p>To achieve this organic look, we use the <code>smoothstep()</code> function. It is arguably the most important function in procedural shader programming after <code>mix()</code>.</p>
<h3 id="heading-understanding-smoothstep">Understanding <code>smoothstep</code></h3>
<p>The function takes three arguments:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> result = smoothstep(edge0, edge1, x);
</code></pre>
<p>It returns a value between <strong>0.0</strong> and <strong>1.0</strong>, based on where <code>x</code> lies relative to <code>edge0</code> and <code>edge1</code>.</p>
<ol>
<li><p><strong>Below the Lower Edge</strong>: If <code>x</code> is less than <code>edge0</code>, it returns <strong>0.0</strong>.</p>
</li>
<li><p><strong>Above the Upper Edge</strong>: If <code>x</code> is greater than <code>edge1</code>, it returns <strong>1.0</strong>.</p>
</li>
<li><p><strong>In Between</strong>: If <code>x</code> is between the edges, it performs a smooth <a target="_blank" href="https://en.wikipedia.org/wiki/Hermite_interpolation">Hermite interpolation</a> (an S-shaped curve). It starts slow, speeds up in the middle, and slows down at the end.</p>
</li>
</ol>
<h3 id="heading-visual-comparison">Visual Comparison</h3>
<p>Imagine we are fading from black to white as UV.x goes from 0.0 to 1.0.</p>
<p><strong>Linear (</strong><code>t = x</code>): The change is constant. It looks like a stiff, perfect ramp.<br /><code>0.0 ➔ 0.25 ➔ 0.5 ➔ 0.75 ➔ 1.0</code></p>
<p><strong>Smoothstep (</strong><code>t = smoothstep(0.0, 1.0, x)</code>): The change eases in and eases out. It creates more contrast in the middle and softer transitions at the ends.<br /><code>0.0 ➔ 0.11 ➔ 0.5 ➔ 0.89 ➔ 1.0</code></p>
<h3 id="heading-using-smoothstep-with-mix">Using <code>smoothstep</code> with <code>mix</code></h3>
<p>The real power comes when you use the result of <code>smoothstep</code> as the <code>t</code> (control) value for your <code>mix</code> function.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> color_a = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> color_b = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>);

    <span class="hljs-comment">// Define the transition zone: from 30% to 70% of the screen</span>
    <span class="hljs-keyword">let</span> edge0 = <span class="hljs-number">0.3</span>;
    <span class="hljs-keyword">let</span> edge1 = <span class="hljs-number">0.7</span>;

    <span class="hljs-comment">// Calculate the curve</span>
    <span class="hljs-keyword">let</span> t = smoothstep(edge0, edge1, <span class="hljs-keyword">in</span>.uv.x);

    <span class="hljs-comment">// Blend colors using the curved t</span>
    <span class="hljs-keyword">let</span> color = mix(color_a, color_b, t);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p><strong>What happens here:</strong></p>
<ul>
<li><p><strong>Left 30%</strong>: Pure <code>color_a</code> (Black).</p>
</li>
<li><p><strong>Right 30%</strong>: Pure <code>color_b</code> (White).</p>
</li>
<li><p><strong>Middle 40%</strong>: A smooth, contrast-rich transition from Black to White.</p>
</li>
</ul>
<h3 id="heading-useful-patterns">Useful Patterns</h3>
<h4 id="heading-1-inverting-the-control">1. Inverting the Control</h4>
<p>Often you want <strong>1.0</strong> at the start and <strong>0.0</strong> at the end (like a light fading out). You can do this in two ways:</p>
<ul>
<li><strong>The Math Way</strong>: Generate a normal 0-to-1 curve, then subtract it from 1.0.</li>
</ul>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> t = <span class="hljs-number">1.0</span> - smoothstep(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, x);
</code></pre>
<ul>
<li><strong>The Shorthand Way</strong>: Simply swap the edges.</li>
</ul>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> t = smoothstep(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, x);
</code></pre>
<p>Both lines of code do exactly the same thing. Use whichever one makes more sense to you!</p>
<h4 id="heading-2-hard-edges">2. Hard Edges</h4>
<p>If <code>edge0</code> and <code>edge1</code> are very close together, the smooth transition tightens until it looks like a hard line. This is how we do anti-aliasing!</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A sharp, but slightly anti-aliased line at 0.5</span>
<span class="hljs-keyword">let</span> line = smoothstep(<span class="hljs-number">0.49</span>, <span class="hljs-number">0.51</span>, <span class="hljs-keyword">in</span>.uv.x);
</code></pre>
<h2 id="heading-radial-gradients">Radial Gradients</h2>
<p>Now that we understand interpolation and smoothing, let's leave the grid behind. Radial gradients emanate from a center point, creating circles, glows, and vignettes.</p>
<h3 id="heading-the-math-distance">The Math: Distance</h3>
<p>The engine of a radial gradient is the <code>distance()</code> function (or <code>length()</code>). We measure how far the current pixel (UV) is from a center point.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>);
<span class="hljs-keyword">let</span> dist = distance(<span class="hljs-keyword">in</span>.uv, center); <span class="hljs-comment">// or length(in.uv - center)</span>
</code></pre>
<p>In the center, <code>dist</code> is <strong>0.0</strong>. At the edges of a square (0.5 units away), <code>dist</code> is <strong>0.5</strong>. In the corners, <code>dist</code> is roughly <strong>0.707</strong> (the square root of 0.5² + 0.5²).</p>
<h3 id="heading-basic-radial-gradient">Basic Radial Gradient</h3>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>);

    <span class="hljs-comment">// Calculate distance</span>
    <span class="hljs-keyword">let</span> dist = distance(<span class="hljs-keyword">in</span>.uv, center);

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

    <span class="hljs-keyword">let</span> color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(brightness); <span class="hljs-comment">// Grayscale result</span>

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p><strong>Result</strong>: A cone-like gradient. It looks sharp and pointy in the center, like a pyramid viewed from above.</p>
<h3 id="heading-soft-glow-with-smoothstep">Soft Glow with <code>smoothstep</code></h3>
<p>The linear version usually looks too "pointy." To make it look like a glowing sphere or a spotlight, we use <code>smoothstep</code>.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>);
    <span class="hljs-keyword">let</span> dist = distance(<span class="hljs-keyword">in</span>.uv, center);

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

    <span class="hljs-keyword">let</span> color_center = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.8</span>, <span class="hljs-number">0.2</span>); <span class="hljs-comment">// Warm Orange</span>
    <span class="hljs-keyword">let</span> color_edge = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.1</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);   <span class="hljs-comment">// Dark Red</span>

    <span class="hljs-keyword">let</span> color = mix(color_edge, color_center, glow);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p><strong>Why flip the</strong> <code>smoothstep</code> arguments? <code>smoothstep(0.5, 0.0, dist)</code> is a valid trick.</p>
<ul>
<li><p>If <code>dist</code> is 0.0 (center), it returns 1.0.</p>
</li>
<li><p>If <code>dist</code> is 0.5 (edge), it returns 0.0.<br />  It saves us from writing 1.0 - ... and often easier to read: "Map 0.5 to 0, and 0.0 to 1".</p>
</li>
</ul>
<h3 id="heading-off-center-gradient">Off-Center Gradient</h3>
<p>You aren't stuck in the middle. By changing the center variable, you can move the "light source."</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Move center to top-right</span>
    <span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.8</span>, <span class="hljs-number">0.8</span>);
    <span class="hljs-keyword">let</span> dist = distance(<span class="hljs-keyword">in</span>.uv, center);

    <span class="hljs-comment">// Tighter glow radius (0.4)</span>
    <span class="hljs-keyword">let</span> glow = smoothstep(<span class="hljs-number">0.4</span>, <span class="hljs-number">0.0</span>, dist);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(glow), <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-animating-the-gradient">Animating the Gradient</h3>
<p>Since <code>mix</code> and <code>smoothstep</code> are just math, we can animate the parameters over time.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Animate the center point in a circle</span>
    <span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(
        <span class="hljs-number">0.5</span> + cos(material.time) * <span class="hljs-number">0.3</span>,
        <span class="hljs-number">0.5</span> + sin(material.time) * <span class="hljs-number">0.3</span>
    );

    <span class="hljs-keyword">let</span> dist = distance(<span class="hljs-keyword">in</span>.uv, center);

    <span class="hljs-comment">// Pulsate the radius</span>
    <span class="hljs-keyword">let</span> radius = <span class="hljs-number">0.3</span> + sin(material.time * <span class="hljs-number">5.0</span>) * <span class="hljs-number">0.05</span>;

    <span class="hljs-keyword">let</span> glow = smoothstep(radius, <span class="hljs-number">0.0</span>, dist);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(glow), <span class="hljs-number">1.0</span>);
}
</code></pre>
<h2 id="heading-multi-stop-gradients">Multi-Stop Gradients</h2>
<p>So far, we've only mixed two colors. But what if you want a sunset that goes <strong>Blue ➔ Pink ➔ Orange</strong>? <code>mix()</code> only takes two inputs, so we need to chain them together using logic.</p>
<h3 id="heading-the-ifelse-approach">The "<code>if/else</code>" Approach</h3>
<p>The simplest way to handle three colors is to split your UV space in half.</p>
<ul>
<li><p>If we are on the left side (0.0 to 0.5), mix <strong>Color A</strong> and <strong>Color B</strong>.</p>
</li>
<li><p>If we are on the right side (0.5 to 1.0), mix <strong>Color B</strong> and <strong>Color C</strong>.</p>
</li>
</ul>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> color_a = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// Blue</span>
    <span class="hljs-keyword">let</span> color_b = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// Pink</span>
    <span class="hljs-keyword">let</span> color_c = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.0</span>); <span class="hljs-comment">// Orange</span>

    <span class="hljs-keyword">let</span> t = <span class="hljs-keyword">in</span>.uv.y; <span class="hljs-comment">// Vertical gradient</span>

    var color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;;

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

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p><strong>Alternative: The</strong> <code>select()</code> Function</p>
<p>WGSL provides a built-in function called <code>select(false_value, true_value, condition)</code> that picks a value without using an <code>if</code> statement. This is often preferred in shader programming because it is concise and avoids "branching" (splitting the code path), which can sometimes optimize better on GPUs.</p>
<p>We could rewrite the logic above like this:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Calculate both possibilities</span>
<span class="hljs-keyword">let</span> mix_1 = mix(color_a, color_b, t / <span class="hljs-number">0.5</span>);
<span class="hljs-keyword">let</span> mix_2 = mix(color_b, color_c, (t - <span class="hljs-number">0.5</span>) / <span class="hljs-number">0.5</span>);

<span class="hljs-comment">// Pick the correct one based on the boolean condition</span>
<span class="hljs-keyword">let</span> color = select(mix_2, mix_1, t &lt; <span class="hljs-number">0.5</span>);
</code></pre>
<h3 id="heading-the-smoothstep-blending-approach">The <code>smoothstep</code> Blending Approach</h3>
<p>For a softer, more organic look without hard math logic, you can calculate a "weight" for each color band using <code>smoothstep</code> and add them together.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> t = <span class="hljs-keyword">in</span>.uv.x;

    <span class="hljs-comment">// Define colors</span>
    <span class="hljs-keyword">let</span> deep_blue = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.1</span>, <span class="hljs-number">0.1</span>, <span class="hljs-number">0.4</span>);
    <span class="hljs-keyword">let</span> purple    = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.5</span>);
    <span class="hljs-keyword">let</span> gold      = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.8</span>, <span class="hljs-number">0.0</span>);

    <span class="hljs-comment">// Start with the base color</span>
    var final_color = deep_blue;

    <span class="hljs-comment">// As we pass 0.3, fade in Purple</span>
    <span class="hljs-keyword">let</span> t_purple = smoothstep(<span class="hljs-number">0.3</span>, <span class="hljs-number">0.5</span>, t);
    final_color = mix(final_color, purple, t_purple);

    <span class="hljs-comment">// As we pass 0.6, fade in Gold</span>
    <span class="hljs-keyword">let</span> t_gold = smoothstep(<span class="hljs-number">0.6</span>, <span class="hljs-number">0.8</span>, t);
    final_color = mix(final_color, gold, t_gold);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(final_color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p>This approach essentially layers the gradients on top of each other. It's much cleaner to read and easier to tweak.</p>
<h2 id="heading-angled-gradients-with-vector-math">Angled Gradients with Vector Math</h2>
<p>We can make horizontal, vertical, and radial gradients... but what about a gradient at a 45-degree angle? Or 30 degrees?</p>
<p>To do this, we need a tiny bit of vector math: the <strong>Dot Product</strong>.</p>
<h3 id="heading-the-logic-projection">The Logic: Projection</h3>
<p>Imagine a line pointing in the direction you want your gradient to go. To color a specific pixel, we need to know: "How far along this line is this pixel?"</p>
<p>The <code>dot(A, B)</code> function answers exactly this question. It "projects" vector A onto vector B.</p>
<ol>
<li><p><strong>Direction</strong>: We define a direction vector (e.g., pointing 45 degrees up-right).</p>
</li>
<li><p><strong>Position</strong>: We take the pixel's UV position relative to the center.</p>
</li>
<li><p><strong>Projection</strong>: dot(position, direction) gives us a single number representing how far along that direction the pixel lies.</p>
</li>
</ol>
<h3 id="heading-implementation">Implementation</h3>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Define the angle (in radians)</span>
    <span class="hljs-comment">// 45 degrees = PI / 4</span>
    <span class="hljs-keyword">let</span> angle = <span class="hljs-number">0.785</span>; 

    <span class="hljs-comment">// Convert angle to a direction vector (x=cos, y=sin)</span>
    <span class="hljs-keyword">let</span> dir = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(cos(angle), sin(angle));

    <span class="hljs-comment">// 2. Center the UVs (so 0,0 is in the middle of the mesh)</span>
    <span class="hljs-keyword">let</span> centered_uv = <span class="hljs-keyword">in</span>.uv - <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// 3. Project the position onto the direction</span>
    <span class="hljs-comment">// This returns a value roughly between -0.7 and 0.7</span>
    <span class="hljs-keyword">let</span> projection = dot(centered_uv, dir);

    <span class="hljs-comment">// 4. Remap to 0.0 - 1.0 range for mixing</span>
    <span class="hljs-comment">// We add 0.5 to re-center it, and scale it slightly to fit</span>
    <span class="hljs-keyword">let</span> t = clamp(projection + <span class="hljs-number">0.5</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);

    <span class="hljs-comment">// 5. Mix!</span>
    <span class="hljs-keyword">let</span> color = mix(
        vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>), <span class="hljs-comment">// Red</span>
        vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>), <span class="hljs-comment">// Blue</span>
        t
    );

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p><strong>Why is this powerful?</strong></p>
<p>Because angle is just a number, you can pass it in as a <strong>uniform</strong> or animate it using <code>material.time</code>. This allows you to create spinning gradients like radar sweeps or rotating lights with zero extra effort.</p>
<h2 id="heading-dithering-techniques">Dithering Techniques</h2>
<p>You might notice that your beautiful smooth gradients sometimes look like a series of ugly bands or steps, especially in dark areas. This is called <strong>Color Banding</strong>.</p>
<h3 id="heading-why-banding-happens">Why Banding Happens</h3>
<p>Banding occurs because monitors have limited precision. A standard monitor uses 8 bits per color channel, meaning it can only display <strong>256</strong> shades of Red, Green, or Blue.</p>
<p>If you stretch a gradient from Black to Dark Blue (<code>0.0</code> to <code>0.1</code>) across 1000 pixels, you only have about 25 shades available to cover that distance. Each shade will span roughly 40 pixels, creating visible "steps."</p>
<h3 id="heading-the-solution-dithering">The Solution: Dithering</h3>
<p>Dithering is a trick from the days of print media. By adding controlled noise to the image, we break up the hard edges between bands. Our eyes blend the noise together, creating the illusion of a smoother gradient.</p>
<h3 id="heading-1-simple-random-dithering">1. Simple Random Dithering</h3>
<p>The easiest method is to add a tiny amount of random noise to each pixel.</p>
<p>We first need a "pseudo-random" number generator. Since shaders are deterministic, we use a mathematical function that looks random.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A standard "hash" function for shaders.</span>
<span class="hljs-comment">// It takes a 2D coordinate and returns a pseudo-random value between 0.0 and 1.0.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> p3 = fract(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(p.x, p.y, p.x) * <span class="hljs-number">0.1031</span>);
    <span class="hljs-keyword">let</span> dot_product = dot(p3, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(p3.y, p3.z, p3.x) + <span class="hljs-number">33.33</span>);
    <span class="hljs-keyword">return</span> fract((p3.x + p3.y) * dot_product);
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Create a smooth gradient susceptible to banding</span>
    <span class="hljs-keyword">let</span> t = <span class="hljs-keyword">in</span>.uv.x;
    <span class="hljs-keyword">let</span> color = mix(
        vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.05</span>), <span class="hljs-comment">// Very dark blue</span>
        vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.2</span>),  <span class="hljs-comment">// Slightly lighter blue</span>
        t
    );

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

    <span class="hljs-comment">// Scale the noise.</span>
    <span class="hljs-comment">// 1.0 / 255.0 represents the smallest possible color step on a monitor.</span>
    <span class="hljs-comment">// We subtract 0.5 so the noise averages out to 0.</span>
    <span class="hljs-keyword">let</span> dither_strength = <span class="hljs-number">1.0</span> / <span class="hljs-number">255.0</span>;
    <span class="hljs-keyword">let</span> dither = (random_val - <span class="hljs-number">0.5</span>) * dither_strength;

    <span class="hljs-comment">// Add the noise to the color</span>
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color + dither, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-2-ordered-bayer-dithering">2. Ordered (Bayer) Dithering</h3>
<p>Random noise can sometimes look like "film grain" or static. <a target="_blank" href="https://en.wikipedia.org/wiki/Ordered_dithering"><strong>Ordered Dithering</strong></a> uses a specific grid pattern (a Bayer Matrix) to offset pixels in a checkerboard-like fashion. This looks much cleaner and more stable.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Returns a value from a 4x4 Bayer Matrix</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">bayer_dither</span></span>(position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> x = <span class="hljs-built_in">u32</span>(position.x) % <span class="hljs-number">4</span>u;
    <span class="hljs-keyword">let</span> y = <span class="hljs-built_in">u32</span>(position.y) % <span class="hljs-number">4</span>u;
    <span class="hljs-keyword">let</span> index = y * <span class="hljs-number">4</span>u + x;

    <span class="hljs-comment">// The 4x4 Bayer Matrix pattern</span>
    var matrix = array&lt;<span class="hljs-built_in">f32</span>, <span class="hljs-number">16</span>&gt;(
         <span class="hljs-number">0.0</span>,  <span class="hljs-number">8.0</span>,  <span class="hljs-number">2.0</span>, <span class="hljs-number">10.0</span>,
        <span class="hljs-number">12.0</span>,  <span class="hljs-number">4.0</span>, <span class="hljs-number">14.0</span>,  <span class="hljs-number">6.0</span>,
         <span class="hljs-number">3.0</span>, <span class="hljs-number">11.0</span>,  <span class="hljs-number">1.0</span>,  <span class="hljs-number">9.0</span>,
        <span class="hljs-number">15.0</span>,  <span class="hljs-number">7.0</span>, <span class="hljs-number">13.0</span>,  <span class="hljs-number">5.0</span>
    );

    <span class="hljs-keyword">return</span> matrix[index] / <span class="hljs-number">16.0</span>;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> t = <span class="hljs-keyword">in</span>.uv.x;
    <span class="hljs-keyword">let</span> color = mix(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>), vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.1</span>), t);

    <span class="hljs-comment">// Use screen-space coordinates (in.clip_position) for the dither pattern</span>
    <span class="hljs-comment">// so the pattern stays crisp even if the object moves.</span>
    <span class="hljs-keyword">let</span> bayer_val = bayer_dither(<span class="hljs-keyword">in</span>.clip_position);

    <span class="hljs-keyword">let</span> dither_strength = <span class="hljs-number">1.0</span> / <span class="hljs-number">255.0</span>;
    <span class="hljs-keyword">let</span> dither = (bayer_val - <span class="hljs-number">0.5</span>) * dither_strength;

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color + dither, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h2 id="heading-metallic-and-iridescent-effects">Metallic and Iridescent Effects</h2>
<p>Gradients aren't just for 2D patterns. In 3D rendering, gradients are fundamental to creating the look of complex materials like metal or oil.</p>
<h3 id="heading-the-fresnel-gradient">The Fresnel Gradient</h3>
<p>Metallic and shiny surfaces look different depending on the angle you view them from. Surfaces facing away from you (the edges of a sphere) tend to be more reflective than surfaces facing directly at you. This is called the <strong>Fresnel Effect</strong>.</p>
<p>We can calculate this "grazing angle" using the dot product between the surface Normal and the View Direction.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Get standard vectors</span>
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);
    <span class="hljs-comment">// Calculate direction from camera to pixel</span>
    <span class="hljs-comment">// (Assuming we pass camera_position as a uniform, or derive it)</span>
    <span class="hljs-keyword">let</span> view_dir = normalize(<span class="hljs-keyword">in</span>.world_position - camera_position); 
    <span class="hljs-comment">// Note: In real code, View Dir is usually (Camera - Position) to point towards camera.</span>
    <span class="hljs-comment">// Let's fix that direction:</span>
    <span class="hljs-keyword">let</span> to_camera = normalize(camera_position - <span class="hljs-keyword">in</span>.world_position);

    <span class="hljs-comment">// 2. Calculate alignment</span>
    <span class="hljs-comment">// dot() is 1.0 if looking straight at surface, 0.0 if looking at edge.</span>
    <span class="hljs-keyword">let</span> alignment = max(<span class="hljs-number">0.0</span>, dot(normal, to_camera));

    <span class="hljs-comment">// 3. Invert it to get the "Edge Factor"</span>
    <span class="hljs-comment">// 0.0 at center, 1.0 at edge.</span>
    <span class="hljs-keyword">let</span> fresnel = <span class="hljs-number">1.0</span> - alignment;

    <span class="hljs-comment">// 4. Sharpen the gradient</span>
    <span class="hljs-comment">// Raising to a power (typically 3.0 to 5.0) creates that distinct "rim light" look</span>
    <span class="hljs-keyword">let</span> rim_light = pow(fresnel, <span class="hljs-number">3.0</span>);

    <span class="hljs-comment">// 5. Mix colors based on viewing angle</span>
    <span class="hljs-keyword">let</span> center_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.5</span>); <span class="hljs-comment">// Dark Blue</span>
    <span class="hljs-keyword">let</span> edge_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>);   <span class="hljs-comment">// Bright Cyan</span>

    <span class="hljs-keyword">let</span> color = mix(center_color, edge_color, rim_light);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-iridescence-holographic-effects">Iridescence (Holographic Effects)</h3>
<p>By mapping that Fresnel gradient to a spectrum of colors instead of just two, we can simulate iridescent materials like bubbles, oil slicks, or holographic stickers.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rainbow_gradient</span></span>(t: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// A cosine-based palette technique (Procedural Color Palette)</span>
    <span class="hljs-comment">// It cycles R, G, and B waves at different offsets.</span>
    <span class="hljs-keyword">let</span> a = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>);
    <span class="hljs-keyword">let</span> b = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>);
    <span class="hljs-keyword">let</span> c = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>);
    <span class="hljs-keyword">let</span> d = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.33</span>, <span class="hljs-number">0.67</span>);

    <span class="hljs-keyword">return</span> a + b * cos(<span class="hljs-number">6.28318</span> * (c * t + d));
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);
    <span class="hljs-keyword">let</span> to_camera = normalize(camera_position - <span class="hljs-keyword">in</span>.world_position);
    <span class="hljs-keyword">let</span> fresnel = <span class="hljs-number">1.0</span> - max(<span class="hljs-number">0.0</span>, dot(normal, to_camera));

    <span class="hljs-comment">// Use the fresnel factor to sample a rainbow</span>
    <span class="hljs-keyword">let</span> color = rainbow_gradient(fresnel);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p>This creates a surface that shifts color wildly as you move the camera around it.</p>
<hr />
<h2 id="heading-complete-example-aurora-borealis-shader">Complete Example: Aurora Borealis Shader</h2>
<p>To bring everything we've learned together, we will build a shader for one of nature's most beautiful gradients: the Aurora Borealis.</p>
<p>Unlike the simple gradients we've made so far, an aurora is dynamic. It moves, shimmers, and folds. To achieve this look without using textures, we need to combine <strong>gradients</strong> with <strong>noise</strong> and <strong>coordinate distortion</strong>.</p>
<h3 id="heading-our-goal">Our Goal</h3>
<p>We want to create a "curtain" of light that hangs in the sky.</p>
<ol>
<li><p><strong>Shape</strong>: It should look like vertical rays or folds.</p>
</li>
<li><p><strong>Color</strong>: It should transition from green at the bottom (oxygen) to purple at the top (nitrogen).</p>
</li>
<li><p><strong>Movement</strong>: The curtain should wave slowly, while the internal rays shimmer quickly.</p>
</li>
</ol>
<h3 id="heading-the-shader-assetsshadersd0304aurorawgsl">The Shader (<code>assets/shaders/d03_04_aurora.wgsl</code>)</h3>
<p>This shader relies on a technique called <strong>Domain Distortion</strong>. Instead of drawing noise directly on the standard grid, we first "bend" the coordinate system into a curve. When we draw vertical lines on this bent grid, they appear to wind back and forth like a curtain.</p>
<p>The fragment shader creates the effect in five distinct steps:</p>
<ol>
<li><p><strong>Curtain Shape</strong>: We offset the Y coordinate based on a sine wave of the X coordinate. This creates the winding path.</p>
</li>
<li><p><strong>Ray Generation</strong>: We generate noise using a coordinate system that is heavily stretched on the Y-axis. This turns what would be "blobs" of noise into long vertical streaks.</p>
</li>
<li><p><strong>Vertical Masking</strong>: We use smoothstep to fade the aurora out at the bottom and top, ensuring it blends naturally into the sky.</p>
</li>
<li><p><strong>Color Gradient</strong>: We map the Y (altitude) coordinate to a color gradient, blending from the base color to the top color.</p>
</li>
<li><p><strong>Composition</strong>: We multiply the color, the rays, and the mask together to get the final glowing result.</p>
</li>
</ol>
<pre><code class="lang-rust">#import bevy_pbr::forward_io::VertexOutput
#import bevy_pbr::mesh_view_bindings::globals
#import bevy_pbr::mesh_functions::{get_world_from_local, mesh_position_local_to_world}
#import bevy_pbr::view_transformations::position_world_to_clip

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">AuroraMaterial</span></span> {
    base_color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    top_color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    speed: <span class="hljs-built_in">f32</span>,
    curtain_waviness: <span class="hljs-built_in">f32</span>,
    ray_density: <span class="hljs-built_in">f32</span>,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: AuroraMaterial;

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">Vertex</span></span> {
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
};

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(vertex: Vertex) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-comment">// Standard Bevy vertex transformation</span>
    <span class="hljs-keyword">let</span> model = get_world_from_local(vertex.instance_index);
    <span class="hljs-keyword">let</span> world_position = mesh_position_local_to_world(model, vec4&lt;<span class="hljs-built_in">f32</span>&gt;(vertex.position, <span class="hljs-number">1.0</span>));

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

    <span class="hljs-keyword">return</span> out;
}

<span class="hljs-comment">// A simple pseudo-random hash function</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> p3 = fract(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(p.x, p.y, p.x) * <span class="hljs-number">0.1031</span>);
    <span class="hljs-keyword">let</span> dot_product = dot(p3, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(p3.y, p3.z, p3.x) + <span class="hljs-number">33.33</span>);
    <span class="hljs-keyword">return</span> fract((p3.x + p3.y) * dot_product);
}

<span class="hljs-comment">// Value Noise: Smoothly interpolated random values</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">noise</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> i = floor(p);
    <span class="hljs-keyword">let</span> f = fract(p);
    <span class="hljs-keyword">let</span> u = f * f * (<span class="hljs-number">3.0</span> - <span class="hljs-number">2.0</span> * f); <span class="hljs-comment">// smoothstep curve</span>

    <span class="hljs-keyword">let</span> a = hash(i);
    <span class="hljs-keyword">let</span> b = hash(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>));
    <span class="hljs-keyword">let</span> c = hash(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> d = hash(i + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));

    <span class="hljs-keyword">return</span> mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

<span class="hljs-comment">// Fractal Brownian Motion: Layering noise for detail</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fbm</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    var value = <span class="hljs-number">0.0</span>;
    var amp = <span class="hljs-number">0.5</span>;
    var freq = <span class="hljs-number">1.0</span>;
    var p_iter = p;

    <span class="hljs-comment">// 3 layers of noise</span>
    <span class="hljs-keyword">for</span> (var i = <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">3</span>; i++) {
        value += noise(p_iter) * amp;
        p_iter *= <span class="hljs-number">2.0</span>;
        amp *= <span class="hljs-number">0.5</span>;
    }
    <span class="hljs-keyword">return</span> value;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> time = globals.time * material.speed;

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

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

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

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

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

    <span class="hljs-comment">// 5. COMBINE</span>
    <span class="hljs-comment">// Multiply color by the rays and the alpha mask.</span>
    <span class="hljs-comment">// We boost the brightness (rays * 2.0) to make it glow.</span>
    <span class="hljs-keyword">let</span> final_color = aurora_color * rays * <span class="hljs-number">2.0</span>;
    <span class="hljs-keyword">let</span> final_alpha = rays * alpha_mask;

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(final_color, final_alpha);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0304aurorars">The Rust Material (<code>src/materials/d03_04_aurora.rs</code>)</h3>
<p>This struct maps our shader uniforms to Rust types. Note the <code>alpha_mode</code> implementation: setting this to <code>AlphaMode::Blend</code> is crucial. Without it, our shader would render as an opaque block, blacking out anything behind it. By enabling blending, we tell the pipeline to mix our output color with the background based on the alpha value we calculated in the shader, creating the ethereal, transparent look of light.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">AuroraMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> base_color: LinearRgba,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> top_color: LinearRgba,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> speed: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> curtain_waviness: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> ray_density: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> AuroraMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
        <span class="hljs-keyword">Self</span> {
            base_color: LinearRgba::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">1.0</span>), <span class="hljs-comment">// Bright Green</span>
            top_color: LinearRgba::new(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>),  <span class="hljs-comment">// Purple</span>
            speed: <span class="hljs-number">0.5</span>,
            curtain_waviness: <span class="hljs-number">0.5</span>,
            ray_density: <span class="hljs-number">10.0</span>,
        }
    }
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> AuroraMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d03_04_aurora.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d03_04_aurora.wgsl"</span>.into()
    }

    <span class="hljs-comment">// Essential for the "transparent" look of light in the sky</span>
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">alpha_mode</span></span>(&amp;<span class="hljs-keyword">self</span>) -&gt; AlphaMode {
        AlphaMode::Blend
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_04_aurora;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0304aurorars">The Demo Module (<code>src/demos/d03_04_aurora.rs</code>)</h3>
<p>To properly showcase the effect, we need more than just a quad in empty space. This demo constructs a scene with a sense of scale:</p>
<ol>
<li><p><strong>The Aurora</strong>: A large quad placed high in the distance, tilted slightly toward the camera.</p>
</li>
<li><p><strong>The Ruins</strong>: We generate a ring of large, rectangular stone monoliths in the foreground. These silhouettes create a "Stonehenge-like" frame for the sky, adding parallax and contrast.</p>
</li>
<li><p><strong>The Lighting</strong>: We use a very dim AmbientLight so the stones are visible as silhouettes, preserving the ominous night-time atmosphere.</p>
</li>
</ol>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d03_04_aurora::AuroraMaterial;
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> std::<span class="hljs-built_in">f32</span>::consts::TAU;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;AuroraMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(Update, (update_ui, handle_input))
        .run();
}

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

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

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

    <span class="hljs-keyword">let</span> radius = <span class="hljs-number">10.0</span>;
    <span class="hljs-keyword">let</span> stone_count = <span class="hljs-number">12</span>;

    <span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..stone_count {
        <span class="hljs-keyword">let</span> angle = (i <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / stone_count <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span>) * TAU;
        <span class="hljs-keyword">let</span> x = angle.cos() * radius;
        <span class="hljs-keyword">let</span> z = angle.sin() * radius;

        commands.spawn((
            Mesh3d(stone_mesh.clone()),
            MeshMaterial3d(stone_mat.clone()),
            Transform::from_xyz(x, <span class="hljs-number">0.5</span>, z)
                <span class="hljs-comment">// Rotate the stone to face the center of the circle</span>
                .looking_at(Vec3::ZERO, Vec3::Y),
        ));
    }

    <span class="hljs-comment">// 4. Lighting (Subtle)</span>
    commands.insert_resource(AmbientLight {
        color: Color::srgb(<span class="hljs-number">0.05</span>, <span class="hljs-number">0.05</span>, <span class="hljs-number">0.1</span>),
        brightness: <span class="hljs-number">100.0</span>,
        ..default()
    });

    <span class="hljs-comment">// 5. Camera</span>
    commands.spawn((
        Camera3d::default(),
        <span class="hljs-comment">// Center of the circle, looking up at the sky through the stones</span>
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">6.0</span>).looking_at(Vec3::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">4.0</span>, -<span class="hljs-number">10.0</span>), Vec3::Y),
    ));

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

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;AuroraMaterial&gt;&gt;,
    time: Res&lt;Time&gt;,
) {
    <span class="hljs-keyword">let</span> delta = time.delta_secs();

    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-comment">// Speed Control</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyQ) { material.speed += delta; }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyA) { material.speed -= delta; }

        <span class="hljs-comment">// Ray Density (The "Streaks")</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyW) { material.ray_density += delta * <span class="hljs-number">5.0</span>; }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyS) { material.ray_density -= delta * <span class="hljs-number">5.0</span>; }

        <span class="hljs-comment">// Curtain Waviness (The "Snake" shape)</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyE) { material.curtain_waviness += delta; }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyD) { material.curtain_waviness -= delta; }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(
    materials: Res&lt;Assets&lt;AuroraMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;,
) {
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, material)) = materials.iter().next() {
        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
            **text = <span class="hljs-built_in">format!</span>(
                <span class="hljs-string">"[Q/A] Speed | [W/S] Ray Density | [E/D] Waviness\n\
                 \n\
                 Speed: {:.2}\n\
                 Ray Density: {:.2}\n\
                 Waviness: {:.2}"</span>,
                material.speed,
                material.ray_density,
                material.curtain_waviness
            );
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_04_aurora;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"3.4"</span>,
    title: <span class="hljs-string">"Gradients &amp; Aurora"</span>,
    run: demos::d03_04_aurora::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>When you run the demo, you should see a glowing, shimmering curtain of light with distinct vertical streaks. The bottom should be a vibrant green fading into a deep purple at the top.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key</td><td>Action</td><td>Effect</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Q / A</strong></td><td>Speed +/-</td><td>Controls how fast the aurora undulates and shimmers.</td></tr>
<tr>
<td><strong>W / S</strong></td><td>Density +/-</td><td>Increases or decreases the number of vertical rays. High values look like energetic rain; low values look like soft clouds.</td></tr>
<tr>
<td><strong>E / D</strong></td><td>Waviness +/-</td><td>Controls how much the curtain curves. Set to 0 for a straight wall of light, or high for a very twisted path.</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763749172607/126559cc-5432-4617-81a0-531ccbcd4c93.png" alt class="image--center mx-auto" /></p>
<ul>
<li><p><strong>The Curtain Shape</strong>: Notice how the entire wall of light moves back and forth. This is the <code>curtain_curve</code> in the shader effectively moving the <code>y</code> coordinate based on the <code>x</code> position.</p>
</li>
<li><p><strong>The Rays</strong>: The vertical lines are created by the <code>fbm</code> noise. Because we multiply the <code>x</code> coordinate by <code>ray_density</code> (e.g., 10.0) but leave <code>y</code> alone, the noise stretches vertically.</p>
</li>
<li><p><strong>The Gradient</strong>: The color shift from Green to Purple is purely a function of the Y-axis (altitude), interpolated with <code>smoothstep</code>.</p>
</li>
</ul>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<ol>
<li><p><code>mix()</code> is your best friend: Almost every smooth transition in graphics - whether it's color, position, or opacity - is built on linear interpolation.</p>
</li>
<li><p><code>smoothstep()</code> adds the polish: Replacing linear transitions with S-curves makes procedural effects look organic and high-quality.</p>
</li>
<li><p><strong>Math is Logic</strong>: You can build complex logic using simple math functions. A radial gradient is just <code>distance()</code>; an angled gradient is just <code>dot()</code>.</p>
</li>
<li><p><strong>Coordinates are Malleable</strong>: As seen in the Aurora, you don't have to accept UV coordinates as they are. You can bend, twist, and stretch them to create complex shapes from simple noise.</p>
</li>
<li><p><strong>Dithering matters</strong>: When working with subtle dark gradients, always keep banding in mind. A little bit of noise goes a long way.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>We've mastered patterns and gradients, but so far everything has been mathematically generated. What if we want to use actual images - photos of bricks, wood, or metal? In the next phase, we unlock the power of <strong>Textures</strong>, learning how to load images into Bevy and sample them in our shaders.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/35-distance-functions-sdfs"><strong><em>3.5 - Distance Functions</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<h3 id="heading-linear-interpolation-lerp">Linear Interpolation (Lerp)</h3>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> result = mix(start, end, t); <span class="hljs-comment">// t is 0.0 to 1.0</span>
</code></pre>
<h3 id="heading-smooth-transitions">Smooth Transitions</h3>
<pre><code class="lang-rust"><span class="hljs-comment">// Returns 0.0 if x &lt; 0.2, 1.0 if x &gt; 0.8, smooth curve in between</span>
<span class="hljs-keyword">let</span> t = smoothstep(<span class="hljs-number">0.2</span>, <span class="hljs-number">0.8</span>, x);
</code></pre>
<h3 id="heading-radial-gradient">Radial Gradient</h3>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> dist = distance(uv, center);
<span class="hljs-keyword">let</span> glow = <span class="hljs-number">1.0</span> - smoothstep(<span class="hljs-number">0.0</span>, radius, dist);
</code></pre>
<h3 id="heading-angled-gradient">Angled Gradient</h3>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> dir = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(cos(angle), sin(angle));
<span class="hljs-keyword">let</span> t = dot(uv - <span class="hljs-number">0.5</span>, dir) + <span class="hljs-number">0.5</span>;
</code></pre>
<h3 id="heading-procedural-dithering">Procedural Dithering</h3>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> noise = fract(sin(dot(uv, vec2(<span class="hljs-number">12.9898</span>, <span class="hljs-number">78.233</span>))) * <span class="hljs-number">43758.5453</span>);
<span class="hljs-keyword">let</span> dithered_color = color + (noise - <span class="hljs-number">0.5</span>) / <span class="hljs-number">255.0</span>;
</code></pre>
]]></content:encoded></item><item><title><![CDATA[3.3 - UV-Based Patterns]]></title><description><![CDATA[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...]]></description><link>https://blog.hexbee.net/33-uv-based-patterns</link><guid isPermaLink="true">https://blog.hexbee.net/33-uv-based-patterns</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Mon, 05 Jan 2026 07:39:59 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1763669189547/3d1a52d8-50c2-43d2-a480-87921e990176.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>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.</p>
<p>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.</p>
<p>Understanding how to manipulate UVs is a foundational skill in shader programming. These techniques are used everywhere:</p>
<ul>
<li><p>Creating tiling patterns for floors, walls, and architectural details.</p>
</li>
<li><p>Generating masks to control where effects appear.</p>
</li>
<li><p>Building complex procedural textures like wood grain or marble from scratch.</p>
</li>
<li><p>Driving animations and visual effects across a surface.</p>
</li>
<li><p>Creating debug visualizations to understand how data flows across your mesh.</p>
</li>
</ul>
<p>By the end of this article, you will have mastered the art and science of UV-based patterns. You'll learn:</p>
<ul>
<li><p>How to visualize and use <strong>UV coordinates</strong>, the 2D blueprint for your 3D model's surface.</p>
</li>
<li><p>To craft fundamental patterns like <strong>stripes, checkerboards, and circles</strong> using a handful of powerful WGSL functions like <code>fract()</code> and <code>step()</code>.</p>
</li>
<li><p>How to manipulate UV space itself - <strong>scaling, rotating, and moving</strong> your patterns for precise control.</p>
</li>
<li><p>Techniques for <strong>layering and blending</strong> simple patterns to create complex and unique designs.</p>
</li>
<li><p>How to solve the common problem of <strong>pattern stretching</strong> on non-square shapes using aspect ratio correction.</p>
</li>
<li><p>To build a complete, interactive <strong>procedural pattern generator</strong> that combines all these concepts.</p>
</li>
</ul>
<h2 id="heading-understanding-uv-coordinates">Understanding UV Coordinates</h2>
<p>Before we can generate patterns, we must understand the canvas we're painting on. In the world of shaders, that canvas is defined by <strong>UV coordinates</strong>.</p>
<h3 id="heading-what-are-uv-coordinates">What Are UV Coordinates?</h3>
<p>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.</p>
<p>The 2D coordinate system of this unwrapped map is called UV space.</p>
<ul>
<li><p>The letters <strong>U</strong> and <strong>V</strong> are used instead of X and Y simply to distinguish them from 3D world coordinates.</p>
</li>
<li><p><strong>U</strong> typically represents the horizontal axis.</p>
</li>
<li><p><strong>V</strong> typically represents the vertical axis.</p>
</li>
</ul>
<p>This unwrapped map provides a 2D "address" for every single point on the 3D model's surface.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764431599137/74911ed5-b4d4-4371-bc17-21ff8e826e6d.png" alt class="image--center mx-auto" /></p>
<p><strong>Key Properties:</strong></p>
<ul>
<li><p><strong>Normalized Range:</strong> UV coordinates almost always exist within a <code>[0.0, 1.0]</code> square. The bottom-left corner is <code>(0.0, 0.0)</code> and the top-right is <code>(1.0, 1.0)</code>.</p>
</li>
<li><p><strong>Topology, Not Size:</strong> 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.</p>
</li>
<li><p><strong>Defined Per-Vertex:</strong> 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.</p>
</li>
</ul>
<h3 id="heading-how-uvs-flow-from-bevy-to-wgsl">How UVs Flow from Bevy to WGSL</h3>
<p>When you create a standard mesh in Bevy (like <code>Plane3d</code> or a loaded model), it usually comes with UV coordinates already defined.</p>
<ol>
<li><p><strong>In Rust:</strong> The list of <code>[f32; 2]</code> UV coordinates is inserted as a vertex attribute on the <code>Mesh</code>. Bevy's default meshes use <code>Mesh::ATTRIBUTE_UV_0</code>.</p>
</li>
<li><p><strong>In the Vertex Shader:</strong> We declare an input that matches this attribute's location. For standard meshes, the UVs are at <code>@location(2)</code>. We then pass this value to an output struct.</p>
</li>
<li><p><strong>In the Fragment Shader:</strong> 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.</p>
</li>
</ol>
<p>Here's the complete data flow in code:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A standard vertex shader input struct for a mesh with UVs</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// &lt;-- UVs from the mesh</span>
}

<span class="hljs-comment">// Data passed from the vertex to the fragment shader</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// &lt;-- Pass the UVs through</span>
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;
    <span class="hljs-comment">// ... vertex position transformation math ...</span>
    out.uv = <span class="hljs-keyword">in</span>.uv; <span class="hljs-comment">// Simply pass the UVs to the next stage</span>
    <span class="hljs-keyword">return</span> out;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// We can now use the interpolated `in.uv` value</span>
    <span class="hljs-comment">// to create our patterns.</span>
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<h3 id="heading-visualizing-uv-coordinates">Visualizing UV Coordinates</h3>
<p>The best way to understand UVs is to see them. Since the U and V components are floats from <code>0.0</code> to <code>1.0</code>, we can plug them directly into the red and green channels of our output color to create a debug visualization.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// U (horizontal) -&gt; Red channel</span>
    <span class="hljs-comment">// V (vertical)   -&gt; Green channel</span>
    <span class="hljs-comment">// Blue channel is always 0.0</span>
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.uv.x, <span class="hljs-keyword">in</span>.uv.y, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p>This simple shader produces a distinctive and incredibly useful gradient map:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764431878141/419ac761-885e-49a4-9bcf-3a0815c26154.png" alt class="image--center mx-auto" /></p>
<p>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.</p>
<h2 id="heading-basic-patterns-with-fract-and-step">Basic Patterns with <code>fract()</code> and <code>step()</code></h2>
<p>The smooth, predictable gradient of UV coordinates is our starting point. The challenge is to transform this simple ramp into complex, repeating patterns. <code>fract()</code> gives us repetition, and <code>step()</code> gives us sharp edges.</p>
<h3 id="heading-the-fract-function-the-engine-of-repetition">The <code>fract()</code> Function: The Engine of Repetition</h3>
<p>The <code>fract()</code> function is simple: it returns the fractional part of a number, effectively discarding the integer part.</p>
<pre><code class="lang-rust">fract(<span class="hljs-number">0.3</span>) <span class="hljs-comment">// returns 0.3</span>
fract(<span class="hljs-number">1.7</span>) <span class="hljs-comment">// returns 0.7</span>
fract(<span class="hljs-number">5.2</span>) <span class="hljs-comment">// returns 0.2</span>
fract(-<span class="hljs-number">0.4</span>) <span class="hljs-comment">// returns 0.6 (handles negatives by wrapping)</span>
</code></pre>
<p>If you visualize what <code>fract(x)</code> does as x increases, it creates a repeating sawtooth wave that always stays within the <code>[0.0, 1.0)</code> range.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764432234252/539ea615-8e93-4b35-a880-a05a0118f80e.png" alt class="image--center mx-auto" /></p>
<p><strong>Why this is essential:</strong> <code>fract()</code> is the key to tiling. If we scale our UV coordinates by <code>10.0</code>, they will range from <code>0.0</code> to <code>10.0</code>. By applying <code>fract()</code> to this scaled value, we transform that single large gradient into ten identical, small gradients, each repeating from <code>0.0</code> to <code>1.0</code>.</p>
<h3 id="heading-the-step-function-the-hard-edge">The <code>step()</code> Function: The Hard Edge</h3>
<p>The <code>step(edge, x)</code> function is a binary switch. It compares <code>x</code> to a threshold value (<code>edge</code>) and returns either <code>0.0</code> or <code>1.0</code>.</p>
<ul>
<li><p>If <code>x</code> is less than <code>edge</code>, it returns <code>0.0</code> (black).</p>
</li>
<li><p>If <code>x</code> is greater than or equal to <code>edge</code>, it returns <code>1.0</code> (white).</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764432599283/fb62cc11-7b42-49d8-8659-c848bdb7ffb9.png" alt class="image--center mx-auto" /></p>
<pre><code class="lang-rust">step(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.3</span>) <span class="hljs-comment">// returns 0.0 (because 0.3 &lt; 0.5)</span>
step(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.7</span>) <span class="hljs-comment">// returns 1.0 (because 0.7 &gt;= 0.5)</span>
step(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>) <span class="hljs-comment">// returns 1.0 (because 0.5 &gt;= 0.5)</span>
</code></pre>
<p><strong>Why this is essential:</strong> <code>step()</code> is how we turn the smooth gradients produced by <code>fract()</code> into solid, high-contrast patterns. It takes a ramp of gray values and flattens it into pure black and white.</p>
<h3 id="heading-combining-fract-and-step-the-core-technique">Combining <code>fract()</code> and <code>step()</code>: The Core Technique</h3>
<p>The magic happens when you chain these two functions together. This is the fundamental recipe for creating stripes, grids, and checkerboards.</p>
<p>Let's create vertical stripes to see it in action:</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Scale the U coordinate to create 5 repetitions.</span>
    <span class="hljs-keyword">let</span> scaled_u = <span class="hljs-keyword">in</span>.uv.x * <span class="hljs-number">5.0</span>;

    <span class="hljs-comment">// 2. Apply fract() to get a repeating 0-to-1 gradient.</span>
    <span class="hljs-keyword">let</span> repeating_gradient = fract(scaled_u);

    <span class="hljs-comment">// 3. Apply step() to turn the gradient into black and white bands.</span>
    <span class="hljs-keyword">let</span> pattern = step(<span class="hljs-number">0.5</span>, repeating_gradient);

    <span class="hljs-comment">// Output the pattern as a grayscale color.</span>
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(vec3(pattern), <span class="hljs-number">1.0</span>);
}
</code></pre>
<p>Let's trace the value as it moves from the left edge (<code>uv.x = 0.0</code>) to the right edge (<code>uv.x = 1.0</code>):</p>
<ol>
<li><p><code>in.uv.x</code>: Starts as a smooth gradient from <code>0.0</code> to <code>1.0</code>.<br /> <code>0.0 ────────────────&gt; 1.0</code></p>
</li>
<li><p><code>scaled_u</code>: Multiplying by <code>5.0</code> scales the gradient to range from <code>0.0</code> to <code>5.0</code>.<br /> <code>0.0 ──────────────────────────────────&gt; 5.0</code></p>
</li>
<li><p><code>repeating_gradient</code>: <code>fract()</code> chops the scaled gradient into five identical sawtooth waves.<br /> <code>0↗1, 0↗1, 0↗1, 0↗1, 0↗1</code></p>
</li>
<li><p><code>pattern</code>: <code>step(0.5, ...)</code> evaluates each of the five mini-gradients. For the first half of each gradient (where the value is <code>&lt; 0.5</code>), it outputs <code>0.0</code>. For the second half (where the value is <code>&gt;= 0.5</code>), it outputs <code>1.0</code>.<br /> <code>00001111, 00001111, 00001111, 00001111, 00001111</code></p>
</li>
</ol>
<p>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.</p>
<h2 id="heading-creating-stripe-patterns">Creating Stripe Patterns</h2>
<p>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.</p>
<h3 id="heading-vertical-stripes">Vertical Stripes</h3>
<p>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.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertical_stripes</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// 1. Scale the U coordinate by the desired frequency.</span>
    <span class="hljs-keyword">let</span> scaled_u = uv.x * frequency;

    <span class="hljs-comment">// 2. Get the repeating 0-to-1 gradient.</span>
    <span class="hljs-keyword">let</span> repeating_gradient = fract(scaled_u);

    <span class="hljs-comment">// 3. Threshold the gradient to create a hard edge.</span>
    <span class="hljs-keyword">return</span> step(<span class="hljs-number">0.5</span>, repeating_gradient);
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Create 10 pairs of black and white stripes.</span>
    <span class="hljs-keyword">let</span> stripe_value = vertical_stripes(<span class="hljs-keyword">in</span>.uv, <span class="hljs-number">10.0</span>);
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(vec3(stripe_value), <span class="hljs-number">1.0</span>);
}
</code></pre>
<p>You can change two key values to control the result:</p>
<ul>
<li><p><code>frequency</code>: The number of stripe pairs. A higher value means thinner, more numerous stripes.</p>
</li>
<li><p><strong>The</strong> <code>step()</code> threshold: Changing <code>0.5</code> to <code>0.2</code> will make the white stripes much wider than the black ones.</p>
</li>
</ul>
<h3 id="heading-horizontal-stripes">Horizontal Stripes</h3>
<p>To change the direction, we apply the exact same logic, but to the vertical uv.y coordinate instead.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">horizontal_stripes</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// The only change is using .y instead of .x</span>
    <span class="hljs-keyword">let</span> scaled_v = uv.y * frequency;
    <span class="hljs-keyword">let</span> repeating_gradient = fract(scaled_v);
    <span class="hljs-keyword">return</span> step(<span class="hljs-number">0.5</span>, repeating_gradient);
}
</code></pre>
<h3 id="heading-variable-width-stripes">Variable Width Stripes</h3>
<p>What if you want to control the width of the stripe precisely? We can achieve this by changing our <code>step()</code> logic. Instead of a 50/50 split, we can specify an exact width.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">variable_width_stripes</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, width: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> repeating_gradient = fract(uv.x * frequency);

    <span class="hljs-comment">// We want a value of 1.0 when the gradient is LESS than our width,</span>
    <span class="hljs-comment">// and 0.0 otherwise. `1.0 - step(width, ...)` achieves this.</span>
    <span class="hljs-keyword">return</span> <span class="hljs-number">1.0</span> - step(width, repeating_gradient);
}

<span class="hljs-comment">// Usage: Create 10 stripes, where each white stripe</span>
<span class="hljs-comment">// takes up only 20% of the space.</span>
<span class="hljs-keyword">let</span> stripe = variable_width_stripes(<span class="hljs-keyword">in</span>.uv, <span class="hljs-number">10.0</span>, <span class="hljs-number">0.2</span>);
</code></pre>
<h3 id="heading-diagonal-stripes">Diagonal Stripes</h3>
<p>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.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">diagonal_stripes</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// Adding U and V creates a gradient that runs from</span>
    <span class="hljs-comment">// bottom-left (0.0) to top-right (2.0).</span>
    <span class="hljs-keyword">let</span> diagonal_gradient = uv.x + uv.y;
    <span class="hljs-keyword">let</span> repeating_gradient = fract(diagonal_gradient * frequency);
    <span class="hljs-keyword">return</span> step(<span class="hljs-number">0.5</span>, repeating_gradient);
}
</code></pre>
<p>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.</p>
<h3 id="heading-animated-stripes">Animated Stripes</h3>
<p>Creating motion is surprisingly easy. By adding an offset based on time before the <code>fract()</code> call, we can make the pattern scroll across the surface.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">animated_stripes</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, time: <span class="hljs-built_in">f32</span>, speed: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// Add a time-based offset to the coordinate.</span>
    <span class="hljs-comment">// This effectively slides the whole coordinate system.</span>
    <span class="hljs-keyword">let</span> offset_u = uv.x + time * speed;

    <span class="hljs-keyword">let</span> repeating_gradient = fract(offset_u * frequency);
    <span class="hljs-keyword">return</span> step(<span class="hljs-number">0.5</span>, repeating_gradient);
}
</code></pre>
<p>The pattern will now scroll horizontally. A negative <code>speed</code> value will make it scroll in the opposite direction.</p>
<h3 id="heading-smooth-stripes-with-smoothstep">Smooth Stripes with <code>smoothstep()</code></h3>
<p>The hard, pixelated edge created by <code>step()</code> is called "aliasing." For a softer, anti-aliased look, we can use a related function: <code>smoothstep()</code>.</p>
<p><code>smoothstep(edge1, edge2, x)</code> is like a blurry version of <code>step()</code>. Instead of jumping instantly from <code>0.0</code> to <code>1.0</code>, it creates a smooth transition between <code>edge1</code> and <code>edge2</code>.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">smooth_stripes</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, smoothness: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> repeating_gradient = fract(uv.x * frequency);

    <span class="hljs-comment">// Define a small transition zone around the 0.5 mark.</span>
    <span class="hljs-keyword">let</span> edge1 = <span class="hljs-number">0.5</span> - smoothness * <span class="hljs-number">0.5</span>;
    <span class="hljs-keyword">let</span> edge2 = <span class="hljs-number">0.5</span> + smoothness * <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// Create a smooth transition instead of a hard edge.</span>
    <span class="hljs-keyword">return</span> smoothstep(edge1, edge2, repeating_gradient);
}
</code></pre>
<p>The <code>smoothness</code> parameter controls the width of the blurred edge. A value of <code>0.0</code> is identical to <code>step()</code>, while a value like <code>0.1</code> will produce a nice, soft anti-aliased line.</p>
<h2 id="heading-checkerboard-patterns">Checkerboard Patterns</h2>
<p>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.</p>
<h3 id="heading-the-logic-an-xor-operation">The Logic: An XOR Operation</h3>
<p>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."</p>
<p>Let's visualize this. We'll generate a vertical stripe pattern (<code>u_stripe</code>) and a horizontal one (<code>v_stripe</code>), where <code>0</code> is black and <code>1</code> is white.</p>
<pre><code class="lang-plaintext">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
</code></pre>
<p>Now, we compare them cell by cell. The easiest way to perform an XOR on <code>0.0</code>/<code>1.0</code> values in a shader is <code>abs(a - b)</code>.</p>
<pre><code class="lang-plaintext">abs(u_stripe - v_stripe):
      |0-0| |1-0| |0-0| |1-0|   -&gt;   0 1 0 1
      |0-1| |1-1| |0-1| |1-1|   -&gt;   1 0 1 0
      |0-0| |1-0| |0-0| |1-0|   -&gt;   0 1 0 1
      |0-1| |1-1| |0-1| |1-1|   -&gt;   1 0 1 0
</code></pre>
<p>The result is a perfect checkerboard pattern.</p>
<h3 id="heading-basic-checkerboard-shader">Basic Checkerboard Shader</h3>
<p>Here is that logic implemented in WGSL:</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">checkerboard</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// Create the repeating 0-to-1 gradients in both directions.</span>
    <span class="hljs-keyword">let</span> u_gradient = fract(uv.x * frequency);
    <span class="hljs-keyword">let</span> v_gradient = fract(uv.y * frequency);

    <span class="hljs-comment">// Threshold them to get binary stripe patterns.</span>
    <span class="hljs-keyword">let</span> u_stripe = step(<span class="hljs-number">0.5</span>, u_gradient);
    <span class="hljs-keyword">let</span> v_stripe = step(<span class="hljs-number">0.5</span>, v_gradient);

    <span class="hljs-comment">// Perform the XOR operation to combine them.</span>
    <span class="hljs-keyword">return</span> abs(u_stripe - v_stripe);
}
</code></pre>
<h3 id="heading-an-alternative-the-grid-coordinate-method">An Alternative: The Grid Coordinate Method</h3>
<p>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.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">checkerboard_mod</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// 1. Convert continuous UVs into integer grid coordinates.</span>
    <span class="hljs-comment">// floor() discards the fractional part, leaving an integer.</span>
    <span class="hljs-keyword">let</span> grid_coords = floor(uv * frequency);

    <span class="hljs-comment">// 2. Add the integer coordinates together.</span>
    <span class="hljs-keyword">let</span> sum = grid_coords.x + grid_coords.y;

    <span class="hljs-comment">// 3. Check if the sum is even or odd.</span>
    <span class="hljs-comment">// An even number times 0.5 has a fractional part of 0.0.</span>
    <span class="hljs-comment">// An odd number times 0.5 has a fractional part of 0.5.</span>
    <span class="hljs-comment">// We multiply by 2.0 to map this to 0.0 or 1.0.</span>
    <span class="hljs-keyword">let</span> pattern = fract(sum * <span class="hljs-number">0.5</span>) * <span class="hljs-number">2.0</span>;

    <span class="hljs-keyword">return</span> pattern;
}
</code></pre>
<p>This approach works because the sum of the integer coordinates alternates between even and odd for adjacent squares, just like on a real chessboard.</p>
<h3 id="heading-diagonal-checkerboard">Diagonal Checkerboard</h3>
<p>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.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">diagonal_checkerboard</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// A simple 45-degree rotation can be done with this transform.</span>
    <span class="hljs-comment">// We multiply by 0.707 (which is 1/√2) to keep the scale consistent.</span>
    <span class="hljs-keyword">let</span> rotated_uv = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(
        (uv.x - uv.y) * <span class="hljs-number">0.707</span>,
        (uv.x + uv.y) * <span class="hljs-number">0.707</span>
    );

    <span class="hljs-comment">// Now, run the standard checkerboard function on our new, rotated coordinates.</span>
    <span class="hljs-keyword">return</span> checkerboard(rotated_uv, frequency);
}
</code></pre>
<p>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.</p>
<h2 id="heading-circular-patterns">Circular Patterns</h2>
<p>To create circular patterns, we need to shift our thinking from "what is my <code>uv.x</code> and <code>uv.y</code> 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 <code>distance()</code> and <code>length()</code> functions.</p>
<h3 id="heading-the-tools-distance-and-length">The Tools: <code>distance()</code> and <code>length()</code></h3>
<p>These two functions are the foundation of all radial patterns. They both measure distance, just with slightly different inputs.</p>
<ul>
<li><p><code>length(v)</code>: Measures the distance of a vector v from the origin <code>(0,0)</code>.</p>
</li>
<li><p><code>distance(a, b)</code>: Measures the distance between two points, <code>a</code> and <code>b</code>.</p>
</li>
</ul>
<p>They are directly related. The distance between a and b is simply the length of the vector that connects them: <code>distance(a, b)</code> is equivalent to <code>length(a - b)</code>.</p>
<p>For our purposes, we will almost always want to measure the distance from our current fragment's <code>uv</code> coordinate to the center of the UV space, which is <code>vec2&lt;f32&gt;(0.5, 0.5)</code>.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Center of the UV space</span>
<span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>);

<span class="hljs-comment">// Calculate the distance of the current fragment from the center.</span>
<span class="hljs-keyword">let</span> dist_from_center = distance(<span class="hljs-keyword">in</span>.uv, center);
</code></pre>
<p>This <code>dist_from_center</code> value gives us a beautiful radial gradient, starting at <code>0.0</code> in the exact center and increasing outwards towards the corners. This gradient is the raw material for all our circular patterns.</p>
<h3 id="heading-simple-circle">Simple Circle</h3>
<p>The easiest way to create a circle is to apply <code>step()</code> 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."</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">circle</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, center: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, radius: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> dist = distance(uv, center);
    <span class="hljs-comment">// If our distance is less than the radius, step() will produce 1.0.</span>
    <span class="hljs-comment">// To do this, we flip the arguments.</span>
    <span class="hljs-keyword">return</span> <span class="hljs-number">1.0</span> - step(radius, dist);
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> circle_mask = circle(<span class="hljs-keyword">in</span>.uv, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>), <span class="hljs-number">0.3</span>);
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(vec3(circle_mask), <span class="hljs-number">1.0</span>);
}
</code></pre>
<p>This produces a solid white circle on a black background.</p>
<h3 id="heading-ring-pattern">Ring Pattern</h3>
<p>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.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">ring</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, center: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, radius: <span class="hljs-built_in">f32</span>, thickness: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> dist = distance(uv, center);
    <span class="hljs-keyword">let</span> inner_radius = radius - thickness * <span class="hljs-number">0.5</span>;
    <span class="hljs-keyword">let</span> outer_radius = radius + thickness * <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// Create the two circles</span>
    <span class="hljs-keyword">let</span> outer_circle = <span class="hljs-number">1.0</span> - step(outer_radius, dist);
    <span class="hljs-keyword">let</span> inner_circle = <span class="hljs-number">1.0</span> - step(inner_radius, dist);

    <span class="hljs-comment">// Subtract the inner from the outer to get the ring</span>
    <span class="hljs-keyword">return</span> outer_circle - inner_circle;
}
</code></pre>
<h3 id="heading-concentric-circles-bullseye">Concentric Circles (Bullseye)</h3>
<p>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 <code>fract()</code> to create repeating sawtooth waves.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">concentric_circles</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, center: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// 1. Get the radial gradient.</span>
    <span class="hljs-keyword">let</span> dist = distance(uv, center);

    <span class="hljs-comment">// 2. Scale and repeat it with fract().</span>
    <span class="hljs-keyword">let</span> repeating_gradient = fract(dist * frequency);

    <span class="hljs-comment">// 3. Threshold it to create hard-edged rings.</span>
    <span class="hljs-keyword">return</span> step(<span class="hljs-number">0.5</span>, repeating_gradient);
}
</code></pre>
<h3 id="heading-dots-grid">Dots Grid</h3>
<p>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.</p>
<ol>
<li><p>First, we use <code>fract(uv * frequency)</code> to create a grid of repeating UV spaces, where each cell has its own local <code>[0,1]</code> coordinate system.</p>
</li>
<li><p>Then, within each of these cells, we calculate the distance from the cell's center <code>(0.5, 0.5)</code> to create a circle.</p>
</li>
</ol>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">dots_grid</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, dot_size: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// 1. Create the repeating grid cells.</span>
    <span class="hljs-keyword">let</span> grid_uv = fract(uv * frequency);

    <span class="hljs-comment">// 2. In each cell, calculate distance from its local center.</span>
    <span class="hljs-keyword">let</span> dist_from_cell_center = distance(grid_uv, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>));

    <span class="hljs-comment">// 3. Create a circle in each cell.</span>
    <span class="hljs-comment">// The radius is half the dot size.</span>
    <span class="hljs-keyword">return</span> <span class="hljs-number">1.0</span> - step(dot_size * <span class="hljs-number">0.5</span>, dist_from_cell_center);
}
</code></pre>
<p>This powerful technique of using <code>fract()</code> to create a local coordinate space is fundamental to many advanced procedural textures.</p>
<h2 id="heading-combining-multiple-patterns">Combining Multiple Patterns</h2>
<p>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.</p>
<h3 id="heading-additive-blending-combining-light">Additive Blending: Combining Light</h3>
<p>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.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">combined_grid</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// Generate two separate stripe patterns.</span>
    <span class="hljs-keyword">let</span> vertical = vertical_stripes(uv, <span class="hljs-number">10.0</span>);
    <span class="hljs-keyword">let</span> horizontal = horizontal_stripes(uv, <span class="hljs-number">10.0</span>);

    <span class="hljs-comment">// Add them together. The result can range from 0.0 to 2.0.</span>
    <span class="hljs-comment">// We clamp it to [0.0, 1.0] to keep it in the visible range.</span>
    <span class="hljs-keyword">let</span> result = clamp(vertical + horizontal, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);

    <span class="hljs-keyword">return</span> result;
}
</code></pre>
<p><strong>Result:</strong> This creates a grid. The lines themselves are gray (<code>1.0</code>), and the intersections where the patterns overlap are bright white (<code>2.0</code> before clamping). Additive blending is great for effects where you want to accumulate light or energy, like sparks or overlapping glows.</p>
<h3 id="heading-multiplicative-blending-masking-and-filtering">Multiplicative Blending: Masking and Filtering</h3>
<p>Multiplying two patterns is one of the most common and useful techniques. It acts as a <strong>mask</strong> or <strong>filter</strong>. The result will only be white (<code>1.0</code>) where both input patterns are white. If either pattern is black (<code>0.0</code>), the result will be black.</p>
<p>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.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">combined_masked_stripes</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// Pattern A: A set of diagonal stripes.</span>
    <span class="hljs-keyword">let</span> stripes = diagonal_stripes(uv, <span class="hljs-number">20.0</span>);

    <span class="hljs-comment">// Pattern B: A soft circular mask.</span>
    <span class="hljs-keyword">let</span> circle_mask = <span class="hljs-number">1.0</span> - smoothstep(<span class="hljs-number">0.4</span>, <span class="hljs-number">0.45</span>, distance(uv, vec2(<span class="hljs-number">0.5</span>)));

    <span class="hljs-comment">// Multiply them. The stripes will only appear inside the circle.</span>
    <span class="hljs-keyword">return</span> stripes * circle_mask;
}
</code></pre>
<p><strong>Result:</strong> 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.</p>
<h3 id="heading-mix-blending-smooth-transitions"><code>mix()</code> Blending: Smooth Transitions</h3>
<p>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 <code>mix()</code> function, which performs linear interpolation.</p>
<p><code>mix(a, b, t)</code> blends from pattern <code>a</code> to pattern <code>b</code> using <code>t</code> as the control factor. When <code>t</code> is <code>0.0</code>, you get 100% of <code>a</code>. When <code>t</code> is <code>1.0</code>, you get 100% of <code>b</code>. When <code>t</code> is <code>0.5</code>, you get an even 50/50 mix.</p>
<p>Crucially, the control factor t can itself be another pattern.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">combined_mix</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// Pattern A: A checkerboard.</span>
    <span class="hljs-keyword">let</span> pattern_a = checkerboard(uv, <span class="hljs-number">16.0</span>);

    <span class="hljs-comment">// Pattern B: A dots grid.</span>
    <span class="hljs-keyword">let</span> pattern_b = dots_grid(uv, <span class="hljs-number">8.0</span>, <span class="hljs-number">0.4</span>);

    <span class="hljs-comment">// Blend Factor: A simple horizontal gradient across the screen.</span>
    <span class="hljs-keyword">let</span> blend_factor = uv.x;

    <span class="hljs-comment">// Blend from the checkerboard on the left to the dots on the right.</span>
    <span class="hljs-keyword">return</span> mix(pattern_a, pattern_b, blend_factor);
}
</code></pre>
<p><strong>Result:</strong> The left side of the screen will show a perfect checkerboard, which will smoothly fade into the dots grid pattern on the right side.</p>
<h3 id="heading-boolean-operations-logical-combinations">Boolean Operations: Logical Combinations</h3>
<p>For crisp, binary patterns, it's often useful to think in terms of logical operations. We can simulate these with <code>min()</code> and <code>max()</code>.</p>
<ul>
<li><p><strong>Union (A or B):</strong> <code>max(a, b)</code> The result is white if either <code>a</code> or <code>b</code> is white.</p>
</li>
<li><p><strong>Intersection (A and B):</strong> <code>min(a, b)</code> The result is white only if <em>both</em> a and b are white. (This is equivalent to multiplication for binary <code>0.0</code>/<code>1.0</code> patterns).</p>
</li>
<li><p><strong>Subtraction (A not B):</strong> <code>max(a - b, 0.0)</code> This is like using pattern <code>b</code> as a "cookie cutter" to remove a shape from pattern <code>a</code>.</p>
</li>
</ul>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">boolean_example</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> circle_a = circle(uv, vec2(<span class="hljs-number">0.4</span>, <span class="hljs-number">0.5</span>), <span class="hljs-number">0.3</span>);
    <span class="hljs-keyword">let</span> circle_b = circle(uv, vec2(<span class="hljs-number">0.6</span>, <span class="hljs-number">0.5</span>), <span class="hljs-number">0.3</span>);

    <span class="hljs-comment">// Try swapping this line with min() or max(a-b, 0.0)</span>
    <span class="hljs-comment">// to see the different operations!</span>
    <span class="hljs-keyword">return</span> max(circle_a, circle_b); <span class="hljs-comment">// Union (two overlapping circles)</span>
}
</code></pre>
<h2 id="heading-scaling-offsetting-and-rotating-uvs">Scaling, Offsetting, and Rotating UVs</h2>
<p>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.</p>
<h3 id="heading-scaling-uvs-tiling-and-repetition">Scaling UVs (Tiling and Repetition)</h3>
<p>Scaling is the most common UV transformation. We do it by simply multiplying the <code>uv</code> vector. This is what we've been doing with our frequency parameter all along, but let's formalize it.</p>
<p>There's a key intuitive flip you need to grasp:</p>
<ul>
<li><p><strong>Scaling UVs UP (</strong><code>* &gt; 1.0</code>) makes the pattern appear <strong>SMALLER</strong> (more repetitions).</p>
</li>
<li><p><strong>Scaling UVs DOWN (</strong><code>* &lt; 1.0</code>) makes the pattern appear <strong>LARGER</strong> (fewer repetitions).</p>
</li>
</ul>
<p>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.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Scale uniformly in both directions</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">scaled_pattern</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, scale: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> scaled_uv = uv * scale;
    <span class="hljs-comment">// The pattern function now operates in a scaled space.</span>
    <span class="hljs-keyword">return</span> checkerboard(scaled_uv, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// Frequency is now controlled by scale</span>
}

<span class="hljs-comment">// Scale differently in U and V to stretch the pattern</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">non_uniform_scale</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, scale_u: <span class="hljs-built_in">f32</span>, scale_v: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> scaled_uv = uv * vec2&lt;<span class="hljs-built_in">f32</span>&gt;(scale_u, scale_v);
    <span class="hljs-keyword">return</span> checkerboard(scaled_uv, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-offsetting-uvs-moving-and-scrolling">Offsetting UVs (Moving and Scrolling)</h3>
<p>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.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">offset_pattern</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, offset: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> offset_uv = uv + offset;
    <span class="hljs-keyword">return</span> checkerboard(offset_uv, <span class="hljs-number">8.0</span>);
}

<span class="hljs-comment">// Animate the offset over time to create scrolling</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">scrolling_pattern</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, time: <span class="hljs-built_in">f32</span>, speed: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> offset = time * speed;
    <span class="hljs-keyword">let</span> offset_uv = uv + offset;
    <span class="hljs-keyword">return</span> checkerboard(offset_uv, <span class="hljs-number">8.0</span>);
}
</code></pre>
<h3 id="heading-rotating-uvs">Rotating UVs</h3>
<p>Rotation is more complex because we need to rotate around a specific pivot point - usually the center of the UV space, <code>(0.5, 0.5)</code>. The process involves three steps:</p>
<ol>
<li><p><strong>Translate</strong> the coordinates so the pivot point is at the origin <code>(0,0)</code>.</p>
</li>
<li><p><strong>Apply</strong> the standard 2D rotation matrix formula.</p>
</li>
<li><p><strong>Translate</strong> the coordinates back to their original position.</p>
</li>
</ol>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rotate_uv</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, angle_radians: <span class="hljs-built_in">f32</span>, center: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec2&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Translate to origin</span>
    <span class="hljs-keyword">let</span> translated_uv = uv - center;

    <span class="hljs-comment">// 2. Apply rotation</span>
    <span class="hljs-keyword">let</span> cos_a = cos(angle_radians);
    <span class="hljs-keyword">let</span> sin_a = sin(angle_radians);
    <span class="hljs-keyword">let</span> rotated_uv = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(
        translated_uv.x * cos_a - translated_uv.y * sin_a,
        translated_uv.x * sin_a + translated_uv.y * cos_a
    );

    <span class="hljs-comment">// 3. Translate back</span>
    <span class="hljs-keyword">return</span> rotated_uv + center;
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rotated_pattern</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, angle_radians: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>);
    <span class="hljs-keyword">let</span> rotated_uv = rotate_uv(uv, angle_radians, center);
    <span class="hljs-keyword">return</span> checkerboard(rotated_uv, <span class="hljs-number">8.0</span>);
}
</code></pre>
<h3 id="heading-the-importance-of-order">The Importance of Order</h3>
<p>When you combine these transformations, the order in which you apply them matters significantly. The standard, most intuitive order is <strong>Scale, then Rotate, then Translate (SRT)</strong>.</p>
<ol>
<li><p><strong>Scale</strong> first to set the pattern's size.</p>
</li>
<li><p><strong>Rotate</strong> the scaled pattern around its center.</p>
</li>
<li><p><strong>Translate</strong> the final scaled and rotated pattern to its desired position.</p>
</li>
</ol>
<p>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.</p>
<h2 id="heading-aspect-ratio-correction">Aspect Ratio Correction</h2>
<p>So far, we've assumed we're working on a perfectly square plane where the UV space <code>[0,1]</code> 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.</p>
<h3 id="heading-the-problem-unwanted-stretching">The Problem: Unwanted Stretching</h3>
<p>If you apply a pattern designed in square UV space directly to a rectangular surface, the pattern will be distorted.</p>
<p>Imagine our <code>circle()</code> 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.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764432759204/d2f3e01e-e551-474f-8704-e6682f662e57.png" alt class="image--center mx-auto" /></p>
<p>To create truly procedural and robust materials, we must correct for this distortion.</p>
<h3 id="heading-the-solution-pre-scaling-the-uvs">The Solution: Pre-scaling the UVs</h3>
<p>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.</p>
<p>To do this, we need one piece of information from our application: the aspect ratio of the surface (calculated as <code>width / height</code>). This must be passed to the shader as a uniform.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In your Rust code, when setting up the material:</span>
<span class="hljs-keyword">let</span> aspect_ratio = viewport_width / viewport_height;
material.uniforms.aspect_ratio = aspect_ratio;
</code></pre>
<pre><code class="lang-rust"><span class="hljs-comment">// In your WGSL shader</span>
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: MyMaterial; <span class="hljs-comment">// Contains aspect_ratio</span>
</code></pre>
<p>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.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">correct_aspect_ratio</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, aspect_ratio: <span class="hljs-built_in">f32</span>) -&gt; vec2&lt;<span class="hljs-built_in">f32</span>&gt; {
    var corrected_uv = uv;
    <span class="hljs-comment">// If the surface is wider than it is tall (e.g., 1920/1080 = 1.77)</span>
    <span class="hljs-keyword">if</span> (aspect_ratio &gt; <span class="hljs-number">1.0</span>) {
        <span class="hljs-comment">// We need to scale the V coordinate to make it "travel faster"</span>
        <span class="hljs-comment">// to match the U coordinate's speed across the wider screen.</span>
        corrected_uv.y *= aspect_ratio;
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// Otherwise, the surface is taller than it is wide.</span>
        <span class="hljs-comment">// Scale the U coordinate.</span>
        corrected_uv.x /= aspect_ratio;
    }
    <span class="hljs-keyword">return</span> corrected_uv;
}
</code></pre>
<p><strong>Wait, this looks weird!</strong> 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.</p>
<h3 id="heading-the-better-solution-centered-correction">The Better Solution: Centered Correction</h3>
<p>This method preserves the <code>[0,1]</code> range and the center point, which makes composing transformations much easier.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">correct_aspect_ratio_centered</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, aspect_ratio: <span class="hljs-built_in">f32</span>) -&gt; vec2&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Shift coordinates so (0,0) is at the center, ranging from -0.5 to 0.5.</span>
    var centered_uv = uv - <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// 2. Scale the shorter dimension to match the longer one.</span>
    <span class="hljs-keyword">if</span> (aspect_ratio &gt; <span class="hljs-number">1.0</span>) { <span class="hljs-comment">// Wider than tall</span>
        centered_uv.y *= aspect_ratio;
    } <span class="hljs-keyword">else</span> { <span class="hljs-comment">// Taller than wide</span>
        centered_uv.x /= aspect_ratio;
    }

    <span class="hljs-comment">// 3. Shift the coordinates back so (0.5, 0.5) is the center again.</span>
    <span class="hljs-keyword">return</span> centered_uv + <span class="hljs-number">0.5</span>;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Correct the UVs first!</span>
    <span class="hljs-keyword">let</span> corrected_uv = correct_aspect_ratio_centered(<span class="hljs-keyword">in</span>.uv, material.aspect_ratio);

    <span class="hljs-comment">// Now all subsequent patterns will be distortion-free.</span>
    <span class="hljs-keyword">let</span> pattern = circle(corrected_uv, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>), <span class="hljs-number">0.3</span>);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(vec3(pattern), <span class="hljs-number">1.0</span>);
}
</code></pre>
<p>Now, your circle will appear as a perfect circle regardless of the window's shape, giving your procedural patterns a professional and robust quality.</p>
<hr />
<h2 id="heading-complete-example-procedural-tile-pattern-generator">Complete Example: Procedural Tile Pattern Generator</h2>
<p>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.</p>
<h3 id="heading-our-goal">Our Goal</h3>
<p>We will create a scene with a single large plane that acts as our canvas. This plane will be rendered with a custom <code>UvPatternMaterial</code>. 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.</p>
<h3 id="heading-what-this-project-demonstrates">What This Project Demonstrates</h3>
<ul>
<li><p><strong>Shader Organization:</strong> How to structure a complex shader with a library of helper functions for patterns and blending.</p>
</li>
<li><p><strong>Dynamic Control:</strong> How to use uniforms to control shader logic from Rust, switching between different pattern types and blend modes in real-time.</p>
</li>
<li><p><strong>UV Transformations:</strong> See the direct, interactive effect of scaling, rotating, and offsetting UV coordinates on a final pattern.</p>
</li>
<li><p><strong>Pattern Composition:</strong> Gain an intuitive feel for how different blend modes (Mix, Add, Multiply, XOR) create vastly different results when combining the same two patterns.</p>
</li>
<li><p><strong>Practical Application:</strong> This example is a blueprint for creating your own powerful, reusable procedural materials.</p>
</li>
</ul>
<h3 id="heading-the-shader-assetsshadersd0303uvpatternswgsl">The Shader (<code>assets/shaders/d03_03_uv_patterns.wgsl</code>)</h3>
<p>The heart of this demo is a single, powerful fragment shader. It is organized into three main parts:</p>
<ol>
<li><p><strong>A Library of Pattern Functions:</strong> Each of the eight patterns (stripes, circles, etc.) is encapsulated in its own clean, reusable function.</p>
</li>
<li><p><strong>A "Router" Function:</strong> A get_pattern function uses a switch statement to select which pattern function to call based on a <code>u32</code> uniform sent from Rust. This is a very efficient way to manage multiple behaviors in one shader.</p>
</li>
<li><p><strong>The Main fragment Function:</strong> 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.</p>
</li>
</ol>
<p>Notice how the <code>UvPatternMaterial</code> struct at the top contains all the parameters we need to control the final look from our Rust application.</p>
<pre><code class="lang-rust">#import bevy_pbr::forward_io::VertexOutput
#import bevy_pbr::mesh_view_bindings::view

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">UvPatternMaterial</span></span> {
    <span class="hljs-comment">// Pattern selection (0-6)</span>
    pattern_a_type: <span class="hljs-built_in">u32</span>,
    pattern_b_type: <span class="hljs-built_in">u32</span>,

    <span class="hljs-comment">// Pattern parameters</span>
    frequency_a: <span class="hljs-built_in">f32</span>,
    frequency_b: <span class="hljs-built_in">f32</span>,

    <span class="hljs-comment">// Animation</span>
    time: <span class="hljs-built_in">f32</span>,
    animation_speed: <span class="hljs-built_in">f32</span>,

    <span class="hljs-comment">// Blending</span>
    blend_mode: <span class="hljs-built_in">u32</span>,  <span class="hljs-comment">// 0=mix, 1=add, 2=multiply, 3=xor</span>
    blend_factor: <span class="hljs-built_in">f32</span>,

    <span class="hljs-comment">// Transform</span>
    rotation_a: <span class="hljs-built_in">f32</span>,
    rotation_b: <span class="hljs-built_in">f32</span>,
    scale_a: <span class="hljs-built_in">f32</span>,
    scale_b: <span class="hljs-built_in">f32</span>,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: UvPatternMaterial;

<span class="hljs-comment">// ============================================================================</span>
<span class="hljs-comment">// Helper Functions</span>
<span class="hljs-comment">// ============================================================================</span>

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rotate_uv</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, angle: <span class="hljs-built_in">f32</span>, center: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec2&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> translated = uv - center;
    <span class="hljs-keyword">let</span> cos_a = cos(angle);
    <span class="hljs-keyword">let</span> sin_a = sin(angle);
    <span class="hljs-keyword">let</span> rotated = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(
        translated.x * cos_a - translated.y * sin_a,
        translated.x * sin_a + translated.y * cos_a
    );
    <span class="hljs-keyword">return</span> rotated + center;
}

<span class="hljs-comment">// ============================================================================</span>
<span class="hljs-comment">// Pattern Functions (return 0.0 to 1.0)</span>
<span class="hljs-comment">// ============================================================================</span>

<span class="hljs-comment">// Pattern 0: Vertical Stripes</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">pattern_vertical_stripes</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> scaled = (uv.x + time * <span class="hljs-number">0.1</span>) * frequency;
    <span class="hljs-keyword">return</span> step(<span class="hljs-number">0.5</span>, fract(scaled));
}

<span class="hljs-comment">// Pattern 1: Horizontal Stripes</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">pattern_horizontal_stripes</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> scaled = (uv.y + time * <span class="hljs-number">0.1</span>) * frequency;
    <span class="hljs-keyword">return</span> step(<span class="hljs-number">0.5</span>, fract(scaled));
}

<span class="hljs-comment">// Pattern 2: Checkerboard</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">pattern_checkerboard</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> animated_uv = uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(time * <span class="hljs-number">0.05</span>);
    <span class="hljs-keyword">let</span> grid_uv = floor(animated_uv * frequency);
    <span class="hljs-keyword">let</span> sum = grid_uv.x + grid_uv.y;
    <span class="hljs-keyword">return</span> fract(sum * <span class="hljs-number">0.5</span>) * <span class="hljs-number">2.0</span>;
}

<span class="hljs-comment">// Pattern 3: Concentric Circles</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">pattern_circles</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>);
    <span class="hljs-keyword">let</span> dist = distance(uv, center) + time * <span class="hljs-number">0.05</span>;
    <span class="hljs-keyword">return</span> step(<span class="hljs-number">0.5</span>, fract(dist * frequency));
}

<span class="hljs-comment">// Pattern 4: Radial Segments</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">pattern_radial</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>);
    <span class="hljs-keyword">let</span> to_center = uv - center;
    <span class="hljs-keyword">let</span> angle = atan2(to_center.y, to_center.x);
    <span class="hljs-keyword">let</span> normalized_angle = (angle + <span class="hljs-number">3.14159265</span> + time * <span class="hljs-number">0.5</span>) / (<span class="hljs-number">2.0</span> * <span class="hljs-number">3.14159265</span>);
    <span class="hljs-keyword">return</span> step(<span class="hljs-number">0.5</span>, fract(normalized_angle * frequency));
}

<span class="hljs-comment">// Pattern 5: Dots Grid</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">pattern_dots</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> grid_uv = fract(uv * frequency);
    <span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>);
    <span class="hljs-keyword">let</span> dist = distance(grid_uv, center);
    <span class="hljs-keyword">let</span> pulse = <span class="hljs-number">0.3</span> + sin(time * <span class="hljs-number">2.0</span>) * <span class="hljs-number">0.1</span>;
    <span class="hljs-keyword">return</span> step(dist, pulse);
}

<span class="hljs-comment">// Pattern 6: Diagonal Stripes</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">pattern_diagonal</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> diagonal = (uv.x + uv.y + time * <span class="hljs-number">0.1</span>) * frequency;
    <span class="hljs-keyword">return</span> step(<span class="hljs-number">0.5</span>, fract(diagonal));
}

<span class="hljs-comment">// Pattern 7: Spiral</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">pattern_spiral</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>);
    <span class="hljs-keyword">let</span> to_center = uv - center;
    <span class="hljs-keyword">let</span> dist = length(to_center);
    <span class="hljs-keyword">let</span> angle = atan2(to_center.y, to_center.x);
    <span class="hljs-keyword">let</span> spiral = (dist * <span class="hljs-number">3.0</span> + angle / (<span class="hljs-number">2.0</span> * <span class="hljs-number">3.14159265</span>) + time * <span class="hljs-number">0.2</span>) * frequency;
    <span class="hljs-keyword">return</span> step(<span class="hljs-number">0.5</span>, fract(spiral));
}

<span class="hljs-comment">// ============================================================================</span>
<span class="hljs-comment">// Pattern Router</span>
<span class="hljs-comment">// ============================================================================</span>

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_pattern</span></span>(pattern_type: <span class="hljs-built_in">u32</span>, uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    switch pattern_type {
        case <span class="hljs-number">0</span>u: { <span class="hljs-keyword">return</span> pattern_vertical_stripes(uv, frequency, time); }
        case <span class="hljs-number">1</span>u: { <span class="hljs-keyword">return</span> pattern_horizontal_stripes(uv, frequency, time); }
        case <span class="hljs-number">2</span>u: { <span class="hljs-keyword">return</span> pattern_checkerboard(uv, frequency, time); }
        case <span class="hljs-number">3</span>u: { <span class="hljs-keyword">return</span> pattern_circles(uv, frequency, time); }
        case <span class="hljs-number">4</span>u: { <span class="hljs-keyword">return</span> pattern_radial(uv, frequency, time); }
        case <span class="hljs-number">5</span>u: { <span class="hljs-keyword">return</span> pattern_dots(uv, frequency, time); }
        case <span class="hljs-number">6</span>u: { <span class="hljs-keyword">return</span> pattern_diagonal(uv, frequency, time); }
        case <span class="hljs-number">7</span>u: { <span class="hljs-keyword">return</span> pattern_spiral(uv, frequency, time); }
        default: { <span class="hljs-keyword">return</span> <span class="hljs-number">0.0</span>; }
    }
}

<span class="hljs-comment">// ============================================================================</span>
<span class="hljs-comment">// Blending Modes</span>
<span class="hljs-comment">// ============================================================================</span>

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">blend_patterns</span></span>(a: <span class="hljs-built_in">f32</span>, b: <span class="hljs-built_in">f32</span>, mode: <span class="hljs-built_in">u32</span>, factor: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    switch mode {
        case <span class="hljs-number">0</span>u: { <span class="hljs-keyword">return</span> mix(a, b, factor); }           <span class="hljs-comment">// Mix/Lerp</span>
        case <span class="hljs-number">1</span>u: { <span class="hljs-keyword">return</span> clamp(a + b * factor, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>); }  <span class="hljs-comment">// Add</span>
        case <span class="hljs-number">2</span>u: { <span class="hljs-keyword">return</span> a * mix(<span class="hljs-number">1.0</span>, b, factor); }     <span class="hljs-comment">// Multiply</span>
        case <span class="hljs-number">3</span>u: { <span class="hljs-keyword">return</span> abs(a - b) * factor + a * (<span class="hljs-number">1.0</span> - factor); }  <span class="hljs-comment">// XOR</span>
        default: { <span class="hljs-keyword">return</span> a; }
    }
}

<span class="hljs-comment">// ============================================================================</span>
<span class="hljs-comment">// Vertex Shader</span>
<span class="hljs-comment">// ============================================================================</span>

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-comment">// Simple pass-through vertex shader</span>
    <span class="hljs-keyword">let</span> world_position = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(position, <span class="hljs-number">1.0</span>);
    out.position = view.clip_from_world * world_position;
    out.uv = uv;
    out.world_position = world_position;
    out.world_normal = normal;

    <span class="hljs-keyword">return</span> out;
}

<span class="hljs-comment">// ============================================================================</span>
<span class="hljs-comment">// Fragment Shader</span>
<span class="hljs-comment">// ============================================================================</span>

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> time = material.time * material.animation_speed;

    <span class="hljs-comment">// Transform UV for pattern A</span>
    var uv_a = rotate_uv(<span class="hljs-keyword">in</span>.uv, material.rotation_a, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>));
    uv_a = (uv_a - <span class="hljs-number">0.5</span>) * material.scale_a + <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// Transform UV for pattern B</span>
    var uv_b = rotate_uv(<span class="hljs-keyword">in</span>.uv, material.rotation_b, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>));
    uv_b = (uv_b - <span class="hljs-number">0.5</span>) * material.scale_b + <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// Generate patterns</span>
    <span class="hljs-keyword">let</span> pattern_a = get_pattern(
        material.pattern_a_type,
        uv_a,
        material.frequency_a,
        time
    );

    <span class="hljs-keyword">let</span> pattern_b = get_pattern(
        material.pattern_b_type,
        uv_b,
        material.frequency_b,
        time
    );

    <span class="hljs-comment">// Blend patterns</span>
    <span class="hljs-keyword">let</span> final_pattern = blend_patterns(
        pattern_a,
        pattern_b,
        material.blend_mode,
        material.blend_factor
    );

    <span class="hljs-comment">// Convert to color (grayscale for simplicity, could add color mapping)</span>
    <span class="hljs-keyword">let</span> color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(final_pattern);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0303uvpatternsrs">The Rust Material (<code>src/materials/d03_03_uv_patterns.rs</code>)</h3>
<p>This Rust module defines the <code>UvPatternMaterial</code> 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.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>

    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">UvPatternMaterial</span></span> {
        <span class="hljs-keyword">pub</span> pattern_a_type: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> pattern_b_type: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> frequency_a: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> frequency_b: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> animation_speed: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> blend_mode: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> blend_factor: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> rotation_a: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> rotation_b: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> scale_a: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> scale_b: <span class="hljs-built_in">f32</span>,
    }

    <span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> UvPatternMaterial {
        <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
            <span class="hljs-keyword">Self</span> {
                pattern_a_type: <span class="hljs-number">0</span>, <span class="hljs-comment">// Vertical stripes</span>
                pattern_b_type: <span class="hljs-number">1</span>, <span class="hljs-comment">// Horizontal stripes</span>
                frequency_a: <span class="hljs-number">10.0</span>,
                frequency_b: <span class="hljs-number">10.0</span>,
                time: <span class="hljs-number">0.0</span>,
                animation_speed: <span class="hljs-number">1.0</span>,
                blend_mode: <span class="hljs-number">0</span>, <span class="hljs-comment">// Mix</span>
                blend_factor: <span class="hljs-number">0.5</span>,
                rotation_a: <span class="hljs-number">0.0</span>,
                rotation_b: <span class="hljs-number">0.0</span>,
                scale_a: <span class="hljs-number">1.0</span>,
                scale_b: <span class="hljs-number">1.0</span>,
            }
        }
    }
}

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">use</span> uniforms::UvPatternMaterial <span class="hljs-keyword">as</span> UvPatternUniforms;

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">UvPatternMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: UvPatternUniforms,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> UvPatternMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d03_03_uv_patterns.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d03_03_uv_patterns.wgsl"</span>.into()
    }
}

<span class="hljs-comment">// Helper function to get pattern name</span>
<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_pattern_name</span></span>(pattern_type: <span class="hljs-built_in">u32</span>) -&gt; &amp;<span class="hljs-symbol">'static</span> <span class="hljs-built_in">str</span> {
    <span class="hljs-keyword">match</span> pattern_type {
        <span class="hljs-number">0</span> =&gt; <span class="hljs-string">"Vertical Stripes"</span>,
        <span class="hljs-number">1</span> =&gt; <span class="hljs-string">"Horizontal Stripes"</span>,
        <span class="hljs-number">2</span> =&gt; <span class="hljs-string">"Checkerboard"</span>,
        <span class="hljs-number">3</span> =&gt; <span class="hljs-string">"Concentric Circles"</span>,
        <span class="hljs-number">4</span> =&gt; <span class="hljs-string">"Radial Segments"</span>,
        <span class="hljs-number">5</span> =&gt; <span class="hljs-string">"Dots Grid"</span>,
        <span class="hljs-number">6</span> =&gt; <span class="hljs-string">"Diagonal Stripes"</span>,
        <span class="hljs-number">7</span> =&gt; <span class="hljs-string">"Spiral"</span>,
        _ =&gt; <span class="hljs-string">"Unknown"</span>,
    }
}

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_blend_mode_name</span></span>(blend_mode: <span class="hljs-built_in">u32</span>) -&gt; &amp;<span class="hljs-symbol">'static</span> <span class="hljs-built_in">str</span> {
    <span class="hljs-keyword">match</span> blend_mode {
        <span class="hljs-number">0</span> =&gt; <span class="hljs-string">"Mix/Lerp"</span>,
        <span class="hljs-number">1</span> =&gt; <span class="hljs-string">"Add"</span>,
        <span class="hljs-number">2</span> =&gt; <span class="hljs-string">"Multiply"</span>,
        <span class="hljs-number">3</span> =&gt; <span class="hljs-string">"XOR"</span>,
        _ =&gt; <span class="hljs-string">"Unknown"</span>,
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other materials</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_03_uv_patterns;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0303uvpatternsrs">The Demo Module (<code>src/demos/d03_03_uv_patterns.rs</code>)</h3>
<p>This is the main application logic. The <code>setup</code> function spawns our camera and the plane mesh with our custom <code>UvPatternMaterial</code>. 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.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d03_03_uv_patterns::{
    UvPatternMaterial, UvPatternUniforms, get_blend_mode_name, get_pattern_name,
};
<span class="hljs-keyword">use</span> bevy::prelude::*;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;UvPatternMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(Update, (update_time, handle_input, update_ui))
        .run();
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;UvPatternMaterial&gt;&gt;,
) {
    <span class="hljs-comment">// Create a plane to display patterns</span>
    commands.spawn((
        Mesh3d(meshes.add(Plane3d::default().mesh().size(<span class="hljs-number">4.0</span>, <span class="hljs-number">4.0</span>))),
        MeshMaterial3d(materials.add(UvPatternMaterial {
            uniforms: UvPatternUniforms::default(),
        })),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
    ));

    <span class="hljs-comment">// Camera</span>
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">5.0</span>, <span class="hljs-number">0.1</span>).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    <span class="hljs-comment">// UI</span>
    commands.spawn((
        Text::new(
            <span class="hljs-string">"[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"</span>,
        ),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">10.0</span>),
            left: Val::Px(<span class="hljs-number">10.0</span>),
            padding: UiRect::all(Val::Px(<span class="hljs-number">10.0</span>)),
            ..default()
        },
        TextFont {
            font_size: <span class="hljs-number">16.0</span>,
            ..default()
        },
        TextColor(Color::WHITE),
        BackgroundColor(Color::srgba(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.7</span>)),
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_time</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;UvPatternMaterial&gt;&gt;) {
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        material.uniforms.time = time.elapsed_secs();
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    time: Res&lt;Time&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;UvPatternMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> delta = time.delta_secs();

    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-keyword">let</span> shift = keyboard.pressed(KeyCode::ShiftLeft) || keyboard.pressed(KeyCode::ShiftRight);

        <span class="hljs-comment">// Pattern selection (1-8)</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit1) {
            <span class="hljs-keyword">if</span> shift {
                material.uniforms.pattern_b_type = <span class="hljs-number">0</span>;
            } <span class="hljs-keyword">else</span> {
                material.uniforms.pattern_a_type = <span class="hljs-number">0</span>;
            }
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit2) {
            <span class="hljs-keyword">if</span> shift {
                material.uniforms.pattern_b_type = <span class="hljs-number">1</span>;
            } <span class="hljs-keyword">else</span> {
                material.uniforms.pattern_a_type = <span class="hljs-number">1</span>;
            }
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit3) {
            <span class="hljs-keyword">if</span> shift {
                material.uniforms.pattern_b_type = <span class="hljs-number">2</span>;
            } <span class="hljs-keyword">else</span> {
                material.uniforms.pattern_a_type = <span class="hljs-number">2</span>;
            }
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit4) {
            <span class="hljs-keyword">if</span> shift {
                material.uniforms.pattern_b_type = <span class="hljs-number">3</span>;
            } <span class="hljs-keyword">else</span> {
                material.uniforms.pattern_a_type = <span class="hljs-number">3</span>;
            }
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit5) {
            <span class="hljs-keyword">if</span> shift {
                material.uniforms.pattern_b_type = <span class="hljs-number">4</span>;
            } <span class="hljs-keyword">else</span> {
                material.uniforms.pattern_a_type = <span class="hljs-number">4</span>;
            }
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit6) {
            <span class="hljs-keyword">if</span> shift {
                material.uniforms.pattern_b_type = <span class="hljs-number">5</span>;
            } <span class="hljs-keyword">else</span> {
                material.uniforms.pattern_a_type = <span class="hljs-number">5</span>;
            }
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit7) {
            <span class="hljs-keyword">if</span> shift {
                material.uniforms.pattern_b_type = <span class="hljs-number">6</span>;
            } <span class="hljs-keyword">else</span> {
                material.uniforms.pattern_a_type = <span class="hljs-number">6</span>;
            }
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit8) {
            <span class="hljs-keyword">if</span> shift {
                material.uniforms.pattern_b_type = <span class="hljs-number">7</span>;
            } <span class="hljs-keyword">else</span> {
                material.uniforms.pattern_a_type = <span class="hljs-number">7</span>;
            }
        }

        <span class="hljs-comment">// Frequency controls</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyQ) {
            material.uniforms.frequency_a = (material.uniforms.frequency_a - delta * <span class="hljs-number">5.0</span>).max(<span class="hljs-number">1.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyE) {
            material.uniforms.frequency_a = (material.uniforms.frequency_a + delta * <span class="hljs-number">5.0</span>).min(<span class="hljs-number">50.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyR) {
            material.uniforms.frequency_b = (material.uniforms.frequency_b - delta * <span class="hljs-number">5.0</span>).max(<span class="hljs-number">1.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyT) {
            material.uniforms.frequency_b = (material.uniforms.frequency_b + delta * <span class="hljs-number">5.0</span>).min(<span class="hljs-number">50.0</span>);
        }

        <span class="hljs-comment">// Rotation controls</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyA) {
            material.uniforms.rotation_a += delta;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyD) {
            material.uniforms.rotation_a -= delta;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyF) {
            material.uniforms.rotation_b += delta;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyG) {
            material.uniforms.rotation_b -= delta;
        }

        <span class="hljs-comment">// Scale controls</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyZ) {
            material.uniforms.scale_a = (material.uniforms.scale_a - delta * <span class="hljs-number">0.5</span>).max(<span class="hljs-number">0.1</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyC) {
            material.uniforms.scale_a = (material.uniforms.scale_a + delta * <span class="hljs-number">0.5</span>).min(<span class="hljs-number">5.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyV) {
            material.uniforms.scale_b = (material.uniforms.scale_b - delta * <span class="hljs-number">0.5</span>).max(<span class="hljs-number">0.1</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyB) {
            material.uniforms.scale_b = (material.uniforms.scale_b + delta * <span class="hljs-number">0.5</span>).min(<span class="hljs-number">5.0</span>);
        }

        <span class="hljs-comment">// Blend mode</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Tab) {
            material.uniforms.blend_mode = (material.uniforms.blend_mode + <span class="hljs-number">1</span>) % <span class="hljs-number">4</span>;
        }

        <span class="hljs-comment">// Blend factor</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::Space) {
            material.uniforms.blend_factor += delta * <span class="hljs-number">0.5</span>;
            <span class="hljs-keyword">if</span> material.uniforms.blend_factor &gt; <span class="hljs-number">1.0</span> {
                material.uniforms.blend_factor = <span class="hljs-number">0.0</span>;
            }
        }

        <span class="hljs-comment">// Animation toggle</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyP) {
            <span class="hljs-keyword">if</span> material.uniforms.animation_speed &gt; <span class="hljs-number">0.0</span> {
                material.uniforms.animation_speed = <span class="hljs-number">0.0</span>;
            } <span class="hljs-keyword">else</span> {
                material.uniforms.animation_speed = <span class="hljs-number">1.0</span>;
            }
        }

        <span class="hljs-comment">// Reset</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyL) {
            material.uniforms = UvPatternUniforms::default();
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(materials: Res&lt;Assets&lt;UvPatternMaterial&gt;&gt;, <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;) {
    <span class="hljs-keyword">if</span> !materials.is_changed() {
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, material)) = materials.iter().next() {
        <span class="hljs-keyword">let</span> pattern_a_name = get_pattern_name(material.uniforms.pattern_a_type);
        <span class="hljs-keyword">let</span> pattern_b_name = get_pattern_name(material.uniforms.pattern_b_type);
        <span class="hljs-keyword">let</span> blend_mode_name = get_blend_mode_name(material.uniforms.blend_mode);
        <span class="hljs-keyword">let</span> animation_status = <span class="hljs-keyword">if</span> material.uniforms.animation_speed &gt; <span class="hljs-number">0.0</span> {
            <span class="hljs-string">"ON"</span>
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-string">"OFF"</span>
        };

        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
            **text = <span class="hljs-built_in">format!</span>(
                <span class="hljs-string">"[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: {}"</span>,
                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
            );
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other demos</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_03_uv_patterns;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"3.3"</span>,
    title: <span class="hljs-string">"UV-Based Patterns"</span>,
    run: demos::d03_03_uv_patterns::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>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.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key(s)</td><td>Action</td></tr>
</thead>
<tbody>
<tr>
<td><strong>1-8</strong></td><td>Select Pattern A type.</td></tr>
<tr>
<td><strong>Shift + 1-8</strong></td><td>Select Pattern B type.</td></tr>
<tr>
<td><strong>Q / E</strong></td><td>Decrease / Increase Frequency of Pattern A.</td></tr>
<tr>
<td><strong>R / T</strong></td><td>Decrease / Increase Frequency of Pattern B.</td></tr>
<tr>
<td><strong>A / D</strong></td><td>Rotate Pattern A.</td></tr>
<tr>
<td><strong>F / G</strong></td><td>Rotate Pattern B.</td></tr>
<tr>
<td><strong>Z / C</strong></td><td>Scale Pattern A (smaller / larger).</td></tr>
<tr>
<td><strong>V / B</strong></td><td>Scale Pattern B (smaller / larger).</td></tr>
<tr>
<td><strong>Tab</strong></td><td>Cycle through Blend Modes (Mix, Add, Multiply, XOR).</td></tr>
<tr>
<td><strong>Space</strong></td><td>Animate the Blend Factor from 0.0 to 1.0.</td></tr>
<tr>
<td><strong>P</strong></td><td>Pause / Resume all animations.</td></tr>
<tr>
<td><strong>L</strong></td><td>Reset all parameters to their default state.</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763669296964/a5b5f50d-1672-4779-b395-ccadfca112c3.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763669318056/8db97e9d-adb2-4235-8f74-b2c0eb98e061.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763669524401/471dda10-b9d5-483e-b964-58867e15a537.png" alt class="image--center mx-auto" /></p>
<p>This interactive playground is designed to give you an intuitive feel for how all the concepts in this article work together.</p>
<ul>
<li><p><strong>Pattern Selection:</strong> Use the number keys to swap out the base patterns (<code>A</code>) and the layer patterns (<code>B</code>). Notice how some patterns are Cartesian (stripes, checkerboards) while others are polar (circles, spirals).</p>
</li>
<li><p><strong>Transformations:</strong> 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.</p>
</li>
<li><p><strong>Blending:</strong> This is the most important control. Cycle through the blend modes with <strong>Tab</strong> to see how they combine the two patterns you've designed.</p>
<ul>
<li><p><strong>Mix:</strong> Creates a smooth, transparent blend.</p>
</li>
<li><p><strong>Add:</strong> Makes overlapping areas brighter, creating a grid or glowing effect.</p>
</li>
<li><p><strong>Multiply:</strong> Uses one pattern to mask the other.</p>
</li>
<li><p><strong>XOR:</strong> Creates an interference pattern where the patterns are different.</p>
</li>
</ul>
</li>
<li><p><strong>Animation:</strong> The subtle, constant motion helps you see how the patterns are constructed. Pause it with <strong>P</strong> for a static view to analyze a specific combination.</p>
</li>
</ul>
<h4 id="heading-interesting-combinations-to-try">Interesting Combinations to Try</h4>
<ol>
<li><p><strong>Grid Effect:</strong></p>
<ul>
<li><p>Pattern A: Vertical Stripes</p>
</li>
<li><p>Pattern B: Horizontal Stripes</p>
</li>
<li><p>Blend: Add</p>
</li>
<li><p>Result: A perfect grid.</p>
</li>
</ul>
</li>
<li><p><strong>Circular Weave:</strong></p>
<ul>
<li><p>Pattern A: Concentric Circles (high frequency)</p>
</li>
<li><p>Pattern B: Radial Segments</p>
</li>
<li><p>Blend: Multiply</p>
</li>
<li><p>Result: A woven, spiderweb-like pattern.</p>
</li>
</ul>
</li>
<li><p><strong>Moiré Effect:</strong></p>
<ul>
<li><p>Pattern A: Dots Grid (Freq ~20)</p>
</li>
<li><p>Pattern B: Dots Grid (Freq ~22)</p>
</li>
<li><p>Rotate pattern B slightly with <strong>F</strong>.</p>
</li>
<li><p>Blend: Mix</p>
</li>
<li><p>Result: Hypnotic, shifting interference patterns.</p>
</li>
</ul>
</li>
<li><p><strong>Dotted Checkerboard:</strong></p>
<ul>
<li><p>Pattern A: Checkerboard</p>
</li>
<li><p>Pattern B: Dots Grid</p>
</li>
<li><p>Blend: Multiply</p>
</li>
<li><p>Result: Dots appear only inside the black (or white) squares.</p>
</li>
</ul>
</li>
</ol>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>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:</p>
<ol>
<li><p><strong>UVs are Your Canvas:</strong> UV coordinates provide a normalized <code>[0,1]</code> 2D coordinate system mapped directly onto the surface of your 3D models. Visualizing them is the first step to understanding any procedural effect.</p>
</li>
<li><p><code>fract()</code> 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.</p>
</li>
<li><p><code>step()</code> and <code>smoothstep()</code> 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.</p>
</li>
<li><p><strong>Combine Simple Patterns for Complexity:</strong> 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.</p>
</li>
<li><p><strong>Transform the Space, Not the Pattern:</strong> 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.</p>
</li>
<li><p><strong>Always Correct for Aspect Ratio:</strong> 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.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>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?</p>
<p>In the next article, we will bridge the gap between procedural generation and traditional texturing. We'll dive into <strong>texture sampling</strong>, learning how to load image files (like <code>.png</code> or <code>.jpg</code>) 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.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/34-gradients-and-interpolation"><strong><em>3.4- Gradients and Interpolation</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<p>A concise guide to the functions and formulas used in this article.</p>
<h3 id="heading-essential-wgsl-functions">Essential WGSL Functions</h3>
<pre><code class="lang-rust"><span class="hljs-comment">// Returns the fractional part of x (e.g., fract(3.7) -&gt; 0.7)</span>
<span class="hljs-keyword">let</span> repeating_gradient = fract(x);

<span class="hljs-comment">// Returns 0.0 if x &lt; edge, else 1.0</span>
<span class="hljs-keyword">let</span> hard_edge = step(edge, x);

<span class="hljs-comment">// Smoothly transitions from 0.0 to 1.0 as x goes from edge1 to edge2</span>
<span class="hljs-keyword">let</span> soft_edge = smoothstep(edge1, edge2, x);

<span class="hljs-comment">// Distance between two points</span>
<span class="hljs-keyword">let</span> dist = distance(point_a, point_b);

<span class="hljs-comment">// Distance from the origin (0,0)</span>
<span class="hljs-keyword">let</span> len = length(vector);

<span class="hljs-comment">// Angle of a vector in radians (-PI to PI)</span>
<span class="hljs-keyword">let</span> angle = atan2(vector.y, vector.x);
</code></pre>
<h3 id="heading-common-pattern-formulas">Common Pattern Formulas</h3>
<h4 id="heading-cartesian-patterns">Cartesian Patterns</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// Vertical Stripes</span>
<span class="hljs-keyword">let</span> stripes_v = step(<span class="hljs-number">0.5</span>, fract(uv.x * frequency));

<span class="hljs-comment">// Checkerboard (XOR method)</span>
<span class="hljs-keyword">let</span> u = step(<span class="hljs-number">0.5</span>, fract(uv.x * frequency));
<span class="hljs-keyword">let</span> v = step(<span class="hljs-number">0.5</span>, fract(uv.y * frequency));
<span class="hljs-keyword">let</span> checker = abs(u - v);

<span class="hljs-comment">// Dots Grid</span>
<span class="hljs-keyword">let</span> grid_uv = fract(uv * frequency);
<span class="hljs-keyword">let</span> dots = <span class="hljs-number">1.0</span> - step(<span class="hljs-number">0.4</span>, distance(grid_uv, vec2(<span class="hljs-number">0.5</span>)));
</code></pre>
<h4 id="heading-radial-patterns">Radial Patterns</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// Circle</span>
<span class="hljs-keyword">let</span> circle = <span class="hljs-number">1.0</span> - step(radius, distance(uv, center));

<span class="hljs-comment">// Ring</span>
<span class="hljs-keyword">let</span> outer = <span class="hljs-number">1.0</span> - step(radius + thickness, dist);
<span class="hljs-keyword">let</span> inner = <span class="hljs-number">1.0</span> - step(radius - thickness, dist);
<span class="hljs-keyword">let</span> ring = outer - inner;

<span class="hljs-comment">// Concentric Rings</span>
<span class="hljs-keyword">let</span> rings = step(<span class="hljs-number">0.5</span>, fract(distance(uv, center) * frequency));
</code></pre>
<h3 id="heading-uv-transformations">UV Transformations</h3>
<pre><code class="lang-rust"><span class="hljs-comment">// Scale (makes pattern smaller/more frequent)</span>
<span class="hljs-keyword">let</span> scaled_uv = uv * scale_factor;

<span class="hljs-comment">// Offset / Pan / Scroll</span>
<span class="hljs-keyword">let</span> offset_uv = uv + vec2(offset_x, offset_y);

<span class="hljs-comment">// Rotate around a pivot point</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rotate</span></span>(uv, angle, pivot) -&gt; vec2&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> sa = sin(angle);
    <span class="hljs-keyword">let</span> ca = cos(angle);
    <span class="hljs-keyword">let</span> rotated = mat2x2(ca, -sa, sa, ca) * (uv - pivot);
    <span class="hljs-keyword">return</span> rotated + pivot;
}
</code></pre>
<h3 id="heading-blending-operations">Blending Operations</h3>
<pre><code class="lang-rust"><span class="hljs-comment">// Smooth blend (t from 0.0 to 1.0)</span>
<span class="hljs-keyword">let</span> result = mix(pattern_a, pattern_b, t);

<span class="hljs-comment">// Additive</span>
<span class="hljs-keyword">let</span> result = clamp(pattern_a + pattern_b, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);

<span class="hljs-comment">// Multiplicative (Masking)</span>
<span class="hljs-keyword">let</span> result = pattern_a * pattern_b;

<span class="hljs-comment">// Union (A or B)</span>
<span class="hljs-keyword">let</span> result = max(pattern_a, pattern_b);

<span class="hljs-comment">// Intersection (A and B)</span>
<span class="hljs-keyword">let</span> result = min(pattern_a, pattern_b);

<span class="hljs-comment">// Subtraction (A not B)</span>
<span class="hljs-keyword">let</span> result = max(pattern_a - pattern_b, <span class="hljs-number">0.0</span>);

<span class="hljs-comment">// XOR</span>
<span class="hljs-keyword">let</span> result = abs(pattern_a - pattern_b);
</code></pre>
]]></content:encoded></item><item><title><![CDATA[3.2 - Color Spaces and Operations]]></title><description><![CDATA[What We're Learning
In the last article, you learned the fundamental mechanic of the fragment shader: outputting a vec4<f32> to color a pixel. Now, we dive into the art and science behind that vector. The simple act of choosing a color touches on phy...]]></description><link>https://blog.hexbee.net/32-color-spaces-and-operations</link><guid isPermaLink="true">https://blog.hexbee.net/32-color-spaces-and-operations</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Sat, 27 Dec 2025 10:28:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1763669156774/bc5a4a7e-7983-4b07-b7a7-5ad1623a373e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>In the last article, you learned the fundamental mechanic of the fragment shader: outputting a <code>vec4&lt;f32&gt;</code> to color a pixel. Now, we dive into the art and science behind that vector. The simple act of choosing a color touches on physics, human perception, and decades of display technology evolution. Understanding these details is the difference between colors that look <em>okay</em> and colors that look <em>correct</em>.</p>
<p>This article will take you from simply <em>setting</em> colors to truly <em>understanding</em> them. We'll explore why the color red on your screen isn't the same as the color red used in lighting calculations, how to blend colors in a physically accurate way, and how to think about color more intuitively, like an artist.</p>
<p>By the end of this article, you'll have mastered:</p>
<ul>
<li><p>The foundational <strong>RGB color model</strong> and the role of the alpha channel.</p>
</li>
<li><p>The critical difference between <strong>Linear</strong> and <strong>sRGB</strong> color spaces, and why <strong>gamma correction</strong> is so important for lighting.</p>
</li>
<li><p>How to perform mathematical operations on colors to tint, brighten, and blend them.</p>
</li>
<li><p>Using the <code>mix()</code> function to create smooth, powerful gradients.</p>
</li>
<li><p>How to work with the artist-friendly <strong>HSV</strong> (Hue, Saturation, Value) color model.</p>
</li>
<li><p>How to use <strong>color temperature</strong> to give your lighting a realistic warm or cool feel.</p>
</li>
<li><p>The concept of <strong>High Dynamic Range (HDR)</strong> color and why it's essential for realistic lighting.</p>
</li>
</ul>
<h2 id="heading-understanding-the-rgb-color-space">Understanding the RGB Color Space</h2>
<p>At the heart of nearly every digital display is the RGB color model. It's the simple but powerful idea that we can create a vast spectrum of colors by mixing different amounts of <strong>R</strong>ed, <strong>G</strong>reen, and <strong>B</strong>lue light.</p>
<h3 id="heading-the-additive-color-model">The Additive Color Model</h3>
<p>RGB is an <strong>additive</strong> model, which means you start with black (no light) and <em>add</em> light to create color. This is the opposite of a subtractive model like paint or ink, where you start with white and add pigments to remove light.</p>
<ul>
<li><p><strong>No Light:</strong> (R:0, G:0, B:0) results in black.</p>
</li>
<li><p><strong>Full Light:</strong> (R:1, G:1, B:1) results in white.</p>
</li>
</ul>
<p>By combining the three primary colors of light, we can form secondary colors:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764886230537/1ddff56c-41d2-497a-8790-27f745a7a8a6.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-rgb-in-wgsl-and-bevy">RGB in WGSL and Bevy</h3>
<p>In WGSL, we represent an RGB color as a three-component floating-point vector, <code>vec3&lt;f32&gt;</code>, where each component typically ranges from <code>0.0</code> to <code>1.0</code>.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Primary colors</span>
<span class="hljs-keyword">let</span> red: vec3&lt;<span class="hljs-built_in">f32</span>&gt; = vec3(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
<span class="hljs-keyword">let</span> green: vec3&lt;<span class="hljs-built_in">f32</span>&gt; = vec3(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>);
<span class="hljs-keyword">let</span> blue: vec3&lt;<span class="hljs-built_in">f32</span>&gt; = vec3(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);

<span class="hljs-comment">// Secondary colors</span>
<span class="hljs-keyword">let</span> yellow: vec3&lt;<span class="hljs-built_in">f32</span>&gt; = vec3(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>);  <span class="hljs-comment">// Red + Green</span>
<span class="hljs-keyword">let</span> magenta: vec3&lt;<span class="hljs-built_in">f32</span>&gt; = vec3(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// Red + Blue</span>
<span class="hljs-keyword">let</span> cyan: vec3&lt;<span class="hljs-built_in">f32</span>&gt; = vec3(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>);    <span class="hljs-comment">// Green + Blue</span>

<span class="hljs-comment">// Other examples</span>
<span class="hljs-keyword">let</span> orange: vec3&lt;<span class="hljs-built_in">f32</span>&gt; = vec3(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.0</span>);  <span class="hljs-comment">// Full red, half green</span>
<span class="hljs-keyword">let</span> white: vec3&lt;<span class="hljs-built_in">f32</span>&gt; = vec3(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>);
<span class="hljs-keyword">let</span> gray: vec3&lt;<span class="hljs-built_in">f32</span>&gt; = vec3(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>);
</code></pre>
<p>This maps directly to how you often define colors in Bevy. When you write <code>Color::srgb(1.0, 0.5, 0.0)</code> in Rust, you are providing the same three values that will eventually be sent to a <code>vec3&lt;f32&gt;</code> in your shader.</p>
<h3 id="heading-why-00-10-instead-of-0-255">Why <code>[0.0, 1.0]</code> Instead of <code>[0, 255]</code>?</h3>
<p>You might be more familiar with colors represented as integers from 0 to 255. Shaders use normalized floating-point values for several critical reasons:</p>
<ol>
<li><p><strong>Precision</strong>: Floats allow for incredibly smooth gradients and subtle color variations that would be impossible with only 256 integer steps.</p>
</li>
<li><p><strong>Mathematical Simplicity</strong>: Performing math like blending or scaling is far more natural with values in a <code>[0, 1]</code> range. Multiplying <code>0.5 * 0.5</code> is simpler than <code>128 * 128 / 255</code>.</p>
</li>
<li><p><strong>High Dynamic Range (HDR)</strong>: As we'll see later, floats allow us to represent brightness values <em>greater than 1.0</em>, which is essential for realistic lighting.</p>
</li>
<li><p><strong>Hardware Optimization</strong>: GPUs are massively parallel processors designed from the ground up to excel at floating-point vector math.</p>
</li>
</ol>
<h3 id="heading-component-access-and-swizzling">Component Access and Swizzling</h3>
<p>WGSL provides a convenient way to access and rearrange the components of a vector, known as <strong>swizzling</strong>.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.8</span>, <span class="hljs-number">0.3</span>, <span class="hljs-number">0.2</span>);

<span class="hljs-comment">// Access individual components by name</span>
<span class="hljs-keyword">let</span> r = color.r; <span class="hljs-comment">// 0.8</span>
<span class="hljs-keyword">let</span> g = color.g; <span class="hljs-comment">// 0.3</span>
<span class="hljs-keyword">let</span> b = color.b; <span class="hljs-comment">// 0.2</span>

<span class="hljs-comment">// You can also use .x, .y, .z</span>
<span class="hljs-keyword">let</span> x = color.x; <span class="hljs-comment">// 0.8</span>

<span class="hljs-comment">// Swizzling: reorder or duplicate components to create new vectors</span>
<span class="hljs-keyword">let</span> bgr = color.bgr;     <span class="hljs-comment">// vec3(0.2, 0.3, 0.8)</span>
<span class="hljs-keyword">let</span> rrr = color.rrr;     <span class="hljs-comment">// vec3(0.8, 0.8, 0.8) - a gray value</span>
<span class="hljs-keyword">let</span> gb = color.gb;       <span class="hljs-comment">// vec2(0.3, 0.2)</span>
</code></pre>
<h3 id="heading-the-problem-perception-vs-reality">The Problem: Perception vs. Reality</h3>
<p>Here is a critical insight that sets the stage for our next topic: the RGB color space is <strong>not perceptually uniform</strong>. This means that a linear mathematical change in a color's value does not result in a linear change in how our eyes perceive its brightness.</p>
<pre><code class="lang-plaintext">Mathematical steps:      What we perceive:
0.0 → 0.25 → 0.5         Dark → Still quite dark → Suddenly much brighter!
</code></pre>
<p>The perceived jump in brightness from 0.0 to 0.5 is much smaller than the jump from 0.5 to 1.0.</p>
<p>This mismatch between linear math and human perception is a fundamental problem in computer graphics. If we perform lighting calculations on these raw RGB values, the results will look wrong - often too dark and with harsh, unnatural transitions. To fix this, we must introduce the concept of <strong>gamma correction</strong> and <strong>color spaces</strong>.</p>
<h2 id="heading-the-alpha-channel-rgba-and-transparency">The Alpha Channel: RGBA and Transparency</h2>
<p>To handle transparency, the three-component RGB model is extended with a fourth component: <strong>Alpha</strong>. This creates the <strong>RGBA</strong> color model, which is the standard output format (<code>vec4&lt;f32&gt;</code>) for fragment shaders.</p>
<h3 id="heading-understanding-alpha">Understanding Alpha</h3>
<p>The alpha channel represents a fragment's <strong>opacity</strong>. It's a value from <code>0.0</code> to <code>1.0</code> that dictates how much it obscures what is behind it.</p>
<ul>
<li><p><strong>Alpha = 1.0</strong>: Fully opaque. The fragment completely replaces the color behind it.</p>
</li>
<li><p><strong>Alpha = 0.5</strong>: 50% transparent. The final color is a mix of the fragment's color and the background color.</p>
</li>
<li><p><strong>Alpha = 0.0</strong>: Fully transparent. The fragment is effectively invisible; the background color is unchanged.</p>
</li>
</ul>
<p>In WGSL, we use a <code>vec4&lt;f32&gt;</code> to represent RGBA color.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// An opaque orange</span>
<span class="hljs-keyword">let</span> opaque_orange = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);

<span class="hljs-comment">// A 50% transparent blue</span>
<span class="hljs-keyword">let</span> transparent_blue = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>);

<span class="hljs-comment">// An invisible green (the RGB values don't matter when alpha is 0)</span>
<span class="hljs-keyword">let</span> invisible_green = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
</code></pre>
<h3 id="heading-how-alpha-blending-works">How Alpha Blending Works</h3>
<p>When a transparent fragment is rendered, the GPU performs a <strong>blend operation</strong> to combine its color (the source) with the color already on the screen (the destination). The most common blend mode is known as "normal" or "over" blending.</p>
<p>The formula is:</p>
<p><code>Final Color = (Source Color × Source Alpha) + (Destination Color × (1 - Source Alpha))</code></p>
<p>Let's break this down. Imagine rendering a 50% transparent red fragment over a solid blue background:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> source = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.5</span>); <span class="hljs-comment">// Red, 50% alpha</span>
<span class="hljs-keyword">let</span> destination = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// Blue background</span>

<span class="hljs-comment">// GPU performs this math:</span>
<span class="hljs-keyword">let</span> source_contribution = source.rgb * source.a;        <span class="hljs-comment">// (1,0,0) * 0.5 = (0.5, 0.0, 0.0)</span>
<span class="hljs-keyword">let</span> dest_contribution = destination * (<span class="hljs-number">1.0</span> - source.a); <span class="hljs-comment">// (0,0,1) * 0.5 = (0.0, 0.0, 0.5)</span>

<span class="hljs-keyword">let</span> result = source_contribution + dest_contribution;   <span class="hljs-comment">// (0.5, 0.0, 0.5) -&gt; Purple</span>
</code></pre>
<p>The result is purple, an equal mix of red and blue, which is exactly what we'd expect.</p>
<h3 id="heading-enabling-transparency-in-bevy">Enabling Transparency in Bevy</h3>
<p>Simply returning an alpha value less than <code>1.0</code> from your shader is not enough to make an object transparent. By default, for performance, Bevy treats all materials as opaque. You must explicitly tell Bevy's renderer that your material requires blending.</p>
<p>This is done by overriding the alpha_mode function in your Material implementation in Rust.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In your material's .rs file</span>
<span class="hljs-keyword">use</span> bevy::pbr::AlphaMode;

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> MyTransparentMaterial {
    <span class="hljs-comment">// ... other functions ...</span>

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">alpha_mode</span></span>(&amp;<span class="hljs-keyword">self</span>) -&gt; AlphaMode {
        AlphaMode::Blend <span class="hljs-comment">// This enables traditional alpha blending</span>
    }
}
</code></pre>
<p>Bevy offers several <code>AlphaMode</code> options for different effects:</p>
<ul>
<li><p><code>AlphaMode::Opaque</code>: The default. Fastest performance. Ignores the alpha channel.</p>
</li>
<li><p><code>AlphaMode::Blend</code>: Normal alpha blending. Essential for glass, water, and smooth fades.</p>
</li>
<li><p><code>AlphaMode::Mask(f32)</code>: "Cutout" or "alpha testing" transparency. Fragments are either 100% visible or 100% discarded based on whether their alpha is above a certain threshold. Great for things like chain-link fences or foliage.</p>
</li>
<li><p><code>AlphaMode::Add</code>: Additive blending. Adds the source color to the destination. Perfect for fire, sparks, and glowing effects.</p>
</li>
</ul>
<p><strong>Performance Note:</strong> Transparency is expensive. Enabling any alpha mode other than <code>Opaque</code> can have a significant performance cost because it often requires the GPU to sort objects from back-to-front and disables certain hardware optimizations like Early-Z testing. Use it only when necessary. We will cover this in detail in a future article.</p>
<h2 id="heading-linear-vs-srgb-the-most-important-topic-in-color">Linear vs. sRGB: The Most Important Topic in Color</h2>
<p>This is the most critical concept you will learn about color. Understanding the difference between <strong>Linear</strong> and <strong>sRGB</strong> color space is essential for creating correct and realistic lighting. Getting this wrong is the most common source of visual bugs for developers new to graphics programming, resulting in colors that look too dark, lighting that feels harsh, and blending that seems unnatural.</p>
<h3 id="heading-the-problem-physics-vs-perception">The Problem: Physics vs. Perception</h3>
<p>As we discussed, there's a mismatch between how light behaves physically and how our eyes perceive it.</p>
<ul>
<li><p><strong>Physical Light (Linear Space):</strong> In the real world, light intensity is linear. If you have one lightbulb and you turn on a second, identical one, you get exactly twice the amount of light energy (<code>1 + 1 = 2</code>). All physics-based calculations, especially for lighting, must be done in this linear space to be correct.</p>
</li>
<li><p><strong>Human Vision (Non-Linear):</strong> Our eyes are more sensitive to changes in dark tones than in bright tones. This non-linear perception helped our ancestors spot predators lurking in the shadows.</p>
</li>
</ul>
<p>Monitors and image formats are designed to cater to our non-linear perception. If they stored colors linearly, we would waste a huge amount of data on bright shades we can barely distinguish, leaving too little data for the dark shades, which would result in ugly "banding" artifacts in shadows.</p>
<h3 id="heading-the-solution-srgb-and-gamma-correction">The Solution: sRGB and Gamma Correction</h3>
<p>To solve this, nearly all images you see (<code>.png</code>, <code>.jpg</code>), colors you pick, and monitors you use operate in a non-linear color space called <strong>sRGB</strong>. The sRGB standard includes a process called <strong>gamma correction</strong>.</p>
<p>Think of gamma correction as a curve that "pre-adjusts" the colors to account for our perceptual quirks. An sRGB image stores colors in a "gamma-encoded" format. When your monitor displays it, its hardware applies an inverse curve, and the result on screen looks perceptually correct to your eyes.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764417657387/a3b5c985-7c3e-4337-9d98-970c07ffd654.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-the-golden-rule-for-shaders">The Golden Rule for Shaders</h3>
<p>This creates a critical workflow for rendering:</p>
<ol>
<li><p><strong>Input:</strong> All color textures and color values start in sRGB space (because that's how artists create them and how they're stored).</p>
</li>
<li><p><strong>Conversion to Linear:</strong> Before any math is performed, these sRGB colors <strong>must be converted to Linear space</strong>. This is called "gamma decoding".</p>
</li>
<li><p><strong>Calculations:</strong> All lighting, blending, and mixing math is performed in Linear space, where the physics is correct.</p>
</li>
<li><p><strong>Conversion to sRGB:</strong> After all calculations are complete, the final linear color <strong>must be converted back to sRGB space</strong>. This is "gamma encoding".</p>
</li>
<li><p><strong>Output:</strong> The final, gamma-encoded color is sent to the monitor, which then displays it correctly.</p>
</li>
</ol>
<p><strong>This might sound complicated, but Bevy and the GPU do almost all of this for you!</strong></p>
<h3 id="heading-how-bevy-manages-color-spaces">How Bevy Manages Color Spaces</h3>
<p>Bevy is designed to make this process seamless as long as you follow its conventions.</p>
<ul>
<li><p><strong>Textures:</strong> When you load a standard image texture, Bevy tells the GPU it's in sRGB format. The GPU hardware then <strong>automatically converts it to linear space</strong> for you every time you sample it in the shader.</p>
</li>
<li><p><strong>Colors from Rust:</strong> When you define a color with <code>Color::srgb(...)</code> in Rust, Bevy knows it's an sRGB color and correctly converts it to linear space before sending it to your shader's uniform buffer.</p>
</li>
<li><p><strong>Final Output:</strong> Bevy configures the screen's framebuffer to expect sRGB output. This tells the GPU to <strong>automatically convert the final linear color</strong> you return from your fragment shader into sRGB space as it's written to the screen.</p>
</li>
</ul>
<p>Your only responsibility is to follow the golden rule:</p>
<p><strong>Do all your math in the shader in linear space. Never mix linear and sRGB values, and trust Bevy to handle the conversions at the boundaries.</strong></p>
<p>Here’s a visual example of why this is so important. Let's try to blend red and green.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ✓ CORRECT: Blending in Linear space (what your shader should do)</span>
<span class="hljs-comment">// The GPU has already converted the sRGB inputs to linear for you.</span>
<span class="hljs-keyword">let</span> linear_red = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
<span class="hljs-keyword">let</span> linear_green = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>);
<span class="hljs-keyword">let</span> correct_yellow = mix(linear_red, linear_green, <span class="hljs-number">0.5</span>);
<span class="hljs-comment">// The result is a bright, correct yellow.</span>
<span class="hljs-comment">// The GPU will then convert this to sRGB for display.</span>

<span class="hljs-comment">// ✗ WRONG: Blending raw sRGB values (will produce incorrect results)</span>
<span class="hljs-keyword">let</span> srgb_red = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>); <span class="hljs-comment">// Imagine this was a raw sRGB value</span>
<span class="hljs-keyword">let</span> srgb_green = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>);
<span class="hljs-keyword">let</span> wrong_yellow = pow(mix(pow(srgb_red, vec3(<span class="hljs-number">2.2</span>)), pow(srgb_green, vec3(<span class="hljs-number">2.2</span>)), <span class="hljs-number">0.5</span>), vec3(<span class="hljs-number">1.0</span>/<span class="hljs-number">2.2</span>));
<span class="hljs-comment">// This is what happens if you do the math in sRGB space. The result is a darker, muddy yellow</span>
<span class="hljs-comment">// because the gamma curve is applied incorrectly relative to the math.</span>
</code></pre>
<p>Fortunately, you don't need to write the manual pow conversions yourself. As long as you let Bevy manage the inputs and outputs, the values you work with inside your WGSL fragment shader will already be in the correct linear space for your calculations.</p>
<h2 id="heading-color-arithmetic">Color Arithmetic</h2>
<p>Since colors in a shader are just vectors, we can apply standard vector math to them. Performing these operations in linear space allows us to simulate the way light behaves in the real world.</p>
<h3 id="heading-color-addition-light-accumulation">Color Addition (Light Accumulation)</h3>
<p>Adding two colors together is like shining two lights onto the same spot. Their light combines, becoming brighter.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> red_light = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
<span class="hljs-keyword">let</span> green_light = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>);

<span class="hljs-comment">// Adding the two light colors results in yellow</span>
<span class="hljs-keyword">let</span> yellow_light = red_light + green_light; <span class="hljs-comment">// vec3(1.0, 1.0, 0.0)</span>
</code></pre>
<p><strong>Key Use Cases:</strong></p>
<ul>
<li><p><strong>Combining Multiple Lights:</strong> This is the foundation of lighting a scene. The final color of a surface is the sum of the contributions from every light source (the sun, point lights, etc.) plus any ambient light.</p>
</li>
<li><p><strong>Emissive/Glow Effects:</strong> To make a surface glow, you add a color to it after all lighting has been calculated.</p>
</li>
</ul>
<p><strong>Important Note:</strong> Adding colors can easily result in values greater than <code>1.0</code>. This is not an error! It is the correct behavior for HDR (High Dynamic Range) rendering. A surface lit by two bright lights should be brighter than a surface lit by one. Do not clamp the result.</p>
<h3 id="heading-color-multiplication-filtering">Color Multiplication (Filtering)</h3>
<p>Multiplying two colors is like shining a light through a colored filter. The filter absorbs certain wavelengths of light and lets others pass through. In shaders, this is the most common way to apply textures and materials.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A white light shining on a red surface</span>
<span class="hljs-keyword">let</span> white_light = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>);
<span class="hljs-keyword">let</span> red_surface_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);

<span class="hljs-comment">// The surface "filters" the white light, reflecting only red</span>
<span class="hljs-keyword">let</span> final_color = white_light * red_surface_color; <span class="hljs-comment">// vec3(1.0, 0.0, 0.0)</span>
</code></pre>
<p>The multiplication is done component-wise: <code>(r1*r2, g1*g2, b1*b2)</code>. This means that if either color has a <code>0.0</code> in a channel, the result will also have a <code>0.0</code> in that channel.</p>
<p><strong>Key Use Cases:</strong></p>
<ul>
<li><p><strong>Applying Textures:</strong> The base color of a material (from a texture or a uniform) is multiplied by the incoming light to determine the final reflected color. This is the core operation of material definition.</p>
</li>
<li><p><strong>Tinting:</strong> Multiplying a scene by a specific color can apply a tint.</p>
</li>
<li><p><strong>Shadowing:</strong> A simple way to apply shadows is to multiply the final color by a shadow factor (e.g., <code>0.5</code> for 50% shadow, <code>0.0</code> for full shadow).</p>
</li>
</ul>
<p>You can also multiply a color by a single scalar value (<code>f32</code>) to scale its brightness.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> orange = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.0</span>);
<span class="hljs-keyword">let</span> darker = orange * <span class="hljs-number">0.5</span>;   <span class="hljs-comment">// vec3(0.5, 0.25, 0.0)</span>
<span class="hljs-keyword">let</span> brighter = orange * <span class="hljs-number">2.0</span>; <span class="hljs-comment">// vec3(2.0, 1.0, 0.0) - An HDR color</span>
</code></pre>
<h3 id="heading-color-subtraction">Color Subtraction</h3>
<p>Subtraction is less common but can be useful for specific effects. It's like removing specific colors of light.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> white_light = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>);
<span class="hljs-keyword">let</span> red_light = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);

<span class="hljs-comment">// Removing red light from white light leaves cyan</span>
<span class="hljs-keyword">let</span> cyan_light = white_light - red_light; <span class="hljs-comment">// vec3(0.0, 1.0, 1.0)</span>
</code></pre>
<p><strong>Key Use Cases:</strong></p>
<ul>
<li><p>Creating complementary colors.</p>
</li>
<li><p>Certain types of color correction or filtering effects.</p>
</li>
</ul>
<p>Be aware that subtraction can result in negative values, which are physically meaningless for color. You should usually clamp the result to zero.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.2</span>, <span class="hljs-number">0.8</span>, <span class="hljs-number">0.5</span>);
<span class="hljs-keyword">let</span> to_remove = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.3</span>, <span class="hljs-number">0.1</span>, <span class="hljs-number">0.0</span>);
<span class="hljs-comment">// Using max() prevents any component from going below 0.0</span>
<span class="hljs-keyword">let</span> result = max(color - to_remove, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>));
</code></pre>
<h2 id="heading-the-mix-function-smooth-color-blending">The <code>mix()</code> Function: Smooth Color Blending</h2>
<p>One of the most powerful and frequently used built-in functions in WGSL for color work is <code>mix()</code>. It performs <a target="_blank" href="https://en.wikipedia.org/wiki/Linear_interpolation">linear interpolation</a> between two values, often called a "lerp". It is the correct and most efficient way to blend colors, create gradients, or fade between different effects.</p>
<h3 id="heading-how-mix-works">How <code>mix()</code> Works</h3>
<p>The function signature is <code>mix(a, b, t)</code>, where:</p>
<ul>
<li><p><code>a</code> is the starting value (e.g., the first color).</p>
</li>
<li><p><code>b</code> is the ending value (e.g., the second color).</p>
</li>
<li><p><code>t</code> is the interpolation factor, a float typically in the <code>[0.0, 1.0]</code> range that determines the blend amount.</p>
</li>
</ul>
<p>The underlying math is: <code>result = a * (1.0 - t) + b * t</code>.</p>
<ul>
<li><p>When <code>t</code> is <code>0.0</code>, the result is <code>a * 1.0 + b * 0.0</code>, which is 100% <code>a</code>.</p>
</li>
<li><p>When <code>t</code> is <code>1.0</code>, the result is <code>a * 0.0 + b * 1.0</code>, which is 100% <code>b</code>.</p>
</li>
<li><p>When <code>t</code> is <code>0.5</code>, the result is <code>a * 0.5 + b * 0.5</code>, an even 50/50 blend of <code>a</code> and <code>b</code>.</p>
</li>
</ul>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> red = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
<span class="hljs-keyword">let</span> blue = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);

<span class="hljs-comment">// A 25% blend from red to blue (mostly red)</span>
<span class="hljs-keyword">let</span> result = mix(red, blue, <span class="hljs-number">0.25</span>); <span class="hljs-comment">// vec3(0.75, 0.0, 0.25)</span>

<span class="hljs-comment">// A 75% blend from red to blue (mostly blue)</span>
<span class="hljs-keyword">let</span> result = mix(red, blue, <span class="hljs-number">0.75</span>); <span class="hljs-comment">// vec3(0.25, 0.0, 0.75)</span>
</code></pre>
<h3 id="heading-use-case-1-creating-gradients">Use Case 1: Creating Gradients</h3>
<p>The most common use for <code>mix()</code> is creating gradients. By varying the <code>t</code> factor across a surface using its UV coordinates, you can create smooth color transitions.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> color_a = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.8</span>, <span class="hljs-number">0.0</span>); <span class="hljs-comment">// Gold</span>
    <span class="hljs-keyword">let</span> color_b = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.8</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// Purple</span>

    <span class="hljs-comment">// Use the horizontal texture coordinate (0.0 on left, 1.0 on right)</span>
    <span class="hljs-comment">// as the blend factor.</span>
    <span class="hljs-keyword">let</span> t = <span class="hljs-keyword">in</span>.uv.x;
    <span class="hljs-keyword">let</span> gradient_color = mix(color_a, color_b, t);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(gradient_color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p>This simple code will produce a smooth horizontal gradient from gold to purple across your mesh.</p>
<h3 id="heading-use-case-2-multi-color-gradients">Use Case 2: Multi-Color Gradients</h3>
<p>You can chain <code>mix()</code> calls to create gradients with more than two colors. A common way is to check which segment the <code>t</code> value is in and remap it to a <code>[0, 1]</code> range for that segment.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">three_color_gradient</span></span>(color_a: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, color_b: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, color_c: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, t: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">if</span> t &lt; <span class="hljs-number">0.5</span> {
        <span class="hljs-comment">// First half of the gradient: blend from A to B</span>
        <span class="hljs-comment">// We remap t from [0.0, 0.5] to [0.0, 1.0] by multiplying by 2.</span>
        <span class="hljs-keyword">return</span> mix(color_a, color_b, t * <span class="hljs-number">2.0</span>);
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// Second half of the gradient: blend from B to C</span>
        <span class="hljs-comment">// We remap t from [0.5, 1.0] to [0.0, 1.0] by subtracting 0.5 and then multiplying by 2.</span>
        <span class="hljs-keyword">return</span> mix(color_b, color_c, (t - <span class="hljs-number">0.5</span>) * <span class="hljs-number">2.0</span>);
    }
}
</code></pre>
<h3 id="heading-use-case-3-conditional-blending">Use Case 3: Conditional Blending</h3>
<p><code>mix()</code> is an excellent way to create "soft" conditional logic without expensive branching. For example, you could blend between a "grass" color and a "rock" color based on the steepness of a surface.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Assume 'normal' is the surface normal and 'up' is vec3(0.0, 1.0, 0.0)</span>
<span class="hljs-keyword">let</span> grass_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.1</span>, <span class="hljs-number">0.6</span>, <span class="hljs-number">0.2</span>);
<span class="hljs-keyword">let</span> rock_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>);

<span class="hljs-comment">// dot(normal, up) gives 1.0 for flat ground, 0.0 for vertical cliffs.</span>
<span class="hljs-keyword">let</span> flatness = saturate(dot(normal, up));

<span class="hljs-comment">// Blend between rock and grass based on the flatness.</span>
<span class="hljs-comment">// Cliffs will be pure rock, flat ground will be pure grass.</span>
<span class="hljs-keyword">let</span> terrain_color = mix(rock_color, grass_color, flatness);
</code></pre>
<p>This is far more efficient than an <code>if</code> statement and produces a much more natural, smooth transition between the two colors.</p>
<h3 id="heading-use-case-4-animated-effects">Use Case 4: Animated Effects</h3>
<p>By driving the <code>t</code> factor with time, you can create dynamic effects like fades, color cycling, or pulses.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Make a color pulse by blending towards white and back.</span>
<span class="hljs-keyword">let</span> base_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
<span class="hljs-keyword">let</span> pulse_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// White</span>

<span class="hljs-comment">// sin(time) gives a smooth oscillation between -1 and 1.</span>
<span class="hljs-comment">// We map it to the [0, 1] range to use as a blend factor.</span>
<span class="hljs-keyword">let</span> pulse_t = sin(material.time * <span class="hljs-number">5.0</span>) * <span class="hljs-number">0.5</span> + <span class="hljs-number">0.5</span>;

<span class="hljs-keyword">let</span> final_color = mix(base_color, pulse_color, pulse_t);
</code></pre>
<h2 id="heading-hsv-color-space-an-artists-perspective">HSV Color Space: An Artist's Perspective</h2>
<p>While the RGB model is perfect for computers and displays, it's not very intuitive for humans. If you have an orange color <code>(1.0, 0.5, 0.0)</code> and you want to make it slightly more reddish, or less vibrant, or darker, how do you adjust the R, G, and B values? It's not obvious.</p>
<p>This is where the <strong>HSV</strong> (Hue, Saturation, Value) color model comes in. It was designed to map more closely to how artists and designers think about color. Instead of R, G, and B light components, it defines color with three more intuitive properties.</p>
<h3 id="heading-the-three-components-of-hsv">The Three Components of HSV</h3>
<ol>
<li><p><strong>Hue</strong>: This is the pure "color" itself, represented as a position on a 360-degree color wheel. It's what you mean when you say "red," "green," or "purple". In shaders, we typically map this <code>[0, 360]</code> degree range to a <code>[0.0, 1.0]</code> float.</p>
</li>
<li><p><strong>Saturation</strong>: This is the "intensity" or "purity" of the color. A saturation of <code>1.0</code> is a vibrant, fully saturated color. A saturation of <code>0.0</code> is completely desaturated - a grayscale color (white, gray, or black).</p>
</li>
<li><p><strong>Value</strong>: This is the "brightness" or "lightness" of the color. A value of <code>1.0</code> is the full, bright color. A value of <code>0.0</code> is always black, regardless of the hue or saturation.</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764419019118/68f0a1ba-c371-47cb-821e-a549277d6a4d.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-why-use-hsv">Why Use HSV?</h3>
<p>Working in HSV makes many common color adjustments trivial:</p>
<ul>
<li><p><strong>To make a color darker?</strong> Decrease the Value.</p>
</li>
<li><p><strong>To make a color paler or more washed-out?</strong> Decrease the Saturation.</p>
</li>
<li><p><strong>To shift a color to a neighboring one (e.g., orange to yellow)?</strong> Just add a small amount to the Hue, letting it wrap around the color wheel.</p>
</li>
</ul>
<p>This is far more predictable than trying to guess the right mix of R, G, and B. The typical workflow in a shader is to:</p>
<ol>
<li><p>Start with a color in RGB.</p>
</li>
<li><p>Convert it to HSV.</p>
</li>
<li><p>Perform your adjustments on the H, S, or V components.</p>
</li>
<li><p>Convert the result back to RGB for the final output.</p>
</li>
</ol>
<h3 id="heading-rgb-hsv-conversion-in-wgsl">RGB ↔ HSV Conversion in WGSL</h3>
<p>Here are standard, production-ready functions for converting between the two color spaces in WGSL. While they might look complex, you can treat them as a black box: put RGB in, get HSV out, and vice-versa.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Converts an RGB color (each component in [0,1]) to HSV</span>
<span class="hljs-comment">// Returns a vec3&lt;f32&gt; where:</span>
<span class="hljs-comment">// - x is Hue [0,1]</span>
<span class="hljs-comment">// - y is Saturation [0,1]</span>
<span class="hljs-comment">// - z is Value [0,1]</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rgb_to_hsv</span></span>(rgb: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> c_max = max(rgb.r, max(rgb.g, rgb.b));
    <span class="hljs-keyword">let</span> c_min = min(rgb.r, min(rgb.g, rgb.b));
    <span class="hljs-keyword">let</span> delta = c_max - c_min;

    var hue: <span class="hljs-built_in">f32</span>;
    <span class="hljs-keyword">if</span> (delta == <span class="hljs-number">0.0</span>) {
        hue = <span class="hljs-number">0.0</span>;
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (c_max == rgb.r) {
        hue = ((rgb.g - rgb.b) / delta) % <span class="hljs-number">6.0</span>;
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (c_max == rgb.g) {
        hue = (rgb.b - rgb.r) / delta + <span class="hljs-number">2.0</span>;
    } <span class="hljs-keyword">else</span> {
        hue = (rgb.r - rgb.g) / delta + <span class="hljs-number">4.0</span>;
    }

    hue = hue * <span class="hljs-number">60.0</span>; <span class="hljs-comment">// convert to degrees</span>
    <span class="hljs-keyword">if</span> (hue &lt; <span class="hljs-number">0.0</span>) {
        hue += <span class="hljs-number">360.0</span>;
    }

    <span class="hljs-keyword">let</span> saturation = select(<span class="hljs-number">0.0</span>, delta / c_max, c_max &gt; <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> value = c_max;

    <span class="hljs-keyword">return</span> vec3&lt;<span class="hljs-built_in">f32</span>&gt;(hue / <span class="hljs-number">360.0</span>, saturation, value); <span class="hljs-comment">// Normalize hue to [0,1]</span>
}

<span class="hljs-comment">// Converts an HSV color (each component in [0,1]) back to RGB</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hsv_to_rgb</span></span>(hsv: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> h = hsv.x * <span class="hljs-number">360.0</span>; <span class="hljs-comment">// convert hue back to degrees</span>
    <span class="hljs-keyword">let</span> s = hsv.y;
    <span class="hljs-keyword">let</span> v = hsv.z;

    <span class="hljs-keyword">let</span> c = v * s;
    <span class="hljs-keyword">let</span> x = c * (<span class="hljs-number">1.0</span> - abs((h / <span class="hljs-number">60.0</span>) % <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> m = v - c;

    var rgb_prime: vec3&lt;<span class="hljs-built_in">f32</span>&gt;;
    <span class="hljs-keyword">if</span> (h &lt; <span class="hljs-number">60.0</span>) {
        rgb_prime = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(c, x, <span class="hljs-number">0.0</span>);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (h &lt; <span class="hljs-number">120.0</span>) {
        rgb_prime = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(x, c, <span class="hljs-number">0.0</span>);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (h &lt; <span class="hljs-number">180.0</span>) {
        rgb_prime = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, c, x);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (h &lt; <span class="hljs-number">240.0</span>) {
        rgb_prime = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, x, c);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (h &lt; <span class="hljs-number">300.0</span>) {
        rgb_prime = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(x, <span class="hljs-number">0.0</span>, c);
    } <span class="hljs-keyword">else</span> {
        rgb_prime = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(c, <span class="hljs-number">0.0</span>, x);
    }

    <span class="hljs-keyword">return</span> rgb_prime + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(m);
}
</code></pre>
<h3 id="heading-practical-color-adjustments-with-hsv">Practical Color Adjustments with HSV</h3>
<p>With these conversion functions, adjusting colors becomes incredibly intuitive.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Start with an RGB color</span>
<span class="hljs-keyword">let</span> base_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.2</span>, <span class="hljs-number">0.0</span>); <span class="hljs-comment">// An orange color</span>

<span class="hljs-comment">// 1. Convert to HSV</span>
var hsv = rgb_to_hsv(base_color);

<span class="hljs-comment">// 2. Perform intuitive adjustments</span>
<span class="hljs-comment">// Shift the hue by 10% of the color wheel (orange -&gt; yellow)</span>
hsv.x = fract(hsv.x + <span class="hljs-number">0.1</span>);

<span class="hljs-comment">// Reduce the saturation by 50% (make it paler)</span>
hsv.y *= <span class="hljs-number">0.5</span>;

<span class="hljs-comment">// Increase the brightness by 10%</span>
hsv.z = min(hsv.z * <span class="hljs-number">1.1</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// clamp to 1.0</span>

<span class="hljs-comment">// 3. Convert back to RGB for output</span>
<span class="hljs-keyword">let</span> adjusted_color = hsv_to_rgb(hsv);
</code></pre>
<p>This ability to precisely and predictably manipulate color is invaluable for procedural generation, UI feedback, and creating rich, dynamic visual effects.</p>
<h2 id="heading-color-temperature-and-tinting">Color Temperature and Tinting</h2>
<p>Not all white light is the same. The "white" light from a candle is a warm, orange-yellow, while the "white" light on an overcast day is a cool, subtle blue. This characteristic of light is called <strong>color temperature</strong>, and simulating it is a powerful way to establish the mood and realism of a scene.</p>
<h3 id="heading-understanding-color-temperature">Understanding Color Temperature</h3>
<p>Color temperature is a concept from physics that describes the color of light emitted by an idealized object (a "<a target="_blank" href="https://en.wikipedia.org/wiki/Black_body">black body</a>") as it's heated. It's measured in <strong>Kelvin (K)</strong>.</p>
<ul>
<li><p><strong>Low Kelvin (1000K - 3000K):</strong> Corresponds to lower heat and produces "warm" light, shifting towards red, orange, and yellow. Think of candle flames, fire, and old incandescent light bulbs.</p>
</li>
<li><p><strong>Mid Kelvin (5000K - 6500K):</strong> Corresponds to neutral, "daylight" white. This is the temperature of direct sunlight or a photography flash.</p>
</li>
<li><p><strong>High Kelvin (7000K - 10000K+):</strong> Corresponds to higher heat and produces "cool" light, shifting towards blue. Think of an overcast sky or the deep blue of a clear sky.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764420194198/238d7fc3-2ac6-40b1-bcfd-c4059fd64b54.png" alt class="image--center mx-auto" /></p>
<p>By tinting your light sources with a color derived from a Kelvin temperature, you can instantly make a scene feel like a cozy interior lit by a fireplace or a cold, sterile laboratory.</p>
<h4 id="heading-the-hot-is-cool-paradox">The "Hot is Cool" Paradox</h4>
<p>It can seem counter-intuitive that physically hotter temperatures (like 10,000K) produce what we artistically call "cool" colors (blue), while cooler temperatures (2700K) produce "warm" colors (yellow/orange). This happens because our artistic sense of "warm" and "cool" is based on cultural association, not physics.</p>
<p>A perfect real-world example is a flame.</p>
<ul>
<li><p>A <strong>butane lighter flame</strong> burns at around 1600 K. It has a soft, yellow-orange color which we artistically call <strong>"warm"</strong>.</p>
</li>
<li><p>An <strong>oxy-acetylene torch flame</strong> burns at over 3500 K, more than twice as hot. Its color is a piercing, blue-white which we artistically call <strong>"cool"</strong>.</p>
</li>
</ul>
<p>The physically hotter flame produces a bluer, "cooler" color. This is because as an object's temperature rises, the light it emits shifts from the red end of the spectrum (lower energy) towards the blue and ultraviolet end (higher energy).</p>
<p>So, when working with color temperature, just remember the relationship:</p>
<ul>
<li><p><strong>Low Kelvin</strong> = Physically Cooler = <strong>Artistically Warm</strong> (a candle)</p>
</li>
<li><p><strong>High Kelvin</strong> = Physically Hotter = <strong>Artistically Cool</strong> (a torch flame)</p>
</li>
</ul>
<h3 id="heading-kelvin-to-rgb-conversion-in-wgsl">Kelvin to RGB Conversion in WGSL</h3>
<p>The exact conversion from Kelvin to an RGB color is complex. However, for real-time graphics, we can use a high-quality approximation that blends between three key colors: a warm orange, a neutral white, and a cool blue.</p>
<p>This function takes a temperature in Kelvin and returns a corresponding RGB tint color.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Approximates an RGB color tint from a temperature in Kelvin.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">kelvin_to_rgb</span></span>(kelvin: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Normalize temperature to a [0,1] range over a typical artistic spectrum (e.g., 1000K to 10000K)</span>
    <span class="hljs-keyword">let</span> t = saturate((kelvin - <span class="hljs-number">1000.0</span>) / <span class="hljs-number">9000.0</span>);

    <span class="hljs-comment">// Key color points</span>
    <span class="hljs-keyword">let</span> warm_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.6</span>, <span class="hljs-number">0.2</span>);   <span class="hljs-comment">// A rich orange for warm temperatures</span>
    <span class="hljs-keyword">let</span> neutral_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// Neutral white</span>
    <span class="hljs-keyword">let</span> cool_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.8</span>, <span class="hljs-number">0.9</span>, <span class="hljs-number">1.0</span>);   <span class="hljs-comment">// A soft, cool blue</span>

    <span class="hljs-comment">// Blend between warm and neutral for the first half,</span>
    <span class="hljs-comment">// and neutral and cool for the second half.</span>
    <span class="hljs-keyword">if</span> (t &lt; <span class="hljs-number">0.5</span>) {
        <span class="hljs-keyword">return</span> mix(warm_color, neutral_color, t * <span class="hljs-number">2.0</span>);
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-keyword">return</span> mix(neutral_color, cool_color, (t - <span class="hljs-number">0.5</span>) * <span class="hljs-number">2.0</span>);
    }
}
</code></pre>
<h3 id="heading-applying-the-tint-to-lighting">Applying the Tint to Lighting</h3>
<p>Once you have this tint color, you apply it by multiplying it with your light's base color and intensity.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A simple lighting calculation</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_light</span></span>(light_intensity: <span class="hljs-built_in">f32</span>, temperature_kelvin: <span class="hljs-built_in">f32</span>, surface_color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Get the color of the light based on its temperature</span>
    <span class="hljs-keyword">let</span> light_tint = kelvin_to_rgb(temperature_kelvin);

    <span class="hljs-comment">// 2. The full light color is its tint multiplied by its brightness</span>
    <span class="hljs-keyword">let</span> final_light_color = light_tint * light_intensity;

    <span class="hljs-comment">// 3. The light reflects off the surface</span>
    <span class="hljs-keyword">let</span> final_surface_color = surface_color * final_light_color;

    <span class="hljs-keyword">return</span> final_surface_color;
}

<span class="hljs-comment">// Example usage</span>
<span class="hljs-keyword">let</span> lit_color = calculate_light(<span class="hljs-number">1.5</span>, <span class="hljs-number">2700.0</span>, my_material.color); <span class="hljs-comment">// Lit by a warm bulb</span>
</code></pre>
<p>This approach ensures that a red brick lit by a warm light looks different from the same brick lit by a cool skylight, adding a significant layer of realism and artistry to your scene.</p>
<h2 id="heading-from-ldr-to-hdr-handling-real-world-brightness">From LDR to HDR: Handling Real-World Brightness</h2>
<p>So far, we've mostly treated colors as being within the <code>[0.0, 1.0]</code> range. This is known as <strong>Low Dynamic Range (LDR)</strong>. It's simple, but it doesn't accurately represent how light works in the real world. This is where <strong>High Dynamic Range (HDR)</strong> comes in, and understanding it is key to creating realistic lighting.</p>
<h3 id="heading-the-limits-of-ldr">The Limits of LDR</h3>
<p>Imagine a scene with a white piece of paper, a bright lightbulb, and a reflection of the sun. In the real world, their brightness levels are vastly different.</p>
<ul>
<li><p>The white paper reflects a certain amount of light.</p>
</li>
<li><p>The lightbulb <em>emits</em> much more light.</p>
</li>
<li><p>The sun's reflection is orders of magnitude brighter still.</p>
</li>
</ul>
<p>In a strict LDR world, all of these would be clamped to the same maximum value: <code>vec3&lt;f32&gt;(1.0, 1.0, 1.0)</code>. We lose all the information about their relative brightness. This makes it impossible to create effects like a glowing bloom around the lightbulb or a blinding glare from the sun's reflection.</p>
<h3 id="heading-what-is-high-dynamic-range-hdr">What is High Dynamic Range (HDR)?</h3>
<p>HDR rendering solves this problem by getting rid of the artificial <code>1.0</code> limit. Since our shaders use floating-point numbers, we can output color values far greater than <code>1.0</code> to represent physically-based light intensity.</p>
<ul>
<li><p><strong>White Paper:</strong> <code>vec3&lt;f32&gt;(1.0, 1.0, 1.0)</code></p>
</li>
<li><p><strong>Bright Lightbulb:</strong> <code>vec3&lt;f32&gt;(15.0, 12.0, 8.0)</code></p>
</li>
<li><p><strong>Sun Reflection:</strong> <code>vec3&lt;f32&gt;(100.0, 100.0, 95.0)</code></p>
</li>
</ul>
<p>By preserving this high dynamic range of brightness, our lighting calculations become far more realistic and enable advanced visual effects.</p>
<h3 id="heading-the-challenge-displaying-hdr-on-an-ldr-monitor">The Challenge: Displaying HDR on an LDR Monitor</h3>
<p>There's a catch: your monitor is an LDR device. It can only display colors in the <code>[0, 1]</code> range. So how do we show these HDR values?</p>
<p>The naive approach is to simply clamp the final color, throwing away any brightness above <code>1.0</code>.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ✗ BAD: Destructive clamping</span>
<span class="hljs-keyword">let</span> final_color = clamp(hdr_color, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>), vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>));
</code></pre>
<p>This is a destructive operation that crushes all of our carefully calculated brightness information into a flat, uniform white, as shown below.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764431333228/51828220-b939-41df-938a-807c457a3387.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-the-solution-tone-mapping">The Solution: Tone Mapping</h3>
<p>The correct solution is <strong>tone mapping</strong>. Tone mapping is an intelligent, artistic process that gracefully compresses the wide range of HDR brightness values into the LDR range that a monitor can display. It's like a skilled photographer developing a photo, adjusting the exposure, contrast, and highlights to ensure that details in both the darkest shadows and brightest areas are preserved.</p>
<h3 id="heading-how-bevy-handles-it-the-post-processing-pipeline">How Bevy Handles It: The Post-Processing Pipeline</h3>
<p>Crucially, tone mapping is <strong>not</strong> something you typically do inside an individual material's shader. It is a global, full-screen effect that is applied at the very end of the rendering process, after all objects have been drawn. This is known as a <strong>post-processing</strong> step.</p>
<p><strong>Bevy's default rendering pipeline handles this for you automatically.</strong> It renders your scene in HDR, and then, as a final step, it applies a high-quality tone mapping operator before sending the image to your screen.</p>
<p>By default, Bevy 0.16 uses an operator called <strong>TonyMcMapface</strong>, a modern curve designed for a natural, film-like appearance. Bevy also provides a number of other built-in options for you to choose from, including <a target="_blank" href="https://github.com/aces-aswf/aces-core">AcesFitted</a> (a popular film industry standard) and AgX (another excellent modern curve).</p>
<p>The key takeaway is that a sophisticated, detail-preserving conversion from HDR to LDR is happening for you behind the scenes. You don't need to implement it yourself unless you are building a fully custom render pipeline.</p>
<h3 id="heading-your-role-in-the-fragment-shader">Your Role in the Fragment Shader</h3>
<p>This makes your job in the fragment shader simple but very important:</p>
<p><strong>Calculate the correct final color in linear space and do not clamp it.</strong></p>
<p>Trust Bevy's renderer to handle the HDR-to-LDR conversion correctly. By returning values greater than <code>1.0</code> for bright surfaces, you are providing the renderer with the necessary information to create realistic bloom, glow, and exposure effects.</p>
<p>In a future article, we will take full control of this process, disable Bevy's built-in effects, and build our own custom post-processing pipeline from scratch, where we'll implement tone mapping, bloom, and more. For now, just know that it's happening for you behind the scenes.</p>
<hr />
<h2 id="heading-complete-example-interactive-color-mixer">Complete Example: Interactive Color Mixer</h2>
<p>It's time to put all this theory into practice. We will build a single, comprehensive material that acts as an interactive playground for color theory. You'll be able to mix colors in different spaces, apply operations, adjust temperature and HSV values, and see the results of HDR and tone mapping in real-time.</p>
<h3 id="heading-our-goal">Our Goal</h3>
<p>We will create a simple scene with a large plane that fills the view. This plane will be rendered with our custom ColorMixerMaterial. Using keyboard controls, we will change the material's uniform values to switch between different visualization modes and manipulate the colors on screen, with a UI panel providing feedback on the current settings.</p>
<h3 id="heading-what-this-project-demonstrates">What This Project Demonstrates</h3>
<ul>
<li><p><strong>Live Color Manipulation</strong>: How to control a shader's color properties from Rust in real-time.</p>
</li>
<li><p><strong>RGB vs. HSV Blending</strong>: A direct visual comparison of blending colors in different color spaces.</p>
</li>
<li><p><strong>Practical Color Operations</strong>: See how Add, Multiply, Screen, and Overlay blend modes affect colors.</p>
</li>
<li><p><strong>HSV Adjustments</strong>: Get a feel for the intuitive power of adjusting Hue, Saturation, and Value.</p>
</li>
<li><p><strong>HDR in Action</strong>: Directly observe the destructive nature of clamping versus the detail-preserving power of tone mapping operators when using HDR values.</p>
</li>
<li><p><strong>Shader Logic</strong>: How to use a uniform to switch between different behaviors within a single fragment shader.</p>
</li>
</ul>
<h3 id="heading-the-shader-assetsshadersd0302colormixerwgsl">The Shader (<code>assets/shaders/d03_02_color_mixer.wgsl</code>)</h3>
<p>The fragment shader is the heart of this demo. It contains all the conversion functions and color logic we've discussed. At its core is a large <code>if/else if</code> block that reads the <code>material.mix_mode</code> uniform. This single integer, controlled from Rust, determines which of the five demonstration modes is active.</p>
<p>Notice the <code>ColorMixerMaterial</code> struct at the top. It includes several <code>_padding</code> fields. These are necessary to ensure the struct's data aligns correctly with the GPU's memory requirements, which can be very strict. We've covered the rules of memory alignment in detail in <a target="_blank" href="https://hexbee.hashnode.dev/17-uniforms-and-gpu-memory-layout">1.7 - Uniforms and GPU Memory Layout</a>.</p>
<pre><code class="lang-rust">#import bevy_pbr::forward_io::VertexOutput

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">ColorMixerMaterial</span></span> {
    time: <span class="hljs-built_in">f32</span>,
    mix_mode: <span class="hljs-built_in">u32</span>,
    exposure: <span class="hljs-built_in">f32</span>,
    tonemap_mode: <span class="hljs-built_in">u32</span>,
    rgb_a: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    _padding1: <span class="hljs-built_in">f32</span>,
    rgb_b: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    _padding2: <span class="hljs-built_in">f32</span>,
    hsv_a: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    _padding3: <span class="hljs-built_in">f32</span>,
    hsv_b: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    _padding4: <span class="hljs-built_in">f32</span>,
    temperature: <span class="hljs-built_in">f32</span>,
    mix_factor: <span class="hljs-built_in">f32</span>,
    operation_mode: <span class="hljs-built_in">u32</span>,
    _padding5: <span class="hljs-built_in">f32</span>,
    _padding6: <span class="hljs-built_in">f32</span>,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: ColorMixerMaterial;

<span class="hljs-comment">// RGB to HSV conversion</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rgb_to_hsv</span></span>(rgb: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> K = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, -<span class="hljs-number">1.0</span> / <span class="hljs-number">3.0</span>, <span class="hljs-number">2.0</span> / <span class="hljs-number">3.0</span>, -<span class="hljs-number">1.0</span>);
    <span class="hljs-keyword">let</span> p = mix(vec4&lt;<span class="hljs-built_in">f32</span>&gt;(rgb.bg, K.wz), vec4&lt;<span class="hljs-built_in">f32</span>&gt;(rgb.gb, K.xy), step(rgb.b, rgb.g));
    <span class="hljs-keyword">let</span> q = mix(vec4&lt;<span class="hljs-built_in">f32</span>&gt;(p.xyw, rgb.r), vec4&lt;<span class="hljs-built_in">f32</span>&gt;(rgb.r, p.yzx), step(p.x, rgb.r));

    <span class="hljs-keyword">let</span> d = q.x - min(q.w, q.y);
    <span class="hljs-keyword">let</span> e = <span class="hljs-number">1.0e-10</span>;

    <span class="hljs-keyword">return</span> vec3&lt;<span class="hljs-built_in">f32</span>&gt;(abs(q.z + (q.w - q.y) / (<span class="hljs-number">6.0</span> * d + e)), d / (q.x + e), q.x);
}

<span class="hljs-comment">// HSV to RGB conversion</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hsv_to_rgb</span></span>(hsv: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> K = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">2.0</span> / <span class="hljs-number">3.0</span>, <span class="hljs-number">1.0</span> / <span class="hljs-number">3.0</span>, <span class="hljs-number">3.0</span>);
    <span class="hljs-keyword">let</span> p = abs(fract(hsv.xxx + K.xyz) * <span class="hljs-number">6.0</span> - K.www);
    <span class="hljs-keyword">return</span> hsv.z * mix(
        K.xxx,
        clamp(p - K.xxx, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>), vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>)),
        hsv.y
    );
}

<span class="hljs-comment">// Kelvin to RGB (simplified)</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">kelvin_to_rgb</span></span>(kelvin: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> t = clamp((kelvin - <span class="hljs-number">1000.0</span>) / <span class="hljs-number">9000.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
    <span class="hljs-keyword">let</span> warm = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.7</span>, <span class="hljs-number">0.4</span>);
    <span class="hljs-keyword">let</span> neutral = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>);
    <span class="hljs-keyword">let</span> cool = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.7</span>, <span class="hljs-number">0.8</span>, <span class="hljs-number">1.0</span>);

    <span class="hljs-keyword">if</span> t &lt; <span class="hljs-number">0.5</span> {
        <span class="hljs-keyword">return</span> mix(warm, neutral, t * <span class="hljs-number">2.0</span>);
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-keyword">return</span> mix(neutral, cool, (t - <span class="hljs-number">0.5</span>) * <span class="hljs-number">2.0</span>);
    }
}

<span class="hljs-comment">// Color blending operations</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">blend_add</span></span>(a: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, b: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> min(a + b, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">blend_multiply</span></span>(a: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, b: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> a * b;
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">blend_screen</span></span>(a: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, b: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>) - (vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>) - a) * (vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>) - b);
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">blend_overlay</span></span>(a: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, b: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    var result = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>);

    <span class="hljs-keyword">for</span> (var i = <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">3</span>; i++) {
        <span class="hljs-keyword">if</span> a[i] &lt; <span class="hljs-number">0.5</span> {
            result[i] = <span class="hljs-number">2.0</span> * a[i] * b[i];
        } <span class="hljs-keyword">else</span> {
            result[i] = <span class="hljs-number">1.0</span> - <span class="hljs-number">2.0</span> * (<span class="hljs-number">1.0</span> - a[i]) * (<span class="hljs-number">1.0</span> - b[i]);
        }
    }

    <span class="hljs-keyword">return</span> result;
}

<span class="hljs-comment">// Tone mapping operators</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">tonemap_reinhard</span></span>(hdr: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> hdr / (vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>) + hdr);
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">tonemap_aces</span></span>(hdr: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> a = <span class="hljs-number">2.51</span>;
    <span class="hljs-keyword">let</span> b = <span class="hljs-number">0.03</span>;
    <span class="hljs-keyword">let</span> c = <span class="hljs-number">2.43</span>;
    <span class="hljs-keyword">let</span> d = <span class="hljs-number">0.59</span>;
    <span class="hljs-keyword">let</span> e = <span class="hljs-number">0.14</span>;

    <span class="hljs-keyword">return</span> clamp(
        (hdr * (a * hdr + b)) / (hdr * (c * hdr + d) + e),
        vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>),
        vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>)
    );
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    var final_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>);

    <span class="hljs-comment">// UV-based mixing for visualization</span>
    <span class="hljs-keyword">let</span> t = <span class="hljs-keyword">in</span>.uv.x;

    <span class="hljs-keyword">if</span> material.mix_mode == <span class="hljs-number">0</span>u {
        <span class="hljs-comment">// RGB Mix Mode</span>
        final_color = mix(material.rgb_a, material.rgb_b, t);

    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.mix_mode == <span class="hljs-number">1</span>u {
        <span class="hljs-comment">// HSV Mix Mode</span>
        <span class="hljs-keyword">let</span> hsv_mixed = mix(material.hsv_a, material.hsv_b, t);
        final_color = hsv_to_rgb(hsv_mixed);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.mix_mode == <span class="hljs-number">2</span>u {
        <span class="hljs-comment">// Color Temperature Mode</span>
        <span class="hljs-keyword">let</span> base_color = mix(material.rgb_a, material.rgb_b, t);
        <span class="hljs-keyword">let</span> temp_color = kelvin_to_rgb(material.temperature);
        final_color = base_color * temp_color;

    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.mix_mode == <span class="hljs-number">3</span>u {
        <span class="hljs-comment">// Color Operations Mode</span>
        <span class="hljs-keyword">let</span> rgb_a = material.rgb_a;
        <span class="hljs-keyword">let</span> rgb_b = material.rgb_b;

        <span class="hljs-comment">// Split screen: show both colors and blend in middle</span>
        <span class="hljs-keyword">if</span> t &lt; <span class="hljs-number">0.33</span> {
            final_color = rgb_a;
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> t &gt; <span class="hljs-number">0.67</span> {
            final_color = rgb_b;
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-comment">// Middle section: show operation result</span>
            <span class="hljs-keyword">if</span> material.operation_mode == <span class="hljs-number">0</span>u {
                final_color = blend_add(rgb_a, rgb_b);
            } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.operation_mode == <span class="hljs-number">1</span>u {
                final_color = blend_multiply(rgb_a, rgb_b);
            } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.operation_mode == <span class="hljs-number">2</span>u {
                final_color = blend_screen(rgb_a, rgb_b);
            } <span class="hljs-keyword">else</span> {
                final_color = blend_overlay(rgb_a, rgb_b);
            }
        }
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.mix_mode == <span class="hljs-number">4</span>u {
        <span class="hljs-comment">// HDR and Tone Mapping Mode</span>
        <span class="hljs-comment">// Create HDR colors (values can exceed 1.0)</span>
        <span class="hljs-keyword">let</span> hdr_a = material.rgb_a * material.exposure;
        <span class="hljs-keyword">let</span> hdr_b = material.rgb_b * material.exposure;

        <span class="hljs-comment">// Mix in HDR</span>
        <span class="hljs-keyword">let</span> hdr_mixed = mix(hdr_a, hdr_b, t);

        <span class="hljs-comment">// Show different sections: left=clamped, middle=Reinhard, right=ACES</span>
        <span class="hljs-keyword">if</span> material.tonemap_mode == <span class="hljs-number">0</span>u {
            <span class="hljs-comment">// Clamp (shows loss of detail)</span>
            final_color = clamp(hdr_mixed, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>), vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>));
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.tonemap_mode == <span class="hljs-number">1</span>u {
            <span class="hljs-comment">// Reinhard</span>
            final_color = tonemap_reinhard(hdr_mixed);
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-comment">// ACES</span>
            final_color = tonemap_aces(hdr_mixed);
        }
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.mix_mode != <span class="hljs-number">4</span>u {
        var hsv = rgb_to_hsv(material.rgb_a);

        <span class="hljs-comment">// Shift hue, wrapping around the color wheel</span>
        hsv.x = fract(hsv.x + material.hsv_a.x);

        <span class="hljs-comment">// Adjust saturation (0.0 = grayscale, 1.0 = normal, &gt;1.0 = super-saturated)</span>
        hsv.y = hsv.y * material.hsv_a.y;

        <span class="hljs-comment">// Adjust brightness</span>
        hsv.z = hsv.z * material.hsv_a.z;

        <span class="hljs-keyword">let</span> adjusted_color = hsv_to_rgb(saturate(hsv)); <span class="hljs-comment">// Saturate HSV to keep it valid</span>

        final_color = mix(adjusted_color, material.rgb_a, t);
    }

    <span class="hljs-comment">// Add animated pulse in bottom section</span>
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">in</span>.uv.y &lt; <span class="hljs-number">0.2</span> {
        <span class="hljs-keyword">let</span> pulse = sin(material.time * <span class="hljs-number">2.0</span>) * <span class="hljs-number">0.5</span> + <span class="hljs-number">0.5</span>;
        final_color = mix(final_color, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>), pulse * <span class="hljs-number">0.2</span>);
    }

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(final_color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0302colormixerrs">The Rust Material (<code>src/materials/d03_02_color_mixer.rs</code>)</h3>
<p>The Rust code for our material is a standard implementation. It defines a single uniforms field which holds a struct that exactly mirrors the layout of the one in our shader, including the necessary padding.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>

    <span class="hljs-keyword">use</span> bevy::prelude::*;
    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">ColorMixerMaterial</span></span> {
        <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> mix_mode: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> exposure: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> tonemap_mode: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> rgb_a: Vec3,
        <span class="hljs-keyword">pub</span> _padding1: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> rgb_b: Vec3,
        <span class="hljs-keyword">pub</span> _padding2: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> hsv_a: Vec3,
        <span class="hljs-keyword">pub</span> _padding3: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> hsv_b: Vec3,
        <span class="hljs-keyword">pub</span> _padding4: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> temperature: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> mix_factor: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> operation_mode: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> _padding5: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> _padding6: <span class="hljs-built_in">f32</span>,
    }

    <span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> ColorMixerMaterial {
        <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
            <span class="hljs-keyword">Self</span> {
                time: <span class="hljs-number">0.0</span>,
                mix_mode: <span class="hljs-number">0</span>,
                exposure: <span class="hljs-number">1.0</span>,
                tonemap_mode: <span class="hljs-number">1</span>,                 <span class="hljs-comment">// Default to Reinhard</span>
                rgb_a: Vec3::new(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>), <span class="hljs-comment">// Red</span>
                rgb_b: Vec3::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>), <span class="hljs-comment">// Blue</span>
                hsv_a: Vec3::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>), <span class="hljs-comment">// Red</span>
                hsv_b: Vec3::new(<span class="hljs-number">0.5</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>), <span class="hljs-comment">// Cyan</span>
                temperature: <span class="hljs-number">5500.0</span>,
                mix_factor: <span class="hljs-number">0.5</span>,
                operation_mode: <span class="hljs-number">0</span>,

                _padding1: <span class="hljs-number">0.0</span>,
                _padding2: <span class="hljs-number">0.0</span>,
                _padding3: <span class="hljs-number">0.0</span>,
                _padding4: <span class="hljs-number">0.0</span>,
                _padding5: <span class="hljs-number">0.0</span>,
                _padding6: <span class="hljs-number">0.0</span>,
            }
        }
    }
}

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">use</span> uniforms::ColorMixerMaterial <span class="hljs-keyword">as</span> ColorMixerUniforms;

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">ColorMixerMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: ColorMixerUniforms,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> ColorMixerMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d03_02_color_mixer.wgsl"</span>.into()
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other materials</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_02_color_mixer;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0302colormixerrs">The Demo Module (<code>src/demos/d03_02_color_mixer.rs</code>)</h3>
<p>The demo module sets up our scene, which consists of a single large plane mesh, a camera, and a UI.</p>
<p>Notice that in our <code>Material</code> implementation, we only specified a <code>fragment_shader()</code>. We didn't provide a <code>vertex_shader()</code>. When you do this, Bevy automatically uses its default vertex shader. This is a common and useful pattern. Since our fragment shader only needs the standard UV coordinates (<code>in.uv</code>) and doesn't require any special, custom data from the vertex stage (like world position or normals), we can let Bevy handle the standard object transformations for us.</p>
<p>The update systems listen for keyboard input, modify the uniforms on our material asset, and update the UI text to reflect the current state.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d03_02_color_mixer::{ColorMixerMaterial, ColorMixerUniforms};
<span class="hljs-keyword">use</span> bevy::prelude::*;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;ColorMixerMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(Update, (update_time, handle_input, update_ui))
        .run();
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;ColorMixerMaterial&gt;&gt;,
) {
    <span class="hljs-comment">// Create a plane to display the color mixing</span>
    <span class="hljs-keyword">let</span> plane = Plane3d::default().mesh().size(<span class="hljs-number">8.0</span>, <span class="hljs-number">4.0</span>).build();

    commands.spawn((
        Mesh3d(meshes.add(plane)),
        MeshMaterial3d(materials.add(ColorMixerMaterial {
            uniforms: ColorMixerUniforms::default(),
        })),
        <span class="hljs-comment">// Rotate plane to face camera (Plane3d defaults to XZ plane)</span>
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>)
            .with_rotation(Quat::from_rotation_x(std::<span class="hljs-built_in">f32</span>::consts::FRAC_PI_2)),
    ));

    <span class="hljs-comment">// Camera</span>
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">8.0</span>).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    <span class="hljs-comment">// UI</span>
    commands.spawn((
        Text::new(
            <span class="hljs-string">"[1-5] Mix Mode | [R/G/B] Adjust Color A | [T/Y/U] Adjust Color B\n\
             [H] Hue Shift | [S] Saturation | [V] Brightness | [K] Temperature\n\
             [E] Exposure (HDR) | [M] Tone Map Mode | [O] Operation | [Space] Reset\n\
             \n\
             Mode: RGB Mix | Color A: Red | Color B: Blue"</span>,
        ),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">10.0</span>),
            left: Val::Px(<span class="hljs-number">10.0</span>),
            padding: UiRect::all(Val::Px(<span class="hljs-number">10.0</span>)),
            ..default()
        },
        TextFont {
            font_size: <span class="hljs-number">14.0</span>,
            ..default()
        },
        TextColor(Color::WHITE),
        BackgroundColor(Color::srgba(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.8</span>)),
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_time</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;ColorMixerMaterial&gt;&gt;) {
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        material.uniforms.time = time.elapsed_secs();
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    time: Res&lt;Time&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;ColorMixerMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> alt = keyboard.pressed(KeyCode::AltLeft) || keyboard.pressed(KeyCode::AltRight);
    <span class="hljs-keyword">let</span> delta = <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ShiftLeft) || keyboard.pressed(KeyCode::ShiftRight) {
        -time.delta_secs()
    } <span class="hljs-keyword">else</span> {
        time.delta_secs()
    };

    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-comment">// Mix mode selection</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit1) {
            material.uniforms.mix_mode = <span class="hljs-number">0</span>; <span class="hljs-comment">// RGB</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit2) {
            material.uniforms.mix_mode = <span class="hljs-number">1</span>; <span class="hljs-comment">// HSV</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit3) {
            material.uniforms.mix_mode = <span class="hljs-number">2</span>; <span class="hljs-comment">// Temperature</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit4) {
            material.uniforms.mix_mode = <span class="hljs-number">3</span>; <span class="hljs-comment">// Operations</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit5) {
            material.uniforms.mix_mode = <span class="hljs-number">4</span>; <span class="hljs-comment">// HDR</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit6) {
            material.uniforms.mix_mode = <span class="hljs-number">5</span>; <span class="hljs-comment">// Color grading</span>
        }

        <span class="hljs-comment">// RGB adjustments</span>
        <span class="hljs-keyword">if</span> !alt {
            <span class="hljs-comment">// Color A adjustments</span>
            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyR) {
                material.uniforms.rgb_a.x = (material.uniforms.rgb_a.x + delta).max(<span class="hljs-number">0.0</span>).min(<span class="hljs-number">1.0</span>);
            }
            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyG) {
                material.uniforms.rgb_a.y = (material.uniforms.rgb_a.y + delta).max(<span class="hljs-number">0.0</span>).min(<span class="hljs-number">1.0</span>);
            }
            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyB) {
                material.uniforms.rgb_a.z = (material.uniforms.rgb_a.z + delta).max(<span class="hljs-number">0.0</span>).min(<span class="hljs-number">1.0</span>);
            }
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-comment">// Color B adjustments</span>
            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyR) {
                material.uniforms.rgb_b.x = (material.uniforms.rgb_b.x + delta).max(<span class="hljs-number">0.0</span>).min(<span class="hljs-number">1.0</span>);
            }
            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyG) {
                material.uniforms.rgb_b.y = (material.uniforms.rgb_b.y + delta).max(<span class="hljs-number">0.0</span>).min(<span class="hljs-number">1.0</span>);
            }
            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyB) {
                material.uniforms.rgb_b.z = (material.uniforms.rgb_b.z + delta).max(<span class="hljs-number">0.0</span>).min(<span class="hljs-number">1.0</span>);
            }
        }

        <span class="hljs-comment">// HSV adjustments</span>
        <span class="hljs-keyword">if</span> !alt {
            <span class="hljs-comment">// Color A adjustments</span>
            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyH) {
                material.uniforms.hsv_a.x =
                    (material.uniforms.hsv_a.x + delta * <span class="hljs-number">0.3</span>).max(<span class="hljs-number">0.0</span>).min(<span class="hljs-number">1.0</span>);
            }

            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyS) {
                material.uniforms.hsv_a.y = (material.uniforms.hsv_a.y + delta).max(<span class="hljs-number">0.0</span>).min(<span class="hljs-number">1.0</span>);
            }

            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyV) {
                material.uniforms.hsv_a.z = (material.uniforms.hsv_a.z + delta).max(<span class="hljs-number">0.0</span>).min(<span class="hljs-number">1.0</span>);
            }
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-comment">// Color B adjustments</span>
            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyH) {
                material.uniforms.hsv_b.x =
                    (material.uniforms.hsv_b.x + delta * <span class="hljs-number">0.3</span>).max(<span class="hljs-number">0.0</span>).min(<span class="hljs-number">1.0</span>);
            }

            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyS) {
                material.uniforms.hsv_b.y = (material.uniforms.hsv_b.y + delta).max(<span class="hljs-number">0.0</span>).min(<span class="hljs-number">1.0</span>);
            }

            <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyV) {
                material.uniforms.hsv_b.z = (material.uniforms.hsv_b.z + delta).max(<span class="hljs-number">0.0</span>).min(<span class="hljs-number">1.0</span>);
            }
        }

        <span class="hljs-comment">// Temperature</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyK) {
            material.uniforms.temperature = (material.uniforms.temperature + delta * <span class="hljs-number">1000.0</span>)
                .max(<span class="hljs-number">1000.0</span>)
                .min(<span class="hljs-number">10000.0</span>);
        }

        <span class="hljs-comment">// Exposure (HDR)</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyE) {
            material.uniforms.exposure = (material.uniforms.exposure + delta).max(<span class="hljs-number">0.1</span>).min(<span class="hljs-number">5.0</span>);
        }

        <span class="hljs-comment">// Tone mapping mode (cycles through modes)</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyM) {
            material.uniforms.tonemap_mode = (material.uniforms.tonemap_mode + <span class="hljs-number">1</span>) % <span class="hljs-number">3</span>;
        }

        <span class="hljs-comment">// Operation mode (cycles through modes)</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyO) {
            material.uniforms.operation_mode = (material.uniforms.operation_mode + <span class="hljs-number">1</span>) % <span class="hljs-number">4</span>;
        }

        <span class="hljs-comment">// Reset</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Space) {
            material.uniforms = ColorMixerUniforms::default();
        }
    }
}

<span class="hljs-keyword">mod</span> ui {
    <span class="hljs-keyword">use</span> super::*;

    <span class="hljs-comment">//  [K] Temperature | [E] Exposure (HDR) | [M] Tone Map Mode | [O] Operation | [Space] Reset\n\</span>
    <span class="hljs-comment">//  Hold Shift to decrease values\n\</span>
    <span class="hljs-comment">//  \n\</span>
    <span class="hljs-comment">//  Operation: {} | Tone Map: {}\n\</span>
    <span class="hljs-comment">// {}</span>
    <span class="hljs-comment">// {}</span>
    <span class="hljs-comment">//  Temperature: {:.0}K | Exposure: {:.2}x</span>

    <span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rgb</span></span>(material: &amp;ColorMixerMaterial) -&gt; <span class="hljs-built_in">String</span> {
        <span class="hljs-built_in">format!</span>(
            <span class="hljs-string">"1 - RGB Mix\n\
            [R/G/B] Adjust Color A | [Alt + R/G/B] Adjust Color B\n\
            Hold Shift to decrease values\n\
            [Space] Reset\n\
            Color A: RGB({:.2}, {:.2}, {:.2})\n\
            Color B: RGB({:.2}, {:.2}, {:.2})"</span>,
            material.uniforms.rgb_a.x,
            material.uniforms.rgb_a.y,
            material.uniforms.rgb_a.z,
            material.uniforms.rgb_b.x,
            material.uniforms.rgb_b.y,
            material.uniforms.rgb_b.z,
        )
    }

    <span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hsv</span></span>(material: &amp;ColorMixerMaterial) -&gt; <span class="hljs-built_in">String</span> {
        <span class="hljs-built_in">format!</span>(
            <span class="hljs-string">"2 - HSV Mix\n\
            [H/S/V] Adjust Color A | [Alt + H/S/V] Adjust Color B\n\
            Hold Shift to decrease values\n\
            [Space] Reset\n\
            Color A: HSV({:.2}, {:.2}, {:.2})\n\
            Color B: HSV({:.2}, {:.2}, {:.2})"</span>,
            material.uniforms.hsv_a.x,
            material.uniforms.hsv_a.y,
            material.uniforms.hsv_a.z,
            material.uniforms.hsv_b.x,
            material.uniforms.hsv_b.y,
            material.uniforms.hsv_b.z,
        )
    }

    <span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">temperature</span></span>(material: &amp;ColorMixerMaterial) -&gt; <span class="hljs-built_in">String</span> {
        <span class="hljs-built_in">format!</span>(
            <span class="hljs-string">"3 - Color Temperature\n\
            [R/G/B] Adjust Color A | [Alt + R/G/B] Adjust Color B\n\
            Hold Shift to decrease values\n\
            [K] Temperature\n\
            [Space] Reset\n\
            Color A: RGB({:.2}, {:.2}, {:.2})\n\
            Color B: RGB({:.2}, {:.2}, {:.2})\n\
            Temperature: {:.0}K"</span>,
            material.uniforms.rgb_a.x,
            material.uniforms.rgb_a.y,
            material.uniforms.rgb_a.z,
            material.uniforms.rgb_b.x,
            material.uniforms.rgb_b.y,
            material.uniforms.rgb_b.z,
            material.uniforms.temperature,
        )
    }

    <span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">operations</span></span>(material: &amp;ColorMixerMaterial) -&gt; <span class="hljs-built_in">String</span> {
        <span class="hljs-keyword">let</span> operation_name = <span class="hljs-keyword">match</span> material.uniforms.operation_mode {
            <span class="hljs-number">0</span> =&gt; <span class="hljs-string">"Add"</span>,
            <span class="hljs-number">1</span> =&gt; <span class="hljs-string">"Multiply"</span>,
            <span class="hljs-number">2</span> =&gt; <span class="hljs-string">"Screen"</span>,
            <span class="hljs-number">3</span> =&gt; <span class="hljs-string">"Overlay"</span>,
            _ =&gt; <span class="hljs-string">"Unknown"</span>,
        };

        <span class="hljs-built_in">format!</span>(
            <span class="hljs-string">"4 - Operations\n\
            [R/G/B] Adjust Color A | [Alt + R/G/B] Adjust Color B\n\
            Hold Shift to decrease values\n\
            [O] Operation\n\
            [Space] Reset\n\
            Color A: RGB({:.2}, {:.2}, {:.2})\n\
            Color B: RGB({:.2}, {:.2}, {:.2})\n\
            Operation: {}"</span>,
            material.uniforms.rgb_a.x,
            material.uniforms.rgb_a.y,
            material.uniforms.rgb_a.z,
            material.uniforms.rgb_b.x,
            material.uniforms.rgb_b.y,
            material.uniforms.rgb_b.z,
            operation_name,
        )
    }

    <span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hsv_adjustments</span></span>(material: &amp;ColorMixerMaterial) -&gt; <span class="hljs-built_in">String</span> {
        <span class="hljs-built_in">format!</span>(
            <span class="hljs-string">"6 - HSV Adjustments\n\
            [R/G/B] Adjust Color A | [Alt + R/G/B] Adjust Color B\n\
            [H/S/V] Change Color Adjustment\n\
            Hold Shift to decrease values\n\
            [Space] Reset\n\
            Color A: RGB({:.2}, {:.2}, {:.2})\n\
            Hue Shift: {:.2}\n\
            Saturation: {:.2}\n\
            Brightness: {:.2}"</span>,
            material.uniforms.rgb_a.x,
            material.uniforms.rgb_a.y,
            material.uniforms.rgb_a.z,
            material.uniforms.hsv_a.x,
            material.uniforms.hsv_a.y,
            material.uniforms.hsv_a.z,
        )
    }

    <span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">tone_mapping</span></span>(material: &amp;ColorMixerMaterial) -&gt; <span class="hljs-built_in">String</span> {
        <span class="hljs-keyword">let</span> tonemap_name = <span class="hljs-keyword">match</span> material.uniforms.tonemap_mode {
            <span class="hljs-number">0</span> =&gt; <span class="hljs-string">"Clamp (Bad)"</span>,
            <span class="hljs-number">1</span> =&gt; <span class="hljs-string">"Reinhard"</span>,
            <span class="hljs-number">2</span> =&gt; <span class="hljs-string">"ACES Filmic"</span>,
            _ =&gt; <span class="hljs-string">"Unknown"</span>,
        };

        <span class="hljs-built_in">format!</span>(
            <span class="hljs-string">"5 - HDR &amp; Tone Mapping\n\
            [R/G/B] Adjust Color A | [Alt + R/G/B] Adjust Color B\n\
            Hold Shift to decrease values\n\
            [M] Tone Map Mode\n\
            [E] Exposure (HDR)\n\
            [Space] Reset\n\
            Color A: RGB({:.2}, {:.2}, {:.2})\n\
            Color B: RGB({:.2}, {:.2}, {:.2})\n\
            Tone Map: {}\n\
            Exposure: {:.2}x"</span>,
            material.uniforms.rgb_a.x,
            material.uniforms.rgb_a.y,
            material.uniforms.rgb_a.z,
            material.uniforms.rgb_b.x,
            material.uniforms.rgb_b.y,
            material.uniforms.rgb_b.z,
            tonemap_name,
            material.uniforms.exposure,
        )
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(materials: Res&lt;Assets&lt;ColorMixerMaterial&gt;&gt;, <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;) {
    <span class="hljs-keyword">if</span> !materials.is_changed() {
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, material)) = materials.iter().next() {
        <span class="hljs-keyword">let</span> ui = <span class="hljs-keyword">match</span> material.uniforms.mix_mode {
            <span class="hljs-number">0</span> =&gt; ui::rgb(material),
            <span class="hljs-number">1</span> =&gt; ui::hsv(material),
            <span class="hljs-number">2</span> =&gt; ui::temperature(material),
            <span class="hljs-number">3</span> =&gt; ui::operations(material),
            <span class="hljs-number">4</span> =&gt; ui::tone_mapping(material),
            <span class="hljs-number">5</span> =&gt; ui::hsv_adjustments(material),
            _ =&gt; <span class="hljs-string">"Unknown"</span>.to_string(),
        };

        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
            **text = <span class="hljs-built_in">format!</span>(<span class="hljs-string">"[1-6] Mix Mode -&gt; {}"</span>, ui);
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other demos</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_02_color_mixer;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"3.2"</span>,
    title: <span class="hljs-string">"Color Spaces and Operations"</span>,
    run: demos::d03_02_color_mixer::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>When you run the application, you will see a large colored plane. Use the keyboard controls to explore the different color concepts in real time.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key(s)</td><td>Action</td></tr>
</thead>
<tbody>
<tr>
<td><strong>1-6</strong></td><td>Select the active visualization mode.</td></tr>
<tr>
<td><strong>R, G, B</strong></td><td>Hold to adjust RGB for Color A.</td></tr>
<tr>
<td><strong>Alt + R, G, B</strong></td><td>Hold to adjust RGB for Color B.</td></tr>
<tr>
<td><strong>H, S, V</strong></td><td>Hold to adjust HSV for Color A (or as adjustments in Mode 6).</td></tr>
<tr>
<td><strong>Alt + H, S, V</strong></td><td>Hold to adjust HSV for Color B.</td></tr>
<tr>
<td><strong>Shift</strong></td><td>Hold with any adjustment key to decrease the value.</td></tr>
<tr>
<td><strong>K</strong></td><td>Adjust the Color Temperature (in Temperature Mode).</td></tr>
<tr>
<td><strong>E</strong></td><td>Adjust the Exposure (in HDR Mode).</td></tr>
<tr>
<td><strong>O</strong></td><td>Cycle through the blend operations (in Operations Mode).</td></tr>
<tr>
<td><strong>M</strong></td><td>Cycle through the tone mapping operators (in HDR Mode).</td></tr>
<tr>
<td><strong>Spacebar</strong></td><td>Reset all values to their defaults.</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763500486308/42855ccd-bf55-47b1-ab34-a4a2f04a1197.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763500507446/9870cbb9-0e67-43c6-a677-fd7f89a23d1f.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763500534608/d86ca4ff-20ed-4271-8117-d3e226fc691b.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763500546503/66cd1ab2-8d77-4895-9134-899203818298.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763500559499/971beddf-7b44-485c-8fda-dceba5a5f8b2.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763500579523/d4cb667c-14ca-4458-b5eb-c3bc060a5582.png" alt class="image--center mx-auto" /></p>
<ul>
<li><p><strong>Mode 1 (RGB Mix):</strong> Shows a direct mathematical blend between the RGB components of two colors. Notice how blending between complementary colors like red and cyan results in a dull gray middle.</p>
</li>
<li><p><strong>Mode 2 (HSV Mix):</strong> This mode blends between the hue, saturation, and value components separately. Blending red and cyan now produces a vibrant rainbow as the hue interpolates around the color wheel.</p>
</li>
<li><p><strong>Mode 3 (Color Temperature):</strong> Applies a global tint to the base gradient. Use <strong>K</strong> to change the temperature and see how it affects the mood, from a warm, fire-lit feel to a cool, sterile one.</p>
</li>
<li><p><strong>Mode 4 (Operations):</strong> The screen is split to show Color A, Color B, and the result of a blend operation in the middle. Press <strong>O</strong> to cycle through them and see how they combine colors.</p>
</li>
<li><p><strong>Mode 5 (HDR &amp; Tone Mapping):</strong></p>
<ol>
<li><p>Hold <strong>E</strong> to increase the <strong>Exposure</strong> above 1.0.</p>
</li>
<li><p>Press <strong>M</strong> to cycle the tone mappers.</p>
</li>
<li><p>On <strong>"Clamp (Bad)"</strong>, bright areas will flatten into a detail-less white.</p>
</li>
<li><p>On <strong>"Reinhard"</strong> and <strong>"ACES Filmic"</strong>, the gradient remains smooth and detailed, showing the power of tone mapping.</p>
</li>
</ol>
</li>
<li><p><strong>Mode 6 (HSV Adjustments):</strong> This mode demonstrates the power of HSV for color grading. It starts with Color A, and uses the H, S, and V controls to adjust it. The screen shows a gradient from the new, adjusted color on the left to the original Color A on the right. This lets you directly compare your changes. Try making a color paler (lower <strong>S</strong>aturation) or shifting its hue (change <strong>H</strong>ue) and see how intuitive it is.</p>
</li>
</ul>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>You've now covered the essential theory and practice of color in modern rendering. Before moving on, ensure these core concepts are clear:</p>
<ol>
<li><p><strong>Linear is for Math, sRGB is for Humans:</strong> All lighting and blending calculations must happen in <strong>linear color space</strong> to be physically correct. <strong>sRGB</strong> is a non-linear space used in images and on monitors that is optimized for human perception.</p>
</li>
<li><p><strong>Bevy Handles the Conversions:</strong> In a standard setup, Bevy automatically converts sRGB textures and colors to linear space for your shader, and converts your shader's final linear output back to sRGB for the screen. Your job is to do the math correctly in the middle.</p>
</li>
<li><p><strong>Operations Have Physical Meanings:</strong> Adding colors is like combining lights. Multiplying colors is like filtering light through a material. <code>mix()</code> is the correct way to blend between them.</p>
</li>
<li><p><strong>HSV is for Intuition:</strong> The HSV (Hue, Saturation, Value) model is often more intuitive for artistic adjustments like changing a color's vibrancy or shifting its hue than trying to manipulate raw RGB values.</p>
</li>
<li><p><strong>Don't Clamp Your Output:</strong> To achieve realistic lighting effects like bloom, your fragment shader must be able to output HDR color values greater than <code>1.0</code>. <strong>Tone mapping</strong>, a final post-processing step handled by Bevy, is responsible for safely compressing this HDR range for your LDR monitor.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>You are now equipped with a powerful understanding of how to create, manipulate, and blend colors. But a solid color is just the beginning. How do we create more complex surfaces with intricate details, repeating patterns, and realistic textures?</p>
<p>In the next article, we will move beyond simple colors and learn how to use <strong>UV coordinates</strong> to create procedural patterns directly in the shader. We will master functions like <code>fract()</code>, <code>step()</code>, and <code>length()</code> to generate everything from simple stripes and checkerboards to complex circular and tiled designs, laying the foundation for procedural texturing.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/33-uv-based-patterns"><strong><em>3.3 - UV-Based Patterns</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<p><strong>Color Representation in WGSL</strong>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// RGB color</span>
<span class="hljs-keyword">let</span> red = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
<span class="hljs-comment">// RGBA color (with alpha)</span>
<span class="hljs-keyword">let</span> transparent_blue = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>);
</code></pre>
<p><strong>Common Color Operations (in Linear Space)</strong>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Add (combine light)</span>
<span class="hljs-keyword">let</span> yellow = vec3(<span class="hljs-number">1.0</span>,<span class="hljs-number">0</span>.,<span class="hljs-number">0</span>.) + vec3(<span class="hljs-number">0</span>.,<span class="hljs-number">1</span>.,<span class="hljs-number">0</span>.);

<span class="hljs-comment">// Multiply (filter/tint)</span>
<span class="hljs-keyword">let</span> final_color = light_color * surface_color;

<span class="hljs-comment">// Scale Brightness</span>
<span class="hljs-keyword">let</span> darker = final_color * <span class="hljs-number">0.5</span>;
<span class="hljs-keyword">let</span> brighter_hdr = final_color * <span class="hljs-number">2.0</span>; <span class="hljs-comment">// Can exceed 1.0</span>

<span class="hljs-comment">// Linear Interpolation (Blend)</span>
<span class="hljs-keyword">let</span> blended = mix(color_a, color_b, <span class="hljs-number">0.5</span>);
</code></pre>
<p><strong>Desaturation (Grayscale)</strong>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Perceptually-correct luminance for linear RGB</span>
<span class="hljs-keyword">let</span> luminance = dot(color.rgb, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.2126</span>, <span class="hljs-number">0.7152</span>, <span class="hljs-number">0.0722</span>));
<span class="hljs-keyword">let</span> grayscale = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(luminance);

<span class="hljs-comment">// Partial desaturation</span>
<span class="hljs-keyword">let</span> less_vibrant = mix(grayscale, color.rgb, <span class="hljs-number">0.5</span>);
</code></pre>
<p><strong>Clamping to LDR (Use Sparingly)</strong>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Clamp to [0,1] range. Avoid on final output in an HDR pipeline.</span>
<span class="hljs-keyword">let</span> ldr_color = saturate(hdr_color);
</code></pre>
<p><strong>HSV Conversion</strong>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Use the full functions provided in the article for accurate conversion</span>
<span class="hljs-keyword">let</span> hsv = rgb_to_hsv(rgb_color);
<span class="hljs-keyword">let</span> rgb = hsv_to_rgb(hsv_color);

<span class="hljs-comment">// Manipulate HSV components for intuitive changes</span>
hsv.x = fract(hsv.x + <span class="hljs-number">0.1</span>); <span class="hljs-comment">// Shift Hue</span>
hsv.y *= <span class="hljs-number">0.5</span>;               <span class="hljs-comment">// Desaturate</span>
hsv.z *= <span class="hljs-number">1.2</span>;               <span class="hljs-comment">// Brighten</span>
</code></pre>
<p><strong>Color Temperature (Approximate)</strong>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Get a tint color from a Kelvin value to multiply with your light</span>
<span class="hljs-keyword">let</span> light_tint = kelvin_to_rgb(<span class="hljs-number">2700.0</span>); <span class="hljs-comment">// Warm light bulb</span>
</code></pre>
<p><strong>Bevy Color Creation (Rust)</strong>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In Rust, create colors in sRGB space (most common)</span>
Color::srgb(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.0</span>);

<span class="hljs-comment">// For advanced cases, specify a color already in linear space</span>
Color::linear_rgb(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.25</span>, <span class="hljs-number">0.0</span>);
</code></pre>
<p><strong>Enabling Transparency (Rust)</strong>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::pbr::AlphaMode;

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> MyMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">alpha_mode</span></span>(&amp;<span class="hljs-keyword">self</span>) -&gt; AlphaMode {
        AlphaMode::Blend <span class="hljs-comment">// Or ::Mask(0.5), ::Add, etc.</span>
    }
}
</code></pre>
]]></content:encoded></item><item><title><![CDATA[3.1 - Fragment Shader Fundamentals]]></title><description><![CDATA[What We're Learning
Welcome to Phase 3! In Phase 2, we took complete control of our geometry. We mastered the vertex shader, learning to manipulate the shape, position, and animation of every vertex on the GPU. We've answered the questions of "where"...]]></description><link>https://blog.hexbee.net/31-fragment-shader-fundamentals</link><guid isPermaLink="true">https://blog.hexbee.net/31-fragment-shader-fundamentals</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Sat, 20 Dec 2025 15:15:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1763360389233/0b5ad932-b0d8-4424-b9f6-689e8db87b23.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>Welcome to Phase 3! In Phase 2, we took complete control of our geometry. We mastered the vertex shader, learning to manipulate the shape, position, and animation of every vertex on the GPU. We've answered the questions of "where" and "how" our geometry exists in the world. Now, we shift our focus to the most visually impactful question of all: <strong>what does it look like?</strong></p>
<p>This brings us to the second major programmable stage of the graphics pipeline and the heart of this entire phase: the <strong>fragment shader</strong>.</p>
<p>If the vertex shader is the sculptor, shaping the form of our models, the fragment shader is the painter, giving them color, texture, light, and life. Every single pixel you see on your screen - every smooth gradient, every intricate texture, every realistic lighting effect - is the final product of a fragment shader's calculation.</p>
<p>This power comes with a critical shift in scale. Where vertex shaders run once per vertex (perhaps thousands of times per model), fragment shaders run once for nearly every visible pixel on your screen (potentially millions of times per frame). Mastering this stage is not just about creating beautiful visuals; it's also about learning to think efficiently at a massive scale.</p>
<p>This first article lays the groundwork for everything to come. We will demystify what a fragment is, how it gets its data, and how you can write your first fragment shaders to control the color of your creations.</p>
<p>By the end of this article, you'll understand:</p>
<ul>
<li><p>What a fragment is and how it differs from a pixel</p>
</li>
<li><p>The fragment shader entry point and signature</p>
</li>
<li><p>How interpolation brings vertex data to fragments</p>
</li>
<li><p>The fragment shader's output: color values</p>
</li>
<li><p>Screen-space coordinates and built-in variables</p>
</li>
<li><p>The fragment execution model and performance implications</p>
</li>
<li><p>How overdraw impacts performance</p>
</li>
<li><p>Basic fragment shader patterns in Bevy</p>
</li>
<li><p>Building a complete color visualization material</p>
</li>
</ul>
<h2 id="heading-the-rendering-pipeline-recap">The Rendering Pipeline Recap</h2>
<p>Let's quickly revisit where the fragment shader fits into the grand journey of rendering a single frame. Understanding this context is crucial, as the fragment shader's inputs are the direct outputs of the stages that come before it.</p>
<ol>
<li><p>Vertex Shader</p>
<ul>
<li><p>Input: Vertex data from a mesh (position, normal, UV, etc.).</p>
</li>
<li><p>Process: Transforms each vertex's position into clip space. Prepares and passes other data (like normals and UVs) downstream.</p>
</li>
<li><p>Runs: Once per vertex.</p>
</li>
</ul>
</li>
<li><p>Rasterization (A non-programmable, hardware-driven stage)</p>
<ul>
<li><p>Input: The transformed triangles from the vertex shader.</p>
</li>
<li><p>Process: The GPU determines exactly which pixels on the screen each triangle covers. For each covered pixel, it generates a "fragment" and perfectly interpolates the vertex data (color, UVs, etc.) across the triangle's surface to that fragment's specific location.</p>
</li>
<li><p>Runs: A stream of fragments, ready for shading.</p>
</li>
</ul>
</li>
<li><p>Fragment Shader ← WE ARE HERE</p>
<ul>
<li><p>Input: An individual fragment with its interpolated data.</p>
</li>
<li><p>Process: Executes your custom WGSL code to calculate a final color for that fragment. This is where texturing and most lighting calculations happen.</p>
</li>
<li><p>Runs: Once for (almost) every visible pixel of a mesh.</p>
</li>
</ul>
</li>
<li><p>Output Merger (Another non-programmable stage)</p>
<ul>
<li>Process: The final colored fragment is subjected to tests like the depth test (is it behind something else?). If it passes, it may be blended with the pixel already on the screen before finally being written to the framebuffer.</li>
</ul>
</li>
</ol>
<p><strong>The key insight</strong>: Notice the dramatic shift in workload between the stages. The vertex shader might run a few thousand times for a detailed model, but the fragment shader runs for <em>millions</em> of pixels on a standard 1080p screen. A simple quad has only 4 vertices, but if it fills a 500x500 pixel area on screen, the fragment shader will run 250,000 times! This incredible multiplier is why fragment shader performance is one of the most critical aspects of real-time graphics.</p>
<h3 id="heading-understanding-fragments-vs-pixels">Understanding Fragments vs. Pixels</h3>
<p>Before we dive deeper, we need to clarify a crucial piece of terminology. While we often use "fragment" and "pixel" interchangeably, they represent two distinct concepts.</p>
<p>A <strong>Pixel</strong> (short for "picture element") is the final, colored dot you see on your monitor. It has one job: display a single color.</p>
<p>A <strong>Fragment</strong> is a <em>potential pixel</em>. Think of it as a data packet generated by the rasterizer for a single pixel location that a triangle covers. This data packet contains everything needed to <em>calculate</em> a final pixel color, including:</p>
<ul>
<li><p>Its screen position (which pixel it corresponds to).</p>
</li>
<li><p>A set of smoothly interpolated attributes (like UV coordinates, world position, and normals) that it inherited from the triangle's vertices.</p>
</li>
</ul>
<p>A fragment is the <strong>input</strong> to the fragment shader. The shader runs its code, calculates a color, and then the fragment must pass a series of final hardware tests.</p>
<p><code>Fragment → Fragment Shader → Depth Test → Blend → Pixel</code></p>
<p><strong>Not all fragments become pixels!</strong> A fragment is discarded and its color is never written to the screen if:</p>
<ul>
<li><p>It fails the <strong>depth test</strong> (meaning it's hidden behind an object that has already been drawn closer to the camera).</p>
</li>
<li><p>Your shader code explicitly uses the <code>discard</code> keyword (for effects like cut-out transparency).</p>
</li>
<li><p>It is clipped because it lies outside the defined viewport.</p>
</li>
</ul>
<p>For the rest of this article, we'll follow the common convention of using the terms loosely, but it's vital to remember this distinction: fragments are the candidates, and pixels are the winners.</p>
<h2 id="heading-the-fragment-shader-entry-point">The Fragment Shader Entry Point</h2>
<p>Just as vertex shaders are identified by the <code>@vertex</code> attribute, fragment shaders are marked with <code>@fragment</code>. This attribute tells the WGSL compiler that this function is the main entry point for the fragment processing stage.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// This is where all the color calculation magic happens.</span>
    <span class="hljs-comment">// For now, we'll just return a solid color.</span>
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// A solid, opaque red.</span>
}
</code></pre>
<p>Let's break down this essential signature:</p>
<ol>
<li><p><code>@fragment</code>: The attribute that declares this function as the fragment shader's entry point.</p>
</li>
<li><p><code>in: VertexOutput</code>: This is the most common input parameter. It's a struct containing all the data that was output by the vertex shader and then interpolated by the rasterizer. The fields of this struct are our raw materials for determining color.</p>
</li>
<li><p><code>-&gt; @location(0) vec4&lt;f32&gt;</code>: This defines the function's return type.</p>
<ul>
<li><p><code>vec4&lt;f32&gt;</code>: The four-component vector represents the final RGBA (Red, Green, Blue, Alpha) color, with each component typically ranging from 0.0 to 1.0.</p>
</li>
<li><p><code>@location(0)</code>: This specifies that the output color should be written to the first "render target." For now, you can think of this as the main image buffer that will eventually be displayed on the screen.</p>
</li>
</ul>
</li>
</ol>
<h3 id="heading-the-minimal-fragment-shader">The Minimal Fragment Shader</h3>
<p>The absolute simplest fragment shader requires no inputs at all. It just returns a constant color. Every single fragment processed by this shader will receive the exact same color value.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>() -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// A solid, opaque orange.</span>
}
</code></pre>
<p>If you were to apply a material using this shader to a sphere, the entire sphere would appear as a flat, uniformly orange circle on your screen.</p>
<h3 id="heading-function-signature-variations">Function Signature Variations</h3>
<p>The fragment shader's signature is flexible, allowing you to request only the data you need.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Pattern 1: No inputs needed for a solid color.</span>
@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>() -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>);
}

<span class="hljs-comment">// Pattern 2: Using interpolated data from the vertex shader. (Most common)</span>
@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// e.g., using interpolated vertex colors</span>
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.color.rgb, <span class="hljs-number">1.0</span>);
}

<span class="hljs-comment">// Pattern 3: Using built-in hardware variables.</span>
@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(
    @builtin(position) frag_coord: vec4&lt;<span class="hljs-built_in">f32</span>&gt;
) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Color based on pixel's screen coordinates</span>
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(frag_coord.xy / <span class="hljs-number">1000.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
}

<span class="hljs-comment">// Pattern 4: Combining inputs.</span>
@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(
    <span class="hljs-keyword">in</span>: VertexOutput,
    @builtin(position) frag_coord: vec4&lt;<span class="hljs-built_in">f32</span>&gt;
) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// You can request both interpolated and built-in data.</span>
    <span class="hljs-keyword">let</span> color = mix(<span class="hljs-keyword">in</span>.color.rgb, frag_coord.xy / <span class="hljs-number">1000.0</span>, <span class="hljs-number">0.5</span>);
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}

<span class="hljs-comment">// Pattern 5: Multiple Render Targets (Advanced).</span>
<span class="hljs-comment">// You can output different data to different image buffers simultaneously.</span>
<span class="hljs-comment">// This is the foundation of techniques like Deferred Rendering.</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">FragmentOutput</span></span> {
    @location(<span class="hljs-number">0</span>) color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal_data: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; FragmentOutput {
    var out: FragmentOutput;
    out.color = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>);
    out.normal_data = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.normal, <span class="hljs-number">1.0</span>);
    <span class="hljs-keyword">return</span> out;
}
</code></pre>
<p>For the majority of this phase, we will be focusing on <strong>Pattern 2</strong> and <strong>Pattern 4</strong>, as they form the basis of most standard texturing and lighting work.</p>
<h2 id="heading-understanding-interpolation">Understanding Interpolation</h2>
<p><strong>Interpolation</strong> is the automatic process that bridges the gap between per-vertex outputs and per-fragment inputs. The GPU's rasterizer calculates smooth, intermediate values for every fragment across a triangle's surface based on the data at its three corner vertices.</p>
<p>Imagine a triangle with red, green, and blue assigned to its vertices. The fragment shader will receive a smoothly blended color for every fragment inside that triangle, creating a colorful gradient. This process applies to any numerical data passed from the vertex shader, such as UV coordinates, normals, and world positions.</p>
<h3 id="heading-the-data-flow">The Data Flow</h3>
<p>The flow is straightforward: you define a struct to pass data, and the hardware handles the interpolation.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// This struct is output by the vertex shader.</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// Per-vertex value</span>
    @location(<span class="hljs-number">1</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,    <span class="hljs-comment">// Per-vertex value</span>
}

<span class="hljs-comment">// RASTERIZER (Hardware): Magically creates interpolated</span>
<span class="hljs-comment">// versions of VertexOutput for every fragment.</span>

<span class="hljs-comment">// This struct is the input to the fragment shader.</span>
@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// `in.color` and `in.uv` are now smoothly interpolated per-fragment values.</span>
    <span class="hljs-keyword">let</span> texture_color = textureSample(my_texture, my_sampler, <span class="hljs-keyword">in</span>.uv);
    <span class="hljs-keyword">return</span> texture_color;
}
</code></pre>
<p><strong>The key takeaway</strong>: Any field in your <code>VertexOutput</code> decorated with <code>@location</code> is automatically interpolated. You don't write the interpolation code; it's a fundamental feature of the GPU.</p>
<h3 id="heading-interpolation-modes">Interpolation Modes</h3>
<p>You can hint at how the GPU should interpolate using the @interpolate attribute.</p>
<ul>
<li><p><code>perspective</code> (Default): The 3D-correct mode. Use this for colors, UVs, normals - basically everything on a 3D object. You don't need to write it explicitly.</p>
</li>
<li><p><code>linear</code>: Simple 2D screen-space interpolation. Can be useful for UI, but looks wrong in 3D.</p>
</li>
<li><p><code>flat</code>: No interpolation. Creates a faceted, low-poly look. Required for passing integer values.</p>
</li>
</ul>
<h3 id="heading-the-golden-rule-of-normal-interpolation">The Golden Rule of Normal Interpolation</h3>
<p>Interpolation has one critical pitfall: <strong>interpolating a normalized vector does not result in a normalized vector.</strong> The resulting vector will always be shorter than unit-length, which will break your lighting calculations.</p>
<p>The solution must become a reflex:</p>
<p><strong>Always re-normalize your normal vector at the beginning of your fragment shader.</strong></p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// ✓ CORRECT: The first thing we do is re-normalize.</span>
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);

    <span class="hljs-comment">// Now `normal` is safe to use for lighting.</span>
    <span class="hljs-keyword">let</span> diffuse = dot(normal, light_dir);
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<h2 id="heading-fragment-output-color-values">Fragment Output: Color Values</h2>
<p>The primary job of a fragment shader is to calculate and output a single color. This color determines what you see on the screen for that specific pixel. Understanding the format and properties of this output value is key to achieving the look you want.</p>
<h3 id="heading-color-format-rgba">Color Format: RGBA</h3>
<p>Fragment shaders in WGSL output color as a four-component floating-point vector, or <code>vec4&lt;f32&gt;</code>. Each component corresponds to a channel in the RGBA color model:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// The standard output signature</span>
-&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt;

<span class="hljs-comment">// R: Red   (0.0 to 1.0+)</span>
<span class="hljs-comment">// G: Green (0.0 to 1.0+)</span>
<span class="hljs-comment">// B: Blue  (0.0 to 1.0+)</span>
<span class="hljs-comment">// A: Alpha (0.0 = transparent, 1.0 = opaque)</span>
</code></pre>
<p>Here are some common color values:</p>
<pre><code class="lang-plaintext">Black:       vec4&lt;f32&gt;(0.0, 0.0, 0.0, 1.0)
White:       vec4&lt;f32&gt;(1.0, 1.0, 1.0, 1.0)
Red:         vec4&lt;f32&gt;(1.0, 0.0, 0.0, 1.0)
Gray (50%):  vec4&lt;f32&gt;(0.5, 0.5, 0.5, 1.0)
Transparent: vec4&lt;f32&gt;(0.0, 0.0, 0.0, 0.0) // Alpha is 0
Semi-Trans:  vec4&lt;f32&gt;(1.0, 0.0, 0.0, 0.5) // 50% transparent red
</code></pre>
<h3 id="heading-high-dynamic-range-hdr-and-color-values">High Dynamic Range (HDR) and Color Values</h3>
<p>You may have noticed the <code>+</code> in the component range (<code>0.0</code> to <code>1.0+</code>). This is critically important. By default, Bevy uses a <strong>High Dynamic Range (HDR)</strong> rendering pipeline. This means your shader is not limited to outputting colors within the <code>[0, 1]</code> range. It can, and for realistic lighting often should, output much brighter values.</p>
<ul>
<li><p>A standard white surface might be <code>(1.0, 1.0, 1.0)</code>.</p>
</li>
<li><p>A bright light source or a reflection of the sun could be <code>(50.0, 50.0, 50.0)</code>.</p>
</li>
</ul>
<p>These HDR values represent physically-based brightness and are essential for effects like realistic bloom.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A standard white surface color.</span>
<span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>);

<span class="hljs-comment">// An HDR color for a very bright emissive surface.</span>
<span class="hljs-comment">// This value will be preserved for post-processing.</span>
<span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">2.0</span>, <span class="hljs-number">2.0</span>, <span class="hljs-number">1.5</span>, <span class="hljs-number">1.0</span>);

<span class="hljs-comment">// Negative values are physically meaningless for color and should be avoided.</span>
<span class="hljs-comment">// They will likely be clamped to 0 by later stages.</span>
<span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(-<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// Will probably become (0.0, 0.5, 0.5)</span>
</code></pre>
<p>For now, it's fine to work within the <code>[0, 1]</code> range for basic colors. But when we get to lighting, remember that you can return values greater than 1.0 to indicate intense brightness.</p>
<h3 id="heading-color-spaces-srgb-and-tonemapping">Color Spaces, sRGB, and Tonemapping</h3>
<p>Color on computers is a surprisingly complex topic. The most important concept to grasp is that your shader performs its calculations in a <strong>linear</strong> color space, but your monitor displays color in a non-linear space called <strong>sRGB</strong>.</p>
<p>Fortunately, Bevy's rendering pipeline manages the conversion for you. Here's the journey your color takes after leaving the fragment shader:</p>
<ol>
<li><p><strong>Fragment Shader (You are here)</strong>: You output a color in <strong>linear HDR</strong> format. This is where all lighting and blending math should happen, as it is physically accurate.</p>
</li>
<li><p><strong>Post-Processing (Bevy)</strong>: Bevy runs a series of effects on the HDR image. The most important one is <strong>Tonemapping</strong>. This is a smart process that takes the wide range of HDR brightness values (from 0 to 50 or more) and artistically maps them back into the standard <code>[0, 1]</code> range that a display can handle. It does this in a way that preserves detail in both dark shadows and bright highlights, preventing harsh clipping and creating a much more pleasing image.</p>
</li>
<li><p><strong>Final Write (GPU Hardware)</strong>: The tonemapped linear color is sent to the framebuffer. Because Bevy has configured the screen's texture format as sRGB, the GPU hardware automatically applies the correct <strong>gamma correction</strong> during the final write, converting the linear <code>[0, 1]</code> values into the non-linear sRGB values your monitor expects.</p>
</li>
</ol>
<p>Your only responsibility is to do your math correctly in linear space. Bevy and the GPU will handle the rest.</p>
<h3 id="heading-alpha-and-transparency">Alpha and Transparency</h3>
<p>The alpha channel (<code>A</code> in RGBA) controls a fragment's opacity.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Fully opaque (most common)</span>
<span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);

<span class="hljs-comment">// 50% transparent</span>
<span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.5</span>);
</code></pre>
<p><strong>Note</strong>: Enabling transparency is not as simple as just returning an alpha value less than <code>1.0</code>. It requires additional configuration on your <code>Material</code> in Bevy and has significant performance implications (like disabling the Early-Z optimization we'll discuss later). We will dedicate a future article to mastering transparency; for now, we will stick to fully opaque materials where alpha is always <code>1.0</code>.</p>
<h3 id="heading-common-color-operations">Common Color Operations</h3>
<p>Here are some common operations you'll perform on colors. All of these correctly operate in the linear color space of the shader.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Brighten or Darken (Adjust Exposure)</span>
<span class="hljs-comment">// In linear space, multiplication is the correct way to adjust brightness.</span>
<span class="hljs-keyword">let</span> brightened = color * <span class="hljs-number">1.5</span>; <span class="hljs-comment">// 50% brighter</span>
<span class="hljs-keyword">let</span> darkened = color * <span class="hljs-number">0.5</span>;   <span class="hljs-comment">// 50% darker</span>

<span class="hljs-comment">// Blend (Linear Interpolation)</span>
<span class="hljs-comment">// The `mix()` function is the physically accurate way to blend two linear colors.</span>
<span class="hljs-keyword">let</span> blended = mix(color_a, color_b, <span class="hljs-number">0.5</span>); <span class="hljs-comment">// A 50/50 blend</span>

<span class="hljs-comment">// Grayscale (Luminance)</span>
<span class="hljs-comment">// This calculates the perceived brightness of a color using weights</span>
<span class="hljs-comment">// that are correct for converting linear RGB to luminance.</span>
<span class="hljs-keyword">let</span> luminance = dot(color.rgb, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.2126</span>, <span class="hljs-number">0.7152</span>, <span class="hljs-number">0.0722</span>));
<span class="hljs-keyword">let</span> grayscale = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(luminance);

<span class="hljs-comment">// Invert Color</span>
<span class="hljs-comment">// Note: This operation assumes the input is in the [0, 1] range.</span>
<span class="hljs-comment">// Using it on HDR colors greater than 1.0 will produce negative values.</span>
<span class="hljs-keyword">let</span> inverted = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>) - color.rgb;

<span class="hljs-comment">// Clamp to LDR (Low Dynamic Range)</span>
<span class="hljs-comment">// The `saturate()` or `clamp()` functions clamp a value to the `[0, 1]` range.</span>
<span class="hljs-comment">// WARNING: In Bevy's default pipeline, you should AVOID clamping your final output</span>
<span class="hljs-comment">// color. This is a destructive operation that throws away all HDR information.</span>
<span class="hljs-comment">// The Tonemapping pass is designed to handle this conversion gracefully.</span>
<span class="hljs-keyword">let</span> ldr_color = saturate(color.rgb);
</code></pre>
<h4 id="heading-a-note-on-grayscale-and-the-magic-numbers">A Note on Grayscale and the "Magic Numbers"</h4>
<p>The vector <code>vec3&lt;f32&gt;(0.2126, 0.7152, 0.0722)</code> used for the grayscale calculation might seem arbitrary, but it's a precise model of human vision.</p>
<p>The reason for these specific weights is that <strong>the human eye is significantly more sensitive to green light than it is to red or blue light</strong>. If you had three lights of pure red, green, and blue all emitting the same physical amount of energy, the green light would appear much brighter to you.</p>
<p>The numbers are standardized weights from the <a target="_blank" href="https://en.wikipedia.org/wiki/Rec._709"><strong>Rec. 709</strong></a> specification (used for HDTVs and modern monitors) that reflect this sensitivity in linear space:</p>
<ul>
<li><p><strong>Green</strong>: 0.7152 (~71.5%)</p>
</li>
<li><p><strong>Red</strong>: 0.2126 (~21.3%)</p>
</li>
<li><p><strong>Blue</strong>: 0.0722 (~7.2%)</p>
</li>
</ul>
<p>By using these weights in a <code>dot()</code> product, we calculate the color's perceived brightness (luminance) in a way that is consistent with how our eyes work and how our screens are calibrated. Using older, incorrect weights for sRGB space would result in colors that look too dark or have a slight color shift when converted to grayscale.</p>
<h2 id="heading-fragment-shader-built-in-variables">Fragment Shader Built-in Variables</h2>
<p>In addition to the interpolated data you pass from the vertex shader, the fragment shader has access to a special set of read-only input variables and writable output variables called <strong>built-ins</strong>. These are not user-defined data; they are provided directly by the GPU hardware and give you intrinsic information and control over the fragment currently being processed.</p>
<p>Their availability is specific to each shader stage. For example, @builtin(vertex_index) is only available in a vertex shader. Here is the complete list of built-ins you have access to specifically within the <strong>fragment shader stage</strong>.</p>
<h3 id="heading-input-built-ins">Input Built-ins</h3>
<p>These are read-only values that provide information about the fragment.</p>
<h4 id="heading-builtinposition-vec4-fragment-coordinates"><code>@builtin(position): vec4&lt;f32&gt;</code> - Fragment Coordinates</h4>
<p>This is the most fundamental and frequently used built-in. It provides the fragment's coordinates within the context of the screen.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(
    @builtin(position) frag_coord: vec4&lt;<span class="hljs-built_in">f32</span>&gt;
) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// frag_coord.x: The fragment's X position in pixels.</span>
    <span class="hljs-comment">// frag_coord.y: The fragment's Y position in pixels.</span>
    <span class="hljs-comment">// frag_coord.z: The fragment's depth value (0.0 near to 1.0 far).</span>
    <span class="hljs-comment">// frag_coord.w: 1.0 / clip_space_w (used for perspective calculations).</span>

    <span class="hljs-comment">// ...</span>
}
</code></pre>
<h5 id="heading-screen-space-coordinates-explained">Screen-Space Coordinates Explained</h5>
<p>The <code>frag_coord</code> variable lives in <strong>screen space</strong>.</p>
<ul>
<li><p><strong>X and Y</strong>: These are the pixel coordinates. The origin <code>(0, 0)</code> is at the <strong>top-left</strong> corner of the screen. <code>X</code> increases to the right, and <code>Y</code> increases downwards.</p>
</li>
<li><p><strong>Z (Depth)</strong>: This is the fragment's depth, a value between <code>0.0</code> and <code>1.0</code> representing its distance from the camera relative to the near and far clipping planes. <code>0.0</code> is as close as possible, and <code>1.0</code> is as far as possible. This is the value used by the GPU for depth testing.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764881123835/f2ea68a4-1e0d-4d86-aef3-288611773d4b.png" alt class="image--center mx-auto" /></p>
<h5 id="heading-the-dual-meaning-of-builtinposition">The Dual Meaning of <code>@builtin(position)</code></h5>
<p>It is critical to remember that <code>@builtin(position)</code> has a completely different meaning and data format depending on where you use it:</p>
<ul>
<li><p><strong>In a vertex shader (output)</strong>: It represents the vertex's position in <strong>homogeneous clip space</strong>. This is a 4D coordinate that you must calculate and provide to the rasterizer.</p>
</li>
<li><p><strong>In a fragment shader (input)</strong>: It represents the fragment's position in <strong>screen space</strong>. This is a read-only value provided by the hardware, measured in pixels.</p>
</li>
</ul>
<p>Because of this dual meaning, you cannot have <code>@builtin(position)</code> in your vertex shader's output struct and also declare it as an input parameter to your fragment shader. This is a common source of confusion and compiler errors.</p>
<p>The standard Bevy pattern to resolve this is to use <strong>two separate structs</strong>: one for the vertex shader's output and a different one for the fragment shader's input.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// VERTEX shader returns this struct</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-comment">// FRAGMENT shader receives this struct</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">FragmentInput</span></span> {
    @location(<span class="hljs-number">0</span>) color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(
    <span class="hljs-keyword">in</span>: FragmentInput,                       <span class="hljs-comment">// Interpolated data</span>
    @builtin(position) frag_coord: vec4&lt;<span class="hljs-built_in">f32</span>&gt; <span class="hljs-comment">// Screen-space built-in</span>
) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Now you have access to both!</span>
}
</code></pre>
<h4 id="heading-builtinfrontfacing-bool-face-orientation"><code>@builtin(front_facing): bool</code> - Face Orientation</h4>
<p>This boolean tells you if the current fragment belongs to a triangle that is facing the camera.</p>
<ul>
<li><p><code>true</code>: The triangle is "front-facing" (its vertices are in counter-clockwise order on screen).</p>
</li>
<li><p><code>false</code>: The triangle is "back-facing" (its vertices are in clockwise order).</p>
</li>
</ul>
<p>By default, Bevy enables <strong>back-face culling</strong>, an optimization that automatically discards all back-facing triangles. In this default state, <code>@builtin(front_facing)</code> will always be <code>true</code>. However, if you disable culling (a material setting), you can render both sides of a mesh. This is where <code>front_facing</code> becomes essential.</p>
<p><strong>Common Use Cases:</strong></p>
<ul>
<li><p><strong>Two-Sided Materials</strong>: Imagine a sheet of paper, a flag, or a playing card. You need to render both sides, but perhaps with different colors or textures. You can use <code>front_facing</code> to decide which texture to apply.</p>
</li>
<li><p><strong>Refraction (Glass/Water)</strong>: To properly simulate light bending as it enters and exits a material, you need to process both the front surfaces (where light enters) and the back surfaces (where light exits).</p>
</li>
<li><p><strong>Debugging</strong>: Visualizing front and back faces with different colors is a great way to find geometry issues, like "flipped normals."</p>
</li>
</ul>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(
    @builtin(front_facing) is_front: <span class="hljs-built_in">bool</span>,
) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// `select` is a branchless way to choose between two values.</span>
    <span class="hljs-comment">// select(value_if_false, value_if_true, condition)</span>
    <span class="hljs-keyword">let</span> color = select(
        vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>), <span class="hljs-comment">// Red if false (back)</span>
        vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>), <span class="hljs-comment">// Green if true (front)</span>
        is_front,
    );

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h4 id="heading-builtinprimitiveindex-u32-triangle-index"><code>@builtin(primitive_index): u32</code> - Triangle Index</h4>
<p>This is a debugging tool that provides the index of the current primitive (triangle or line) within the current draw call. It's not typically used for visual effects but is invaluable for visualizing meshes.</p>
<p><strong>Use Case</strong>: Creating a "clown vomit" or "mesh ID" visualization where every triangle gets a unique color, making it easy to see the mesh topology.</p>
<h4 id="heading-builtinsampleindex-u32-msaa-sample-index"><code>@builtin(sample_index): u32</code> - MSAA Sample Index</h4>
<p>This input relates to <strong>Multisample Anti-Aliasing (MSAA)</strong>. When MSAA is enabled, the GPU tests coverage at multiple "sample" locations within each pixel. This <code>u32</code> tells you which specific sub-pixel sample (e.g., 0, 1, 2, or 3 for 4x MSAA) the shader is currently processing.</p>
<p><strong>Use Case</strong>: This is <strong>very rarely used</strong>. It requires enabling a special, performance-intensive mode called "sample-rate shading," where the fragment shader runs for every single sub-pixel. This is reserved for extremely high-end, custom rendering techniques.</p>
<h4 id="heading-builtinclipdistance-array-custom-clipping-plane-distance"><code>@builtin(clip_distance): array&lt;f32&gt;</code> - Custom Clipping Plane Distance</h4>
<p>This is an array of floating-point values that is passed from the vertex shader. In the vertex shader, you would calculate the signed distance of a vertex from a custom plane. The rasterizer then interpolates these distances, and the GPU hardware will automatically discard any fragments where the interpolated distance is negative.</p>
<p><strong>Use Case</strong>: Slicing through objects, creating cross-section views, or rendering reflections on a perfectly flat plane like water.</p>
<h3 id="heading-output-built-ins">Output Built-ins</h3>
<p>These are variables your fragment shader can write to, which will affect later stages of the rendering pipeline. To use them, you add them to the return signature of your fragment shader function.</p>
<h4 id="heading-builtinfragdepth-f32-custom-depth-output"><code>@builtin(frag_depth)</code>: f32 - Custom Depth Output</h4>
<p>By default, the depth (<code>frag_coord.z</code>) of a fragment is interpolated by the hardware. This output allows you to <strong>override</strong> that value with a custom one.</p>
<p><strong>Use Case</strong>: This is essential for techniques that don't rely on standard rasterized geometry, such as raymarching SDFs (Signed Distance Fields) within a shader, or for advanced decal rendering systems.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// The output is added to the function's return signature</span>
@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: FragmentInput) -&gt; (@location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt;, @builtin(frag_depth) <span class="hljs-built_in">f32</span>) {
    <span class="hljs-keyword">let</span> color = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
    <span class="hljs-keyword">let</span> custom_depth = <span class="hljs-number">0.25</span>; <span class="hljs-comment">// A custom depth value</span>
    <span class="hljs-keyword">return</span> (color, custom_depth);
}
</code></pre>
<p><strong>CRITICAL PERFORMANCE WARNING:</strong> Writing to <code>@builtin(frag_depth)</code> <strong>disables the Early-Z optimization</strong>. The GPU cannot know a fragment's final depth until after the shader has finished executing, so it cannot discard hidden fragments early. This can have a severe negative impact on performance. Use this feature only when absolutely necessary.</p>
<h4 id="heading-builtinsamplemask-u32-msaa-sample-mask"><code>@builtin(sample_mask): u32</code> - MSAA Sample Mask</h4>
<p>This <code>u32</code> acts as a bitmask where each bit corresponds to a sub-pixel sample (e.g., 4 bits for 4x MSAA). While it can be an input, its power comes from being an output. You can programmatically decide which of the sub-pixel samples should be written to.</p>
<p><strong>Use Case</strong>: Implementing "Alpha to Coverage." This is a technique for creating dithered, non-binary transparency for things like foliage. Instead of blending, you can use the alpha value to mask out a proportional number of samples, creating a stippled transparency effect that interacts correctly with the depth buffer.</p>
<h2 id="heading-the-fragment-execution-model">The Fragment Execution Model</h2>
<p>Understanding how and when your fragment shader code runs is the single most important factor in managing rendering performance. A small inefficiency in your shader is magnified millions of times, and can easily be the difference between a smooth 60 FPS and a stuttering slideshow.</p>
<h3 id="heading-the-multiplier-effect-from-vertices-to-fragments">The Multiplier Effect: From Vertices to Fragments</h3>
<p>The core concept is simple: your fragment shader is executed for (almost) every single pixel that your mesh covers on screen.</p>
<p>Consider a moderately detailed 3D model with 10,000 vertices.</p>
<ul>
<li>The <strong>vertex shader</strong> will run <strong>10,000</strong> times.</li>
</ul>
<p>Now, imagine that model is close to the camera on a 1920x1080 screen and covers a 500x500 pixel area.</p>
<ul>
<li>The <strong>fragment shader</strong> will run approximately <strong>250,000</strong> times.</li>
</ul>
<p>That's a 25x multiplier. If the object fills the whole screen, the fragment shader could run over <strong>2,000,000</strong> times, a 200x multiplier over the vertex shader. This massive execution count is why fragment shaders are frequently the main performance bottleneck in a frame.</p>
<h3 id="heading-early-depth-testing-earlyz">Early Depth Testing (EarlyZ)</h3>
<p>Fragment shaders are the most expensive part of the rendering pipeline because they can run millions of times per frame. To combat this, GPUs have a critical optimization called Early Depth Testing (or Early-Z). The entire goal of Early-Z is to <strong>avoid running the fragment shader for pixels that are hidden behind other objects</strong>.</p>
<p>To understand this, you first need a clear picture of the <strong>Depth Buffer</strong>.</p>
<h4 id="heading-what-is-the-depth-buffer">What is the Depth Buffer?</h4>
<p>Think of the depth buffer (or z-buffer) as a grayscale image that sits alongside the final color image. Instead of storing color, each of its pixels stores a single number (usually from 0.0 to 1.0) representing the depth of the closest object rendered so far at that pixel location.</p>
<ul>
<li><p><code>0.0</code> = At the camera's near plane (very close)</p>
</li>
<li><p><code>1.0</code> = At the camera's far plane (very far)</p>
</li>
</ul>
<h4 id="heading-how-early-z-works-a-step-by-step-example">How Early-Z Works: A Step-by-Step Example</h4>
<p>Imagine a scene where your player character is standing in front of a brick wall. For optimal performance, Bevy will try to draw opaque objects from front to back.</p>
<ol>
<li><p><strong>Draw the Player First</strong>: The GPU starts rasterizing the player model's triangles. For each potential pixel:</p>
<ul>
<li><p>It calculates the fragment's depth (e.g., <code>0.2</code>).</p>
</li>
<li><p>It looks at the depth buffer. The buffer is currently empty (or has the default "infinitely far" value).</p>
</li>
<li><p>The depth test passes (<code>0.2</code> is closer).</p>
</li>
<li><p>The GPU runs the <strong>player's fragment shader</strong> to get a color.</p>
</li>
<li><p>It writes the player's color to the color buffer and <code>0.2</code> to the depth buffer.</p>
</li>
</ul>
</li>
<li><p><strong>Draw the Wall Second</strong>: Now the GPU starts rasterizing the wall's triangles. For a fragment on the wall that is behind the player:</p>
<ul>
<li><p>It calculates the fragment's depth (e.g., <code>0.5</code>).</p>
</li>
<li><p>It looks at the depth buffer. The value at this pixel is now <code>0.2</code> (from the player).</p>
</li>
<li><p>The depth test fails (<code>0.5</code> is further away than <code>0.2</code>).</p>
</li>
<li><p>Because the test failed early, the GPU <strong>immediately discards the wall fragment</strong>.</p>
</li>
<li><p><strong>The expensive wall fragment shader is never executed for this pixel</strong>.</p>
</li>
</ul>
</li>
</ol>
<p>This is a massive performance save. By drawing the scene front-to-back, you avoid running costly fragment shaders for millions of pixels that would just be painted over anyway.</p>
<h4 id="heading-when-early-z-is-disabled-the-gpus-dilemma">When Early-Z is Disabled: The GPU's Dilemma</h4>
<p>This "early" test can only work if the GPU knows a fragment's final depth and knows for sure that the fragment will be opaque before running the shader. Certain shader operations make this impossible, forcing the GPU to revert to the old, slow method of running the shader first and doing a "late" depth test afterward.</p>
<p>Here are the main reasons Early-Z gets disabled:</p>
<ul>
<li><p><strong>Manually Writing to Depth (</strong><code>@builtin(frag_depth)</code>): If your fragment shader contains code that modifies the fragment's depth, the GPU can't know the final depth value until after the shader has finished executing. The "early" test is impossible because the necessary information isn't available early enough.</p>
</li>
<li><p><strong>Using</strong> <code>discard</code>: The <code>discard</code> keyword tells the GPU to throw the fragment away completely. This is often used for "cutout" transparency, like a chain-link fence where you throw away the fragments in the holes. The GPU cannot know if a fragment will be discarded until it runs the shader and hits that <code>if</code> statement. If it discarded the fragment early based on depth, it might be wrong (e.g., discarding a closer fence fragment that should have been a hole, preventing a character behind it from being seen).</p>
</li>
<li><p><strong>Alpha Blending (Transparency)</strong>: Alpha blending requires mixing the fragment's color with the color of whatever is already in the framebuffer behind it. If the GPU performed an early depth test and discarded a farther object, there would be no color to blend with! The GPU <strong>must</strong> run the fragment shaders for both the foreground and background objects and then blend their results, making overdraw unavoidable for transparent surfaces. This is why transparency is so expensive.</p>
</li>
</ul>
<h4 id="heading-practical-advice-for-keeping-early-z-active">Practical Advice for Keeping Early-Z Active:</h4>
<ul>
<li><p><strong>Draw Opaque Geometry First</strong>: Let Bevy's renderer sort opaque objects from front-to-back so the depth buffer can be filled effectively.</p>
</li>
<li><p><strong>Avoid Transparency When Possible</strong>: An opaque material will always be faster than a transparent one. If something doesn't need to be see-through, don't use alpha.</p>
</li>
<li><p><strong>Prefer Alpha Cutouts (</strong><code>discard</code>) over Alpha Blending for Foliage: For things like leaves, using <code>discard</code> is often better than semi-transparent blending, as parts of the object can still benefit from Early-Z.</p>
</li>
<li><p><strong>Use a Depth Pre-Pass</strong>: This is an advanced technique where you render all your opaque geometry once in a very fast pass that only writes to the depth buffer. Then, you render the geometry a second time with the full, expensive fragment shaders. In this second pass, Early-Z will be extremely effective at culling hidden pixels.</p>
</li>
</ul>
<h3 id="heading-fragment-shader-execution-groups-the-2x2-quad">Fragment Shader Execution Groups: The 2x2 Quad</h3>
<p>Just like vertex shaders, fragment shaders are executed in large parallel groups. However, there's a special, smaller unit of work that is fundamental to how they operate: the <strong>2x2 pixel quad</strong>.</p>
<p>A GPU processes fragments in blocks of four, arranged in a 2x2 square on the screen. Even if a triangle only covers a single pixel, the GPU will likely activate a full 2x2 quad to process it.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764416790515/aad7c2f4-55d2-44f3-a12a-2acc30f2b5b1.png" alt class="image--center mx-auto" /></p>
<h4 id="heading-why-a-2x2-block-the-answer-is-mipmapping-and-derivatives">Why a 2x2 block? The answer is Mipmapping and Derivatives.</h4>
<p>This might seem inefficient, but it enables one of the most important features of texturing: <strong>automatic mipmap level selection</strong>.</p>
<ol>
<li><p><strong>The Problem</strong>: When you sample a texture, how does the GPU know whether to use the high-resolution original (<code>mip 0</code>), a blurry medium-resolution version (<code>mip 4</code>), or a tiny 1x1 pixel version (<code>mip 10</code>)? Using a high-res texture on a faraway object causes shimmering and aliasing, while using a low-res texture up close looks blurry and pixelated. The GPU needs to pick the perfect mip level.</p>
</li>
<li><p><strong>The Solution</strong>: To pick the right mip level, the GPU needs to know how "stretched" or "squished" the texture is on the screen for a given fragment. It answers the question: "How much are the texture's UV coordinates changing as I move from this pixel to its immediate neighbors?"</p>
</li>
<li><p><strong>This "rate of change" is called a derivative</strong>. The GPU calculates it by comparing the UV coordinates of the fragments within the 2x2 quad.</p>
<ul>
<li><p>By comparing the UVs of the left and right fragments in the quad, it can calculate the rate of change in the X direction (<code>ddx</code>).</p>
</li>
<li><p>By comparing the UVs of the top and bottom fragments, it can calculate the rate of change in the Y direction (<code>ddy</code>).</p>
</li>
</ul>
</li>
</ol>
<p>Because all four fragments in the quad are processed together, they can share their data, allowing the GPU to instantly compute these derivatives for every fragment. This information tells the GPU exactly how large the texture's "footprint" is on the screen, which allows it to select the perfect mipmap level.</p>
<h4 id="heading-practical-implications-of-quad-execution">Practical Implications of Quad Execution</h4>
<p>Understanding this has a few important consequences:</p>
<ul>
<li><p><strong>Automatic Mipmapping Just Works</strong>: This is the big win. When you use <code>textureSample()</code>, the GPU uses the quad to calculate derivatives implicitly, giving you high-quality, alias-free texturing for free. This is why you don't have to specify a mip level manually most of the time.</p>
</li>
<li><p><strong>Inefficiency with Tiny Triangles</strong>: If you have a mesh that is so far away that its triangles only cover one or two pixels on screen, the GPU still has to launch a full 2x2 quad for each one. This means 2-3 of the fragment shader invocations in the quad are "wasted work" - they run, realize they are outside the triangle, and are discarded. This is known as <strong>quad overshading</strong> and can be a performance issue when rendering complex geometry from a great distance.</p>
</li>
<li><p><strong>Divergence is Still a Problem</strong>: All fragments in a quad (and usually a larger group called a "wave" or "warp") execute the same code path in lockstep. If you have an if statement that depends on frag_coord (the pixel's position), it's very likely that some fragments in a quad will take one path and others will take the second. This <strong>divergence</strong> forces the hardware to execute both code paths, which is slower than if all fragments had taken the same path.</p>
</li>
</ul>
<h3 id="heading-the-cost-of-fragment-shaders">The Cost of Fragment Shaders</h3>
<p>We've established that fragment shaders run for almost every pixel on the screen, potentially millions of times per frame. This massive execution count means they are nearly always the most performance-critical part of the rendering pipeline. While vertex shaders might consume 5-15% of your frame's GPU time, it's common for fragment shaders to be responsible for <strong>60-80% or more</strong>.</p>
<p>Even a tiny, seemingly insignificant operation in a fragment shader is magnified millions of times. A single extra instruction that takes one nanosecond to execute can add milliseconds to your total frame time. Therefore, understanding what makes a fragment shader expensive is crucial.</p>
<p>GPU performance is typically limited by one of two factors:</p>
<ol>
<li><p><strong>ALU / Compute Bound</strong>: The shader is bottlenecked by the raw number of mathematical calculations it has to perform. The GPU's Arithmetic Logic Units (ALUs) are running at 100%, and the shader is limited by its "thinking time."</p>
</li>
<li><p><strong>Memory / Bandwidth Bound</strong>: The shader is bottlenecked by how quickly it can fetch data (primarily textures) from VRAM. The GPU is spending most of its time "waiting for data" rather than calculating.</p>
</li>
</ol>
<p>A slow shader is often suffering from one or both of these problems.</p>
<h4 id="heading-what-makes-a-shader-compute-bound-too-much-math">What Makes a Shader Compute-Bound? (Too Much Math)</h4>
<p>Some math operations are much more expensive than others. A simple addition or multiplication might take a single cycle, but more complex functions can take many.</p>
<ul>
<li><p><strong>Procedural Calculations</strong>: Generating noise (like Perlin or Simplex), fractals, or complex patterns in the shader involves a lot of math and is a classic example of a compute-heavy task.</p>
</li>
<li><p><strong>Complex Lighting Models</strong>: Physically-based lighting often involves many complex calculations, including <code>pow</code>, <code>sqrt</code>, and <code>saturate</code>, for every single light source.</p>
</li>
<li><p><strong>Heavy Use of Expensive Functions</strong>: Trigonometry (<code>sin</code>, <code>cos</code>), transcendentals (<code>pow</code>, <code>exp</code>, <code>log</code>), and square roots (<code>sqrt</code>) are significantly more costly than basic arithmetic. A shader with many of these can easily become compute-bound.</p>
</li>
<li><p><strong>Long Loops</strong>: A <code>for</code> loop that runs many times per fragment multiplies the cost of all the instructions inside it.</p>
</li>
</ul>
<h4 id="heading-what-makes-a-shader-memory-bound-too-many-texture-reads">What Makes a Shader Memory-Bound? (Too Many Texture Reads)</h4>
<p>Every time your shader calls <code>textureSample()</code>, the GPU has to fetch data from memory. While GPUs have very fast caches to help with this, it's still one of the slowest things a shader can do.</p>
<ul>
<li><p><strong>Multiple Texture Samples</strong>: A material that samples from a color texture, a normal map, a roughness map, and a metalness map is performing four separate memory fetches for every single pixel. This can quickly saturate the GPU's memory bandwidth.</p>
</li>
<li><p><strong>Dependent Texture Reads</strong>: This is a worst-case scenario where the result of one texture sample is used to calculate the UV coordinates for a second texture sample. This prevents the GPU from fetching data in parallel and can cause a significant stall as it waits for the first read to complete before it can even begin the second.</p>
</li>
<li><p><strong>Large, Uncompressed Textures</strong>: Using large, unoptimized textures can strain the memory bus and lead to cache misses, slowing down fetches.</p>
</li>
</ul>
<h4 id="heading-an-intuitive-cost-hierarchy">An Intuitive Cost Hierarchy</h4>
<p>While exact costs vary by GPU, a good mental model for the relative cost of operations is:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764880676491/4130a57d-7610-475c-b896-5df389c59d81.png" alt class="image--center mx-auto" /></p>
<h4 id="heading-the-final-multiplier-overdraw">The Final Multiplier: Overdraw</h4>
<p>Remember that <strong>overdraw multiplies the cost of everything</strong>. If you have a complex, memory-bound shader that samples 5 textures, and it's running on an object with an average of 3x overdraw, you are effectively performing <strong>15 texture samples</strong> for that screen pixel. This is why controlling draw order and transparency is just as important as optimizing the shader code itself.</p>
<h2 id="heading-understanding-overdraw-and-fragment-load">Understanding Overdraw and Fragment Load</h2>
<p>Overdraw is one of the most significant performance killers in fragment-heavy scenes. It occurs whenever the GPU is forced to run a fragment shader for a pixel that will ultimately be covered up by another object drawn later in the same frame. It is, quite simply, <strong>wasted work</strong>.</p>
<h3 id="heading-what-is-overdraw">What is Overdraw?</h3>
<p>Imagine a scene with overlapping objects drawn in a "back-to-front" order: a skybox, a mountain range, and a tree.</p>
<ol>
<li><p><strong>Draw Skybox</strong>: The GPU shades every single pixel on the screen blue.</p>
</li>
<li><p><strong>Draw Mountains</strong>: The GPU shades all the pixels for the mountains, painting over the skybox pixels that are behind them. The work done on the sky pixels now covered by the mountains was completely wasted.</p>
</li>
<li><p><strong>Draw Tree</strong>: The GPU shades all the pixels for the tree, painting over the mountain pixels behind it. Again, the work on those mountain pixels was wasted.</p>
</li>
</ol>
<p>In the areas where the tree overlaps the mountain, which overlaps the sky, the fragment shader has been run three times for the same final pixel. This is called <strong>3x overdraw</strong>. An average overdraw of 2x means that, on average, every pixel on your screen is being shaded twice. High overdraw (5x or more) can bring even powerful GPUs to their knees.</p>
<h3 id="heading-measuring-overdraw">Measuring Overdraw</h3>
<p>Identifying where overdraw is happening is the first step to fixing it.</p>
<ul>
<li><strong>DIY Visualization</strong>: A simple trick is to create a special material that is semi-transparent and uses additive blending. When applied to your whole scene, areas with more overdraw will accumulate more light and appear brighter, creating a "heat map" of your scene's fragment cost.</li>
</ul>
<pre><code class="lang-rust"><span class="hljs-comment">// In a temporary debug material:</span>
@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>() -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Return a dim, constant color.</span>
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.1</span>, <span class="hljs-number">0.1</span>, <span class="hljs-number">0.1</span>, <span class="hljs-number">1.0</span>);
}
</code></pre>
<ul>
<li><p>You would then configure this material in Bevy to use <code>AlphaMode::Add</code>. Brighter areas in the resulting image indicate higher overdraw.</p>
</li>
<li><p><strong>Professional Tools (The Right Way)</strong>: The industry-standard approach is to use a graphics debugger like <a target="_blank" href="https://renderdoc.org/"><strong>RenderDoc</strong></a>. You can launch your Bevy application with RenderDoc, capture a single frame, and then use its built-in visualization modes. RenderDoc has an explicit "Overdraw" view that shows you exactly how many times each pixel is being shaded, letting you pinpoint problematic areas with ease.</p>
</li>
</ul>
<h3 id="heading-common-causes-of-overdraw">Common Causes of Overdraw</h3>
<ol>
<li><p><strong>Inefficient Draw Order</strong>: Drawing opaque objects from back-to-front is the worst-case scenario. It completely defeats the Early-Z optimization. (Fortunately, Bevy's default renderer sorts opaque objects front-to-back to help prevent this).</p>
</li>
<li><p><strong>Transparent Surfaces</strong>: Transparency is the biggest enemy of overdraw. Because transparent objects must be blended with what's behind them, the GPU must run the fragment shader for both the transparent surface and the object behind it. Early-Z is effectively disabled for these interactions. A scene with many large, overlapping transparent surfaces (windows, particle effects, foliage) will inherently have high overdraw.</p>
</li>
<li><p><strong>Dense Geometry and Small Triangles</strong>: Particle effects with large, overlapping billboards, or dense foliage where many leaves cover the same few pixels, are major sources of overdraw. Likewise, rendering a complex mesh from a great distance results in many tiny triangles covering the same pixel, forcing many quads to be processed for a small area.</p>
</li>
<li><p><strong>UI Elements</strong>: A complex user interface with many overlapping panels and elements is, by definition, a high-overdraw situation.</p>
</li>
</ol>
<h3 id="heading-reducing-overdraw">Reducing Overdraw</h3>
<ol>
<li><p><strong>Strategy 1: Rely on Front-to-Back Sorting for Opaque Objects</strong>: This is the most critical strategy, and Bevy's PBR renderer handles it for you by default for opaque meshes. By drawing the closest objects first, you fill the depth buffer with "near" values, allowing Early-Z to be maximally effective.</p>
</li>
<li><p><strong>Strategy 2: Use Alpha Cutouts Instead of Blending</strong>: If an effect can be achieved with a "cutout" shader (using the discard keyword) instead of semi-transparent blending, it's often a performance win. The discarded fragments don't need to be blended and can still benefit from depth testing.</p>
</li>
<li><p><strong>Strategy 3: Use a Depth Pre-Pass</strong>: This is a powerful technique for complex scenes. It works in two stages:</p>
<ul>
<li><p><strong>Pass 1 (Depth Only)</strong>: Render all opaque geometry with an extremely simple, fast vertex shader and no fragment shader at all, only writing depth values to the depth buffer.</p>
</li>
<li><p><strong>Pass 2 (Shading)</strong>: Render all the opaque geometry again, but this time with the full, expensive fragment shaders. Because the depth buffer is already perfectly filled from the first pass, Early-Z can now discard every single hidden fragment with near-100% efficiency. This trades more draw calls for a massive reduction in fragment shader work.</p>
</li>
</ul>
</li>
<li><p><strong>Strategy 4: Occlusion Culling</strong>: Bevy automatically performs Frustum Culling (don't draw objects outside the camera's view). Occlusion Culling goes a step further and doesn't draw objects that are inside the view but are completely hidden behind other objects (e.g., a character in another room behind a wall). Bevy does not have this built-in, but plugins are available for this advanced technique.</p>
</li>
</ol>
<h3 id="heading-the-vicious-multiplier-how-overdraw-amplifies-shader-cost">The Vicious Multiplier: How Overdraw Amplifies Shader Cost</h3>
<p>Finally, it's crucial to understand that overdraw and shader complexity multiply each other's performance impact.</p>
<p>Consider a moderately expensive shader that samples 4 textures.</p>
<ul>
<li><p>With <strong>1x</strong> overdraw (the ideal), you perform <strong>4</strong> texture samples per final pixel.</p>
</li>
<li><p>With <strong>5x</strong> overdraw, you are now performing <strong>20</strong> texture samples for that same final pixel.</p>
</li>
</ul>
<p>This is why a shader that runs at a smooth 60 FPS in isolation can drag a complex scene down to 20 FPS. You must optimize <strong>both</strong> the shader's intrinsic cost (reducing math and texture fetches) <strong>and</strong> the scene's overdraw.</p>
<h2 id="heading-basic-fragment-shader-structure-in-bevy">Basic Fragment Shader Structure in Bevy</h2>
<p>Now that we understand the theory, let's look at how a fragment shader is practically integrated into a custom Bevy <code>Material</code>. The structure is a direct mirror of the vertex shader integration we've already seen.</p>
<h3 id="heading-the-minimal-bevy-material">The Minimal Bevy Material</h3>
<p>This is the simplest possible custom material. It defines a single color in Rust, passes it to the GPU as a uniform, and the fragment shader simply reads that uniform and outputs it.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// src/materials/simple_color.rs</span>
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">SimpleColorMaterial</span></span> {
    <span class="hljs-comment">// Bevy will upload the value of this field to the GPU.</span>
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> color: LinearRgba,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> SimpleColorMaterial {
    <span class="hljs-comment">// This material only needs a fragment shader. Bevy will use</span>
    <span class="hljs-comment">// its default vertex shader to handle transformations.</span>
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/simple_color.wgsl"</span>.into()
    }
}
</code></pre>
<pre><code class="lang-rust"><span class="hljs-comment">// assets/shaders/simple_color.wgsl</span>

<span class="hljs-comment">// The WGSL struct must match the layout of the Rust struct.</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">SimpleColorMaterial</span></span> {
    color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-comment">// Access the uniform data at the binding specified in Rust.</span>
<span class="hljs-comment">// Materials in Bevy are typically placed in bind group 2.</span>
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: SimpleColorMaterial;

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>() -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Read the color from the uniform and return it.</span>
    <span class="hljs-keyword">return</span> material.color;
}
</code></pre>
<p>Key components of this pattern:</p>
<ol>
<li><p><strong>The Rust</strong> <code>Material</code> struct: This holds the data you want to control from the CPU (in this case, color). The <code>AsBindGroup</code> derive macro handles the work of preparing this data for the GPU.</p>
</li>
<li><p><strong>The</strong> <code>#[uniform(0)]</code> attribute: This tells Bevy that this field should be part of a uniform buffer at binding <code>0</code> within the material's bind group.</p>
</li>
<li><p><strong>The</strong> <code>Material</code> trait: The implementation of this trait tells Bevy's renderer which shader file(s) to use. If <code>vertex_shader()</code> is not specified, a default one is used.</p>
</li>
<li><p><strong>The WGSL uniform block</strong>: The struct in WGSL and the <code>@group(2) @binding(0)</code> declaration provide the shader with access to the data uploaded from Rust.</p>
</li>
</ol>
<h3 id="heading-accepting-interpolated-input">Accepting Interpolated Input</h3>
<p>Most fragment shaders need data from the vertex shader. To do this, you must provide both a vertex and a fragment shader in your <code>Material</code> implementation.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// in the material's .rs file</span>
<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> InterpolatedMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/interpolated.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/interpolated.wgsl"</span>.into()
    }
}
</code></pre>
<p>The shader file then contains both entry points. The <code>VertexOutput</code> struct acts as the bridge, packaging the data that the rasterizer will interpolate.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// assets/shaders/interpolated.wgsl</span>
#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

<span class="hljs-comment">// ... Uniforms and VertexInput ...</span>

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;
    <span class="hljs-comment">// ... calculate clip_position and world_normal ...</span>
    out.world_normal = mesh_functions::mesh_normal_local_to_world(
        <span class="hljs-keyword">in</span>.normal,
        <span class="hljs-keyword">in</span>.instance_index
    );
    <span class="hljs-keyword">return</span> out;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// `in.world_normal` is the interpolated value from the vertex stage.</span>
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);

    <span class="hljs-comment">// Use the interpolated normal to calculate a simple lighting value.</span>
    <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.0</span>, dot(normal, light_dir));

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(diffuse), <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-the-standard-material-pattern">The Standard Material Pattern</h3>
<p>For organization and to ensure correct memory layout, it's a common best practice to separate your uniform data into its own dedicated struct.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In a `uniforms` module within your material's .rs file</span>

<span class="hljs-meta">#[derive(ShaderType, Debug, Clone, Copy, Default)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">MyMaterialUniforms</span></span> {
    <span class="hljs-keyword">pub</span> color: LinearRgba,
    <span class="hljs-keyword">pub</span> intensity: <span class="hljs-built_in">f32</span>,
    <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
    <span class="hljs-comment">// ... other fields</span>
}

<span class="hljs-comment">// In the main material .rs file</span>

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">MyMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: MyMaterialUniforms, <span class="hljs-comment">// A single field holding the struct</span>
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> MyMaterial {
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<p>This pattern, which uses the <code>ShaderType</code> derive to guarantee alignment, keeps your code clean, organized, and robust against common GPU memory layout issues.</p>
<hr />
<h2 id="heading-complete-example-color-visualization-material">Complete Example: Color Visualization Material</h2>
<p>It's time to put all this theory into practice. We will build a complete, interactive Bevy application with a custom "debug" material. This material will allow us to visualize all the different kinds of data a fragment shader has access to, from interpolated vertex attributes like normals and UVs to built-in hardware variables like screen position and depth.</p>
<h3 id="heading-our-goal">Our Goal</h3>
<p>We will create a scene with several 3D primitives (a sphere, a cube, and a torus). A single, versatile <code>ColorVisualizationMaterial</code> will be applied to all of them. By pressing keys, we will be able to cycle through different "display modes" in the fragment shader, changing what data is being used to generate the final color.</p>
<h3 id="heading-what-this-project-demonstrates">What This Project Demonstrates</h3>
<ul>
<li><p><strong>Passing Data</strong>: How to correctly pass various attributes (<code>world_position</code>, <code>world_normal</code>, <code>local_position</code>, <code>uv</code>) from the vertex shader.</p>
</li>
<li><p><strong>Interpolation in Action</strong>: We'll see how smoothly interpolated values create gradients across curved and flat surfaces.</p>
</li>
<li><p><strong>Visualizing Built-ins</strong>: We'll render colors based on screen position (<code>@builtin(position)</code>), depth (<code>frag_coord.z</code>), and face orientation (<code>@builtin(front_facing)</code>).</p>
</li>
<li><p><strong>Uniform-Based Branching</strong>: A safe and efficient <code>if/else if</code> chain in the fragment shader, controlled by a uniform, will be used to switch between visualization modes.</p>
</li>
<li><p><strong>The Two-Struct Pattern</strong>: We will correctly use separate <code>VertexOutput</code> and <code>FragmentInput</code> structs to access both interpolated data and <code>@builtin(position)</code>.</p>
</li>
</ul>
<h3 id="heading-the-shader-assetsshadersd0301colorvisualizationwgsl">The Shader (<code>assets/shaders/d03_01_color_visualization.wgsl</code>)</h3>
<p>This single WGSL file contains both our vertex and fragment shaders. The vertex shader's job is straightforward: it performs standard transformations and passes four key attributes (<code>world_position</code>, <code>world_normal</code>, <code>local_position</code>, and <code>uv</code>) to the next stage.</p>
<p>The fragment shader is where the real logic lives. It receives the interpolated data in a <code>FragmentInput</code> struct and also takes <code>frag_coord</code> and <code>is_front</code> as built-in parameters. The core of the shader is a large <code>if/else if</code> block that checks the <code>material.display_mode</code> uniform to decide which visualization to render. Each block takes a different piece of data and maps its values into a visible RGB color.</p>
<pre><code class="lang-rust">#import bevy_pbr::{
    mesh_functions,
    view_transformations::position_world_to_clip,
}

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">ColorVisualizationMaterial</span></span> {
    display_mode: <span class="hljs-built_in">u32</span>,  <span class="hljs-comment">// Which property to visualize</span>
    time: <span class="hljs-built_in">f32</span>,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: ColorVisualizationMaterial;

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) world_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) local_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">3</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">FragmentInput</span></span> {
    @location(<span class="hljs-number">0</span>) world_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) local_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">3</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-comment">// Note: We need separate structs because @builtin(position) has different meanings:</span>
<span class="hljs-comment">// - In vertex shader: output clip-space position (for rasterization)</span>
<span class="hljs-comment">// - In fragment shader: input screen-space fragment coordinates</span>
<span class="hljs-comment">// We can't have it in both the input struct and as a parameter, so we use</span>
<span class="hljs-comment">// FragmentInput (without @builtin) and declare fragment coordinates separately.</span>

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
    <span class="hljs-keyword">let</span> world_position = mesh_functions::mesh_position_local_to_world(
        model,
        vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">1.0</span>)
    );

    out.clip_position = position_world_to_clip(world_position.xyz);
    out.world_position = world_position.xyz;
    out.world_normal = mesh_functions::mesh_normal_local_to_world(
        <span class="hljs-keyword">in</span>.normal,
        <span class="hljs-keyword">in</span>.instance_index
    );
    out.local_position = <span class="hljs-keyword">in</span>.position;
    out.uv = <span class="hljs-keyword">in</span>.uv;

    <span class="hljs-keyword">return</span> out;
}

<span class="hljs-comment">// Convert a direction vector to RGB color (mapping [-1,1] to [0,1])</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">direction_to_color</span></span>(dir: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> dir * <span class="hljs-number">0.5</span> + <span class="hljs-number">0.5</span>;
}

<span class="hljs-comment">// Create a checkerboard pattern</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">checkerboard</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> checker = (floor(uv.x * frequency) + floor(uv.y * frequency)) % <span class="hljs-number">2.0</span>;
    <span class="hljs-keyword">return</span> checker;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(
    <span class="hljs-keyword">in</span>: FragmentInput,
    @builtin(position) frag_coord: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @builtin(front_facing) is_front: <span class="hljs-built_in">bool</span>,
) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    var color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;;

    <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">0</span>u {
        <span class="hljs-comment">// Mode 0: World-space position</span>
        <span class="hljs-comment">// Maps world position to color (mod to keep in 0-1 range)</span>
        color = fract(<span class="hljs-keyword">in</span>.world_position);

    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">1</span>u {
        <span class="hljs-comment">// Mode 1: Local-space position</span>
        color = direction_to_color(normalize(<span class="hljs-keyword">in</span>.local_position));

    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">2</span>u {
        <span class="hljs-comment">// Mode 2: World-space normal</span>
        color = direction_to_color(normalize(<span class="hljs-keyword">in</span>.world_normal));

    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">3</span>u {
        <span class="hljs-comment">// Mode 3: UV coordinates</span>
        color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.uv, <span class="hljs-number">0.0</span>);

    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">4</span>u {
        <span class="hljs-comment">// Mode 4: UV checkerboard</span>
        <span class="hljs-keyword">let</span> checker = checkerboard(<span class="hljs-keyword">in</span>.uv, <span class="hljs-number">10.0</span>);
        color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(checker);

    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">5</span>u {
        <span class="hljs-comment">// Mode 5: Screen-space position (fragment coordinates)</span>
        <span class="hljs-comment">// Normalize by a reasonable screen size</span>
        color = fract(frag_coord.xyz / <span class="hljs-number">100.0</span>);

    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">6</span>u {
        <span class="hljs-comment">// Mode 6: Depth visualization</span>
        <span class="hljs-keyword">let</span> min_depth = <span class="hljs-number">0.01115</span>;
        <span class="hljs-keyword">let</span> max_depth = <span class="hljs-number">0.015</span>;
        <span class="hljs-keyword">let</span> depth = (frag_coord.z - min_depth) / (max_depth - min_depth);

        color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(depth);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">7</span>u {
        <span class="hljs-comment">// Mode 7: Front/back face</span>
        <span class="hljs-keyword">if</span> is_front {
            <span class="hljs-comment">// Create a grid pattern in world-space to punch holes in the mesh.</span>
            <span class="hljs-keyword">let</span> check = checkerboard(<span class="hljs-keyword">in</span>.uv, <span class="hljs-number">10.0</span>);

            <span class="hljs-comment">// If this fragment is in a "hole" of our grid, discard it entirely.</span>
            <span class="hljs-keyword">if</span> check &lt; <span class="hljs-number">0.1</span> { <span class="hljs-comment">// Use &lt; 0.1 for float comparison safety</span>
                discard;
            }

            <span class="hljs-comment">// If not discarded, color it green.</span>
            color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>);
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-comment">// Back faces are a red checkerboard.</span>
            <span class="hljs-keyword">let</span> checker = checkerboard(<span class="hljs-keyword">in</span>.uv, <span class="hljs-number">20.0</span>);
            color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(checker, <span class="hljs-number">0</span>., <span class="hljs-number">0</span>.);
        }
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">8</span>u {
        <span class="hljs-comment">// Mode 8: Animated gradient</span>
        <span class="hljs-keyword">let</span> dist = length(<span class="hljs-keyword">in</span>.local_position.xy);
        <span class="hljs-keyword">let</span> wave = sin(dist * <span class="hljs-number">10.0</span> - material.time * <span class="hljs-number">3.0</span>) * <span class="hljs-number">0.5</span> + <span class="hljs-number">0.5</span>;
        color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(wave, <span class="hljs-number">1.0</span> - wave, <span class="hljs-number">0.5</span>);

    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">9</span>u {
        <span class="hljs-comment">// Mode 9: Simple lighting demo</span>
        <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);
        <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
        <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.0</span>, dot(normal, light_dir));

        <span class="hljs-keyword">let</span> base_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.8</span>, <span class="hljs-number">0.6</span>, <span class="hljs-number">0.4</span>);
        color = base_color * (<span class="hljs-number">0.3</span> + diffuse * <span class="hljs-number">0.7</span>);

    } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// Fallback: Magenta for invalid mode</span>
        color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
    }

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0301colorvisualizationrs">The Rust Material (<code>src/materials/d03_01_color_visualization.rs</code>)</h3>
<p>The Rust code for our material is a standard implementation. It uses the best practice of a nested uniforms module and derives <code>ShaderType</code> to ensure correct memory layout. It also includes a helper function <code>get_mode_name</code> that we'll use to display the current mode in the UI.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};
<span class="hljs-keyword">use</span> bevy::pbr::{Material, MaterialPipelineKey};
<span class="hljs-keyword">use</span> bevy::render::mesh::MeshVertexBufferLayoutRef;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{RenderPipelineDescriptor, SpecializedMeshPipelineError};


<span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>

    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone, Copy)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">ColorVisualizationUniforms</span></span> {
        <span class="hljs-keyword">pub</span> display_mode: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
    }

    <span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> ColorVisualizationUniforms {
        <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
            <span class="hljs-keyword">Self</span> {
                display_mode: <span class="hljs-number">0</span>,
                time: <span class="hljs-number">0.0</span>,
            }
        }
    }
}

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">use</span> uniforms::ColorVisualizationUniforms;

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">ColorVisualizationMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: ColorVisualizationUniforms,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> ColorVisualizationMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d03_01_color_visualization.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d03_01_color_visualization.wgsl"</span>.into()
    }

    <span class="hljs-comment">// Disable backface culling so we can see both front and back faces</span>
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">specialize</span></span>(
        _pipeline: &amp;bevy::pbr::MaterialPipeline&lt;<span class="hljs-keyword">Self</span>&gt;,
        descriptor: &amp;<span class="hljs-keyword">mut</span> RenderPipelineDescriptor,
        _layout: &amp;MeshVertexBufferLayoutRef,
        _key: MaterialPipelineKey&lt;<span class="hljs-keyword">Self</span>&gt;,
    ) -&gt; <span class="hljs-built_in">Result</span>&lt;(), SpecializedMeshPipelineError&gt; {
        descriptor.primitive.cull_mode = <span class="hljs-literal">None</span>;
        <span class="hljs-literal">Ok</span>(())
    }
}

<span class="hljs-comment">// Helper to get mode name for UI</span>
<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_mode_name</span></span>(mode: <span class="hljs-built_in">u32</span>) -&gt; &amp;<span class="hljs-symbol">'static</span> <span class="hljs-built_in">str</span> {
    <span class="hljs-keyword">match</span> mode {
        <span class="hljs-number">0</span> =&gt; <span class="hljs-string">"World Position"</span>,
        <span class="hljs-number">1</span> =&gt; <span class="hljs-string">"Local Position"</span>,
        <span class="hljs-number">2</span> =&gt; <span class="hljs-string">"World Normal"</span>,
        <span class="hljs-number">3</span> =&gt; <span class="hljs-string">"UV Coordinates"</span>,
        <span class="hljs-number">4</span> =&gt; <span class="hljs-string">"UV Checkerboard"</span>,
        <span class="hljs-number">5</span> =&gt; <span class="hljs-string">"Screen Position"</span>,
        <span class="hljs-number">6</span> =&gt; <span class="hljs-string">"Depth"</span>,
        <span class="hljs-number">7</span> =&gt; <span class="hljs-string">"Front/Back Face"</span>,
        <span class="hljs-number">8</span> =&gt; <span class="hljs-string">"Animated Gradient"</span>,
        <span class="hljs-number">9</span> =&gt; <span class="hljs-string">"Simple Lighting"</span>,
        _ =&gt; <span class="hljs-string">"Invalid Mode"</span>,
    }
}
</code></pre>
<p>Don't forget to add it to src/materials/mod.rs:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other materials</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_01_color_visualization;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0301colorvisualizationrs">The Demo Module (<code>src/demos/d03_01_color_visualization.rs</code>)</h3>
<p>The demo module sets up our scene, spawning the three shapes and applying the custom material. It includes several systems: one to update the <code>time</code> uniform every frame, another to handle keyboard input for changing the <code>display_mode</code>, and a third to update the UI text.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d03_01_color_visualization::{
    ColorVisualizationMaterial, ColorVisualizationUniforms, get_mode_name,
};
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> std::<span class="hljs-built_in">f32</span>::consts::PI;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;ColorVisualizationMaterial&gt;::default())
        .init_resource::&lt;RotationPaused&gt;()
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (update_time, handle_input, rotate_objects, update_ui),
        )
        .run();
}

<span class="hljs-meta">#[derive(Component)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">RotatingObject</span></span> {
    speed: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;ColorVisualizationMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> material = materials.add(ColorVisualizationMaterial {
        uniforms: ColorVisualizationUniforms::default(),
    });

    <span class="hljs-comment">// Sphere - smooth surface, good for normals and lighting</span>
    commands.spawn((
        Mesh3d(meshes.add(Sphere::new(<span class="hljs-number">1.0</span>).mesh().uv(<span class="hljs-number">32</span>, <span class="hljs-number">16</span>))),
        MeshMaterial3d(material.clone()),
        Transform::from_xyz(-<span class="hljs-number">3.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        RotatingObject { speed: <span class="hljs-number">0.5</span> },
    ));

    <span class="hljs-comment">// Cube - flat faces, good for seeing face differences</span>
    commands.spawn((
        Mesh3d(meshes.add(Cuboid::new(<span class="hljs-number">1.5</span>, <span class="hljs-number">1.5</span>, <span class="hljs-number">1.5</span>))),
        MeshMaterial3d(material.clone()),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        RotatingObject { speed: <span class="hljs-number">0.3</span> },
    ));

    <span class="hljs-comment">// Torus - complex geometry with interesting UVs</span>
    commands.spawn((
        Mesh3d(meshes.add(Torus::new(<span class="hljs-number">0.6</span>, <span class="hljs-number">0.3</span>).mesh().build())),
        MeshMaterial3d(material),
        Transform::from_xyz(<span class="hljs-number">3.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        RotatingObject { speed: <span class="hljs-number">0.7</span> },
    ));

    <span class="hljs-comment">// Lighting</span>
    commands.spawn((
        DirectionalLight {
            illuminance: <span class="hljs-number">10000.0</span>,
            shadows_enabled: <span class="hljs-literal">false</span>,
            ..default()
        },
        Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -PI / <span class="hljs-number">4.0</span>, PI / <span class="hljs-number">4.0</span>, <span class="hljs-number">0.0</span>)),
    ));

    <span class="hljs-comment">// Camera</span>
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span>, <span class="hljs-number">8.0</span>).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    <span class="hljs-comment">// UI</span>
    commands.spawn((
        Text::new(
            <span class="hljs-string">"[1-0] Select visualization mode | [Space] Pause rotation\n\
             \n\
             Mode: World Position"</span>,
        ),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">10.0</span>),
            left: Val::Px(<span class="hljs-number">10.0</span>),
            padding: UiRect::all(Val::Px(<span class="hljs-number">10.0</span>)),
            ..default()
        },
        TextFont {
            font_size: <span class="hljs-number">16.0</span>,
            ..default()
        },
        TextColor(Color::WHITE),
        BackgroundColor(Color::srgba(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.7</span>)),
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_time</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;ColorVisualizationMaterial&gt;&gt;) {
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        material.uniforms.time = time.elapsed_secs();
    }
}

<span class="hljs-meta">#[derive(Resource, Default)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">RotationPaused</span></span>(<span class="hljs-built_in">bool</span>);

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;ColorVisualizationMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> paused: ResMut&lt;RotationPaused&gt;,
) {
    <span class="hljs-comment">// Toggle pause</span>
    <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Space) {
        paused.<span class="hljs-number">0</span> = !paused.<span class="hljs-number">0</span>;
    }

    <span class="hljs-comment">// Mode selection</span>
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit1) {
            material.uniforms.display_mode = <span class="hljs-number">0</span>;
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit2) {
            material.uniforms.display_mode = <span class="hljs-number">1</span>;
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit3) {
            material.uniforms.display_mode = <span class="hljs-number">2</span>;
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit4) {
            material.uniforms.display_mode = <span class="hljs-number">3</span>;
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit5) {
            material.uniforms.display_mode = <span class="hljs-number">4</span>;
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit6) {
            material.uniforms.display_mode = <span class="hljs-number">5</span>;
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit7) {
            material.uniforms.display_mode = <span class="hljs-number">6</span>;
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit8) {
            material.uniforms.display_mode = <span class="hljs-number">7</span>;
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit9) {
            material.uniforms.display_mode = <span class="hljs-number">8</span>;
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit0) {
            material.uniforms.display_mode = <span class="hljs-number">9</span>;
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rotate_objects</span></span>(
    time: Res&lt;Time&gt;,
    paused: Res&lt;RotationPaused&gt;,
    <span class="hljs-keyword">mut</span> query: Query&lt;(&amp;<span class="hljs-keyword">mut</span> Transform, &amp;RotatingObject)&gt;,
) {
    <span class="hljs-keyword">if</span> paused.<span class="hljs-number">0</span> {
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">mut</span> transform, rotating) <span class="hljs-keyword">in</span> query.iter_mut() {
        transform.rotate_y(time.delta_secs() * rotating.speed);
        transform.rotate_x(time.delta_secs() * rotating.speed)
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(
    materials: Res&lt;Assets&lt;ColorVisualizationMaterial&gt;&gt;,
    paused: Res&lt;RotationPaused&gt;,
    <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;,
) {
    <span class="hljs-keyword">if</span> !materials.is_changed() &amp;&amp; !paused.is_changed() {
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, material)) = materials.iter().next() {
        <span class="hljs-keyword">let</span> mode_name = get_mode_name(material.uniforms.display_mode);
        <span class="hljs-keyword">let</span> pause_status = <span class="hljs-keyword">if</span> paused.<span class="hljs-number">0</span> { <span class="hljs-string">"PAUSED"</span> } <span class="hljs-keyword">else</span> { <span class="hljs-string">"Playing"</span> };

        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
            **text = <span class="hljs-built_in">format!</span>(
                <span class="hljs-string">"[1-0] Select visualization mode | [Space] Pause rotation\n\
                 \n\
                 Mode: {} | Rotation: {}"</span>,
                mode_name, pause_status
            );
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other demos</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d03_01_color_visualization;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"3.1"</span>,
    title: <span class="hljs-string">"Fragment Shader Fundamentals"</span>,
    run: demos::d03_01_color_visualization::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>When you run the application, you will see the three shapes rotating in the center of the screen. Use the number keys to cycle through the different visualization modes and observe how the colors on the objects change.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key</td><td>Action</td></tr>
</thead>
<tbody>
<tr>
<td><strong>1 - 8</strong></td><td>Select the visualization mode.</td></tr>
<tr>
<td><strong>Spacebar</strong></td><td>Pause / Resume the rotation of the objects.</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763360601590/e8c405eb-b828-4129-a537-533f48b8ee66.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763360618866/cf065088-d15c-4a3f-8485-25863d507530.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763360640835/80cfef58-da63-41ca-ac8f-25d0e3d6dc42.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763360653433/85589622-115b-4013-8b83-ddc0fd65181a.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763360749164/17c72ed4-474e-4b07-a73f-c7682173f580.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763360764143/d1497e64-a3d9-4b0e-915d-4485bcd1afca.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763360774353/ac8690df-3e43-4b49-9263-10191512ae34.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763360784420/6a125434-c90d-4044-aff7-1fce1681abb2.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763360796016/5c4761bc-91bb-45be-b661-2d8884e9b0ca.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763360806425/d287f5a0-51cf-473f-9105-1c64061fd872.png" alt class="image--center mx-auto" /></p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Mode</td><td>Description</td></tr>
</thead>
<tbody>
<tr>
<td>1 - World Position</td><td>Visualizes the object's absolute position in the 3D scene using a repeating color grid. The <code>fract()</code> function wraps the world coordinates, causing the colors to tile every 1 unit in world space. As objects rotate, their surfaces move through this fixed 3D color grid.</td></tr>
<tr>
<td>2 - Local Position</td><td>Shows the position relative to each object's own center. The <code>normalize()</code> function turns the position into a direction, which is then mapped to color. This creates a consistent gradient from the object's core outwards that remains "painted on" and unchanging as the object rotates.</td></tr>
<tr>
<td>3 - World Normal</td><td>Displays the direction each part of the surface is facing in world space. The direction vector is mapped to color (X-axis to Red, Y to Green, Z to Blue). Notice the smooth color transitions on the sphere, while each flat face of the cube has a single, solid color representing its orientation.</td></tr>
<tr>
<td>4 - UV Coordinates</td><td>Renders the model's 2D texture coordinates directly as color, mapping the U coordinate to the Red channel and V to the Green. This reveals the "unwrapped" layout of the mesh, which is fundamental for applying textures. Note the visible "seam" where the UVs meet.</td></tr>
<tr>
<td>5 - UV Checkerboard</td><td>Uses the UV coordinates to generate a procedural checkerboard pattern. This is a classic diagnostic tool used by 3D artists to visually inspect a model's UV map for unwanted stretching, compression, or distortion.</td></tr>
<tr>
<td>6 - Screen Position</td><td>The color is determined by the fragment's pixel coordinate on the screen. The colors appear to be projected onto the objects from your monitor, remaining static in screen space. As the objects rotate, they move through this fixed field of color.</td></tr>
<tr>
<td>7 - Depth</td><td>Visualizes the fragment's distance from the camera using repeating contour lines. To make the tiny differences in the non-linear depth buffer visible, we amplify the frag_coord.z value with a large multiplier and use fract() to create distinct bands, similar to a topographic map.</td></tr>
<tr>
<td>8 - Front/Back Face</td><td>This mode visualizes the difference between a mesh's outer (front) and inner (back) surfaces. To make the inner surfaces visible, the shader programmatically punches holes in the solid green front faces using the <code>discard</code> keyword. Through these cutouts, you can see the denser, red-and-black checkerboard pattern being rendered on the back faces. This entire effect is only possible because we disabled back-face culling in the Rust material file, allowing the GPU to render both sides of the triangles.</td></tr>
<tr>
<td>9 - Animated Gradient</td><td>A simple procedural pattern using <code>sin()</code> and <code>time</code> to demonstrate that fragment shaders can create dynamic, animated colors.</td></tr>
<tr>
<td>0 - Simple Lighting</td><td>A basic diffuse lighting model that uses the interpolated world normal to calculate how much light a surface receives from a fixed directional source. The brightness is calculated using the dot product between the normal and the light's direction.</td></tr>
</tbody>
</table>
</div><h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>You have now covered the foundational theory and practice of the fragment shader. Before moving on, ensure these core concepts are clear:</p>
<ol>
<li><p><strong>The Fragment Shader's Core Job</strong>: Its one and only purpose is to calculate and return the final color for a given fragment (a potential pixel).</p>
</li>
<li><p><strong>Massive Parallelism</strong>: Fragment shaders run for nearly every visible pixel of a mesh, potentially millions of times per frame. This makes their performance absolutely critical.</p>
</li>
<li><p><strong>Interpolation is Automatic</strong>: Data passed from the vertex shader (like normals, UVs, and world positions) is automatically and smoothly interpolated across a triangle's surface by the GPU hardware.</p>
</li>
<li><p><strong>Always Re-Normalize Normals</strong>: The process of interpolation will cause normal vectors to become non-unit length. The very first step in a fragment shader that uses normals for lighting should be to <code>normalize()</code> them.</p>
</li>
<li><p><strong>Work in Linear HDR Space</strong>: Your shader performs its calculations in a linear color space and can output High Dynamic Range (HDR) values greater than 1.0. Bevy's post-processing pipeline is responsible for tonemapping these values back to a displayable range.</p>
</li>
<li><p><strong>Built-ins Provide Context</strong>: Hardware variables like <code>@builtin(position)</code> and <code>@builtin(front_facing)</code> give your shader crucial information about its location on the screen and the orientation of the geometry it belongs to.</p>
</li>
<li><p><strong>Performance is Everything</strong>: Critical optimizations like <strong>Early-Z</strong> (which culls hidden fragments before they are shaded) and avoiding <strong>Overdraw</strong> (shading the same pixel multiple times) are fundamental to achieving good performance.</p>
</li>
<li><p><strong>Engine and Shader Work Together</strong>: Shader effects can depend on render states set in the engine. Disabling back-face culling in a Bevy Material is a perfect example of how Rust code is sometimes required to enable a WGSL feature.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>You are now able to create custom materials that color objects based on their geometry and position. We've treated color as a simple set of numbers, but to create truly rich and believable visuals, we need to understand how to manipulate it with more nuance.</p>
<p>In the next article, we will take a deeper dive into the art and science of digital color. We'll properly explore the difference between linear and sRGB color spaces, master a library of color operations like blending and contrast, and learn how to convert between different color models like HSV to achieve more intuitive artistic effects.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/32-color-spaces-and-operations"><strong><em>3.2 - Color Spaces and Operations</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<p><strong>Basic Fragment Shader Syntax</strong>:</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: FragmentInput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Return an opaque red color</span>
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p><strong>Two-Struct Pattern for Inputs</strong>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Output by the Vertex Shader</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-comment">// Received by the Fragment Shader</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">FragmentInput</span></span> {
    @location(<span class="hljs-number">0</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(
    <span class="hljs-keyword">in</span>: FragmentInput,
    @builtin(position) frag_coord: vec4&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// Separate built-in</span>
) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal); <span class="hljs-comment">// Don't forget to normalize!</span>
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<p><strong>Common Fragment Built-ins</strong>:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Built-in</td><td>Type</td><td>Description</td></tr>
</thead>
<tbody>
<tr>
<td>@builtin(position)</td><td><code>vec4&lt;f32&gt;</code></td><td>Fragment's screen-space coordinates and depth.</td></tr>
<tr>
<td>@builtin(front_facing)</td><td><code>bool</code></td><td>true if the triangle is facing the camera.</td></tr>
<tr>
<td>@builtin(frag_depth)</td><td><code>f32</code> (out)</td><td>Allows you to manually write the depth value.</td></tr>
<tr>
<td>@builtin(primitive_index)</td><td><code>u32</code></td><td>Index of the current triangle in the draw call.</td></tr>
</tbody>
</table>
</div><p><strong>Common Color Operations (in Linear Space)</strong>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Blend two colors</span>
<span class="hljs-keyword">let</span> blended = mix(color_a, color_b, <span class="hljs-number">0.5</span>);

<span class="hljs-comment">// Calculate luminance (perceived brightness)</span>
<span class="hljs-keyword">let</span> luminance = dot(color.rgb, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.2126</span>, <span class="hljs-number">0.7152</span>, <span class="hljs-number">0.0722</span>));
<span class="hljs-keyword">let</span> grayscale = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(luminance);

<span class="hljs-comment">// Adjust exposure</span>
<span class="hljs-keyword">let</span> brighter = color * <span class="hljs-number">1.5</span>;
<span class="hljs-keyword">let</span> darker = color * <span class="hljs-number">0.5</span>;
</code></pre>
<p><strong>Performance Reminders</strong>:</p>
<ul>
<li><p><strong>Overdraw is the enemy</strong>: Shading the same pixel multiple times multiplies your shader's cost.</p>
</li>
<li><p><strong>Keep Early-Z active</strong>: Avoid discard, alpha blending, and writing to frag_depth on opaque objects unless necessary, as they prevent the GPU from culling hidden pixels early.</p>
</li>
<li><p><strong>Fragment shaders are expensive</strong>: Move calculations to the vertex shader or the CPU whenever possible.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[2.8 - Vertex Shader Optimization]]></title><description><![CDATA[What We're Learning
You've built amazing vertex shaders: flags that wave in the wind, fields of thousands of unique grass blades, and surfaces that pulse and deform. You've moved from "does it work?" to "does it look good?". Now, it's time to ask the...]]></description><link>https://blog.hexbee.net/28-vertex-shader-optimization</link><guid isPermaLink="true">https://blog.hexbee.net/28-vertex-shader-optimization</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Sat, 13 Dec 2025 18:11:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1763203161017/36222e84-9f84-4522-bde8-df3105718d0c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>You've built amazing vertex shaders: flags that wave in the wind, fields of thousands of unique grass blades, and surfaces that pulse and deform. You've moved from "does it work?" to "does it look good?". Now, it's time to ask the third crucial question: <strong>how fast is it?</strong></p>
<p>Performance optimization isn't about making code cryptic or sacrificing quality. It's a fundamental shift in mindset to understand how the GPU works and to write shaders that work <em>with</em> the hardware, not against it. A well-optimized shader isn't just about hitting a target framerate; it's a creative enabler. The performance budget you save can be spent on richer effects, more complex geometry, and denser scenes. An optimized shader is the difference between rendering a single tree and rendering an entire forest.</p>
<p>To unlock this performance, we need to think like a GPU. The GPU is a massively parallel machine that achieves incredible speed through specialization. It wants to perform simple, predictable work on huge batches of data. Throughout this article, we'll learn to align our code with this model by focusing on a few key principles:</p>
<ul>
<li><p>Performing simple, uniform calculations across many vertices at once.</p>
</li>
<li><p>Avoiding divergent branches that force parts of the GPU to wait.</p>
</li>
<li><p>Accessing memory in efficient, predictable patterns.</p>
</li>
<li><p>Moving work that is constant for all vertices from the GPU to the CPU.</p>
</li>
<li><p>Leveraging the GPU's highly optimized built-in functions.</p>
</li>
</ul>
<p>By the end of this article, you will have a deep understanding of not just <em>what</em> to optimize, but <em>why</em> it works, enabling you to write high-performance shaders for any task.</p>
<p>You will learn:</p>
<ul>
<li><p>How vertex shaders execute on the GPU (the SIMD/wavefront model).</p>
</li>
<li><p>Why certain operations are expensive and how to avoid them.</p>
</li>
<li><p>How to move constant calculations from the GPU to the CPU.</p>
</li>
<li><p>The true cost of branching and the techniques to minimize or eliminate it.</p>
</li>
<li><p>The importance of memory access patterns and cache efficiency.</p>
</li>
<li><p>How to leverage GPU-optimized built-in functions for maximum speed.</p>
</li>
<li><p>How to use profiling tools and techniques in Bevy to find bottlenecks.</p>
</li>
<li><p>A complete, practical optimization workflow: measure, optimize, and verify.</p>
</li>
</ul>
<h2 id="heading-understanding-vertex-shader-execution">Understanding Vertex Shader Execution</h2>
<p>To optimize a shader, you must first understand how it actually runs on the GPU. The GPU is not a faster CPU; it's a fundamentally different kind of processor built for massive parallelism. Thinking it processes vertices one-by-one is the most common and costly mistake a shader programmer can make.</p>
<h3 id="heading-the-simdwavefront-execution-model">The SIMD/Wavefront Execution Model</h3>
<p>GPUs achieve their speed by processing data in large, synchronized groups. Instead of executing your vertex shader on one vertex at a time, it runs it on a <strong>batch</strong> of 32 or 64 vertices simultaneously. This batch is called a <strong>wavefront</strong> (on AMD/Vulkan/Metal) or a <strong>warp</strong> (on NVIDIA).</p>
<p>Think of a wavefront as a small platoon of soldiers. The entire platoon receives the <em>same command</em> at the <em>same time</em> and must execute it in perfect lockstep. This architecture is called <strong>SIMD</strong> (Single Instruction, Multiple Data). One instruction (e.g., "add 5 to the Y position") is applied to a large set of different data (the positions of all 64 vertices in the wavefront).</p>
<pre><code class="lang-plaintext">CPU Model (One at a time):           GPU Model (Wavefront in lockstep):

Vertex 1 → Process → Done            [Vertex 1, Vertex 2, ..., Vertex 64]
Vertex 2 → Process → Done                      ▼
Vertex 3 → Process → Done            Process ALL with the SAME instruction
...                                            ▼
Vertex N → Process → Done            All 64 are done simultaneously

Time to process 64 vertices: ~64x.   Time to process 64 vertices: ~1x
</code></pre>
<p>This lockstep execution is the source of the GPU's incredible power, but it comes with a critical trade-off that has profound implications for our shader code.</p>
<h3 id="heading-what-this-means-for-your-code">What This Means for Your Code</h3>
<p>Let's see how this plays out with two simple examples.</p>
<h4 id="heading-uniform-code-the-ideal-scenario">Uniform Code: The Ideal Scenario</h4>
<p>First, consider a simple, uniform calculation where every vertex in the wavefront performs the exact same operation.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Every vertex does the same calculation. This is ideal.</span>
<span class="hljs-keyword">let</span> height = position.y * <span class="hljs-number">2.0</span>;
</code></pre>
<p>In this case, the entire wavefront executes the instruction in perfect unison. There is no waiting and no wasted time. All hardware resources are used with maximum efficiency.</p>
<pre><code class="lang-plaintext">All 64 vertices execute the same instruction.
[✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓]
    ↓
  All vertices multiply their `position.y` by 2.0.
    ↓
  Done!

Total Time: 1x (Optimal)
</code></pre>
<p>This is the GPU working at its best. The code is <strong>uniform</strong> across all vertices.</p>
<h4 id="heading-divergent-code-the-performance-problem">Divergent Code: The Performance Problem</h4>
<p>Now, let's introduce an <code>if/else</code> statement where the condition depends on each vertex's unique position.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Different vertices will take different paths. This is a problem.</span>
<span class="hljs-keyword">if</span> position.y &gt; <span class="hljs-number">0.5</span> {
    <span class="hljs-comment">// Path A: Some vertices do this</span>
    height = position.y * <span class="hljs-number">2.0</span>;
} <span class="hljs-keyword">else</span> {
    <span class="hljs-comment">// Path B: The other vertices do this</span>
    height = position.y * <span class="hljs-number">0.5</span>;
}
</code></pre>
<p>Because the entire wavefront must execute in lockstep, it cannot split up and run both paths simultaneously. Instead, the hardware <strong>serializes</strong> the execution:</p>
<ol>
<li><p>The if condition is evaluated for all 64 vertices. Some are <code>true</code>, some are <code>false</code>.</p>
</li>
<li><p><strong>Path A Execution:</strong> The GPU executes the code for the true branch (<code>height = position.y * 2.0</code>). During this step, all vertices that evaluated to <code>false</code> are temporarily disabled and forced to wait.</p>
</li>
<li><p><strong>Path B Execution:</strong> The GPU executes the code for the else branch (<code>height = position.y * 0.5</code>). Now, all the vertices that took the first path are disabled and wait.</p>
</li>
<li><p>The paths converge, and the full wavefront becomes active again.</p>
</li>
</ol>
<p>The wavefront was forced to execute <strong>both</strong> branches, effectively taking twice as long. This phenomenon is called <strong>branch divergence</strong>, and it is one of the biggest performance killers in shader programming.</p>
<h3 id="heading-visualizing-wavefront-divergence">Visualizing Wavefront Divergence</h3>
<h4 id="heading-uniform-code-coherent-branch-no-divergence">Uniform Code (Coherent Branch, No Divergence):</h4>
<p>Imagine a wavefront where all vertices happen to have position.y &gt; 0.5.</p>
<pre><code class="lang-plaintext">All 64 vertices take the same path.
[✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓]
    ↓
  All vertices execute Path A. Path B is skipped entirely.
    ↓
  Done!

Total Time: 1x (Optimal)
</code></pre>
<h4 id="heading-divergent-code-incoherent-branch">Divergent Code (Incoherent Branch):</h4>
<p>Now, imagine half the vertices are above 0.5 and half are below.</p>
<pre><code class="lang-plaintext">32 vertices take Path A (✓), 32 take Path B (✗)
[✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗✗]
    ↓
  Step 1: Execute Path A.
  (The ✗ vertices are inactive and wait.)
    ↓
  Step 2: Execute Path B.
  (The ✓ vertices are now inactive and wait.)
    ↓
  Done!

Total Time: ~2x (or worse, if the branches are complex)
</code></pre>
<p>The key takeaway is that <strong>divergence forces sequential execution on parallel hardware</strong>, neutralizing its main advantage.</p>
<h3 id="heading-when-branches-are-acceptable">When Branches Are Acceptable</h3>
<p>Not all branches are bad. The performance penalty only occurs with <em>divergent</em> branches. A branch is perfectly fine if it is <strong>coherent</strong> - meaning all vertices in a wavefront take the same path.</p>
<p>This typically happens under two conditions:</p>
<ol>
<li><p><strong>The condition is based on uniform data:</strong> Since uniforms are the same for all vertices, the entire wavefront will always choose the same path.</p>
</li>
<li><p><strong>The condition is based on data that is likely to be the same for large groups of vertices:</strong> For example, culling objects based on their distance to the camera. While one wavefront at the edge of the culling distance might diverge, the vast majority will be coherently "in" or "out."</p>
</li>
</ol>
<pre><code class="lang-rust"><span class="hljs-comment">// ✓ OK - Coherent Branch.</span>
<span class="hljs-comment">// The material's `mode` is a uniform, so all vertices in this</span>
<span class="hljs-comment">// draw call will take the same path.</span>
<span class="hljs-keyword">if</span> material.mode == <span class="hljs-number">0</span>u {
    <span class="hljs-comment">// All vertices do this OR none do.</span>
    displacement = wave_displacement(position, time);
} <span class="hljs-keyword">else</span> {
    <span class="hljs-comment">// All vertices do this OR none do.</span>
    displacement = noise_displacement(position, time);
}

<span class="hljs-comment">// ✓ OK - Mostly Coherent Branch for an early exit.</span>
<span class="hljs-comment">// This saves a huge amount of work for the many wavefronts that are far away.</span>
<span class="hljs-keyword">if</span> distance_to_camera &gt; <span class="hljs-number">100.0</span> {
    <span class="hljs-comment">// Skip expensive animation for distant objects.</span>
    <span class="hljs-keyword">return</span> simple_vertex_output(position);
}

<span class="hljs-comment">// ✗ BAD - Divergent Branch.</span>
<span class="hljs-comment">// The result of this `if` will be different for nearly every</span>
<span class="hljs-comment">// vertex, causing maximum divergence.</span>
<span class="hljs-keyword">if</span> sin(position.x * <span class="hljs-number">10.0</span>) &gt; <span class="hljs-number">0.0</span> {
    <span class="hljs-comment">// Some vertices do this...</span>
    height = complex_calculation_a();
} <span class="hljs-keyword">else</span> {
    <span class="hljs-comment">// ...while others do this.</span>
    height = complex_calculation_b();
}
</code></pre>
<h3 id="heading-the-golden-rule">The Golden Rule</h3>
<p>The fundamental principle of vertex shader optimization can be summarized in one rule:</p>
<p><strong>Minimize divergence, maximize uniformity.</strong></p>
<p>If all vertices in a wavefront are doing the same simple thing, the GPU will fly. The more you introduce divergence and complex, per-vertex decision-making, the more you force the GPU to slow down and serialize its work.</p>
<h2 id="heading-optimization-strategy-1-move-constant-calculations-to-cpu">Optimization Strategy 1: Move Constant Calculations to CPU</h2>
<p>Now that we understand the GPU's execution model, we can begin with our first and often easiest optimization strategy: <strong>if a calculation produces the same result for every single vertex in a draw call, it does not belong on the GPU.</strong></p>
<p>The vertex shader's job is to process unique, per-vertex data. Any work that is constant across all vertices is a waste of the GPU's massively parallel power. It's like asking an entire army of 40,000 soldiers to individually calculate <code>sin(0.5)</code> - they will all get the same answer, but you've wasted 39,999 identical calculations.</p>
<h3 id="heading-identifying-expensive-operations">Identifying Expensive Operations</h3>
<p>To understand why this is so important, let's look at the relative costs of common shader operations. While the exact numbers vary between GPU architectures, the relative differences are a powerful mental model.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Operation Type</td><td>Relative Cost (Approximate)</td><td>Notes</td></tr>
</thead>
<tbody>
<tr>
<td>Addition / Subtraction</td><td><strong>1x</strong> (Baseline)</td><td>The fastest operations.</td></tr>
<tr>
<td>Multiplication</td><td><strong>1x</strong></td><td>As fast as addition.</td></tr>
<tr>
<td>Division</td><td><strong>~4-8x</strong></td><td>Significantly slower. Avoid if able.</td></tr>
<tr>
<td>sqrt (Square root)</td><td><strong>~4-8x</strong></td><td>An expensive operation. For vector normalization, <code>inverseSqrt</code> is a faster alternative to dividing by <code>sqrt</code>. We'll explore this in "Strategy 3."</td></tr>
<tr>
<td>sin, cos (Trig)</td><td><strong>~8-16x</strong></td><td>Very expensive. Minimize usage.</td></tr>
<tr>
<td>pow (Power)</td><td><strong>~10-20x</strong></td><td>Also very expensive.</td></tr>
<tr>
<td>Texture Sample</td><td><strong>~20-200x</strong></td><td>Involves memory access. Very slow.</td></tr>
<tr>
<td>Divergent Branch</td><td><strong>(Cost of A + Cost of B)</strong></td><td>Can be extremely costly.</td></tr>
</tbody>
</table>
</div><p>The takeaway is clear: trigonometry, powers, square roots, and divisions are significantly more expensive than simple addition and multiplication. Our first goal should be to minimize these expensive operations, especially when they are redundant.</p>
<h3 id="heading-the-precomputation-pattern">The Precomputation Pattern</h3>
<p>Let's look at a common mistake: calculating a time-based value inside the vertex shader.</p>
<h4 id="heading-bad-computing-sincos-per-vertex">✗ BAD: Computing sin/cos Per-Vertex</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// in: MyMaterial.wgsl</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">MyMaterial</span></span> {
    time: <span class="hljs-built_in">f32</span>,
    <span class="hljs-comment">// ... other uniforms</span>
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>) var&lt;uniform&gt; material: MyMaterial;

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    <span class="hljs-comment">// ✗ BAD: Every vertex computes sin(time) and cos(time).</span>
    <span class="hljs-comment">// These values are identical for all vertices in this draw call.</span>
    <span class="hljs-keyword">let</span> wave = sin(material.time) * <span class="hljs-number">0.5</span>;
    <span class="hljs-keyword">let</span> rotation_amount = cos(material.time * <span class="hljs-number">0.5</span>);

    <span class="hljs-comment">// ... use wave and rotation_amount ...</span>
}
</code></pre>
<p>If our mesh has 10,000 vertices, this code performs <strong>20,000</strong> expensive trigonometric calculations every single frame.</p>
<h4 id="heading-good-precomputing-on-the-cpu">✓ GOOD: Precomputing on the CPU</h4>
<p>The solution is to perform the calculation just once on the CPU each frame and pass the result to the GPU as a uniform.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// in: my_material.rs (Rust code)</span>

<span class="hljs-comment">// A system that runs once per frame</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_material</span></span>(
    time: Res&lt;Time&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;MyMaterial&gt;&gt;,
) {
    <span class="hljs-comment">// Iterate over all instances of our material that exist</span>
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-keyword">let</span> t = time.elapsed_secs();

        <span class="hljs-comment">// Compute these expensive values ONCE on the CPU</span>
        material.uniforms.time_sin = t.sin();
        material.uniforms.time_cos = (t * <span class="hljs-number">0.5</span>).cos();
    }
}
</code></pre>
<p>Now, we update our shader to use these precomputed values.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// in: MyMaterial.wgsl</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">MyMaterial</span></span> {
    time: <span class="hljs-built_in">f32</span>,
    time_sin: <span class="hljs-built_in">f32</span>,  <span class="hljs-comment">// Precomputed on CPU</span>
    time_cos: <span class="hljs-built_in">f32</span>,  <span class="hljs-comment">// Precomputed on CPU</span>
}
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>) var&lt;uniform&gt; material: MyMaterial;


@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    <span class="hljs-comment">// ✓ GOOD: Just read the precomputed values from the uniform buffer.</span>
    <span class="hljs-comment">// This is just a fast memory read.</span>
    <span class="hljs-keyword">let</span> wave = material.time_sin * <span class="hljs-number">0.5</span>;
    <span class="hljs-keyword">let</span> rotation_amount = material.time_cos;

    <span class="hljs-comment">// ... use wave and rotation_amount ...</span>
}
</code></pre>
<p>The performance difference is enormous. We've replaced 20,000 expensive GPU operations with just 2 on the CPU. The total cost of these operations inside the shader is now effectively zero.</p>
<h3 id="heading-important-caveat-position-dependent-calculations">Important Caveat: Position-Dependent Calculations</h3>
<p>This technique is powerful, but it has a critical limitation: it only works for calculations that are <strong>independent of per-vertex attributes</strong> like <code>position</code>, <code>normal</code>, or <code>uv</code>.</p>
<p>Consider a spatial wave effect:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// This CANNOT be precomputed on the CPU</span>
<span class="hljs-keyword">let</span> wave = sin(position.x * frequency + time);
</code></pre>
<p>Here, <code>sin()</code> depends on both <code>time</code> (a uniform) and <code>position.x</code> (a vertex attribute). Since <code>position.x</code> is different for every vertex, the result of the <code>sin()</code> call will also be different. This calculation <em>must</em> happen in the vertex shader. For these cases, we rely on other optimization strategies, like reducing the complexity or using a Level of Detail (LOD) system, which we'll cover later.</p>
<h3 id="heading-what-to-precompute">What to Precompute</h3>
<p><strong>Always precompute on the CPU if a value is the same for all vertices.</strong> Common candidates include:</p>
<ul>
<li><p><strong>Time-based animations:</strong> Any <code>sin(time)</code>, <code>cos(time)</code>, or other function that only depends on a time uniform.</p>
</li>
<li><p><strong>Complex uniform expressions:</strong> If you are combining multiple uniforms in a complex formula, do it once on the CPU.</p>
</li>
<li><p><strong>Matrix inversions and transposes:</strong> The normal matrix (<code>transpose(inverse(model))</code>) is a classic example. It's an expensive calculation that is constant for all vertices of a given mesh.</p>
</li>
</ul>
<h4 id="heading-example-the-normal-matrix">Example: The Normal Matrix</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// ✓ GOOD - Compute normal matrix on CPU (conceptual Rust)</span>
<span class="hljs-keyword">let</span> model_matrix: Mat4 = transform.compute_matrix();
<span class="hljs-keyword">let</span> normal_matrix: Mat3 = model_matrix.inverse().transpose().into();
<span class="hljs-comment">// ... upload both model_matrix and normal_matrix to the GPU ...</span>
</code></pre>
<pre><code class="lang-rust"><span class="hljs-comment">// in shader</span>
<span class="hljs-comment">// Just read the precomputed matrix</span>
<span class="hljs-keyword">let</span> world_normal = material.normal_matrix * <span class="hljs-keyword">in</span>.normal;
</code></pre>
<p>This is vastly superior to calculating it per-vertex:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ✗ BAD - Computing inverse-transpose per vertex</span>
<span class="hljs-keyword">let</span> model_3x3 = mat3x3(model[<span class="hljs-number">0</span>].xyz, model[<span class="hljs-number">1</span>].xyz, model[<span class="hljs-number">2</span>].xyz);
<span class="hljs-comment">// This is extremely expensive!</span>
<span class="hljs-keyword">let</span> normal_matrix = transpose(inverse(model_3x3));
<span class="hljs-keyword">let</span> world_normal = normal_matrix * <span class="hljs-keyword">in</span>.normal;
</code></pre>
<h3 id="heading-precomputation-checklist">Precomputation Checklist</h3>
<p>Review your vertex shader and ask these questions for every calculation:</p>
<ol>
<li><p>Does this value depend only on uniforms (like <code>time</code>, <code>color</code>, <code>mode</code>)? → <strong>Precompute on CPU.</strong></p>
</li>
<li><p>Does this involve expensive operations (<code>sin</code>, <code>cos</code>, <code>pow</code>, <code>sqrt</code>, <code>inverse</code>, <code>transpose</code>)? → <strong>Precompute if possible.</strong></p>
</li>
<li><p>Is this value calculated every frame but rarely changes? → <strong>Cache it on the CPU and only update it when needed.</strong></p>
</li>
<li><p>Is this value the same for every single vertex being drawn? → <strong>Precompute on CPU.</strong></p>
</li>
</ol>
<h2 id="heading-optimization-strategy-2-avoid-complex-branching">Optimization Strategy 2: Avoid Complex Branching</h2>
<p>We've established that branch divergence is a primary enemy of GPU performance. An <code>if/else</code> statement based on per-vertex data forces the GPU's parallel hardware into a sequential, one-path-then-the-other execution model, destroying its efficiency.</p>
<p>The solution is to transform our code from a "control flow" problem (choosing which code to run) into a "data flow" problem (calculating a result with math). GPUs are phenomenal at math. By replacing <code>if</code> statements with mathematical equivalents, we can create <strong>branchless</strong> code that runs uniformly across all vertices in a wavefront, keeping the hardware fully saturated and performing at its peak.</p>
<h3 id="heading-technique-1-replace-choosing-with-blending">Technique 1: Replace "Choosing" with "Blending"</h3>
<p>The most common pattern for eliminating a branch is to calculate the results of both paths and then use a mathematical function to blend between them. This might seem counterintuitive - why do more math? - but it's faster because it avoids the costly process of stopping, waiting, and serializing execution that a divergent branch causes.</p>
<p>Our main tools for this are <code>step()</code> and <code>mix()</code>.</p>
<ul>
<li><p><code>step(edge, x)</code>: This is a simple threshold function. If <code>x</code> is less than <code>edge</code>, it returns <code>0.0</code>. Otherwise, it returns <code>1.0</code>. It's a perfect mathematical switch.</p>
</li>
<li><p><code>mix(a, b, t)</code>: This performs a linear interpolation. It returns <code>a</code> when <code>t</code> is <code>0.0</code> and <code>b</code> when t is <code>1.0</code>.</p>
</li>
</ul>
<p>By combining them, we can create a powerful branchless equivalent to <code>if/else</code>.</p>
<h4 id="heading-bad-branching">✗ BAD: Branching</h4>
<pre><code class="lang-rust">var height: <span class="hljs-built_in">f32</span>;
<span class="hljs-comment">// If the vertex is above y=0, make it taller.</span>
<span class="hljs-comment">// If it's below, make it shorter.</span>
<span class="hljs-keyword">if</span> position.y &gt; <span class="hljs-number">0.0</span> {
    height = position.y * <span class="hljs-number">2.0</span>; <span class="hljs-comment">// Path A</span>
} <span class="hljs-keyword">else</span> {
    height = position.y * <span class="hljs-number">0.5</span>; <span class="hljs-comment">// Path B</span>
}
</code></pre>
<h4 id="heading-good-branchless-blending">✓ GOOD: Branchless Blending</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// 1. Create a switch that is 0.0 for Path B and 1.0 for Path A.</span>
<span class="hljs-keyword">let</span> is_positive = step(<span class="hljs-number">0.0</span>, position.y); <span class="hljs-comment">// Returns 0.0 or 1.0</span>

<span class="hljs-comment">// 2. Use the switch as the blend factor in mix().</span>
<span class="hljs-keyword">let</span> height = mix(
    position.y * <span class="hljs-number">0.5</span>,   <span class="hljs-comment">// Value when is_positive is 0.0 (Path B)</span>
    position.y * <span class="hljs-number">2.0</span>,   <span class="hljs-comment">// Value when is_positive is 1.0 (Path A)</span>
    is_positive         <span class="hljs-comment">// The blend factor (our switch)</span>
);
</code></pre>
<p>On a modern GPU, this branchless version is often significantly faster. The hardware calculates both <code>position.y * 0.5</code> and <code>position.y * 2.0</code> in parallel and then uses the <code>is_positive</code> value to select the correct result, all without causing the pipeline to diverge.</p>
<h3 id="heading-technique-2-use-min-max-and-clamp-for-range-checks">Technique 2: Use <code>min</code>, <code>max</code>, and <code>clamp</code> for Range Checks</h3>
<p>Another common use for <code>if</code> is to clamp a value within a certain range. This should almost always be replaced with the dedicated built-in functions, which correspond to single, fast hardware instructions.</p>
<h4 id="heading-bad-branching-1">✗ BAD: Branching</h4>
<pre><code class="lang-rust"><span class="hljs-keyword">if</span> height &lt; <span class="hljs-number">0.0</span> {
    height = <span class="hljs-number">0.0</span>;
}
</code></pre>
<pre><code class="lang-rust"><span class="hljs-keyword">if</span> value &gt; <span class="hljs-number">1.0</span> {
    value = <span class="hljs-number">1.0</span>;
} <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> value &lt; <span class="hljs-number">0.0</span> {
    value = <span class="hljs-number">0.0</span>;
}
</code></pre>
<h4 id="heading-good-branchless">✓ GOOD: Branchless</h4>
<pre><code class="lang-rust">height = max(height, <span class="hljs-number">0.0</span>);
</code></pre>
<pre><code class="lang-rust">value = clamp(value, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
</code></pre>
<h3 id="heading-technique-3-move-the-condition-from-code-to-data">Technique 3: Move the Condition from Code to Data</h3>
<p>Sometimes, you can eliminate a branch by encoding the condition directly into your mesh data as a vertex attribute. This is an advanced but powerful technique for cases like a cloth simulation where some vertices are "pinned" and should not move.</p>
<h4 id="heading-bad-branching-on-vertex-id-or-position">✗ BAD: Branching on Vertex ID or Position</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// This would be extremely divergent if pinned vertices</span>
<span class="hljs-comment">// are scattered throughout the mesh.</span>
<span class="hljs-keyword">if</span> is_pinned_position(position) {
    <span class="hljs-comment">// Don't move this vertex</span>
    <span class="hljs-keyword">return</span> position;
} <span class="hljs-keyword">else</span> {
    <span class="hljs-comment">// Apply physics</span>
    <span class="hljs-keyword">return</span> position + displacement;
}
</code></pre>
<h4 id="heading-good-using-a-vertex-attribute-as-a-mask">✓ GOOD: Using a Vertex Attribute as a Mask</h4>
<p>In your Rust code, when you generate the mesh, you add a custom attribute.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In mesh generation (conceptual Rust)</span>
<span class="hljs-keyword">let</span> pinned_flags: <span class="hljs-built_in">Vec</span>&lt;<span class="hljs-built_in">f32</span>&gt; = my_vertices.iter().map(|v| {
    <span class="hljs-keyword">if</span> v.is_pinned { <span class="hljs-number">1.0</span> } <span class="hljs-keyword">else</span> { <span class="hljs-number">0.0</span> }
}).collect();

mesh.insert_attribute(
    <span class="hljs-comment">// A custom attribute, at the next available location</span>
    Mesh::ATTRIBUTE_JOINT_INDEX, <span class="hljs-comment">// Example location</span>
    pinned_flags
);
</code></pre>
<p>Now, the shader can use this attribute as a simple multiplier, completely avoiding a branch.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Read the custom attribute</span>
@location(<span class="hljs-number">4</span>) is_pinned: <span class="hljs-built_in">f32</span>, <span class="hljs-comment">// 0.0 = free, 1.0 = pinned</span>

<span class="hljs-comment">// ... inside the vertex shader ...</span>

<span class="hljs-comment">// No branch needed! Just multiply the displacement by 0.0 or 1.0.</span>
<span class="hljs-keyword">let</span> final_displacement = displacement * (<span class="hljs-number">1.0</span> - is_pinned);
<span class="hljs-keyword">return</span> position + final_displacement;
</code></pre>
<h3 id="heading-when-you-absolutely-must-branch-keep-it-coherent">When You Absolutely Must Branch: Keep It Coherent</h3>
<p>If a branch is unavoidable, the goal is to ensure it is as <strong>coherent</strong> as possible, meaning that large, contiguous groups of vertices are likely to take the same path.</p>
<ol>
<li><p><strong>Branch Early:</strong> Perform your branch check as early as possible in the shader to skip the maximum amount of expensive work.</p>
</li>
<li><p><strong>Branch on Uniform Data:</strong> As discussed, branching on a uniform is always perfectly coherent and fast.</p>
</li>
<li><p><strong>Branch on Slowly-Changing Data:</strong> Branching on values like <code>distance_to_camera</code> is generally acceptable. While there will be divergence right at the threshold, the vast majority of vertices will be either clearly near or clearly far, resulting in highly coherent wavefronts.</p>
</li>
</ol>
<p>This is the principle behind Level of Detail (LOD) systems.</p>
<h4 id="heading-ok-a-coherent-branch-for-lod">✓ OK: A Coherent Branch for LOD</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// A mostly coherent branch that saves a huge amount of work.</span>
<span class="hljs-keyword">if</span> distance_to_camera &gt; material.lod_distance {
    <span class="hljs-comment">// Simple, cheap path for distant vertices.</span>
    <span class="hljs-comment">// Skip all the expensive calculations below.</span>
    <span class="hljs-keyword">return</span> simple_vertex_transform(position);
}

<span class="hljs-comment">// Full-quality, expensive path for nearby vertices.</span>
<span class="hljs-keyword">let</span> wave = calculate_complex_wave(position, time); <span class="hljs-comment">// Expensive</span>
<span class="hljs-keyword">let</span> foam = calculate_procedural_foam(uv);         <span class="hljs-comment">// Also expensive</span>
<span class="hljs-comment">// ...</span>
</code></pre>
<p>This is a good trade-off. We accept a small amount of divergence at the LOD boundary in exchange for saving a massive amount of computation on the thousands of vertices that are far away.</p>
<h3 id="heading-branchless-patterns-cheat-sheet">Branchless Patterns Cheat Sheet</h3>
<p>Use this table as a quick reference for converting common if statements into faster, branchless equivalents. Many of the logical patterns assume you are working with float values of 0.0 for false and 1.0 for true, a common convention in shader programming.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Instead of...</td><td>Use...</td></tr>
</thead>
<tbody>
<tr>
<td><code>if (x &gt; threshold) { a } else { b }</code></td><td><code>let result = mix(b, a, step(threshold, x));</code></td></tr>
<tr>
<td><code>if (x &lt; 0.0) { 0.0 } else { x }</code></td><td><code>let result = max(x, 0.0);</code></td></tr>
<tr>
<td><code>if (x &gt; 1.0) { 1.0 } else if ... { x }</code></td><td><code>let result = clamp(x, 0.0, 1.0);</code></td></tr>
<tr>
<td><code>if (my_bool) { value } else { 0.0 }</code></td><td><code>let result = value * f32(my_bool);</code></td></tr>
<tr>
<td><code>if (a &gt; 0.5 &amp;&amp; b &gt; 0.5) { value }</code></td><td><code>let result = value * step(0.5, a) * step(0.5, b);</code></td></tr>
<tr>
<td>`if (a &gt; 0.5</td></tr>
</tbody>
</table>
</div><h2 id="heading-optimization-strategy-3-use-built-in-functions">Optimization Strategy 3: Use Built-in Functions</h2>
<p>GPU drivers are some of the most heavily optimized pieces of software on the planet. The engineers at NVIDIA, AMD, and Apple have spent millions of hours fine-tuning the performance of the core WGSL functions. Our third strategy is simple but powerful: <strong>trust their work and use it.</strong></p>
<p>Whenever you are tempted to write a common mathematical function yourself (like linear interpolation or vector normalization), stop and check if a built-in function already exists. A custom implementation is almost guaranteed to be slower than the driver's version.</p>
<h3 id="heading-why-built-ins-are-faster">Why Built-ins Are Faster</h3>
<p>Built-in functions aren't just convenient wrappers. They often map directly to:</p>
<ul>
<li><p><strong>Specialized Hardware Instructions:</strong> Many functions like <code>normalize()</code>, <code>dot()</code>, and <code>mix()</code> are implemented directly in the silicon. They execute in a single, incredibly fast hardware instruction.</p>
</li>
<li><p><strong>Optimized Microcode:</strong> For more complex functions, the driver uses a highly optimized sequence of low-level instructions that are tuned for the specific GPU architecture you are running on.</p>
</li>
<li><p><strong>Numerical Stability:</strong> Driver implementations are carefully designed to handle edge cases and avoid precision errors that can easily creep into custom code.</p>
</li>
</ul>
<h3 id="heading-common-built-ins-to-favor">Common Built-ins to Favor</h3>
<p>Here is a non-exhaustive list of essential, highly-optimized functions available in WGSL. You should always prefer these over manual implementations.</p>
<h4 id="heading-vector-operations">Vector Operations</h4>
<pre><code class="lang-rust">length(v)
distance(a, b)
normalize(v)
dot(a, b)
cross(a, b)
</code></pre>
<h4 id="heading-scalar-math-amp-clamping">Scalar Math &amp; Clamping</h4>
<pre><code class="lang-rust">min(a, b)
max(a, b)
clamp(x, low, hi)
abs(x)
sign(x)
</code></pre>
<h4 id="heading-interpolation-amp-stepping">Interpolation &amp; Stepping</h4>
<pre><code class="lang-rust">mix(a, b, t)          <span class="hljs-comment">// Linear interpolation (lerp)</span>
smoothstep(e0, e1, x) <span class="hljs-comment">// Smooth Hermite interpolation</span>
step(edge, x)         <span class="hljs-comment">// 0.0 if x &lt; edge, else 1.0</span>
</code></pre>
<h4 id="heading-exponentials-amp-trigonometry">Exponentials &amp; Trigonometry</h4>
<pre><code class="lang-rust">exp(x), exp2(x)
log(x), log2(x)
pow(x, y)
sqrt(x)
inverseSqrt(x)       <span class="hljs-comment">// 1.0 / sqrt(x)</span>
sin(x), cos(x), tan(x)
</code></pre>
<h3 id="heading-example-1-the-power-of-inversesqrt-and-normalize">Example 1: The Power of <code>inverseSqrt</code> and <code>normalize</code></h3>
<p>As promised, let's explore <code>inverseSqrt</code>. Its primary purpose is to accelerate vector normalization, one of the most common operations in 3D graphics. The goal of normalization is to make a vector's length equal to 1. The formula is <code>v / length(v)</code>.</p>
<h4 id="heading-bad-manual-normalization">✗ BAD: Manual Normalization</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// This involves a dot product, an expensive `sqrt`, and an expensive vector division.</span>
<span class="hljs-keyword">let</span> len = sqrt(dot(v, v));
<span class="hljs-keyword">let</span> normalized = v / len;
</code></pre>
<h4 id="heading-good-using-inversesqrt-to-avoid-division">✓ GOOD: Using <code>inverseSqrt</code> to Avoid Division</h4>
<p>We can mathematically rewrite the formula to replace the slow division with a fast multiplication. <code>v / len</code> is the same as <code>v * (1.0 / len)</code>. The term <code>1.0 / sqrt(dot(v, v))</code> is exactly what <code>inverseSqrt</code> calculates, and it does so with a single, highly-optimized hardware instruction.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// This is better. We've replaced a sqrt and a division with a single, fast</span>
<span class="hljs-comment">// inverseSqrt and a fast multiplication.</span>
<span class="hljs-keyword">let</span> inv_len = inverseSqrt(dot(v, v));
<span class="hljs-keyword">let</span> normalized = v * inv_len;
</code></pre>
<h4 id="heading-best-using-normalize">✨ BEST: Using <code>normalize</code></h4>
<p>The best approach is to use the highest-level function that describes your intent. The <code>normalize()</code> function is the clearest and gives the driver the most freedom to use the absolute fastest method available on the hardware, which might be even more optimized than a manual <code>inverseSqrt</code>.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Perfect. This is readable, concise, and guaranteed to be the fastest method.</span>
<span class="hljs-keyword">let</span> normalized = normalize(v);
</code></pre>
<h3 id="heading-example-2-vector-distance">Example 2: Vector Distance</h3>
<h4 id="heading-bad-manual-implementation">✗ BAD: Manual Implementation</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// Manual, verbose, and misses potential optimizations.</span>
<span class="hljs-keyword">let</span> d = b - a;
<span class="hljs-keyword">let</span> distance = sqrt(d.x*d.x + d.y*d.y + d.z*d.z);
</code></pre>
<h4 id="heading-good-built-in-function">✓ GOOD: Built-in Function</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// The driver can optimize this much more effectively.</span>
<span class="hljs-keyword">let</span> distance = distance(a, b);
</code></pre>
<h3 id="heading-example-3-color-interpolation">Example 3: Color Interpolation</h3>
<h4 id="heading-bad-manual-linear-interpolation-lerp">✗ BAD: Manual Linear Interpolation (lerp)</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// This is the definition of lerp, but `mix` is faster.</span>
<span class="hljs-keyword">let</span> result = color_a + (color_b - color_a) * t;
</code></pre>
<h4 id="heading-good-built-in-mix">✓ GOOD: Built-in mix</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// `mix` is the WGSL name for lerp.</span>
<span class="hljs-keyword">let</span> result = mix(color_a, color_b, t);
</code></pre>
<h3 id="heading-built-in-patterns-cheat-sheet">Built-in Patterns Cheat Sheet</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>For this task...</td><td>Use this function...</td><td>Instead of this...</td></tr>
</thead>
<tbody>
<tr>
<td>Vector Normalization</td><td><code>let n = normalize(v);</code></td><td><code>v / length(v)</code></td></tr>
<tr>
<td>Distance Between Pts</td><td><code>let d = distance(a, b);</code></td><td><code>length(b - a)</code></td></tr>
<tr>
<td>Clamping a Value</td><td><code>let c = clamp(x, 0.0, 1.0);</code></td><td><code>min(max(x, 0.0), 1.0)</code></td></tr>
<tr>
<td>Linear Interpolation</td><td><code>let lerp = mix(a, b, t);</code></td><td><code>a + (b - a) * t</code></td></tr>
<tr>
<td>Smooth Interpolation</td><td><code>let s = smoothstep(0.0, 1.0, x);</code></td><td>A custom <code>x*x*(3.0-2.0*x)</code> curve</td></tr>
<tr>
<td>Absolute Value</td><td><code>let abs_val = abs(x);</code></td><td><code>if (x &lt; 0.0) { -x } else { x }</code></td></tr>
<tr>
<td>Sign of a Value</td><td><code>let s = sign(x);</code></td><td><code>if (x &lt; 0.0) { -1.0 } else { 1.0 }</code></td></tr>
</tbody>
</table>
</div><h2 id="heading-optimization-strategy-4-memory-access-patterns">Optimization Strategy 4: Memory Access Patterns</h2>
<p>Modern GPUs are mathematical beasts. They can perform trillions of floating-point operations per second. In many cases, the bottleneck in a shader is not the math (being <em>ALU-bound</em>), but the time it takes to fetch data from memory (being <em>memory-bound</em>). Every texture sample, every uniform read, requires a trip to memory. Our fourth strategy is to make those trips as short and infrequent as possible.</p>
<h3 id="heading-understanding-the-memory-hierarchy">Understanding the Memory Hierarchy</h3>
<p>Not all memory on a GPU is created equal. There is a hierarchy of memory types, each with a trade-off between speed and size.</p>
<pre><code class="lang-plaintext">Fastest &amp; Smallest   │ Registers          → Extremely fast, on-chip. Holds local variables.
                     ├────────────────────
                     │ L1 / Texture Cache → Very fast, small. Holds recently accessed data.
                     ├────────────────────
                     │ L2 Cache           → Fast, larger. A shared cache for more data.
                     ├────────────────────
Slowest &amp; Largest    │ VRAM (Global)      → Slow. Holds all your textures and buffers.
</code></pre>
<p>Think of it like a workshop:</p>
<ul>
<li><p><strong>Registers</strong> are the tools in your hand. Access is instant.</p>
</li>
<li><p><strong>Caches</strong> are the tools on your workbench. Quick to grab.</p>
</li>
<li><p><strong>VRAM</strong> is the supply closet down the hall. Every trip costs you time.</p>
</li>
</ul>
<p>Your goal as a shader programmer is to keep the data you need in your hand or on the workbench, minimizing trips to the supply closet. You do this by following two key principles of memory access.</p>
<h3 id="heading-the-two-rules-of-efficient-memory-access">The Two Rules of Efficient Memory Access</h3>
<ol>
<li><p><strong>Temporal Locality (Reuse What You Fetch):</strong> If you take the time to fetch an item from memory, keep it in a local variable for as long as you need it. Don't go back to memory for the same piece of data multiple times. This ensures the data stays in the fastest registers.</p>
</li>
<li><p><strong>Spatial Locality (Access Nearby Data):</strong> When you access memory (especially textures), the GPU is smart. It fetches not just the single piece of data you asked for, but also a small block of its neighbors, storing them in the cache. If your next memory access is for one of those neighbors, it will be an incredibly fast "cache hit."</p>
</li>
</ol>
<h3 id="heading-texture-sampling-the-most-expensive-memory-access">Texture Sampling: The Most Expensive Memory Access</h3>
<p>A texture sample is often the single most expensive operation in a shader because it involves a complex memory lookup. Minimizing texture fetches is one of the biggest performance wins you can achieve.</p>
<h4 id="heading-rule-1-sample-once-use-all-channels">✓ Rule 1: Sample Once, Use All Channels</h4>
<p>Never sample the same texture multiple times with the same UV coordinates. Fetch it once into a local <code>vec4</code> variable and reuse that variable.</p>
<pre><code class="lang-rust">@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    <span class="hljs-comment">// ✗ BAD: Three separate, expensive trips to memory for the same location.</span>
    <span class="hljs-keyword">let</span> noise1 = textureSample(noise_tex, samp, <span class="hljs-keyword">in</span>.uv).r;
    <span class="hljs-keyword">let</span> noise2 = textureSample(noise_tex, samp, <span class="hljs-keyword">in</span>.uv).g;
    <span class="hljs-keyword">let</span> noise3 = textureSample(noise_tex, samp, <span class="hljs-keyword">in</span>.uv).b;

    <span class="hljs-comment">// ... use noise1, noise2, noise3 ...</span>
}
</code></pre>
<pre><code class="lang-rust">@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    <span class="hljs-comment">// ✓ GOOD: One trip to memory. The result is stored in a fast register.</span>
    <span class="hljs-keyword">let</span> noise_sample = textureSample(noise_tex, samp, <span class="hljs-keyword">in</span>.uv);
    <span class="hljs-keyword">let</span> noise1 = noise_sample.r;
    <span class="hljs-keyword">let</span> noise2 = noise_sample.g;
    <span class="hljs-keyword">let</span> noise3 = noise_sample.b;

    <span class="hljs-comment">// Same result, much faster.</span>
}
</code></pre>
<h4 id="heading-rule-2-pack-your-data">✓ Rule 2: Pack Your Data</h4>
<p>Following from the first rule, you can drastically reduce your total sample count by packing multiple grayscale masks or data values into the R, G, B, and A channels of a single texture. This is a standard technique in game development.</p>
<p>Instead of using three separate textures:</p>
<ul>
<li><p><code>Texture 1 (R)</code>: Height displacement</p>
</li>
<li><p><code>Texture 2 (R)</code>: Roughness value</p>
</li>
<li><p><code>Texture 3 (R)</code>: Ambient occlusion mask</p>
</li>
</ul>
<p>Combine them into one:</p>
<ul>
<li><p><code>Combined_Texture.r</code>: Height displacement</p>
</li>
<li><p><code>Combined_Texture.g</code>: Roughness value</p>
</li>
<li><p><code>Combined_Texture.b</code>: Ambient occlusion mask</p>
</li>
<li><p><code>Combined_Texture.a</code>: (another value, like metallic)</p>
</li>
</ul>
<p>This technique reduces three expensive texture fetches down to just one.</p>
<h3 id="heading-uniform-and-storage-buffer-access">Uniform and Storage Buffer Access</h3>
<p>The principle of temporal locality applies equally to uniform and storage buffers. While this data is cached, you can still gain performance by being explicit.</p>
<h4 id="heading-rule-3-cache-buffer-values-in-local-variables">✓ Rule 3: Cache Buffer Values in Local Variables</h4>
<p>When you access a uniform like <code>material.time</code> multiple times, you are repeatedly reading from the uniform buffer. While this is likely to hit a cache, it's even faster to read it once into a local variable, which guarantees it lives in a register.</p>
<pre><code class="lang-rust">@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    <span class="hljs-comment">// Each use of `material.time` is a potential memory access.</span>
    <span class="hljs-keyword">let</span> wave1 = sin(position.x + material.time);
    <span class="hljs-keyword">let</span> wave2 = cos(position.y + material.time);
    <span class="hljs-keyword">let</span> wave3 = sin(position.z * material.time);
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<pre><code class="lang-rust">@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    <span class="hljs-comment">// ✓ GOOD: Read from the uniform buffer ONCE.</span>
    <span class="hljs-keyword">let</span> time = material.time;
    <span class="hljs-comment">// `time` now lives in a fast register for the rest of the shader.</span>
    <span class="hljs-keyword">let</span> wave1 = sin(position.x + time);
    <span class="hljs-keyword">let</span> wave2 = cos(position.y + time);
    <span class="hljs-keyword">let</span> wave3 = sin(position.z * time);
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<p>This pattern is especially important for storage buffers in instanced rendering. Access the instance data array once, store the result in a local struct, and then use the fields from that local struct.</p>
<h3 id="heading-memory-access-checklist">Memory Access Checklist</h3>
<ul>
<li><p><strong>Minimize texture fetches.</strong> They are your most expensive memory operation.</p>
</li>
<li><p><strong>Pack data</strong> into RGBA channels to reduce the total number of textures and samples.</p>
</li>
<li><p><strong>Sample once, reuse often.</strong> Store texture samples in a local <code>vec4</code> and use its components.</p>
</li>
<li><p><strong>Cache uniform and storage buffer values</strong> in local variables at the top of your shader.</p>
</li>
<li><p><strong>Prioritize local variables (registers)</strong> for all frequently used data to avoid round trips to slower memory.</p>
</li>
</ul>
<h2 id="heading-optimization-strategy-5-reduce-vertex-shader-complexity">Optimization Strategy 5: Reduce Vertex Shader Complexity</h2>
<p>The optimizations we've covered so far - precomputation, branchless math, built-ins, and efficient memory access - are about making your existing calculations run faster. Our final strategy is simpler and often more impactful: <strong>just do less work.</strong></p>
<p>A complex vertex shader with multiple waves, noise functions, and procedural animations might look fantastic up close, but that detail is completely wasted on an object that is a few pixels wide on the horizon. By strategically reducing or eliminating work for distant or insignificant vertices, you can free up enormous amounts of GPU time to be spent where it matters.</p>
<h3 id="heading-technique-1-level-of-detail-lod">Technique 1: Level of Detail (LOD)</h3>
<p>Level of Detail is the most powerful technique in this category. The concept is simple: check how far a vertex is from the camera, and run a cheaper, lower-quality version of your shader for it if it's far away.</p>
<p>This is a prime example of an "acceptable branch." While the <code>if/else</code> will cause some divergence for the few wavefronts at the LOD boundaries, the vast majority of wavefronts will be coherently near or far. The performance saved by skipping expensive calculations for the thousands of distant vertices far outweighs the cost of the branch itself.</p>
<pre><code class="lang-rust">@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    <span class="hljs-keyword">let</span> world_pos = <span class="hljs-comment">/* ... transform vertex to world space ... */</span>;
    <span class="hljs-keyword">let</span> distance_to_camera = distance(world_pos, view.world_position);

    var displacement: vec3&lt;<span class="hljs-built_in">f32</span>&gt;;

    <span class="hljs-comment">// Check distance and choose a code path</span>
    <span class="hljs-keyword">if</span> distance_to_camera &lt; <span class="hljs-number">20.0</span> {
        <span class="hljs-comment">// CLOSE: Full quality. Use multiple sine waves and noise.</span>
        displacement = calculate_complex_waves_with_noise(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">4</span>u);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> distance_to_camera &lt; <span class="hljs-number">50.0</span> {
        <span class="hljs-comment">// MEDIUM: Reduced quality. Just a single sine wave.</span>
        displacement = calculate_simple_sine_wave(<span class="hljs-keyword">in</span>.position);
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// FAR: Minimal quality. No displacement at all.</span>
        displacement = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>);
    }

    <span class="hljs-comment">// Apply displacement and continue...</span>
}
</code></pre>
<p>This technique ensures that your GPU's budget is spent rendering beautiful detail on the objects the player is actually looking at, not wasting cycles on distant scenery.</p>
<h3 id="heading-technique-2-back-face-optimization">Technique 2: Back-Face Optimization</h3>
<p>For any solid, opaque object (like a character model or a rock), any vertex on a face pointing away from the camera will never be visible. We can detect this and skip expensive calculations for those vertices.</p>
<p>To do this, we need to know which way the vertex's normal is pointing and which way the camera is looking.</p>
<ul>
<li><p>The <strong>view direction</strong> is the vector from the vertex's position to the camera's position.</p>
</li>
<li><p>The <code>dot()</code> product of the vertex normal and the view direction tells us if they are pointing in roughly the same direction.</p>
<ul>
<li><p>If <code>dot(normal, view_dir) &gt; 0</code>, the face is pointing towards the camera (front-facing).</p>
</li>
<li><p>If <code>dot(normal, view_dir) &lt; 0</code>, the face is pointing away from the camera (back-facing).</p>
</li>
</ul>
</li>
</ul>
<p>We can use this as an early exit for expensive work.</p>
<pre><code class="lang-rust">@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    <span class="hljs-keyword">let</span> world_pos = <span class="hljs-comment">/* ... get world position ... */</span>;
    <span class="hljs-keyword">let</span> world_normal = <span class="hljs-comment">/* ... get world normal ... */</span>;
    <span class="hljs-keyword">let</span> view_dir = normalize(view.world_position - world_pos);

    <span class="hljs-comment">// Is this vertex on the back of the object?</span>
    <span class="hljs-keyword">if</span> dot(world_normal, view_dir) &lt; <span class="hljs-number">0.0</span> {
        <span class="hljs-comment">// Yes, it's hidden. Skip all expensive displacement.</span>
        <span class="hljs-keyword">return</span> simple_output(world_pos);
    }

    <span class="hljs-comment">// No, it's visible. Proceed with the full, expensive calculation.</span>
    <span class="hljs-keyword">let</span> displacement = calculate_expensive_displacement(world_pos);
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<p><strong>Critical Caveat:</strong> This optimization is <strong>only valid for closed, opaque meshes.</strong> Never use it on:</p>
<ul>
<li><p><strong>Transparent objects:</strong> You need to see the back faces through the front.</p>
</li>
<li><p><strong>Single-sided planes:</strong> Grass, leaves, cloth, paper. The back face is the only other side.</p>
</li>
<li><p><strong>Objects with holes:</strong> You might see the "back" of an interior surface through a hole.</p>
</li>
</ul>
<p>Use this technique carefully, but for the right meshes, it can effectively cut your vertex shader's workload in half.</p>
<h3 id="heading-technique-3-simplify-your-math">Technique 3: Simplify Your Math</h3>
<p>Sometimes, a visually identical or "good enough" effect can be achieved with a much cheaper mathematical formula. Always look for opportunities to simplify.</p>
<ul>
<li><p><strong>Powers:</strong> <code>pow(x, 2.0)</code> is expensive. <code>x * x</code> is cheap and gives the same result. <code>pow(x, 3.0)</code> can be <code>x * x * x</code>.</p>
</li>
<li><p><strong>Approximations:</strong> Do you need a perfectly circular falloff, or would a cheaper, linear falloff look just as good in motion? Do you need a high-quality <code>sin</code> wave, or can you use a simpler triangular wave pattern for a background effect?</p>
</li>
<li><p><strong>Reduce Frequency:</strong> For effects like noise, often you can use a lower-frequency (larger scale) noise pattern that requires fewer calculations (fewer octaves) with little to no perceptible difference from a distance.</p>
</li>
</ul>
<p>Always start with the simplest effect that achieves your goal, and only add mathematical complexity if it provides a clear visual benefit.</p>
<h2 id="heading-profiling-and-measuring-performance">Profiling and Measuring Performance</h2>
<p>There is a golden rule in all software optimization: <strong>you cannot optimize what you cannot measure.</strong></p>
<p>It's easy to fall into the trap of "premature optimization" - guessing where the slow parts of your code are and making changes based on intuition. This often leads to wasted time, more complex code, and minimal performance gains. A professional workflow is always driven by data. You must first <strong>profile</strong> your application to find the actual <strong>bottleneck</strong> before you try to fix it.</p>
<h3 id="heading-step-1-is-the-cpu-or-gpu-the-bottleneck">Step 1: Is the CPU or GPU the Bottleneck?</h3>
<p>Your application's frame rate is limited by whichever processor finishes its work last.</p>
<ul>
<li><p>If the <strong>CPU</strong> takes 20ms to prepare a frame and the GPU only takes 5ms to render it, your frame time will be 20ms (~50 FPS). You are <strong>CPU-bound</strong>. Optimizing your shader will have zero effect on your frame rate.</p>
</li>
<li><p>If the <strong>CPU</strong> takes 5ms and the <strong>GPU</strong> takes 20ms, your frame time will also be 20ms. You are <strong>GPU-bound</strong>. In this case, optimizing your shader is critical.</p>
</li>
</ul>
<p>Bevy's built-in diagnostics are the perfect tool for this initial investigation.</p>
<h4 id="heading-enabling-bevys-frame-time-diagnostics">Enabling Bevy's Frame Time Diagnostics</h4>
<p>Add the following plugins to your Bevy <code>App</code>:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::diagnostic::{FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin};

App::new()
    .add_plugins(DefaultPlugins)
    .add_plugins(FrameTimeDiagnosticsPlugin)
    .add_plugins(LogDiagnosticsPlugin::default())
    <span class="hljs-comment">// ...</span>
</code></pre>
<p>When you run your application from the command line, Bevy will now print the frame time. The most important number to watch is the cpu frame time.</p>
<ul>
<li><p><strong>If</strong> <code>cpu frame time</code> is high (e.g., &gt; 16.6ms for 60 FPS): Your bottleneck is likely on the CPU. This is often caused by having too many draw calls (which instancing solves), running too many complex systems, or spawning/despawning too many entities per frame.</p>
</li>
<li><p><strong>If</strong> <code>cpu frame time</code> is low (e.g., &lt; 5ms) but your FPS is still poor: Your bottleneck is almost certainly on the GPU. This means your shaders are too complex, you have too much geometry, or you're rendering too many pixels (fill-rate limited).</p>
</li>
</ul>
<h3 id="heading-step-2-profiling-the-gpu">Step 2: Profiling the GPU</h3>
<p>Once you've confirmed you are GPU-bound, you need to dig deeper. Specialized GPU profiling tools are essential for this. They can capture a single frame of your application and give you a detailed breakdown of exactly how long each draw call and shader took to execute.</p>
<ul>
<li><p><strong>RenderDoc</strong> (Windows &amp; Linux): The industry standard for graphics debugging. It allows you to inspect every stage of the pipeline and provides detailed GPU timings for each event.</p>
</li>
<li><p><strong>Xcode Instruments</strong> (macOS): Provides excellent GPU profiling tools for Metal, allowing you to see shader execution times and identify bottlenecks.</p>
</li>
<li><p><strong>PIX</strong> (Windows, for DirectX): Microsoft's dedicated performance tuning and debugging tool.</p>
</li>
</ul>
<p>The process generally involves:</p>
<ol>
<li><p>Launching your Bevy application through the profiler.</p>
</li>
<li><p>Pressing a key to "capture" a single, representative frame.</p>
</li>
<li><p>Analyzing the captured frame in the tool's UI to see a timeline of GPU work. You can find your expensive draw call and see precisely how many milliseconds were spent in its vertex and fragment shaders.</p>
</li>
</ol>
<h3 id="heading-step-3-an-iterative-optimization-workflow">Step 3: An Iterative Optimization Workflow</h3>
<p>With these tools, you can adopt a professional, data-driven workflow:</p>
<ol>
<li><p><strong>Establish a Baseline:</strong> Run your scene and record the performance metrics. What is the current FPS? What does the GPU profiler say your vertex shader time is?</p>
</li>
<li><p><strong>Form a Hypothesis:</strong> Based on the principles in this article, identify a potential optimization. For example, "I believe the 6-octave noise function in my vertex shader is the main bottleneck."</p>
</li>
<li><p><strong>Implement One Change:</strong> Apply a <em>single optimization</em>. For example, add a LOD system to reduce the noise to 1 octave for distant vertices.</p>
</li>
<li><p><strong>Measure Again:</strong> Run the profiler again under the exact same conditions. Did the frame time improve? Did the vertex shader execution time decrease as expected?</p>
</li>
<li><p><strong>Verify:</strong> If the performance improved, you've confirmed your hypothesis. If not, revert the change and go back to step 2 with a new hypothesis.</p>
</li>
</ol>
<p>This iterative cycle of <strong>measure → hypothesize → change → measure</strong> ensures that you are always making meaningful, data-backed improvements to your code.</p>
<h3 id="heading-manual-debugging-and-visualization">Manual Debugging and Visualization</h3>
<p>Sometimes a full profiling tool is overkill. You can often get a good sense of what your shader is doing by outputting debug information as colors.</p>
<h4 id="heading-example-visualizing-lod-distance">Example: Visualizing LOD Distance</h4>
<p>You can visualize which code path your LOD system is taking by passing the distance to the fragment shader and outputting it as a color.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// in vertex shader</span>
<span class="hljs-comment">// ...</span>
out.distance_to_camera = distance_to_camera;

<span class="hljs-comment">// in fragment shader</span>
@location(<span class="hljs-number">4</span>) distance_to_camera: <span class="hljs-built_in">f32</span>,
<span class="hljs-comment">// ...</span>
<span class="hljs-comment">// Color vertices by which LOD they fall into</span>
var debug_color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;;
<span class="hljs-keyword">if</span> <span class="hljs-keyword">in</span>.distance_to_camera &lt; <span class="hljs-number">20.0</span> {
    debug_color = vec3(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>); <span class="hljs-comment">// Red = High Detail</span>
} <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> <span class="hljs-keyword">in</span>.distance_to_camera &lt; <span class="hljs-number">50.0</span> {
    debug_color = vec3(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>); <span class="hljs-comment">// Green = Medium Detail</span>
} <span class="hljs-keyword">else</span> {
    debug_color = vec3(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>); <span class="hljs-comment">// Blue = Low Detail</span>
}
<span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(debug_color, <span class="hljs-number">1.0</span>);
</code></pre>
<p>This will instantly show you if your LOD thresholds are set correctly and are behaving as you expect, turning your scene into a performance heatmap.</p>
<hr />
<h2 id="heading-complete-example-optimizing-a-complex-vertex-shader">Complete Example: Optimizing a Complex Vertex Shader</h2>
<p>Theory is essential, but seeing optimization in action provides the crucial "aha!" moment. We will now apply every principle we've learned in a practical, step-by-step optimization of a complex ocean shader.</p>
<h3 id="heading-our-goal">Our Goal</h3>
<p>Our starting point is an unoptimized ocean shader that renders a large, detailed water plane with 40,000 vertices. It uses a multi-octave sine wave function for realistic ripples and a noise texture to generate sea foam. While it looks nice, its performance is poor, running at around <strong>30 FPS</strong> on a typical GPU.</p>
<p>Our goal is to significantly improve its performance by applying optimization principles without significantly sacrificing the visual quality for the parts of the ocean closest to the camera. We will do this by creating a second, optimized version of the shader and a Bevy application that lets us toggle between the two in real-time to see the performance difference.</p>
<h3 id="heading-what-this-project-demonstrates">What This Project Demonstrates</h3>
<ul>
<li><p><strong>Profiling and Baseline:</strong> How to establish a clear performance baseline.</p>
</li>
<li><p><strong>Applying Optimizations:</strong> A step-by-step application of our optimization strategies.</p>
<ul>
<li><p>Reducing mathematical complexity (fewer wave octaves).</p>
</li>
<li><p>Replacing branches with branchless math (<code>mix</code>, <code>smoothstep</code>).</p>
</li>
<li><p>Optimizing memory access (single texture sample).</p>
</li>
<li><p>Implementing a Level of Detail (LOD) system to do less work.</p>
</li>
</ul>
</li>
<li><p><strong>Verification:</strong> How to measure the concrete FPS improvement from each change.</p>
</li>
<li><p><strong>Real-World Trade-offs:</strong> Understanding that optimization is about spending your performance budget intelligently, not just making code run faster.</p>
</li>
</ul>
<h3 id="heading-the-unoptimized-shader-identifying-the-anti-patterns">The Unoptimized Shader: Identifying the Anti-Patterns</h3>
<p>First, let's analyze the unoptimized shader and its associated Rust code. This is our starting point, running at <strong>~30 FPS</strong>.</p>
<p><strong>Dependency Note</strong>: Before adding the application code, you'll need to add one dependency to your project. The demo uses this to generate a noise texture on the CPU. Open your <code>Cargo.toml</code> file and add the following line under <code>[dependencies]</code>:</p>
<pre><code class="lang-toml"><span class="hljs-section">[dependencies]</span>
<span class="hljs-attr">bevy</span> = <span class="hljs-string">"0.16"</span> <span class="hljs-comment"># Ensure this matches your Bevy version</span>
<span class="hljs-attr">noise</span> = <span class="hljs-string">"0.9"</span>
</code></pre>
<h4 id="heading-the-unoptimized-shader-assetsshadersd0208oceanunoptimizedwgsl">The Unoptimized Shader (<code>assets/shaders/d02_08_ocean_unoptimized.wgsl</code>)</h4>
<p>This shader is full of common performance mistakes that we can now easily identify.</p>
<ul>
<li><p><code>calculate_wave_height</code>:</p>
<ul>
<li><p>It uses a <code>for</code> loop to calculate 6 octaves of sine waves for every single vertex, which is incredibly expensive.</p>
</li>
<li><p>The expensive <code>sin</code> and <code>cos</code> functions are called repeatedly inside the loop.</p>
</li>
</ul>
</li>
<li><p><code>calculate_foam</code>:</p>
<ul>
<li><p>It samples the same noise texture three times with slightly different UVs, resulting in three expensive memory fetches where one would suffice.</p>
</li>
<li><p>It uses a chain of <code>if/else if/else</code> statements based on <code>wave_height</code>, which is per-vertex data. This will cause significant <strong>branch divergence</strong>.</p>
</li>
<li><p>It manually clamps the foam value with <code>if</code> statements instead of using the built-in <code>clamp()</code> function.</p>
</li>
</ul>
</li>
<li><p><code>vertex</code> function:</p>
<ul>
<li><p>It performs the full, expensive wave and foam calculations for every vertex, regardless of its distance from the camera. There is no <strong>LOD system</strong>.</p>
</li>
<li><p>It calculates the distance to the camera manually instead of using the built-in <code>distance()</code> function.</p>
</li>
<li><p>It has another divergent branch to calculate <code>detail_level</code> based on distance.</p>
</li>
</ul>
</li>
</ul>
<pre><code class="lang-rust">#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">OceanMaterial</span></span> {
    time: <span class="hljs-built_in">f32</span>,
    camera_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    wave_amplitude: <span class="hljs-built_in">f32</span>,
    wave_frequency: <span class="hljs-built_in">f32</span>,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: OceanMaterial;

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">1</span>)
var noise_texture: texture_2d&lt;<span class="hljs-built_in">f32</span>&gt;;

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">2</span>)
var noise_sampler: sampler;

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) world_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) foam_amount: <span class="hljs-built_in">f32</span>,
    @location(<span class="hljs-number">3</span>) wave_height: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-comment">// A helper for a single directional wave.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">wave</span></span>(position: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, direction: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, frequency: <span class="hljs-built_in">f32</span>, amplitude: <span class="hljs-built_in">f32</span>, speed: <span class="hljs-built_in">f32</span>, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> angle = dot(direction, position);
    <span class="hljs-keyword">return</span> sin(angle * frequency + time * speed) * amplitude;
}

<span class="hljs-comment">// ✗ ANTI-PATTERN: Excessive Complexity. Uses a long loop for ALL vertices.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_wave_height</span></span>(pos: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    var height = <span class="hljs-number">0.0</span>;
    <span class="hljs-keyword">let</span> base_amp = material.wave_amplitude;
    <span class="hljs-keyword">let</span> base_freq = material.wave_frequency;

    <span class="hljs-comment">// Use a loop with 6 waves to be expensive.</span>
    <span class="hljs-keyword">let</span> directions = array&lt;vec2&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-number">6</span>&gt;(
        normalize(vec2(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>)), normalize(vec2(<span class="hljs-number">0.8</span>, <span class="hljs-number">1.0</span>)),
        normalize(vec2(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.3</span>)), normalize(vec2(-<span class="hljs-number">0.2</span>, <span class="hljs-number">1.0</span>)),
        normalize(vec2(<span class="hljs-number">0.7</span>, <span class="hljs-number">0.7</span>)), normalize(vec2(<span class="hljs-number">1.0</span>, -<span class="hljs-number">0.3</span>))
    );
    <span class="hljs-keyword">for</span> (var i = <span class="hljs-number">0</span>u; i &lt; <span class="hljs-number">6</span>u; i = i + <span class="hljs-number">1</span>u) {
        <span class="hljs-comment">// We vary the parameters inside the loop to make it look complex.</span>
        height += wave(pos.xz, directions[i], base_freq * (<span class="hljs-number">1.0</span> + <span class="hljs-built_in">f32</span>(i)*<span class="hljs-number">0.2</span>), base_amp * (<span class="hljs-number">1.0</span> - <span class="hljs-built_in">f32</span>(i)*<span class="hljs-number">0.1</span>), <span class="hljs-number">1.0</span> + <span class="hljs-built_in">f32</span>(i)*<span class="hljs-number">0.1</span>, time);
    }
    <span class="hljs-keyword">return</span> height;
}

<span class="hljs-comment">// ✗ ANTI-PATTERN: Redundant Texture Samples &amp; Divergent Branching.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_foam</span></span>(pos: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, wave_height: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> uv1 = fract(pos.xz * <span class="hljs-number">0.1</span>);
    <span class="hljs-keyword">let</span> uv2 = fract(pos.xz * <span class="hljs-number">0.15</span>);
    <span class="hljs-keyword">let</span> uv3 = fract(pos.xz * <span class="hljs-number">0.2</span>);

    <span class="hljs-comment">// ✗ ANTI-PATTERN: Three separate, expensive texture fetches.</span>
    <span class="hljs-keyword">let</span> noise1 = textureSampleLevel(noise_texture, noise_sampler, uv1, <span class="hljs-number">0.0</span>).r;
    <span class="hljs-keyword">let</span> noise2 = textureSampleLevel(noise_texture, noise_sampler, uv2, <span class="hljs-number">0.0</span>).g;
    <span class="hljs-keyword">let</span> noise3 = textureSampleLevel(noise_texture, noise_sampler, uv3, <span class="hljs-number">0.0</span>).b;

    <span class="hljs-comment">// ✗ ANTI-PATTERN: Divergent branch on per-vertex data.</span>
    var foam = <span class="hljs-number">0.0</span>;
    <span class="hljs-keyword">if</span> wave_height &gt; <span class="hljs-number">0.5</span> {
        foam = noise1;
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> wave_height &gt; <span class="hljs-number">0.25</span> {
        foam = noise2;
    } <span class="hljs-keyword">else</span> {
        foam = noise3;
    }

    <span class="hljs-comment">// ✗ ANTI-PATTERN: Manual clamping instead of built-in.</span>
    <span class="hljs-keyword">if</span> foam &gt; <span class="hljs-number">1.0</span> { foam = <span class="hljs-number">1.0</span>; }
    <span class="hljs-keyword">if</span> foam &lt; <span class="hljs-number">0.0</span> { foam = <span class="hljs-number">0.0</span>; }

    <span class="hljs-keyword">return</span> foam;
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
    <span class="hljs-keyword">let</span> world_position = mesh_functions::mesh_position_local_to_world(
        model,
        vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">1.0</span>)
    ).xyz;

    <span class="hljs-comment">// ✗ ANTI-PATTERN: No LOD system. All vertices get the full calculation.</span>
    <span class="hljs-keyword">let</span> wave_height = calculate_wave_height(world_position, material.time);

    var displaced_position = world_position;
    displaced_position.y += wave_height;

    <span class="hljs-keyword">let</span> foam = calculate_foam(displaced_position, wave_height);

    <span class="hljs-comment">// ✗ ANTI-PATTERN: Not using built-in distance function.</span>
    <span class="hljs-keyword">let</span> dx = displaced_position.x - material.camera_position.x;
    <span class="hljs-keyword">let</span> dy = displaced_position.y - material.camera_position.y;
    <span class="hljs-keyword">let</span> dz = displaced_position.z - material.camera_position.z;
    <span class="hljs-keyword">let</span> distance_to_camera = sqrt(dx*dx + dy*dy + dz*dz);

    <span class="hljs-comment">// ✗ ANTI-PATTERN: A second divergent branch.</span>
    var detail_level: <span class="hljs-built_in">f32</span>;
    <span class="hljs-keyword">if</span> distance_to_camera &lt; <span class="hljs-number">20.0</span> {
        detail_level = <span class="hljs-number">1.0</span>;
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> distance_to_camera &lt; <span class="hljs-number">50.0</span> {
        detail_level = <span class="hljs-number">0.5</span>;
    } <span class="hljs-keyword">else</span> {
        detail_level = <span class="hljs-number">0.25</span>;
    }

    <span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(
        <span class="hljs-keyword">in</span>.normal,
        <span class="hljs-keyword">in</span>.instance_index
    );

    out.clip_position = position_world_to_clip(displaced_position);
    out.world_position = displaced_position;
    out.world_normal = normalize(world_normal);
    <span class="hljs-comment">// Apply the pointless detail_level to match original behavior.</span>
    out.foam_amount = foam * detail_level;
    out.wave_height = wave_height;

    <span class="hljs-keyword">return</span> out;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);

    <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.0</span>, dot(normal, light_dir)) * <span class="hljs-number">0.7</span>;
    <span class="hljs-keyword">let</span> ambient = <span class="hljs-number">0.3</span>;

    <span class="hljs-keyword">let</span> base_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.1</span>, <span class="hljs-number">0.3</span>, <span class="hljs-number">0.5</span>);
    <span class="hljs-keyword">let</span> foam_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.9</span>, <span class="hljs-number">0.9</span>, <span class="hljs-number">0.95</span>);

    <span class="hljs-keyword">let</span> final_color = mix(base_color, foam_color, <span class="hljs-keyword">in</span>.foam_amount);
    <span class="hljs-keyword">let</span> lit_color = final_color * (ambient + diffuse);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(lit_color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h4 id="heading-the-unoptimized-rust-material-srcmaterialsd0208oceanunoptimizedrs">The Unoptimized Rust Material (<code>src/materials/d02_08_ocean_unoptimized.rs</code>)</h4>
<p>This is a standard Bevy material setup. It defines the <code>uniforms</code> struct and the <code>Material</code> implementation that links to our unoptimized WGSL file.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>

    <span class="hljs-keyword">use</span> bevy::prelude::*;
    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone, Copy)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">OceanUnoptimizedUniforms</span></span> {
        <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> camera_position: Vec3,
        <span class="hljs-keyword">pub</span> wave_amplitude: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> wave_frequency: <span class="hljs-built_in">f32</span>,
    }

    <span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> OceanUnoptimizedUniforms {
        <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
            <span class="hljs-keyword">Self</span> {
                time: <span class="hljs-number">0.0</span>,
                camera_position: Vec3::ZERO,
                wave_amplitude: <span class="hljs-number">0.5</span>,
                wave_frequency: <span class="hljs-number">1.0</span>,
            }
        }
    }
}

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">use</span> uniforms::OceanUnoptimizedUniforms;

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">OceanMaterialUnoptimized</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: OceanUnoptimizedUniforms,

    <span class="hljs-meta">#[texture(1)]</span>
    <span class="hljs-meta">#[sampler(2)]</span>
    <span class="hljs-keyword">pub</span> noise_texture: Handle&lt;Image&gt;,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> OceanMaterialUnoptimized {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_08_ocean_unoptimized.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_08_ocean_unoptimized.wgsl"</span>.into()
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other materials</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_08_ocean_unoptimized;
<span class="hljs-comment">// We will add the optimized version later</span>
</code></pre>
<p>Now that we have our baseline, let's start improving it.</p>
<h3 id="heading-the-optimized-version-a-breakdown-of-the-changes">The Optimized Version: A Breakdown of the Changes</h3>
<p>We will now create a new set of files, <code>d02_08_ocean_optimized.wgsl</code> and <code>d02_08_ocean_optimized.rs</code>, that implement the fixes for all the performance problems we identified. We will then create a single Bevy application that allows us to switch between the unoptimized and optimized materials on the fly, making the performance difference immediately obvious.</p>
<h4 id="heading-a-critical-note-why-not-precompute-sintime">A Critical Note: Why Not Precompute <code>sin(time)</code>?</h4>
<p>Our very first optimization strategy was to move constant calculations to the CPU. A sharp-eyed reader might wonder why we aren't precomputing <code>sin(material.time)</code> and <code>cos(material.time)</code> on the CPU for our wave calculations.</p>
<p>This is a crucial point: <strong>precomputation only works for calculations that are independent of per-vertex attributes.</strong></p>
<p>Our wave formula is <code>sin(pos.x * frequency + time)</code>. The <code>sin</code> function depends on both <code>pos.x</code> (which is unique to each vertex) and <code>time</code> (which is uniform). There is no mathematical way to separate these two inputs; the wave's shape across space is intrinsically linked to the current time. Trying to precompute <code>sin(time)</code> would break the spatial wave effect, resulting in the entire ocean plane simply bobbing up and down as a single flat sheet.</p>
<p>Therefore, for this specific effect, the expensive <code>sin</code> and <code>cos</code> calls <em>must</em> remain in the vertex shader. Our primary strategies will be to <strong>do less work</strong> (by calling them less often in loops and for distant vertices via LOD) and to <strong>work more efficiently</strong> with the rest of the code.</p>
<h4 id="heading-the-optimized-shader-assetsshadersd0208oceanoptimizedwgsl">The Optimized Shader (<code>assets/shaders/d02_08_ocean_optimized.wgsl</code>)</h4>
<p>Here is the complete optimized shader. Read the comments carefully to see how each of our strategies has been applied.</p>
<ul>
<li><p><strong>LOD System:</strong> The main <code>vertex</code> function now checks the <code>distance_to_camera</code> first and calls different, cheaper versions of the wave calculation for medium and far vertices. This is the single biggest performance win.</p>
</li>
<li><p><strong>Reduced Complexity:</strong> The most detailed wave function, <code>calculate_wave_height_detailed</code>, now only performs 2 octaves instead of 6. The <code>_simple</code> version performs only 1.</p>
</li>
<li><p><strong>Optimized Memory Access:</strong> <code>calculate_foam</code> now performs only a <strong>single</strong> <code>textureSampleLevel</code> and uses the R, G, and B channels of the result.</p>
</li>
<li><p><strong>Branchless Math:</strong> The divergent <code>if/else if</code> chain in <code>calculate_foam</code> has been replaced with a mathematically equivalent, branchless version using <code>mix</code> and <code>smoothstep</code>.</p>
</li>
<li><p><strong>Built-in Functions:</strong> All manual calculations like distance and clamping have been replaced with their fast, built-in equivalents.</p>
</li>
<li><p><strong>Cached Uniforms:</strong> Values like <code>camera_pos</code> and <code>time</code> are read from the uniform buffer once at the top of the shader and stored in local variables.</p>
</li>
</ul>
<pre><code class="lang-rust">#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">OceanMaterial</span></span> {
    time: <span class="hljs-built_in">f32</span>,
    time_sin: <span class="hljs-built_in">f32</span>,
    time_cos: <span class="hljs-built_in">f32</span>,
    time_sin_slow: <span class="hljs-built_in">f32</span>,
    time_cos_slow: <span class="hljs-built_in">f32</span>,
    camera_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    wave_amplitude: <span class="hljs-built_in">f32</span>,
    wave_frequency: <span class="hljs-built_in">f32</span>,
    lod_near: <span class="hljs-built_in">f32</span>,
    lod_far: <span class="hljs-built_in">f32</span>,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: OceanMaterial;

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">1</span>)
var noise_texture: texture_2d&lt;<span class="hljs-built_in">f32</span>&gt;;

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">2</span>)
var noise_sampler: sampler;

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) world_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) foam_amount: <span class="hljs-built_in">f32</span>,
    @location(<span class="hljs-number">3</span>) wave_height: <span class="hljs-built_in">f32</span>,
    @location(<span class="hljs-number">4</span>) distance_to_camera: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-comment">// A helper function for a single directional wave. The core of a realistic water effect.</span>
<span class="hljs-comment">// It takes a 2D position and calculates the wave height at that point.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">wave</span></span>(
    position: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,  <span class="hljs-comment">// The world-space XZ position of the vertex</span>
    direction: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// A normalized 2D vector for the wave's direction</span>
    frequency: <span class="hljs-built_in">f32</span>,     <span class="hljs-comment">// Controls the distance between wave crests (higher = choppier)</span>
    amplitude: <span class="hljs-built_in">f32</span>,     <span class="hljs-comment">// Controls the height of the wave crests</span>
    speed: <span class="hljs-built_in">f32</span>,         <span class="hljs-comment">// Controls how fast the wave travels in its direction</span>
    time: <span class="hljs-built_in">f32</span>           <span class="hljs-comment">// The global time uniform to animate the wave</span>
) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// The dot product projects the vertex position onto the wave's direction vector.</span>
    <span class="hljs-comment">// This is the key to making the wave move in a specific direction instead of just along the X or Z axis.</span>
    <span class="hljs-keyword">let</span> angle = dot(direction, position);
    <span class="hljs-comment">// The classic sine wave formula, using our calculated angle.</span>
    <span class="hljs-keyword">return</span> sin(angle * frequency + time * speed) * amplitude;
}

<span class="hljs-comment">// Detailed wave calculation for nearby vertices. It sums four different directional waves.</span>
<span class="hljs-comment">// This is what creates the chaotic, overlapping, and natural-looking surface of water.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_wave_height_detailed</span></span>(pos: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> amp = material.wave_amplitude;
    <span class="hljs-keyword">let</span> freq = material.wave_frequency;
    var height = <span class="hljs-number">0.0</span>;

    <span class="hljs-comment">// We add four different waves together. Each one has a unique direction, frequency,</span>
    <span class="hljs-comment">// amplitude, and speed. This combination is what breaks the repetitive grid-like</span>
    <span class="hljs-comment">// pattern and makes the surface look organic.</span>
    height += wave(pos.xz, normalize(vec2(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>)), freq * <span class="hljs-number">1.2</span>, amp * <span class="hljs-number">0.5</span>, <span class="hljs-number">1.5</span>, time);
    height += wave(pos.xz, normalize(vec2(<span class="hljs-number">0.8</span>, <span class="hljs-number">1.0</span>)), freq * <span class="hljs-number">0.8</span>, amp * <span class="hljs-number">0.3</span>, <span class="hljs-number">1.2</span>, time);
    height += wave(pos.xz, normalize(vec2(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.3</span>)), freq * <span class="hljs-number">2.2</span>, amp * <span class="hljs-number">0.15</span>, <span class="hljs-number">2.1</span>, time);
    height += wave(pos.xz, normalize(vec2(-<span class="hljs-number">0.2</span>, <span class="hljs-number">1.0</span>)), freq * <span class="hljs-number">1.5</span>, amp * <span class="hljs-number">0.25</span>, <span class="hljs-number">1.8</span>, time);

    <span class="hljs-keyword">return</span> height;
}

<span class="hljs-comment">// A much cheaper version for medium-distance vertices.</span>
<span class="hljs-comment">// It only calculates two of the four waves, saving half the work.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_wave_height_simple</span></span>(pos: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> amp = material.wave_amplitude;
    <span class="hljs-keyword">let</span> freq = material.wave_frequency;
    var height = <span class="hljs-number">0.0</span>;

    <span class="hljs-comment">// We use the two largest, most noticeable waves for the medium LOD.</span>
    <span class="hljs-comment">// The smaller, high-frequency detail waves are skipped as they wouldn't be visible from a distance anyway.</span>
    height += wave(pos.xz, normalize(vec2(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>)), freq * <span class="hljs-number">1.2</span>, amp * <span class="hljs-number">0.5</span>, <span class="hljs-number">1.5</span>, time);
    height += wave(pos.xz, normalize(vec2(<span class="hljs-number">0.8</span>, <span class="hljs-number">1.0</span>)), freq * <span class="hljs-number">0.8</span>, amp * <span class="hljs-number">0.3</span>, <span class="hljs-number">1.2</span>, time);
    <span class="hljs-keyword">return</span> height;
}

<span class="hljs-comment">// Foam calculation with optimized texture sampling</span>
<span class="hljs-comment">// ✓ Single texture sample (vs 3 in unoptimized)</span>
<span class="hljs-comment">// ✓ Uses all RGB channels from one sample</span>
<span class="hljs-comment">// ✓ Branchless calculation using smoothstep (vs if/else branching)</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_foam</span></span>(pos: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, wave_height: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// ✓ Single texture sample, use all channels</span>
    <span class="hljs-comment">// We start with our world position, scaled for the desired tiling size.</span>
    <span class="hljs-keyword">let</span> tiling_coords = pos.xz * <span class="hljs-number">0.05</span>;

    <span class="hljs-comment">// We use fract() to wrap the coordinates into the [0.0, 1.0] range.</span>
    <span class="hljs-keyword">let</span> uv = fract(tiling_coords);

    <span class="hljs-comment">// We now sample the texture using these correctly wrapped UVs.</span>
    <span class="hljs-keyword">let</span> noise_sample = textureSampleLevel(
        noise_texture,
        noise_sampler,
        uv,
        <span class="hljs-number">0.0</span>
    );

    <span class="hljs-comment">// ✓ Branchless foam calculation using smoothstep</span>
    <span class="hljs-keyword">let</span> t1 = smoothstep(<span class="hljs-number">0.25</span>, <span class="hljs-number">0.5</span>, wave_height);
    <span class="hljs-keyword">let</span> t2 = smoothstep(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.75</span>, wave_height);
    <span class="hljs-keyword">let</span> foam = mix(noise_sample.b, mix(noise_sample.g, noise_sample.r, t1), t2);

    <span class="hljs-comment">// ✓ Use built-in clamp</span>
    <span class="hljs-keyword">return</span> clamp(foam, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-comment">// ✓ Cache uniform values</span>
    <span class="hljs-keyword">let</span> camera_pos = material.camera_position;
    <span class="hljs-keyword">let</span> time = material.time;
    <span class="hljs-keyword">let</span> lod_near = material.lod_near;
    <span class="hljs-keyword">let</span> lod_far = material.lod_far;

    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
    <span class="hljs-keyword">let</span> world_position = mesh_functions::mesh_position_local_to_world(
        model,
        vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">1.0</span>)
    );

    <span class="hljs-comment">// ✓ Use built-in distance function</span>
    <span class="hljs-keyword">let</span> distance_to_camera = distance(world_position.xyz, camera_pos);

    <span class="hljs-comment">// ✓ LOD system: adjust detail based on distance</span>
    var wave_height: <span class="hljs-built_in">f32</span>;
    var foam: <span class="hljs-built_in">f32</span>;

    <span class="hljs-keyword">if</span> distance_to_camera &lt; lod_near {
        <span class="hljs-comment">// Close: Full quality (2 octaves)</span>
        wave_height = calculate_wave_height_detailed(world_position.xyz, time);
        foam = calculate_foam(world_position.xyz, wave_height);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> distance_to_camera &lt; lod_far {
        <span class="hljs-comment">// Medium: Simplified waves (1 octave), no foam</span>
        wave_height = calculate_wave_height_simple(world_position.xyz, time);
        foam = <span class="hljs-number">0.0</span>;
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// Far: Minimal processing</span>
        wave_height = <span class="hljs-number">0.0</span>;
        foam = <span class="hljs-number">0.0</span>;
    }

    var displaced_position = world_position.xyz;
    displaced_position.y += wave_height;

    <span class="hljs-comment">// Pass normal through (will be normalized in fragment shader after interpolation)</span>
    <span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(
        <span class="hljs-keyword">in</span>.normal,
        <span class="hljs-keyword">in</span>.instance_index
    );

    out.clip_position = position_world_to_clip(displaced_position);
    out.world_position = displaced_position;
    out.world_normal = world_normal;
    out.foam_amount = foam;
    out.wave_height = wave_height;
    out.distance_to_camera = distance_to_camera;

    <span class="hljs-keyword">return</span> out;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Normalize after interpolation</span>
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);

    <span class="hljs-comment">// Simple directional lighting</span>
    <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.0</span>, dot(normal, light_dir)) * <span class="hljs-number">0.7</span>;
    <span class="hljs-keyword">let</span> ambient = <span class="hljs-number">0.3</span>;

    <span class="hljs-comment">// Ocean color</span>
    <span class="hljs-keyword">let</span> base_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.1</span>, <span class="hljs-number">0.3</span>, <span class="hljs-number">0.5</span>);
    <span class="hljs-keyword">let</span> foam_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.9</span>, <span class="hljs-number">0.9</span>, <span class="hljs-number">0.95</span>);

    <span class="hljs-comment">// Mix base and foam based on calculated amount</span>
    <span class="hljs-keyword">let</span> final_color = mix(base_color, foam_color, <span class="hljs-keyword">in</span>.foam_amount);
    <span class="hljs-keyword">let</span> lit_color = final_color * (ambient + diffuse);

    <span class="hljs-comment">// Optional: Fade distant ocean to horizon color</span>
    <span class="hljs-keyword">let</span> fade = smoothstep(<span class="hljs-number">100.0</span>, <span class="hljs-number">50.0</span>, <span class="hljs-keyword">in</span>.distance_to_camera);
    <span class="hljs-keyword">let</span> horizon_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.6</span>, <span class="hljs-number">0.7</span>, <span class="hljs-number">0.8</span>);
    <span class="hljs-keyword">let</span> color_with_distance = mix(horizon_color, lit_color, fade);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color_with_distance, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h4 id="heading-the-optimized-rust-material-srcmaterialsd0208oceanoptimizedrs">The Optimized Rust Material (<code>src/materials/d02_08_ocean_optimized.rs</code>)</h4>
<p>The Rust code for our optimized material is very similar to the unoptimized one, but we add the new <code>lod_near</code> and <code>lod_far</code> fields to the <code>OceanUniforms</code> struct. This allows us to control the Level of Detail distances from our Bevy application.</p>
<p>We also include the <code>time_sin</code> and <code>time_cos</code> fields to match the WGSL struct, even though they aren't used for the wave effect itself. This demonstrates how you would pass precomputed values if you had other, non-position-dependent effects.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>

    <span class="hljs-keyword">use</span> bevy::prelude::*;
    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone, Copy)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">OceanUniforms</span></span> {
        <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> time_sin: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> time_cos: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> time_sin_slow: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> time_cos_slow: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> camera_position: Vec3,
        <span class="hljs-keyword">pub</span> wave_amplitude: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> wave_frequency: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> lod_near: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> lod_far: <span class="hljs-built_in">f32</span>,
    }

    <span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> OceanUniforms {
        <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
            <span class="hljs-keyword">Self</span> {
                time: <span class="hljs-number">0.0</span>,
                time_sin: <span class="hljs-number">0.0</span>,
                time_cos: <span class="hljs-number">1.0</span>,
                time_sin_slow: <span class="hljs-number">0.0</span>,
                time_cos_slow: <span class="hljs-number">1.0</span>,
                camera_position: Vec3::ZERO,
                wave_amplitude: <span class="hljs-number">0.5</span>,
                wave_frequency: <span class="hljs-number">0.5</span>, <span class="hljs-comment">// Lower frequency for more visible waves</span>
                lod_near: <span class="hljs-number">80.0</span>,      <span class="hljs-comment">// Increased to show waves for most of the ocean</span>
                lod_far: <span class="hljs-number">120.0</span>,      <span class="hljs-comment">// Increased to match scene size</span>
            }
        }
    }
}

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">use</span> uniforms::OceanUniforms;

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">OceanMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: OceanUniforms,

    <span class="hljs-meta">#[texture(1)]</span>
    <span class="hljs-meta">#[sampler(2)]</span>
    <span class="hljs-keyword">pub</span> noise_texture: Handle&lt;Image&gt;,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> OceanMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_08_ocean_optimized.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_08_ocean_optimized.wgsl"</span>.into()
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other materials</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_08_ocean_unoptimized;
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_08_ocean_optimized;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0208oceandemors">The Demo Module (<code>src/demos/d02_08_ocean_demo.rs</code>)</h3>
<p>This is the core of our project. This Bevy application sets up a scene with a single large ocean plane and allows the user to press a key to hot-swap the material between the unoptimized and optimized versions.</p>
<p>Key components of this file:</p>
<ul>
<li><p><strong>OceanMaterials Resource:</strong> We create a custom resource to hold the handles for both our <code>OceanMaterial</code> and <code>OceanMaterialUnoptimized</code>. This gives us a single, reliable place to access them from any system.</p>
</li>
<li><p><code>setup</code> function: Creates the high-vertex ocean mesh, generates a procedural noise texture, and creates both material assets. It spawns the ocean entity initially using the <strong>optimized</strong> material.</p>
</li>
<li><p><code>toggle_optimization</code> system: This is the magic. When the user presses <code>P</code>, this system gets the <code>Entity</code> of our ocean plane. It then uses <code>commands</code> to remove the currently active material component (e.g., <code>MeshMaterial3d&lt;OceanMaterial&gt;</code>) and insert the other one (e.g., <code>MeshMaterial3d&lt;OceanMaterialUnoptimized&gt;</code>). Bevy's renderer detects this change and uses the new shader on the next frame.</p>
</li>
<li><p><code>update_ocean_materials</code> system: This system runs every frame to update the <code>time</code> and <code>camera_position</code> uniforms for <em>both</em> materials, ensuring a seamless visual transition when toggling.</p>
</li>
<li><p><strong>Other Systems:</strong> Standard systems are included to handle camera controls, UI updates, and input for adjusting wave parameters.</p>
</li>
</ul>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d02_08_ocean_optimized::{OceanMaterial, OceanUniforms};
<span class="hljs-keyword">use</span> crate::materials::d02_08_ocean_unoptimized::{OceanMaterialUnoptimized, OceanUnoptimizedUniforms};
<span class="hljs-keyword">use</span> bevy::diagnostic::{FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin};
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> std::<span class="hljs-built_in">f32</span>::consts::PI;

<span class="hljs-meta">#[derive(Component)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">OceanPlane</span></span>;

<span class="hljs-meta">#[derive(Component)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">OrbitCamera</span></span> {
    radius: <span class="hljs-built_in">f32</span>,
    angle: <span class="hljs-built_in">f32</span>,
    height: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-comment">// Resource to store both material handles</span>
<span class="hljs-meta">#[derive(Resource)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">OceanMaterials</span></span> {
    optimized: Handle&lt;OceanMaterial&gt;,
    unoptimized: Handle&lt;OceanMaterialUnoptimized&gt;,
    using_optimized: <span class="hljs-built_in">bool</span>,
}

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(FrameTimeDiagnosticsPlugin::default())
        .add_plugins(LogDiagnosticsPlugin::default())
        .add_plugins(MaterialPlugin::&lt;OceanMaterial&gt;::default())
        .add_plugins(MaterialPlugin::&lt;OceanMaterialUnoptimized&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (
                update_ocean_materials,
                handle_input,
                update_camera,
                toggle_optimization,
                update_ui,
            ),
        )
        .run();
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> optimized_materials: ResMut&lt;Assets&lt;OceanMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> unoptimized_materials: ResMut&lt;Assets&lt;OceanMaterialUnoptimized&gt;&gt;,
    <span class="hljs-keyword">mut</span> images: ResMut&lt;Assets&lt;Image&gt;&gt;,
) {
    <span class="hljs-comment">// Generate noise texture</span>
    <span class="hljs-keyword">let</span> noise_texture = generate_noise_texture(<span class="hljs-number">256</span>);
    <span class="hljs-keyword">let</span> noise_handle = images.add(noise_texture);

    <span class="hljs-comment">// Create ocean plane with high vertex count</span>
    <span class="hljs-keyword">let</span> ocean_mesh = create_ocean_plane(<span class="hljs-number">200</span>, <span class="hljs-number">200</span>, <span class="hljs-number">100.0</span>);
    <span class="hljs-keyword">let</span> mesh_handle = meshes.add(ocean_mesh);

    <span class="hljs-built_in">println!</span>(<span class="hljs-string">"Ocean mesh: 200x200 grid = 40,000 vertices"</span>);

    <span class="hljs-comment">// Create both materials</span>
    <span class="hljs-keyword">let</span> optimized_handle = optimized_materials.add(OceanMaterial {
        uniforms: OceanUniforms::default(),
        noise_texture: noise_handle.clone(),
    });

    <span class="hljs-keyword">let</span> unoptimized_handle = unoptimized_materials.add(OceanMaterialUnoptimized {
        uniforms: OceanUnoptimizedUniforms::default(),
        noise_texture: noise_handle.clone(),
    });

    <span class="hljs-comment">// Store material handles in a resource</span>
    commands.insert_resource(OceanMaterials {
        optimized: optimized_handle.clone(),
        unoptimized: unoptimized_handle.clone(),
        using_optimized: <span class="hljs-literal">true</span>,
    });

    <span class="hljs-comment">// Spawn ocean with optimized material initially</span>
    commands.spawn((
        Mesh3d(mesh_handle.clone()),
        MeshMaterial3d(optimized_handle.clone()),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        OceanPlane,
    ));

    <span class="hljs-comment">// Lighting</span>
    commands.spawn((
        DirectionalLight {
            illuminance: <span class="hljs-number">15000.0</span>,
            shadows_enabled: <span class="hljs-literal">false</span>,
            ..default()
        },
        Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -PI / <span class="hljs-number">3.0</span>, PI / <span class="hljs-number">4.0</span>, <span class="hljs-number">0.0</span>)),
    ));

    <span class="hljs-comment">// Camera</span>
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">30.0</span>, <span class="hljs-number">50.0</span>).looking_at(Vec3::ZERO, Vec3::Y),
        OrbitCamera {
            radius: <span class="hljs-number">50.0</span>,
            angle: <span class="hljs-number">0.0</span>,
            height: <span class="hljs-number">30.0</span>,
        },
    ));

    <span class="hljs-comment">// UI</span>
    commands.spawn((
        Text::new(
            <span class="hljs-string">"[P] Toggle Optimized/Unoptimized Shader\n\
             [Arrow Keys] Rotate Camera | [Z/X] Camera Height\n\
             [+/-] Wave Amplitude | [[ / ]] Wave Frequency\n\
             [1/2] LOD Near Distance (optimized only)\n\
             \n\
             Mode: OPTIMIZED | FPS: -- | Vertices: 40,000"</span>,
        ),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">10.0</span>),
            left: Val::Px(<span class="hljs-number">10.0</span>),
            padding: UiRect::all(Val::Px(<span class="hljs-number">10.0</span>)),
            ..default()
        },
        TextFont {
            font_size: <span class="hljs-number">16.0</span>,
            ..default()
        },
        TextColor(Color::WHITE),
        BackgroundColor(Color::srgba(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.7</span>)),
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">create_ocean_plane</span></span>(width_segments: <span class="hljs-built_in">u32</span>, height_segments: <span class="hljs-built_in">u32</span>, size: <span class="hljs-built_in">f32</span>) -&gt; Mesh {
    <span class="hljs-keyword">use</span> bevy::render::mesh::{Indices, PrimitiveTopology};
    <span class="hljs-keyword">use</span> bevy::render::render_asset::RenderAssetUsages;

    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> positions = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> normals = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> uvs = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> indices = <span class="hljs-built_in">Vec</span>::new();

    <span class="hljs-keyword">for</span> y <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..=height_segments {
        <span class="hljs-keyword">for</span> x <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..=width_segments {
            <span class="hljs-keyword">let</span> u = x <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / width_segments <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span>;
            <span class="hljs-keyword">let</span> v = y <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / height_segments <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span>;

            <span class="hljs-keyword">let</span> pos_x = (u - <span class="hljs-number">0.5</span>) * size;
            <span class="hljs-keyword">let</span> pos_z = (v - <span class="hljs-number">0.5</span>) * size;

            positions.push([pos_x, <span class="hljs-number">0.0</span>, pos_z]);
            normals.push([<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>]);
            uvs.push([u, v]);
        }
    }

    <span class="hljs-keyword">for</span> y <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..height_segments {
        <span class="hljs-keyword">for</span> x <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..width_segments {
            <span class="hljs-keyword">let</span> quad_start = y * (width_segments + <span class="hljs-number">1</span>) + x;

            indices.push(quad_start);
            indices.push(quad_start + width_segments + <span class="hljs-number">1</span>);
            indices.push(quad_start + <span class="hljs-number">1</span>);

            indices.push(quad_start + <span class="hljs-number">1</span>);
            indices.push(quad_start + width_segments + <span class="hljs-number">1</span>);
            indices.push(quad_start + width_segments + <span class="hljs-number">2</span>);
        }
    }

    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> mesh = Mesh::new(
        PrimitiveTopology::TriangleList,
        RenderAssetUsages::default(),
    );

    mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
    mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
    mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
    mesh.insert_indices(Indices::U32(indices));

    mesh
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">generate_noise_texture</span></span>(size: <span class="hljs-built_in">u32</span>) -&gt; Image {
    <span class="hljs-keyword">use</span> bevy::render::render_asset::RenderAssetUsages;
    <span class="hljs-keyword">use</span> bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
    <span class="hljs-keyword">use</span> noise::{NoiseFn, Perlin};
    <span class="hljs-keyword">use</span> std::<span class="hljs-built_in">f64</span>::consts::PI;

    <span class="hljs-comment">// Use a 4D Perlin noise function for generating seamless 2D noise.</span>
    <span class="hljs-keyword">let</span> perlin = Perlin::new(<span class="hljs-number">42</span>);
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> data = <span class="hljs-built_in">Vec</span>::with_capacity((size * size * <span class="hljs-number">4</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">usize</span>);

    <span class="hljs-keyword">for</span> y <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..size {
        <span class="hljs-keyword">for</span> x <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..size {
            <span class="hljs-comment">// Map the 2D coordinates to a circle in 4D space.</span>
            <span class="hljs-comment">// This is the mathematical trick to make the noise tileable.</span>
            <span class="hljs-keyword">let</span> angle_x = (x <span class="hljs-keyword">as</span> <span class="hljs-built_in">f64</span> / size <span class="hljs-keyword">as</span> <span class="hljs-built_in">f64</span>) * <span class="hljs-number">2.0</span> * PI;
            <span class="hljs-keyword">let</span> angle_y = (y <span class="hljs-keyword">as</span> <span class="hljs-built_in">f64</span> / size <span class="hljs-keyword">as</span> <span class="hljs-built_in">f64</span>) * <span class="hljs-number">2.0</span> * PI;

            <span class="hljs-comment">// We use sin/cos to wrap the coordinates around, ensuring the</span>
            <span class="hljs-comment">// start and end points of the texture match up perfectly.</span>
            <span class="hljs-keyword">let</span> p_x = angle_x.cos();
            <span class="hljs-keyword">let</span> p_y = angle_x.sin();
            <span class="hljs-keyword">let</span> p_z = angle_y.cos();
            <span class="hljs-keyword">let</span> p_w = angle_y.sin();

            <span class="hljs-comment">// The scale factor determines the "zoom" of the noise pattern.</span>
            <span class="hljs-keyword">let</span> scale = <span class="hljs-number">2.0</span>;
            <span class="hljs-keyword">let</span> noise_value = perlin.get([p_x * scale, p_y * scale, p_z * scale, p_w * scale]);

            <span class="hljs-comment">// Map the noise value from [-1, 1] to [0, 255] for the texture.</span>
            <span class="hljs-keyword">let</span> byte_value = ((noise_value + <span class="hljs-number">1.0</span>) * <span class="hljs-number">0.5</span> * <span class="hljs-number">255.0</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">u8</span>;

            data.push(byte_value); <span class="hljs-comment">// R</span>
            data.push(byte_value); <span class="hljs-comment">// G</span>
            data.push(byte_value); <span class="hljs-comment">// B</span>
            data.push(<span class="hljs-number">255</span>); <span class="hljs-comment">// A</span>
        }
    }

    Image::new(
        Extent3d {
            width: size,
            height: size,
            depth_or_array_layers: <span class="hljs-number">1</span>,
        },
        TextureDimension::D2,
        data,
        TextureFormat::Rgba8Unorm,
        RenderAssetUsages::default(),
    )
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ocean_materials</span></span>(
    time: Res&lt;Time&gt;,
    camera_query: Query&lt;&amp;Transform, With&lt;Camera3d&gt;&gt;,
    <span class="hljs-keyword">mut</span> optimized_materials: ResMut&lt;Assets&lt;OceanMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> unoptimized_materials: ResMut&lt;Assets&lt;OceanMaterialUnoptimized&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> t = time.elapsed_secs();

    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Ok</span>(camera_transform) = camera_query.single() {
        <span class="hljs-comment">// Update optimized material (with precomputed values)</span>
        <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> optimized_materials.iter_mut() {
            material.uniforms.time = t;
            material.uniforms.time_sin = t.sin();
            material.uniforms.time_cos = t.cos();
            material.uniforms.time_sin_slow = (t * <span class="hljs-number">0.5</span>).sin();
            material.uniforms.time_cos_slow = (t * <span class="hljs-number">0.5</span>).cos();
            material.uniforms.camera_position = camera_transform.translation;
        }

        <span class="hljs-comment">// Update unoptimized material (just time and camera)</span>
        <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> unoptimized_materials.iter_mut() {
            material.uniforms.time = t;
            material.uniforms.camera_position = camera_transform.translation;
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    time: Res&lt;Time&gt;,
    <span class="hljs-keyword">mut</span> optimized_materials: ResMut&lt;Assets&lt;OceanMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> unoptimized_materials: ResMut&lt;Assets&lt;OceanMaterialUnoptimized&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> delta = time.delta_secs();

    <span class="hljs-comment">// Update wave amplitude for optimized materials</span>
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> optimized_materials.iter_mut() {
        <span class="hljs-comment">// Wave amplitude controls</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::Equal) {
            material.uniforms.wave_amplitude =
                (material.uniforms.wave_amplitude + delta * <span class="hljs-number">0.5</span>).min(<span class="hljs-number">3.0</span>);
            <span class="hljs-built_in">println!</span>(<span class="hljs-string">"Wave amplitude: {:.2}"</span>, material.uniforms.wave_amplitude);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::Minus) {
            material.uniforms.wave_amplitude =
                (material.uniforms.wave_amplitude - delta * <span class="hljs-number">0.5</span>).max(<span class="hljs-number">0.0</span>);
            <span class="hljs-built_in">println!</span>(<span class="hljs-string">"Wave amplitude: {:.2}"</span>, material.uniforms.wave_amplitude);
        }

        <span class="hljs-comment">// Wave frequency controls</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::BracketRight) {
            material.uniforms.wave_frequency =
                (material.uniforms.wave_frequency + delta * <span class="hljs-number">0.2</span>).min(<span class="hljs-number">2.0</span>);
            <span class="hljs-built_in">println!</span>(<span class="hljs-string">"Wave frequency: {:.2}"</span>, material.uniforms.wave_frequency);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::BracketLeft) {
            material.uniforms.wave_frequency =
                (material.uniforms.wave_frequency - delta * <span class="hljs-number">0.2</span>).max(<span class="hljs-number">0.1</span>);
            <span class="hljs-built_in">println!</span>(<span class="hljs-string">"Wave frequency: {:.2}"</span>, material.uniforms.wave_frequency);
        }

        <span class="hljs-comment">// LOD distance controls</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::Digit1) {
            material.uniforms.lod_near = (material.uniforms.lod_near + delta * <span class="hljs-number">10.0</span>).min(<span class="hljs-number">200.0</span>);
            <span class="hljs-built_in">println!</span>(
                <span class="hljs-string">"LOD near: {:.1}, far: {:.1}"</span>,
                material.uniforms.lod_near, material.uniforms.lod_far
            );
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::Digit2) {
            material.uniforms.lod_near = (material.uniforms.lod_near - delta * <span class="hljs-number">10.0</span>).max(<span class="hljs-number">10.0</span>);
            <span class="hljs-built_in">println!</span>(
                <span class="hljs-string">"LOD near: {:.1}, far: {:.1}"</span>,
                material.uniforms.lod_near, material.uniforms.lod_far
            );
        }
    }

    <span class="hljs-comment">// Update wave amplitude for unoptimized materials</span>
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> unoptimized_materials.iter_mut() {
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::Equal) {
            material.uniforms.wave_amplitude =
                (material.uniforms.wave_amplitude + delta * <span class="hljs-number">0.5</span>).min(<span class="hljs-number">3.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::Minus) {
            material.uniforms.wave_amplitude =
                (material.uniforms.wave_amplitude - delta * <span class="hljs-number">0.5</span>).max(<span class="hljs-number">0.0</span>);
        }

        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::BracketRight) {
            material.uniforms.wave_frequency =
                (material.uniforms.wave_frequency + delta * <span class="hljs-number">0.2</span>).min(<span class="hljs-number">2.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::BracketLeft) {
            material.uniforms.wave_frequency =
                (material.uniforms.wave_frequency - delta * <span class="hljs-number">0.2</span>).max(<span class="hljs-number">0.1</span>);
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_camera</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    time: Res&lt;Time&gt;,
    <span class="hljs-keyword">mut</span> camera_query: Query&lt;(&amp;<span class="hljs-keyword">mut</span> Transform, &amp;<span class="hljs-keyword">mut</span> OrbitCamera), With&lt;Camera3d&gt;&gt;,
) {
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Ok</span>((<span class="hljs-keyword">mut</span> transform, <span class="hljs-keyword">mut</span> orbit)) = camera_query.single_mut() {
        <span class="hljs-keyword">let</span> delta = time.delta_secs();

        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowLeft) {
            orbit.angle += delta;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowRight) {
            orbit.angle -= delta;
        }

        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyZ) {
            orbit.height = (orbit.height - delta * <span class="hljs-number">20.0</span>).max(<span class="hljs-number">5.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyX) {
            orbit.height = (orbit.height + delta * <span class="hljs-number">20.0</span>).min(<span class="hljs-number">100.0</span>);
        }

        <span class="hljs-keyword">let</span> x = orbit.angle.cos() * orbit.radius;
        <span class="hljs-keyword">let</span> z = orbit.angle.sin() * orbit.radius;

        transform.translation = Vec3::new(x, orbit.height, z);
        transform.look_at(Vec3::ZERO, Vec3::Y);
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">toggle_optimization</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials_res: ResMut&lt;OceanMaterials&gt;,
    <span class="hljs-keyword">mut</span> commands: Commands,
    ocean_entity: Query&lt;Entity, With&lt;OceanPlane&gt;&gt;,
) {
    <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyP) {
        materials_res.using_optimized = !materials_res.using_optimized;

        <span class="hljs-built_in">println!</span>(
            <span class="hljs-string">"Switched to {} shader"</span>,
            <span class="hljs-keyword">if</span> materials_res.using_optimized {
                <span class="hljs-string">"OPTIMIZED"</span>
            } <span class="hljs-keyword">else</span> {
                <span class="hljs-string">"UNOPTIMIZED"</span>
            }
        );

        <span class="hljs-comment">// Get the ocean entity</span>
        <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Ok</span>(entity) = ocean_entity.single() {
            <span class="hljs-comment">// Remove old material component and add new one</span>
            <span class="hljs-keyword">if</span> materials_res.using_optimized {
                <span class="hljs-comment">// Switch to optimized</span>
                commands
                    .entity(entity)
                    .remove::&lt;MeshMaterial3d&lt;OceanMaterialUnoptimized&gt;&gt;()
                    .insert(MeshMaterial3d(materials_res.optimized.clone()));
            } <span class="hljs-keyword">else</span> {
                <span class="hljs-comment">// Switch to unoptimized</span>
                commands
                    .entity(entity)
                    .remove::&lt;MeshMaterial3d&lt;OceanMaterial&gt;&gt;()
                    .insert(MeshMaterial3d(materials_res.unoptimized.clone()));
            }
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(
    diagnostics: Res&lt;bevy::diagnostic::DiagnosticsStore&gt;,
    materials_res: Res&lt;OceanMaterials&gt;,
    optimized_materials: Res&lt;Assets&lt;OceanMaterial&gt;&gt;,
    unoptimized_materials: Res&lt;Assets&lt;OceanMaterialUnoptimized&gt;&gt;,
    <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;,
) {
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>(fps_diagnostic) = diagnostics.get(&amp;FrameTimeDiagnosticsPlugin::FPS) {
        <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>(fps_smoothed) = fps_diagnostic.smoothed() {
            <span class="hljs-keyword">let</span> mode_text = <span class="hljs-keyword">if</span> materials_res.using_optimized {
                <span class="hljs-string">"OPTIMIZED"</span>
            } <span class="hljs-keyword">else</span> {
                <span class="hljs-string">"UNOPTIMIZED"</span>
            };

            <span class="hljs-comment">// Get current material settings</span>
            <span class="hljs-keyword">let</span> (wave_amp, wave_freq, lod_info) = <span class="hljs-keyword">if</span> materials_res.using_optimized {
                <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>(mat) = optimized_materials.get(&amp;materials_res.optimized) {
                    (
                        mat.uniforms.wave_amplitude,
                        mat.uniforms.wave_frequency,
                        <span class="hljs-built_in">format!</span>(
                            <span class="hljs-string">" | LOD: {:.0}/{:.0}"</span>,
                            mat.uniforms.lod_near, mat.uniforms.lod_far
                        ),
                    )
                } <span class="hljs-keyword">else</span> {
                    (<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>, <span class="hljs-built_in">String</span>::new())
                }
            } <span class="hljs-keyword">else</span> {
                <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>(mat) = unoptimized_materials.get(&amp;materials_res.unoptimized) {
                    (
                        mat.uniforms.wave_amplitude,
                        mat.uniforms.wave_frequency,
                        <span class="hljs-built_in">String</span>::new(),
                    )
                } <span class="hljs-keyword">else</span> {
                    (<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>, <span class="hljs-built_in">String</span>::new())
                }
            };

            <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
                **text = <span class="hljs-built_in">format!</span>(
                    <span class="hljs-string">"[P] Toggle Optimized/Unoptimized Shader\n\
                     [Arrow Keys] Rotate Camera | [Z/X] Camera Height\n\
                     [+/-] Wave Amplitude | [[ / ]] Wave Frequency\n\
                     [1/2] LOD Near Distance (optimized only)\n\
                     \n\
                     Mode: {} | FPS: {:.0} | Vertices: 40,000\n\
                     Amplitude: {:.2} | Frequency: {:.2}{}"</span>,
                    mode_text, fps_smoothed, wave_amp, wave_freq, lod_info
                );
            }
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other demos</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_08_ocean_demo;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"2.8"</span>,
    title: <span class="hljs-string">"Vertex Shader Optimization"</span>,
    run: demos::d02_08_ocean_demo::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>Now that we have all the code in place, run the application. You'll see a large ocean plane with rolling waves and sea foam. The UI displays the current performance and allows you to control the scene. The goal of this demo is not necessarily to see a slideshow turn into a smooth experience, but to directly compare the <strong>relative cost</strong> of two different approaches to writing a shader.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key</td><td>Action</td></tr>
</thead>
<tbody>
<tr>
<td><strong>P</strong></td><td>Toggle between the Optimized/Unoptimized shader.</td></tr>
<tr>
<td><strong>Arrow Keys</strong></td><td>Orbit the camera around the center of the ocean.</td></tr>
<tr>
<td><strong>Z / X</strong></td><td>Lower / Raise the camera height.</td></tr>
<tr>
<td><strong>+ / -</strong></td><td>Increase / Decrease the wave amplitude.</td></tr>
<tr>
<td><strong>[ / ]</strong></td><td>Decrease / Increase the wave frequency.</td></tr>
<tr>
<td><strong>1 / 2</strong></td><td>Adjust the LOD near distance (Optimized only).</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763211775000/f75a52a9-25fa-49ac-b683-489848529241.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763211789054/9828f26a-196f-4856-8495-533e06c86dcb.png" alt class="image--center mx-auto" /></p>
<p>This is the core of the lesson. Press <strong>P</strong> to toggle between the two shaders and watch the "FPS" counter in the UI.</p>
<p>The performance difference you see will depend heavily on your GPU.</p>
<ul>
<li><p>On a lower-end or older GPU, you may see a very significant FPS difference, illustrating the real-world cost of the unoptimized code.</p>
</li>
<li><p>On a high-end, modern GPU, both versions might run at a high frame rate. However, the unoptimized shader will be using significantly more of your GPU's power to do so. In a real game, that wasted power would mean less budget for everything else - other models, other effects, and higher resolutions.</p>
</li>
</ul>
<p>The key takeaway is that the unoptimized version is demonstrably more expensive, and this demo gives you a tool to see that cost on your own hardware.</p>
<h3 id="heading-optimization-breakdown">Optimization Breakdown</h3>
<p>In our simple demo, some of the individual changes we've made might only provide a small, or even unmeasurable, FPS boost on a modern GPU. However, in a large-scale game with millions of vertices, complex lighting, and dozens of different materials, these optimizations are not just good practice; they are the difference between a playable frame rate and an unworkable one.</p>
<p>Let's break down the cost of each anti-pattern in the unoptimized shader and how the optimized version solves it.</p>
<h4 id="heading-anti-pattern-1-redundant-texture-samples">Anti-Pattern 1: Redundant Texture Samples</h4>
<ul>
<li><p><strong>The Cost:</strong> The unoptimized <code>calculate_foam</code> function calls <code>textureSampleLevel</code> three times per vertex. For our 40,000 vertex mesh, this is <strong>120,000</strong> expensive memory fetches per frame just for the foam. In a real game with multiple textures per material, this kind of redundant sampling can quickly become a major memory bottleneck.</p>
</li>
<li><p><strong>The Solution:</strong> The optimized version samples the texture only <strong>once</strong> and stores the result in a local <code>vec4</code> variable. It then reuses the <code>.r</code>, <code>.g</code>, and <code>.b</code> components of this variable. This is a 3x reduction in memory fetches for this effect, a critical habit for writing scalable code.</p>
</li>
</ul>
<h4 id="heading-anti-pattern-2-divergent-branching">Anti-Pattern 2: Divergent Branching</h4>
<ul>
<li><p><strong>The Cost:</strong> The <code>if/else if/else</code> chain in the unoptimized foam logic is highly divergent, forcing parallel GPU hardware to run sequentially. While modern GPUs have improved branch prediction, this is still fundamentally inefficient and forces vertices in a wavefront to wait on each other.</p>
</li>
<li><p><strong>The Solution:</strong> The optimized version uses a branchless equivalent with <code>smoothstep</code> and <code>mix</code>. This transforms the problem from "choosing which code to run" to "calculating a value," which is what GPUs are designed to do with maximum efficiency.</p>
</li>
</ul>
<h4 id="heading-anti-pattern-3-no-level-of-detail-lod">Anti-Pattern 3: No Level of Detail (LOD)</h4>
<ul>
<li><p><strong>The Cost:</strong> The unoptimized shader calculates 6 complex waves for every single vertex, even those that are just a few pixels wide on the horizon. This is the single biggest waste of computation in the shader.</p>
</li>
<li><p><strong>The Solution:</strong> The optimized shader implements a simple LOD system. This is the highest-impact optimization. By running cheaper math (or no math at all) for the vast majority of vertices that are far from the camera, we save millions of expensive <code>sin()</code> calculations per frame. This is a foundational technique for rendering large, detailed worlds.</p>
</li>
</ul>
<h4 id="heading-anti-pattern-4-manual-math-and-incorrect-data-handling">Anti-Pattern 4: Manual Math and Incorrect Data Handling</h4>
<ul>
<li><p><strong>The Cost:</strong> The unoptimized shader uses several less-than-ideal practices: it manually calculates distance instead of using the optimized built-in, and it passes incorrect coordinates to the texture sampler. While a single manual distance calculation might be immeasurably slow on its own, thousands of these small inefficiencies across a large project add up to a significant performance drain.</p>
</li>
<li><p><strong>The Solution:</strong> The optimized version uses best practices. It uses the fast, built-in <code>distance()</code> function. It uses <code>fract()</code> to correctly wrap coordinates for texture tiling. These are "micro-optimizations" that, when practiced consistently, lead to robust, high-performance code that is also easier to read and maintain.</p>
</li>
</ul>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>This phase has been a deep dive into the art of vertex manipulation. Before we move on to coloring our creations, let's solidify the core principles of vertex shader programming and optimization.</p>
<ol>
<li><p><strong>Think Like the GPU: Minimize Divergence:</strong> The GPU processes vertices in lockstep. Avoid <code>if/else</code> statements that depend on per-vertex data. Whenever possible, use branchless math (<code>mix</code>, <code>step</code>, <code>clamp</code>) to turn control flow problems into data calculation problems.</p>
</li>
<li><p><strong>Do Less Work:</strong> The most significant performance gains often come from simply doing less. A Level of Detail (LOD) system that runs cheaper calculations for distant objects is the most powerful tool in your optimization arsenal.</p>
</li>
<li><p><strong>Work Efficiently:</strong> Your shader's performance is not just about the math, but how you access memory. Minimize expensive texture fetches by packing data into RGBA channels and sampling only once. Always prefer the GPU's highly-optimized built-in functions over manual implementations.</p>
</li>
<li><p><strong>Profile First, Optimize Second:</strong> Don't guess where your performance bottlenecks are. Use profiling tools to gather data, form a hypothesis, make one change, and measure the result. A data-driven approach is the hallmark of a professional graphics programmer.</p>
</li>
<li><p><strong>Correct Your Data:</strong> The most subtle bugs can come from a misunderstanding of your data's coordinate space. Ensure the coordinates you pass to functions (like <code>textureSampleLevel</code>) are in the range they expect (<code>[0, 1]</code>) by using functions like <code>fract()</code> for tiling.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>You are now equipped with the knowledge to control the position, shape, and animation of every vertex on the GPU, and how to do so for thousands of objects at once with high performance. We have completed our journey through the first major programmable stage of the graphics pipeline.</p>
<p>In the next phase, we will shift our focus from the "what" and "where" to the "how it looks." We will dive headfirst into the <strong>Fragment Shader</strong>, the part of the pipeline that runs for every pixel on your screen and is responsible for giving our creations color, texture, and life.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/31-fragment-shader-fundamentals"><strong><em>3.1 - Fragment Shaders Fundamentals</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<p>Use this section as a quick reminder of the core concepts, patterns, and functions covered in this phase.</p>
<h3 id="heading-the-golden-rule-of-shader-optimization">The Golden Rule of Shader Optimization</h3>
<p><strong>Minimize divergence, maximize uniformity.</strong> Write code that allows the GPU to perform the same simple operations on large batches of vertices in lockstep.</p>
<h3 id="heading-the-optimization-workflow">The Optimization Workflow</h3>
<ol>
<li><p><strong>Profile First:</strong> Use tools to measure performance and identify the actual bottleneck (CPU vs. GPU, Vertex vs. Fragment). Don't guess.</p>
</li>
<li><p><strong>Hypothesize:</strong> Form a theory about what is slow (e.g., "This loop is too complex").</p>
</li>
<li><p><strong>Change One Thing:</strong> Apply a single optimization strategy.</p>
</li>
<li><p><strong>Measure Again:</strong> Verify that the change had the intended positive impact.</p>
</li>
<li><p><strong>Repeat.</strong></p>
</li>
</ol>
<h3 id="heading-key-optimization-strategies-checklist">Key Optimization Strategies Checklist</h3>
<ul>
<li><p><strong>Move to CPU:</strong> Is the calculation the same for all vertices (e.g., <code>sin(time)</code>)? Pre-calculate it once on the CPU and pass it in a uniform.</p>
</li>
<li><p><strong>Implement LOD:</strong> Are you doing expensive work for distant vertices? Add a <code>distance_to_camera</code> check to run a cheaper version of the shader (or do no work at all) for far-away objects.</p>
</li>
<li><p><strong>Reduce Divergence:</strong> Do you have <code>if/else</code> statements that depend on per-vertex data like <code>position</code> or <code>uv</code>? Rewrite them using branchless math.</p>
</li>
<li><p><strong>Minimize Memory Access:</strong></p>
<ul>
<li><p>Are you calling <code>textureSample</code> multiple times? Sample once and store the result in a local <code>vec4</code>.</p>
</li>
<li><p>Are you using multiple grayscale textures? Pack them into the R, G, B, and A channels of a single texture.</p>
</li>
</ul>
</li>
<li><p><strong>Use Built-ins:</strong> Are you writing common math functions by hand (e.g., <code>v / length(v)</code>)? Replace them with the highly optimized built-in function (<code>normalize(v)</code>).</p>
</li>
<li><p><strong>Correct Your Data:</strong> Are you passing coordinates to a sampler that are outside the expected range? Use <code>fract()</code> to wrap them for correct tiling.</p>
</li>
</ul>
<h3 id="heading-branchless-patterns">Branchless Patterns</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Instead of...</td><td>Use...</td></tr>
</thead>
<tbody>
<tr>
<td>if (x &gt; threshold) { a } else { b }</td><td>let result = mix(b, a, step(threshold, x));</td></tr>
<tr>
<td>if (x &lt; 0.0) { 0.0 } else { x }</td><td>let result = max(x, 0.0);</td></tr>
<tr>
<td>if (x &gt; 1.0) { 1.0 } else if ... { x }</td><td>let result = clamp(x, 0.0, 1.0);</td></tr>
</tbody>
</table>
</div><h3 id="heading-performance-targets-for-a-60-fps-goal">Performance Targets (for a 60 FPS goal)</h3>
<ul>
<li><p><strong>Total Frame Budget:</strong> 16.6 ms</p>
</li>
<li><p><strong>Typical Vertex Shader Budget:</strong> &lt; 4 ms</p>
</li>
</ul>
<p>If a GPU profiler shows your vertex shader is taking more than a few milliseconds, it's a prime candidate for optimization.</p>
]]></content:encoded></item><item><title><![CDATA[2.7 - Instanced Rendering]]></title><description><![CDATA[What We're Learning
Instanced rendering is a fundamental optimization technique for rendering massive numbers of similar objects. It's the key to creating vast forests, dense asteroid fields, and sprawling crowds without bringing your CPU to its knee...]]></description><link>https://blog.hexbee.net/27-instanced-rendering</link><guid isPermaLink="true">https://blog.hexbee.net/27-instanced-rendering</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Fri, 05 Dec 2025 17:28:07 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1763059133575/d3f4cecd-39b8-44b5-924d-b6f92a1fe498.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>Instanced rendering is a fundamental optimization technique for rendering massive numbers of similar objects. It's the key to creating vast forests, dense asteroid fields, and sprawling crowds without bringing your CPU to its knees.</p>
<p>By the end of this article, you will understand:</p>
<ul>
<li><p>The performance problem caused by excessive draw calls and how instancing solves it.</p>
</li>
<li><p>How to use the @builtin(instance_index) WGSL attribute to create per-instance variations.</p>
</li>
<li><p>Techniques for procedural variation using world position to make instances look natural and unique.</p>
</li>
<li><p>The difference between procedural variation and using storage buffers for explicit per-instance data.</p>
</li>
<li><p>How Bevy's renderer automatically instances entities that share the same mesh and material.</p>
</li>
<li><p>The practical performance benefits and limitations of instancing, such as culling and transparency.</p>
</li>
<li><p>How to build a complete, animated field of grass with thousands of unique, swaying blades.</p>
</li>
</ul>
<h2 id="heading-the-problem-why-render-a-thousand-things-slowly">The Problem: Why Render a Thousand Things Slowly?</h2>
<p>Imagine you need to render a field with 40,000 blades of grass. A straightforward approach might be to create 40,000 entities, each with its own mesh, material, and transform, and then ask the GPU to draw them one by one.</p>
<p>This seems logical, but it runs headfirst into one of the biggest bottlenecks in real-time graphics: <strong>draw call overhead</strong>.</p>
<h3 id="heading-the-cost-of-a-conversation">The Cost of a Conversation</h3>
<p>Think of the CPU as a manager and the GPU as a highly specialized, incredibly fast worker. Every time the CPU tells the GPU to draw something, it's not just a simple command. It's a whole conversation:</p>
<ol>
<li><p><strong>CPU Prepares:</strong> The CPU gathers all the data for one blade of grass - its mesh, material, and world position.</p>
</li>
<li><p><strong>CPU Submits:</strong> It packages this into a "draw call" and sends it to the graphics driver.</p>
</li>
<li><p><strong>Driver Translates:</strong> The driver (special software from NVIDIA, AMD, etc.) translates this into a language the GPU hardware understands.</p>
</li>
<li><p><strong>GPU Executes:</strong> The GPU finally receives the command and draws the single blade of grass.</p>
</li>
</ol>
<p>This conversation has a fixed cost. It takes time, regardless of how simple the object being drawn is. Now, repeat that entire process 40,000 times for every single frame.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ❌ THE NAIVE APPROACH - Do Not Do This!</span>
<span class="hljs-comment">// Spawning 40,000 individual entities.</span>
<span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..<span class="hljs-number">40_000</span> {
    commands.spawn((
        Mesh3d(grass_blade_mesh.clone()),
        MeshMaterial3d(material.clone()),
        Transform::from_xyz(
            positions[i].x,
            positions[i].y,
            positions[i].z
        ),
    ));
}
<span class="hljs-comment">// Result: 40,000 entities, potentially 40,000 draw calls.</span>
<span class="hljs-comment">// Frame Time: Unplayable.</span>
</code></pre>
<p>Your manager (the CPU) spends all its time talking to the worker instead of preparing the next frame's logic, and your game grinds to a halt. You become <strong>CPU-bound</strong>, limited not by the GPU's drawing speed but by the CPU's ability to issue commands.</p>
<h2 id="heading-the-solution-instanced-rendering">The Solution: Instanced Rendering</h2>
<p>Instanced rendering flips this conversation on its head. Instead of 40,000 separate, costly conversations, the CPU has one, much smarter conversation with the GPU.</p>
<p>It says: "Here is the mesh for a single blade of grass. I want you to draw it 40,000 times. Also, here is a list of 40,000 different positions and rotations. Use the first transform for the first blade, the second for the second, and so on. Let me know when you're done."</p>
<p>The GPU, which is designed for exactly this kind of massive parallel work, can now execute the entire batch with incredible efficiency.</p>
<pre><code class="lang-plaintext">Without Instancing:                  With Instancing:

CPU: "Draw blade 1"      ──→ GPU     CPU: "Draw blade mesh x40,000" ──→  GPU
CPU: "Draw blade 2"      ──→ GPU          "Use this list of transforms"   │
CPU: "Draw blade 3"      ──→ GPU                                          │
    ... (repeats 40,000x) ...                                             ▼
CPU: "Draw blade 40,000" ──→ GPU                     GPU processes all instances in parallel
      │                                                                   │
      ▼                                                                   ▼
Result: SLOW! (CPU-bound)                                  Result: FAST! (GPU-bound)
</code></pre>
<p>This is the core concept of instancing: <strong>one draw call to render one mesh many times with per-instance variations.</strong> In the shader, we gain access to a special built-in variable that lets us know which instance we're currently drawing, allowing us to apply the correct unique properties.</p>
<h2 id="heading-the-magic-variable-builtininstanceindex">The Magic Variable: <code>@builtin(instance_index)</code></h2>
<p>Now that we understand the "what" and "why" of instancing, let's explore the "how." How does a single shader, running on the GPU, know which of the 40,000 grass blades it's currently processing?</p>
<p>The answer is a special <strong>built-in variable</strong> provided by WGSL: <code>@builtin(instance_index)</code>.</p>
<p>You can think of instance_index as a simple counter. When the GPU begins processing our single instanced draw call for 40,000 grass blades, this is what happens:</p>
<ul>
<li><p>For the very first instance, the vertex shader runs with <code>instance_index</code> set to <code>0</code>.</p>
</li>
<li><p>For the second instance, it runs with <code>instance_index</code> set to <code>1</code>.</p>
</li>
<li><p>...and so on, all the way up to <code>39999</code> for the last instance.</p>
</li>
</ul>
<p>This index is the crucial link that allows us to fetch unique data for each instance. It's our key to unlocking per-instance variation.</p>
<h3 id="heading-accessing-the-index-in-wgsl">Accessing the Index in WGSL</h3>
<p>To get access to this value, you simply add it to your vertex shader's input arguments with the <code>@builtin</code> decorator.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In your vertex shader signature:</span>

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    <span class="hljs-comment">// Other attributes like position, normal, etc.</span>
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    <span class="hljs-comment">// Add this line to get the instance index</span>
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    <span class="hljs-comment">// Now you can use `in.instance_index` inside your shader.</span>
    <span class="hljs-comment">// It will be 0 for the first instance, 1 for the second, etc.</span>

    <span class="hljs-comment">// ... shader logic ...</span>
}
</code></pre>
<p>On its own, the index is just a number. Its power comes from what we do with it. In a Bevy context, its most important job is to look up the correct Transform (model matrix) for the current instance from a list that Bevy automatically provides to the GPU. From there, we can derive all sorts of other interesting variations.</p>
<h2 id="heading-from-index-to-positional-variation">From Index to Positional Variation</h2>
<p>While you could theoretically use the <code>instance_index</code> directly to create variations (e.g., <code>if instance_index % 2 == 0 { color = green; }</code>), this approach is brittle. If you add or remove an object, the indices of all subsequent objects shift, changing their appearance.</p>
<p>A far more powerful and stable technique is to use the <code>instance_index</code> for one primary purpose: <strong>to retrieve the instance's unique world transform.</strong> From that transform, we can extract the object's world position and use that as a stable source for generating consistent, procedural variations. An object's appearance will be tied to its location in the world, not the arbitrary order in which it was created.</p>
<p>Bevy's PBR shader imports make this incredibly simple. The <code>mesh_functions::get_world_from_local()</code> function takes the instance_index and returns the correct <code>mat4x4&lt;f32&gt;</code> model matrix for that specific instance.</p>
<h3 id="heading-the-core-pattern">The Core Pattern</h3>
<p>The workflow inside the vertex shader looks like this:</p>
<ol>
<li><p><strong>Get the Index:</strong> Receive the <code>instance_index</code> as a vertex attribute.</p>
</li>
<li><p><strong>Get the Transform:</strong> Use the index to fetch the instance's model matrix.</p>
</li>
<li><p><strong>Extract World Position:</strong> The world position of the instance is stored in the fourth column of its model matrix.</p>
</li>
<li><p><strong>Generate Variation:</strong> Feed this world position into simple "hash" functions to generate consistent, pseudo-random numbers.</p>
</li>
<li><p><strong>Apply Variation:</strong> Use these numbers to modify attributes like height, color, or animation offsets.</p>
</li>
</ol>
<p>Let's see this in action.</p>
<pre><code class="lang-rust">#import bevy_pbr::mesh_functions

<span class="hljs-comment">// A simple function to generate a consistent "random" float (0.0 to 1.0)</span>
<span class="hljs-comment">// from a 2D world position. The same input position will always produce</span>
<span class="hljs-comment">// the same output number.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash_from_position</span></span>(pos: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// Arbitrary numbers to create chaotic but deterministic results</span>
    <span class="hljs-keyword">let</span> p = pos * <span class="hljs-number">0.1031</span>;
    <span class="hljs-keyword">let</span> n = dot(p, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">12.9898</span>, <span class="hljs-number">78.233</span>));
    <span class="hljs-keyword">return</span> fract(sin(n) * <span class="hljs-number">43758.5453</span>);
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    <span class="hljs-comment">// ... other inputs</span>
) -&gt; VertexOutput {
    <span class="hljs-comment">// 1. Get the transform matrix for this specific instance</span>
    <span class="hljs-keyword">let</span> model_matrix = mesh_functions::get_world_from_local(instance_index);

    <span class="hljs-comment">// 2. Extract the instance's world position from the matrix's 4th column</span>
    <span class="hljs-keyword">let</span> instance_world_pos = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(model_matrix[<span class="hljs-number">3</span>].x, model_matrix[<span class="hljs-number">3</span>].z);

    <span class="hljs-comment">// 3. Use the position to generate a consistent "random" value</span>
    <span class="hljs-keyword">let</span> random_val = hash_from_position(instance_world_pos);

    <span class="hljs-comment">// 4. Use that value to create variations</span>
    <span class="hljs-keyword">let</span> height_scale = <span class="hljs-number">0.8</span> + random_val * <span class="hljs-number">0.4</span>; <span class="hljs-comment">// Scale from 0.8 to 1.2</span>

    <span class="hljs-comment">// 5. Apply the variation to the vertex</span>
    var scaled_position = position;
    scaled_position.y *= height_scale;

    <span class="hljs-comment">// Finally, transform the modified vertex into world space using the instance's matrix</span>
    <span class="hljs-keyword">let</span> world_position = model_matrix * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(scaled_position, <span class="hljs-number">1.0</span>);

    <span class="hljs-comment">// ... continue with the rest of the MVP transformation ...</span>
    var out: VertexOutput;
    <span class="hljs-comment">// ...</span>
    <span class="hljs-keyword">return</span> out;
}
</code></pre>
<p>This pattern is the foundation of our grass field demo. It ensures that each blade of grass has a unique height, lean, and color based on where it is in the world, creating a natural, organic look without needing to store any extra data on the CPU.</p>
<h2 id="heading-for-full-control-per-instance-data-buffers">For Full Control: Per-Instance Data Buffers</h2>
<p>The positional variation technique we just covered is elegant and highly efficient for creating natural, organic-looking scenes. However, it has its limits. The variations are generated entirely on the GPU based on a mathematical recipe. What if you need to control each instance with specific data from your game's logic on the CPU?</p>
<ul>
<li><p>What if you want to set the color of each grass blade based on team ownership?</p>
</li>
<li><p>What if each instance needs to display a specific frame from a sprite sheet animation?</p>
</li>
<li><p>What if an instance's properties are determined by complex game state, not just its position?</p>
</li>
</ul>
<p>For these scenarios, where you need explicit, artist- or game-driven control, you need a way to send a large batch of custom data from the CPU to the GPU. The solution is a <strong>storage buffer</strong>.</p>
<h3 id="heading-what-is-a-storage-buffer">What is a Storage Buffer?</h3>
<p>A storage buffer is a versatile block of GPU memory that you can fill with a large array of custom data structures. In the context of instancing, you can create an array where each element corresponds to an instance, and then access that data in your shader using <code>instance_index</code>.</p>
<pre><code class="lang-plaintext">CPU Side (Your Rust Code):             GPU Side (Your WGSL Shader):
                                   ┌──────────────────────────────┐
let instance_data = vec![          │ Storage Buffer on GPU        │
  Instance { color: RED, ... },    │                              │
  Instance { color: BLUE, ... },   │ [ Data for instance 0 ]      │
  Instance { color: RED, ... },    │ [ Data for instance 1 ]      │
  // ... 39,997 more ...           │ [ Data for instance 2 ]      │
];                                 │ ...                          │
                                   │ [ Data for instance 39999 ]  │
// Upload this data to the GPU     └──────────────────────────────┘
                                                  │
                                                  │ access via index
                                                  ▼
                                      In shader: let data = my_buffer[instance_index];
</code></pre>
<h3 id="heading-a-glimpse-into-the-code">A Glimpse into the Code</h3>
<p>While the full implementation is an advanced topic that involves interacting with Bevy's render world, the shader-side code is quite intuitive. You define a struct that matches your CPU-side data, declare a storage buffer, and access it like an array.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// WGSL Shader (Conceptual)</span>

<span class="hljs-comment">// Define a struct for our custom per-instance data</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">InstanceProperties</span></span> {
    color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    animation_frame: <span class="hljs-built_in">u32</span>,
    <span class="hljs-comment">// ... any other data you need</span>
}

<span class="hljs-comment">// Declare the storage buffer. It's an array of our structs.</span>
<span class="hljs-comment">// Note the `storage, read` which specifies its type and usage.</span>
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">1</span>)
var&lt;storage, read&gt; instance_properties: array&lt;InstanceProperties&gt;;

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(@builtin(instance_index) idx: <span class="hljs-built_in">u32</span>, <span class="hljs-comment">/*... other inputs ...*/</span>) {
    <span class="hljs-comment">// Access the unique data for this instance</span>
    <span class="hljs-keyword">let</span> data = instance_properties[idx];

    <span class="hljs-comment">// Now you can use this data to control the shader's output</span>
    <span class="hljs-keyword">let</span> final_color = data.color;
    <span class="hljs-comment">// ... use data.animation_frame to calculate UV offsets, etc.</span>
}
</code></pre>
<p>This method offers maximum flexibility at the cost of increased memory usage (you have to store the data for every instance) and more complex setup code in Rust.</p>
<p>For our grass field, where the goal is to create a natural, chaotic, and organic look, the procedural approach based on world position is not only simpler but also more memory-efficient and achieves the desired artistic result perfectly.</p>
<h2 id="heading-instancing-the-bevy-way-automatic-batching">Instancing the Bevy Way: Automatic Batching</h2>
<p>One of the best features of Bevy's renderer is that, in many cases, <strong>you get the performance benefits of instancing for free, without any special setup.</strong></p>
<p>Bevy's renderer is designed to be efficient by default. During the "prepare" phase of rendering, it analyzes all the visible entities in your scene that you've told it to draw. It then automatically groups, or "batches," entities that can be rendered together in a single instanced draw call.</p>
<h3 id="heading-the-rules-of-automatic-batching">The Rules of Automatic Batching</h3>
<p>For Bevy to automatically batch a group of entities, they must share two things:</p>
<ol>
<li><p><strong>The exact same</strong> <code>Handle&lt;Mesh&gt;</code>: They must all refer to the same mesh asset.</p>
</li>
<li><p><strong>The exact same</strong> <code>Handle&lt;Material&gt;</code>: They must all use the same instance of a material asset.</p>
</li>
</ol>
<p>If these two conditions are met, Bevy takes care of the rest behind the scenes:</p>
<ol>
<li><p><strong>Grouping:</strong> The renderer identifies the group of entities that share the mesh and material.</p>
</li>
<li><p><strong>Data Collection:</strong> It efficiently gathers up the <code>GlobalTransform</code> (which contains the model matrix) from each entity in the group.</p>
</li>
<li><p><strong>GPU Upload:</strong> It uploads this list of transforms to a buffer on the GPU.</p>
</li>
<li><p><strong>Single Draw Call:</strong> It issues a single, instanced draw call, telling the GPU to render the mesh N times, where N is the number of entities in the group.</p>
</li>
<li><p><strong>Shader Access:</strong> Inside your WGSL shader, the <code>mesh_functions::get_world_from_local(instance_index)</code> function we saw earlier is what Bevy uses to look up the correct transform from that GPU buffer for each instance.</p>
</li>
</ol>
<p>This is precisely how our grass demo works. We spawn 40,000 entities, but we give every single one the same mesh handle and the same material handle.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// This code triggers Bevy's automatic instancing.</span>
<span class="hljs-comment">// Even though we spawn 40,000 separate entities, the renderer</span>
<span class="hljs-comment">// is smart enough to combine them into one draw call.</span>

<span class="hljs-keyword">let</span> blade_mesh_handle = meshes.add(<span class="hljs-comment">/* ... */</span>);
<span class="hljs-keyword">let</span> grass_material_handle = materials.add(<span class="hljs-comment">/* ... */</span>);

<span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..<span class="hljs-number">40_000</span> {
    commands.spawn((
        <span class="hljs-comment">// All entities share this handle</span>
        Mesh3d(blade_mesh_handle.clone()),
        <span class="hljs-comment">// All entities share this handle</span>
        MeshMaterial3d(grass_material_handle.clone()),  
        <span class="hljs-comment">// Each entity gets its own unique transform</span>
        Transform::from_xyz( <span class="hljs-comment">/* ... unique position ... */</span> ),
    ));
}
</code></pre>
<h3 id="heading-performance-reality-check">Performance Reality Check</h3>
<p>The performance difference is not subtle; it's dramatic.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Scenario: 40,000 Grass Blades</td><td>Without Instancing (40k unique materials)</td><td>With Instancing (1 shared material)</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Draw Calls</strong></td><td>~40,000</td><td><strong>1</strong></td></tr>
<tr>
<td><strong>CPU Frame Time</strong></td><td>100+ ms (Completely CPU-bound)</td><td><strong>&lt; 1 ms</strong> (for draw prep)</td></tr>
<tr>
<td><strong>Resulting FPS</strong></td><td>&lt; 10 FPS</td><td><strong>60+ FPS</strong> (GPU-bound)</td></tr>
</tbody>
</table>
</div><p>This automatic behavior is a huge advantage. It means you can structure your game logic around individual entities, but still get the performance of a highly optimized, low-level rendering technique.</p>
<h3 id="heading-limitations-to-keep-in-mind">Limitations to Keep in Mind</h3>
<p>While powerful, this automatic system has some limitations you should be aware of:</p>
<ul>
<li><p><strong>Culling:</strong> The entire group of instances is culled as a single unit. If the bounding box of the entire group is visible, the GPU will process all instances in that group, even those that are individually off-screen. For a dense field of grass, this is usually acceptable.</p>
</li>
<li><p><strong>Transparency:</strong> Correctly rendering transparent objects requires sorting them from back-to-front. Instanced rendering draws objects in an arbitrary order, which means it is generally not suitable for transparent objects that need to overlap correctly.</p>
</li>
</ul>
<h2 id="heading-creating-natural-variation">Creating Natural Variation</h2>
<p>The key to a convincing field of grass is variation that appears organic, not robotic. If every blade is just "random," it can look like static noise. To achieve a natural look, we use three simple strategies in our shader:</p>
<ol>
<li><p><strong>Distribution Curves:</strong> Instead of a linear random distribution (where every height is equally likely), we can modify the random value, for example by squaring it (<code>random * random</code>). This biases the result towards smaller numbers, creating a field where most grass is short to medium height, with only the occasional tall blade sticking out.</p>
</li>
<li><p><strong>Layered Noise:</strong> We use multiple, different "hash" functions for different attributes. One random value controls height, while a completely different one controls the "lean" or "bend" of the blade. This prevents obvious patterns where, for example, the tallest blades are always the most bent.</p>
</li>
<li><p><strong>Color Tinting:</strong> Real grass isn't one solid color. We can use the world position to subtly shift the hue (slightly more yellow or blue) and brightness. This breaks up the visual repetition and adds depth to the scene.</p>
</li>
</ol>
<p>We will apply all of these techniques in the final project.</p>
<hr />
<h2 id="heading-complete-example-procedural-field-of-grass">Complete Example: Procedural Field of Grass</h2>
<p>Let's put everything we've learned together to build a lush, animated grass field renderer. This system relies entirely on Bevy's automatic instancing. All variations - height, color, lean, and wind animation - are generated procedurally in the vertex shader based on each blade's world position.</p>
<h3 id="heading-our-goal">Our Goal</h3>
<p>We will create a scene with 40,000 individual blades of grass. Instead of melting our CPU with 40,000 draw calls, we will render them all in a single batch. We will add a custom material that handles the wind simulation and color variation on the GPU, and we'll implement a simple interactive camera to fly around and inspect our work.</p>
<h3 id="heading-what-this-project-demonstrates">What This Project Demonstrates</h3>
<ul>
<li><p><strong>Automatic Instancing:</strong> How to structure your Bevy entities to trigger the renderer's automatic batching.</p>
</li>
<li><p><strong>Procedural Vertex Animation:</strong> Modifying vertex positions in the shader to simulate wind without complex physics.</p>
</li>
<li><p><strong>Position-Based Hashing:</strong> Generating stable random numbers in WGSL using world coordinates.</p>
</li>
<li><p><strong>Instance-Dependent Logic:</strong> Using <code>instance_index</code> indirectly (via the model matrix) to make every object unique.</p>
</li>
</ul>
<h3 id="heading-the-shader-assetsshadersd0207grassinstancingwgsl">The Shader (<code>assets/shaders/d02_07_grass_instancing.wgsl</code>)</h3>
<p>This shader is the heart of the project, handling both the procedural generation in the vertex stage and the lighting model in the fragment stage.</p>
<ul>
<li><p>The <code>@vertex</code> shader is responsible for all the procedural variation and animation. It uses the <code>instance_index</code> to fetch each blade's transform, calculates a unique <code>height_scale</code>, <code>lean</code>, and <code>color_tint</code> from its world position, and applies the animated <code>wind_sway</code>. It then passes all the necessary data (world position, normal, UVs) to the fragment shader.</p>
</li>
<li><p>The <code>@fragment</code> shader performs a rich lighting calculation to give the scene life and depth. It uses a combination of techniques: standard diffuse lighting for basic color, fake ambient occlusion to darken the base of the blades, specular highlights to give the grass a healthy sheen, and soft rim lighting to make the blades pop from the background.</p>
</li>
</ul>
<blockquote>
<p><strong>Note:</strong> The fragment shader uses several common lighting concepts like specular highlights and rim lighting. Don't worry about understanding every line of the lighting code just yet! Each of these techniques will be covered in great detail during the "Lighting Models" phase of the curriculum. For now, focus on how the vertex shader enables this advanced lighting by providing it with the necessary data.</p>
</blockquote>
<pre><code class="lang-rust">#import bevy_pbr::{
    mesh_functions,
    mesh_bindings::mesh,
    mesh_view_bindings::view,
    view_transformations::position_world_to_clip,
}

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">GrassMaterial</span></span> {
    time: <span class="hljs-built_in">f32</span>,
    wind_strength: <span class="hljs-built_in">f32</span>,
    wind_direction: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    wind_speed: <span class="hljs-built_in">f32</span>,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: GrassMaterial;

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) color_tint: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) world_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">3</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
}
<span class="hljs-comment">// Generates pseudo-random values based on world position.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash_positional</span></span>(pos: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> p = pos * <span class="hljs-number">0.05</span>;
    <span class="hljs-keyword">let</span> n = dot(p, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">12.9898</span>, <span class="hljs-number">78.233</span>));
    <span class="hljs-keyword">return</span> fract(sin(n) * <span class="hljs-number">43758.5453</span>);
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash_positional_2</span></span>(pos: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> p = pos * <span class="hljs-number">0.07</span>;
    <span class="hljs-keyword">let</span> n = dot(p, vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">34.376</span>, <span class="hljs-number">63.934</span>));
    <span class="hljs-keyword">return</span> fract(sin(n) * <span class="hljs-number">39748.8473</span>);
}

<span class="hljs-comment">// 2D noise for organic, non-repetitive patterns. Used here for wind waves.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">value_noise_2d</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> p_int = floor(p);
    <span class="hljs-keyword">let</span> p_frac = fract(p);
    <span class="hljs-keyword">let</span> uv = p_frac * p_frac * (<span class="hljs-number">3.0</span> - <span class="hljs-number">2.0</span> * p_frac); <span class="hljs-comment">// smoothstep</span>

    <span class="hljs-keyword">let</span> n00 = hash_positional(p_int + vec2(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>));
    <span class="hljs-keyword">let</span> n01 = hash_positional(p_int + vec2(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> n10 = hash_positional(p_int + vec2(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>));
    <span class="hljs-keyword">let</span> n11 = hash_positional(p_int + vec2(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));

    <span class="hljs-keyword">let</span> n0 = mix(n00, n01, uv.y);
    <span class="hljs-keyword">let</span> n1 = mix(n10, n11, uv.y);
    <span class="hljs-keyword">return</span> mix(n0, n1, uv.x);
}

<span class="hljs-comment">// Get per-instance variations based on world position.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_height_scale</span></span>(pos: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// Square the random value to favor shorter grass, making taller blades rarer.</span>
    <span class="hljs-keyword">let</span> rand = hash_positional(pos);
    <span class="hljs-keyword">return</span> <span class="hljs-number">0.6</span> + (rand * rand) * <span class="hljs-number">0.8</span>;
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_lean</span></span>(pos: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">return</span> (hash_positional_2(pos) - <span class="hljs-number">0.5</span>) * <span class="hljs-number">0.6</span>; <span class="hljs-comment">// -0.3 to 0.3</span>
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_color_tint</span></span>(pos: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> hue_rand = hash_positional(pos);
    <span class="hljs-keyword">let</span> bright_rand = hash_positional_2(pos);

    <span class="hljs-comment">// Base color for lush grass</span>
    var color = vec3(<span class="hljs-number">0.2</span>, <span class="hljs-number">0.45</span>, <span class="hljs-number">0.1</span>);

    <span class="hljs-comment">// Add some yellow/brown for variation</span>
    color.r += (hue_rand - <span class="hljs-number">0.5</span>) * <span class="hljs-number">0.2</span>;
    <span class="hljs-comment">// Vary brightness</span>
    color *= <span class="hljs-number">0.8</span> + bright_rand * <span class="hljs-number">0.4</span>;

    <span class="hljs-keyword">return</span> color;
}

<span class="hljs-comment">// Calculates a dreamy, wave-like wind sway.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_wind_sway</span></span>(
    height_influence: <span class="hljs-built_in">f32</span>,
    instance_world_pos: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Large-scale waves using noise - made slightly faster</span>
    <span class="hljs-keyword">let</span> wave_coord_1 = instance_world_pos * <span class="hljs-number">0.2</span> + material.time * material.wind_speed * <span class="hljs-number">0.2</span> * material.wind_direction;
    <span class="hljs-keyword">let</span> wave_1 = value_noise_2d(wave_coord_1) - <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// Smaller, faster ripples on top - also made faster</span>
    <span class="hljs-keyword">let</span> wave_coord_2 = instance_world_pos * <span class="hljs-number">0.6</span> + material.time * material.wind_speed * <span class="hljs-number">0.6</span> * material.wind_direction.yx;
    <span class="hljs-keyword">let</span> wave_2 = value_noise_2d(wave_coord_2) - <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// Combine waves. The influence of height means the top of the blade moves most.</span>
    <span class="hljs-keyword">let</span> total_sway = (wave_1 * <span class="hljs-number">0.6</span> + wave_2 * <span class="hljs-number">0.4</span>) * material.wind_strength * height_influence;

    <span class="hljs-comment">// Apply sway in the specified wind direction.</span>
    var sway_offset = vec3(<span class="hljs-number">0.0</span>);
    sway_offset.x = material.wind_direction.x * total_sway;
    sway_offset.z = material.wind_direction.y * total_sway;

    <span class="hljs-keyword">return</span> sway_offset;
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
    <span class="hljs-keyword">let</span> instance_world_pos = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(model[<span class="hljs-number">3</span>].x, model[<span class="hljs-number">3</span>].z);

    <span class="hljs-comment">// Get procedural variations for this blade of grass</span>
    <span class="hljs-keyword">let</span> height_scale = get_height_scale(instance_world_pos);
    <span class="hljs-keyword">let</span> lean = get_lean(instance_world_pos);
    out.color_tint = get_color_tint(instance_world_pos);

    <span class="hljs-comment">// Apply scaling and lean</span>
    var local_pos = <span class="hljs-keyword">in</span>.position;
    local_pos.y *= height_scale;
    local_pos.x += lean * local_pos.y * <span class="hljs-number">0.2</span>; <span class="hljs-comment">// Lean increases with height</span>

    <span class="hljs-comment">// Wind animation</span>
    <span class="hljs-comment">// The influence of wind is stronger at the top of the blade.</span>
    <span class="hljs-keyword">let</span> height_influence = pow(<span class="hljs-keyword">in</span>.position.y, <span class="hljs-number">2.0</span>);
    <span class="hljs-keyword">let</span> wind_sway = calculate_wind_sway(height_influence, instance_world_pos);
    local_pos += wind_sway;

    <span class="hljs-comment">// Transform to world and clip space</span>
    <span class="hljs-keyword">let</span> world_position = (model * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(local_pos, <span class="hljs-number">1.0</span>)).xyz;
    out.clip_position = position_world_to_clip(world_position);

    <span class="hljs-comment">// Apply lean and sway to normals for correct lighting</span>
    var normal_offset = vec3(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
    normal_offset.x = lean * <span class="hljs-number">0.2</span>;
    normal_offset += wind_sway * <span class="hljs-number">2.0</span>;
    out.world_normal = mesh_functions::mesh_normal_local_to_world(
        normalize(<span class="hljs-keyword">in</span>.normal - normal_offset),
        <span class="hljs-keyword">in</span>.instance_index
    );
    out.world_position = world_position;
    out.uv = <span class="hljs-keyword">in</span>.uv;

    <span class="hljs-keyword">return</span> out;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);

    <span class="hljs-comment">// Get essential vectors</span>
    <span class="hljs-keyword">let</span> light_dir = normalize(vec3(<span class="hljs-number">0.8</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>));
    <span class="hljs-comment">// The view direction is from the fragment's world position to the camera</span>
    <span class="hljs-keyword">let</span> view_dir = normalize(view.world_position.xyz - <span class="hljs-keyword">in</span>.world_position);

    <span class="hljs-comment">// 1. Fake Ambient Occlusion</span>
    <span class="hljs-comment">// Darken the base of the blade. `in.uv.y` goes from 1.0 (bottom) to 0.0 (top).</span>
    <span class="hljs-comment">// `smoothstep` creates a nice gradient.</span>
    <span class="hljs-keyword">let</span> ambient_occlusion = mix(<span class="hljs-number">0.5</span>, <span class="hljs-number">1.0</span>, smoothstep(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.4</span>, <span class="hljs-number">1.0</span> - <span class="hljs-keyword">in</span>.uv.y));
    <span class="hljs-keyword">let</span> ambient = <span class="hljs-number">0.4</span> * ambient_occlusion;

    <span class="hljs-comment">// Standard diffuse lighting</span>
    <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.0</span>, dot(normal, light_dir)) * <span class="hljs-number">0.6</span>;

    <span class="hljs-comment">// 2. Specular Highlights (Blinn-Phong)</span>
    <span class="hljs-comment">// `half_vec` is halfway between the light and view directions.</span>
    <span class="hljs-keyword">let</span> half_vec = normalize(light_dir + view_dir);
    <span class="hljs-comment">// The dot product with the normal tells us how aligned the surface is to reflect light.</span>
    <span class="hljs-comment">// `pow(..., 64.0)` creates a small, tight highlight.</span>
    <span class="hljs-keyword">let</span> specular_power = <span class="hljs-number">64.0</span>;
    <span class="hljs-keyword">let</span> specular = pow(max(<span class="hljs-number">0.0</span>, dot(normal, half_vec)), specular_power) * <span class="hljs-number">0.3</span>;

    <span class="hljs-comment">// 3. Rim Lighting</span>
    <span class="hljs-comment">// `rim_dot` is close to 1 when we look straight at a surface, and 0 at the edge.</span>
    <span class="hljs-keyword">let</span> rim_dot = <span class="hljs-number">1.0</span> - max(<span class="hljs-number">0.0</span>, dot(normal, view_dir));
    <span class="hljs-comment">// `pow(..., 2.0)` enhances the effect at the very edge.</span>
    <span class="hljs-keyword">let</span> rim_intensity = pow(rim_dot, <span class="hljs-number">2.0</span>) * <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// Combine all lighting components</span>
    <span class="hljs-keyword">let</span> total_lighting = ambient + diffuse + specular + rim_intensity;
    <span class="hljs-keyword">let</span> lit_color = <span class="hljs-keyword">in</span>.color_tint * total_lighting;

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(lit_color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0207grassinstancingrs">The Rust Material (<code>src/materials/d02_07_grass_instancing.rs</code>)</h3>
<p>This is our standard material setup. It defines the GrassMaterial asset type and the GrassMaterialUniforms struct that will be sent to the GPU. The time uniform is updated every frame from a Rust system, which drives the wind animation.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>

    <span class="hljs-keyword">use</span> bevy::prelude::*;
    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-comment">// The uniform struct for our grass material</span>
    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone, Copy, Default)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">GrassMaterial</span></span> {
        <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> wind_strength: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> wind_direction: Vec2,
        <span class="hljs-keyword">pub</span> wind_speed: <span class="hljs-built_in">f32</span>,
    }
}

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">use</span> uniforms::GrassMaterial <span class="hljs-keyword">as</span> GrassMaterialUniforms;

<span class="hljs-comment">// The Bevy Asset and BindGroup for our grass material</span>
<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">GrassMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: GrassMaterialUniforms,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> GrassMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_07_grass_instancing.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_07_grass_instancing.wgsl"</span>.into()
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other materials</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_07_grass_instancing;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0207grassinstancingrs">The Demo Module (<code>src/demos/d02_07_grass_instancing.rs</code>)</h3>
<p><strong>Dependency Note</strong>: This demo uses the <code>rand</code> crate to give each blade of grass a unique, random rotation, which makes the field look much more natural. Before adding the code, you must add this dependency to your project.</p>
<p>Open your <code>Cargo.toml</code> file and add the following line under <code>[dependencies]</code>:</p>
<pre><code class="lang-toml"><span class="hljs-section">[dependencies]</span>
<span class="hljs-attr">bevy</span> = <span class="hljs-string">"0.16"</span> <span class="hljs-comment"># Ensure this matches your Bevy version</span>
<span class="hljs-attr">rand</span> = <span class="hljs-string">"0.8.5"</span>
</code></pre>
<p>The application logic is straightforward. The <code>setup</code> function:</p>
<ol>
<li><p>Creates a single, simple mesh for one blade of grass.</p>
</li>
<li><p>Creates one instance of our <code>GrassMaterial</code>.</p>
</li>
<li><p>Spawns 40,000 entities in a grid. Crucially, every entity gets a <code>clone()</code> of the same mesh and material handles, which is what triggers Bevy's automatic instancing.</p>
</li>
<li><p>Each entity is given a unique position and a random Y-axis rotation.</p>
</li>
</ol>
<p>The <code>update_time</code> system continuously updates the <code>time</code> uniform in our material, driving the wind animation on the GPU. Other systems handle input for controlling the wind and camera.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d02_07_grass_instancing::{GrassMaterial, GrassMaterialUniforms};
<span class="hljs-keyword">use</span> bevy::{
    pbr::MeshMaterial3d,
    prelude::*,
    render::{
        mesh::{Indices, PrimitiveTopology},
        render_asset::RenderAssetUsages,
    },
};
<span class="hljs-keyword">use</span> std::<span class="hljs-built_in">f32</span>::consts::PI;

<span class="hljs-keyword">const</span> GRID_SIZE: <span class="hljs-built_in">i32</span> = <span class="hljs-number">200</span>;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;GrassMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (update_time, handle_input, update_camera, update_ui),
        )
        .run();
}

<span class="hljs-meta">#[derive(Component)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">OrbitCamera</span></span> {
    radius: <span class="hljs-built_in">f32</span>,
    angle: <span class="hljs-built_in">f32</span>,
    height: <span class="hljs-built_in">f32</span>,
    target: Vec3,
}

<span class="hljs-meta">#[derive(Resource)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">GrassMaterialHandle</span></span>(Handle&lt;GrassMaterial&gt;);

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;GrassMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> standard_materials: ResMut&lt;Assets&lt;StandardMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> blade_mesh_handle = meshes.add(create_grass_blade_mesh());

    <span class="hljs-keyword">let</span> material_handle = materials.add(GrassMaterial {
        uniforms: GrassMaterialUniforms {
            time: <span class="hljs-number">0.0</span>,
            wind_strength: <span class="hljs-number">1.5</span>,
            wind_speed: <span class="hljs-number">2.0</span>,
            wind_direction: Vec2::new(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>).normalize(),
        },
    });

    <span class="hljs-comment">// Store the handle as a resource so we can access it reliably</span>
    commands.insert_resource(GrassMaterialHandle(material_handle.clone()));

    <span class="hljs-keyword">let</span> spacing = <span class="hljs-number">0.2</span>;
    <span class="hljs-keyword">for</span> x <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..GRID_SIZE {
        <span class="hljs-keyword">for</span> z <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..GRID_SIZE {
            commands.spawn((
                Mesh3d(blade_mesh_handle.clone()),
                MeshMaterial3d(material_handle.clone()),
                Transform::from_xyz(
                    (x <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> - GRID_SIZE <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / <span class="hljs-number">2.0</span>) * spacing,
                    <span class="hljs-number">0.0</span>,
                    (z <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> - GRID_SIZE <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / <span class="hljs-number">2.0</span>) * spacing,
                )
                .with_rotation(Quat::from_rotation_y(rand::random::&lt;<span class="hljs-built_in">f32</span>&gt;() * PI * <span class="hljs-number">2.0</span>)),
            ));
        }
    }

    <span class="hljs-built_in">println!</span>(
        <span class="hljs-string">"Spawned {} grass blades with wind_strength: 1.5"</span>,
        GRID_SIZE * GRID_SIZE
    );

    commands.spawn((
        Mesh3d(meshes.add(Plane3d::default().mesh().size(<span class="hljs-number">50.0</span>, <span class="hljs-number">50.0</span>))),
        MeshMaterial3d(standard_materials.add(StandardMaterial {
            base_color: Color::srgb(<span class="hljs-number">0.2</span>, <span class="hljs-number">0.3</span>, <span class="hljs-number">0.1</span>),
            ..default()
        })),
    ));

    commands.spawn((
        DirectionalLight {
            illuminance: <span class="hljs-number">15000.0</span>,
            shadows_enabled: <span class="hljs-literal">false</span>,
            ..default()
        },
        Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -PI / <span class="hljs-number">3.0</span>, PI / <span class="hljs-number">4.0</span>, <span class="hljs-number">0.0</span>)),
    ));

    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(-<span class="hljs-number">10.0</span>, <span class="hljs-number">5.0</span>, <span class="hljs-number">10.0</span>).looking_at(Vec3::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span>, <span class="hljs-number">0.0</span>), Vec3::Y),
        OrbitCamera {
            radius: <span class="hljs-number">15.0</span>,
            angle: -PI / <span class="hljs-number">4.0</span>,
            height: <span class="hljs-number">5.0</span>,
            target: Vec3::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span>, <span class="hljs-number">0.0</span>),
        },
    ));

    commands.spawn((
        Text::new(<span class="hljs-string">""</span>),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">10.0</span>),
            left: Val::Px(<span class="hljs-number">10.0</span>),
            padding: UiRect::all(Val::Px(<span class="hljs-number">10.0</span>)),
            ..default()
        },
        TextFont {
            font_size: <span class="hljs-number">16.0</span>,
            ..default()
        },
        TextColor(Color::WHITE),
        BackgroundColor(Color::srgba(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.7</span>)),
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">create_grass_blade_mesh</span></span>() -&gt; Mesh {
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> mesh = Mesh::new(
        PrimitiveTopology::TriangleList,
        RenderAssetUsages::default(),
    );

    <span class="hljs-keyword">let</span> width = <span class="hljs-number">0.1</span>;
    <span class="hljs-keyword">let</span> height = <span class="hljs-number">1.0</span>;

    <span class="hljs-keyword">let</span> positions: <span class="hljs-built_in">Vec</span>&lt;[<span class="hljs-built_in">f32</span>; <span class="hljs-number">3</span>]&gt; = <span class="hljs-built_in">vec!</span>[
        [-width / <span class="hljs-number">2.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>],
        [width / <span class="hljs-number">2.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>],
        [width / <span class="hljs-number">4.0</span>, height, -width / <span class="hljs-number">4.0</span>],
        [-width / <span class="hljs-number">4.0</span>, height, width / <span class="hljs-number">4.0</span>],
    ];

    <span class="hljs-keyword">let</span> normals: <span class="hljs-built_in">Vec</span>&lt;[<span class="hljs-built_in">f32</span>; <span class="hljs-number">3</span>]&gt; = <span class="hljs-built_in">vec!</span>[
        [<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>],
        [<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>],
        [<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>],
        [<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>],
    ];

    <span class="hljs-keyword">let</span> uvs: <span class="hljs-built_in">Vec</span>&lt;[<span class="hljs-built_in">f32</span>; <span class="hljs-number">2</span>]&gt; = <span class="hljs-built_in">vec!</span>[[<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>], [<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>], [<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>], [<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>]];

    <span class="hljs-keyword">let</span> indices: <span class="hljs-built_in">Vec</span>&lt;<span class="hljs-built_in">u32</span>&gt; = <span class="hljs-built_in">vec!</span>[<span class="hljs-number">0</span>, <span class="hljs-number">1</span>, <span class="hljs-number">2</span>, <span class="hljs-number">0</span>, <span class="hljs-number">2</span>, <span class="hljs-number">3</span>];

    mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
    mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
    mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
    mesh.insert_indices(Indices::U32(indices));
    mesh
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_time</span></span>(
    time: Res&lt;Time&gt;,
    material_handle: Res&lt;GrassMaterialHandle&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;GrassMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>(material) = materials.get_mut(&amp;material_handle.<span class="hljs-number">0</span>) {
        material.uniforms.time = time.elapsed_secs();
        <span class="hljs-comment">// get_mut automatically marks the asset as changed in Bevy's asset system</span>
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    time: Res&lt;Time&gt;,
    material_handle: Res&lt;GrassMaterialHandle&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;GrassMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> delta = time.delta_secs();

    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>(material) = materials.get_mut(&amp;material_handle.<span class="hljs-number">0</span>) {
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyW) {
            material.uniforms.wind_strength =
                (material.uniforms.wind_strength + delta * <span class="hljs-number">0.5</span>).min(<span class="hljs-number">3.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyS) {
            material.uniforms.wind_strength =
                (material.uniforms.wind_strength - delta * <span class="hljs-number">0.5</span>).max(<span class="hljs-number">0.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyQ) {
            material.uniforms.wind_speed = (material.uniforms.wind_speed - delta).max(<span class="hljs-number">0.1</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyE) {
            material.uniforms.wind_speed = (material.uniforms.wind_speed + delta).min(<span class="hljs-number">5.0</span>);
        }

        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyA) || keyboard.pressed(KeyCode::KeyD) {
            <span class="hljs-keyword">let</span> rotation_amount = <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyA) {
                delta
            } <span class="hljs-keyword">else</span> {
                -delta
            };
            <span class="hljs-keyword">let</span> angle = material
                .uniforms
                .wind_direction
                .y
                .atan2(material.uniforms.wind_direction.x)
                + rotation_amount;
            material.uniforms.wind_direction = Vec2::new(angle.cos(), angle.sin());
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_camera</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    time: Res&lt;Time&gt;,
    <span class="hljs-keyword">mut</span> query: Query&lt;(&amp;<span class="hljs-keyword">mut</span> Transform, &amp;<span class="hljs-keyword">mut</span> OrbitCamera), With&lt;Camera3d&gt;&gt;,
) {
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Ok</span>((<span class="hljs-keyword">mut</span> transform, <span class="hljs-keyword">mut</span> orbit)) = query.single_mut() {
        <span class="hljs-keyword">let</span> delta = time.delta_secs();
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowLeft) {
            orbit.angle += delta;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowRight) {
            orbit.angle -= delta;
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyZ) {
            orbit.height = (orbit.height - delta * <span class="hljs-number">5.0</span>).max(<span class="hljs-number">1.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyX) {
            orbit.height = (orbit.height + delta * <span class="hljs-number">5.0</span>).min(<span class="hljs-number">20.0</span>);
        }

        transform.translation = orbit.target
            + Vec3::new(
                orbit.angle.cos() * orbit.radius,
                orbit.height,
                orbit.angle.sin() * orbit.radius,
            );
        *transform = transform.looking_at(orbit.target, Vec3::Y);
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(
    material_handle: Res&lt;GrassMaterialHandle&gt;,
    materials: Res&lt;Assets&lt;GrassMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;,
) {
    <span class="hljs-keyword">if</span> !materials.is_changed() {
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>(material) = materials.get(&amp;material_handle.<span class="hljs-number">0</span>) {
        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
            **text = <span class="hljs-built_in">format!</span>(
                <span class="hljs-string">"[W/S] Wind Strength: {:.2}\n\
                 [A/D] Wind Direction\n\
                 [Q/E] Wind Speed: {:.2}\n\
                 [Z/X] Camera Height\n\
                 [Arrows] Orbit Camera\n\n\
                 Blades: {}\n\
                 Time: {:.1}s"</span>,
                material.uniforms.wind_strength,
                material.uniforms.wind_speed,
                GRID_SIZE * GRID_SIZE,
                material.uniforms.time
            );
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other demos</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_07_grass_instancing;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"2.7"</span>,
    title: <span class="hljs-string">"Instanced Rendering"</span>,
    run: demos::d02_07_grass_instancing::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>When you run the demo, you'll be greeted by a field of 40,000 blades of grass swaying gently in a simulated wind. The initial performance should be excellent, demonstrating the power of a single instanced draw call. Use the controls to manipulate the wind and camera to see how the procedural animation responds in real-time.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key</td><td>Action</td></tr>
</thead>
<tbody>
<tr>
<td><strong>W / S</strong></td><td>Increase / Decrease wind strength.</td></tr>
<tr>
<td><strong>A / D</strong></td><td>Change the wind direction.</td></tr>
<tr>
<td><strong>Q / E</strong></td><td>Decrease / Increase wind speed.</td></tr>
<tr>
<td><strong>Arrow Keys</strong></td><td>Orbit the camera around the field.</td></tr>
<tr>
<td><strong>Z / X</strong></td><td>Lower / Raise the camera height.</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763059208560/fdffe1e5-ac7d-4d37-98e1-edcc1a58121e.png" alt class="image--center mx-auto" /></p>
<ul>
<li><p><strong>High Performance:</strong> Notice your high and stable frame rate, even with 40,000 unique objects. This is the direct result of instancing.</p>
</li>
<li><p><strong>Natural Variation:</strong> Look closely at the field. You'll see subtle differences in the height, color, and static lean of each blade. This is the hash_positional function at work.</p>
</li>
<li><p><strong>Dynamic Wind:</strong> The grass isn't just moving back and forth. The <code>value_noise_2d</code> function creates wave-like patterns that travel across the field, creating a more believable and organic effect.</p>
</li>
<li><p><strong>No Repetition:</strong> Because all variations are derived from the unique world position of each blade, you won't see any tiling or repeating patterns, no matter how large the field is.</p>
</li>
</ul>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<ol>
<li><p><strong>Instancing Solves the Draw Call Problem:</strong> Instancing is the go-to solution when you need to render hundreds or thousands of similar objects, drastically reducing CPU overhead by combining many draws into one.</p>
</li>
<li><p><strong>Bevy Automates Instancing:</strong> If you spawn multiple entities with the same mesh and material handles, Bevy's renderer will automatically batch them into a single, high-performance instanced draw call.</p>
</li>
<li><p><code>@builtin(instance_index)</code> is the Key: This WGSL built-in variable is the unique identifier for each instance within a shader, allowing you to apply custom logic.</p>
</li>
<li><p><strong>Positional Variation is Powerful:</strong> Using the <code>instance_index</code> to get the object's model matrix and then deriving variations from its world position is a robust, efficient, and flexible pattern for creating natural-looking scenes.</p>
</li>
<li><p><strong>Procedural Animation on the GPU:</strong> Complex animations like wind can be simulated directly in the vertex shader, offloading the work from the CPU and enabling massive-scale effects.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>You've now learned how to optimize rendering for a massive number of objects by moving variation logic to the GPU. This completes our deep dive into the vertex shader. We have transformed vertices, handled normals correctly, and now rendered thousands of instances efficiently.</p>
<p>In the next phase, we'll shift our focus from the shape and position of our objects to what gives them color and life. We will dive headfirst into the <strong>Fragment Shader</strong>, learning how to control the color of every single pixel on our screen.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/28-vertex-shader-optimization"><strong><em>2.8 - Vertex Shader Optimization</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<ul>
<li><p><strong>Instancing:</strong> A rendering technique to draw one mesh many times in a single draw call, providing unique per-instance data (like transforms) for variation.</p>
</li>
<li><p><strong>@builtin(instance_index):</strong> A WGSL vertex shader input that provides the zero-based index of the current instance being processed.</p>
</li>
<li><p><strong>Bevy's Automatic Instancing Trigger:</strong> Spawning entities that share the exact same <code>Handle&lt;Mesh&gt;</code> and <code>Handle&lt;Material&gt;</code>.</p>
</li>
<li><p><strong>Positional Variation Pattern:</strong></p>
<ol>
<li><p>In WGSL, get the instance's model matrix: <code>let model = mesh_functions::get_world_from_local(in.instance_index);</code></p>
</li>
<li><p>Extract the world position: <code>let pos = model[3].xyz;</code></p>
</li>
<li><p>Use <code>pos</code> as input to a hash/noise function to generate consistent random values.</p>
</li>
<li><p>Apply these values to modify vertex attributes like position, color, etc.</p>
</li>
</ol>
</li>
<li><p><strong>When to Use Instancing:</strong> For any scene with large quantities (&gt;100) of similar objects: grass, trees, rocks, bullets, particles, asteroids, etc.</p>
</li>
<li><p><strong>Limitations:</strong> Generally not suitable for objects requiring back-to-front sorting for transparency. Culling is performed on the entire group, not individual instances.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[2.6 - Normal Vector Transformation]]></title><description><![CDATA[What We're Learning
So far, we have transformed vertex positions from local space to world space and passed normals to the fragment shader for lighting. It seems straightforward: if you want to know which way a normal is facing in the world, you just...]]></description><link>https://blog.hexbee.net/26-normal-vector-transformation</link><guid isPermaLink="true">https://blog.hexbee.net/26-normal-vector-transformation</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Fri, 28 Nov 2025 19:51:44 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1762971621017/760a56fb-a35c-4cf8-bd96-2e311c62a7e2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>So far, we have transformed vertex positions from local space to world space and passed normals to the fragment shader for lighting. It seems straightforward: if you want to know which way a normal is facing in the world, you just transform it with the model matrix, right?</p>
<p><strong>This is one of the most common and critical mistakes in shader programming.</strong></p>
<p>Normals are not positions. They represent <em>orientation</em>, and when an object is scaled non-uniformly (stretched or squashed), its surface orientation changes in a way that the model matrix simply can't account for. Applying the same transformation to both positions and normals will lead to bizarre and incorrect lighting, from highlights that appear in the wrong place to surfaces that look strangely dark or flat.</p>
<p>This article tackles that fundamental problem head-on. Getting this right is absolutely essential for any kind of correct lighting, and it forms the bedrock for more advanced techniques like normal mapping, which we will cover later.</p>
<p>By the end of this article, you will understand:</p>
<ul>
<li><p>Why you can't transform normals the same way you transform positions.</p>
</li>
<li><p>The theory behind the <strong>Normal Matrix</strong> and why the <code>transpose(inverse(model))</code> formula is the correct solution.</p>
</li>
<li><p>How to transform normals correctly and efficiently in Bevy using its built-in functions.</p>
</li>
<li><p>The critical importance of <strong>renormalizing</strong> vectors after transformation and interpolation.</p>
</li>
<li><p>How to build a complete <strong>TBN (Tangent, Bitangent, Normal) matrix</strong>, a prerequisite for normal mapping.</p>
</li>
<li><p>How to build a powerful debugging visualizer to see your vectors in real-time.</p>
</li>
</ul>
<h2 id="heading-the-problem-why-position-transformation-doesnt-work">The Problem: Why Position Transformation Doesn't Work</h2>
<p>To understand the solution, we first need to build a rock-solid intuition for the problem. Why can't we just multiply a normal by the model matrix and call it a day? The answer lies in the fundamental difference between a <em>position</em> and a <em>direction</em>.</p>
<h3 id="heading-normals-represent-direction-not-position">Normals Represent Direction, Not Position</h3>
<p>A <strong>position vector</strong> in a shader tells us <em>where</em> a vertex is in space. A <strong>normal vector</strong> tells us <em>which way</em> the surface is facing at that vertex. Normals are pure direction; they are not affected by translation, and they are affected by scaling in a very particular way that is different from positions.</p>
<p>Let's use an analogy. Imagine a car on a map.</p>
<ul>
<li><p>Its <strong>position</strong> is a specific coordinate, like (<code>latitude</code>, <code>longitude</code>). If you move the car 10 miles east (a translation), its position changes.</p>
</li>
<li><p>Its <strong>direction</strong> is which way it's facing, like "North." If you move the car 10 miles east, its direction remains "North."</p>
</li>
</ul>
<p>The model matrix contains translation, rotation, and scale information. While rotation affects both positions and directions, translation only affects positions. The real problem, however, comes from scaling. Scaling a position makes sense - you're moving it further from the origin. But what does it mean to "scale a direction"? This is the core of the issue.</p>
<h3 id="heading-the-non-uniform-scaling-problem">The Non-Uniform Scaling Problem</h3>
<p>Things break down completely when an object is scaled non-uniformly - that is, stretched or squashed by different amounts along different axes.</p>
<p>Let's consider a simple 2D plane tilted at a 45-degree angle (the blue line in the diagram below). Its surface is perfectly straight. At every point on this surface, the normal vector is perpendicular to it, pointing up and to the left at (-0.707, 0.707).</p>
<p>Now, let's apply a non-uniform scale, making the object twice as wide (X-axis) and half as tall (Y-axis). Intuitively, this should squash the plane, making its slope much flatter. A flatter slope means the new normal vector should point more vertically upwards.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764359022793/9c5a3082-467f-4d2b-b13f-118c362eced3.png" alt class="image--center mx-auto" /></p>
<p>Let's see what happens if we incorrectly transform the original normal (-0.707, 0.707) using the scale part of the model matrix:</p>
<ul>
<li><p><strong>New X component:</strong> -0.707 * 2.0 = -1.414</p>
</li>
<li><p><strong>New Y component:</strong> 0.707 * 0.5 = 0.354</p>
</li>
</ul>
<p>The resulting incorrect vector is (-1.414, 0.354). This vector points much more to the left than it does up. It implies the surface has become almost vertical, which is the exact opposite of what actually happened! If used for lighting, this would make a nearly flat surface look as if it were a steep cliff face, completely breaking the visual result.</p>
<p>The surface orientation has changed, but the model matrix fails to describe that change correctly for the normal vector.</p>
<h3 id="heading-mathematical-proof">Mathematical Proof</h3>
<p>We can prove this failure mathematically. By definition, a normal vector N is perpendicular to any tangent vector T (a vector lying flat on the surface) at the same point.</p>
<p>In vector math, "perpendicular" means their dot product is zero:</p>
<p><code>N · T = 0</code></p>
<p>After we transform our object with a model matrix <code>M</code>, the tangent <code>T</code> (a direction along the surface) transforms correctly into <code>T' = M * T</code>.</p>
<p>Let's <strong>assume</strong> for a moment that the normal transforms the same way, so <code>N' = M * N</code>.</p>
<p>For our lighting to be correct, the new normal <code>N'</code> must still be perpendicular to the new tangent <code>T'</code>. Let's test their dot product:</p>
<p><code>N' · T' = (M * N) · (M * T)</code></p>
<p>Using a rule from linear algebra, this dot product is equivalent to:</p>
<p><code>N' · T' = Nᵀ * Mᵀ * M * T</code></p>
<p>(where <code>Nᵀ</code> is the transpose of <code>N</code>, and <code>Mᵀ</code> is the transpose of <code>M</code>)</p>
<p>For this expression to remain zero (and thus perpendicular), the <code>Mᵀ * M</code> part in the middle must be the Identity matrix (<code>I</code>), which doesn't change the equation.</p>
<p>A matrix <code>M</code> for which <code>Mᵀ * M = I</code> is called an <strong>orthogonal matrix</strong>. This property holds true for pure rotations and uniform scaling (where all axes are scaled by the same amount). However, it <strong>fails</strong> for non-uniform scaling or shearing.</p>
<p>This proves that using the model matrix <code>M</code> to transform normals is only mathematically valid for transformations that don't warp the object. For the general case, we need a different solution.</p>
<h2 id="heading-the-solution-the-normal-matrix">The Solution: The Normal Matrix</h2>
<p>If the model matrix <code>M</code> is incorrect for transforming normals, what is the right tool for the job? The answer is a special matrix derived from the model matrix, known simply as the <strong>Normal Matrix</strong>. It is specifically constructed to solve the non-uniform scaling problem and ensure normals remain perpendicular to their surface after transformation.</p>
<h3 id="heading-the-normal-matrix-formula">The Normal Matrix Formula</h3>
<p>The formula for the normal matrix is the <strong>transpose of the inverse of the model matrix</strong>.</p>
<p><code>Normal Matrix = transpose(inverse(Model Matrix))</code></p>
<p>More specifically, since normals are direction vectors and are not affected by translation, we only care about the rotation and scale part of the model matrix. Therefore, we use the upper-left 3x3 portion of the <code>mat4x4</code>.</p>
<p><code>Normal Matrix = transpose(inverse(model_3x3))</code></p>
<p>In shader code, the full manual operation to transform a normal would look like this:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> world_normal = normalize(transpose(inverse(model_3x3)) * local_normal);
</code></pre>
<h3 id="heading-the-intuition-why-does-this-work">The Intuition: Why Does This Work?</h3>
<p>The formula might seem plucked from thin air, but there's a strong intuition behind it.</p>
<p>Think of it this way: the model matrix transformation squashed our surface, making it flatter. To keep the normal perpendicular to this new flatter surface, it had to become more vertical. The transformation that was applied to the surface and the one that needs to be applied to the normal are opposites.</p>
<ul>
<li><p>If the model matrix <strong>stretches</strong> space along an axis, the normal matrix must <strong>squash</strong> the normals along that same axis to compensate.</p>
</li>
<li><p>If the model matrix <strong>squashes</strong> space, the normal matrix must <strong>stretch</strong> the normals.</p>
</li>
</ul>
<p>The <code>inverse()</code> operation is what provides this "opposite" transformation. It mathematically "undoes" the original scale and rotation. The <code>transpose()</code> operation then correctly orients this inverted transformation to work for normals. In short, the inverse-transpose is the unique mathematical operation that preserves the property of perpendicularity under a non-uniform linear transformation.</p>
<h3 id="heading-the-mathematical-proof">The Mathematical Proof</h3>
<p>We can revisit our proof from before to show why this works. We need to find a special matrix, let's call it <code>N_mat</code>, such that a transformed normal <code>N' = N_mat * N remains</code> perpendicular to a transformed tangent <code>T' = M * T</code>.</p>
<p>Their dot product must be zero:</p>
<p><code>(N_mat * N) · (M * T) = 0</code></p>
<p>Rewriting this using matrix notation:</p>
<p><code>Nᵀ * N_matᵀ * M * T = 0</code></p>
<p>For this equation to be true for any <code>N</code> and <code>T</code>, the middle part, <code>N_matᵀ * M</code>, must equal the Identity matrix <code>I</code>.</p>
<p><code>N_matᵀ * M = I</code></p>
<p>To solve for our unknown normal matrix <code>N_mat</code>, we can multiply both sides by the inverse of <code>M</code> (<code>M⁻¹</code>):</p>
<p><code>N_matᵀ * M * M⁻¹ = I * M⁻¹</code></p>
<p><code>N_matᵀ = M⁻¹</code></p>
<p>Finally, to get <code>N_mat</code>, we just take the transpose of both sides:</p>
<p><code>N_mat = (M⁻¹)ᵀ</code></p>
<p>This proves that the correct transformation matrix for normals is <code>transpose(inverse(M))</code>. It's precisely the matrix required to ensure that normals remain perpendicular to their transformed tangents.</p>
<h3 id="heading-a-practical-guide-when-to-use-it">A Practical Guide: When to Use It</h3>
<p>So, when do you actually need to worry about this?</p>
<p>You <strong>must</strong> use the normal matrix when your object's transform involves:</p>
<ul>
<li><p><strong>Non-uniform scaling:</strong> The most common case. E.g., <code>Transform::from_scale(Vec3::new(2.0, 1.0, 1.0))</code>.</p>
</li>
<li><p><strong>Shearing:</strong> A less common transformation that skews an object.</p>
</li>
</ul>
<p>You <strong>can skip</strong> the expensive inverse-transpose calculation and just use the model matrix (or its 3x3 part) for normals if the transform <strong>only</strong> involves:</p>
<ul>
<li><p><strong>Translation:</strong> Normals ignore translation anyway.</p>
</li>
<li><p><strong>Rotation:</strong> Rotations are orthogonal transformations that preserve perpendicularity.</p>
</li>
<li><p><strong>Uniform scaling:</strong> All axes are scaled by the exact same amount. Like rotation, this is an orthogonal transformation.</p>
</li>
</ul>
<p><strong>The Golden Rule for Beginners:</strong> When in doubt, always use the normal matrix. The performance cost of getting it wrong (broken lighting) is far greater than the computational cost of getting it right. Modern engines like Bevy are designed to handle this for you correctly and efficiently behind the scenes, but it's crucial to understand what is happening so you can debug it when things go wrong.</p>
<h2 id="heading-implementing-normal-transformation-in-bevy">Implementing Normal Transformation in Bevy</h2>
<p>Now that we understand the theory behind the normal matrix, let's look at how to apply it within a Bevy shader. Bevy, being a well-designed engine, provides a highly optimized, built-in solution. However, understanding how to do it manually is crucial for writing fully custom render pipelines or for moments when you can't use the standard helpers.</p>
<h3 id="heading-the-idiomatic-bevy-way-using-pbr-imports">The Idiomatic Bevy Way: Using PBR Imports</h3>
<p>For almost all use cases, the best approach is to use the helper functions provided in Bevy's <code>bevy_pbr</code> shader module. This is the easiest, safest, and most performant method.</p>
<pre><code class="lang-rust">#import bevy_pbr::mesh_functions

<span class="hljs-comment">// ... inside your @vertex function ...</span>

<span class="hljs-comment">// Get the model matrix (same as before)</span>
<span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);

<span class="hljs-comment">// Transform position to world space (same as before)</span>
<span class="hljs-keyword">let</span> world_position = (model * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">1.0</span>)).xyz;

<span class="hljs-comment">// CORRECTLY transform the normal to world space</span>
<span class="hljs-comment">// This is the magic function that uses the pre-calculated normal matrix!</span>
<span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(
    <span class="hljs-keyword">in</span>.normal,
    <span class="hljs-keyword">in</span>.instance_index
);

<span class="hljs-comment">// We'll discuss why this normalize() call is critical in the next section</span>
out.world_normal = normalize(world_normal);
</code></pre>
<h4 id="heading-whats-happening-under-the-hood">What's Happening Under the Hood?</h4>
<p>You don't see a <code>transpose(inverse(model))</code> call here because Bevy does the heavy lifting for you on the CPU. Here is the workflow:</p>
<ol>
<li><p><strong>On the CPU (in Rust):</strong> Whenever an entity's <code>Transform</code> changes, Bevy's renderer automatically calculates its <code>Mat4</code> model matrix.</p>
</li>
<li><p><strong>Pre-computation:</strong> At the same time, it also calculates the corresponding <code>Mat3</code> normal matrix (<code>transpose(inverse(model))</code>).</p>
</li>
<li><p><strong>GPU Data:</strong> Both matrices are sent to the GPU and stored in a buffer accessible to your shader.</p>
</li>
<li><p><strong>In the Shader (in WGSL):</strong> The <code>mesh_normal_local_to_world()</code> function simply looks up this pre-computed normal matrix and multiplies it by your vertex normal.</p>
</li>
</ol>
<p>This is incredibly efficient. The expensive inverse and transpose operations are done only once per object when its transform changes, not for every single vertex, every single frame.</p>
<h3 id="heading-the-manual-calculation-for-education-amp-special-cases">The Manual Calculation (For Education &amp; Special Cases)</h3>
<p>To truly understand what Bevy is doing for you, it's helpful to see what a manual calculation would look like. While WGSL provides a built-in <code>inverse()</code> function that makes this possible, <strong>you should almost never do this in production code</strong>. It serves as an excellent learning exercise that powerfully demonstrates why Bevy's approach is superior.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A function to manually compute the normal matrix from a model matrix.</span>
<span class="hljs-comment">// WARNING: This is computationally expensive and for educational purposes only.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_normal_matrix</span></span>(model: mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; mat3x3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// First, extract the upper-left 3x3 portion (rotation and scale)</span>
    <span class="hljs-keyword">let</span> model_3x3 = mat3x3&lt;<span class="hljs-built_in">f32</span>&gt;(
        model[<span class="hljs-number">0</span>].xyz,
        model[<span class="hljs-number">1</span>].xyz,
        model[<span class="hljs-number">2</span>].xyz
    );

    <span class="hljs-comment">// WGSL has a built-in inverse function, but it's expensive to run per-vertex.</span>
    <span class="hljs-keyword">let</span> model_inverse = inverse(model_3x3);

    <span class="hljs-comment">// Finally, transpose the inverse to get the normal matrix</span>
    <span class="hljs-keyword">return</span> transpose(model_inverse);
}

<span class="hljs-comment">// ... inside your @vertex function ...</span>

<span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
<span class="hljs-keyword">let</span> normal_matrix = calculate_normal_matrix(model);

<span class="hljs-keyword">let</span> world_normal = normal_matrix * <span class="hljs-keyword">in</span>.normal;
out.world_normal = normalize(world_normal); <span class="hljs-comment">// Still need to normalize!</span>
</code></pre>
<blockquote>
<p><strong>Performance Warning:</strong> Calculating a matrix inverse is one of the more expensive operations you can ask a GPU to do. Running the code above for every vertex in a mesh with thousands of vertices is a recipe for poor performance. This is why the idiomatic "Bevy Way" is to compute it once on the CPU.</p>
</blockquote>
<h3 id="heading-the-professional-approach-precomputed-uniforms">The Professional Approach: Precomputed Uniforms</h3>
<p>If you are writing a completely custom material from scratch and not importing <code>bevy_pbr::mesh_functions</code>, the best practice is to mimic Bevy's approach: compute the normal matrix in Rust and pass it to your shader as a uniform.</p>
<h4 id="heading-step-1-in-your-rust-code">Step 1: In Your Rust Code</h4>
<p>When you define your custom material, you'll pass both the model and normal matrices.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In your custom material's uniform struct</span>
<span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

<span class="hljs-meta">#[derive(ShaderType)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">MyMaterialUniforms</span></span> {
    model_matrix: Mat4,
    normal_matrix: Mat3, <span class="hljs-comment">// Use a Mat3 for the 3x3 normal matrix</span>
}

<span class="hljs-comment">// In the system that prepares your material's bind group</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">prepare_my_materials</span></span>(
    <span class="hljs-comment">// ... query for your material and the object's GlobalTransform ...</span>
) {
    <span class="hljs-keyword">for</span> (material_handle, transform) <span class="hljs-keyword">in</span> query.iter() {
        <span class="hljs-keyword">let</span> model_matrix = transform.compute_matrix();

        <span class="hljs-comment">// glam (Bevy's math library) makes this easy!</span>
        <span class="hljs-keyword">let</span> normal_matrix = Mat3::from_mat4(model_matrix).inverse().transpose();

        <span class="hljs-comment">// Now, write these values to the material's uniform buffer.</span>
        <span class="hljs-comment">// ...</span>
    }
}
</code></pre>
<h4 id="heading-step-2-in-your-wgsl-shader">Step 2: In Your WGSL Shader</h4>
<p>The shader now becomes beautifully simple. It just receives the pre-computed matrix and uses it.</p>
<pre><code class="lang-rust"><span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">MyMaterial</span></span> {
    model_matrix: mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal_matrix: mat3x3&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// The matrix we computed in Rust</span>
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: MyMaterial;

<span class="hljs-comment">// ... inside your @vertex function ...</span>

<span class="hljs-comment">// Just use the precomputed matrix. Simple and fast!</span>
<span class="hljs-keyword">let</span> world_normal = material.normal_matrix * <span class="hljs-keyword">in</span>.normal;
out.world_normal = normalize(world_normal);
</code></pre>
<p>This approach gives you the full performance benefit of the "Bevy Way" while allowing for a completely independent, custom material and shader.</p>
<h2 id="heading-renormalizing-after-transformation">Renormalizing After Transformation</h2>
<p>There is a simple, non-negotiable rule in shader programming: after transforming a normal, and <strong>again</strong> after it's been interpolated, <strong>you must normalize it.</strong> This is arguably the most common source of subtle lighting errors for beginners. Neglecting this step will break your lighting calculations, which almost universally assume that normal vectors have a length of exactly 1.0.</p>
<p>There are two primary reasons why a normal's length can change.</p>
<h3 id="heading-reason-1-the-transformation-itself">Reason 1: The Transformation Itself</h3>
<p>Even if your input normal from the mesh is a perfect unit vector (length 1.0), the act of multiplying it by the normal matrix can alter its length. This is particularly true when non-uniform scaling is involved. The matrix correctly changes the normal's direction to keep it perpendicular to the scaled surface, but it doesn't guarantee its length will remain 1.0.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764359307435/ca1e260b-0125-4a7d-8092-bdcc95c62b81.png" alt class="image--center mx-auto" /></p>
<p>Therefore, the first <code>normalize()</code> call is essential immediately after the transformation in the vertex shader.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In the vertex shader...</span>

<span class="hljs-comment">// Transform the normal using the correct matrix</span>
<span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(
    <span class="hljs-keyword">in</span>.normal,
    <span class="hljs-keyword">in</span>.instance_index
);

<span class="hljs-comment">// CORRECT: Immediately normalize the result before passing it on.</span>
out.world_normal = normalize(world_normal);
</code></pre>
<h3 id="heading-reason-2-interpolation-the-hidden-trap">Reason 2: Interpolation (The Hidden Trap)</h3>
<p>The second, more subtle reason happens between the vertex and fragment shaders. For each pixel it renders, the GPU's rasterizer looks at the normals from the vertices of the triangle that pixel belongs to and <strong>linearly interpolates</strong> them to create a smooth normal for that specific fragment.</p>
<p>This interpolation process does not preserve length.</p>
<p>Imagine a simple 2D example. The normal at one vertex is <code>(1, 0)</code> and at another is <code>(0, 1)</code>. Both are perfect unit vectors.</p>
<p>What is the interpolated normal exactly halfway between them?</p>
<ul>
<li><p><strong>Interpolated Vector:</strong> <code>(0.5, 0.5)</code></p>
</li>
<li><p><strong>Length:</strong> <code>sqrt(0.5*0.5 + 0.5*0.5) = sqrt(0.25 + 0.25) = sqrt(0.5) ≈ 0.707</code></p>
</li>
</ul>
<p>The interpolated vector is <strong>not</strong> a unit vector! Its length is less than 1.0. This means the normal vector arriving in your fragment shader is almost never perfectly normalized.</p>
<p>This leads to the second golden rule: <strong>Always re-normalize your normal vector at the beginning of your fragment shader.</strong></p>
<pre><code class="lang-rust"><span class="hljs-comment">// In the fragment shader...</span>

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// WRONG: `in.world_normal` is not guaranteed to be unit length!</span>
    <span class="hljs-comment">// let N = in.world_normal;</span>

    <span class="hljs-comment">// CORRECT: The very first thing you do is re-normalize the incoming normal.</span>
    <span class="hljs-keyword">let</span> N = normalize(<span class="hljs-keyword">in</span>.world_normal);

    <span class="hljs-comment">// Now proceed with lighting calculations...</span>
    <span class="hljs-comment">// let light_dot_product = dot(N, light_direction);</span>
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<h3 id="heading-what-about-the-performance-cost">What About the Performance Cost?</h3>
<p>Beginners are often tempted to skip <code>normalize()</code> thinking it's an expensive operation. On modern GPUs, this is a false economy. The <code>normalize()</code> function is a highly optimized, low-level instruction.</p>
<ul>
<li><p><strong>Cost of</strong> <code>normalize()</code>: A few clock cycles. Negligible.</p>
</li>
<li><p><strong>Cost of incorrect lighting:</strong> Catastrophic. Your game will look broken.</p>
</li>
</ul>
<p>The performance gain from skipping this is almost zero, and the visual penalty is enormous.</p>
<h3 id="heading-when-can-you-really-skip-normalizing">When Can You <em>Really</em> Skip Normalizing?</h3>
<p>For advanced optimization, you can sometimes skip it, but only if you meet <strong>all</strong> of these conditions:</p>
<ol>
<li><p>You are absolutely certain the transformation is rotation-only (no scale at all).</p>
</li>
<li><p>The input normal from the mesh was already perfectly normalized.</p>
</li>
<li><p>You are not interpolating the normal (e.g., using "flat" interpolation, common in low-poly styles).</p>
</li>
</ol>
<p>In practice, for 99% of cases, especially when learning, the rule is simple: <strong>just normalize.</strong> Once in the vertex shader after transformation, and once again in the fragment shader before use.</p>
<h2 id="heading-tangent-space-and-tbn-matrices">Tangent Space and TBN Matrices</h2>
<p>Correctly transforming vertex normals is a huge step, but it's only part of the story. To unlock advanced techniques like <strong>normal mapping</strong> - a cornerstone of modern real-time graphics - we need to establish a complete, local coordinate system at every single point on a mesh's surface. This is known as <strong>Tangent Space</strong>.</p>
<h3 id="heading-what-is-tangent-space">What is Tangent Space?</h3>
<p>Imagine a tiny ladybug standing on the surface of a complex model. From her perspective, "up" isn't the world's Y-axis; it's the direction pointing straight away from the surface she's on. This is the <strong>Normal</strong>. If she walks forward along the grain of the surface texture, she is walking along the <strong>Tangent</strong>. The direction to her immediate left or right is the <strong>Bitangent</strong>.</p>
<p>Together, these three vectors - Tangent, Bitangent, and Normal - form the <strong>TBN frame</strong>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764359460796/ead8f23f-9e99-420e-a5fd-03ea32c3e471.png" alt class="image--center mx-auto" /></p>
<ul>
<li><p><strong>N (Normal):</strong> The vector we already know, perpendicular to the surface ("up" for the ladybug).</p>
</li>
<li><p><strong>T (Tangent):</strong> A vector that runs parallel to the surface, typically aligned with the U-axis of the mesh's UV coordinates (the "forward" direction for textures).</p>
</li>
<li><p><strong>B (Bitangent):</strong> A vector that is also parallel to the surface, perpendicular to both the Normal and the Tangent. It is calculated using a cross product.</p>
</li>
</ul>
<p>These three vectors form an <em>orthonormal basis</em>: they are all mutually perpendicular and have a length of 1.0. They define a complete 3D coordinate system that is "stuck" to the surface of the mesh, twisting and turning with it.</p>
<h3 id="heading-why-do-we-need-tangent-space">Why Do We Need Tangent Space?</h3>
<p>The primary motivation for tangent space is <strong>normal mapping</strong>. A normal map is a special texture where the RGB values don't represent color, but instead store the X, Y, and Z components of normal vectors. This allows us to add incredibly detailed lighting information - like bumps, cracks, and pores - to a low-polygon model.</p>
<p>The key is that these normals are stored relative to the surface. They are defined in Tangent Space. For example, a flat blue color <code>(0.5, 0.5, 1.0)</code> in a normal map texture translates to a vector <code>(0, 0, 1)</code> in tangent space, which means "this part of the surface points straight out, along the local normal." A different color would represent a vector pointing at an angle, creating the illusion of a bump.</p>
<p>To use this information, our shader must perform a transformation:</p>
<ol>
<li><p>Read the normal vector from the texture (which is in Tangent Space).</p>
</li>
<li><p>Use the TBN matrix to transform this vector from Tangent Space into World Space.</p>
</li>
<li><p>Use the resulting World Space normal for our lighting calculations.</p>
</li>
</ol>
<h3 id="heading-getting-the-tangent-vector-in-bevy">Getting the Tangent Vector in Bevy</h3>
<p>Just like normals, tangents are an attribute of a <code>Mesh</code>. They are typically generated from the mesh's UV coordinates, as the tangent is defined to follow the U direction of the texture map.</p>
<p>You almost never need to calculate these yourself. Bevy provides a convenient helper function:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In your Rust setup code, after creating a mesh...</span>
<span class="hljs-comment">// This requires the mesh to have UV coordinates.</span>
mesh.generate_tangents().expect(<span class="hljs-string">"Failed to generate tangents"</span>);

<span class="hljs-comment">// The tangent attribute is now available on the mesh. Bevy's PBR</span>
<span class="hljs-comment">// material pipeline automatically picks it up. If you are writing</span>
<span class="hljs-comment">// a fully custom material, you would need to ensure the vertex</span>
<span class="hljs-comment">// attribute is enabled in your render pipeline.</span>
</code></pre>
<p>The tangent attribute in Bevy is a <code>vec4</code>:</p>
<ul>
<li><p><code>.xyz</code>: The 3D direction of the tangent vector.</p>
</li>
<li><p><code>.w</code>: A value called "handedness" (either <code>1.0</code> or <code>-1.0</code>). This is used to correct the orientation of the calculated Bitangent, ensuring the TBN frame points the right way on meshes with mirrored UVs.</p>
</li>
</ul>
<h3 id="heading-building-the-tbn-frame-in-the-vertex-shader">Building the TBN Frame in the Vertex Shader</h3>
<p>The goal of the vertex shader is to calculate the World Space versions of the T, B, and N vectors and pass them to the fragment shader.</p>
<ol>
<li><p><strong>Transform the Normal:</strong> We already know how to do this correctly using the normal matrix via <code>mesh_normal_local_to_world</code>.</p>
</li>
<li><p><strong>Transform the Tangent:</strong> A tangent is a direction <em>along</em> the surface, so it transforms like a position vector (without the translation part). We can use the standard model matrix for this.</p>
</li>
<li><p><strong>Calculate the Bitangent:</strong> The bitangent is simply the cross product of the world-space normal and tangent. We multiply by the <code>tangent.w</code> handedness value to ensure the correct orientation.</p>
</li>
</ol>
<p>Here is the complete process inside a vertex shader:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... inside the @vertex function ...</span>

<span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);

<span class="hljs-comment">// 1. Transform Normal and normalize</span>
<span class="hljs-keyword">let</span> world_normal = normalize(
    mesh_functions::mesh_normal_local_to_world(<span class="hljs-keyword">in</span>.normal, <span class="hljs-keyword">in</span>.instance_index)
);

<span class="hljs-comment">// 2. Transform Tangent and normalize</span>
<span class="hljs-keyword">let</span> world_tangent = normalize(
    (model * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.tangent.xyz, <span class="hljs-number">0.0</span>)).xyz
);

<span class="hljs-comment">// 3. Calculate Bitangent from the normalized N and T</span>
<span class="hljs-keyword">let</span> world_bitangent = cross(world_normal, world_tangent) * <span class="hljs-keyword">in</span>.tangent.w;

<span class="hljs-comment">// Pass these three vectors to the fragment shader via the output struct</span>
out.world_normal = world_normal;
out.world_tangent = world_tangent;
out.world_bitangent = world_bitangent;
</code></pre>
<h3 id="heading-using-the-tbn-matrix-in-the-fragment-shader">Using the TBN Matrix in the Fragment Shader</h3>
<p>In the fragment shader, we assemble these three vectors into a <code>mat3x3</code>. This matrix serves as a bridge, allowing us to convert vectors from tangent space (like those from a normal map) into world space.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... inside the @fragment function ...</span>

<span class="hljs-comment">// First, re-normalize all incoming vectors to correct for interpolation.</span>
<span class="hljs-keyword">let</span> N = normalize(<span class="hljs-keyword">in</span>.world_normal);
<span class="hljs-keyword">let</span> T = normalize(<span class="hljs-keyword">in</span>.world_tangent);
<span class="hljs-keyword">let</span> B = normalize(<span class="hljs-keyword">in</span>.world_bitangent);

<span class="hljs-comment">// Assemble the TBN matrix. The columns of this matrix are our basis vectors.</span>
<span class="hljs-keyword">let</span> TBN = mat3x3&lt;<span class="hljs-built_in">f32</span>&gt;(T, B, N);

<span class="hljs-comment">// --- Normal Mapping Example ---</span>
<span class="hljs-comment">// (We will cover this in depth in a later phase)</span>

<span class="hljs-comment">// 1. Sample a normal from a texture. The value is in the [0,1] range.</span>
<span class="hljs-comment">// Note: We use textureSample() here, not textureSampleLevel(). The GPU</span>
<span class="hljs-comment">// can automatically calculate the correct mipmap level in a fragment</span>
<span class="hljs-comment">// shader because it has screen-space information, which is not</span>
<span class="hljs-comment">// available in the vertex shader.</span>
<span class="hljs-keyword">let</span> tangent_normal_from_texture = textureSample(normal_map, sampler, <span class="hljs-keyword">in</span>.uv).xyz;

<span class="hljs-comment">// 2. Remap the normal from [0,1] to the [-1,1] vector range.</span>
<span class="hljs-keyword">let</span> tangent_normal = tangent_normal_from_texture * <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>;

<span class="hljs-comment">// 3. Transform the normal from tangent space to world space using the TBN matrix.</span>
<span class="hljs-keyword">let</span> final_world_normal = normalize(TBN * tangent_normal);

<span class="hljs-comment">// 4. Use `final_world_normal` for all your lighting calculations.</span>
<span class="hljs-comment">// ...</span>
</code></pre>
<h3 id="heading-pro-tip-gram-schmidt-orthogonalizationhttpsenwikipediaorgwikigrame28093schmidtprocess">Pro Tip: <a target="_blank" href="https://en.wikipedia.org/wiki/Gram%E2%80%93Schmidt_process">Gram-Schmidt Orthogonalization</a></h3>
<p>Just as interpolation can mess up a vector's length, it can also slightly disrupt the perfect 90-degree angles between the T, B, and N vectors. For high-precision work, you can enforce orthogonality in the fragment shader using a process called Gram-Schmidt orthogonalization.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In the fragment shader, an improved way to create the TBN basis</span>
<span class="hljs-keyword">let</span> N = normalize(<span class="hljs-keyword">in</span>.world_normal);
<span class="hljs-keyword">let</span> T = normalize(<span class="hljs-keyword">in</span>.world_tangent);

<span class="hljs-comment">// This forces the tangent to be perfectly perpendicular to the normal.</span>
<span class="hljs-keyword">let</span> T_ortho = normalize(T - dot(T, N) * N);

<span class="hljs-comment">// Recalculate the bitangent from the now-orthogonal T and N.</span>
<span class="hljs-keyword">let</span> B_ortho = cross(N, T_ortho);

<span class="hljs-comment">// This TBN matrix is guaranteed to be perfectly orthonormal.</span>
<span class="hljs-keyword">let</span> TBN = mat3x3&lt;<span class="hljs-built_in">f32</span>&gt;(T_ortho, B_ortho, N);
</code></pre>
<p>This is a robust way to ensure your tangent space basis is clean and accurate, eliminating a potential source of subtle lighting artifacts.</p>
<h2 id="heading-common-normal-transformation-errors">Common Normal Transformation Errors</h2>
<p>Theory is one thing, but debugging a shader that produces bizarre lighting is another. Most issues with normals boil down to a handful of common mistakes. Learning to recognize their symptoms will save you hours of frustration.</p>
<h3 id="heading-error-1-using-the-model-matrix-on-normals">Error 1: Using the Model Matrix on Normals</h3>
<p>This is the fundamental error this entire article is about, and it's the most common first mistake.</p>
<h4 id="heading-the-code">The Code</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// WRONG: Using the model matrix instead of the normal matrix.</span>
<span class="hljs-keyword">let</span> world_normal = (model * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.normal, <span class="hljs-number">0.0</span>)).xyz;
</code></pre>
<h4 id="heading-the-symptom">The Symptom</h4>
<p>Lighting will look correct on objects that are only rotated or uniformly scaled. However, as soon as you apply <strong>non-uniform scale</strong> (e.g., <code>scale = (2.0, 0.5, 1.0)</code>), the lighting will become dramatically incorrect. Highlights will appear stretched, squashed, or in the wrong places, and surfaces will look too dark or flat.</p>
<h4 id="heading-the-fix">The Fix</h4>
<p>Always use the proper function for normal transformation, which internally uses the normal matrix.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// CORRECT:</span>
<span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(<span class="hljs-keyword">in</span>.normal, <span class="hljs-keyword">in</span>.instance_index);
</code></pre>
<h3 id="heading-error-2-forgetting-to-normalize-in-either-shader">Error 2: Forgetting to Normalize (In Either Shader)</h3>
<p>This is the second most common mistake and produces more subtle, but still very wrong, results.</p>
<h4 id="heading-the-code-1">The Code</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// In Vertex Shader</span>
<span class="hljs-comment">// WRONG: Not normalized after transformation.</span>
out.world_normal = mesh_functions::mesh_normal_local_to_world(<span class="hljs-keyword">in</span>.normal, <span class="hljs-keyword">in</span>.instance_index);

<span class="hljs-comment">// In Fragment Shader</span>
<span class="hljs-comment">// WRONG: Not re-normalized after interpolation.</span>
<span class="hljs-keyword">let</span> N = <span class="hljs-keyword">in</span>.world_normal;
</code></pre>
<h4 id="heading-the-symptom-1">The Symptom</h4>
<p>Lighting intensity will be incorrect. If the interpolated normal's length is less than 1.0 (which is typical), the <code>dot()</code> product in your lighting calculation will be smaller than it should be, making the surface appear darker. If the length were greater than <code>1.0</code>, it would appear unusually bright or "blown out." The overall effect is inconsistent and often looks "dull."</p>
<h4 id="heading-the-fix-1">The Fix</h4>
<p>Normalize, normalize, normalize. Once in the vertex shader after transformation, and again in the fragment shader before any calculations.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// CORRECT (Vertex):</span>
out.world_normal = normalize(mesh_functions::mesh_normal_local_to_world(<span class="hljs-keyword">in</span>.normal, <span class="hljs-keyword">in</span>.instance_index));

<span class="hljs-comment">// CORRECT (Fragment):</span>
<span class="hljs-keyword">let</span> N = normalize(<span class="hljs-keyword">in</span>.world_normal);
</code></pre>
<h3 id="heading-error-3-transforming-tangents-incorrectly">Error 3: Transforming Tangents Incorrectly</h3>
<p>When building a TBN matrix, it's easy to get confused and transform the tangent vector the same way you transformed the normal.</p>
<h4 id="heading-the-code-2">The Code</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// WRONG: Using the normal matrix on a tangent vector.</span>
<span class="hljs-keyword">let</span> world_tangent = mesh_functions::mesh_normal_local_to_world(<span class="hljs-keyword">in</span>.tangent.xyz, <span class="hljs-keyword">in</span>.instance_index);
</code></pre>
<h4 id="heading-the-symptom-2">The Symptom</h4>
<p>Normal mapping and other tangent-space effects will be completely broken. The lighting will seem to come from the wrong direction relative to the surface details, creating a confusing and distorted look.</p>
<h4 id="heading-the-fix-2">The Fix</h4>
<p>Remember that tangents are directions along the surface and transform with the regular model matrix.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// CORRECT:</span>
<span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
<span class="hljs-keyword">let</span> world_tangent = (model * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.tangent.xyz, <span class="hljs-number">0.0</span>)).xyz;
</code></pre>
<h3 id="heading-error-4-incorrect-bitangent-calculation">Error 4: Incorrect Bitangent Calculation</h3>
<p>A small mistake in calculating the bitangent can flip your tangent space upside-down.</p>
<h4 id="heading-the-code-3">The Code</h4>
<pre><code class="lang-rust"><span class="hljs-comment">// WRONG: Forgetting to multiply by the handedness component (tangent.w).</span>
<span class="hljs-keyword">let</span> world_bitangent = cross(world_normal, world_tangent);
</code></pre>
<h4 id="heading-the-symptom-3">The Symptom</h4>
<p>Similar to transforming tangents incorrectly. Normal mapped details might appear inverted (bumps look like divots) or lit from the opposite direction, but only on certain parts of your model (specifically, where UV islands have been mirrored by the 3D artist to save texture space).</p>
<h4 id="heading-the-fix-3">The Fix</h4>
<p>Always multiply the cross product by <code>tangent.w</code>.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// CORRECT:</span>
<span class="hljs-keyword">let</span> world_bitangent = cross(world_normal, world_tangent) * <span class="hljs-keyword">in</span>.tangent.w;
</code></pre>
<h3 id="heading-debugging-with-visualizations">Debugging with Visualizations</h3>
<p>The single most effective way to debug these issues is to stop guessing and start visualizing. Instead of calculating lighting, have your fragment shader output the normal vector itself as a color.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A simple function to map a vector direction [-1, 1] to a color [0, 1]</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">direction_to_color</span></span>(dir: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> dir * <span class="hljs-number">0.5</span> + <span class="hljs-number">0.5</span>;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> N = normalize(<span class="hljs-keyword">in</span>.world_normal);
    <span class="hljs-keyword">let</span> color = direction_to_color(N);
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p>This "normal visualizer" will immediately show you if your normals are behaving correctly:</p>
<ul>
<li><p><strong>Smooth Gradients:</strong> A sphere should show smooth color transitions.</p>
</li>
<li><p><strong>Consistent Colors:</strong> A cube should have a solid, distinct color for each face.</p>
</li>
<li><p><strong>Rotation:</strong> When you rotate the object, the world-space colors should change accordingly.</p>
</li>
<li><p><strong>Scaling:</strong> When you apply non-uniform scale, the colors should distort to match the new surface orientation. If they don't, you're not using the normal matrix correctly.</p>
</li>
</ul>
<hr />
<h2 id="heading-complete-example-normal-visualization-system">Complete Example: Normal Visualization System</h2>
<p>The best way to solidify these concepts and build a powerful debugging tool for the future is to create a shader that can visually display normals, tangents, and bitangents. By mapping these vectors to RGB colors, we can see, instantly, if our transformations are working correctly. This is an indispensable technique used by graphics programmers everywhere.</p>
<h3 id="heading-our-goal">Our Goal</h3>
<p>We will build a complete Bevy application that displays several 3D meshes. We will create a custom material and WGSL shader that, instead of performing lighting, colors the mesh based on its vertex attributes. We will add interactive controls to switch between visualizing normals, tangents, and bitangents, and to toggle between local and world space, allowing us to directly observe the effects of our transformation logic.</p>
<h3 id="heading-what-this-project-demonstrates">What This Project Demonstrates</h3>
<ul>
<li><p><strong>Correct Normal Transformation:</strong> You will see the direct output of <code>mesh_normal_local_to_world</code> and confirm that it works correctly under rotation and non-uniform scaling.</p>
</li>
<li><p><strong>TBN Frame Calculation:</strong> The visualization will show the Tangent, Bitangent, and Normal vectors, confirming our TBN frame is constructed properly.</p>
</li>
<li><p><strong>Local vs. World Space:</strong> Toggling between the two spaces provides a clear, intuitive understanding of how transformations affect orientation vectors.</p>
</li>
<li><p><strong>The Importance of the Normal Matrix:</strong> A dedicated control will apply non-uniform scaling, proving visually why the model matrix fails and the normal matrix is necessary for correct results.</p>
</li>
<li><p><strong>A Powerful Debugging Tool:</strong> You can adapt this shader and reuse it in your own projects whenever you suspect your lighting is wrong.</p>
</li>
</ul>
<h3 id="heading-the-shader-assetsshadersd0206normalvisualizationwgsl">The Shader (<code>assets/shaders/d02_06_normal_visualization.wgsl</code>)</h3>
<p>This single WGSL file contains both the vertex and fragment shaders.</p>
<ul>
<li><p>The <strong>vertex shader</strong> is responsible for transforming the position, normal, and tangent from local to world space. It calculates the bitangent and passes the full set of local-space and world-space vectors to the fragment shader.</p>
</li>
<li><p>The <strong>fragment shader</strong> receives these interpolated vectors. Based on uniform parameters controlled by our Rust code, it selects which vector to display (Normal, Tangent, or Bitangent) and from which space (Local or World). It then uses a helper function to map the <code>[-1, 1]</code> vector direction to a <code>[0, 1]</code> RGB color for output.</p>
</li>
</ul>
<pre><code class="lang-rust">#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">NormalVisualizationMaterial</span></span> {
    display_mode: <span class="hljs-built_in">u32</span>,  <span class="hljs-comment">// 0=normals, 1=tangents, 2=bitangents, 3=lighting test, 4=TBN combined</span>
    show_world_space: <span class="hljs-built_in">u32</span>,  <span class="hljs-comment">// 0=local space, 1=world space</span>
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: NormalVisualizationMaterial;

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">3</span>) tangent: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) world_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) world_tangent: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">3</span>) world_bitangent: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">4</span>) local_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">5</span>) local_tangent: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">6</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);

    <span class="hljs-comment">// Transform position</span>
    <span class="hljs-keyword">let</span> world_position = mesh_functions::mesh_position_local_to_world(
        model,
        vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">1.0</span>)
    );

    <span class="hljs-comment">// Transform normal - using Bevy's helper which uses the normal matrix</span>
    <span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(
        <span class="hljs-keyword">in</span>.normal,
        <span class="hljs-keyword">in</span>.instance_index
    );

    <span class="hljs-comment">// Transform tangent - transforms like a position (direction vector)</span>
    <span class="hljs-keyword">let</span> world_tangent = (model * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.tangent.xyz, <span class="hljs-number">0.0</span>)).xyz;

    <span class="hljs-comment">// Calculate bitangent</span>
    <span class="hljs-comment">// Must normalize before cross product to get correct length</span>
    <span class="hljs-keyword">let</span> N = normalize(world_normal);
    <span class="hljs-keyword">let</span> T = normalize(world_tangent);
    <span class="hljs-keyword">let</span> B = cross(N, T) * <span class="hljs-keyword">in</span>.tangent.w;  <span class="hljs-comment">// Include handedness</span>

    <span class="hljs-comment">// Store both local and world space versions</span>
    out.clip_position = position_world_to_clip(world_position.xyz);
    out.world_position = world_position.xyz;
    out.world_normal = N;
    out.world_tangent = T;
    out.world_bitangent = B;
    out.local_normal = <span class="hljs-keyword">in</span>.normal;
    out.local_tangent = <span class="hljs-keyword">in</span>.tangent.xyz;
    out.uv = <span class="hljs-keyword">in</span>.uv;

    <span class="hljs-keyword">return</span> out;
}

<span class="hljs-comment">// Convert a direction vector to RGB color</span>
<span class="hljs-comment">// Maps [-1,1] range to [0,1] color range</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">direction_to_color</span></span>(dir: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> dir * <span class="hljs-number">0.5</span> + <span class="hljs-number">0.5</span>;
}

<span class="hljs-comment">// Simple lighting for lighting test mode</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_simple_lighting</span></span>(normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Directional light</span>
    <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.0</span>, dot(normal, light_dir));

    <span class="hljs-comment">// View direction for specular</span>
    <span class="hljs-keyword">let</span> view_dir = normalize(-position);
    <span class="hljs-keyword">let</span> half_vec = normalize(light_dir + view_dir);
    <span class="hljs-keyword">let</span> specular = pow(max(<span class="hljs-number">0.0</span>, dot(normal, half_vec)), <span class="hljs-number">32.0</span>);

    <span class="hljs-comment">// Combine</span>
    <span class="hljs-keyword">let</span> ambient = <span class="hljs-number">0.2</span>;
    <span class="hljs-keyword">let</span> light_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.9</span>);

    <span class="hljs-keyword">return</span> light_color * (ambient + diffuse * <span class="hljs-number">0.7</span> + specular * <span class="hljs-number">0.3</span>);
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Re-normalize after interpolation</span>
    <span class="hljs-keyword">let</span> world_normal = normalize(<span class="hljs-keyword">in</span>.world_normal);
    <span class="hljs-keyword">let</span> world_tangent = normalize(<span class="hljs-keyword">in</span>.world_tangent);
    <span class="hljs-keyword">let</span> world_bitangent = normalize(<span class="hljs-keyword">in</span>.world_bitangent);

    <span class="hljs-comment">// Determine which space to use</span>
    var normal_to_display: vec3&lt;<span class="hljs-built_in">f32</span>&gt;;
    var tangent_to_display: vec3&lt;<span class="hljs-built_in">f32</span>&gt;;
    var bitangent_to_display: vec3&lt;<span class="hljs-built_in">f32</span>&gt;;

    <span class="hljs-keyword">if</span> material.show_world_space == <span class="hljs-number">1</span>u {
        <span class="hljs-comment">// World space</span>
        normal_to_display = world_normal;
        tangent_to_display = world_tangent;
        bitangent_to_display = world_bitangent;
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// Local space</span>
        normal_to_display = normalize(<span class="hljs-keyword">in</span>.local_normal);
        tangent_to_display = normalize(<span class="hljs-keyword">in</span>.local_tangent);
        <span class="hljs-comment">// Calculate local bitangent</span>
        <span class="hljs-keyword">let</span> local_N = normalize(<span class="hljs-keyword">in</span>.local_normal);
        <span class="hljs-keyword">let</span> local_T = normalize(<span class="hljs-keyword">in</span>.local_tangent);
        bitangent_to_display = cross(local_N, local_T);
    }

    var color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;;

    <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">0</span>u {
        <span class="hljs-comment">// Mode 0: Display normals as color</span>
        color = direction_to_color(normal_to_display);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">1</span>u {
        <span class="hljs-comment">// Mode 1: Display tangents as color</span>
        color = direction_to_color(tangent_to_display);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">2</span>u {
        <span class="hljs-comment">// Mode 2: Display bitangents as color</span>
        color = direction_to_color(bitangent_to_display);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">3</span>u {
        <span class="hljs-comment">// Mode 3: Lighting test using normals</span>
        color = calculate_simple_lighting(world_normal, <span class="hljs-keyword">in</span>.world_position);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.display_mode == <span class="hljs-number">4</span>u {
        <span class="hljs-comment">// Mode 4: TBN combined visualization</span>
        <span class="hljs-comment">// Show each component in a different color channel</span>
        <span class="hljs-keyword">let</span> n_contribution = abs(dot(world_normal, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>)));
        <span class="hljs-keyword">let</span> t_contribution = abs(dot(world_tangent, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>)));
        <span class="hljs-keyword">let</span> b_contribution = abs(dot(world_bitangent, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>)));

        color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(
            t_contribution,  <span class="hljs-comment">// Red channel = tangent alignment</span>
            n_contribution,  <span class="hljs-comment">// Green channel = normal alignment</span>
            b_contribution   <span class="hljs-comment">// Blue channel = bitangent alignment</span>
        );
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// Fallback: magenta (error color)</span>
        color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
    }

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0206normalvisualizationrs">The Rust Material (<code>src/materials/d02_06_normal_visualization.rs</code>)</h3>
<p>This file defines the <code>NormalVisualizationMaterial</code> that connects our Rust logic to the shader. It defines a uniform struct to hold our settings (<code>display_mode</code>, <code>show_world_space</code>) and implements the <code>Material</code> trait. The <code>specialize</code> function is used here to ensure the render pipeline is configured to expect all the vertex attributes our shader needs (<code>POSITION</code>, <code>NORMAL</code>, <code>UV_0</code>, <code>TANGENT</code>), preventing runtime errors.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::pbr::{MaterialPipeline, MaterialPipelineKey};
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::mesh::MeshVertexBufferLayoutRef;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};
<span class="hljs-keyword">use</span> bevy::render::render_resource::{RenderPipelineDescriptor, SpecializedMeshPipelineError};

<span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>

    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone, Copy)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">NormalVisualizationMaterial</span></span> {
        <span class="hljs-keyword">pub</span> display_mode: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> show_world_space: <span class="hljs-built_in">u32</span>,
    }

    <span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> NormalVisualizationMaterial {
        <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
            <span class="hljs-keyword">Self</span> {
                display_mode: <span class="hljs-number">0</span>,     <span class="hljs-comment">// Normals</span>
                show_world_space: <span class="hljs-number">1</span>, <span class="hljs-comment">// World space</span>
            }
        }
    }
}

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">use</span> uniforms::NormalVisualizationMaterial <span class="hljs-keyword">as</span> NormalVisualizationUniforms;

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">NormalVisualizationMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: NormalVisualizationUniforms,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> NormalVisualizationMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_06_normal_visualization.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_06_normal_visualization.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">specialize</span></span>(
        _pipeline: &amp;MaterialPipeline&lt;<span class="hljs-keyword">Self</span>&gt;,
        descriptor: &amp;<span class="hljs-keyword">mut</span> RenderPipelineDescriptor,
        layout: &amp;MeshVertexBufferLayoutRef,
        _key: MaterialPipelineKey&lt;<span class="hljs-keyword">Self</span>&gt;,
    ) -&gt; <span class="hljs-built_in">Result</span>&lt;(), SpecializedMeshPipelineError&gt; {
        <span class="hljs-comment">// Ensure we have all required vertex attributes</span>
        <span class="hljs-keyword">let</span> vertex_layout = layout.<span class="hljs-number">0</span>.get_layout(&amp;[
            Mesh::ATTRIBUTE_POSITION.at_shader_location(<span class="hljs-number">0</span>),
            Mesh::ATTRIBUTE_NORMAL.at_shader_location(<span class="hljs-number">1</span>),
            Mesh::ATTRIBUTE_UV_0.at_shader_location(<span class="hljs-number">2</span>),
            Mesh::ATTRIBUTE_TANGENT.at_shader_location(<span class="hljs-number">3</span>),
        ])?;

        descriptor.vertex.buffers = <span class="hljs-built_in">vec!</span>[vertex_layout];
        <span class="hljs-literal">Ok</span>(())
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other materials</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> flag_simulation;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0206normalvisualizationrs">The Demo Module (<code>src/demos/d02_06_normal_visualization.rs</code>)</h3>
<p>The Bevy application logic sets up our scene and handles interactivity. The <code>setup</code> system creates three distinct meshes (a sphere, a cube, and a torus) to showcase how the visualization looks on different types of geometry. It is crucial that it calls <code>generate_tangents()</code> for each mesh before applying our custom material. The <code>handle_input</code> system listens for keyboard presses to change the material's uniform values, allowing us to interactively change the visualization mode and toggle the non-uniform scaling test.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d02_06_normal_visualization::{
    NormalVisualizationMaterial, NormalVisualizationUniforms,
};
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> std::<span class="hljs-built_in">f32</span>::consts::PI;

<span class="hljs-meta">#[derive(Component)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">RotatingObject</span></span> {
    speed: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-meta">#[derive(Component)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">ScalingObject</span></span> {
    base_scale: Vec3,
}

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;NormalVisualizationMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (handle_input, rotate_objects, scale_objects, update_ui),
        )
        .run();
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;NormalVisualizationMaterial&gt;&gt;,
) {
    <span class="hljs-comment">// Create material</span>
    <span class="hljs-keyword">let</span> material = materials.add(NormalVisualizationMaterial {
        uniforms: NormalVisualizationUniforms::default(),
    });

    <span class="hljs-comment">// Sphere - uniform geometry (good for seeing smooth normals)</span>
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> sphere_mesh = Sphere::new(<span class="hljs-number">1.0</span>).mesh().uv(<span class="hljs-number">32</span>, <span class="hljs-number">16</span>);
    sphere_mesh
        .generate_tangents()
        .expect(<span class="hljs-string">"Failed to generate tangents"</span>);

    commands.spawn((
        Mesh3d(meshes.add(sphere_mesh)),
        MeshMaterial3d(material.clone()),
        Transform::from_xyz(-<span class="hljs-number">3.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        RotatingObject { speed: <span class="hljs-number">0.5</span> },
    ));

    <span class="hljs-comment">// Cube - flat faces (good for seeing face normals)</span>
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> cube_mesh = Cuboid::new(<span class="hljs-number">1.5</span>, <span class="hljs-number">1.5</span>, <span class="hljs-number">1.5</span>).mesh().build();
    cube_mesh
        .generate_tangents()
        .expect(<span class="hljs-string">"Failed to generate tangents"</span>);

    commands.spawn((
        Mesh3d(meshes.add(cube_mesh)),
        MeshMaterial3d(material.clone()),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        RotatingObject { speed: <span class="hljs-number">0.3</span> },
    ));

    <span class="hljs-comment">// Torus - complex geometry (good for seeing tangent space)</span>
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> torus_mesh = Torus::new(<span class="hljs-number">0.8</span>, <span class="hljs-number">0.3</span>).mesh().build();
    torus_mesh
        .generate_tangents()
        .expect(<span class="hljs-string">"Failed to generate tangents"</span>);

    commands.spawn((
        Mesh3d(meshes.add(torus_mesh)),
        MeshMaterial3d(material.clone()),
        Transform::from_xyz(<span class="hljs-number">3.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        RotatingObject { speed: <span class="hljs-number">0.7</span> },
        ScalingObject {
            base_scale: Vec3::ONE,
        },
    ));

    <span class="hljs-comment">// Lighting (for lighting test mode)</span>
    commands.spawn((
        DirectionalLight {
            illuminance: <span class="hljs-number">10000.0</span>,
            shadows_enabled: <span class="hljs-literal">false</span>,
            ..default()
        },
        Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -PI / <span class="hljs-number">4.0</span>, PI / <span class="hljs-number">4.0</span>, <span class="hljs-number">0.0</span>)),
    ));

    <span class="hljs-comment">// Camera</span>
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">3.0</span>, <span class="hljs-number">8.0</span>).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    <span class="hljs-comment">// UI</span>
    commands.spawn((
        Text::new(
            <span class="hljs-string">"[1-5] Display Mode | [Space] Toggle Space | [S] Toggle Scaling\n\
             \n\
             Mode: Normals | Space: World | Scaling: Off\n\
             \n\
             Color Mapping:\n\
             - Red: X+ / Right\n\
             - Green: Y+ / Up\n\
             - Blue: Z+ / Forward"</span>,
        ),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">10.0</span>),
            left: Val::Px(<span class="hljs-number">10.0</span>),
            ..default()
        },
        TextFont {
            font_size: <span class="hljs-number">16.0</span>,
            ..default()
        },
        TextColor(Color::WHITE),
        BackgroundColor(Color::srgba(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.7</span>)),
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;NormalVisualizationMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> scaling_query: Query&lt;(&amp;<span class="hljs-keyword">mut</span> Transform, &amp;ScalingObject)&gt;,
) {
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-comment">// Display mode</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit1) {
            material.uniforms.display_mode = <span class="hljs-number">0</span>; <span class="hljs-comment">// Normals</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit2) {
            material.uniforms.display_mode = <span class="hljs-number">1</span>; <span class="hljs-comment">// Tangents</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit3) {
            material.uniforms.display_mode = <span class="hljs-number">2</span>; <span class="hljs-comment">// Bitangents</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit4) {
            material.uniforms.display_mode = <span class="hljs-number">3</span>; <span class="hljs-comment">// Lighting test</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit5) {
            material.uniforms.display_mode = <span class="hljs-number">4</span>; <span class="hljs-comment">// TBN combined</span>
        }

        <span class="hljs-comment">// Toggle world/local space</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Space) {
            material.uniforms.show_world_space = <span class="hljs-number">1</span> - material.uniforms.show_world_space;
        }
    }

    <span class="hljs-comment">// Toggle scaling (demonstrates normal matrix importance)</span>
    <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyS) {
        <span class="hljs-keyword">for</span> (<span class="hljs-keyword">mut</span> transform, scaling_obj) <span class="hljs-keyword">in</span> scaling_query.iter_mut() {
            <span class="hljs-keyword">if</span> transform.scale == scaling_obj.base_scale {
                <span class="hljs-comment">// Apply non-uniform scale</span>
                transform.scale = Vec3::new(<span class="hljs-number">2.0</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">1.0</span>);
            } <span class="hljs-keyword">else</span> {
                <span class="hljs-comment">// Reset to base scale</span>
                transform.scale = scaling_obj.base_scale;
            }
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rotate_objects</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> query: Query&lt;(&amp;<span class="hljs-keyword">mut</span> Transform, &amp;RotatingObject)&gt;) {
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">mut</span> transform, rotating) <span class="hljs-keyword">in</span> query.iter_mut() {
        transform.rotate_y(time.delta_secs() * rotating.speed);
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">scale_objects</span></span>(
    time: Res&lt;Time&gt;,
    <span class="hljs-keyword">mut</span> query: Query&lt;(&amp;<span class="hljs-keyword">mut</span> Transform, &amp;ScalingObject), Without&lt;RotatingObject&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> time_val = time.elapsed_secs();

    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">mut</span> transform, _) <span class="hljs-keyword">in</span> query.iter_mut() {
        <span class="hljs-comment">// Only scale if currently scaled (preserve user's S key toggle)</span>
        <span class="hljs-keyword">if</span> transform.scale != Vec3::ONE {
            <span class="hljs-comment">// Animate the scaling</span>
            <span class="hljs-keyword">let</span> scale_x = <span class="hljs-number">2.0</span> + (time_val * <span class="hljs-number">0.5</span>).sin() * <span class="hljs-number">0.5</span>;
            <span class="hljs-keyword">let</span> scale_y = <span class="hljs-number">0.5</span> + (time_val * <span class="hljs-number">0.7</span>).cos() * <span class="hljs-number">0.3</span>;
            transform.scale = Vec3::new(scale_x, scale_y, <span class="hljs-number">1.0</span>);
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(
    materials: Res&lt;Assets&lt;NormalVisualizationMaterial&gt;&gt;,
    scaling_query: Query&lt;&amp;Transform, With&lt;ScalingObject&gt;&gt;,
    <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;,
) {
    <span class="hljs-keyword">if</span> !materials.is_changed() {
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, material)) = materials.iter().next() {
        <span class="hljs-keyword">let</span> mode_name = <span class="hljs-keyword">match</span> material.uniforms.display_mode {
            <span class="hljs-number">0</span> =&gt; <span class="hljs-string">"Normals"</span>,
            <span class="hljs-number">1</span> =&gt; <span class="hljs-string">"Tangents"</span>,
            <span class="hljs-number">2</span> =&gt; <span class="hljs-string">"Bitangents"</span>,
            <span class="hljs-number">3</span> =&gt; <span class="hljs-string">"Lighting Test"</span>,
            <span class="hljs-number">4</span> =&gt; <span class="hljs-string">"TBN Combined"</span>,
            _ =&gt; <span class="hljs-string">"Unknown"</span>,
        };

        <span class="hljs-keyword">let</span> space_name = <span class="hljs-keyword">if</span> material.uniforms.show_world_space == <span class="hljs-number">1</span> {
            <span class="hljs-string">"World"</span>
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-string">"Local"</span>
        };

        <span class="hljs-keyword">let</span> scaling_status = <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Ok</span>(transform) = scaling_query.single() {
            <span class="hljs-keyword">if</span> transform.scale != Vec3::ONE {
                <span class="hljs-string">"On (Non-Uniform)"</span>
            } <span class="hljs-keyword">else</span> {
                <span class="hljs-string">"Off"</span>
            }
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-string">"N/A"</span>
        };

        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
            **text = <span class="hljs-built_in">format!</span>(
                <span class="hljs-string">"[1-5] Display Mode | [Space] Toggle Space | [S] Toggle Scaling\n\
                 \n\
                 Mode: {} | Space: {} | Scaling: {}\n\
                 \n\
                 Color Mapping:\n\
                 - Red: X+ / Right\n\
                 - Green: Y+ / Up\n\
                 - Blue: Z+ / Forward"</span>,
                mode_name, space_name, scaling_status
            );
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other demos</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> flag_simulation;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"2.6"</span>,
    title: <span class="hljs-string">"Normal Vector Transformation"</span>,
    run: demos::d02_06_normal_visualization::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>When you run the demo, you'll see three rotating objects, each colored according to the default visualization setting (World-space Normals). Use the keyboard to explore the different modes and observe the results.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key</td><td>Action</td></tr>
</thead>
<tbody>
<tr>
<td><strong>1</strong></td><td>Display <strong>Normals</strong> as color.</td></tr>
<tr>
<td><strong>2</strong></td><td>Display <strong>Tangents</strong> as color.</td></tr>
<tr>
<td><strong>3</strong></td><td>Display <strong>Bitangents</strong> as color.</td></tr>
<tr>
<td><strong>4</strong></td><td>Display a <strong>Lighting Test</strong> using world normals.</td></tr>
<tr>
<td><strong>5</strong></td><td>Display a <strong>Combined TBN</strong> visualization.</td></tr>
<tr>
<td><strong>Space</strong></td><td>Toggle between <strong>World Space</strong> and <strong>Local Space</strong>.</td></tr>
<tr>
<td><strong>S</strong></td><td>Toggle <strong>Non-Uniform Scaling</strong> on the torus.</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762974386268/2b5e5b8d-8fc9-4fd5-aaf5-4339c5745ea5.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762974402562/9f600c84-2135-4edc-a975-61c41f093c4e.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762974412350/b9a16bdd-b0bf-47fb-b76c-2758fec803fd.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762974432522/65515f6a-7da9-4d7d-a142-3be82261268e.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762974444270/6e4b654a-acd5-4f4f-a363-941ac7f50d90.png" alt class="image--center mx-auto" /></p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Mode</td><td>What to Look For</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Normals (World)</strong></td><td>Colors change as objects rotate. Red faces right (X+), Green faces up (Y+), Blue faces toward you (Z+). The sphere has smooth gradients. The cube has solid-colored faces.</td></tr>
<tr>
<td><strong>Normals (Local)</strong></td><td>Colors are "painted on" and do not change as objects rotate. This shows the normals relative to the object itself.</td></tr>
<tr>
<td><strong>Tangents</strong></td><td>Shows the direction of the U-axis of the UV map. Useful for debugging texture mapping issues.</td></tr>
<tr>
<td><strong>Lighting Test</strong></td><td>A simple check to see if the world-space normals are behaving correctly for a basic lighting model. The highlight should move smoothly across the surface.</td></tr>
<tr>
<td><strong>Scaling Test</strong></td><td>Press 'S' to squash the torus. In Lighting mode, notice the highlight remains correct and plausible. <strong>This is the normal matrix in action.</strong> If we had used the model matrix, the lighting would appear stretched and incorrect.</td></tr>
</tbody>
</table>
</div><h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>This has been a dense but critical topic. Internalizing these concepts will prevent the vast majority of lighting and transformation errors in your shaders.</p>
<ol>
<li><p><strong>Normals are Not Positions:</strong> Normals represent <strong>orientation</strong> and must be treated differently from position vectors, which represent a location in space.</p>
</li>
<li><p><strong>The Normal Matrix is the Solution:</strong> For any transformation involving non-uniform scaling or shearing, you must use the <strong>Normal Matrix</strong> (transpose(inverse(<code>model_matrix_3x3</code>))) to transform normals correctly.</p>
</li>
<li><p><strong>Use Bevy's Helpers:</strong> The most efficient and reliable way to do this in Bevy is with <code>mesh_functions::mesh_normal_local_to_world()</code>. This function uses a normal matrix that Bevy pre-computes on the CPU for you.</p>
</li>
<li><p><strong>Normalize After Transformation:</strong> The normal matrix corrects a normal's direction but not necessarily its length. You <strong>must</strong> normalize the result in the vertex shader immediately after transforming it.</p>
</li>
<li><p><strong>Re-Normalize After Interpolation:</strong> Linear interpolation between vertices does not preserve a vector's length. You <strong>must</strong> re-normalize the normal vector at the very beginning of your fragment shader before using it in any calculations.</p>
</li>
<li><p><strong>Tangents Transform Like Positions:</strong> Tangent vectors are directions along the surface and are transformed correctly using the standard model matrix, not the normal matrix.</p>
</li>
<li><p><strong>The TBN Frame is a Local Coordinate System:</strong> The <strong>Tangent</strong>, <strong>Bitangent</strong>, and <strong>Normal</strong> vectors form a complete coordinate system on the surface of your mesh, which is essential for advanced effects like normal mapping.</p>
</li>
<li><p><strong>Calculate the Bitangent Carefully:</strong> The bitangent is calculated with <code>cross(world_normal, world_tangent) * tangent.w</code>. Forgetting the <code>tangent.w</code> (handedness) will cause incorrect results on meshes with mirrored UVs.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>You have now mastered one of the trickiest and most fundamental aspects of vertex shading. Understanding how to correctly handle different vertex attributes like positions, normals, and tangents provides a solid foundation for more advanced techniques.</p>
<p>In the next article, we will continue exploring the power of the vertex shader by diving into <strong>Instanced Rendering</strong> - an incredibly powerful technique for rendering thousands of similar objects with high performance. We will see how to leverage the <code>@builtin(instance_index)</code> to create massive, varied scenes while keeping draw calls to a minimum.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/27-instanced-rendering"><strong><em>2.7 - Instanced Rendering</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<h3 id="heading-the-golden-rule">The Golden Rule</h3>
<p>Normals are not positions. They represent <strong>orientation</strong> and must be transformed differently.</p>
<h3 id="heading-normal-transformation">Normal Transformation</h3>
<ul>
<li><p><strong>Problem:</strong> Non-uniform scaling breaks lighting if you use the standard model matrix on normals.</p>
</li>
<li><p><strong>Solution:</strong> Use the <strong>Normal Matrix</strong>, which is specifically designed to preserve the correct surface orientation.</p>
</li>
<li><p><strong>Formula:</strong> <code>Normal Matrix = transpose(inverse(model_3x3))</code></p>
</li>
<li><p><strong>Bevy Practice:</strong> Use the built-in <code>mesh_functions::mesh_normal_local_to_world()</code> function, as it uses a pre-calculated Normal Matrix for you.</p>
</li>
</ul>
<h3 id="heading-the-two-rules-of-normalization">The Two Rules of Normalization</h3>
<ol>
<li><p>Always <code>normalize()</code> in the <strong>vertex shader</strong> immediately after transformation.</p>
</li>
<li><p>Always <code>normalize()</code> again in the <strong>fragment shader</strong> to correct for interpolation errors.</p>
</li>
</ol>
<h3 id="heading-tangent-space-the-tbn-frame">Tangent Space (The TBN Frame)</h3>
<p>A local coordinate system on a mesh's surface, essential for normal mapping.</p>
<ul>
<li><p><strong>N (Normal):</strong> Points "out" from the surface.</p>
</li>
<li><p><strong>T (Tangent):</strong> Runs "along" the surface, typically following the texture's U direction.</p>
</li>
<li><p><strong>B (Bitangent):</strong> Runs "across" the surface, perpendicular to both N and T.</p>
</li>
</ul>
<h3 id="heading-how-to-transform-the-tbn-frame">How to Transform the TBN Frame</h3>
<ul>
<li><p><strong>Normal (N):</strong> Transforms using the <strong>Normal Matrix</strong>.</p>
</li>
<li><p><strong>Tangent (T):</strong> Transforms using the standard <strong>Model Matrix</strong>.</p>
</li>
<li><p><strong>Bitangent (B):</strong> Is not transformed directly, but is calculated in world space: <code>cross(world_normal, world_tangent) * handedness</code>.</p>
</li>
</ul>
<h3 id="heading-the-best-debugging-technique">The Best Debugging Technique</h3>
<p>When in doubt, visualize your vectors. Output them directly as colors from the fragment shader to see if they are behaving as you expect.</p>
<ul>
<li><strong>Mapping:</strong> <code>output_color = vector_direction * 0.5 + 0.5</code> maps a <code>[-1, 1]</code> vector to a <code>[0, 1]</code> color.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[2.5 - Advanced Vertex Displacement]]></title><description><![CDATA[What We're Learning
In the previous article, we learned the fundamentals of vertex deformation. We saw how a simple sine wave can bring a mesh to life, creating predictable pulses and oscillations. While powerful, these basic techniques are like a me...]]></description><link>https://blog.hexbee.net/25-advanced-vertex-displacement</link><guid isPermaLink="true">https://blog.hexbee.net/25-advanced-vertex-displacement</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Sat, 22 Nov 2025 10:26:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1762951442504/19094567-0fdb-4ab9-b036-e5a67e64235e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>In the previous article, we learned the fundamentals of vertex deformation. We saw how a simple sine wave can bring a mesh to life, creating predictable pulses and oscillations. While powerful, these basic techniques are like a metronome: they produce a steady, simple, and ultimately artificial rhythm.</p>
<p>This article is about moving from the metronome to the symphony.</p>
<p>Advanced vertex displacement is the art of layering multiple simple motions to create one complex, organic, and believable result. Instead of a single, predictable wave, we will orchestrate an entire ensemble of effects: broad, rolling waves for the main movement, high-frequency noise for subtle texture, and artist-driven maps for deliberate control. This layering is the key to transforming flat, rigid geometry into surfaces that feel dynamic and alive, as if responding to invisible forces like wind, water, or energy.</p>
<p>By the end of this article, you will understand how to:</p>
<ul>
<li><p><strong>Layer Multi-Axis Waves:</strong> Combine waves traveling in different directions to create complex and natural-looking interference patterns.</p>
</li>
<li><p><strong>Compose with Multiple Frequencies:</strong> Use a technique called "octaves" to add detail at different scales, from large, rolling movements to fine, subtle ripples.</p>
</li>
<li><p><strong>Leverage Noise Functions:</strong> Use texture-based noise to break the repetition of periodic waves, introducing organic, non-repeating displacement.</p>
</li>
<li><p><strong>Create Advanced Deformations:</strong> Implement sophisticated twist and spiral effects with smooth falloffs for more controlled and artistic results.</p>
</li>
<li><p><strong>Use Displacement Maps:</strong> Allow artists to directly control deformation by painting displacement details in a texture.</p>
</li>
<li><p><strong>Preserve Mesh Integrity:</strong> Implement techniques to prevent visual glitches like gaps or self-intersections during extreme deformation.</p>
</li>
<li><p><strong>Optimize Complex Vertex Shaders:</strong> Learn crucial performance strategies to ensure your complex effects run smoothly.</p>
</li>
<li><p><strong>Build a Complete Flag Simulation:</strong> Apply all these concepts to create a physically-inspired flag that waves, billows, and flutters in a simulated wind.</p>
</li>
</ul>
<h2 id="heading-building-on-our-foundation">Building on Our Foundation</h2>
<p>Before we orchestrate our symphony, let's quickly refresh our memory of the fundamental instruments we learned to play in the last article. For a detailed breakdown of these techniques, please review <a target="_blank" href="/https://hexbee.hashnode.dev/24-simple-vertex-deformations"><strong>2.4 - Simple Vertex Deformations</strong></a>.</p>
<p>Our core strategy was to start with a vertex's original position and add a calculated offset. We explored three primary tools for this:</p>
<ol>
<li><p>We used <strong>sine waves</strong> to create rhythmic, oscillating motion - the foundation for any waving or rippling effect. However, a single sine wave is too perfect; its endless repetition and uniformity can look artificial.</p>
</li>
<li><p>We implemented <strong>uniform scaling</strong> to make objects breathe or pulse. But this motion is monolithic, affecting the entire object at once, unlike organic growth which tends to bulge and stretch locally.</p>
</li>
<li><p>And we used <strong>basic twists</strong> to rotate geometry around an axis. But this twist was linear and constant, lacking the smooth falloffs and easing found in natural motion.</p>
</li>
</ol>
<p>Each of these tools is powerful but suffers from the same core problem: they are <strong>uniform, predictable, and isolated</strong>. The key to advanced displacement is to shatter that uniformity. In this article, we will learn to layer these effects, vary them across the surface of a mesh, and introduce controlled randomness to transform these simple mechanical movements into beautifully complex, organic motion.</p>
<h2 id="heading-multi-axis-wave-effects">Multi-Axis Wave Effects</h2>
<p>Real-world motion is rarely tidy. Wind doesn't just push a flag up and down; it creates complex, flowing patterns across its surface. A pebble dropped in a pond doesn't create a single wave but a series of expanding ripples that interact with each other. To simulate this beautiful complexity, we must stop thinking in single dimensions and start orchestrating waves that travel in multiple directions at once.</p>
<h3 id="heading-the-magic-of-wave-interference">The Magic of Wave Interference</h3>
<p>The secret to creating complexity from simplicity lies in a principle called <strong>wave interference</strong>. When two or more waves meet, they combine by simple addition.</p>
<ul>
<li><p>If their peaks align, they amplify each other, creating a larger peak (<strong>constructive interference</strong>).</p>
</li>
<li><p>If a peak meets a trough, they cancel each other out, creating a flatter surface (<strong>destructive interference</strong>).</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764414447707/25b941b2-e3b9-44a5-b77f-c654ccc16640.png" alt class="image--center mx-auto" /></p>
<p>This simple act of adding waves together is the source of the rich, organic, and non-uniform patterns we see in nature. By simulating this in our shader, we can transform a repetitive grid of sine waves into something that feels far more natural.</p>
<h3 id="heading-implementing-multi-directional-waves">Implementing Multi-Directional Waves</h3>
<p>Let's create this effect in code. We'll write a function that calculates three separate waves traveling in different directions and simply adds their results together.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_multi_wave_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    var displacement = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>);

    <span class="hljs-comment">// Wave 1: A primary wave moving along the X-axis.</span>
    <span class="hljs-comment">// This is our base movement.</span>
    <span class="hljs-keyword">let</span> wave1 = sin(position.x * <span class="hljs-number">3.0</span> - time * <span class="hljs-number">2.0</span>) * <span class="hljs-number">0.15</span>;
    displacement.y += wave1;

    <span class="hljs-comment">// Wave 2: A cross-wave moving along the Z-axis.</span>
    <span class="hljs-comment">// It has a different frequency and speed, creating interference.</span>
    <span class="hljs-keyword">let</span> wave2 = sin(position.z * <span class="hljs-number">2.5</span> - time * <span class="hljs-number">1.5</span>) * <span class="hljs-number">0.12</span>;
    displacement.y += wave2;

    <span class="hljs-comment">// Wave 3: A diagonal wave.</span>
    <span class="hljs-comment">// This breaks up the grid-like pattern from the first two waves,</span>
    <span class="hljs-comment">// adding a more chaotic, natural ripple.</span>
    <span class="hljs-keyword">let</span> diagonal_input = position.x * <span class="hljs-number">0.707</span> + position.z * <span class="hljs-number">0.707</span>;
    <span class="hljs-keyword">let</span> wave3 = sin(diagonal_input * <span class="hljs-number">4.0</span> - time * <span class="hljs-number">2.5</span>) * <span class="hljs-number">0.08</span>;
    displacement.y += wave3;

    <span class="hljs-keyword">return</span> displacement;
}
</code></pre>
<p><strong>Key Insight:</strong> The magic is in the variation. Each wave has a slightly different frequency, speed, and amplitude. This ensures they are constantly moving in and out of phase with each other, preventing the final pattern from ever looking too regular or predictable.</p>
<h3 id="heading-propagating-waves-from-a-center-point">Propagating Waves from a Center Point</h3>
<p>Sometimes we want waves to radiate outwards from a specific point, like the ripples from a raindrop hitting a puddle. To do this, we base our sine wave calculation not on the x or z coordinate, but on the vertex's <strong>distance from a center point</strong>.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_radial_waves</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    center: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// We only care about the distance in the horizontal plane (XZ)</span>
    <span class="hljs-keyword">let</span> distance = length(position.xz - center.xz);

    <span class="hljs-comment">// Combine multiple wave frequencies based on distance</span>
    <span class="hljs-keyword">let</span> wave1 = sin(distance * <span class="hljs-number">5.0</span> - time * <span class="hljs-number">3.0</span>) * <span class="hljs-number">0.1</span>;
    <span class="hljs-keyword">let</span> wave2 = sin(distance * <span class="hljs-number">3.0</span> - time * <span class="hljs-number">2.0</span>) * <span class="hljs-number">0.15</span>;
    <span class="hljs-keyword">let</span> wave3 = sin(distance * <span class="hljs-number">8.0</span> - time * <span class="hljs-number">4.0</span>) * <span class="hljs-number">0.05</span>;

    <span class="hljs-keyword">let</span> total_wave = wave1 + wave2 + wave3;

    <span class="hljs-comment">// Displace vertically (along the Y-axis)</span>
    <span class="hljs-keyword">return</span> vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, total_wave, <span class="hljs-number">0.0</span>);
}
</code></pre>
<p>This creates a beautiful ripple effect that spreads outward, with the multiple layered waves giving it a complex and natural-looking surface.</p>
<h3 id="heading-attenuated-waves-with-falloff">Attenuated Waves with Falloff</h3>
<p>In the real world, waves lose energy as they travel. A ripple is strongest at its center and fades to nothing over distance. We can simulate this with a <strong>falloff</strong> factor that multiplies our wave's amplitude.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_attenuated_wave</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    center: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
    max_distance: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> distance = length(position.xz - center.xz);
    <span class="hljs-keyword">let</span> wave = sin(distance * <span class="hljs-number">5.0</span> - time * <span class="hljs-number">3.0</span>) * <span class="hljs-number">0.2</span>;

    <span class="hljs-comment">// Calculate a falloff factor that goes from 1.0 at the center</span>
    <span class="hljs-comment">// to 0.0 at the max_distance.</span>
    <span class="hljs-keyword">let</span> falloff = <span class="hljs-number">1.0</span> - smoothstep(<span class="hljs-number">0.0</span>, max_distance, distance);

    <span class="hljs-keyword">return</span> vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, wave * falloff, <span class="hljs-number">0.0</span>);
}
</code></pre>
<p>Choosing the right falloff function is an artistic decision that defines the character of your effect:</p>
<ul>
<li><p><code>smoothstep(max_distance, 0.0, distance)</code>: The most common choice. Creates a gentle, elegant fade-out with a smooth start and end.</p>
</li>
<li><p><strong>Linear (1.0 -</strong> <code>clamp(distance / max_distance, 0.0, 1.0))</code>: A simple, abrupt fade. Can sometimes look unnatural.</p>
</li>
<li><p><strong>Exponential (</strong><code>exp(-distance * falloff_rate)</code>): A very natural, gradual fade that never quite reaches zero.</p>
</li>
<li><p><strong>Inverse Square (</strong><code>1.0 / (1.0 + distance * distance * falloff_rate)</code>): Mimics how physical forces like light and gravity diminish. The falloff is very rapid at first and then slows down.</p>
</li>
</ul>
<h3 id="heading-displacing-along-the-surface-normal">Displacing Along the Surface Normal</h3>
<p>So far, we have only displaced vertices along the world's Y-axis. This works for flat surfaces like planes, but what about a sphere? Displacing a sphere's vertices along Y would just stretch it vertically into an oblong shape.</p>
<p>To create a more natural deformation on any shape, we should displace each vertex along its own <strong>surface normal</strong> - the vector that points directly "out" from the surface at that vertex.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// This function takes the mesh's normal as an input</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_normal_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// The per-vertex normal vector</span>
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Calculate a wave value based on the vertex's world position</span>
    <span class="hljs-keyword">let</span> wave_input = position.x * <span class="hljs-number">3.0</span> + position.z * <span class="hljs-number">2.0</span>;
    <span class="hljs-keyword">let</span> wave_amount = sin(wave_input - time * <span class="hljs-number">2.0</span>) * <span class="hljs-number">0.2</span>;

    <span class="hljs-comment">// Displace along the normal's direction instead of just "up"</span>
    <span class="hljs-keyword">return</span> normal * wave_amount;
}
</code></pre>
<p>This is a crucial technique. It ensures that the displacement respects the underlying curvature of the mesh, creating a much more believable effect. For a sphere, displacing along the normal doesn't just stretch it - it makes it inflate and deflate, creating a natural "breathing" or pulsing effect that works on any 3D model, not just flat planes.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764414758735/b881fed4-8b2c-47dd-9d17-becf33ee934f.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-combining-multiple-sine-waves-for-complexity">Combining Multiple Sine Waves for Complexity</h2>
<p>The secret to creating natural-looking motion is <strong>layering</strong>. A single sine wave, no matter how you tweak it, will always look artificial because it has only one frequency. Real-world surfaces are never this simple. Think of the surface of an ocean: there are large, slow swells (low frequency), smaller, faster waves on top of them (medium frequency), and tiny, rapid ripples on top of those (high frequency).</p>
<p>By combining waves of different frequencies and amplitudes, we can mimic this multi-layered detail and create motion that feels rich and organic.</p>
<h3 id="heading-the-concept-of-octaves">The Concept of Octaves</h3>
<p>In music, an octave is the interval between one note and another with double its frequency. In computer graphics, we borrow this term to describe layers of waves, where each successive layer (or "octave") typically has a higher frequency and a lower amplitude than the one before it.</p>
<p>This technique is often called <a target="_blank" href="https://thebookofshaders.com/13/"><strong>Fractal Brownian Motion</strong></a> <strong>(fBm)</strong> when applied to noise, but the principle is identical for sine waves. We build the final shape by adding layers of detail.</p>
<pre><code class="lang-plaintext">Octave 1 (Low Frequency, High Amplitude):
  Defines the large, primary shapes of the motion.
  Like the slow, rolling swells in the deep ocean.

     ~~~~~~~~~~~~~~~~~~~~~

Octave 2 (Medium Frequency, Medium Amplitude):
  Adds secondary motion on top of the primary shapes.
  Like the smaller waves riding on the swells.

     ~~  ~~  ~~  ~~  ~~  ~~

Octave 3 (High Frequency, Low Amplitude):
  Adds fine, textural detail.
  Like the tiny ripples on the surface of the waves.

     ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~

Combined Result:
  A complex, natural-looking surface with detail at multiple scales.

     ~~⁓~⁓~~⁓~~⁓~⁓~⁓~~⁓~
</code></pre>
<h3 id="heading-implementing-layered-waves">Implementing Layered Waves</h3>
<p>The implementation is surprisingly simple: we just loop several times, and in each iteration, we add a wave, increase our frequency, and decrease our amplitude.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">layered_wave_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; <span class="hljs-built_in">f32</span> {
    var total_displacement = <span class="hljs-number">0.0</span>;
    var amplitude = <span class="hljs-number">0.3</span>; <span class="hljs-comment">// Start with the largest amplitude</span>
    var frequency = <span class="hljs-number">2.0</span>; <span class="hljs-comment">// Start with the lowest frequency</span>

    <span class="hljs-comment">// Octave 1: The main, large-scale wave.</span>
    total_displacement += sin(position.x * frequency - time * <span class="hljs-number">1.5</span>) * amplitude;

    <span class="hljs-comment">// Octave 2: Smaller, faster wave.</span>
    amplitude *= <span class="hljs-number">0.5</span>; <span class="hljs-comment">// Reduce amplitude.</span>
    frequency *= <span class="hljs-number">2.0</span>; <span class="hljs-comment">// Increase frequency.</span>
    total_displacement += sin(position.x * frequency - time * <span class="hljs-number">2.0</span>) * amplitude;

    <span class="hljs-comment">// Octave 3: Even smaller, faster ripples.</span>
    amplitude *= <span class="hljs-number">0.5</span>;
    frequency *= <span class="hljs-number">2.0</span>;
    total_displacement += sin(position.x * frequency - time * <span class="hljs-number">3.0</span>) * amplitude;

    <span class="hljs-comment">// Octave 4: Fine, textural detail.</span>
    amplitude *= <span class="hljs-number">0.5</span>;
    frequency *= <span class="hljs-number">2.0</span>;
    total_displacement += sin(position.x * frequency - time * <span class="hljs-number">4.0</span>) * amplitude;

    <span class="hljs-keyword">return</span> total_displacement;
}
</code></pre>
<p>This pattern is the core of procedural generation. In each step:</p>
<ul>
<li><p>We <strong>increase the frequency</strong>. This adds detail at a smaller scale.</p>
</li>
<li><p>We <strong>decrease the amplitude</strong>. This ensures that the smaller details don't overpower the larger, foundational shapes.</p>
</li>
</ul>
<h3 id="heading-a-flexible-parameterized-system">A Flexible, Parameterized System</h3>
<p>Hard-coding the octaves works, but a more powerful approach is to create a flexible function where we can control the layering process with parameters.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">multi_octave_wave</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
    octaves: <span class="hljs-built_in">u32</span>,
    persistence: <span class="hljs-built_in">f32</span>, <span class="hljs-comment">// How much amplitude is retained each octave.</span>
    lacunarity: <span class="hljs-built_in">f32</span>,  <span class="hljs-comment">// How much frequency increases each octave.</span>
) -&gt; <span class="hljs-built_in">f32</span> {
    var total_displacement = <span class="hljs-number">0.0</span>;
    var amplitude = <span class="hljs-number">1.0</span>;
    var frequency = <span class="hljs-number">1.0</span>;

    <span class="hljs-keyword">for</span> (var i = <span class="hljs-number">0</span>u; i &lt; octaves; i = i + <span class="hljs-number">1</span>u) {
        <span class="hljs-comment">// Calculate the wave for the current octave</span>
        <span class="hljs-keyword">let</span> wave = sin(position.x * frequency - time * (<span class="hljs-number">1.0</span> + <span class="hljs-built_in">f32</span>(i) * <span class="hljs-number">0.5</span>));
        total_displacement += wave * amplitude;

        <span class="hljs-comment">// Prepare for the next octave</span>
        amplitude *= persistence;
        frequency *= lacunarity;
    }

    <span class="hljs-keyword">return</span> total_displacement;
}
</code></pre>
<p>This function gives us artistic control over the character of our motion.</p>
<p><strong>Parameter Guide:</strong></p>
<ul>
<li><p><strong>octaves</strong>: Controls the level of detail. <code>3</code> or <code>4</code> is usually enough. More octaves add finer detail but cost more performance.</p>
</li>
<li><p><strong>persistence</strong>: Controls the "roughness." It's the factor by which amplitude decreases for each octave.</p>
<ul>
<li><p>A typical value is <code>0.5</code>. Each octave is half as influential as the last.</p>
</li>
<li><p>A lower value (<code>0.3</code>) creates a smoother result, dominated by large-scale waves.</p>
</li>
<li><p>A higher value (<code>0.7</code>) creates a "noisier," more detailed result where the small ripples are more prominent.</p>
</li>
</ul>
</li>
<li><p><strong>lacunarity</strong>: Controls the "gap" between frequencies. It's the factor by which frequency increases for each octave.</p>
<ul>
<li><p>A typical value is <code>2.0</code>. Each octave has double the frequency of the last.</p>
</li>
<li><p>A lower value (<code>1.5</code>) creates a softer, smoother blend between layers.</p>
</li>
<li><p>A higher value (<code>3.0</code>) creates a more chaotic, high-frequency result.</p>
</li>
</ul>
</li>
</ul>
<h3 id="heading-varying-the-direction-of-each-octave">Varying the Direction of Each Octave</h3>
<p>For the ultimate level of complexity, we can make each octave's wave travel in a different direction. This breaks up the grid-like alignment and creates a swirling, turbulent effect that is far more natural than waves moving in a single direction.</p>
<p>We achieve this using the <strong>dot product</strong>. The dot product of two vectors gives a single number that represents how much one vector points in the direction of the other. By projecting our vertex <code>position</code> onto a <code>direction</code> vector, we get a value that tells us "how far along" that direction the vertex is. We can then use this value as the input to our <code>sin</code> function, effectively making the wave travel along our custom direction.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">directional_multi_wave</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    var displacement = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>);

    <span class="hljs-comment">// Octave 1: Main wave, traveling along a diagonal.</span>
    <span class="hljs-keyword">let</span> dir1 = normalize(vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> wave_input_1 = dot(position.xz, dir1);
    displacement.y += sin(wave_input_1 * <span class="hljs-number">2.0</span> - time * <span class="hljs-number">1.5</span>) * <span class="hljs-number">0.25</span>;

    <span class="hljs-comment">// Octave 2: A faster wave, traveling along a different diagonal.</span>
    <span class="hljs-keyword">let</span> dir2 = normalize(vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, -<span class="hljs-number">0.5</span>));
    <span class="hljs-keyword">let</span> wave_input_2 = dot(position.xz, dir2);
    displacement.y += sin(wave_input_2 * <span class="hljs-number">4.0</span> - time * <span class="hljs-number">2.0</span>) * <span class="hljs-number">0.12</span>;

    <span class="hljs-comment">// Octave 3: A high-frequency ripple, traveling along yet another direction.</span>
    <span class="hljs-keyword">let</span> dir3 = normalize(vec2&lt;<span class="hljs-built_in">f32</span>&gt;(-<span class="hljs-number">0.5</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> wave_input_3 = dot(position.xz, dir3);
    displacement.y += sin(wave_input_3 * <span class="hljs-number">8.0</span> - time * <span class="hljs-number">3.0</span>) * <span class="hljs-number">0.06</span>;

    <span class="hljs-keyword">return</span> displacement;
}
</code></pre>
<p>This technique is incredibly powerful. By simply adding together a few directional sine waves, we have created a complex interference pattern that looks chaotic, turbulent, and convincingly natural - perfect for simulating water, wind, or energy fields.</p>
<h2 id="heading-using-noise-functions-for-displacement">Using Noise Functions for Displacement</h2>
<p>Sine waves are powerful, but they have a fundamental limitation: they are <strong>periodic</strong>. They repeat in a perfectly predictable pattern forever. This repetition is something our brains quickly pick up on, making the effect look artificial. To break this cycle and create motion that feels genuinely organic and unpredictable, we need a source of structured randomness. We need <strong>noise</strong>.</p>
<h3 id="heading-what-is-noise">What is Noise?</h3>
<p>In graphics, "noise" doesn't mean the harsh, chaotic static you see on an old TV. Instead, it refers to a special kind of pseudo-randomness that varies smoothly across space. Think of it as the difference between a random number generator and a smoothly rolling landscape.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764414898158/2f3dd4be-a260-4859-b74e-d4eefc570305.png" alt class="image--center mx-auto" /></p>
<p><strong>Common Types of Noise:</strong></p>
<ul>
<li><p><a target="_blank" href="https://en.wikipedia.org/wiki/Perlin_noise"><strong>Perlin/Simplex Noise</strong></a><strong>:</strong> The most famous types. They are specifically designed to look natural and avoid grid-like artifacts.</p>
</li>
<li><p><a target="_blank" href="https://en.wikipedia.org/wiki/Value_noise"><strong>Value Noise</strong></a><strong>:</strong> A simpler form, created by interpolating between random values on a grid.</p>
</li>
<li><p><a target="_blank" href="https://en.wikipedia.org/wiki/Worley_noise"><strong>Worley Noise</strong></a><strong>:</strong> Creates cellular or <a target="_blank" href="https://en.wikipedia.org/wiki/Voronoi_diagram">voronoi</a> patterns, perfect for effects like cracked earth or water caustics.</p>
</li>
</ul>
<h3 id="heading-noise-via-texture-lookup">Noise via Texture Lookup</h3>
<p>A key point for WGSL developers is that, unlike other shading languages like GLSL, <strong>WGSL does not have built-in noise functions like</strong> <code>noise()</code> or <code>perlin()</code>. The standard and most performant way to use noise in Bevy and WGSL is to pre-generate it on the CPU, store it in a texture, and then sample that texture in our shader.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Define the texture and sampler in our material's bind group</span>
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">1</span>)
var noise_texture: texture_2d&lt;<span class="hljs-built_in">f32</span>&gt;;
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">2</span>)
var noise_sampler: sampler;

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sample_noise</span></span>(position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, time: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-comment">// Use the vertex's world position to create UV coordinates.</span>
    <span class="hljs-comment">// The scale factor controls the "frequency" of the noise.</span>
    <span class="hljs-keyword">let</span> uv = position.xz * <span class="hljs-number">0.1</span>;

    <span class="hljs-comment">// To make the noise animate, we scroll the UV coordinates over time.</span>
    <span class="hljs-comment">// Using different speeds for X and Y prevents obvious diagonal scrolling.</span>
    <span class="hljs-keyword">let</span> animated_uv = uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(time * <span class="hljs-number">0.05</span>, time * <span class="hljs-number">0.03</span>);

    <span class="hljs-comment">// Sample the texture to get the noise value.</span>
    <span class="hljs-keyword">let</span> noise_value = textureSampleLevel(
        noise_texture,
        noise_sampler,
        animated_uv,
        <span class="hljs-number">0.0</span> <span class="hljs-comment">// Mip Level</span>
    ).r; <span class="hljs-comment">// We only need one channel (red).</span>

    <span class="hljs-comment">// The texture stores values from 0.0 to 1.0.</span>
    <span class="hljs-comment">// We remap this to the range -1.0 to 1.0 for displacement.</span>
    <span class="hljs-keyword">return</span> noise_value * <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>;
}
</code></pre>
<h4 id="heading-why-we-must-use-texturesamplelevel-in-vertex-shaders">Why We Must Use <code>textureSampleLevel</code> in Vertex Shaders</h4>
<p>You likely noticed we used <code>textureSampleLevel</code> and not the more common <code>textureSample</code>. This is not optional; it is a fundamental requirement when sampling textures inside a vertex shader.</p>
<p>The reason lies in the graphics pipeline. When you use <code>textureSample()</code> in a <strong>fragment shader</strong>, the GPU performs a clever trick. It looks at neighboring pixels to determine how "zoomed in" or "zoomed out" the texture is on the screen. Based on that, it automatically selects the ideal mipmap level to prevent visual artifacts like shimmering or moiré patterns.</p>
<p>This calculation requires information about the final pixels on the screen. The GPU only knows this after the vertex shader has finished and the triangles have been converted into pixels (a process called rasterization).</p>
<p>Since our vertex shader runs before rasterization, the GPU has no pixel information. It cannot automatically choose a mipmap. Therefore, we <strong>must</strong> use <code>textureSampleLevel</code>, which lets us manually specify the exact mipmap level we want to sample from. By providing <code>0.0</code> as the final argument, we are explicitly telling the GPU, "Use the highest-resolution version of this texture (mip level 0)."</p>
<h3 id="heading-generating-a-noise-texture-in-rust">Generating a Noise Texture in Rust</h3>
<p>Creating the noise texture on the CPU is straightforward using the noise crate. The following function generates a seamless 2D Perlin noise pattern and stores it in a Bevy <code>Image</code> asset. This is typically done once during your application's setup.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> noise::{NoiseFn, Perlin}; <span class="hljs-comment">// Add `noise = "0.9"` to your Cargo.toml</span>

<span class="hljs-comment">// Generates a new Image asset containing Perlin noise.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">generate_noise_texture</span></span>(size: <span class="hljs-built_in">u32</span>) -&gt; Image {
    <span class="hljs-comment">// The Perlin generator. The seed ensures we get the same noise every time.</span>
    <span class="hljs-keyword">let</span> perlin = Perlin::new(<span class="hljs-number">42</span>);
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> data = <span class="hljs-built_in">Vec</span>::with_capacity((size * size * <span class="hljs-number">4</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">usize</span>);

    <span class="hljs-keyword">for</span> y <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..size {
        <span class="hljs-keyword">for</span> x <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..size {
            <span class="hljs-comment">// Normalize coordinates to the 0.0-1.0 range</span>
            <span class="hljs-keyword">let</span> nx = x <span class="hljs-keyword">as</span> <span class="hljs-built_in">f64</span> / size <span class="hljs-keyword">as</span> <span class="hljs-built_in">f64</span>;
            <span class="hljs-keyword">let</span> ny = y <span class="hljs-keyword">as</span> <span class="hljs-built_in">f64</span> / size <span class="hljs-keyword">as</span> <span class="hljs-built_in">f64</span>;

            <span class="hljs-comment">// Sample the noise function. The multiplier (e.g., 5.0) acts</span>
            <span class="hljs-comment">// like a frequency setting for the generated pattern.</span>
            <span class="hljs-keyword">let</span> noise_value = perlin.get([nx * <span class="hljs-number">5.0</span>, ny * <span class="hljs-number">5.0</span>]); <span class="hljs-comment">// Returns -1.0 to 1.0</span>

            <span class="hljs-comment">// Remap the noise value from [-1.0, 1.0] to [0, 255] for the texture</span>
            <span class="hljs-keyword">let</span> byte_value = ((noise_value + <span class="hljs-number">1.0</span>) * <span class="hljs-number">0.5</span> * <span class="hljs-number">255.0</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">u8</span>;

            <span class="hljs-comment">// Write the same value to R, G, and B channels for a grayscale image.</span>
            data.push(byte_value); <span class="hljs-comment">// R</span>
            data.push(byte_value); <span class="hljs-comment">// G</span>
            data.push(byte_value); <span class="hljs-comment">// B</span>
            data.push(<span class="hljs-number">255</span>);        <span class="hljs-comment">// A (fully opaque)</span>
        }
    }

    Image::new(
        Extent3d { width: size, height: size, depth_or_array_layers: <span class="hljs-number">1</span> },
        TextureDimension::D2,
        data,
        TextureFormat::Rgba8Unorm,
        RenderAssetUsages::default(),
    )
}
</code></pre>
<h3 id="heading-multi-octave-noise-fractal-brownian-motion">Multi-Octave Noise (Fractal Brownian Motion)</h3>
<p>Just as we layered sine waves, we can layer noise to create <strong>Fractal Brownian Motion (fBm)</strong>. This is the exact same "octave" principle, but applied to noise samples instead of <code>sin()</code> calls. The result is a much richer, more detailed, and more natural-looking noise pattern.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fbm_noise</span></span>(position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, time: <span class="hljs-built_in">f32</span>, octaves: <span class="hljs-built_in">u32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    var total_noise = <span class="hljs-number">0.0</span>;
    var amplitude = <span class="hljs-number">1.0</span>;   <span class="hljs-comment">// persistence starts at 1.0</span>
    var frequency = <span class="hljs-number">1.0</span>;   <span class="hljs-comment">// lacunarity starts at 1.0</span>

    <span class="hljs-keyword">for</span> (var i = <span class="hljs-number">0</span>u; i &lt; octaves; i = i + <span class="hljs-number">1</span>u) {
        <span class="hljs-comment">// Sample noise at the current frequency</span>
        <span class="hljs-keyword">let</span> uv = position.xz * frequency * <span class="hljs-number">0.1</span> + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(time * <span class="hljs-number">0.05</span>);
        <span class="hljs-keyword">let</span> noise = textureSampleLevel(noise_texture, noise_sampler, uv, <span class="hljs-number">0.0</span>).r * <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>;

        total_noise += noise * amplitude;

        <span class="hljs-comment">// Prepare for the next octave</span>
        amplitude *= <span class="hljs-number">0.5</span>; <span class="hljs-comment">// persistence = 0.5</span>
        frequency *= <span class="hljs-number">2.0</span>; <span class="hljs-comment">// lacunarity = 2.0</span>
    }

    <span class="hljs-keyword">return</span> total_noise;
}
</code></pre>
<p>This gives us rich, organic variation with detail at multiple scales - the hallmark of natural phenomena.</p>
<h3 id="heading-combining-noise-and-waves-the-best-of-both-worlds">Combining Noise and Waves: The Best of Both Worlds</h3>
<p>The most powerful and professional approach is often to combine periodic waves with aperiodic noise. Each component plays a specific role:</p>
<ul>
<li><p><strong>Waves provide the rhythm:</strong> The large, predictable, underlying motion (e.g., the main flapping of a flag).</p>
</li>
<li><p><strong>Noise provides the chaos:</strong> The small, unpredictable, organic variations (e.g., the turbulent fluttering on the flag's surface).</p>
</li>
</ul>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hybrid_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. The main, rhythmic motion from a sine wave.</span>
    <span class="hljs-keyword">let</span> base_wave = sin(position.x * <span class="hljs-number">2.0</span> - time * <span class="hljs-number">1.5</span>) * <span class="hljs-number">0.2</span>;

    <span class="hljs-comment">// 2. The organic, turbulent detail from multi-octave noise.</span>
    <span class="hljs-keyword">let</span> turbulence = fbm_noise(position, time, <span class="hljs-number">3</span>u) * <span class="hljs-number">0.1</span>;

    <span class="hljs-comment">// 3. Combine them. The wave provides the main structure, and</span>
    <span class="hljs-comment">//    the noise "perturbs" it to make it look natural.</span>
    <span class="hljs-keyword">let</span> total_displacement = base_wave + turbulence;

    <span class="hljs-keyword">return</span> vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, total_displacement, <span class="hljs-number">0.0</span>);
}
</code></pre>
<p>This hybrid approach gives you the control and predictability of waves plus the realism and organic detail of noise.</p>
<h2 id="heading-advanced-twist-and-spiral-deformations">Advanced Twist and Spiral Deformations</h2>
<p>In the last article, we introduced a basic twist effect that rotated a mesh around an axis, like a rigid pole. While functional, it was linear and uniform. Now, we'll explore how to add artistic control and organic motion to our twists, making them bend, bulge, and undulate in more complex and interesting ways.</p>
<h3 id="heading-twist-with-controlled-falloff">Twist with Controlled Falloff</h3>
<p>Real-world twisting rarely happens uniformly. A rope frays more at the ends, and a character's arm twists from the shoulder, not the elbow. We can control where a twist has the most effect by using <strong>falloff</strong>.</p>
<h4 id="heading-radial-falloff-twisting-from-the-edges">Radial Falloff (Twisting from the Edges)</h4>
<p>Let's create a twist that is strongest at the outer edges of a mesh and has no effect at the center. We can achieve this by making the twist angle proportional to the vertex's distance from the central axis.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">radial_falloff_twist</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Calculate the distance from the center Y-axis in the XZ plane.</span>
    <span class="hljs-keyword">let</span> radius = length(position.xz);

    <span class="hljs-comment">// The twist angle is now a function of this radius.</span>
    <span class="hljs-comment">// Vertices with radius = 0 (at the center) will have angle = 0.</span>
    <span class="hljs-keyword">let</span> twist_amount = sin(time) * <span class="hljs-number">3.0</span>; <span class="hljs-comment">// Max twist in radians</span>
    <span class="hljs-keyword">let</span> angle = radius * twist_amount;

    <span class="hljs-comment">// Apply the standard 2D rotation to the XZ coordinates.</span>
    <span class="hljs-keyword">let</span> cos_a = cos(angle);
    <span class="hljs-keyword">let</span> sin_a = sin(angle);

    var twisted = position;
    twisted.x = position.x * cos_a - position.z * sin_a;
    twisted.z = position.x * sin_a + position.z * cos_a;

    <span class="hljs-keyword">return</span> twisted;
}
</code></pre>
<p>This is great for creating swirling vortex effects or objects that feel like they are being spun from their edges.</p>
<h4 id="heading-height-based-falloff-with-smoothstep">Height-Based Falloff with smoothstep</h4>
<p>Often, we want a twist to occur only within a specific vertical section of a mesh. Using <code>smoothstep</code>, we can create a beautifully clean transition from "no twist" to "full twist."</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">height_smooth_twist</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
    twist_start_y: <span class="hljs-built_in">f32</span>, <span class="hljs-comment">// The Y-coordinate where the twist begins.</span>
    twist_end_y: <span class="hljs-built_in">f32</span>,   <span class="hljs-comment">// The Y-coordinate where the twist reaches full strength.</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// smoothstep creates a value that smoothly goes from 0.0 to 1.0</span>
    <span class="hljs-comment">// as position.y moves from twist_start_y to twist_end_y.</span>
    <span class="hljs-keyword">let</span> twist_influence = smoothstep(twist_start_y, twist_end_y, position.y);

    <span class="hljs-comment">// The final angle is the base twist amount scaled by our influence factor.</span>
    <span class="hljs-keyword">let</span> base_twist = sin(time * <span class="hljs-number">2.0</span>) * <span class="hljs-number">3.0</span>; <span class="hljs-comment">// Max twist</span>
    <span class="hljs-keyword">let</span> angle = twist_influence * base_twist;

    <span class="hljs-comment">// Apply the rotation.</span>
    <span class="hljs-keyword">let</span> cos_a = cos(angle);
    <span class="hljs-keyword">let</span> sin_a = sin(angle);

    var twisted = position;
    twisted.x = position.x * cos_a - position.z * sin_a;
    twisted.z = position.x * sin_a + position.z * cos_a;

    <span class="hljs-keyword">return</span> twisted;
}
</code></pre>
<p>The use of <code>smoothstep</code> is key. It avoids the abrupt, mechanical start and stop of a linear twist, creating a much more elegant and professional-looking deformation.</p>
<h3 id="heading-spiral-deformations">Spiral Deformations</h3>
<p>A spiral is simply a twist combined with an outward (or inward) displacement. As the vertices rotate around the central axis, we also push them away from it, with the push amount depending on their height. This creates a classic spiral or vortex shape.</p>
<p>The logic involves converting a vertex's XZ coordinates to polar coordinates (angle and distance/radius), modifying them, and then converting back to Cartesian coordinates (X and Z).</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">spiral_deformation</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Calculate the twist angle based on height and time.</span>
    <span class="hljs-keyword">let</span> twist_angle = position.y * <span class="hljs-number">2.0</span> + time;

    <span class="hljs-comment">// 2. Calculate the new radius of the vertex.</span>
    <span class="hljs-comment">// It starts with its original radius and expands based on height.</span>
    <span class="hljs-keyword">let</span> base_radius = length(position.xz);
    <span class="hljs-keyword">let</span> height_influence = position.y * <span class="hljs-number">0.1</span>;
    <span class="hljs-keyword">let</span> spiral_radius = base_radius + height_influence;

    <span class="hljs-comment">// 3. Reconstruct the new XZ position.</span>
    <span class="hljs-comment">// First, find the vertex's original angle around the Y-axis.</span>
    <span class="hljs-keyword">let</span> original_angle = atan2(position.z, position.x);
    <span class="hljs-comment">// Then, add our twist to get the new angle.</span>
    <span class="hljs-keyword">let</span> new_angle = original_angle + twist_angle;

    <span class="hljs-comment">// Convert back from polar (angle, radius) to Cartesian (x, z) coordinates.</span>
    var spiraled_position = position;
    spiraled_position.x = spiral_radius * cos(new_angle);
    spiraled_position.z = spiral_radius * sin(new_angle);

    <span class="hljs-keyword">return</span> spiraled_position;
}
</code></pre>
<p>This is perfect for effects like tornadoes, magical spells, or stylized horns.</p>
<h3 id="heading-dynamic-twisting-with-waves">Dynamic Twisting with Waves</h3>
<p>Instead of a uniform twist, what if the amount of twist undulated along the mesh? We can achieve this by using a sine wave to control the twist angle at different heights. This creates a much more organic, "living" motion, as if the object is coiling and uncoiling like a snake.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">wave_twist</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Use one sine wave to determine the twist amount at each height.</span>
    <span class="hljs-keyword">let</span> twist_wave = sin(position.y * <span class="hljs-number">3.0</span> - time * <span class="hljs-number">2.0</span>) * <span class="hljs-number">1.5</span>;

    <span class="hljs-comment">// Use a second, phase-shifted wave to add more complexity.</span>
    <span class="hljs-keyword">let</span> phase_wave = cos(position.y * <span class="hljs-number">2.0</span> + time * <span class="hljs-number">1.5</span>) * <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// The final angle is the combination of these waves.</span>
    <span class="hljs-keyword">let</span> angle = twist_wave + phase_wave;

    <span class="hljs-keyword">let</span> cos_a = cos(angle);
    <span class="hljs-keyword">let</span> sin_a = sin(angle);

    var twisted = position;
    twisted.x = position.x * cos_a - position.z * sin_a;
    twisted.z = position.x * sin_a + position.z * cos_a;

    <span class="hljs-keyword">return</span> twisted;
}
</code></pre>
<h3 id="heading-multi-axis-twisting">Multi-Axis Twisting</h3>
<p>For truly complex deformations, we can apply twists around multiple axes in sequence. For example, we can twist around the Y-axis and then apply another twist around the X-axis.</p>
<p><strong>Important:</strong> The order of these rotations matters! Twisting on Y then X produces a different result than twisting on X then Y.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">multi_axis_twist</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    var result = position;

    <span class="hljs-comment">// 1. Twist around the Y-axis (affecting X and Z).</span>
    <span class="hljs-keyword">let</span> angle_y = position.y * sin(time) * <span class="hljs-number">1.5</span>;
    <span class="hljs-keyword">let</span> cos_y = cos(angle_y);
    <span class="hljs-keyword">let</span> sin_y = sin(angle_y);
    <span class="hljs-keyword">let</span> temp_x = result.x * cos_y - result.z * sin_y;
    result.z = result.x * sin_y + result.z * cos_y;
    result.x = temp_x;

    <span class="hljs-comment">// 2. Then, twist the result around the X-axis (affecting Y and Z).</span>
    <span class="hljs-keyword">let</span> angle_x = position.x * cos(time * <span class="hljs-number">0.7</span>) * <span class="hljs-number">0.5</span>;
    <span class="hljs-keyword">let</span> cos_x = cos(angle_x);
    <span class="hljs-keyword">let</span> sin_x = sin(angle_x);
    <span class="hljs-keyword">let</span> temp_y = result.y * cos_x - result.z * sin_x;
    result.z = result.y * sin_x + result.z * cos_x;
    result.y = temp_y;

    <span class="hljs-keyword">return</span> result;
}
</code></pre>
<p><strong>Performance Note:</strong> Each rotation involves several multiplications and trigonometric functions. Chaining them together can become computationally expensive. Use multi-axis twisting sparingly, typically for "hero" objects or specific, high-impact visual effects.</p>
<h2 id="heading-custom-displacement-maps">Custom Displacement Maps</h2>
<p>So far, all of our effects have been procedural - generated by mathematical formulas. This is powerful, but it can be difficult to achieve a specific, deliberate shape. What if you want a monster's veins to bulge in a precise pattern, or terrain to rise into specific mountain peaks? For this, we turn to <strong>displacement maps</strong>.</p>
<p>A displacement map is a texture that gives artists direct, pixel-level control over vertex deformation. Instead of a formula dictating the displacement, the shader "reads" the color of a pixel from the map and uses that value to determine how much to push or pull the corresponding vertex. This workflow moves the creative control from the programmer to the artist.</p>
<h3 id="heading-understanding-displacement-maps">Understanding Displacement Maps</h3>
<p>Typically, a displacement map is a grayscale image where the brightness of each pixel encodes the displacement amount:</p>
<ul>
<li><p><strong>Mid-gray (Value 0.5):</strong> Represents zero displacement. Vertices corresponding to these pixels will not move.</p>
</li>
<li><p><strong>White (Value 1.0):</strong> Represents maximum positive displacement, pushing vertices "outward" along their normal.</p>
</li>
<li><p><strong>Black (Value 0.0):</strong> Represents maximum negative displacement, pulling vertices "inward" along their normal.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764415043501/795558f6-146c-4228-aa58-dd5b198437ff.png" alt class="image--center mx-auto" /></p>
<p>This allows an artist to simply paint the desired deformation. Painting with white raises the geometry, while painting with black carves into it.</p>
<h3 id="heading-sampling-a-displacement-map-in-the-shader">Sampling a Displacement Map in the Shader</h3>
<p>The implementation is very similar to sampling a noise texture. We use the vertex's UV coordinates to look up the correct pixel in the displacement map.</p>
<pre><code class="lang-rust">@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">1</span>)
var displacement_map: texture_2d&lt;<span class="hljs-built_in">f32</span>&gt;;
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">2</span>)
var displacement_sampler: sampler;

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_displacement_map</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    strength: <span class="hljs-built_in">f32</span>, <span class="hljs-comment">// A uniform to control the overall effect intensity</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Sample the map using the vertex's UV coordinates.</span>
    <span class="hljs-comment">// Remember: we must use textureSampleLevel in a vertex shader.</span>
    <span class="hljs-keyword">let</span> displacement_value = textureSampleLevel(
        displacement_map,
        displacement_sampler,
        uv,
        <span class="hljs-number">0.0</span> <span class="hljs-comment">// Mip Level 0</span>
    ).r; <span class="hljs-comment">// We only need the red channel for a grayscale map.</span>

    <span class="hljs-comment">// Remap the texture value from the [0, 1] range to the [-1, 1] range.</span>
    <span class="hljs-comment">// Mid-gray (0.5) becomes 0.0, black (0.0) becomes -1.0, and white (1.0) becomes 1.0.</span>
    <span class="hljs-keyword">let</span> displacement_amount = (displacement_value * <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>) * strength;

    <span class="hljs-comment">// Apply the final displacement along the vertex's normal.</span>
    <span class="hljs-keyword">return</span> position + normal * displacement_amount;
}
</code></pre>
<h3 id="heading-animated-displacement-maps">Animated Displacement Maps</h3>
<p>While displacement maps are often static, we can easily animate them by manipulating the UV coordinates we use for sampling. Scrolling the UVs across a tileable displacement map is a classic technique for creating effects like flowing lava, moving force fields, or rippling water surfaces.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">animated_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Scroll the UV coordinates over time to create motion.</span>
    <span class="hljs-keyword">let</span> animated_uv = uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(time * <span class="hljs-number">0.1</span>, time * <span class="hljs-number">0.05</span>);

    <span class="hljs-comment">// Sample the map with the animated coordinates.</span>
    <span class="hljs-keyword">let</span> displacement_value = textureSampleLevel(
        displacement_map,
        displacement_sampler,
        animated_uv,
        <span class="hljs-number">0.0</span>
    ).r;

    <span class="hljs-keyword">let</span> displacement = (displacement_value * <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>) * <span class="hljs-number">0.3</span>;

    <span class="hljs-keyword">return</span> position + normal * displacement;
}
</code></pre>
<h3 id="heading-the-hybrid-approach-maps-procedural-effects">The Hybrid Approach: Maps + Procedural Effects</h3>
<p>The most powerful workflow often combines both techniques. An artist creates a displacement map to define the main, static shapes, and then the programmer layers procedural animation on top to add life and dynamic detail.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hybrid_displacement_map</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Static, artist-controlled displacement from the map.</span>
    <span class="hljs-keyword">let</span> map_value = textureSampleLevel(displacement_map, displacement_sampler, uv, <span class="hljs-number">0.0</span>).r;
    <span class="hljs-keyword">let</span> map_displacement = (map_value * <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>) * <span class="hljs-number">0.2</span>;

    <span class="hljs-comment">// 2. Dynamic, procedural animation layered on top.</span>
    <span class="hljs-keyword">let</span> wave = sin(position.x * <span class="hljs-number">5.0</span> - time * <span class="hljs-number">2.0</span>) * <span class="hljs-number">0.1</span>;

    <span class="hljs-comment">// 3. Combine both for the final result.</span>
    <span class="hljs-keyword">let</span> total_displacement = map_displacement + wave;

    <span class="hljs-keyword">return</span> position + normal * total_displacement;
}
</code></pre>
<p>This gives you the best of both worlds: the deliberate control of a hand-painted map and the dynamic energy of procedural animation.</p>
<h3 id="heading-advanced-multi-channel-displacement-maps">Advanced: Multi-Channel Displacement Maps</h3>
<p>For even more control, we can use a color texture where the R, G, and B channels store displacement along the X, Y, and Z axes respectively. This is often called a <strong>Vector Displacement Map</strong>. It allows for much more complex deformations, such as undercuts and overhangs, that are impossible with a simple grayscale map that only displaces along the surface normal.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">multi_channel_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    strength: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Sample the full RGB color from the texture.</span>
    <span class="hljs-keyword">let</span> displacement_vector = textureSampleLevel(
        displacement_map,
        displacement_sampler,
        uv,
        <span class="hljs-number">0.0</span>
    ).rgb; <span class="hljs-comment">// .rgb gets a vec3&lt;f32&gt;</span>

    <span class="hljs-comment">// Remap each channel from [0, 1] to [-1, 1].</span>
    <span class="hljs-keyword">let</span> displacement = (displacement_vector * <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>) * strength;

    <span class="hljs-comment">// Displace in world-space X, Y, and Z directions.</span>
    <span class="hljs-keyword">return</span> position + displacement;
}
</code></pre>
<p>This is an advanced technique commonly used in film and high-end games where complex details from a high-resolution sculpt (e.g., from ZBrush or Blender) are "baked" into a texture to be applied to a lower-resolution game model.</p>
<h2 id="heading-preserving-mesh-topology">Preserving Mesh Topology</h2>
<p>As we apply more extreme displacement, we run the risk of literally tearing our mesh apart. When a vertex is pushed so far that it causes the triangle it belongs to to flip inside-out, intersect with another triangle, or stretch into a razor-thin line, you get ugly visual artifacts. This is a breakdown of the mesh's <strong>topology</strong> - the fundamental structure of how its vertices, edges, and faces are connected.</p>
<p>Preserving this topology is crucial for creating clean, stable, and professional-looking effects.</p>
<h3 id="heading-understanding-the-problem">Understanding the Problem</h3>
<p>Imagine a simple quad made of two triangles. If we apply a wave that is too strong, the vertices at the peak of the wave can be pushed past the vertices in the trough.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764415183091/216c5516-4c73-4343-bbf8-d29d7c76fbf7.png" alt class="image--center mx-auto" /></p>
<p>Our goal is to apply dramatic displacement without causing this kind of topological breakdown.</p>
<h3 id="heading-solution-1-limit-the-displacement-magnitude">Solution 1: Limit the Displacement Magnitude</h3>
<p>The simplest and most direct way to prevent topology issues is to put a hard cap on how far any vertex can move. We calculate the desired displacement, measure its length, and if it exceeds our maximum allowed distance, we scale it back.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">limited_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    displacement_vector: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    max_displacement: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> displacement_length = length(displacement_vector);

    <span class="hljs-keyword">if</span> (displacement_length &gt; max_displacement) {
        <span class="hljs-comment">// The displacement is too large.</span>
        <span class="hljs-comment">// We keep its direction but scale its length down to the maximum.</span>
        <span class="hljs-keyword">let</span> limited_vector = normalize(displacement_vector) * max_displacement;
        <span class="hljs-keyword">return</span> position + limited_vector;
    }

    <span class="hljs-comment">// The displacement is within safe limits.</span>
    <span class="hljs-keyword">return</span> position + displacement_vector;
}
</code></pre>
<p>This is a brute-force but effective method to prevent the most extreme artifacts.</p>
<h3 id="heading-solution-2-normal-based-displacement-filtering">Solution 2: Normal-Based Displacement Filtering</h3>
<p>Another common issue is when displacement pushes vertices "through" the surface, causing triangles to flip inside-out. We can prevent this by checking the direction of the displacement against the vertex's normal. If the displacement is trying to move the vertex against its normal (i.e., inward), we can choose to reduce or reject that movement.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">topology_safe_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    displacement_vector: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// The dot product tells us how much the displacement aligns with the normal.</span>
    <span class="hljs-comment">// A positive result means it's moving outward.</span>
    <span class="hljs-comment">// A negative result means it's moving inward, against the normal.</span>
    <span class="hljs-keyword">let</span> normal_alignment = dot(displacement_vector, normal);

    <span class="hljs-keyword">if</span> (normal_alignment &lt; <span class="hljs-number">0.0</span>) {
        <span class="hljs-comment">// This displacement would move the vertex "inside" the surface.</span>
        <span class="hljs-comment">// To prevent this, we can remove the inward component entirely,</span>
        <span class="hljs-comment">// leaving only the part of the displacement that runs parallel</span>
        <span class="hljs-comment">// (tangential) to the surface.</span>
        <span class="hljs-keyword">let</span> inward_component = normal * normal_alignment;
        <span class="hljs-keyword">let</span> tangential_displacement = displacement_vector - inward_component;
        <span class="hljs-keyword">return</span> position + tangential_displacement;
    }

    <span class="hljs-comment">// The displacement is moving outward, which is generally safe.</span>
    <span class="hljs-keyword">return</span> position + displacement_vector;
}
</code></pre>
<p>This technique is particularly useful for effects like shockwaves or explosions where you want a purely outward bulge without any inward-pulling artifacts.</p>
<h3 id="heading-the-real-solution-mesh-resolution">The Real Solution: Mesh Resolution</h3>
<p>Ultimately, the single most important factor in preserving topology is the <strong>resolution of your mesh</strong>.</p>
<ul>
<li><p><strong>Low-Resolution Mesh (Few Vertices):</strong> Will break down very quickly under displacement. The long edges between vertices have no flexibility and will easily intersect.</p>
</li>
<li><p><strong>High-Resolution Mesh (Many Vertices):</strong> Can handle much more extreme displacement. The smaller, more numerous triangles can stretch and deform to accommodate the movement, resulting in a smooth, continuous surface.</p>
</li>
</ul>
<p>This is why effects like realistic water or cloth require highly tessellated (subdivided) geometry. There is a direct trade-off: higher resolution gives better deformation quality but comes at a higher performance cost.</p>
<p><strong>Rule of Thumb:</strong> If your maximum displacement amount is <code>D</code>, your mesh's vertices should ideally be spaced no more than <code>D / 2</code> units apart. This helps ensure that even at maximum displacement, a vertex is unlikely to be pushed past its neighbor.</p>
<h2 id="heading-culling-the-hidden-gotchas-of-vertex-displacement">Culling: The Hidden Gotchas of Vertex Displacement</h2>
<p>When we move vertices in a shader, we create a knowledge gap. The CPU, which is responsible for high-level scene management, is no longer perfectly aware of where the mesh actually is. The GPU has the final, displaced vertex positions, but the CPU is still working with the original, pre-displacement data. This disconnect can lead to two frustrating and common rendering bugs.</p>
<h3 id="heading-problem-1-the-disappearing-mesh-frustum-culling">Problem 1: The Disappearing Mesh (Frustum Culling)</h3>
<p>To render a scene efficiently, Bevy's CPU-side renderer first performs <strong>frustum culling</strong>. It checks the <strong>Axis-Aligned Bounding Box (AABB)</strong> of each mesh - a simple, invisible box that completely encloses the original geometry - against the camera's view cone (the frustum). If the box is outside the camera's view, the engine concludes the object is not visible and doesn't even bother sending it to the GPU to be rendered. This saves a huge amount of work.</p>
<p>The problem arises when our vertex shader displaces vertices outside of this original bounding box.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764415308830/dae2dbe7-42c5-4095-a4b7-165ddba7d4c0.png" alt class="image--center mx-auto" /></p>
<p>The CPU performs its check, sees the original AABB is off-screen, and culls the object. It never even makes it to the GPU, so our vertex shader never runs, and the object simply disappears, even though it should have been visible.</p>
<p>This is only a problem for <strong>large-scale displacement</strong> that pushes vertices significantly beyond the mesh's original boundaries.</p>
<h4 id="heading-solution-a-the-simple-fix-nofrustumculling"><strong>Solution A: The Simple Fix (</strong><code>NoFrustumCulling</code>)</h4>
<p>The easiest way to solve this is to tell Bevy to skip the check entirely for this one object. You can do this by adding the <code>NoFrustumCulling</code> component to the entity.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Tell Bevy: "Don't perform frustum culling on this entity.</span>
<span class="hljs-comment">// Just send it to the GPU and trust that I know what I'm doing."</span>
commands.spawn((
    Mesh3d(my_mesh_handle),
    MeshMaterial3d(my_material_handle),
    Transform::default(),
    NoFrustumCulling, <span class="hljs-comment">// Add this component</span>
));
</code></pre>
<p>This is a perfectly valid solution, but it does remove a potentially useful optimization.</p>
<h4 id="heading-solution-b-the-optimized-fix-expanding-the-aabb"><strong>Solution B: The Optimized Fix (Expanding the AABB)</strong></h4>
<p>A more robust solution is to give the CPU better information. We can manually compute a larger AABB for our mesh that accounts for the maximum possible displacement and assign it when we create the mesh asset.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// When creating your mesh in Rust...</span>
<span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> mesh = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::default());
<span class="hljs-comment">// ... populate vertices, etc. ...</span>

<span class="hljs-comment">// Calculate a new, larger AABB</span>
<span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>(<span class="hljs-keyword">mut</span> aabb) = mesh.compute_aabb() {
    <span class="hljs-comment">// Expand the box by the maximum possible displacement amount</span>
    <span class="hljs-keyword">let</span> max_displacement = <span class="hljs-number">2.0</span>; <span class="hljs-comment">// The largest value your shader can produce</span>
    <span class="hljs-keyword">let</span> expansion = Vec3::splat(max_displacement);
    aabb.min -= expansion;
    aabb.max += expansion;
    mesh.set_aabb(<span class="hljs-literal">Some</span>(aabb));
}
</code></pre>
<p>Now, the CPU has an accurate bounding box to work with and can perform culling correctly.</p>
<h3 id="heading-problem-2-the-invisible-backside-backface-culling">Problem 2: The Invisible Backside (Backface Culling)</h3>
<p>The second issue is unrelated to object boundaries but is critical for thin surfaces like our upcoming flag. By default, for performance, GPUs practice <strong>backface culling</strong>. They only render triangles whose front face is pointing towards the camera. The back side is assumed to be hidden inside a solid object and is discarded.</p>
<p>For a thin object like a flag, this is a disaster. As the flag waves, you will inevitably see its back side. With backface culling enabled, the back of the flag will be invisible, creating ugly, see-through holes.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764415665204/8f72fc8d-0f7e-4452-bf49-b8c5cf4426f6.png" alt class="image--center mx-auto" /></p>
<h4 id="heading-the-solution-disable-culling-in-the-material"><strong>The Solution: Disable Culling in the Material</strong></h4>
<p>We need to tell the render pipeline that for this specific material, both sides of a triangle should be rendered. We do this by overriding the specialize function in our Material implementation. This function is the designated place to configure the low-level render pipeline state for a material.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In your material's `impl Material` block</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">specialize</span></span>(
    _pipeline: &amp;bevy::pbr::MaterialPipeline&lt;<span class="hljs-keyword">Self</span>&gt;,
    descriptor: &amp;<span class="hljs-keyword">mut</span> RenderPipelineDescriptor,
    _layout: &amp;MeshVertexBufferLayoutRef,
    _key: MaterialPipelineKey&lt;<span class="hljs-keyword">Self</span>&gt;,
) -&gt; <span class="hljs-built_in">Result</span>&lt;(), SpecializedMeshPipelineError&gt; {
    <span class="hljs-comment">// Find the primitive state, which controls how triangles are handled.</span>
    <span class="hljs-comment">// Set `cull_mode` to `None` to disable backface culling.</span>
    descriptor.primitive.cull_mode = <span class="hljs-literal">None</span>;
    <span class="hljs-literal">Ok</span>(())
}
</code></pre>
<p><strong>When to disable backface culling:</strong></p>
<ul>
<li><p><strong>Thin surfaces:</strong> Flags, leaves, paper, single-plane cloth.</p>
</li>
<li><p><strong>Special effects:</strong> Some transparent or translucent surfaces.</p>
</li>
<li><p><strong>Any mesh where the user might see the "inside."</strong></p>
</li>
</ul>
<p><strong>Performance Note:</strong> Disabling backface culling effectively doubles the number of fragments the GPU might have to process for that mesh. Use it only when necessary. Don't disable it for solid, enclosed objects like a rock or a character model.</p>
<h3 id="heading-summary-for-our-flag-project">Summary for Our Flag Project</h3>
<p>Applying this to our upcoming flag simulation:</p>
<ol>
<li><p>The flag's waving motion will be relatively contained. The displacement won't be large enough to move it completely outside its original bounding box, so we <strong>do not</strong> need NoFrustumCulling.</p>
</li>
<li><p>The flag is a thin, two-sided object. We absolutely <strong>do</strong> need to disable backface culling by setting <code>cull_mode = None</code> in our material.</p>
</li>
</ol>
<h2 id="heading-vertex-shader-optimization-techniques">Vertex Shader Optimization Techniques</h2>
<p>A vertex shader runs for every single vertex of a mesh. A detailed character model might have 50,000 vertices, meaning your shader code will execute 50,000 times per frame. Even small inefficiencies can add up quickly and impact your game's performance. Complex displacement effects, with their loops, texture samples, and trigonometric functions, can be particularly demanding.</p>
<p>Here are some essential strategies to keep your vertex shaders fast and efficient.</p>
<h3 id="heading-principle-1-compute-on-the-cpu-use-on-the-gpu">Principle 1: Compute on the CPU, Use on the GPU</h3>
<p>This is the golden rule of shader optimization. Any calculation that produces a result that is the same for every vertex in a draw call should be done <strong>once</strong> on the CPU, not thousands of times on the GPU.</p>
<p>A classic example is pre-calculating values based on time.</p>
<p><strong>❌ Inefficient:</strong> Calculating sin(time) for every vertex.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In the shader</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-comment">/*...*/</span>) -&gt; <span class="hljs-comment">/*...*/</span> {
    <span class="hljs-comment">// This sin() is calculated for every single vertex!</span>
    <span class="hljs-keyword">let</span> displacement = sin(material.time) * <span class="hljs-number">0.5</span>;
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<p><strong>✅ Efficient:</strong> Calculating <code>sin(time)</code> once on the CPU and passing the result.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In a Rust system that runs once per frame</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_my_materials</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;MyMaterial&gt;&gt;) {
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-comment">// Do the expensive work ONCE on the CPU</span>
        material.uniforms.time_sin = time.elapsed_secs().sin();
        material.uniforms.time_cos = time.elapsed_secs().cos();
    }
}
</code></pre>
<pre><code class="lang-rust"><span class="hljs-comment">// In the shader</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-comment">/*...*/</span>) -&gt; <span class="hljs-comment">/*...*/</span> {
    <span class="hljs-comment">// We just use the pre-calculated result. Fast!</span>
    <span class="hljs-keyword">let</span> displacement = material.time_sin * <span class="hljs-number">0.5</span>;
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<p>The performance saving is enormous: one calculation on the CPU versus potentially thousands on the GPU.</p>
<h3 id="heading-principle-2-level-of-detail-lod">Principle 2: Level of Detail (LOD)</h3>
<p>Not all objects need the same level of visual fidelity. An object right in front of the camera needs complex, detailed displacement, while an object 100 meters away can get by with a much simpler effect, or none at all. This is the principle of <strong>Level of Detail (LOD)</strong>.</p>
<p>We can implement a simple distance-based LOD directly in our shader.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">adaptive_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
    camera_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// Passed in as a uniform</span>
    model_matrix: mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// We need to know the vertex's position in world space to check its distance.</span>
    <span class="hljs-keyword">let</span> world_pos = (model_matrix * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(position, <span class="hljs-number">1.0</span>)).xyz;
    <span class="hljs-keyword">let</span> distance_from_camera = length(world_pos - camera_position);

    <span class="hljs-keyword">if</span> (distance_from_camera &lt; <span class="hljs-number">10.0</span>) {
        <span class="hljs-comment">// Close up: Use the highest quality effect (e.g., 4 octaves of noise).</span>
        <span class="hljs-keyword">return</span> fbm_displacement(position, time, <span class="hljs-number">4</span>u);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (distance_from_camera &lt; <span class="hljs-number">50.0</span>) {
        <span class="hljs-comment">// Medium distance: Use a cheaper effect (e.g., 2 octaves).</span>
        <span class="hljs-keyword">return</span> fbm_displacement(position, time, <span class="hljs-number">2</span>u);
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// Far away: Use the cheapest possible effect (e.g., a single sine wave).</span>
        <span class="hljs-keyword">return</span> simple_wave_displacement(position, time);
    }
}
</code></pre>
<p>This ensures you spend your performance budget where it matters most: on the things the player can actually see up close.</p>
<blockquote>
<p><strong>A Note on Branching for LOD:</strong> You might notice this code uses <code>if/else</code>, which Principle 4 warns can be expensive. This is indeed a great observation and highlights a key nuance of optimization: it's all about trade-offs.</p>
<p>The alternative here would be to calculate all three displacement effects (high, medium, and low) for every single vertex and then blend between them. The cost of running the most complex function for every vertex, even distant ones, would be immense.</p>
<p>In this case, the massive performance <strong>gain</strong> from completely skipping expensive calculations for distant objects far outweighs the small performance <strong>cost</strong> of thread divergence at the LOD boundaries. This is a situation where using a branch is the clear and correct optimization.</p>
</blockquote>
<h3 id="heading-principle-3-minimize-texture-samples">Principle 3: Minimize Texture Samples</h3>
<p>Sampling a texture is a relatively expensive operation because it involves memory access. The fewer texture lookups you perform, the faster your shader will be.</p>
<p><strong>❌ Inefficient:</strong> Multiple samples from the same texture to create a crude blur.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> uv = <span class="hljs-keyword">in</span>.uv;
<span class="hljs-keyword">let</span> noise1 = textureSampleLevel(noise_tex, sampler, uv, <span class="hljs-number">0.0</span>).r;
<span class="hljs-keyword">let</span> noise2 = textureSampleLevel(noise_tex, sampler, uv + vec2(<span class="hljs-number">0.01</span>, <span class="hljs-number">0.0</span>), <span class="hljs-number">0.0</span>).r;
<span class="hljs-keyword">let</span> noise3 = textureSampleLevel(noise_tex, sampler, uv - vec2(<span class="hljs-number">0.01</span>, <span class="hljs-number">0.0</span>), <span class="hljs-number">0.0</span>).r;
<span class="hljs-keyword">let</span> result = (noise1 + noise2 + noise3) / <span class="hljs-number">3.0</span>;
</code></pre>
<p><strong>✅ Efficient:</strong> If possible, pack different data into the R, G, and B channels of a single texture. This lets you get three distinct patterns with a single memory lookup.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// One lookup gets three different noise patterns.</span>
<span class="hljs-keyword">let</span> noise_sample = textureSampleLevel(my_rgb_noise_tex, sampler, <span class="hljs-keyword">in</span>.uv, <span class="hljs-number">0.0</span>);
<span class="hljs-keyword">let</span> result = noise_sample.r * <span class="hljs-number">0.5</span> + noise_sample.g * <span class="hljs-number">0.3</span> + noise_sample.b * <span class="hljs-number">0.2</span>;
</code></pre>
<h3 id="heading-principle-4-avoid-divergent-branching">Principle 4: Avoid Divergent Branching</h3>
<p>GPUs achieve their incredible speed by executing the same instruction on a large batch of threads (representing different vertices or fragments) at the same time. This is called SIMD (Single Instruction, Multiple Data).</p>
<p>An <code>if/else</code> statement can break this lockstep execution. If the <code>if</code> condition is based on per-vertex data (like <code>position.y</code>), some threads in a batch will need to execute the <code>if</code> block while others execute the <code>else</code> block. This is called <strong>thread divergence</strong>, and it forces the GPU to run <em>both</em> paths, one after the other, effectively serializing the work and slowing things down.</p>
<p><strong>❌ Bad:</strong> Branching based on per-vertex data.</p>
<pre><code class="lang-rust">var displacement: <span class="hljs-built_in">f32</span>;
<span class="hljs-keyword">if</span> (<span class="hljs-keyword">in</span>.position.y &gt; <span class="hljs-number">0.5</span>) {
    displacement = calculate_complex_effect_A(<span class="hljs-keyword">in</span>.position, time); <span class="hljs-comment">// Path A</span>
} <span class="hljs-keyword">else</span> {
    displacement = calculate_complex_effect_B(<span class="hljs-keyword">in</span>.position, time); <span class="hljs-comment">// Path B</span>
}
</code></pre>
<p><strong>✅ Better:</strong> Use built-in functions like mix() or step() to create the same result without branching. This is called predication.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> effect_A = calculate_complex_effect_A(<span class="hljs-keyword">in</span>.position, time);
<span class="hljs-keyword">let</span> effect_B = calculate_complex_effect_B(<span class="hljs-keyword">in</span>.position, time);
<span class="hljs-comment">// step() returns 0.0 if y &lt;= 0.5, and 1.0 if y &gt; 0.5.</span>
<span class="hljs-keyword">let</span> blend_factor = step(<span class="hljs-number">0.5</span>, <span class="hljs-keyword">in</span>.position.y);
<span class="hljs-comment">// mix() selects between A and B based on the blend factor.</span>
<span class="hljs-keyword">let</span> displacement = mix(effect_B, effect_A, blend_factor);
</code></pre>
<p>Here, all threads execute all the code, but since the calculations run in parallel, it is often much faster than the cost of divergence.</p>
<blockquote>
<p><strong>Note:</strong> Branching on a uniform value (e.g., <code>if material.use_effect_A</code>) is perfectly fine, because all vertices will take the same path and there will be no divergence.</p>
</blockquote>
<h3 id="heading-principle-5-trust-the-built-ins">Principle 5: Trust the Built-ins</h3>
<p>The built-in WGSL functions (<code>normalize</code>, <code>length</code>, <code>dot</code>, <code>mix</code>, <code>clamp</code>, etc.) are highly optimized, low-level instructions on the GPU hardware. Always prefer using a built-in function over writing your own version in WGSL.</p>
<p><strong>❌ Slower:</strong> <code>let len = sqrt(dot(v, v));</code>  <strong>✅ Faster:</strong> <code>let len = length(v);</code></p>
<p><strong>❌ Slower:</strong> <code>let n = v / length(v);</code>  <strong>✅ Faster:</strong> <code>let n = normalize(v);</code></p>
<hr />
<h2 id="heading-complete-example-animated-flag-with-wind-simulation">Complete Example: Animated Flag with Wind Simulation</h2>
<p>Theory is essential, but nothing solidifies understanding like building something tangible. We will now apply everything we've learned to create a physically-inspired, interactive flag simulation. This project will not be a simple, repetitive wave; it will be a dynamic surface that responds to multiple simulated forces, creating a rich and believable sense of motion.</p>
<h3 id="heading-our-goal">Our Goal</h3>
<p>Our goal is to render a flag that looks like it's made of cloth and is being affected by wind. This means it needs to:</p>
<ul>
<li><p>Remain fixed along one edge (the flagpole).</p>
</li>
<li><p>Wave and billow in response to a controllable wind force.</p>
</li>
<li><p>Exhibit small, chaotic flutters (turbulence) on top of the main waves.</p>
</li>
<li><p>Droop realistically under gravity when the wind dies down.</p>
</li>
<li><p>Be viewable and correctly lit from all angles.</p>
</li>
</ul>
<h3 id="heading-what-this-project-demonstrates">What This Project Demonstrates</h3>
<p>This project is a synthesis of nearly every concept covered in this article:</p>
<ul>
<li><p><strong>Multi-Axis &amp; Multi-Octave Waves:</strong> We'll layer several sine waves traveling in different directions to create the main wind motion.</p>
</li>
<li><p><strong>Noise for Turbulence:</strong> A scrolling noise texture will add organic, high-frequency fluttering.</p>
</li>
<li><p><strong>Hybrid Displacement:</strong> The final motion is a combination of procedural waves (wind), noise (turbulence), and simple physics (gravity).</p>
</li>
<li><p><strong>UV-Based Logic:</strong> We'll use the mesh's UV coordinates to "pin" the left edge of the flag to the pole and to make the waving effect stronger at the free end.</p>
</li>
<li><p><strong>Normal Recalculation:</strong> We will dynamically recalculate the surface normals in the vertex shader to ensure the flag is lit correctly as it deforms.</p>
</li>
<li><p><strong>Backface Culling Disabled:</strong> The material will be configured to render both sides of the flag.</p>
</li>
<li><p><strong>Parameter Control:</strong> All key simulation parameters (wind speed, direction, gravity) will be exposed as uniforms that we can change in real-time from our Bevy app.</p>
</li>
</ul>
<h3 id="heading-the-shader-assetsshadersd0205flagsimulationwgsl">The Shader (<code>assets/shaders/d02_05_flag_simulation.wgsl</code>)</h3>
<p>This is where the magic happens. The vertex shader is the heart of the simulation. It executes the following logic for each vertex:</p>
<ol>
<li><p><strong>Check if Pinned:</strong> It first checks the vertex's <code>uv.x</code> coordinate. If it's close to <code>0.0</code>, the vertex is part of the flagpole edge and its position is not modified.</p>
</li>
<li><p><strong>Calculate Forces:</strong> For all other vertices, it calculates and combines several separate displacement vectors for wind, turbulence, gravity, and fine ripples.</p>
</li>
<li><p><strong>Combine and Apply:</strong> These vectors are added together. The total displacement is then scaled by <code>uv.x</code> to ensure the effect is weakest at the pole and strongest at the free edge.</p>
</li>
<li><p><strong>Recalculate Normal:</strong> It then calculates a new surface normal based on the displaced position to ensure lighting remains accurate.</p>
</li>
<li><p><strong>Transform to Clip Space:</strong> Finally, it computes the final world and clip space positions for rendering.</p>
</li>
</ol>
<p>The fragment shader is simple: it applies basic directional lighting using the recalculated normal and draws a striped pattern to make the flag more recognizable.</p>
<pre><code class="lang-rust">#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">FlagMaterial</span></span> {
    time: <span class="hljs-built_in">f32</span>,
    wind_strength: <span class="hljs-built_in">f32</span>,
    wind_direction: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    wave_frequency: <span class="hljs-built_in">f32</span>,
    wave_amplitude: <span class="hljs-built_in">f32</span>,
    turbulence_strength: <span class="hljs-built_in">f32</span>,
    gravity_strength: <span class="hljs-built_in">f32</span>,
    flag_stiffness: <span class="hljs-built_in">f32</span>,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: FlagMaterial;

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">1</span>)
var noise_texture: texture_2d&lt;<span class="hljs-built_in">f32</span>&gt;;

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">2</span>)
var noise_sampler: sampler;

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) world_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">3</span>) displacement_amount: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-comment">// Sample noise with multiple octaves (Fractal Brownian Motion)</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">sample_fbm_noise</span></span>(uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, octaves: <span class="hljs-built_in">u32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    var result = <span class="hljs-number">0.0</span>;
    var amplitude = <span class="hljs-number">1.0</span>;
    var frequency = <span class="hljs-number">1.0</span>;

    <span class="hljs-keyword">for</span> (var i = <span class="hljs-number">0</span>u; i &lt; octaves; i++) {
        <span class="hljs-keyword">let</span> sample_uv = uv * frequency;
        <span class="hljs-comment">// Use textureSampleLevel for vertex shader (mip level 0.0 = full resolution)</span>
        <span class="hljs-keyword">let</span> noise = textureSampleLevel(noise_texture, noise_sampler, sample_uv, <span class="hljs-number">0.0</span>).r;
        result += (noise * <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>) * amplitude;

        amplitude *= <span class="hljs-number">0.5</span>;
        frequency *= <span class="hljs-number">2.0</span>;
    }

    <span class="hljs-keyword">return</span> result;
}

<span class="hljs-comment">// Calculate wind displacement with multiple wave frequencies</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_wind_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    var displacement = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>);

    <span class="hljs-comment">// Distance from pole (left edge) - flags wave more at the free edge</span>
    <span class="hljs-keyword">let</span> edge_influence = uv.x; <span class="hljs-comment">// 0.0 at pole, 1.0 at free edge</span>

    <span class="hljs-comment">// Primary wind wave - travels along wind direction</span>
    <span class="hljs-keyword">let</span> wind_phase = dot(vec2&lt;<span class="hljs-built_in">f32</span>&gt;(position.x, position.z), material.wind_direction);
    <span class="hljs-keyword">let</span> primary_wave = sin(wind_phase * material.wave_frequency - time * <span class="hljs-number">3.0</span>)
        * material.wave_amplitude;

    <span class="hljs-comment">// Secondary wave - different frequency and direction</span>
    <span class="hljs-keyword">let</span> secondary_dir = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(-material.wind_direction.y, material.wind_direction.x);
    <span class="hljs-keyword">let</span> secondary_phase = dot(vec2&lt;<span class="hljs-built_in">f32</span>&gt;(position.x, position.z), secondary_dir);
    <span class="hljs-keyword">let</span> secondary_wave = sin(secondary_phase * material.wave_frequency * <span class="hljs-number">1.5</span> - time * <span class="hljs-number">2.0</span>)
        * material.wave_amplitude * <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// Tertiary wave - high frequency detail</span>
    <span class="hljs-keyword">let</span> detail_wave = sin(position.x * material.wave_frequency * <span class="hljs-number">4.0</span> - time * <span class="hljs-number">5.0</span>)
        * material.wave_amplitude * <span class="hljs-number">0.25</span>;

    <span class="hljs-comment">// Combine waves</span>
    <span class="hljs-keyword">let</span> wave_displacement = (primary_wave + secondary_wave + detail_wave) * edge_influence;

    <span class="hljs-comment">// Apply in wind direction and upward (flags billow up in wind)</span>
    displacement.x += material.wind_direction.x * wave_displacement * material.wind_strength;
    displacement.y += abs(wave_displacement) * material.wind_strength * <span class="hljs-number">0.5</span>; <span class="hljs-comment">// Billow upward</span>
    displacement.z += material.wind_direction.y * wave_displacement * material.wind_strength;

    <span class="hljs-keyword">return</span> displacement;
}

<span class="hljs-comment">// Add turbulent motion using noise</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_turbulence</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Sample noise texture with animation</span>
    <span class="hljs-keyword">let</span> noise_uv = uv * <span class="hljs-number">2.0</span> + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(time * <span class="hljs-number">0.1</span>, time * <span class="hljs-number">0.05</span>);
    <span class="hljs-keyword">let</span> noise_value = sample_fbm_noise(noise_uv, <span class="hljs-number">3</span>u);

    <span class="hljs-comment">// Edge influence - more turbulence at free edge</span>
    <span class="hljs-keyword">let</span> edge_influence = smoothstep(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, uv.x);

    <span class="hljs-comment">// Create turbulent displacement</span>
    var turbulence = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>);

    <span class="hljs-comment">// Sample noise at different positions for each axis</span>
    <span class="hljs-keyword">let</span> noise_x = sample_fbm_noise(noise_uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>), <span class="hljs-number">2</span>u);
    <span class="hljs-keyword">let</span> noise_y = sample_fbm_noise(noise_uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>), <span class="hljs-number">2</span>u);
    <span class="hljs-keyword">let</span> noise_z = sample_fbm_noise(noise_uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>), <span class="hljs-number">2</span>u);

    turbulence.x = noise_x * material.turbulence_strength * edge_influence;
    turbulence.y = noise_y * material.turbulence_strength * edge_influence * <span class="hljs-number">0.5</span>;
    turbulence.z = noise_z * material.turbulence_strength * edge_influence;

    <span class="hljs-keyword">return</span> turbulence;
}

<span class="hljs-comment">// Apply gravity effect - flag droops when not held by wind</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_gravity_effect</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    wind_strength: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Edge influence - more droop at free edge</span>
    <span class="hljs-keyword">let</span> edge_influence = uv.x * uv.x; <span class="hljs-comment">// Quadratic for more natural droop</span>

    <span class="hljs-comment">// Gravity is counteracted by wind</span>
    <span class="hljs-keyword">let</span> effective_gravity = material.gravity_strength * (<span class="hljs-number">1.0</span> - wind_strength * <span class="hljs-number">0.5</span>);

    <span class="hljs-comment">// Droop downward</span>
    <span class="hljs-keyword">let</span> droop = -edge_influence * effective_gravity;

    <span class="hljs-keyword">return</span> vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, droop, <span class="hljs-number">0.0</span>);
}

<span class="hljs-comment">// Calculate ripples that travel along the flag surface</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_surface_ripples</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Ripples travel from pole to edge</span>
    <span class="hljs-keyword">let</span> ripple_phase = uv.x * <span class="hljs-number">10.0</span> - time * <span class="hljs-number">4.0</span>;
    <span class="hljs-keyword">let</span> ripple = sin(ripple_phase) * <span class="hljs-number">0.02</span>;

    <span class="hljs-comment">// Vertical waves</span>
    <span class="hljs-keyword">let</span> vertical_phase = uv.y * <span class="hljs-number">8.0</span> - time * <span class="hljs-number">3.0</span>;
    <span class="hljs-keyword">let</span> vertical_ripple = sin(vertical_phase) * <span class="hljs-number">0.015</span>;

    <span class="hljs-comment">// Apply ripples perpendicular to surface</span>
    <span class="hljs-keyword">return</span> vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, ripple + vertical_ripple, <span class="hljs-number">0.0</span>);
}

<span class="hljs-comment">// Calculate perturbed normal for proper lighting</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_flag_normal</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    original_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Estimate surface gradient from wave function</span>
    <span class="hljs-keyword">let</span> epsilon = <span class="hljs-number">0.01</span>;

    <span class="hljs-comment">// Sample displacement at nearby points</span>
    <span class="hljs-keyword">let</span> center_disp = calculate_wind_displacement(position, uv, time);
    <span class="hljs-keyword">let</span> right_disp = calculate_wind_displacement(
        position + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(epsilon, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(epsilon, <span class="hljs-number">0.0</span>),
        time
    );
    <span class="hljs-keyword">let</span> up_disp = calculate_wind_displacement(
        position + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, epsilon, <span class="hljs-number">0.0</span>),
        uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, epsilon),
        time
    );

    <span class="hljs-comment">// Calculate tangent vectors</span>
    <span class="hljs-keyword">let</span> tangent_x = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(epsilon, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>) + (right_disp - center_disp);
    <span class="hljs-keyword">let</span> tangent_y = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, epsilon, <span class="hljs-number">0.0</span>) + (up_disp - center_disp);

    <span class="hljs-comment">// Cross product gives approximate normal</span>
    <span class="hljs-keyword">let</span> perturbed_normal = cross(tangent_x, tangent_y);

    <span class="hljs-comment">// Blend with original normal for stability</span>
    <span class="hljs-keyword">return</span> normalize(mix(original_normal, perturbed_normal, <span class="hljs-number">0.5</span>));
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-comment">// Start with original position</span>
    var displaced_position = <span class="hljs-keyword">in</span>.position;

    <span class="hljs-comment">// Check if vertex is at the pole (left edge) - these should not move</span>
    <span class="hljs-keyword">let</span> is_pinned = <span class="hljs-keyword">in</span>.uv.x &lt; <span class="hljs-number">0.05</span>; <span class="hljs-comment">// Pin vertices near x=0</span>

    <span class="hljs-keyword">if</span> !is_pinned {
        <span class="hljs-comment">// Calculate all displacement components</span>
        <span class="hljs-keyword">let</span> wind_disp = calculate_wind_displacement(<span class="hljs-keyword">in</span>.position, <span class="hljs-keyword">in</span>.uv, material.time);
        <span class="hljs-keyword">let</span> turbulence_disp = calculate_turbulence(<span class="hljs-keyword">in</span>.position, <span class="hljs-keyword">in</span>.uv, material.time);
        <span class="hljs-keyword">let</span> gravity_disp = calculate_gravity_effect(<span class="hljs-keyword">in</span>.position, <span class="hljs-keyword">in</span>.uv, material.wind_strength);
        <span class="hljs-keyword">let</span> ripple_disp = calculate_surface_ripples(<span class="hljs-keyword">in</span>.position, <span class="hljs-keyword">in</span>.uv, material.time);

        <span class="hljs-comment">// Combine all displacements</span>
        var total_displacement = wind_disp + turbulence_disp + gravity_disp + ripple_disp;

        <span class="hljs-comment">// Apply stiffness - resist extreme deformation</span>
        <span class="hljs-keyword">let</span> max_displacement = <span class="hljs-number">1.0</span> / material.flag_stiffness;
        <span class="hljs-keyword">let</span> displacement_magnitude = length(total_displacement);
        <span class="hljs-keyword">if</span> displacement_magnitude &gt; max_displacement {
            total_displacement = normalize(total_displacement) * max_displacement;
        }

        <span class="hljs-comment">// Apply displacement</span>
        displaced_position += total_displacement;

        <span class="hljs-comment">// Store displacement amount for fragment shader</span>
        out.displacement_amount = displacement_magnitude / max_displacement;
    } <span class="hljs-keyword">else</span> {
        out.displacement_amount = <span class="hljs-number">0.0</span>;
    }

    <span class="hljs-comment">// Calculate perturbed normal if not pinned</span>
    var final_normal = <span class="hljs-keyword">in</span>.normal;
    <span class="hljs-keyword">if</span> !is_pinned {
        final_normal = calculate_flag_normal(<span class="hljs-keyword">in</span>.position, <span class="hljs-keyword">in</span>.normal, <span class="hljs-keyword">in</span>.uv, material.time);
    }

    <span class="hljs-comment">// Transform to world space</span>
    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
    <span class="hljs-keyword">let</span> world_position = mesh_functions::mesh_position_local_to_world(
        model,
        vec4&lt;<span class="hljs-built_in">f32</span>&gt;(displaced_position, <span class="hljs-number">1.0</span>)
    );

    <span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(
        final_normal,
        <span class="hljs-keyword">in</span>.instance_index
    );

    <span class="hljs-comment">// Transform to clip space</span>
    out.clip_position = position_world_to_clip(world_position.xyz);
    out.world_position = world_position.xyz;
    out.world_normal = normalize(world_normal);
    out.uv = <span class="hljs-keyword">in</span>.uv;

    <span class="hljs-keyword">return</span> out;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);

    <span class="hljs-comment">// Simple directional lighting</span>
    <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.0</span>, dot(normal, light_dir)) * <span class="hljs-number">0.7</span>;
    <span class="hljs-keyword">let</span> ambient = <span class="hljs-number">0.3</span>;

    <span class="hljs-comment">// Base color - simple flag pattern</span>
    <span class="hljs-keyword">let</span> stripe_frequency = <span class="hljs-number">8.0</span>;
    <span class="hljs-keyword">let</span> stripe_pattern = step(<span class="hljs-number">0.5</span>, fract(<span class="hljs-keyword">in</span>.uv.y * stripe_frequency));
    <span class="hljs-keyword">let</span> color1 = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.8</span>, <span class="hljs-number">0.2</span>, <span class="hljs-number">0.2</span>); <span class="hljs-comment">// Red</span>
    <span class="hljs-keyword">let</span> color2 = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.9</span>, <span class="hljs-number">0.9</span>, <span class="hljs-number">0.9</span>); <span class="hljs-comment">// White</span>
    <span class="hljs-keyword">let</span> base_color = mix(color1, color2, stripe_pattern);

    <span class="hljs-comment">// Add slight color variation based on displacement</span>
    <span class="hljs-keyword">let</span> displacement_tint = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>) * (<span class="hljs-number">1.0</span> + <span class="hljs-keyword">in</span>.displacement_amount * <span class="hljs-number">0.2</span>);

    <span class="hljs-comment">// Combine lighting and color</span>
    <span class="hljs-keyword">let</span> lit_color = base_color * displacement_tint * (ambient + diffuse);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(lit_color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0205flagsimulationrs">The Rust Material (<code>src/materials/d02_05_flag_simulation.rs</code>)</h3>
<p>This file defines the bridge between our Bevy app and the shader. It contains the <code>FlagUniforms</code> struct, which matches the shader's uniform block, and the <code>FlagMaterial</code> struct which implements Bevy's <code>Material</code> trait. Critically, it includes the <code>specialize</code> function override to disable backface culling, ensuring both sides of our flag are rendered. It also provides the helper function to generate the noise texture.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::pbr::MaterialPipelineKey;
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::mesh::MeshVertexBufferLayoutRef;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};
<span class="hljs-keyword">use</span> bevy::render::render_resource::{RenderPipelineDescriptor, SpecializedMeshPipelineError};
<span class="hljs-keyword">use</span> noise::{NoiseFn, Perlin};

<span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>

    <span class="hljs-keyword">use</span> bevy::prelude::*;
    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">FlagMaterial</span></span> {
        <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> wind_strength: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> wind_direction: Vec2,
        <span class="hljs-keyword">pub</span> wave_frequency: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> wave_amplitude: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> turbulence_strength: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> gravity_strength: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> flag_stiffness: <span class="hljs-built_in">f32</span>,
    }

    <span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> FlagMaterial {
        <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
            <span class="hljs-keyword">Self</span> {
                time: <span class="hljs-number">0.0</span>,
                wind_strength: <span class="hljs-number">1.0</span>,
                wind_direction: Vec2::new(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>),
                wave_frequency: <span class="hljs-number">2.0</span>,
                wave_amplitude: <span class="hljs-number">0.3</span>,
                turbulence_strength: <span class="hljs-number">0.15</span>,
                gravity_strength: <span class="hljs-number">0.2</span>,
                flag_stiffness: <span class="hljs-number">0.5</span>,
            }
        }
    }
}

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">use</span> uniforms::FlagMaterial <span class="hljs-keyword">as</span> FlagUniforms;

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">FlagMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: FlagUniforms,

    <span class="hljs-meta">#[texture(1)]</span>
    <span class="hljs-meta">#[sampler(2)]</span>
    <span class="hljs-keyword">pub</span> noise_texture: Handle&lt;Image&gt;,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> FlagMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_05_flag_simulation.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_05_flag_simulation.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">specialize</span></span>(
        _pipeline: &amp;bevy::pbr::MaterialPipeline&lt;<span class="hljs-keyword">Self</span>&gt;,
        descriptor: &amp;<span class="hljs-keyword">mut</span> RenderPipelineDescriptor,
        _layout: &amp;MeshVertexBufferLayoutRef,
        _key: MaterialPipelineKey&lt;<span class="hljs-keyword">Self</span>&gt;,
    ) -&gt; <span class="hljs-built_in">Result</span>&lt;(), SpecializedMeshPipelineError&gt; {
        <span class="hljs-comment">// Disable backface culling for double-sided rendering</span>
        <span class="hljs-comment">// Flags are thin surfaces visible from both sides</span>
        descriptor.primitive.cull_mode = <span class="hljs-literal">None</span>;
        <span class="hljs-literal">Ok</span>(())
    }
}

<span class="hljs-comment">// Helper function to generate a Perlin noise texture</span>
<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">generate_noise_texture</span></span>(size: <span class="hljs-built_in">u32</span>) -&gt; Image {
    <span class="hljs-keyword">let</span> perlin = Perlin::new(<span class="hljs-number">42</span>);
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> data = <span class="hljs-built_in">Vec</span>::with_capacity((size * size * <span class="hljs-number">4</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">usize</span>);

    <span class="hljs-keyword">for</span> y <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..size {
        <span class="hljs-keyword">for</span> x <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..size {
            <span class="hljs-keyword">let</span> nx = x <span class="hljs-keyword">as</span> <span class="hljs-built_in">f64</span> / size <span class="hljs-keyword">as</span> <span class="hljs-built_in">f64</span>;
            <span class="hljs-keyword">let</span> ny = y <span class="hljs-keyword">as</span> <span class="hljs-built_in">f64</span> / size <span class="hljs-keyword">as</span> <span class="hljs-built_in">f64</span>;

            <span class="hljs-comment">// Sample Perlin noise at multiple scales</span>
            <span class="hljs-keyword">let</span> noise_value = perlin.get([nx * <span class="hljs-number">4.0</span>, ny * <span class="hljs-number">4.0</span>]);

            <span class="hljs-comment">// Remap from [-1, 1] to [0, 255]</span>
            <span class="hljs-keyword">let</span> byte_value = ((noise_value + <span class="hljs-number">1.0</span>) * <span class="hljs-number">0.5</span> * <span class="hljs-number">255.0</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">u8</span>;

            <span class="hljs-comment">// RGBA format</span>
            data.push(byte_value);
            data.push(byte_value);
            data.push(byte_value);
            data.push(<span class="hljs-number">255</span>);
        }
    }

    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> image = Image::new(
        bevy::render::render_resource::Extent3d {
            width: size,
            height: size,
            depth_or_array_layers: <span class="hljs-number">1</span>,
        },
        bevy::render::render_resource::TextureDimension::D2,
        data,
        bevy::render::render_resource::TextureFormat::Rgba8Unorm,
        bevy::render::render_asset::RenderAssetUsages::default(),
    );

    <span class="hljs-comment">// Set sampler to repeat - the default linear filtering is fine</span>
    image.sampler = bevy::image::ImageSampler::Descriptor(bevy::image::ImageSamplerDescriptor {
        address_mode_u: bevy::image::ImageAddressMode::Repeat,
        address_mode_v: bevy::image::ImageAddressMode::Repeat,
        ..<span class="hljs-built_in">Default</span>::default()
    });

    image
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other materials</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_05_flag_simulation;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0205flagsimulationrs">The Demo Module (<code>src/demos/d02_05_flag_simulation.rs</code>)</h3>
<p>The Bevy application is responsible for setting up the scene and controlling the simulation. The <code>setup</code> system creates the camera, light, a simple cylinder for the flagpole, and our flag entity. Crucially, it generates a highly subdivided plane mesh to ensure we have enough vertices for smooth deformation. The <code>handle_input</code> and <code>update_ui</code> systems allow us to interactively tweak the material's uniform values using the keyboard, providing real-time feedback on how each parameter affects the simulation.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d02_05_flag_simulation::{FlagMaterial, FlagUniforms, generate_noise_texture};
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> std::<span class="hljs-built_in">f32</span>::consts::PI;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;FlagMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(Update, (update_time, handle_input, update_ui))
        .run();
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;FlagMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> standard_materials: ResMut&lt;Assets&lt;StandardMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> images: ResMut&lt;Assets&lt;Image&gt;&gt;,
) {
    <span class="hljs-comment">// Generate noise texture</span>
    <span class="hljs-keyword">let</span> noise_texture = images.add(generate_noise_texture(<span class="hljs-number">256</span>));

    <span class="hljs-comment">// Create flag mesh (plane subdivided for smooth deformation)</span>
    <span class="hljs-keyword">let</span> flag_mesh = create_flag_mesh(<span class="hljs-number">40</span>, <span class="hljs-number">20</span>, <span class="hljs-number">4.0</span>, <span class="hljs-number">2.0</span>);

    <span class="hljs-comment">// Spawn flag</span>
    commands.spawn((
        Mesh3d(meshes.add(flag_mesh)),
        MeshMaterial3d(materials.add(FlagMaterial {
            uniforms: FlagUniforms::default(),
            noise_texture: noise_texture.clone(),
        })),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
    ));

    <span class="hljs-comment">// Add a flag pole (simple cylinder)</span>
    commands.spawn((
        Mesh3d(meshes.add(Cylinder::new(<span class="hljs-number">0.05</span>, <span class="hljs-number">3.0</span>))),
        MeshMaterial3d(standard_materials.add(StandardMaterial {
            base_color: Color::srgb(<span class="hljs-number">0.3</span>, <span class="hljs-number">0.2</span>, <span class="hljs-number">0.1</span>),
            ..default()
        })),
        Transform::from_xyz(-<span class="hljs-number">2.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
    ));

    <span class="hljs-comment">// Lighting</span>
    commands.spawn((
        DirectionalLight {
            illuminance: <span class="hljs-number">10000.0</span>,
            shadows_enabled: <span class="hljs-literal">false</span>,
            ..default()
        },
        Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -PI / <span class="hljs-number">4.0</span>, PI / <span class="hljs-number">4.0</span>, <span class="hljs-number">0.0</span>)),
    ));

    <span class="hljs-comment">// Camera</span>
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(<span class="hljs-number">4.0</span>, <span class="hljs-number">2.0</span>, <span class="hljs-number">6.0</span>).looking_at(Vec3::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>), Vec3::Y),
    ));

    <span class="hljs-comment">// UI</span>
    commands.spawn((
        Text::new(
            <span class="hljs-string">"[W/S] Wind Strength | [A/D] Wind Direction | [Q/E] Wave Frequency\n\
             [Z/X] Gravity | [C/V] Turbulence | [R] Reset\n\
             \n\
             Wind: 1.0 | Direction: â†' | Frequency: 2.0 | Gravity: 0.2 | Turbulence: 0.15"</span>,
        ),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">10.0</span>),
            left: Val::Px(<span class="hljs-number">10.0</span>),
            ..default()
        },
        TextFont {
            font_size: <span class="hljs-number">16.0</span>,
            ..default()
        },
    ));
}

<span class="hljs-comment">// Create a subdivided plane mesh for the flag</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">create_flag_mesh</span></span>(width_segments: <span class="hljs-built_in">u32</span>, height_segments: <span class="hljs-built_in">u32</span>, width: <span class="hljs-built_in">f32</span>, height: <span class="hljs-built_in">f32</span>) -&gt; Mesh {
    <span class="hljs-keyword">use</span> bevy::render::mesh::{Indices, PrimitiveTopology};
    <span class="hljs-keyword">use</span> bevy::render::render_asset::RenderAssetUsages;

    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> positions = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> normals = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> uvs = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> indices = <span class="hljs-built_in">Vec</span>::new();

    <span class="hljs-comment">// Generate vertices</span>
    <span class="hljs-keyword">for</span> y <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..=height_segments {
        <span class="hljs-keyword">for</span> x <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..=width_segments {
            <span class="hljs-keyword">let</span> u = x <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / width_segments <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span>;
            <span class="hljs-keyword">let</span> v = y <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / height_segments <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span>;

            <span class="hljs-comment">// Position centered at origin, extending in +X direction</span>
            <span class="hljs-keyword">let</span> pos_x = (u - <span class="hljs-number">0.5</span>) * width;
            <span class="hljs-keyword">let</span> pos_y = (v - <span class="hljs-number">0.5</span>) * height;
            <span class="hljs-keyword">let</span> pos_z = <span class="hljs-number">0.0</span>;

            positions.push([pos_x, pos_y, pos_z]);
            normals.push([<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>]); <span class="hljs-comment">// Face forward</span>
            uvs.push([u, v]);
        }
    }

    <span class="hljs-comment">// Generate indices</span>
    <span class="hljs-keyword">for</span> y <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..height_segments {
        <span class="hljs-keyword">for</span> x <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..width_segments {
            <span class="hljs-keyword">let</span> quad_start = y * (width_segments + <span class="hljs-number">1</span>) + x;

            <span class="hljs-comment">// First triangle</span>
            indices.push(quad_start);
            indices.push(quad_start + width_segments + <span class="hljs-number">1</span>);
            indices.push(quad_start + <span class="hljs-number">1</span>);

            <span class="hljs-comment">// Second triangle</span>
            indices.push(quad_start + <span class="hljs-number">1</span>);
            indices.push(quad_start + width_segments + <span class="hljs-number">1</span>);
            indices.push(quad_start + width_segments + <span class="hljs-number">2</span>);
        }
    }

    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> mesh = Mesh::new(
        PrimitiveTopology::TriangleList,
        RenderAssetUsages::default(),
    );

    mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
    mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
    mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
    mesh.insert_indices(Indices::U32(indices));

    mesh
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_time</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;FlagMaterial&gt;&gt;) {
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        material.uniforms.time = time.elapsed_secs();
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    time: Res&lt;Time&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;FlagMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> delta = time.delta_secs();

    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-comment">// Wind strength</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyW) {
            material.uniforms.wind_strength = (material.uniforms.wind_strength + delta).min(<span class="hljs-number">3.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyS) {
            material.uniforms.wind_strength = (material.uniforms.wind_strength - delta).max(<span class="hljs-number">0.0</span>);
        }

        <span class="hljs-comment">// Wind direction (rotate)</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyA) {
            <span class="hljs-keyword">let</span> angle = material
                .uniforms
                .wind_direction
                .y
                .atan2(material.uniforms.wind_direction.x);
            <span class="hljs-keyword">let</span> new_angle = angle + delta;
            material.uniforms.wind_direction = Vec2::new(new_angle.cos(), new_angle.sin());
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyD) {
            <span class="hljs-keyword">let</span> angle = material
                .uniforms
                .wind_direction
                .y
                .atan2(material.uniforms.wind_direction.x);
            <span class="hljs-keyword">let</span> new_angle = angle - delta;
            material.uniforms.wind_direction = Vec2::new(new_angle.cos(), new_angle.sin());
        }

        <span class="hljs-comment">// Wave frequency</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyQ) {
            material.uniforms.wave_frequency =
                (material.uniforms.wave_frequency - delta * <span class="hljs-number">2.0</span>).max(<span class="hljs-number">0.5</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyE) {
            material.uniforms.wave_frequency =
                (material.uniforms.wave_frequency + delta * <span class="hljs-number">2.0</span>).min(<span class="hljs-number">10.0</span>);
        }

        <span class="hljs-comment">// Gravity</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyZ) {
            material.uniforms.gravity_strength =
                (material.uniforms.gravity_strength - delta * <span class="hljs-number">0.5</span>).max(<span class="hljs-number">0.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyX) {
            material.uniforms.gravity_strength =
                (material.uniforms.gravity_strength + delta * <span class="hljs-number">0.5</span>).min(<span class="hljs-number">1.0</span>);
        }

        <span class="hljs-comment">// Turbulence</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyC) {
            material.uniforms.turbulence_strength =
                (material.uniforms.turbulence_strength - delta * <span class="hljs-number">0.3</span>).max(<span class="hljs-number">0.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyV) {
            material.uniforms.turbulence_strength =
                (material.uniforms.turbulence_strength + delta * <span class="hljs-number">0.3</span>).min(<span class="hljs-number">0.5</span>);
        }

        <span class="hljs-comment">// Reset</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::KeyR) {
            *material = FlagMaterial {
                uniforms: FlagUniforms::default(),
                noise_texture: material.noise_texture.clone(),
            };
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(materials: Res&lt;Assets&lt;FlagMaterial&gt;&gt;, <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;) {
    <span class="hljs-keyword">if</span> !materials.is_changed() {
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, material)) = materials.iter().next() {
        <span class="hljs-comment">// Calculate wind direction as compass direction</span>
        <span class="hljs-keyword">let</span> angle = material
            .uniforms
            .wind_direction
            .y
            .atan2(material.uniforms.wind_direction.x);
        <span class="hljs-keyword">let</span> angle_degrees = angle.to_degrees();
        <span class="hljs-keyword">let</span> direction_arrow = <span class="hljs-keyword">if</span> angle_degrees.abs() &lt; <span class="hljs-number">45.0</span> {
            <span class="hljs-string">"&gt;"</span>
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> angle_degrees &gt; <span class="hljs-number">45.0</span> &amp;&amp; angle_degrees &lt; <span class="hljs-number">135.0</span> {
            <span class="hljs-string">"^"</span>
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> angle_degrees.abs() &gt; <span class="hljs-number">135.0</span> {
            <span class="hljs-string">"&lt;"</span>
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-string">"v"</span>
        };

        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
            **text = <span class="hljs-built_in">format!</span>(
                <span class="hljs-string">"[W/S] Wind Strength | [A/D] Wind Direction | [Q/E] Wave Frequency\n\
                 [Z/X] Gravity | [C/V] Turbulence | [R] Reset\n\
                 \n\
                 Wind: {:.1} | Direction: {} ({:.0} deg) | Frequency: {:.1}\n\
                 Gravity: {:.2} | Turbulence: {:.2}"</span>,
                material.uniforms.wind_strength,
                direction_arrow,
                angle_degrees,
                material.uniforms.wave_frequency,
                material.uniforms.gravity_strength,
                material.uniforms.turbulence_strength,
            );
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other demos</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_05_flag_simulation;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<p>codeRust</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"2.5"</span>,
    title: <span class="hljs-string">"Advanced Vertex Displacement"</span>,
    run: demos::d02_05_flag_simulation::run,
},
</code></pre>
<p><strong>Important</strong>: You'll need to add the <code>noise</code> crate to your <code>Cargo.toml</code>:</p>
<pre><code class="lang-toml"><span class="hljs-section">[dependencies]</span>
<span class="hljs-attr">noise</span> = <span class="hljs-string">"0.9"</span>
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>When you run the project, you'll see a flag attached to a pole, waving with a complex and natural-looking motion. Use the controls to experiment with the different forces acting upon it. This interactivity is key to developing an intuition for how these layered effects combine.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key(s)</td><td>Action</td><td>Effect</td></tr>
</thead>
<tbody>
<tr>
<td><strong>W / S</strong></td><td>Wind Strength</td><td>Increases or decreases the overall power of the wind.</td></tr>
<tr>
<td><strong>A / D</strong></td><td>Wind Direction</td><td>Rotates the direction the wind is blowing from.</td></tr>
<tr>
<td><strong>Q / E</strong></td><td>Wave Frequency</td><td>Changes the size of the main waves in the flag.</td></tr>
<tr>
<td><strong>Z / X</strong></td><td>Gravity</td><td>Increases or decreases the downward pull on the flag.</td></tr>
<tr>
<td><strong>C / V</strong></td><td>Turbulence</td><td>Adjusts the amount of chaotic, noisy flutter.</td></tr>
<tr>
<td><strong>R</strong></td><td>Reset</td><td>Returns all simulation parameters to their default values.</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762974353247/08bd97e0-f620-4874-b8f1-1e4a0532b6f4.png" alt class="image--center mx-auto" /></p>
<p>Observe how the different layers of motion interact:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Condition</td><td>What to Observe</td><td>Concept Illustrated</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Low Wind, High Gravity</strong></td><td>The flag droops realistically, with only minor flutters.</td><td><code>calculate_gravity_effect</code> dominates the final displacement.</td></tr>
<tr>
<td><strong>High Wind, Low Turbulence</strong></td><td>The flag makes large, smooth, rolling waves.</td><td>The multi-octave sine waves from <code>calculate_wind_displacement</code> are the primary movers.</td></tr>
<tr>
<td><strong>High Wind, High Turbulence</strong></td><td>The motion becomes much more chaotic and violent, with small ripples appearing all over the surface.</td><td><code>calculate_turbulence</code> adds high-frequency noise, breaking up the smooth sine waves.</td></tr>
<tr>
<td><strong>Any Mode</strong></td><td>The left edge of the flag remains perfectly still, while the right edge moves the most dramatically.</td><td><strong>UV-Based Logic.</strong> The <code>is_pinned</code> check and <code>edge_influence</code> scaling control the effect's strength across the surface.</td></tr>
<tr>
<td><strong>Rotate the Camera</strong></td><td>As the flag folds and curves, the lighting on its surface changes dynamically and correctly.</td><td><strong>Normal Recalculation.</strong> <code>calculate_flag_normal</code> is successfully updating normals to match the new geometry.</td></tr>
</tbody>
</table>
</div><h2 id="heading-key-takeaways">Key Takeaways</h2>
<ol>
<li><p><strong>Complexity from Layers:</strong> The core principle of advanced displacement is creating complex results by adding simple layers together (e.g., waves + noise + gravity).</p>
</li>
<li><p><strong>Noise is Essential for Realism:</strong> Procedural noise, sampled from a texture, is the key to breaking up repetitive patterns and adding organic, unpredictable motion.</p>
</li>
<li><p><strong>Use UVs for Spatial Logic:</strong> UV coordinates are not just for textures. They are a powerful tool for controlling shader effects based on a vertex's position on the mesh surface (e.g., pinning the flag's edge).</p>
</li>
<li><p><strong>Normals Must Be Updated:</strong> If you significantly displace vertices, you must also recalculate their normals to ensure the object's lighting remains correct.</p>
</li>
<li><p><strong>Address Culling Issues:</strong> Be mindful of frustum culling for large displacements (<code>NoFrustumCulling</code>) and always disable backface culling (cull_mode = None) for thin, two-sided surfaces.</p>
</li>
<li><p><strong>textureSampleLevel is Mandatory:</strong> Remember that you <strong>must</strong> use <code>textureSampleLevel</code> (not <code>textureSample</code>) when sampling textures in a vertex shader.</p>
</li>
<li><p><strong>Optimize Intelligently:</strong> Keep expensive calculations on the CPU, use LODs for distant objects, and understand the trade-offs of branching in your shader code.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>You are now equipped with a powerful arsenal of techniques for manipulating geometry directly on the GPU. You can make surfaces breathe, ripple, twist, and wave in complex and believable ways.</p>
<p>We have spent this chapter shaping the canvas; now, it's time to learn how to paint on it. In the next phase of the curriculum, we will shift our focus from the vertex shader to the <strong>fragment shader</strong>, exploring the rich world of colors, procedural patterns, and lighting models.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/26-normal-vector-transformation"><strong><em>2.6 - Normal Vector Transformation</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<h3 id="heading-core-concepts-for-effect-design">Core Concepts for Effect Design</h3>
<ul>
<li><p><strong>Layering Creates Complexity:</strong> The most important principle. Create rich, believable motion by adding simple, independent effects together (e.g., broad waves + fine noise + physical forces like gravity).</p>
</li>
<li><p><strong>Use Frequencies for Detail:</strong> Stack waves or noise at different scales (octaves). Low frequencies create the large, main motion, while high frequencies add the small, realistic surface details.</p>
</li>
<li><p><strong>Noise Breaks Repetition:</strong> Sine waves are predictable and repetitive. Use noise textures to introduce organic, chaotic, and non-repeating variations that make motion feel natural.</p>
</li>
<li><p><strong>UVs for Spatial Control:</strong> Use a vertex's UV coordinates to control where on a mesh an effect is applied. This is the key to pinning one edge of a flag or making an effect fade in across a surface.</p>
</li>
</ul>
<h3 id="heading-essential-technical-rules">Essential Technical Rules</h3>
<ul>
<li><p><strong>Recalculate Normals for Correct Lighting:</strong> If your shader displaces vertices, it <strong>must</strong> also calculate a new normal vector. If you don't, the lighting on your deformed object will be flat and incorrect.</p>
</li>
<li><p><strong>Handle Culling for Correct Visibility:</strong></p>
<ul>
<li><p>For thin, two-sided objects (like flags or leaves), you <strong>must</strong> disable backface culling in your Rust <code>Material</code> (<code>cull_mode = None</code>), or the back will be invisible.</p>
</li>
<li><p>For very large displacements, you may need to add the <code>NoFrustumCulling</code> component in Rust to prevent the object from disappearing when it moves outside its original bounds.</p>
</li>
</ul>
</li>
<li><p><strong>Vertex Shaders Require textureSampleLevel:</strong> When sampling a texture in a vertex shader, you <strong>must</strong> use this specific function. The more common <code>textureSample</code> will not compile.</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[2.4 - Simple Vertex Deformations]]></title><description><![CDATA[What We're Learning
So far in our journey, we've learned how to take a mesh - a static, rigid collection of vertices - and move, rotate, and scale it as a single unit. We've mastered the transformation pipeline that places our objects in the world an...]]></description><link>https://blog.hexbee.net/24-simple-vertex-deformations</link><guid isPermaLink="true">https://blog.hexbee.net/24-simple-vertex-deformations</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[Rust]]></category><category><![CDATA[shader]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Sat, 15 Nov 2025 10:33:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1762818365238/62a9ee22-6cc7-4ab5-ba1c-5f4480ebf5ce.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>So far in our journey, we've learned how to take a mesh - a static, rigid collection of vertices - and move, rotate, and scale it as a single unit. We've mastered the transformation pipeline that places our objects in the world and projects them onto our screen. But what if we want our objects to do more than just move? What if we want them to live, breathe, and react?</p>
<p>This is where vertex deformation comes in. It is the art and science of manipulating the individual vertices of a mesh in real-time, directly on the GPU. Instead of treating a mesh as a rigid statue, we treat it as a malleable digital clay, changing its very shape every single frame.</p>
<p>This technique is one of the most powerful and efficient tools in a graphics programmer's arsenal. While the CPU would have to laboriously loop through every vertex one by one, the GPU can process thousands or even millions of them simultaneously. It's like having an army of tiny, perfectly synchronized robots, each responsible for moving a single point on your model's surface. The result is the ability to create complex, organic motion - like waving flags, rippling water, or pulsating creatures - with just a few lines of mathematical code.</p>
<p>By the end of this chapter, you will have a practical understanding of this fundamental technique. You will learn:</p>
<ul>
<li><p>How to use sine waves to create waving and rippling effects.</p>
</li>
<li><p>How to implement uniform scaling to create pulsating and breathing animations.</p>
</li>
<li><p>The critical difference between deforming an object in its own local space versus in world space.</p>
</li>
<li><p>How to correctly update surface normals to ensure lighting remains realistic on a changing surface.</p>
</li>
<li><p>How to use the <code>@builtin(instance_index)</code> to give unique behaviors to many copies of the same mesh.</p>
</li>
<li><p>The performance implications of vertex shader operations and how to keep your effects running smoothly.</p>
</li>
<li><p>How to synthesize all these concepts into a complete, interactive pulsating sphere animation.</p>
</li>
</ul>
<h2 id="heading-the-fundamentals-of-vertex-deformation">The Fundamentals of Vertex Deformation</h2>
<p>At its heart, vertex deformation is a beautifully simple concept. For every single vertex in a mesh, we apply one formula:</p>
<pre><code class="lang-plaintext">Deformed Position = Original Position + Offset
</code></pre>
<p>The <code>Original Position</code> is the vertex data read directly from the mesh buffer - the static, unchanging blueprint of your model. The magic lies in how we calculate the <code>Offset</code>. This offset is a <code>vec3&lt;f32&gt;</code> that we compute on-the-fly in the vertex shader, pushing the vertex in a specific direction to change the model's shape.</p>
<p>The function to calculate this offset is the creative core of our effect. It can be based on anything we have access to in the vertex shader:</p>
<ul>
<li><p>The vertex's own position (e.g., vertices higher up move more).</p>
</li>
<li><p>A uniform value like <code>time</code> to create animation.</p>
</li>
<li><p>Built-in values like <code>instance_index</code> for per-object variation.</p>
</li>
<li><p>Mathematical functions like <code>sin()</code> to create waves.</p>
</li>
</ul>
<pre><code class="lang-rust"><span class="hljs-comment">// Read the original position from the mesh attribute.</span>
<span class="hljs-keyword">let</span> original_position = <span class="hljs-keyword">in</span>.position;

<span class="hljs-comment">// Calculate an offset vector. This is where the magic happens!</span>
<span class="hljs-keyword">let</span> offset = calculate_offset(original_position, material.time, other_params);

<span class="hljs-comment">// Apply the offset to get the new, deformed position.</span>
<span class="hljs-keyword">let</span> deformed_position = original_position + offset;

<span class="hljs-comment">// Now, continue with the rest of the transformation pipeline.</span>
<span class="hljs-keyword">let</span> model_matrix = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
<span class="hljs-keyword">let</span> world_position = model_matrix * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(deformed_position, <span class="hljs-number">1.0</span>);
<span class="hljs-comment">// ...and so on.</span>
</code></pre>
<h3 id="heading-the-vertex-deformation-pipeline">The Vertex Deformation Pipeline</h3>
<p>To understand where this logic fits, let's update the transformation pipeline we've learned so far. Deformation is a new step that happens right at the beginning, after reading the vertex data but before applying any matrix transformations.</p>
<pre><code class="lang-plaintext">1. Read vertex attributes from mesh (local/model space)
   ↓
2. APPLY DEFORMATION (still in local space)  &lt;-- We are here!
   ↓
3. Transform to world space (using the model matrix)
   ↓
4. Transform to view space (using the view matrix)
   ↓
5. Transform to clip space (using the projection matrix)
</code></pre>
<p><strong>Key Insight:</strong> For most effects, we perform deformations in <strong>local space</strong>. Think of it this way: you are modifying the object's fundamental blueprint before you place it in the world. The pulsation of a sphere is part of the sphere's nature, regardless of where it is. A flag's waving motion is relative to its flagpole, not its world coordinates.</p>
<p>By deforming in local space, the effect becomes an intrinsic part of the object that moves, rotates, and scales with it naturally. We will explore the crucial distinction between local and world-space deformations in detail later in this article. For now, know that local space is our default and most powerful starting point.</p>
<h2 id="heading-simple-sine-wave-deformation">Simple Sine Wave Deformation</h2>
<p>The "Hello, World!" of vertex deformation is the sine wave. If you've ever seen a flag wave, water ripple, or a piece of cloth gently sway in a game, you've likely seen the <code>sin()</code> function at work. It is the cornerstone of procedural animation because it produces a smooth, predictable, and endlessly repeating oscillation that looks natural and is incredibly cheap for the GPU to calculate.</p>
<h3 id="heading-the-basic-sine-wave">The Basic Sine Wave</h3>
<p>Let's start by making a flat plane mesh wave up and down. The core logic is a single line of code added to our vertex shader.</p>
<pre><code class="lang-rust">@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(
    <span class="hljs-comment">// ... other inputs</span>
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-comment">// --- Start of Deformation ---</span>
    var deformed_position = position;

    <span class="hljs-comment">// Displace the Y coordinate of the vertex based on its X position and time.</span>
    deformed_position.y += sin(position.x * <span class="hljs-number">3.0</span> + material.time * <span class="hljs-number">2.0</span>) * <span class="hljs-number">0.2</span>;
    <span class="hljs-comment">// --- End of Deformation ---</span>

    <span class="hljs-comment">// Continue with the standard MVP transformation pipeline</span>
    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
    <span class="hljs-keyword">let</span> world_position = model * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(deformed_position, <span class="hljs-number">1.0</span>);
    out.clip_position = position_world_to_clip(world_position.xyz);

    <span class="hljs-comment">// ... set other outputs</span>
    <span class="hljs-keyword">return</span> out;
}
</code></pre>
<p>Let's break down that one magical line: <code>deformed_position.y += sin(position.x * 3.0 + material.time * 2.0) * 0.2;</code></p>
<ul>
<li><p><code>sin(...)</code>: This function takes an input number and returns a smooth value that oscillates between <code>-1.0</code> and <code>1.0</code>.</p>
</li>
<li><p><code>position.x * 3.0</code>: We feed the vertex's own <code>x</code> coordinate into the sine function. This means the height of the wave depends on where the vertex is along the X-axis. The multiplier (<code>3.0</code>) is the <strong>frequency</strong> - it controls how many wave crests appear across the mesh.</p>
</li>
<li><p><code>+ material.time * 2.0</code>: We add the elapsed time to the input. As time increases, the whole wave pattern shifts, creating the illusion of movement. The multiplier (2.0) is the <strong>speed</strong>.</p>
</li>
<li><p><code>* 0.2</code>: We take the result of <code>sin()</code> (which is between <code>-1.0</code> and <code>1.0</code>) and scale it. This is the <strong>amplitude</strong> - it controls how high and low the wave peaks are.</p>
</li>
<li><p><code>deformed_position.y += ...</code>: We apply this final calculated offset only to the <code>y</code> component of the position, making the mesh wave up and down.</p>
</li>
</ul>
<p>Visually, this turns a flat line of vertices into a smoothly oscillating curve.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764413502382/05413e23-d41e-4b88-b41a-d5298096f82e.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-understanding-sine-wave-parameters">Understanding Sine Wave Parameters</h3>
<p>To master vertex animation, you need to develop an intuition for how each part of the sine formula affects the final result. The general formula is:</p>
<pre><code class="lang-plaintext">offset = amplitude * sin(frequency * input_position + speed * time + phase)
</code></pre>
<p>Let's visualize what each parameter does:</p>
<h4 id="heading-amplitude">Amplitude</h4>
<p>This is the scaler applied <em>after</em> the <code>sin()</code> function. It controls the intensity or "height" of the wave.</p>
<pre><code class="lang-rust">**<span class="hljs-comment">// Small, subtle waves (low amplitude)</span>
<span class="hljs-keyword">let</span> offset = sin(...) * <span class="hljs-number">0.1</span>;

<span class="hljs-comment">// Big, dramatic waves (high amplitude)</span>
<span class="hljs-keyword">let</span> offset = sin(...) * <span class="hljs-number">0.5</span>;**
</code></pre>
<h4 id="heading-frequency">Frequency</h4>
<p>This is the scaler applied to the vertex <code>position</code> <em>inside</em> the <code>sin()</code> function. It controls the density or "tightness" of the waves.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A few broad waves (low frequency)</span>
<span class="hljs-keyword">let</span> offset = sin(position.x * <span class="hljs-number">1.0</span> + time) * <span class="hljs-number">0.2</span>;

<span class="hljs-comment">// Many tight waves (high frequency)</span>
<span class="hljs-keyword">let</span> offset = sin(position.x * <span class="hljs-number">10.0</span> + time) * <span class="hljs-number">0.2</span>;
</code></pre>
<h4 id="heading-speed">Speed</h4>
<p>This is the scaler applied to <code>time</code>. It controls how fast the wave pattern moves across the mesh.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A slow, gentle wave (low speed)</span>
<span class="hljs-keyword">let</span> offset = sin(position.x * <span class="hljs-number">3.0</span> + time * <span class="hljs-number">0.5</span>) * <span class="hljs-number">0.2</span>;

<span class="hljs-comment">// A fast, energetic wave (high speed)</span>
<span class="hljs-keyword">let</span> offset = sin(position.x * <span class="hljs-number">3.0</span> + time * <span class="hljs-number">5.0</span>) * <span class="hljs-number">0.2</span>;
</code></pre>
<h4 id="heading-phase">Phase</h4>
<p>This is a constant offset added inside the <code>sin()</code> function. It doesn't change the shape of the wave, only its starting position. This is incredibly useful for making multiple objects animate out of sync, which we'll see later with <code>instance_index</code>.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Shift the wave's starting point</span>
<span class="hljs-keyword">let</span> phase_offset = <span class="hljs-number">1.57</span>; <span class="hljs-comment">// ~PI / 2, or a 90-degree shift</span>
<span class="hljs-keyword">let</span> offset = sin(position.x * <span class="hljs-number">3.0</span> + time + phase_offset) * <span class="hljs-number">0.2</span>;
</code></pre>
<h3 id="heading-building-complexity">Building Complexity</h3>
<p>You can create far more interesting effects by combining these simple building blocks.</p>
<h4 id="heading-multi-directional-waves">Multi-Directional Waves</h4>
<p>What happens if you calculate a wave based on <code>position.x</code> and another based on <code>position.z</code> and add them together? You get a beautiful interference pattern, exactly like ripples intersecting on the surface of a pond.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A wave moving along the X-axis</span>
<span class="hljs-keyword">let</span> wave_x = sin(position.x * <span class="hljs-number">3.0</span> + time * <span class="hljs-number">2.0</span>) * <span class="hljs-number">0.1</span>;

<span class="hljs-comment">// A wave moving along the Z-axis with a different speed and frequency</span>
<span class="hljs-keyword">let</span> wave_z = sin(position.z * <span class="hljs-number">4.0</span> + time * <span class="hljs-number">1.5</span>) * <span class="hljs-number">0.1</span>;

<span class="hljs-comment">// The final offset is the sum of both contributions</span>
deformed_position.y += wave_x + wave_z;
</code></pre>
<h4 id="heading-directional-displacement">Directional Displacement</h4>
<p>The deformation offset is a vector. We've only been modifying the <code>.y</code> component, but we can modify any axis we want to create different kinds of motion.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Displace horizontally to create a shearing effect</span>
deformed_position.x += sin(position.y * <span class="hljs-number">5.0</span> + time) * <span class="hljs-number">0.2</span>;

<span class="hljs-comment">// Create a twist effect by rotating XZ coordinates based on height (Y)</span>
<span class="hljs-keyword">let</span> twist_angle = position.y * <span class="hljs-number">2.0</span> + time;
<span class="hljs-keyword">let</span> radius = length(position.xz); <span class="hljs-comment">// Preserve original radius from center</span>
deformed_position.x = cos(twist_angle) * radius;
deformed_position.z = sin(twist_angle) * radius;

<span class="hljs-comment">// Create a radial pulse that expands from the center Y-axis</span>
<span class="hljs-keyword">let</span> distance_from_center = length(position.xz);
<span class="hljs-keyword">let</span> pulse = sin(distance_from_center * <span class="hljs-number">5.0</span> - time * <span class="hljs-number">3.0</span>) * <span class="hljs-number">0.1</span>;
deformed_position.y += pulse;
</code></pre>
<p>By creatively combining these simple mathematical tools, you can produce a massive variety of organic and compelling visual effects.</p>
<pre><code>
## Scaling <span class="hljs-keyword">from</span> Center

Another core deformation technique is scaling vertices relative to the model<span class="hljs-string">'s origin. Instead of adding an offset, we **multiply** the vertex position by a scalar value. This pushes vertices further away from (or pulls them closer to) the center, making the object appear to grow, shrink, or breathe.

### Simple Uniform Scaling

Uniform scaling applies the same scaling factor to all axes (`x`, `y`, and `z`), preserving the object'</span>s proportions. This is the basis <span class="hljs-keyword">for</span> creating a simple pulsating effect.

<span class="hljs-string">``</span><span class="hljs-string">`rust
// Calculate a scale factor that oscillates between 0.8 and 1.2 over time.
// sin() returns -1 to 1. 
// Multiplying by 0.2 gives -0.2 to 0.2. 
// Adding 1.0 shifts the range to 0.8 to 1.2.
let scale = 1.0 + sin(material.time * 2.0) * 0.2;

// Multiply the entire position vector by the scale factor.
let deformed_position = position * scale;</span>
</code></pre><p>This simple operation creates a powerful pulsating effect. Since we are in local space, the position vector represents the direction from the model's center (<code>0,0,0</code>) to the vertex. Multiplying it scales the vertex along that very direction, creating a perfect expansion from the origin.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764413626522/2f7fe88d-f673-4dff-8dd1-cf01da8b8476.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-non-uniform-scaling">Non-Uniform Scaling</h3>
<p>Non-uniform scaling applies different scaling factors to different axes, which allows you to stretch and squash the mesh. This is a classic animation principle used to give motion a sense of weight and flexibility.</p>
<p>A common technique is to approximate volume preservation. If you stretch an object along one axis, you should squash it along the others. A simple way to do this is to make the scaling factors for the other axes inversely proportional to the main axis.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Calculate a primary scaling factor for the Y-axis.</span>
<span class="hljs-keyword">let</span> scale_y = <span class="hljs-number">1.0</span> + sin(time * <span class="hljs-number">2.0</span>) * <span class="hljs-number">0.3</span>; <span class="hljs-comment">// Stretches and squashes vertically</span>

<span class="hljs-comment">// Calculate a reciprocal factor for the XZ-plane to approximate volume conservation.</span>
<span class="hljs-comment">// If scale_y &gt; 1, then 1/sqrt(scale_y) &lt; 1, and vice-versa.</span>
<span class="hljs-keyword">let</span> scale_xz = <span class="hljs-number">1.0</span> / sqrt(scale_y);

<span class="hljs-comment">// Apply the different scale factors to each component.</span>
var deformed_position = position;
deformed_position.y *= scale_y;
deformed_position.xz *= scale_xz;
</code></pre>
<p>This creates a much more organic and satisfying "bouncing" or "breathing" animation than simple uniform scaling.</p>
<h3 id="heading-distance-based-scaling">Distance-Based Scaling</h3>
<p>You can create more nuanced effects by making the scaling factor depend on the vertex's own properties, such as its distance from the model's origin. This can, for instance, make the outer parts of a mesh "breathe" while the core remains relatively stable.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Get the vertex's distance from the model's center (0,0,0 in local space).</span>
<span class="hljs-keyword">let</span> distance = length(position);

<span class="hljs-comment">// Create a scaling effect that is stronger for vertices farther from the center.</span>
<span class="hljs-keyword">let</span> scale_factor = sin(time * <span class="hljs-number">2.0</span>) * (distance * <span class="hljs-number">0.3</span>);
<span class="hljs-keyword">let</span> scale = <span class="hljs-number">1.0</span> + scale_factor;

<span class="hljs-comment">// Apply the scaling.</span>
<span class="hljs-keyword">let</span> deformed_position = position * scale;
</code></pre>
<h3 id="heading-axis-aligned-scaling">Axis-Aligned Scaling</h3>
<p>Sometimes, you may want to scale an object along an arbitrary direction, not just the primary X, Y, or Z axes. This can be achieved using the dot product to project the vertex position onto a direction vector.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Define the axis along which we want to scale (e.g., diagonally).</span>
<span class="hljs-keyword">let</span> scale_axis = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>));

<span class="hljs-comment">// Calculate how much of the vertex's position lies along our chosen axis.</span>
<span class="hljs-comment">// This gives the distance from the origin along the scale_axis.</span>
<span class="hljs-keyword">let</span> projection = dot(position, scale_axis);

<span class="hljs-comment">// Decompose the position into two parts: </span>
<span class="hljs-comment">// 1. A vector parallel to the axis.</span>
<span class="hljs-keyword">let</span> parallel_component = projection * scale_axis;
<span class="hljs-comment">// 2. A vector perpendicular to the axis (the remainder).</span>
<span class="hljs-keyword">let</span> perpendicular_component = position - parallel_component;

<span class="hljs-comment">// Define our scaling amount over time.</span>
<span class="hljs-keyword">let</span> scale_amount = <span class="hljs-number">1.0</span> + sin(time * <span class="hljs-number">2.0</span>) * <span class="hljs-number">0.5</span>;

<span class="hljs-comment">// Scale only the parallel component, then add the perpendicular part back.</span>
<span class="hljs-keyword">let</span> deformed_position = perpendicular_component + parallel_component * scale_amount;
</code></pre>
<p>This advanced technique gives you precise control to stretch or squash a mesh in any direction you can define.</p>
<h2 id="heading-local-vs-world-space-deformations">Local vs. World Space Deformations</h2>
<p>So far, we have been working exclusively in local space. This was a deliberate and important choice. Now, we must understand why we made that choice and when we might want to do things differently. The coordinate space in which you apply your deformation is a fundamental creative decision. It answers the question: <strong>Is this effect part of the object itself, or is it part of the world the object inhabits?</strong></p>
<h3 id="heading-local-space-deformations-the-effect-is-part-of-the-object">Local Space Deformations: "The Effect is part of the Object"</h3>
<p>Think of deforming in local space like being a sculptor working on a piece of clay that sits on a rotating pottery wheel. You shape the clay, adding waves and bulges. The shape you create is intrinsic to the sculpture. When you're done, you can pick the sculpture up, move it to another room, and place it on a shelf at any angle - the shape you sculpted remains perfectly intact, moving and rotating with the object.</p>
<p>This is what happens when we deform before applying the model matrix.</p>
<pre><code class="lang-rust">@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-comment">// 1. Deform in local space FIRST. We are the sculptor.</span>
    var deformed_local = <span class="hljs-keyword">in</span>.position;
    deformed_local.y += sin(<span class="hljs-keyword">in</span>.position.x * <span class="hljs-number">3.0</span> + time) * <span class="hljs-number">0.2</span>;

    <span class="hljs-comment">// 2. THEN, transform the finished sculpture into the world.</span>
    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
    <span class="hljs-keyword">let</span> world_position = model * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(deformed_local, <span class="hljs-number">1.0</span>);

    <span class="hljs-comment">// ... continue to clip space ...</span>
    out.clip_position = position_world_to_clip(world_position.xyz);
    <span class="hljs-keyword">return</span> out;
}
</code></pre>
<p><strong>The Result:</strong> The wave pattern is "baked into" the object's own coordinate system. If you rotate the object, the wave pattern rotates with it. If you move it, the wave moves with it.</p>
<p><strong>Use Cases (when the effect is intrinsic to the object):</strong></p>
<ul>
<li><p>A character's breathing animation.</p>
</li>
<li><p>The waving motion of a flag attached to a moving vehicle.</p>
</li>
<li><p>The pulsation of a magical orb.</p>
</li>
<li><p>The jiggle of a gelatinous cube.</p>
</li>
</ul>
<h3 id="heading-world-space-deformations-the-effect-is-part-of-the-world">World Space Deformations: "The Effect is part of the World"</h3>
<p>Now, imagine taking your finished, static statue and placing it in a river. The flowing water will push and pull at the statue's surface, making it appear to distort. The statue itself isn't changing, but an external, environmental force is acting upon it. The effect is tied to the location, not the object. If you place another statue in the same spot, it will be distorted by the river in the exact same way.</p>
<p>This is what happens when we apply the model matrix first and then deform the resulting world position.</p>
<pre><code class="lang-rust">@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-comment">// 1. Transform to world space FIRST. Place the static object in the environment.</span>
    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
    <span class="hljs-keyword">let</span> world_position = model * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">1.0</span>);

    <span class="hljs-comment">// 2. THEN, deform its world position. The environment acts on the object.</span>
    var deformed_world = world_position;
    deformed_world.y += sin(world_position.x * <span class="hljs-number">3.0</span> + time) * <span class="hljs-number">0.2</span>;

    <span class="hljs-comment">// ... continue to clip space ...</span>
    out.clip_position = position_world_to_clip(deformed_world.xyz);
    <span class="hljs-keyword">return</span> out;
}
</code></pre>
<p><strong>The Result:</strong> The wave pattern is fixed in world space. As an object moves through it, the wave appears to flow across the object's surface. Two different objects at the same world coordinates will be deformed identically.</p>
<p><strong>Use Cases (when the effect is environmental):</strong></p>
<ul>
<li><p>The surface of a large body of water where multiple objects (boats, debris) should ripple in sync.</p>
</li>
<li><p>A fixed force field or magical barrier that distorts things passing through it.</p>
</li>
<li><p>A global wind effect that makes all trees and grass in a certain area sway in the same direction.</p>
</li>
<li><p>A shockwave from an explosion that expands outwards from a point in the world.</p>
</li>
</ul>
<h3 id="heading-hybrid-approaches">Hybrid Approaches</h3>
<p>You can combine these techniques for more nuanced control. For example, you could have a world-space effect (a river) whose strength is modulated by a local-space property (the height of the vertex on the object). This would make the bottom of a boat be affected by the water, while the mast remains untouched.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// 1. Get the vertex's world position.</span>
<span class="hljs-keyword">let</span> world_position = model * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">1.0</span>);

<span class="hljs-comment">// 2. Calculate the world-space effect.</span>
<span class="hljs-keyword">let</span> world_wave = sin(world_position.x * <span class="hljs-number">3.0</span> + time) * <span class="hljs-number">0.2</span>;

<span class="hljs-comment">// 3. Modulate the effect's strength by a local property.</span>
<span class="hljs-comment">//    Here, we use smoothstep to create a falloff.</span>
<span class="hljs-comment">//    Vertices with local y &gt; 1.0 are unaffected.</span>
<span class="hljs-keyword">let</span> local_influence = <span class="hljs-number">1.0</span> - smoothstep(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-keyword">in</span>.position.y);

<span class="hljs-comment">// 4. Apply the final, modulated deformation.</span>
var deformed_world = world_position;
deformed_world.y += world_wave * local_influence;
</code></pre>
<h3 id="heading-comparison-table">Comparison Table</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Aspect</td><td>Local Space Deformation</td><td>World Space Deformation</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Transform Order</strong></td><td><strong>Deform</strong> → Model → View → Clip</td><td>Model → <strong>Deform</strong> → View → Clip</td></tr>
<tr>
<td><strong>Object Rotation</strong></td><td>The effect rotates with the object.</td><td>The effect remains world-aligned; the object moves through it.</td></tr>
<tr>
<td><strong>Multiple Instances</strong></td><td>Each instance is deformed relative to its own center.</td><td>All instances are affected by the same global deformation field.</td></tr>
<tr>
<td><strong>Performance</strong></td><td>Generally faster. Calculations are simpler and inputs are smaller.</td><td>Can be slightly slower. Requires a matrix multiply before deformation.</td></tr>
<tr>
<td><strong>Primary Use Case</strong></td><td><strong>Object-centric effects:</strong> breathing, pulsing, intrinsic motion.</td><td><strong>Environmental effects:</strong> water, wind, force fields.</td></tr>
</tbody>
</table>
</div><h2 id="heading-maintaining-proper-normals">Maintaining Proper Normals</h2>
<p>We've successfully changed the shape of our mesh. But in doing so, we've created a new, subtle problem: <strong>our lighting is now incorrect.</strong></p>
<p>A mesh's normals are vectors that define the orientation of the surface at each vertex. They tell the lighting engine which way the surface is facing, which is essential for calculating how light reflects off it. The core rule is that <strong>a normal vector must always be perpendicular to the surface at that vertex's location.</strong></p>
<p>When we deform the mesh, we change the slope and curvature of that surface. However, the original normals stored in the mesh buffer are not automatically updated. The shader will continue to use the old, now-incorrect normal, leading to lighting that looks flat and wrong, completely breaking the illusion of a dynamic 3D shape.</p>
<p>Let's visualize the problem step-by-step, looking at a small patch of the mesh.</p>
<h3 id="heading-a-original-flat-surface">(A) Original Flat Surface</h3>
<p>On the original mesh, the vertices form a flat plane. The stored normal vector is perpendicular to this plane, so the lighting is correct.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764413812074/e9bc4c4b-5e02-44ee-919d-008e3e6c6d40.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-b-deformed-surface-with-an-incorrect-normal">(B) Deformed Surface with an Incorrect Normal</h3>
<p>After we apply a deformation, the vertices move, creating a sloped surface. But the shader, by default, still uses the <em>original</em> normal, which is no longer perpendicular to the new surface.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764413985805/eae3ff96-c410-4cc8-8efa-ef68618121af.png" alt class="image--center mx-auto" /></p>
<p>The lighting engine sees a vertical normal and calculates light as if the surface were still flat. This is wrong and breaks the illusion of shape. The highlight will appear "painted on" rather than belonging to the new geometry.</p>
<h3 id="heading-c-deformed-surface-with-the-correct-normal">(C) Deformed Surface with the Correct Normal</h3>
<p>To fix this, we must calculate a new normal in our vertex shader that is perpendicular to the newly deformed surface.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764414246996/b50b97f2-c335-4b8b-ad60-8c02bd7832eb.png" alt class="image--center mx-auto" /></p>
<p>With this corrected normal, lighting calculations will produce realistic highlights and shadows that perfectly match the new, dynamic shape of our mesh.</p>
<p>There are several strategies to compute this new normal, ranging from fast approximations to more complex, accurate calculations.</p>
<h3 id="heading-strategy-1-approximating-normals-with-derivatives">Strategy 1: Approximating Normals with Derivatives</h3>
<p>For deformations based on a mathematical function (like our sine wave), we can use a fast <strong>approximation</strong> based on the function's derivative. The derivative tells us the <code>slope</code> of the new surface at any given point. We can use this slope to "nudge" the original normal so that it points in a more plausible direction.</p>
<p>Let's look at our sine wave: <code>offset_y = amplitude * sin(frequency * position.x + time)</code>. The derivative of this function with respect to <code>x</code> tells us the slope along the X-axis:</p>
<pre><code class="lang-plaintext">slope_x = amplitude * frequency * cos(frequency * position.x + time)
</code></pre>
<pre><code class="lang-rust"><span class="hljs-comment">// A wave deforms our position's Y based on its X.</span>
<span class="hljs-keyword">let</span> frequency = <span class="hljs-number">3.0</span>;
<span class="hljs-keyword">let</span> amplitude = <span class="hljs-number">0.2</span>;
<span class="hljs-keyword">let</span> time = material.time;
<span class="hljs-keyword">let</span> wave_input = position.x * frequency + time;
deformed_position.y += sin(wave_input) * amplitude;

<span class="hljs-comment">// The derivative of sin is cos. This gives us the slope along X.</span>
<span class="hljs-keyword">let</span> slope = cos(wave_input) * frequency * amplitude;

<span class="hljs-comment">// The original normal for a flat plane is (0, 1, 0).</span>
<span class="hljs-comment">// A vector representing the new slope is roughly (-slope, 1, 0).</span>
<span class="hljs-comment">// We can construct this new direction and normalize it.</span>
<span class="hljs-comment">// This is NOT a physically accurate calculation, but a fast, plausible-looking</span>
<span class="hljs-comment">// approximation that works well for gentle waves.</span>
<span class="hljs-keyword">let</span> perturbed_normal = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(-slope, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>));
</code></pre>
<p><strong>Important:</strong> This method is an approximation. Its main advantage is that it is extremely fast, adding only a few extra math operations per vertex. It works best for gentle, wave-like deformations where the original surface is mostly flat.</p>
<h3 id="heading-strategy-2-geometric-normal-recalculation-more-accurate-more-expensive">Strategy 2: Geometric Normal Recalculation (More Accurate, More Expensive)</h3>
<p>A more robust and accurate method is to regenerate the normal geometrically. The principle is simple: if we know the deformed position of a vertex and the deformed positions of its immediate neighbors, we can define the new surface and calculate its true normal.</p>
<p>We can simulate this by sampling our deformation function at three nearby points: the original position, a point slightly offset on the X-axis, and a point slightly offset on the Z-axis.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// This function encapsulates our deformation logic.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">deform</span></span>(p: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, time: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    var deformed = p;
    deformed.y += sin(p.x * <span class="hljs-number">3.0</span> + time) * <span class="hljs-number">0.2</span>; <span class="hljs-comment">// Our sine wave</span>
    <span class="hljs-keyword">return</span> deformed;
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">recalculate_normal_geometrically</span></span>(position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, time: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> epsilon = <span class="hljs-number">0.001</span>; <span class="hljs-comment">// A very small offset</span>

    <span class="hljs-comment">// Calculate the deformed position at the center and two nearby points.</span>
    <span class="hljs-keyword">let</span> center_pos = deform(position, time);
    <span class="hljs-keyword">let</span> neighbor_x_pos = deform(position + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(epsilon, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>), time);
    <span class="hljs-keyword">let</span> neighbor_z_pos = deform(position + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, epsilon), time);

    <span class="hljs-comment">// Create two tangent vectors on the new surface.</span>
    <span class="hljs-keyword">let</span> tangent_x = neighbor_x_pos - center_pos;
    <span class="hljs-keyword">let</span> tangent_z = neighbor_z_pos - center_pos;

    <span class="hljs-comment">// The cross product of the tangents gives the new surface normal.</span>
    <span class="hljs-comment">// The direction might need to be flipped (-cross) depending on winding order.</span>
    <span class="hljs-keyword">return</span> normalize(cross(tangent_z, tangent_x));
}
</code></pre>
<p>This method is far more accurate and works for almost any deformation function, no matter how complex. The downside is its performance cost: we are running our deformation logic <strong>three times for every single vertex</strong>. This can be a significant performance hit and should be used judiciously, perhaps only on hero assets or when accuracy is paramount.</p>
<h3 id="heading-when-can-you-skip-normal-updates">When Can You Skip Normal Updates?</h3>
<p>In some situations, you can get away with not updating the normals at all:</p>
<ol>
<li><p><strong>Unlit or Emissive Materials:</strong> If an object is not affected by lighting (e.g., it's a hologram, a UI element, or pure fire), its normals are irrelevant.</p>
</li>
<li><p><strong>Very Subtle Deformations:</strong> If the offset is tiny, the error in the normals will be visually negligible.</p>
</li>
<li><p><strong>Stylized Shading:</strong> For flat-shading or toon-shading styles, precise normals are often less important than the overall silhouette and color bands.</p>
</li>
<li><p><strong>Level of Detail (LOD):</strong> A common optimization is to use accurate recalculated normals for objects close to the camera, but switch to the cheaper original normals (or a faster approximation) for objects in the distance where the lighting error won't be noticeable.</p>
</li>
</ol>
<p>Choosing the right strategy is a classic trade-off between visual quality and performance. For most simple deformations, a fast approximation will be sufficient.</p>
<h2 id="heading-per-object-variation-with-instanceindex">Per-Object Variation with <code>instance_index</code></h2>
<p>So far, if we render ten waving flags using our shader, they will all wave in perfect, robotic synchronization. This instantly breaks the illusion of natural motion. To make a world feel alive, we need variety. Each object should have its own unique character.</p>
<p>The GPU provides a simple but powerful tool to solve this: the <code>@builtin(instance_index)</code>. This is a special WGSL input that gives us a unique, zero-based integer ID for each copy of a mesh being rendered. If you draw 100 spheres, the first sphere's vertex shader will receive an <code>instance_index</code> of <code>0</code>, the second will get <code>1</code>, and so on.</p>
<blockquote>
<p><strong>A Look Ahead:</strong> The mechanism that allows the GPU to draw many objects so efficiently is called <strong>instanced rendering</strong>. It's a deep and important performance topic that we will dedicate all of article <strong>2.7 - Instanced Rendering</strong> to. For now, all you need to know is that <code>instance_index</code> gives us a unique ID for each object, which we can use to break up the uniformity of our effects.</p>
</blockquote>
<h3 id="heading-phase-offsetting">Phase Offsetting</h3>
<p>The simplest way to use <code>instance_index</code> is to create a phase offset for our time-based animations. Instead of every object using the exact same time <code>value</code>, each gets a small offset, making them animate out of sync.</p>
<pre><code class="lang-rust">@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    <span class="hljs-comment">// ...</span>
) -&gt; VertexOutput {
    <span class="hljs-comment">// Each instance gets a slightly different starting point in the sine wave.</span>
    <span class="hljs-keyword">let</span> phase_offset = <span class="hljs-built_in">f32</span>(instance_index) * <span class="hljs-number">0.5</span>;

    var deformed_position = position;
    deformed_position.y += sin(position.x * <span class="hljs-number">3.0</span> + material.time + phase_offset) * <span class="hljs-number">0.2</span>;
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<p><strong>Result:</strong> Instead of a synchronized army, you get a field of objects moving with a natural, chaotic offset.</p>
<h3 id="heading-deterministic-randomness">Deterministic Randomness</h3>
<p>We can also use the <code>instance_index</code> to generate consistent, "random-looking" numbers to vary properties like size or color. We do this with a simple <strong>hash function</strong>, which turns an ordered sequence of IDs (0, 1, 2...) into a jumbled but repeatable sequence of values.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A simple hash function that takes a u32 and returns a float between 0.0 and 1.0.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash</span></span>(index: <span class="hljs-built_in">u32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> n = <span class="hljs-built_in">f32</span>(index) * <span class="hljs-number">12.9898</span>;
    <span class="hljs-keyword">return</span> fract(sin(n) * <span class="hljs-number">43758.5453</span>);
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    <span class="hljs-comment">// ...</span>
) -&gt; VertexOutput {
    <span class="hljs-comment">// Each instance gets its own unique but consistent "random" value.</span>
    <span class="hljs-keyword">let</span> random_val = hash(instance_index);

    <span class="hljs-comment">// Use this value to vary multiple properties of the deformation.</span>
    <span class="hljs-keyword">let</span> frequency = mix(<span class="hljs-number">3.0</span>, <span class="hljs-number">5.0</span>, random_val); <span class="hljs-comment">// Vary frequency</span>
    <span class="hljs-keyword">let</span> amplitude = mix(<span class="hljs-number">0.1</span>, <span class="hljs-number">0.3</span>, random_val); <span class="hljs-comment">// Vary amplitude</span>

    <span class="hljs-comment">// ... apply deformation using these unique values ...</span>
}
</code></pre>
<p>By using <code>instance_index</code> as a seed for variation, you can transform a single, repetitive effect into a rich and believable scene.</p>
<h2 id="heading-performance-considerations">Performance Considerations</h2>
<p>Vertex shaders are executed for every single vertex of a mesh, every single frame. A model with 50,000 vertices running at 60 FPS requires the GPU to run your vertex shader code 3,000,000 times per second for that object alone. While modern GPUs are incredibly fast, it's important to be aware that complex calculations in the vertex shader can become a performance bottleneck.</p>
<p>The core principle of vertex shader optimization is to <strong>do as little work as possible</strong>.</p>
<ul>
<li><p>Any calculation that is the same for every single vertex (e.g., based only on <code>time</code>) should be done once on the CPU and passed to the shader as a uniform.</p>
</li>
<li><p>Avoid complex logic for objects that are far away from the camera, as the detail will be lost anyway.</p>
</li>
</ul>
<blockquote>
<p><strong>A Look Ahead:</strong> Performance tuning is a deep and critical subject in graphics programming. We will dedicate all of article <strong>2.8 - Vertex Shader Optimization</strong> to a thorough exploration of these concepts, covering topics like branch divergence, mathematical optimizations, and profiling tools. For now, focus on getting your effects working correctly; we will learn how to make them fast in a later chapter.</p>
</blockquote>
<hr />
<h2 id="heading-complete-example-pulsating-sphere-system">Complete Example: Pulsating Sphere System</h2>
<p>It's time to put theory into practice. We will build a complete Bevy application that demonstrates all the core concepts of this chapter: multiple deformation types, per-instance variation, correct normal updates, and real-time uniform controls.</p>
<h3 id="heading-our-goal">Our Goal</h3>
<p>Our goal is to create a 5x5 grid of spheres. Each sphere will be animated using a single custom shader, but thanks to per-instance variation, each will have a unique color and animation phase. We will add keyboard controls to switch between different deformation modes (Pulsate, Wave, Twist) and adjust their parameters, like speed and amplitude, on the fly.</p>
<h3 id="heading-what-this-project-demonstrates">What This Project Demonstrates</h3>
<ul>
<li><p><strong>Shader Uniforms:</strong> How to control shader effects from Rust by updating a uniform struct (PulsatingSphereUniforms).</p>
</li>
<li><p><strong>Vertex Deformation:</strong> Implementing multiple, distinct deformation techniques within a single vertex shader.</p>
</li>
<li><p><strong>Normal Maintenance:</strong> Calculating updated normals for each deformation type to ensure lighting remains correct.</p>
</li>
<li><p><strong>Per-Instance Variation:</strong> Using <code>@builtin(instance_index)</code> to give each sphere a unique color and animation offset, making the scene feel organic.</p>
</li>
<li><p><strong>Conditional Logic in Shaders:</strong> Using a <code>deformation_mode</code> uniform to switch between different code paths in the shader.</p>
</li>
<li><p><strong>Fragment Shader Basics:</strong> Applying simple lighting and using data passed from the vertex shader (like a per-instance color) to shade the final pixel.</p>
</li>
</ul>
<h3 id="heading-the-shader-assetsshadersd0204pulsatingspherewgsl">The Shader (<code>assets/shaders/d02_04_pulsating_sphere.wgsl</code>)</h3>
<p>This is the heart of our visual effect. The WGSL code is divided into two main parts:</p>
<ol>
<li><p><strong>@vertex Shader:</strong> This is where all the deformation logic lives. It reads the incoming vertex data, checks the <code>deformation_mode</code> uniform, and calls the appropriate function (<code>apply_pulsate</code>, <code>apply_wave</code>, etc.) to calculate the new vertex position. Critically, it also calculates the corresponding <code>deformed_normal</code> for that mode and passes both the final position and normal to the fragment shader. It also uses a hash function on the <code>instance_index</code> to generate a unique color for each sphere.</p>
</li>
<li><p><strong>@fragment Shader:</strong> This part is simpler. It receives the interpolated world position, the corrected world normal, and the unique instance color from the vertex stage. It then performs basic <a target="_blank" href="https://en.wikipedia.org/wiki/Blinn%E2%80%93Phong_reflection_model">Blinn-Phong</a> lighting calculations to give the spheres a sense of volume and uses the instance color as the base albedo. It also adds a small emissive highlight based on the strength of the deformation.</p>
</li>
</ol>
<pre><code class="lang-rust">#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">PulsatingSphereUniforms</span></span> {
    time: <span class="hljs-built_in">f32</span>,
    pulse_speed: <span class="hljs-built_in">f32</span>,
    pulse_amplitude: <span class="hljs-built_in">f32</span>,
    deformation_mode: <span class="hljs-built_in">u32</span>,  <span class="hljs-comment">// 0=pulsate, 1=wave, 2=twist, 3=combined</span>
    wave_frequency: <span class="hljs-built_in">f32</span>,
    twist_amount: <span class="hljs-built_in">f32</span>,
    camera_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    _padding: <span class="hljs-built_in">f32</span>,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: PulsatingSphereUniforms;

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) world_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) deformation_amount: <span class="hljs-built_in">f32</span>,
    @location(<span class="hljs-number">3</span>) instance_color: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-comment">// Simple hash for per-instance variation</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash_instance</span></span>(index: <span class="hljs-built_in">u32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> n = <span class="hljs-built_in">f32</span>(index) * <span class="hljs-number">12.9898</span>;
    <span class="hljs-keyword">return</span> fract(sin(n) * <span class="hljs-number">43758.5453</span>);
}

<span class="hljs-comment">// Get color based on instance index</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_instance_color</span></span>(index: <span class="hljs-built_in">u32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> hue = hash_instance(index);

    <span class="hljs-comment">// Simple HSV to RGB conversion for varied colors</span>
    <span class="hljs-keyword">let</span> sat = <span class="hljs-number">0.7</span>;
    <span class="hljs-keyword">let</span> val = <span class="hljs-number">0.9</span>;

    <span class="hljs-keyword">let</span> c = val * sat;
    <span class="hljs-keyword">let</span> x = c * (<span class="hljs-number">1.0</span> - abs((hue * <span class="hljs-number">6.0</span>) % <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> m = val - c;

    var rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">if</span> hue &lt; <span class="hljs-number">1.0</span> / <span class="hljs-number">6.0</span> {
        rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(c, x, <span class="hljs-number">0.0</span>);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> hue &lt; <span class="hljs-number">2.0</span> / <span class="hljs-number">6.0</span> {
        rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(x, c, <span class="hljs-number">0.0</span>);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> hue &lt; <span class="hljs-number">3.0</span> / <span class="hljs-number">6.0</span> {
        rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, c, x);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> hue &lt; <span class="hljs-number">4.0</span> / <span class="hljs-number">6.0</span> {
        rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, x, c);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> hue &lt; <span class="hljs-number">5.0</span> / <span class="hljs-number">6.0</span> {
        rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(x, <span class="hljs-number">0.0</span>, c);
    } <span class="hljs-keyword">else</span> {
        rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(c, <span class="hljs-number">0.0</span>, x);
    }

    <span class="hljs-keyword">return</span> rgb + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(m);
}

<span class="hljs-comment">// Mode 0: Simple pulsating</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_pulsate</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
    instance_index: <span class="hljs-built_in">u32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Each instance pulses at slightly different phase</span>
    <span class="hljs-keyword">let</span> phase = <span class="hljs-built_in">f32</span>(instance_index) * <span class="hljs-number">0.3</span>;

    <span class="hljs-comment">// Pulse uniformly in all directions</span>
    <span class="hljs-keyword">let</span> pulse = sin(time * material.pulse_speed + phase) * material.pulse_amplitude;
    <span class="hljs-keyword">let</span> scale = <span class="hljs-number">1.0</span> + pulse;

    <span class="hljs-keyword">return</span> position * scale;
}

<span class="hljs-comment">// Mode 1: Sine wave deformation</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_wave</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
    instance_index: <span class="hljs-built_in">u32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> phase = <span class="hljs-built_in">f32</span>(instance_index) * <span class="hljs-number">0.5</span>;

    var deformed = position;

    <span class="hljs-comment">// Wave propagates based on distance from center</span>
    <span class="hljs-keyword">let</span> dist = length(position.xz);
    <span class="hljs-keyword">let</span> wave = sin(dist * material.wave_frequency - time * material.pulse_speed + phase);

    <span class="hljs-comment">// Displace along normal for organic look</span>
    deformed += normal * wave * material.pulse_amplitude;

    <span class="hljs-keyword">return</span> deformed;
}

<span class="hljs-comment">// Mode 2: Twist deformation</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_twist</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
    instance_index: <span class="hljs-built_in">u32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> phase = <span class="hljs-built_in">f32</span>(instance_index) * <span class="hljs-number">0.4</span>;

    <span class="hljs-comment">// Twist amount varies with time</span>
    <span class="hljs-keyword">let</span> twist = sin(time * material.pulse_speed + phase) * material.twist_amount;

    <span class="hljs-comment">// Twist increases with height</span>
    <span class="hljs-keyword">let</span> angle = position.y * twist;

    <span class="hljs-keyword">let</span> cos_a = cos(angle);
    <span class="hljs-keyword">let</span> sin_a = sin(angle);

    var deformed = position;
    deformed.x = position.x * cos_a - position.z * sin_a;
    deformed.z = position.x * sin_a + position.z * cos_a;

    <span class="hljs-keyword">return</span> deformed;
}

<span class="hljs-comment">// Mode 3: Combined effects</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_combined</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
    instance_index: <span class="hljs-built_in">u32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    var deformed = position;

    <span class="hljs-comment">// Base pulsation</span>
    <span class="hljs-keyword">let</span> phase = <span class="hljs-built_in">f32</span>(instance_index) * <span class="hljs-number">0.3</span>;
    <span class="hljs-keyword">let</span> pulse = sin(time * material.pulse_speed * <span class="hljs-number">0.5</span> + phase) * material.pulse_amplitude * <span class="hljs-number">0.5</span>;
    deformed = deformed * (<span class="hljs-number">1.0</span> + pulse);

    <span class="hljs-comment">// Add wave detail</span>
    <span class="hljs-keyword">let</span> dist = length(position.xz);
    <span class="hljs-keyword">let</span> wave = sin(dist * material.wave_frequency * <span class="hljs-number">2.0</span> - time * material.pulse_speed) * material.pulse_amplitude * <span class="hljs-number">0.3</span>;
    deformed += normal * wave;

    <span class="hljs-comment">// Slight twist</span>
    <span class="hljs-keyword">let</span> twist = sin(time * material.pulse_speed * <span class="hljs-number">0.3</span>) * material.twist_amount * <span class="hljs-number">0.5</span>;
    <span class="hljs-keyword">let</span> angle = position.y * twist;
    <span class="hljs-keyword">let</span> cos_a = cos(angle);
    <span class="hljs-keyword">let</span> sin_a = sin(angle);
    <span class="hljs-keyword">let</span> x = deformed.x * cos_a - deformed.z * sin_a;
    <span class="hljs-keyword">let</span> z = deformed.x * sin_a + deformed.z * cos_a;
    deformed.x = x;
    deformed.z = z;

    <span class="hljs-keyword">return</span> deformed;
}

<span class="hljs-comment">// Calculate perturbed normal for wave deformation</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">calculate_wave_normal</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
    instance_index: <span class="hljs-built_in">u32</span>,
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> phase = <span class="hljs-built_in">f32</span>(instance_index) * <span class="hljs-number">0.5</span>;
    <span class="hljs-keyword">let</span> dist = length(position.xz);

    <span class="hljs-comment">// Calculate gradient of wave</span>
    <span class="hljs-keyword">let</span> wave_gradient = cos(dist * material.wave_frequency - time * material.pulse_speed + phase)
        * material.wave_frequency;

    <span class="hljs-comment">// Direction of gradient</span>
    <span class="hljs-keyword">let</span> gradient_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(position.x, <span class="hljs-number">0.0</span>, position.z));

    <span class="hljs-comment">// Perturb normal</span>
    <span class="hljs-keyword">let</span> tangent_offset = gradient_dir * wave_gradient * material.pulse_amplitude;

    <span class="hljs-keyword">return</span> normalize(normal + tangent_offset * <span class="hljs-number">0.5</span>);
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-comment">// Apply deformation based on mode</span>
    var deformed_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;;
    var deformed_normal = <span class="hljs-keyword">in</span>.normal;
    var deformation_amount = <span class="hljs-number">0.0</span>;

    <span class="hljs-keyword">if</span> material.deformation_mode == <span class="hljs-number">0</span>u {
        <span class="hljs-comment">// Pulsate</span>
        deformed_position = apply_pulsate(<span class="hljs-keyword">in</span>.position, <span class="hljs-keyword">in</span>.normal, material.time, <span class="hljs-keyword">in</span>.instance_index);
        <span class="hljs-keyword">let</span> phase = <span class="hljs-built_in">f32</span>(<span class="hljs-keyword">in</span>.instance_index) * <span class="hljs-number">0.3</span>;
        deformation_amount = sin(material.time * material.pulse_speed + phase) * material.pulse_amplitude;
        <span class="hljs-comment">// Normals don't need adjustment for uniform scaling</span>
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.deformation_mode == <span class="hljs-number">1</span>u {
        <span class="hljs-comment">// Wave</span>
        deformed_position = apply_wave(<span class="hljs-keyword">in</span>.position, <span class="hljs-keyword">in</span>.normal, material.time, <span class="hljs-keyword">in</span>.instance_index);
        deformed_normal = calculate_wave_normal(<span class="hljs-keyword">in</span>.position, <span class="hljs-keyword">in</span>.normal, material.time, <span class="hljs-keyword">in</span>.instance_index);
        <span class="hljs-keyword">let</span> dist = length(<span class="hljs-keyword">in</span>.position.xz);
        <span class="hljs-keyword">let</span> phase = <span class="hljs-built_in">f32</span>(<span class="hljs-keyword">in</span>.instance_index) * <span class="hljs-number">0.5</span>;
        deformation_amount = sin(dist * material.wave_frequency - material.time * material.pulse_speed + phase)
            * material.pulse_amplitude;
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.deformation_mode == <span class="hljs-number">2</span>u {
        <span class="hljs-comment">// Twist</span>
        deformed_position = apply_twist(<span class="hljs-keyword">in</span>.position, <span class="hljs-keyword">in</span>.normal, material.time, <span class="hljs-keyword">in</span>.instance_index);
        <span class="hljs-comment">// For twist, normal also needs to rotate</span>
        <span class="hljs-keyword">let</span> phase = <span class="hljs-built_in">f32</span>(<span class="hljs-keyword">in</span>.instance_index) * <span class="hljs-number">0.4</span>;
        <span class="hljs-keyword">let</span> twist = sin(material.time * material.pulse_speed + phase) * material.twist_amount;
        <span class="hljs-keyword">let</span> angle = <span class="hljs-keyword">in</span>.position.y * twist;
        <span class="hljs-keyword">let</span> cos_a = cos(angle);
        <span class="hljs-keyword">let</span> sin_a = sin(angle);
        deformed_normal.x = <span class="hljs-keyword">in</span>.normal.x * cos_a - <span class="hljs-keyword">in</span>.normal.z * sin_a;
        deformed_normal.z = <span class="hljs-keyword">in</span>.normal.x * sin_a + <span class="hljs-keyword">in</span>.normal.z * cos_a;
        deformation_amount = abs(twist) * material.twist_amount;
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// Combined</span>
        deformed_position = apply_combined(<span class="hljs-keyword">in</span>.position, <span class="hljs-keyword">in</span>.normal, material.time, <span class="hljs-keyword">in</span>.instance_index);
        <span class="hljs-comment">// Use wave normal as approximation</span>
        deformed_normal = calculate_wave_normal(<span class="hljs-keyword">in</span>.position, <span class="hljs-keyword">in</span>.normal, material.time, <span class="hljs-keyword">in</span>.instance_index);
        deformation_amount = <span class="hljs-number">0.5</span>; <span class="hljs-comment">// Middle value for combined</span>
    }

    <span class="hljs-comment">// Transform to world space</span>
    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
    <span class="hljs-keyword">let</span> world_position = mesh_functions::mesh_position_local_to_world(
        model,
        vec4&lt;<span class="hljs-built_in">f32</span>&gt;(deformed_position, <span class="hljs-number">1.0</span>)
    );

    <span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(
        deformed_normal,
        <span class="hljs-keyword">in</span>.instance_index
    );

    <span class="hljs-comment">// Transform to clip space</span>
    out.clip_position = position_world_to_clip(world_position.xyz);
    out.world_position = world_position.xyz;
    out.world_normal = normalize(world_normal);
    out.deformation_amount = deformation_amount;
    out.instance_color = get_instance_color(<span class="hljs-keyword">in</span>.instance_index);

    <span class="hljs-keyword">return</span> out;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);

    <span class="hljs-comment">// Lighting</span>
    <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> view_dir = normalize(material.camera_position - <span class="hljs-keyword">in</span>.world_position);

    <span class="hljs-comment">// Diffuse</span>
    <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.0</span>, dot(normal, light_dir)) * <span class="hljs-number">0.7</span>;

    <span class="hljs-comment">// Simple specular</span>
    <span class="hljs-keyword">let</span> half_vec = normalize(light_dir + view_dir);
    <span class="hljs-keyword">let</span> specular = pow(max(<span class="hljs-number">0.0</span>, dot(normal, half_vec)), <span class="hljs-number">32.0</span>) * <span class="hljs-number">0.3</span>;

    <span class="hljs-comment">// Ambient</span>
    <span class="hljs-keyword">let</span> ambient = <span class="hljs-number">0.3</span>;

    <span class="hljs-comment">// Base color from instance</span>
    <span class="hljs-keyword">let</span> base_color = <span class="hljs-keyword">in</span>.instance_color;

    <span class="hljs-comment">// Modulate by deformation amount</span>
    <span class="hljs-keyword">let</span> deform_highlight = abs(<span class="hljs-keyword">in</span>.deformation_amount) * <span class="hljs-number">0.3</span>;
    <span class="hljs-keyword">let</span> final_color = base_color * (ambient + diffuse) + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(specular) + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(deform_highlight);

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(final_color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0204pulsatingspherers">The Rust Material (<code>src/materials/d02_04_pulsating_sphere.rs</code>)</h3>
<p>This file defines the bridge between our Bevy application and our shader. It contains the <code>PulsatingSphereUniforms</code> struct, which precisely matches the layout of the uniform block in our shader. The <code>PulseSphereMaterial</code> struct wraps these uniforms and implements Bevy's Material and <code>AsBindGroup</code> traits, making it a usable asset.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>

    <span class="hljs-keyword">use</span> bevy::prelude::*;
    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">PulsatingSphereUniforms</span></span> {
        <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> pulse_speed: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> pulse_amplitude: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> deformation_mode: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> wave_frequency: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> twist_amount: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> camera_position: Vec3,
        <span class="hljs-keyword">pub</span> _padding: <span class="hljs-built_in">f32</span>,
    }

    <span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> PulsatingSphereUniforms {
        <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
            <span class="hljs-keyword">Self</span> {
                time: <span class="hljs-number">0.0</span>,
                pulse_speed: <span class="hljs-number">2.0</span>,
                pulse_amplitude: <span class="hljs-number">0.3</span>,
                deformation_mode: <span class="hljs-number">0</span>,
                wave_frequency: <span class="hljs-number">3.0</span>,
                twist_amount: <span class="hljs-number">2.0</span>,
                camera_position: Vec3::ZERO,
                _padding: <span class="hljs-number">0.0</span>,
            }
        }
    }
}

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">use</span> uniforms::PulsatingSphereUniforms;

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">PulseSphereMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: PulsatingSphereUniforms,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> PulseSphereMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_04_pulsating_sphere.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_04_pulsating_sphere.wgsl"</span>.into()
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other materials</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_04_pulsating_sphere;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0204pulsatingspherers">The Demo Module (<code>src/demos/d02_04_pulsating_sphere.rs</code>)</h3>
<p>This Rust file sets up our Bevy scene. It registers our custom material, spawns a grid of <code>Sphere</code> meshes, and assigns our <code>PulseSphereMaterial</code> to each one. It includes systems to update the uniforms every frame based on time and user input, rotate the camera, and display a UI showing the current settings.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d02_04_pulsating_sphere::{PulsatingSphereUniforms, PulseSphereMaterial};
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> std::<span class="hljs-built_in">f32</span>::consts::PI;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;PulseSphereMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (update_time, handle_input, rotate_camera, update_ui),
        )
        .run();
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;PulseSphereMaterial&gt;&gt;,
) {
    <span class="hljs-comment">// Create a grid of pulsating spheres</span>
    <span class="hljs-keyword">let</span> grid_size = <span class="hljs-number">5</span>;
    <span class="hljs-keyword">let</span> spacing = <span class="hljs-number">3.0</span>;

    <span class="hljs-keyword">for</span> x <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..grid_size {
        <span class="hljs-keyword">for</span> z <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..grid_size {
            <span class="hljs-keyword">let</span> pos_x = (x <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> - grid_size <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / <span class="hljs-number">2.0</span>) * spacing;
            <span class="hljs-keyword">let</span> pos_z = (z <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> - grid_size <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / <span class="hljs-number">2.0</span>) * spacing;

            commands.spawn((
                Mesh3d(meshes.add(Sphere::new(<span class="hljs-number">1.0</span>).mesh().uv(<span class="hljs-number">32</span>, <span class="hljs-number">16</span>))),
                MeshMaterial3d(materials.add(PulseSphereMaterial {
                    uniforms: PulsatingSphereUniforms::default(),
                })),
                Transform::from_xyz(pos_x, <span class="hljs-number">0.0</span>, pos_z),
            ));
        }
    }

    <span class="hljs-comment">// Lighting</span>
    commands.spawn((
        DirectionalLight {
            illuminance: <span class="hljs-number">10000.0</span>,
            shadows_enabled: <span class="hljs-literal">false</span>,
            ..default()
        },
        Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -PI / <span class="hljs-number">4.0</span>, PI / <span class="hljs-number">4.0</span>, <span class="hljs-number">0.0</span>)),
    ));

    <span class="hljs-comment">// Camera</span>
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">12.0</span>, <span class="hljs-number">18.0</span>).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    <span class="hljs-comment">// UI</span>
    commands.spawn((
        Text::new(
            <span class="hljs-string">"[1-4] Deformation Mode | [Q/W] Pulse Speed | [A/S] Amplitude\n\
             [Z/X] Wave Frequency | [C/V] Twist Amount\n\
             \n\
             Mode: Pulsate | Speed: 2.0 | Amplitude: 0.3"</span>,
        ),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">10.0</span>),
            left: Val::Px(<span class="hljs-number">10.0</span>),
            ..default()
        },
        TextFont {
            font_size: <span class="hljs-number">16.0</span>,
            ..default()
        },
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_time</span></span>(
    time: Res&lt;Time&gt;,
    camera_query: Query&lt;&amp;Transform, With&lt;Camera3d&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;PulseSphereMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> <span class="hljs-literal">Ok</span>(camera_transform) = camera_query.single() <span class="hljs-keyword">else</span> {
        <span class="hljs-keyword">return</span>;
    };
    <span class="hljs-keyword">let</span> camera_pos = camera_transform.translation;

    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        material.uniforms.time = time.elapsed_secs();
        material.uniforms.camera_position = camera_pos;
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    time: Res&lt;Time&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;PulseSphereMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> delta = time.delta_secs();

    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-comment">// Switch deformation mode</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit1) {
            material.uniforms.deformation_mode = <span class="hljs-number">0</span>; <span class="hljs-comment">// Pulsate</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit2) {
            material.uniforms.deformation_mode = <span class="hljs-number">1</span>; <span class="hljs-comment">// Wave</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit3) {
            material.uniforms.deformation_mode = <span class="hljs-number">2</span>; <span class="hljs-comment">// Twist</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit4) {
            material.uniforms.deformation_mode = <span class="hljs-number">3</span>; <span class="hljs-comment">// Combined</span>
        }

        <span class="hljs-comment">// Adjust pulse speed</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyQ) {
            material.uniforms.pulse_speed = (material.uniforms.pulse_speed - delta * <span class="hljs-number">2.0</span>).max(<span class="hljs-number">0.1</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyW) {
            material.uniforms.pulse_speed = (material.uniforms.pulse_speed + delta * <span class="hljs-number">2.0</span>).min(<span class="hljs-number">10.0</span>);
        }

        <span class="hljs-comment">// Adjust pulse amplitude</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyA) {
            material.uniforms.pulse_amplitude =
                (material.uniforms.pulse_amplitude - delta * <span class="hljs-number">0.5</span>).max(<span class="hljs-number">0.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyS) {
            material.uniforms.pulse_amplitude =
                (material.uniforms.pulse_amplitude + delta * <span class="hljs-number">0.5</span>).min(<span class="hljs-number">1.0</span>);
        }

        <span class="hljs-comment">// Adjust wave frequency</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyZ) {
            material.uniforms.wave_frequency =
                (material.uniforms.wave_frequency - delta * <span class="hljs-number">2.0</span>).max(<span class="hljs-number">0.5</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyX) {
            material.uniforms.wave_frequency =
                (material.uniforms.wave_frequency + delta * <span class="hljs-number">2.0</span>).min(<span class="hljs-number">10.0</span>);
        }

        <span class="hljs-comment">// Adjust twist amount</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyC) {
            material.uniforms.twist_amount = (material.uniforms.twist_amount - delta).max(<span class="hljs-number">0.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyV) {
            material.uniforms.twist_amount = (material.uniforms.twist_amount + delta).min(<span class="hljs-number">5.0</span>);
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rotate_camera</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> camera_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Transform, With&lt;Camera3d&gt;&gt;) {
    <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> transform <span class="hljs-keyword">in</span> camera_query.iter_mut() {
        <span class="hljs-keyword">let</span> angle = time.elapsed_secs() * <span class="hljs-number">0.2</span>;
        <span class="hljs-keyword">let</span> radius = <span class="hljs-number">18.0</span>;
        <span class="hljs-keyword">let</span> height = <span class="hljs-number">12.0</span>;

        transform.translation.x = angle.cos() * radius;
        transform.translation.z = angle.sin() * radius;
        transform.translation.y = height;

        transform.look_at(Vec3::ZERO, Vec3::Y);
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(materials: Res&lt;Assets&lt;PulseSphereMaterial&gt;&gt;, <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;) {
    <span class="hljs-keyword">if</span> !materials.is_changed() {
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, material)) = materials.iter().next() {
        <span class="hljs-keyword">let</span> mode_name = <span class="hljs-keyword">match</span> material.uniforms.deformation_mode {
            <span class="hljs-number">0</span> =&gt; <span class="hljs-string">"Pulsate"</span>,
            <span class="hljs-number">1</span> =&gt; <span class="hljs-string">"Wave"</span>,
            <span class="hljs-number">2</span> =&gt; <span class="hljs-string">"Twist"</span>,
            <span class="hljs-number">3</span> =&gt; <span class="hljs-string">"Combined"</span>,
            _ =&gt; <span class="hljs-string">"Unknown"</span>,
        };

        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
            **text = <span class="hljs-built_in">format!</span>(
                <span class="hljs-string">"[1-4] Deformation Mode | [Q/W] Pulse Speed | [A/S] Amplitude\n\
                 [Z/X] Wave Frequency | [C/V] Twist Amount\n\
                 \n\
                 Mode: {} | Speed: {:.1} | Amplitude: {:.2}\n\
                 Wave Freq: {:.1} | Twist: {:.1}"</span>,
                mode_name,
                material.uniforms.pulse_speed,
                material.uniforms.pulse_amplitude,
                material.uniforms.wave_frequency,
                material.uniforms.twist_amount,
            );
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other demos</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_04_pulsating_sphere;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"2.4"</span>,
    title: <span class="hljs-string">"Simple Vertex Deformations"</span>,
    run: demos::d02_04_pulsating_sphere::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>When you run the application, you will see a grid of 25 spheres, each with a different vibrant color. They will initially be in "Pulsate" mode, gently breathing in and out, but with each sphere slightly out of sync with its neighbors. Use the keyboard controls to explore the different effects.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Keys</td><td>Action</td></tr>
</thead>
<tbody>
<tr>
<td>1-4</td><td>Switch Deformation Mode (1: Pulsate, 2: Wave, 3: Twist, 4: Combined)</td></tr>
<tr>
<td>Q / W</td><td>Decrease / Increase animation speed</td></tr>
<tr>
<td>A / S</td><td>Decrease / Increase animation amplitude/strength</td></tr>
<tr>
<td>Z / X</td><td>Decrease / Increase wave frequency (for Wave mode)</td></tr>
<tr>
<td>C / V</td><td>Decrease / Increase twist amount (for Twist mode)</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762818398186/f28a3f5f-e5cd-4710-8135-3a9f9bc3eb6a.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762818412725/c139ec9d-f83a-4abd-80ef-4fc54865bbcb.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762818422215/ad5b56c6-6613-427c-9e48-e984ab9bcc78.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762818437558/ccd5f787-c33f-4f1d-a3cc-5b6bf08bb2ed.png" alt class="image--center mx-auto" /></p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Mode</td><td>What to Observe</td><td>Concept Illustrated</td></tr>
</thead>
<tbody>
<tr>
<td><strong>1: Pulsate</strong></td><td>The spheres grow and shrink uniformly from their centers. Note that the specular highlight on top remains perfectly stable and round.</td><td><strong>Uniform Scaling.</strong> Because this deformation preserves the direction of normals, no complex normal recalculation is needed.</td></tr>
<tr>
<td><strong>2: Wave</strong></td><td>A ripple effect expands from the center of each sphere and travels down its surface. Watch the specular highlight - it realistically stretches and moves with the surface curvature.</td><td><strong>Normal-based Displacement &amp; Normal Recalculation.</strong> The displacement is along the normal for an organic look, and the normals are perturbed using the derivative of the sine wave to keep lighting correct.</td></tr>
<tr>
<td><strong>3: Twist</strong></td><td>The spheres twist around their vertical axis. The highlight and shading correctly wrap around the twisted shape.</td><td><strong>Rotational Deformation.</strong> Both vertex positions and normals are rotated around the Y-axis, with the angle of rotation based on the vertex's height.</td></tr>
<tr>
<td><strong>4: Combined</strong></td><td>A complex, chaotic motion that layers all three effects.</td><td><strong>Composition.</strong> Simple, independent deformations can be added together to create rich and complex-looking effects without needing to write entirely new logic.</td></tr>
<tr>
<td><strong>All Modes</strong></td><td>Notice that no two spheres are animating in perfect sync, and each has a unique color.</td><td><strong>Per-Instance Variation.</strong> The <code>instance_index</code> is used to create a phase offset for the animation and to seed a hash function for the color, bringing life and variety to the scene.</td></tr>
</tbody>
</table>
</div><h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>You have now taken a significant step from simply moving objects around to fundamentally reshaping them on the GPU. This is a cornerstone of modern real-time graphics. Before moving on to the next chapter, let's solidify the core concepts you've learned.</p>
<ol>
<li><p><strong>Vertex Deformation is</strong> <code>position + offset</code>: At its core, all vertex deformation is the process of calculating an offset vector and adding it to the original vertex position. The creativity lies in how you calculate that offset.</p>
</li>
<li><p><strong>Deform in Local Space:</strong> For effects that are intrinsic to an object (pulsing, breathing, waving), always perform deformations in local space before applying the Model-View-Projection matrices. This ensures the effect scales, rotates, and moves correctly with the object.</p>
</li>
<li><p><strong>Sine Waves are Your Friend:</strong> The <code>sin()</code> function is the workhorse of procedural animation. By mastering its parameters - amplitude, frequency, and speed - you can create a vast array of organic, oscillating motions.</p>
</li>
<li><p><strong>Don't Forget the Normals:</strong> Changing a vertex's position changes the surface. If you don't update the vertex's normal to match, your lighting will be incorrect. This is one of the most common mistakes beginners make.</p>
</li>
<li><p><strong>instance_index Creates Variety:</strong> Use the <code>@builtin(instance_index)</code> to break up robotic synchronization. It is the key to making a scene feel natural and alive by giving each object a unique animation phase, color, or behavior.</p>
</li>
<li><p><strong>Performance Matters:</strong> Vertex shaders run millions of times per second. Be mindful of expensive operations. As we'll see in a later article, moving calculations that are constant for all vertices to the CPU is a key optimization.</p>
</li>
<li><p><strong>Simple Effects Compose:</strong> Complex motion can be achieved by layering multiple simple effects. A pulsation combined with a twist and a wave creates a result that is far more interesting than any of its individual parts.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>You now have the power to bring your static meshes to life with procedural motion. We've mastered the smooth, predictable power of sine waves, but the world isn't always so orderly. In the next chapter, we will build on these fundamentals by exploring more complex and organic displacement techniques. We'll learn how to use noise functions and texture lookups to create less uniform and more natural-looking surface details, like billowing flags and detailed terrain.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/25-advanced-vertex-displacement"><strong><em>2.5 - Advanced Vertex Displacement</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<h3 id="heading-basic-sine-wave">Basic Sine Wave</h3>
<p>Displaces a vertex's <code>y</code> coordinate based on its <code>x</code> coordinate and time.</p>
<pre><code class="lang-rust">deformed_position.y += sin(position.x * frequency + time * speed) * amplitude;
</code></pre>
<h3 id="heading-uniform-scaling">Uniform Scaling</h3>
<p>Multiplies the entire position vector to expand or shrink the mesh from its center.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> scale = <span class="hljs-number">1.0</span> + sin(time) * amount;
deformed_position = position * scale;
</code></pre>
<h3 id="heading-radial-wave">Radial Wave</h3>
<p>Creates a ripple that expands from the local Y-axis.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> distance = length(position.xz);
deformed_position.y += sin(distance * frequency - time * speed) * amplitude;
</code></pre>
<h3 id="heading-twist">Twist</h3>
<p>Rotates the XZ plane of a vertex based on its height (<code>y</code> coordinate).</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> angle = position.y * twist_amount * sin(time);
<span class="hljs-keyword">let</span> cos_a = cos(angle);
<span class="hljs-keyword">let</span> sin_a = sin(angle);
<span class="hljs-keyword">let</span> new_x = position.x * cos_a - position.z * sin_a;
<span class="hljs-keyword">let</span> new_z = position.x * sin_a + position.z * cos_a;
deformed_position.xz = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(new_x, new_z);
</code></pre>
<h3 id="heading-per-instance-randomness">Per-Instance Randomness</h3>
<p>Generates a consistent "random" float between 0.0 and 1.0 for each instance.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash</span></span>(index: <span class="hljs-built_in">u32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> n = <span class="hljs-built_in">f32</span>(index) * <span class="hljs-number">12.9898</span>;
    <span class="hljs-keyword">return</span> fract(sin(n) * <span class="hljs-number">43758.5453</span>);
}
<span class="hljs-keyword">let</span> random_val = hash(instance_index);
</code></pre>
<h3 id="heading-the-deformation-pipeline-order">The Deformation Pipeline Order</h3>
<p>The canonical order of operations for a vertex shader with local-space deformation.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// 1. Read original vertex attributes (position, normal).</span>
<span class="hljs-keyword">let</span> original_pos = <span class="hljs-keyword">in</span>.position;
<span class="hljs-keyword">let</span> original_normal = <span class="hljs-keyword">in</span>.normal;

<span class="hljs-comment">// 2. Apply deformation logic (in local space).</span>
<span class="hljs-keyword">let</span> deformed_pos = apply_deformation(original_pos, time);
<span class="hljs-keyword">let</span> deformed_normal = calculate_new_normal(original_pos, original_normal, time);

<span class="hljs-comment">// 3. Transform position and normal to world space using Bevy's helpers.</span>
<span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
<span class="hljs-keyword">let</span> world_pos = mesh_functions::mesh_position_local_to_world(model, vec4&lt;<span class="hljs-built_in">f32</span>&gt;(deformed_pos, <span class="hljs-number">1.0</span>));
<span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(deformed_normal, <span class="hljs-keyword">in</span>.instance_index);

<span class="hljs-comment">// 4. Transform to clip space for the rasterizer.</span>
<span class="hljs-keyword">let</span> clip_pos = position_world_to_clip(world_pos.xyz);
</code></pre>
]]></content:encoded></item><item><title><![CDATA[2.3 - Working with Vertex Attributes]]></title><description><![CDATA[What We're Learning
In our journey so far, we've treated vertices as simple points in space, focusing exclusively on how to transform their POSITION from local coordinates to the screen. But a vertex is much more than a point; it's a rich packet of i...]]></description><link>https://blog.hexbee.net/23-working-with-vertex-attributes</link><guid isPermaLink="true">https://blog.hexbee.net/23-working-with-vertex-attributes</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Fri, 07 Nov 2025 23:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1762681010422/8a335456-2a92-47e5-bc49-19b26aaed15e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>In our journey so far, we've treated vertices as simple points in space, focusing exclusively on how to transform their <code>POSITION</code> from local coordinates to the screen. But a vertex is much more than a point; it's a rich packet of information that describes the properties of a mesh's surface at that specific location. Beyond position, vertices carry data like normals for lighting, UV coordinates for texturing, and even custom colors.</p>
<p><strong>Vertex attributes are the bridge between your CPU-side mesh data (in Rust) and your GPU-side shader program (in WGSL).</strong> They are the mechanism by which you send this detailed, per-vertex information across the pipeline. Mastering them is the key to unlocking materials that are not uniform, but instead have rich, varied surfaces that respond to light, display complex textures, and feature intricate, procedurally generated patterns.</p>
<p>By the end of this article, you'll have a firm grasp of this fundamental concept. You will learn:</p>
<ul>
<li><p>The purpose of standard vertex attributes: <code>POSITION</code>, <code>NORMAL</code>, <code>UV_0</code>, and <code>COLOR</code>.</p>
</li>
<li><p>How to read and use normals for foundational lighting calculations.</p>
</li>
<li><p>The role of UV coordinates in creating procedural, surface-aligned patterns.</p>
</li>
<li><p>How to use per-vertex colors for efficient, texture-free gradients.</p>
</li>
<li><p>The critical concept of interpolation and how data flows from the vertex shader to the fragment shader.</p>
</li>
<li><p>How to control interpolation with the <code>@interpolate</code> attribute for effects like flat shading.</p>
</li>
<li><p>The complete workflow for creating and using custom vertex attributes, including the crucial <code>specialize</code> method.</p>
</li>
<li><p>How to visualize abstract vertex data (like normals or UVs) as color for debugging and artistic effects.</p>
</li>
</ul>
<h2 id="heading-understanding-vertex-attributes">Understanding Vertex Attributes</h2>
<p>Every vertex in a 3D mesh is more than just a point in space; it's a data packet containing all the information needed to describe the surface at that exact location. When you load a <code>.gltf</code> file or create a <code>Mesh</code> in Bevy, you are defining lists of these attributes. Bevy then efficiently streams this data to the GPU, where the vertex shader's first job is to receive and interpret it.</p>
<h3 id="heading-the-standard-attribute-set">The Standard Attribute Set</h3>
<p>While you can create any custom attributes you like, Bevy defines a standard set for the most common rendering tasks. When you create or load a mesh, it will typically have some combination of these:</p>
<ul>
<li><p><code>POSITION</code>: A <code>vec3&lt;f32&gt;</code> representing the location of the vertex in its local (model) space. This is the only truly required attribute.</p>
</li>
<li><p><code>NORMAL</code>: A <code>vec3&lt;f32&gt;</code> vector indicating the direction the surface is facing. This is essential for all lighting calculations.</p>
</li>
<li><p><code>UV_0</code>: A <code>vec2&lt;f32&gt;</code> representing 2D texture coordinates. It's the "address" of a pixel on a texture image, used to wrap the image around the model. (The <code>_0</code> implies you can have more, like <code>UV_1</code>, for advanced techniques).</p>
</li>
<li><p><code>COLOR</code>: A <code>vec4&lt;f32&gt;</code> that assigns a specific color to the vertex. This allows for creating smooth color gradients across a surface without needing a texture.</p>
</li>
<li><p><code>TANGENT</code>: A <code>vec4&lt;f32&gt;</code> used for more advanced lighting techniques like normal mapping, which we'll cover in a later phase.</p>
</li>
</ul>
<p>In your Rust code, you would provide a separate list of data for each attribute you want your mesh to have.</p>
<h3 id="heading-the-vertex-input-struct-in-wgsl">The Vertex Input Struct in WGSL</h3>
<p>In your WGSL shader, you define an input struct for your <code>@vertex</code> entry point to receive this stream of data. The <code>@location(N)</code> decorator is the critical link. It's a contract that tells the GPU which piece of the incoming vertex data to map to which struct field.</p>
<p>Think of it like a set of numbered mailboxes. When Bevy sends a vertex to the GPU, it puts the <code>POSITION</code> data in mailbox <code>0</code>, the <code>NORMAL</code> data in mailbox <code>1</code>, and so on. Your shader's <code>VertexInput</code> struct must then go to the correct mailbox to retrieve the data it needs.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// This WGSL struct is the "receiver" for the data sent from Bevy.</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    <span class="hljs-comment">// @builtin(instance_index) is provided by the GPU, not the mesh.</span>
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,

    <span class="hljs-comment">// The @location(N) numbers are the key. They MUST match Bevy's layout.</span>
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// Receives ATTRIBUTE_POSITION</span>
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,   <span class="hljs-comment">// Receives ATTRIBUTE_NORMAL</span>
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,       <span class="hljs-comment">// Receives ATTRIBUTE_UV_0</span>
    @location(<span class="hljs-number">3</span>) color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,    <span class="hljs-comment">// Receives ATTRIBUTE_COLOR</span>
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    <span class="hljs-comment">// You can now access the attributes for the current vertex via the 'in' parameter.</span>
    <span class="hljs-keyword">let</span> local_position = <span class="hljs-keyword">in</span>.position;
    <span class="hljs-keyword">let</span> vertex_normal = <span class="hljs-keyword">in</span>.normal;
    <span class="hljs-keyword">let</span> texture_coords = <span class="hljs-keyword">in</span>.uv;
    <span class="hljs-keyword">let</span> vertex_color = <span class="hljs-keyword">in</span>.color;

    <span class="hljs-comment">// ... continue to transform and use them ...</span>
}
</code></pre>
<p>This mapping is non-negotiable and a common source of bugs. For a standard Bevy <code>Mesh</code>, the data is laid out in a specific order, and your shader must respect it. If you declare <code>@location(1)</code> as a <code>vec2&lt;f32&gt;</code> but the mesh provides a three-component <code>NORMAL</code> vector at that location, the data types will not align. This can cause your shader to fail compilation or, in more subtle cases, to run but produce visual garbage as it misinterprets the raw data.</p>
<p>The standard layout is:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Location</td><td>Bevy Attribute</td><td>WGSL Type</td></tr>
</thead>
<tbody>
<tr>
<td>0</td><td><code>Mesh::ATTRIBUTE_POSITION</code></td><td><code>vec3&lt;f32&gt;</code></td></tr>
<tr>
<td>1</td><td><code>Mesh::ATTRIBUTE_NORMAL</code></td><td><code>vec3&lt;f32&gt;</code></td></tr>
<tr>
<td>2</td><td><code>Mesh::ATTRIBUTE_UV_0</code></td><td><code>vec2&lt;f32&gt;</code></td></tr>
<tr>
<td>3</td><td><code>Mesh::ATTRIBUTE_COLOR</code></td><td><code>vec4&lt;f32&gt;</code></td></tr>
</tbody>
</table>
</div><p>If a mesh doesn't have a particular attribute (for example, vertex colors), you simply omit that field from your <code>VertexInput</code> struct. As we'll see later, custom attributes should always start at a location number after the standard ones your mesh uses to avoid conflicts.</p>
<h2 id="heading-working-with-normals">Working with Normals</h2>
<p>Normals are vectors that define the orientation of a surface at a specific point. The term "normal" is a synonym for "perpendicular" in geometry. For any given triangle in your mesh, a normal is a vector that points straight out from its surface. They are the single most important piece of data for creating believable lighting.</p>
<h3 id="heading-what-normals-represent">What Normals Represent</h3>
<p>Imagine a flat tabletop. The normal vector at any point on that table would point straight up towards the ceiling. If you tilt the table, the normal vector tilts with it, always staying perpendicular to the surface.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764413039855/a6a10482-684d-4093-a8c2-9d318455f113.png" alt class="image--center mx-auto" /></p>
<p>Each vertex in a mesh stores a normal, and how these normals are calculated determines how the surface is shaded:</p>
<ul>
<li><p><strong>Flat Shading:</strong> For a mesh with sharp, flat faces (like a cube), the normals of all vertices on a given face will point in the exact same direction. This creates a faceted, low-poly look where each face has a uniform brightness.</p>
</li>
<li><p><strong>Smooth Shading:</strong> For a mesh with a smooth, curved surface (like a sphere), the normal at each vertex is an average of the normals of the surrounding faces. This allows light to transition smoothly across the surface, creating the illusion of a continuous curve. This is the default for most 3D models.</p>
</li>
</ul>
<h3 id="heading-reading-and-transforming-normals">Reading and Transforming Normals</h3>
<p>Just like vertex positions, normals are initially defined in local (model) space and must be transformed into world space for lighting calculations to be consistent. However, a critical pitfall awaits: <strong>you cannot transform a normal vector with the same model matrix you use for position.</strong></p>
<p>Why? Because normals represent direction, not location. Imagine a sphere that you scale to be half as tall (a non-uniform scale). The vertex positions are correctly squashed. But if you apply that same squash operation to the normals on the side of the sphere, they will be incorrectly tilted inwards instead of pointing straight out from the new, flattened surface. The correct transformation requires a special matrix, often called the "normal matrix," which is the transpose of the inverse of the model matrix.</p>
<p>Thankfully, you don't need to calculate this yourself. Bevy's standard shader imports provide a dedicated helper function that handles this complexity for you.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In the vertex shader...</span>
#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

<span class="hljs-comment">// ...</span>

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-comment">// First, transform the vertex position to world space as before.</span>
    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(instance_index);
    <span class="hljs-keyword">let</span> world_position = mesh_functions::mesh_position_local_to_world(
        model,
        vec4&lt;<span class="hljs-built_in">f32</span>&gt;(position, <span class="hljs-number">1.0</span>)
    );

    <span class="hljs-comment">// Now, transform the normal to world space using Bevy's dedicated helper.</span>
    <span class="hljs-comment">// This function correctly handles non-uniform scaling behind the scenes.</span>
    <span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(
        normal,
        instance_index
    );

    <span class="hljs-comment">// Pass the transformed normal to the fragment shader.</span>
    out.world_normal = world_normal;
    out.position = position_world_to_clip(world_position.xyz);
    out.world_position = world_position.xyz; <span class="hljs-comment">// Also pass world position</span>
    <span class="hljs-keyword">return</span> out;
}
</code></pre>
<h4 id="heading-key-principles-for-handling-normals">Key Principles for Handling Normals</h4>
<ol>
<li><p><strong>Use the Right Function:</strong> Always use <code>mesh_functions::mesh_normal_local_to_world</code> to transform normals. Do not multiply them by the standard model matrix.</p>
</li>
<li><p><strong>Always Normalize:</strong> Transformations and, as we'll see soon, interpolation can introduce tiny floating-point errors that cause a vector to no longer have a perfect length of 1.0. The math for lighting relies on normals being "unit vectors" (length of 1.0). You must <code>normalize()</code> them in the fragment shader before any lighting calculation to guarantee correct results.</p>
</li>
</ol>
<h3 id="heading-using-normals-for-lighting">Using Normals for Lighting</h3>
<p>Once the world_normal arrives in the fragment shader (smoothly interpolated across the triangle's surface), we can use it to calculate lighting. The simplest lighting model is called <a target="_blank" href="https://en.wikipedia.org/wiki/Lambertian_reflectance"><strong>Lambertian diffuse lighting</strong></a>. This model describes how light reflects equally in all directions from a matte surface, like chalk or unfinished wood.</p>
<p>The brightness of the surface depends entirely on the angle between its normal and the direction to the light source. The <code>dot()</code> product is the perfect mathematical tool for this, as it tells us how much two vectors are pointing in the same direction.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In the fragment shader...</span>
@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Get a reliable, unit-length normal vector for this specific pixel.</span>
    <span class="hljs-comment">// This is the most important step for correct lighting.</span>
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);

    <span class="hljs-comment">// 2. Define a fixed direction for a light source (e.g., up and to the right).</span>
    <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));

    <span class="hljs-comment">// 3. Calculate the dot product between the normal and the light direction.</span>
    <span class="hljs-comment">// - The result is 1.0 if they are parallel (light hits head-on).</span>
    <span class="hljs-comment">// - The result is 0.0 if they are perpendicular (light glances off the edge).</span>
    <span class="hljs-comment">// - The result is negative if they point in opposite directions (surface is in shadow).</span>
    <span class="hljs-comment">// We use max(..., 0.0) to clamp the result, ensuring surfaces facing</span>
    <span class="hljs-comment">// away from the light are just black, not "negatively" lit.</span>
    <span class="hljs-keyword">let</span> diffuse_intensity = max(<span class="hljs-number">0.0</span>, dot(normal, light_dir));

    <span class="hljs-comment">// 4. Add a little ambient light so the dark side isn't pure black.</span>
    <span class="hljs-keyword">let</span> ambient_light = <span class="hljs-number">0.2</span>;
    <span class="hljs-keyword">let</span> lighting_factor = ambient_light + diffuse_intensity * <span class="hljs-number">0.8</span>;

    <span class="hljs-comment">// 5. Apply the calculated lighting to a base color by multiplication.</span>
    <span class="hljs-keyword">let</span> base_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.8</span>, <span class="hljs-number">0.3</span>, <span class="hljs-number">0.3</span>); <span class="hljs-comment">// A simple red</span>
    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(base_color * lighting_factor, <span class="hljs-number">1.0</span>);
}
</code></pre>
<p>This simple model is the foundation of almost all real-time 3D lighting. By comparing the surface normal to the light direction, we can determine how much light a pixel should receive, bringing our 3D objects to life.</p>
<h2 id="heading-uv-coordinates-mapping-2d-to-3d">UV Coordinates: Mapping 2D to 3D</h2>
<p>While normals are essential for lighting, <strong>UV coordinates</strong> are the foundation of texturing. They solve the fundamental problem of how to wrap a flat, 2D image (like a JPEG or PNG) onto the surface of a complex 3D model. The name "UV" is used simply to distinguish these 2D coordinates from the 3D <code>XYZ</code> coordinates of the vertex position.</p>
<h3 id="heading-understanding-uv-space">Understanding UV Space</h3>
<p>Think of UV coordinates as a set of instructions. Each vertex in a 3D mesh is assigned a 2D coordinate that corresponds to a specific point on a 2D texture. This process, called "UV unwrapping," is typically done in 3D modeling software like Blender, where an artist effectively flattens the 3D model's surface into a 2D pattern.</p>
<p>The UV coordinate system is normalized, meaning it's a square where both the U (horizontal) and V (vertical) axes range from <code>0.0</code> to <code>1.0</code>, regardless of the texture's pixel dimensions.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764413271838/15c9846d-d980-4835-94af-4589e9e518c5.png" alt class="image--center mx-auto" /></p>
<p>By assigning a UV coordinate to each vertex of a triangle, you are "pinning" that part of the 3D model to a corresponding point on the 2D texture. The GPU then automatically stretches and fits the texture image across the triangle's surface based on these pins.</p>
<h3 id="heading-basic-uv-usage">Basic UV Usage</h3>
<p>For the most part, the vertex shader's job is simply to receive the UV coordinates from the mesh and pass them directly through to the fragment shader. They are 2D data and don't require the 3D transformations that positions and normals do. The real work happens in the fragment shader, where the interpolated UVs are used to sample (read the color from) a texture.</p>
<p>Even without a texture, we can use the interpolated UV values to generate procedural patterns, which is a powerful way to create interesting surfaces without relying on image files.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Input struct must include the uv attribute.</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    <span class="hljs-comment">// ...</span>
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// Receives Mesh::ATTRIBUTE_UV_0</span>
}

<span class="hljs-comment">// Output struct must have a field to pass the UVs along.</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    <span class="hljs-comment">// ...</span>
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// Pass the UVs to the fragment stage</span>
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;
    <span class="hljs-comment">// ... transform position and normal ...</span>

    <span class="hljs-comment">// UVs are usually just passed through directly.</span>
    out.uv = <span class="hljs-keyword">in</span>.uv;

    <span class="hljs-keyword">return</span> out;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// The `in.uv` value received here has been smoothly interpolated</span>
    <span class="hljs-comment">// for every single pixel across the triangle's surface.</span>

    <span class="hljs-comment">// Let's create a procedural checkerboard pattern.</span>
    <span class="hljs-comment">// `* 8.0` makes the pattern repeat 8 times across the surface.</span>
    <span class="hljs-keyword">let</span> scaled_uv = <span class="hljs-keyword">in</span>.uv * <span class="hljs-number">8.0</span>;

    <span class="hljs-comment">// `fract()` gives the fractional part, creating a repeating 0-1 gradient.</span>
    <span class="hljs-keyword">let</span> sawtooth_wave = fract(scaled_uv);

    <span class="hljs-comment">// `step(0.5, ...)` converts the gradient into a square wave (0 or 1).</span>
    <span class="hljs-keyword">let</span> square_wave = step(vec2(<span class="hljs-number">0.5</span>), sawtooth_wave);

    <span class="hljs-comment">// Combine the x and y components to create the 2D checkerboard.</span>
    <span class="hljs-comment">// XORing the components (a * (1-b) + (1-a) * b) does the trick.</span>
    <span class="hljs-keyword">let</span> checker = square_wave.x * (<span class="hljs-number">1.0</span> - square_wave.y) + (<span class="hljs-number">1.0</span> - square_wave.x) * square_wave.y;

    <span class="hljs-keyword">return</span> vec4(vec3(checker), <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-uv-manipulation-techniques">UV Manipulation Techniques</h3>
<p>The real power of UVs in shaders comes from manipulating them before you sample a texture or generate a pattern. This allows for a huge range of dynamic and procedural effects.</p>
<h4 id="heading-tiling">Tiling</h4>
<p>Multiply the UVs by a scalar to make the texture or pattern repeat.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// The texture will now appear tiled 4 times in each direction.</span>
<span class="hljs-keyword">let</span> tiled_uv = <span class="hljs-keyword">in</span>.uv * <span class="hljs-number">4.0</span>;
</code></pre>
<h4 id="heading-scrolling">Scrolling</h4>
<p>Add a time-varying offset to the UVs to create movement, perfect for flowing water, conveyor belts, or animated backgrounds.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Scrolls the texture horizontally over time.</span>
<span class="hljs-keyword">let</span> scrolled_uv = <span class="hljs-keyword">in</span>.uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(time * <span class="hljs-number">0.1</span>, <span class="hljs-number">0.0</span>);
</code></pre>
<h4 id="heading-distortion">Distortion</h4>
<p>Add a procedural offset (like a sine wave or noise) to warp the UVs, creating effects like heat haze or underwater ripples.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> distortion_strength = <span class="hljs-number">0.05</span>;
<span class="hljs-keyword">let</span> distortion = sin(<span class="hljs-keyword">in</span>.uv.y * <span class="hljs-number">20.0</span> + time) * distortion_strength;
<span class="hljs-keyword">let</span> distorted_uv = <span class="hljs-keyword">in</span>.uv + vec2&lt;<span class="hljs-built_in">f32</span>&gt;(distortion, <span class="hljs-number">0.0</span>);
</code></pre>
<h4 id="heading-radial-mapping">Radial Mapping</h4>
<p>Convert the Cartesian <code>(u, v)</code> coordinates into polar coordinates <code>(angle, radius)</code> to create circular patterns, radars, or ripples emanating from a central point.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>); <span class="hljs-comment">// Center of the UV space</span>
<span class="hljs-keyword">let</span> to_center = <span class="hljs-keyword">in</span>.uv - center;
<span class="hljs-keyword">let</span> angle = atan2(to_center.y, to_center.x); <span class="hljs-comment">// Angle from the center, in radians</span>
<span class="hljs-keyword">let</span> radius = length(to_center); <span class="hljs-comment">// Distance from the center</span>
</code></pre>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> center = vec2&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>); <span class="hljs-comment">// Center of the UV space</span>
<span class="hljs-keyword">let</span> to_center = <span class="hljs-keyword">in</span>.uv - center;
<span class="hljs-keyword">let</span> angle = atan2(to_center.y, to_center.x); <span class="hljs-comment">// Angle from the center, in radians</span>
<span class="hljs-keyword">let</span> radius = length(to_center); <span class="hljs-comment">// Distance from the center</span>
</code></pre>
<h2 id="heading-vertex-colors-artistic-freedom">Vertex Colors: Artistic Freedom</h2>
<p>Vertex colors provide a powerful and efficient way to add color detail to a mesh without using any textures. Instead of sampling a color from an image, you store the color data directly in each vertex of the mesh itself. The GPU then automatically blends these colors across the face of each triangle, creating smooth and often beautiful gradients.</p>
<h3 id="heading-why-use-vertex-colors">Why Use Vertex Colors?</h3>
<p>While textures are great for photorealistic detail, vertex colors excel in several key areas:</p>
<ul>
<li><p><strong>Performance:</strong> Reading from a vertex attribute is significantly faster than performing a texture lookup in the fragment shader. For simple gradients or stylized art, this is a major performance win.</p>
</li>
<li><p><strong>Artistic Control:</strong> They are perfect for stylized aesthetics, soft gradients (like a sunset sky), or baking lighting information like ambient occlusion directly into a model. Game artists often use vertex painting tools in Blender to "paint" colors onto a model's corners, adding unique detail and variation.</p>
</li>
<li><p><strong>Data Channel:</strong> The <code>vec4&lt;f32&gt;</code> color attribute doesn't have to be used for color! It can be a convenient channel to pass any four floating-point values per vertex, such as animation weights, wind strength, or material properties that can be interpreted by the shader.</p>
</li>
</ul>
<h3 id="heading-reading-vertex-colors">Reading Vertex Colors</h3>
<p>Accessing vertex colors is identical to accessing normals or UVs. You add a field to your <code>VertexInput</code> struct at <code>@location(3)</code> and create a corresponding output field to pass the data through to the fragment shader.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Add the color attribute to the input struct.</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    <span class="hljs-comment">// ...</span>
    @location(<span class="hljs-number">3</span>) color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// Receives Mesh::ATTRIBUTE_COLOR</span>
}

<span class="hljs-comment">// Add a field to the output struct to pass it along.</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    <span class="hljs-comment">// ...</span>
    @location(<span class="hljs-number">3</span>) vertex_color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// Pass interpolated color to fragment</span>
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;
    <span class="hljs-comment">// ... transformations for position and normal ...</span>

    <span class="hljs-comment">// Pass the vertex color directly to the fragment shader.</span>
    <span class="hljs-comment">// The GPU will handle the interpolation automatically.</span>
    out.vertex_color = <span class="hljs-keyword">in</span>.color;

    <span class="hljs-keyword">return</span> out;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 'in.vertex_color' is the smoothly blended color for this specific pixel.</span>
    <span class="hljs-comment">// If the triangle's vertices were red, green, and blue, this value might be</span>
    <span class="hljs-comment">// orange, purple, or cyan depending on the pixel's location.</span>
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">in</span>.vertex_color;
}
</code></pre>
<h3 id="heading-combining-vertex-colors-with-other-data">Combining Vertex Colors with Other Data</h3>
<p>The real power comes from using vertex colors as the <strong>base color</strong> for other calculations, like lighting. Instead of using a hard-coded color in the fragment shader, you use the interpolated vertex color. This is called <strong>modulation</strong>: you modulate (multiply) the vertex color with the lighting value.</p>
<pre><code class="lang-rust">@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Calculate lighting factor as before.</span>
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);
    <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.0</span>, dot(normal, light_dir));
    <span class="hljs-keyword">let</span> lighting_factor = <span class="hljs-number">0.3</span> + diffuse * <span class="hljs-number">0.7</span>; <span class="hljs-comment">// ambient + diffuse</span>

    <span class="hljs-comment">// 2. Modulate the interpolated vertex color by the lighting factor.</span>
    <span class="hljs-comment">// We multiply the RGB components, leaving alpha untouched.</span>
    <span class="hljs-keyword">let</span> lit_color = <span class="hljs-keyword">in</span>.vertex_color.rgb * lighting_factor;

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(lit_color, <span class="hljs-keyword">in</span>.vertex_color.a);
}
</code></pre>
<p>This simple multiplication correctly applies the calculated lighting to the smoothly blended base color from the mesh. A brightly lit area will show the vertex color at full intensity, while a shadowed area will show a darker shade of that same interpolated color, creating a cohesive and beautifully shaded result.</p>
<h2 id="heading-controlling-data-flow-the-interpolation-stage">Controlling Data Flow: The Interpolation Stage</h2>
<p>We've seen how to read attributes like normals, UVs, and colors in the vertex shader. But how do those values, defined only at the corners of a triangle, become the smooth, continuous surfaces we see in the fragment shader?</p>
<p>The answer, as we first introduced in <a target="_blank" href="https://hexbee.hashnode.dev/16-shader-attributes-and-data-flow">article 1.6</a>, is <strong>interpolation</strong>. After the vertex shader runs for all three vertices of a triangle, the GPU's rasterizer hardware takes over. For every pixel covered by that triangle, it automatically generates a blended value for each output of your vertex shader.</p>
<p>In this article, we're not just passing position; we're passing normals, UVs, and colors. The interpolation mode has a dramatic and direct effect on how this attribute data is perceived by the user. WGSL gives us the <code>@interpolate</code> attribute to control this process.</p>
<h3 id="heading-perspective-the-default-for-3d-surfaces"><code>perspective</code>: The Default for 3D Surfaces</h3>
<p>By default, all outputs are interpolated with <code>perspective</code> correction. This is the highest-quality mode and is essential for any data that "sticks" to the 3D surface.</p>
<ul>
<li><p><strong>For UVs:</strong> Without perspective correction, textures on a surface receding into the distance would appear to warp and "swim" incorrectly. This mode is non-negotiable for correct texture mapping.</p>
</li>
<li><p><strong>For Normals &amp; Colors:</strong> It ensures that lighting and color gradients appear stable and are calculated correctly in 3D space.</p>
</li>
</ul>
<p>For 99% of the attributes you pass from a 3D model, you will want the default perspective-correct behavior.</p>
<h3 id="heading-flat-disabling-interpolation-for-a-purpose"><code>flat</code>: Disabling Interpolation for a Purpose</h3>
<p>There are times when you want to prevent blending entirely. <code>@interpolate(flat)</code> instructs the GPU that every pixel within a triangle should receive the exact, unmodified value from a single "provoking vertex" (usually the first vertex of the triangle).</p>
<p>The most common use case is to take a mesh with smooth vertex normals and render it with <strong>flat shading</strong> for a faceted, low-poly aesthetic. You are telling the GPU: "Take the normal from the first vertex of this triangle and use that same normal for the entire face."</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In your VertexOutput struct</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    <span class="hljs-comment">// ...</span>
    @location(<span class="hljs-number">0</span>) @interpolate(flat) flat_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;
    <span class="hljs-comment">// ... calculate world_normal ...</span>

    <span class="hljs-comment">// We send the vertex's normal to this output.</span>
    <span class="hljs-comment">// The @interpolate(flat) attribute ensures it won't be blended.</span>
    out.flat_normal = world_normal;
    <span class="hljs-keyword">return</span> out;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// The value of in.flat_normal is identical for every pixel of this triangle.</span>
    <span class="hljs-comment">// We can use it directly for lighting to get a faceted look.</span>
    <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.0</span>, dot(<span class="hljs-keyword">in</span>.flat_normal, light_dir));
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<p>This is also essential for passing discrete data that must not be blended, like a material type ID (u32). You cannot average ID=2 and ID=5 to get a meaningful result.</p>
<h3 id="heading-linear-the-niche-case-for-screen-space-effects"><code>linear</code>: The Niche Case for Screen-Space Effects</h3>
<p><code>@interpolate(linear)</code> performs a simple 2D blend across the screen. It's faster but incorrect for 3D surfaces. Its primary use is for effects that are detached from the 3D geometry and operate purely in screen space, like a UI vignette where you want to darken the corners of the viewport. We will revisit this in the post-processing phase.</p>
<h2 id="heading-creating-custom-vertex-attributes">Creating Custom Vertex Attributes</h2>
<p>While Bevy's standard attributes cover the most common rendering needs, you are not limited to them. You can define and send almost any per-vertex data you can imagine from your Rust code to your shader. This unlocks a vast range of possibilities for unique visuals and gameplay mechanics that are directly tied to your mesh data.</p>
<p>For example, you could store:</p>
<ul>
<li><p>Wind strength or sway phase to animate foliage.</p>
</li>
<li><p>Bone indices and weights for skeletal animation.</p>
</li>
<li><p>A "wetness" or "temperature" value to visualize environmental interactions.</p>
</li>
<li><p>Surface type information (e.g., grass, rock, sand) for texture blending on terrain.</p>
</li>
</ul>
<p>The process involves three key steps: defining the attribute in Rust, adding the data to a <code>Mesh</code>, and, most importantly, telling your material's render pipeline how to read it.</p>
<h3 id="heading-1-defining-a-custom-attribute-in-rust">1. Defining a Custom Attribute in Rust</h3>
<p>First, you must declare your custom attribute as a static <code>MeshVertexAttribute</code>. This object serves as a unique identifier, containing a debug name, a unique ID, and the data format.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::render::mesh::MeshVertexAttribute;
<span class="hljs-keyword">use</span> bevy::render::render_resource::VertexFormat;

<span class="hljs-comment">// Define a custom attribute for a single float value per vertex.</span>
<span class="hljs-comment">// The ID (a u64) must be unique.</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">const</span> ATTRIBUTE_BLEND_WEIGHT: MeshVertexAttribute =
    MeshVertexAttribute::new(<span class="hljs-string">"BlendWeight"</span>, <span class="hljs-number">988540917</span>, VertexFormat::Float32);

<span class="hljs-comment">// Define another for a 4-component integer vector per vertex.</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">const</span> ATTRIBUTE_BONE_INDICES: MeshVertexAttribute =
    MeshVertexAttribute::new(<span class="hljs-string">"BoneIndices"</span>, <span class="hljs-number">988540918</span>, VertexFormat::Uint32x4);
</code></pre>
<ul>
<li><p><strong>Name:</strong> A string used for debugging and diagnostics.</p>
</li>
<li><p><strong>ID:</strong> A <code>u64</code> that <strong>must be unique</strong> across your entire application. If two different attributes use the same ID, Bevy's renderer will get confused. A good practice is to generate a random <code>u64</code> or use a hash of the attribute's name to ensure it doesn't collide with attributes from other plugins or parts of the engine.</p>
</li>
<li><p><strong>Format:</strong> The VertexFormat enum specifies the data type and size. This <strong>must</strong> match the type you use in your WGSL shader.</p>
</li>
</ul>
<p>The <code>VertexFormat</code> enum is a descriptor, not a Rust type itself. It tells Bevy what kind of data to expect. Here is how the formats map between your Rust code and your WGSL shader:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>VertexFormat</td><td>Data Type in Rust (<code>vec![...]</code>)</td><td>Data Type in WGSL (<code>@location(N)</code>)</td></tr>
</thead>
<tbody>
<tr>
<td><code>Float32</code></td><td><code>f32</code></td><td><code>f32</code></td></tr>
<tr>
<td><code>Float32x4</code></td><td><code>[f32; 4]</code></td><td><code>vec4&lt;f32&gt;</code></td></tr>
<tr>
<td><code>Uint32x4</code></td><td><code>[u32; 4]</code></td><td><code>vec4&lt;u32&gt;</code></td></tr>
<tr>
<td><code>Sint32x4</code></td><td><code>[i32; 4]</code></td><td><code>vec4&lt;i32&gt;</code></td></tr>
</tbody>
</table>
</div><h3 id="heading-2-adding-custom-attributes-to-a-mesh">2. Adding Custom Attributes to a Mesh</h3>
<p>Once defined, you add data to a mesh using <code>insert_attribute</code>, just as you would for standard attributes. The key is to use your newly defined constant as the attribute identifier. The <code>Vec</code> of data you provide must contain one entry for every vertex in the mesh.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// The length of this Vec must match the number of vertices in the mesh.</span>
mesh.insert_attribute(
    ATTRIBUTE_BLEND_WEIGHT,
    <span class="hljs-built_in">vec!</span>[<span class="hljs-number">0.0_f32</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">1.0</span>], <span class="hljs-comment">// One f32 for each vertex</span>
);

mesh.insert_attribute(
    ATTRIBUTE_BONE_INDICES,
    <span class="hljs-built_in">vec!</span>[
        [<span class="hljs-number">10</span>, <span class="hljs-number">2</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>], <span class="hljs-comment">// One vec4&lt;u32&gt; for each vertex</span>
        [<span class="hljs-number">5</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>],
        [<span class="hljs-number">10</span>, <span class="hljs-number">5</span>, <span class="hljs-number">8</span>, <span class="hljs-number">0</span>],
    ],
);
</code></pre>
<h3 id="heading-3-connecting-the-pipeline-with-specialize">3. Connecting the Pipeline with <code>specialize</code></h3>
<p>This is the most critical and often misunderstood step. By default, Bevy's <code>Material</code> render pipeline only knows about the standard attributes (<code>POSITION</code>, <code>NORMAL</code>, etc.). When it sees a mesh with your custom <code>ATTRIBUTE_BLEND_WEIGHT</code>, it doesn't know what to do with it. We must explicitly tell the pipeline how to handle this new data.</p>
<p>The specialize function from the <code>Material</code> trait is the hook that allows us to do this. It lets us intercept the pipeline creation process and provide a <strong>custom vertex buffer layout</strong>, creating a specialized "variant" of the pipeline just for meshes used with our material.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In your material.rs file, inside `impl Material for YourMaterial`</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">specialize</span></span>(
    _pipeline: &amp;MaterialPipeline&lt;<span class="hljs-keyword">Self</span>&gt;,
    descriptor: &amp;<span class="hljs-keyword">mut</span> RenderPipelineDescriptor,
    layout: &amp;MeshVertexBufferLayoutRef,
    _key: MaterialPipelineKey&lt;<span class="hljs-keyword">Self</span>&gt;,
) -&gt; <span class="hljs-built_in">Result</span>&lt;(), SpecializedMeshPipelineError&gt; {
    <span class="hljs-comment">// Get the layout of the specific mesh being rendered.</span>
    <span class="hljs-keyword">let</span> vertex_layout = layout.<span class="hljs-number">0</span>.get_layout(&amp;[
        <span class="hljs-comment">// This list defines the exact "contract" for our shader's VertexInput struct.</span>
        <span class="hljs-comment">// It says: "Map the mesh's POSITION data to shader location 0".</span>
        Mesh::ATTRIBUTE_POSITION.at_shader_location(<span class="hljs-number">0</span>),
        <span class="hljs-comment">// "Map the mesh's NORMAL data to shader location 1".</span>
        Mesh::ATTRIBUTE_NORMAL.at_shader_location(<span class="hljs-number">1</span>),
        <span class="hljs-comment">// "Map our custom BLEND_WEIGHT data to shader location 4".</span>
        ATTRIBUTE_BLEND_WEIGHT.at_shader_location(<span class="hljs-number">4</span>),
        <span class="hljs-comment">// "Map our custom BONE_INDICES data to shader location 5".</span>
        ATTRIBUTE_BONE_INDICES.at_shader_location(<span class="hljs-number">5</span>),
    ])?;

    <span class="hljs-comment">// Overwrite the pipeline descriptor's default vertex layout with our custom one.</span>
    descriptor.vertex.buffers = <span class="hljs-built_in">vec!</span>[vertex_layout];
    <span class="hljs-literal">Ok</span>(())
}
</code></pre>
<p>The key steps in this function are:</p>
<ol>
<li><p>We call <code>get_layout(&amp;[...])</code> on the incoming mesh layout. The slice we provide describes our shader's exact expectations.</p>
</li>
<li><p>Each <code>at_shader_location(N)</code> call creates a binding between a mesh attribute (like <code>Mesh::ATTRIBUTE_POSITION</code>) and a <code>@location(N)</code> in your WGSL shader.</p>
</li>
<li><p>If the mesh is missing an attribute we request (e.g., trying to use <code>ATTRIBUTE_BLEND_WEIGHT</code> on a mesh that doesn't have it), get_layout will return an error, preventing a crash or visual bugs.</p>
</li>
<li><p>Finally, we replace the <code>descriptor.vertex.buffers</code> with our newly created <code>vertex_layout</code>. The <code>descriptor</code> is the master blueprint for the render pipeline, and we have just modified it to suit our shader's specific needs.</p>
</li>
</ol>
<h3 id="heading-4-using-custom-attributes-in-shaders">4. Using Custom Attributes in Shaders</h3>
<p>Now that the pipeline is correctly configured, you can write a shader that receives this data. The <code>@location</code> numbers must perfectly match the numbers you used in the <code>specialize</code> function.</p>
<pre><code class="lang-rust"><span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    <span class="hljs-comment">// Locations 2 and 3 are skipped if we don't need UVs or vertex colors.</span>
    @location(<span class="hljs-number">4</span>) blend_weight: <span class="hljs-built_in">f32</span>,
    @location(<span class="hljs-number">5</span>) bone_indices: vec4&lt;<span class="hljs-built_in">u32</span>&gt;, <span class="hljs-comment">// Note: u32, not i32</span>
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    <span class="hljs-comment">// You can now use your custom attributes just like any other.</span>
    <span class="hljs-keyword">let</span> weight = <span class="hljs-keyword">in</span>.blend_weight;
    <span class="hljs-keyword">let</span> indices = <span class="hljs-keyword">in</span>.bone_indices;

    <span class="hljs-comment">// Displace the vertex position based on the custom weight.</span>
    <span class="hljs-keyword">let</span> displaced_pos = <span class="hljs-keyword">in</span>.position + <span class="hljs-keyword">in</span>.normal * weight * <span class="hljs-number">0.5</span>;
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<hr />
<h2 id="heading-complete-example-rainbow-mesh-system">Complete Example: Rainbow Mesh System</h2>
<p>Theory is essential, but nothing solidifies understanding like building something tangible. We will now create a complete, interactive Bevy application that demonstrates everything we've learned about vertex attributes.</p>
<h3 id="heading-our-goal">Our Goal</h3>
<p>We will build a material that can dynamically visualize different vertex attributes on a single mesh. We'll create a torus mesh procedurally, packing it with rich data: positions, normals, UVs, and custom vertex colors. We will also add a custom "barycentric" attribute to render a clean wireframe overlay. The final application will allow the user to cycle through different visualization modes in real-time, effectively "seeing" the data stored in the mesh.</p>
<h3 id="heading-what-this-project-demonstrates">What This Project Demonstrates</h3>
<ul>
<li><p><strong>Reading Standard Attributes:</strong> The shader will read <code>POSITION</code>, <code>NORMAL</code>, <code>UV_0</code>, and <code>COLOR</code> from the mesh.</p>
</li>
<li><p><strong>Custom Vertex Attributes:</strong> We will define, generate, and use a custom <code>ATTRIBUTE_BARYCENTRIC</code> for wireframe rendering.</p>
</li>
<li><p><strong>The</strong> <code>specialize</code> Method: The Rust material will correctly implement <code>specialize</code> to create a render pipeline that matches our shader's exact vertex layout.</p>
</li>
<li><p><strong>Uniforms for Control:</strong> We'll use a uniform to switch between visualization modes from Rust.</p>
</li>
<li><p><strong>Procedural Mesh Generation:</strong> You'll see how to create a complex mesh from scratch and fill its attribute buffers with data.</p>
</li>
<li><p><strong>Data-Driven Visualization:</strong> The fragment shader will use different vertex attributes as the source for the final pixel color, making abstract data visible.</p>
</li>
</ul>
<h3 id="heading-the-shader-assetsshadersd0203vertexattributeswgsl">The Shader (<code>assets/shaders/d02_03_vertex_attributes.wgsl</code>)</h3>
<p>This single shader file contains both the vertex and fragment logic. The vertex shader performs a simple animation and passes all attributes through. The fragment shader contains the core logic, using the color_mode uniform to decide which attribute to visualize.</p>
<p>This is also where we will implement our wireframe overlay.</p>
<pre><code class="lang-rust">#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexAttributesMaterial</span></span> {
    time: <span class="hljs-built_in">f32</span>,
    color_mode: <span class="hljs-built_in">u32</span>,        <span class="hljs-comment">// 0=vertex colors, 1=uv-based, 2=position-based, 3=normal-based</span>
    show_wireframe: <span class="hljs-built_in">u32</span>,    <span class="hljs-comment">// 0=off, 1=on</span>
    animation_speed: <span class="hljs-built_in">f32</span>,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: VertexAttributesMaterial;

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">3</span>) color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">4</span>) barycentric: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) world_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">3</span>) vertex_color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">4</span>) local_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">5</span>) barycentric: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);

    <span class="hljs-comment">// Animate vertices based on their color (using vertex color as animation parameter)</span>
    var animated_position = <span class="hljs-keyword">in</span>.position;
    <span class="hljs-keyword">let</span> wave = sin(<span class="hljs-keyword">in</span>.position.y * <span class="hljs-number">3.0</span> + material.time * material.animation_speed) * <span class="hljs-number">0.1</span>;
    animated_position = animated_position + <span class="hljs-keyword">in</span>.normal * wave * <span class="hljs-keyword">in</span>.color.r;

    <span class="hljs-keyword">let</span> world_position = mesh_functions::mesh_position_local_to_world(
        model,
        vec4&lt;<span class="hljs-built_in">f32</span>&gt;(animated_position, <span class="hljs-number">1.0</span>)
    );

    <span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(
        <span class="hljs-keyword">in</span>.normal,
        <span class="hljs-keyword">in</span>.instance_index
    );

    out.clip_position = position_world_to_clip(world_position.xyz);
    out.world_position = world_position.xyz;
    out.world_normal = normalize(world_normal);
    out.uv = <span class="hljs-keyword">in</span>.uv;
    out.vertex_color = <span class="hljs-keyword">in</span>.color;
    out.local_position = <span class="hljs-keyword">in</span>.position;
    out.barycentric = <span class="hljs-keyword">in</span>.barycentric;

    <span class="hljs-keyword">return</span> out;
}

<span class="hljs-comment">// HSV to RGB color conversion</span>
<span class="hljs-comment">// HSV to RGB color conversion</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hsv_to_rgb</span></span>(h: <span class="hljs-built_in">f32</span>, s: <span class="hljs-built_in">f32</span>, v: <span class="hljs-built_in">f32</span>) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> c = v * s;
    <span class="hljs-keyword">let</span> x = c * (<span class="hljs-number">1.0</span> - abs((h * <span class="hljs-number">6.0</span>) % <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> m = v - c;

    var rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>);

    <span class="hljs-keyword">if</span> h &lt; <span class="hljs-number">1.0</span> / <span class="hljs-number">6.0</span> {
        rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(c, x, <span class="hljs-number">0.0</span>);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> h &lt; <span class="hljs-number">2.0</span> / <span class="hljs-number">6.0</span> {
        rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(x, c, <span class="hljs-number">0.0</span>);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> h &lt; <span class="hljs-number">3.0</span> / <span class="hljs-number">6.0</span> {
        rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, c, x);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> h &lt; <span class="hljs-number">4.0</span> / <span class="hljs-number">6.0</span> {
        rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, x, c);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> h &lt; <span class="hljs-number">5.0</span> / <span class="hljs-number">6.0</span> {
        rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(x, <span class="hljs-number">0.0</span>, c);
    } <span class="hljs-keyword">else</span> {
        rgb = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(c, <span class="hljs-number">0.0</span>, x);
    }

    <span class="hljs-keyword">return</span> rgb + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(m);
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);

    <span class="hljs-comment">// Calculate base color based on mode</span>
    var base_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>);

    <span class="hljs-keyword">if</span> material.color_mode == <span class="hljs-number">0</span>u {
        <span class="hljs-comment">// Mode 0: Use vertex colors</span>
        base_color = <span class="hljs-keyword">in</span>.vertex_color.rgb;
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.color_mode == <span class="hljs-number">1</span>u {
        <span class="hljs-comment">// Mode 1: UV-based rainbow</span>
        <span class="hljs-keyword">let</span> hue = <span class="hljs-keyword">in</span>.uv.x;
        base_color = hsv_to_rgb(hue, <span class="hljs-number">0.8</span>, <span class="hljs-number">0.9</span>);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.color_mode == <span class="hljs-number">2</span>u {
        <span class="hljs-comment">// Mode 2: Position-based (height gradient)</span>
        <span class="hljs-keyword">let</span> height = (<span class="hljs-keyword">in</span>.local_position.y + <span class="hljs-number">1.0</span>) * <span class="hljs-number">0.5</span>; <span class="hljs-comment">// Normalize to 0-1</span>
        <span class="hljs-keyword">let</span> hue = height;
        base_color = hsv_to_rgb(hue, <span class="hljs-number">0.7</span>, <span class="hljs-number">0.9</span>);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.color_mode == <span class="hljs-number">3</span>u {
        <span class="hljs-comment">// Mode 3: Normal-based (visualize surface orientation)</span>
        base_color = normal * <span class="hljs-number">0.5</span> + <span class="hljs-number">0.5</span>; <span class="hljs-comment">// Map -1..1 to 0..1</span>
    }

    <span class="hljs-comment">// Simple lighting</span>
    <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.0</span>, dot(normal, light_dir));
    <span class="hljs-keyword">let</span> ambient = <span class="hljs-number">0.3</span>;
    <span class="hljs-keyword">let</span> lighting = ambient + diffuse * <span class="hljs-number">0.7</span>;

    var final_color = base_color * lighting;

    <span class="hljs-comment">// Wireframe visualization using barycentric coordinates</span>
    <span class="hljs-keyword">if</span> material.show_wireframe == <span class="hljs-number">1</span>u {
        <span class="hljs-comment">// Barycentric coordinates tell us distance from triangle edges.</span>
        <span class="hljs-comment">// When any coordinate is near 0, we're near an edge.</span>
        <span class="hljs-keyword">let</span> bary = <span class="hljs-keyword">in</span>.barycentric;
        <span class="hljs-keyword">let</span> min_dist_to_edge = min(bary.x, min(bary.y, bary.z));

        <span class="hljs-comment">// Use derivatives for smooth, resolution-independent lines.</span>
        <span class="hljs-comment">// dpdx/dpdy calculate how much a value changes between adjacent pixels.</span>
        <span class="hljs-keyword">let</span> delta_x = dpdx(min_dist_to_edge);
        <span class="hljs-keyword">let</span> delta_y = dpdy(min_dist_to_edge);
        <span class="hljs-keyword">let</span> delta = sqrt(delta_x * delta_x + delta_y * delta_y);

        <span class="hljs-comment">// `smoothstep` creates a sharp line. We use `delta` to keep the</span>
        <span class="hljs-comment">// line thickness consistent across different resolutions.</span>
        <span class="hljs-keyword">let</span> line_width = <span class="hljs-number">1.0</span>; <span class="hljs-comment">// Make this larger for thicker lines</span>
        <span class="hljs-keyword">let</span> edge = <span class="hljs-number">1.0</span> - smoothstep(<span class="hljs-number">0.0</span>, delta * line_width, min_dist_to_edge);

        <span class="hljs-comment">// Mix between wireframe color (black) and surface color based on the line.</span>
        final_color = mix(final_color, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.1</span>), edge);
    }

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(final_color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0203vertexattributesrs">The Rust Material (<code>src/materials/d02_03_vertex_attributes.rs</code>)</h3>
<p>This file defines the connection between our Rust code and the shader. It sets up the custom <code>ATTRIBUTE_BARYCENTRIC</code> constant, the <code>VertexAttributesUniforms</code> struct that will be passed to the GPU, and the <code>Material</code> implementation. The most important part of this file is the specialize function, which creates the custom vertex layout that allows our shader to receive the <code>COLOR</code> and <code>BARYCENTRIC</code> attributes at the correct locations.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::pbr::{MaterialPipeline, MaterialPipelineKey};
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::mesh::{MeshVertexAttribute, MeshVertexBufferLayoutRef};
<span class="hljs-keyword">use</span> bevy::render::render_resource::VertexFormat;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};
<span class="hljs-keyword">use</span> bevy::render::render_resource::{RenderPipelineDescriptor, SpecializedMeshPipelineError};

<span class="hljs-comment">// Custom attribute for barycentric coordinates (for wireframe)</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">const</span> ATTRIBUTE_BARYCENTRIC: MeshVertexAttribute =
    MeshVertexAttribute::new(<span class="hljs-string">"Barycentric"</span>, <span class="hljs-number">988540919</span>, VertexFormat::Float32x3);

<span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>

    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexAttributesMaterial</span></span> {
        <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> color_mode: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> show_wireframe: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> animation_speed: <span class="hljs-built_in">f32</span>,
    }
}

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">use</span> uniforms::VertexAttributesMaterial <span class="hljs-keyword">as</span> VertexAttributesUniforms;

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexAttributesMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> uniforms: VertexAttributesUniforms,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> VertexAttributesMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_03_vertex_attributes.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_03_vertex_attributes.wgsl"</span>.into()
    }

    <span class="hljs-comment">// Override vertex buffer layout to include vertex colors</span>
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">specialize</span></span>(
        _pipeline: &amp;MaterialPipeline&lt;<span class="hljs-keyword">Self</span>&gt;,
        descriptor: &amp;<span class="hljs-keyword">mut</span> RenderPipelineDescriptor,
        layout: &amp;MeshVertexBufferLayoutRef,
        _key: MaterialPipelineKey&lt;<span class="hljs-keyword">Self</span>&gt;,
    ) -&gt; <span class="hljs-built_in">Result</span>&lt;(), SpecializedMeshPipelineError&gt; {
        <span class="hljs-comment">// Specify our custom vertex layout with COLOR and BARYCENTRIC</span>
        <span class="hljs-keyword">let</span> vertex_layout = layout.<span class="hljs-number">0</span>.get_layout(&amp;[
            Mesh::ATTRIBUTE_POSITION.at_shader_location(<span class="hljs-number">0</span>),
            Mesh::ATTRIBUTE_NORMAL.at_shader_location(<span class="hljs-number">1</span>),
            Mesh::ATTRIBUTE_UV_0.at_shader_location(<span class="hljs-number">2</span>),
            Mesh::ATTRIBUTE_COLOR.at_shader_location(<span class="hljs-number">3</span>),
            ATTRIBUTE_BARYCENTRIC.at_shader_location(<span class="hljs-number">4</span>),
        ])?;
        descriptor.vertex.buffers = <span class="hljs-built_in">vec!</span>[vertex_layout];
        <span class="hljs-literal">Ok</span>(())
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other materials</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_03_vertex_attributes;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0203vertexattributesrs">The Demo Module (<code>src/demos/d02_03_vertex_attributes.rs</code>)</h3>
<p>The Rust code sets up our scene and contains the logic for interactivity. The key function is create_rainbow_torus. It procedurally generates the torus mesh, carefully calculating and inserting all of its vertex attributes. The handle_input and update_time systems modify the material's uniform data each frame based on user input, which in turn controls the visualization mode in the shader.</p>
<blockquote>
<p><strong>A Quick Note: Why is</strong> <code>hsv_to_rgb</code> <strong>in both Rust and WGSL?</strong></p>
<p>You'll notice that both the Rust code below and the WGSL shader have a function named <code>hsv_to_rgb</code>. This is intentional and demonstrates a core concept:</p>
<ul>
<li><p>The <strong>Rust</strong> <code>hsv_to_rgb</code> runs once on the <strong>CPU</strong> during setup. Its job is to <strong>bake</strong> static color data into the <code>Mesh::ATTRIBUTE_COLOR</code> buffer. This is what you see in "Mode 1: Vertex Colors".</p>
</li>
<li><p>The <strong>WGSL</strong> <code>hsv_to_rgb</code> runs every frame on the <strong>GPU</strong> for every pixel. Its job is to <strong>procedurally generate</strong> color on-the-fly from other attributes (like UVs or position). This is what you see in Modes 2 and 3.</p>
</li>
</ul>
<p>This duplication highlights the difference between pre-calculated vertex attributes and real-time procedural generation.</p>
<p><strong>Another Note: Seamless UVs and Vertex Duplication</strong></p>
<p>When generating a mesh that wraps around, like a torus or sphere, creating seamless UVs requires a specific trick. A naive approach might re-use the first line of vertices for the last set of triangles. This causes a problem: the GPU would have to interpolate from a UV coordinate of <code>u = 1.0</code> all the way back to <code>u = 0.0</code> across a single triangle, creating a visual artifact (a garbled stripe).</p>
<p>The correct solution, implemented in the code below, is to generate a <strong>duplicate</strong> set of vertices for the seam. One set has a <code>u</code> coordinate of <code>0.0</code>, and the other has <code>u = 1.0</code>. Although they share the exact same 3D position, they are distinct vertices. This allows the UV coordinates to wrap smoothly and correctly, which is also a requirement for the barycentric coordinate technique used for the wireframe.</p>
</blockquote>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d02_03_vertex_attributes::{
    ATTRIBUTE_BARYCENTRIC, VertexAttributesMaterial, VertexAttributesUniforms,
};
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::mesh::PrimitiveTopology;
<span class="hljs-keyword">use</span> bevy::render::render_asset::RenderAssetUsages;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;VertexAttributesMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (update_time, handle_input, rotate_camera, update_ui),
        )
        .run();
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;VertexAttributesMaterial&gt;&gt;,
) {
    <span class="hljs-comment">// Create a rainbow-colored torus</span>
    <span class="hljs-keyword">let</span> mesh = create_rainbow_torus(<span class="hljs-number">32</span>, <span class="hljs-number">16</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.3</span>);

    commands.spawn((
        Mesh3d(meshes.add(mesh)),
        MeshMaterial3d(materials.add(VertexAttributesMaterial {
            uniforms: VertexAttributesUniforms {
                time: <span class="hljs-number">0.0</span>,
                color_mode: <span class="hljs-number">0</span>,
                show_wireframe: <span class="hljs-number">0</span>,
                animation_speed: <span class="hljs-number">2.0</span>,
            },
        })),
    ));

    <span class="hljs-comment">// Light - disable shadows to avoid prepass issues with custom vertex attributes</span>
    commands.spawn((
        DirectionalLight {
            illuminance: <span class="hljs-number">10000.0</span>,
            shadows_enabled: <span class="hljs-literal">false</span>, <span class="hljs-comment">// Disabled to avoid prepass conflicts</span>
            ..default()
        },
        Transform::from_rotation(Quat::from_euler(
            EulerRot::XYZ,
            -std::<span class="hljs-built_in">f32</span>::consts::PI / <span class="hljs-number">4.0</span>,
            std::<span class="hljs-built_in">f32</span>::consts::PI / <span class="hljs-number">4.0</span>,
            <span class="hljs-number">0.0</span>,
        )),
    ));

    <span class="hljs-comment">// Camera</span>
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span>, <span class="hljs-number">5.0</span>).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    <span class="hljs-comment">// UI</span>
    commands.spawn((
        Text::new(
            <span class="hljs-string">"[1-4] Color Mode | [Space] Toggle Wireframe | [+/-] Animation Speed\n\
             Current: Vertex Colors | Wireframe: Off | Speed: 2.0"</span>,
        ),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">10.0</span>),
            left: Val::Px(<span class="hljs-number">10.0</span>),
            ..default()
        },
        TextFont {
            font_size: <span class="hljs-number">16.0</span>,
            ..default()
        },
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">create_rainbow_torus</span></span>(
    major_segments: <span class="hljs-built_in">usize</span>,
    minor_segments: <span class="hljs-built_in">usize</span>,
    major_radius: <span class="hljs-built_in">f32</span>,
    minor_radius: <span class="hljs-built_in">f32</span>,
) -&gt; Mesh {
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> positions = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> normals = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> uvs = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> colors = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> indices = <span class="hljs-built_in">Vec</span>::new();

    <span class="hljs-comment">// Generate torus vertices</span>
    <span class="hljs-comment">// Use inclusive range (0..=) to generate duplicate vertices for the seam.</span>
    <span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..=major_segments {
        <span class="hljs-keyword">let</span> u = i <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / major_segments <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span>;
        <span class="hljs-keyword">let</span> major_angle = u * std::<span class="hljs-built_in">f32</span>::consts::TAU;

        <span class="hljs-comment">// Use inclusive range here as well.</span>
        <span class="hljs-keyword">for</span> j <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..=minor_segments {
            <span class="hljs-keyword">let</span> v = j <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> / minor_segments <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span>;
            <span class="hljs-keyword">let</span> minor_angle = v * std::<span class="hljs-built_in">f32</span>::consts::TAU;

            <span class="hljs-comment">// Torus parametric equations</span>
            <span class="hljs-keyword">let</span> cos_major = major_angle.cos();
            <span class="hljs-keyword">let</span> sin_major = major_angle.sin();
            <span class="hljs-keyword">let</span> cos_minor = minor_angle.cos();
            <span class="hljs-keyword">let</span> sin_minor = minor_angle.sin();

            <span class="hljs-keyword">let</span> x = (major_radius + minor_radius * cos_minor) * cos_major;
            <span class="hljs-keyword">let</span> y = minor_radius * sin_minor;
            <span class="hljs-keyword">let</span> z = (major_radius + minor_radius * cos_minor) * sin_major;
            positions.push([x, y, z]);

            <span class="hljs-comment">// Normal points from the center of the tube</span>
            <span class="hljs-keyword">let</span> nx = cos_minor * cos_major;
            <span class="hljs-keyword">let</span> ny = sin_minor;
            <span class="hljs-keyword">let</span> nz = cos_minor * sin_major;
            normals.push([nx, ny, nz]);

            <span class="hljs-comment">// UV coordinates</span>
            uvs.push([u, v]);

            <span class="hljs-comment">// Rainbow colors based on position around major circle</span>
            <span class="hljs-keyword">let</span> hue = u;
            <span class="hljs-keyword">let</span> color = hsv_to_rgb(hue, <span class="hljs-number">0.8</span>, <span class="hljs-number">0.9</span>);
            colors.push([color.x, color.y, color.z, <span class="hljs-number">1.0</span>]);
        }
    }

    <span class="hljs-comment">// Generate indices for triangles</span>
    <span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..major_segments {
        <span class="hljs-keyword">for</span> j <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..minor_segments {
            <span class="hljs-keyword">let</span> vertices_per_ring = minor_segments + <span class="hljs-number">1</span>;
            <span class="hljs-keyword">let</span> current = (i * vertices_per_ring + j) <span class="hljs-keyword">as</span> <span class="hljs-built_in">u32</span>;
            <span class="hljs-keyword">let</span> next_major = ((i + <span class="hljs-number">1</span>) * vertices_per_ring + j) <span class="hljs-keyword">as</span> <span class="hljs-built_in">u32</span>;
            <span class="hljs-keyword">let</span> next_minor = (i * vertices_per_ring + (j + <span class="hljs-number">1</span>)) <span class="hljs-keyword">as</span> <span class="hljs-built_in">u32</span>;
            <span class="hljs-keyword">let</span> next_both = ((i + <span class="hljs-number">1</span>) * vertices_per_ring + (j + <span class="hljs-number">1</span>)) <span class="hljs-keyword">as</span> <span class="hljs-built_in">u32</span>;

            <span class="hljs-comment">// Two triangles per quad</span>

            <span class="hljs-comment">// Triangle 1</span>
            indices.push(current);
            indices.push(next_minor); <span class="hljs-comment">// Was next_major</span>
            indices.push(next_major); <span class="hljs-comment">// Was next_minor</span>

            <span class="hljs-comment">// Triangle 2</span>
            indices.push(next_minor);
            indices.push(next_both); <span class="hljs-comment">// Was next_major</span>
            indices.push(next_major); <span class="hljs-comment">// Was next_both</span>
        }
    }

    <span class="hljs-comment">// We create new attribute lists by copying the data for each index.</span>
    <span class="hljs-comment">// The mesh must be "un-indexed" for barycentric coordinates to work.</span>
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> expanded_positions = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> expanded_normals = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> expanded_uvs = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> expanded_colors = <span class="hljs-built_in">Vec</span>::new();
    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> barycentrics = <span class="hljs-built_in">Vec</span>::new();

    <span class="hljs-keyword">for</span> idx <span class="hljs-keyword">in</span> &amp;indices {
        <span class="hljs-keyword">let</span> i = *idx <span class="hljs-keyword">as</span> <span class="hljs-built_in">usize</span>;
        expanded_positions.push(positions[i]);
        expanded_normals.push(normals[i]);
        expanded_uvs.push(uvs[i]);
        expanded_colors.push(colors[i]);
    }

    <span class="hljs-comment">// For each new triangle, add the barycentric coordinates.</span>
    <span class="hljs-keyword">for</span> _ <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..indices.len() / <span class="hljs-number">3</span> {
        barycentrics.push([<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>]);
        barycentrics.push([<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>]);
        barycentrics.push([<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>]);
    }

    <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> mesh = Mesh::new(
        PrimitiveTopology::TriangleList,
        RenderAssetUsages::default(),
    );

    mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, expanded_positions);
    mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, expanded_normals);
    mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, expanded_uvs);
    mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, expanded_colors);
    mesh.insert_attribute(ATTRIBUTE_BARYCENTRIC, barycentrics);

    mesh
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hsv_to_rgb</span></span>(h: <span class="hljs-built_in">f32</span>, s: <span class="hljs-built_in">f32</span>, v: <span class="hljs-built_in">f32</span>) -&gt; Vec3 {
    <span class="hljs-keyword">let</span> c = v * s;
    <span class="hljs-keyword">let</span> x = c * (<span class="hljs-number">1.0</span> - ((h * <span class="hljs-number">6.0</span>) % <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>).abs());
    <span class="hljs-keyword">let</span> m = v - c;

    <span class="hljs-keyword">let</span> (r, g, b) = <span class="hljs-keyword">if</span> h &lt; <span class="hljs-number">1.0</span> / <span class="hljs-number">6.0</span> {
        (c, x, <span class="hljs-number">0.0</span>)
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> h &lt; <span class="hljs-number">2.0</span> / <span class="hljs-number">6.0</span> {
        (x, c, <span class="hljs-number">0.0</span>)
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> h &lt; <span class="hljs-number">3.0</span> / <span class="hljs-number">6.0</span> {
        (<span class="hljs-number">0.0</span>, c, x)
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> h &lt; <span class="hljs-number">4.0</span> / <span class="hljs-number">6.0</span> {
        (<span class="hljs-number">0.0</span>, x, c)
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> h &lt; <span class="hljs-number">5.0</span> / <span class="hljs-number">6.0</span> {
        (x, <span class="hljs-number">0.0</span>, c)
    } <span class="hljs-keyword">else</span> {
        (c, <span class="hljs-number">0.0</span>, x)
    };

    Vec3::new(r + m, g + m, b + m)
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_time</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;VertexAttributesMaterial&gt;&gt;) {
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        material.uniforms.time = time.elapsed_secs();
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;VertexAttributesMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-comment">// Switch color mode</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit1) {
            material.uniforms.color_mode = <span class="hljs-number">0</span>; <span class="hljs-comment">// Vertex colors</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit2) {
            material.uniforms.color_mode = <span class="hljs-number">1</span>; <span class="hljs-comment">// UV-based</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit3) {
            material.uniforms.color_mode = <span class="hljs-number">2</span>; <span class="hljs-comment">// Position-based</span>
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit4) {
            material.uniforms.color_mode = <span class="hljs-number">3</span>; <span class="hljs-comment">// Normal-based</span>
        }

        <span class="hljs-comment">// Toggle wireframe</span>
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Space) {
            material.uniforms.show_wireframe = <span class="hljs-number">1</span> - material.uniforms.show_wireframe;
        }

        <span class="hljs-comment">// Adjust animation speed</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::Equal) {
            material.uniforms.animation_speed = (material.uniforms.animation_speed + <span class="hljs-number">0.1</span>).min(<span class="hljs-number">10.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::Minus) {
            material.uniforms.animation_speed = (material.uniforms.animation_speed - <span class="hljs-number">0.1</span>).max(<span class="hljs-number">0.0</span>);
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rotate_camera</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> camera_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Transform, With&lt;Camera3d&gt;&gt;) {
    <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> transform <span class="hljs-keyword">in</span> camera_query.iter_mut() {
        <span class="hljs-keyword">let</span> radius = <span class="hljs-number">5.0</span>;
        <span class="hljs-keyword">let</span> angle = time.elapsed_secs() * <span class="hljs-number">0.3</span>;
        transform.translation.x = angle.cos() * radius;
        transform.translation.z = angle.sin() * radius;
        transform.look_at(Vec3::ZERO, Vec3::Y);
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(materials: Res&lt;Assets&lt;VertexAttributesMaterial&gt;&gt;, <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;) {
    <span class="hljs-keyword">if</span> !materials.is_changed() {
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, material)) = materials.iter().next() {
        <span class="hljs-keyword">let</span> mode_name = <span class="hljs-keyword">match</span> material.uniforms.color_mode {
            <span class="hljs-number">0</span> =&gt; <span class="hljs-string">"Vertex Colors"</span>,
            <span class="hljs-number">1</span> =&gt; <span class="hljs-string">"UV-Based Rainbow"</span>,
            <span class="hljs-number">2</span> =&gt; <span class="hljs-string">"Position-Based Gradient"</span>,
            <span class="hljs-number">3</span> =&gt; <span class="hljs-string">"Normal Visualization"</span>,
            _ =&gt; <span class="hljs-string">"Unknown"</span>,
        };

        <span class="hljs-keyword">let</span> wireframe = <span class="hljs-keyword">if</span> material.uniforms.show_wireframe == <span class="hljs-number">1</span> {
            <span class="hljs-string">"On"</span>
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-string">"Off"</span>
        };

        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
            **text = <span class="hljs-built_in">format!</span>(
                <span class="hljs-string">"[1-4] Color Mode | [Space] Toggle Wireframe | [+/-] Animation Speed\n\
                 Current: {} | Wireframe: {} | Speed: {:.1}"</span>,
                mode_name, wireframe, material.uniforms.animation_speed
            );
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other demos</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_03_vertex_attributes;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"2.3"</span>,
    title: <span class="hljs-string">"Working with Vertex Attributes"</span>,
    run: demos::d02_03_vertex_attributes::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>When you run the application, you will be greeted by a colorful, animating torus. You can use the keyboard to manipulate the material's properties in real-time and see how the shader responds.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key(s)</td><td>Action</td></tr>
</thead>
<tbody>
<tr>
<td><strong>1, 2, 3, 4</strong></td><td>Switch between Vertex Color, UV, Position, and Normal visualization modes.</td></tr>
<tr>
<td><strong>Spacebar</strong></td><td>Toggle the wireframe overlay on and off.</td></tr>
<tr>
<td><strong>\= / -</strong></td><td>Increase / Decrease the speed of the vertex animation.</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762681068740/f97d1f06-af2b-4edc-a04a-117bdb1f9c40.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762681082121/ca641623-036e-45d6-91ef-5e7e1fa973e2.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762681096017/770d61ba-7d32-4bcd-a2bb-3f2dd5e3fa41.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762681106528/bf38513d-bfbe-4ee2-a6be-e933daa9a412.png" alt class="image--center mx-auto" /></p>
<p>This table explains what each visualization mode is showing you. Experiment with each one to build an intuition for how this data is stored and interpolated.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key</td><td>Mode</td><td>What It Demonstrates</td></tr>
</thead>
<tbody>
<tr>
<td>1</td><td><strong>Vertex Colors</strong></td><td>This is the "raw" color data we baked into the mesh. A smooth rainbow gradient is created by assigning a different color to each vertex along the torus's main ring.</td></tr>
<tr>
<td>2</td><td><strong>UV-Based Rainbow</strong></td><td>This mode ignores the vertex colors and generates a new rainbow based on the <code>u</code> (horizontal) texture coordinate. This shows how UVs map around the mesh geometry.</td></tr>
<tr>
<td>3</td><td><strong>Position-Based Gradient</strong></td><td>Here, the color is determined by the vertex's height (its <code>y</code> position in local space). This is a common technique for effects like water depth or snow accumulation.</td></tr>
<tr>
<td>4</td><td><strong>Normal Visualization</strong></td><td>This classic debugging tool maps the X, Y, and Z components of the world normal vector to the R, G, and B color channels. It's a direct visualization of the surface orientation.</td></tr>
</tbody>
</table>
</div><h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>You have now covered one of the most essential concepts in shader programming. The bridge between your mesh data and the GPU is no longer a mystery. Before moving on, ensure you have a solid grasp of these key points:</p>
<ol>
<li><p><strong>Standard Attributes:</strong> Meshes come with standard data channels like <code>POSITION</code> (required), <code>NORMAL</code>, <code>UV_0</code>, and <code>COLOR</code>. Each serves a distinct purpose, from defining shape to enabling lighting and texturing.</p>
</li>
<li><p><strong>Location Mapping:</strong> The <code>@location(N)</code> decorator in your WGSL VertexInput struct is the crucial link to the mesh data on the CPU. The locations must match Bevy's standard layout, or the custom layout you define.</p>
</li>
<li><p><strong>Normals for Lighting:</strong> Normals are vectors describing surface orientation. They must be transformed using a special normal matrix (handled by Bevy's helper functions) and be re-normalized in the fragment shader for accurate lighting.</p>
</li>
<li><p><strong>UVs for Surface Mapping:</strong> UVs are 2D coordinates that map a flat image or procedural pattern onto a 3D surface. They are typically passed directly from the vertex to the fragment shader.</p>
</li>
<li><p><strong>Vertex Colors for Efficiency:</strong> Per-vertex colors allow for texture-free color gradients and are highly performant. They are interpolated smoothly across triangles.</p>
</li>
<li><p><strong>Interpolation is Automatic:</strong> The GPU automatically blends all vertex shader outputs (except position) for each pixel of a triangle, creating smooth surfaces.</p>
</li>
<li><p><code>@interpolate</code> for Control: You can override the default <code>perspective</code> interpolation with <code>flat</code> for faceted shading or discrete data, and <code>linear</code> for screen-space effects.</p>
</li>
<li><p><strong>Custom Attributes are Powerful:</strong> You can create your own vertex attributes in Rust using <code>MeshVertexAttribute</code> and <code>insert_attribute</code>, then read them in your shader after configuring the pipeline with the specialize method.</p>
</li>
<li><p><strong>Data Flow:</strong> The vertex shader reads attributes, performs calculations, and passes its outputs to the fragment shader, which receives them as smoothly interpolated inputs.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>You now have a complete understanding of how data gets from a mesh into your shaders and flows through the pipeline. We've read positions, normals, UVs, and colors, but so far, we've only transformed the object as a whole.</p>
<p>In the next article, we will take the next logical step: manipulating the vertex <code>POSITION</code> attribute itself. We'll move beyond simple MVP transformations and learn how to create dynamic, per-vertex effects like sine wave deformations, pulsing animations, and other simple procedural movements, all performed directly on the GPU.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/24-simple-vertex-deformations"><strong><em>2.4 - Simple Vertex Deformations</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<h3 id="heading-standard-attributes-amp-shader-locations">Standard Attributes &amp; Shader Locations</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Bevy Attribute</td><td>WGSL Type</td><td>Shader Location</td></tr>
</thead>
<tbody>
<tr>
<td><code>Mesh::ATTRIBUTE_POSITION</code></td><td><code>vec3&lt;f32&gt;</code></td><td><code>@location(0)</code></td></tr>
<tr>
<td><code>Mesh::ATTRIBUTE_NORMAL</code></td><td><code>vec3&lt;f32&gt;</code></td><td><code>@location(1)</code></td></tr>
<tr>
<td><code>Mesh::ATTRIBUTE_UV_0</code></td><td><code>vec2&lt;f32&gt;</code></td><td><code>@location(2)</code></td></tr>
<tr>
<td><code>Mesh::ATTRIBUTE_COLOR</code></td><td><code>vec4&lt;f32&gt;</code></td><td><code>@location(3)</code></td></tr>
<tr>
<td><code>Mesh::ATTRIBUTE_UV_1</code></td><td><code>vec2&lt;f32&gt;</code></td><td><code>@location(4)</code>*</td></tr>
<tr>
<td><code>Mesh::ATTRIBUTE_TANGENT</code></td><td><code>vec4&lt;f32&gt;</code></td><td>Handled by PBR</td></tr>
</tbody>
</table>
</div><p><em>\</em>Location assumes* <code>COLOR</code> is also present. Custom attribute locations must not conflict.</p>
<h3 id="heading-interpolation-modes">Interpolation Modes</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>WGSL Attribute</td><td>Behavior</td><td>Use Case</td></tr>
</thead>
<tbody>
<tr>
<td><code>@interpolate(perspective)</code></td><td>Default. 3D perspective-correct.</td><td>Normals, UVs, Colors, World Positions.</td></tr>
<tr>
<td><code>@interpolate(flat)</code></td><td>No interpolation. Constant across triangle.</td><td>Flat shading, integer IDs, discrete data.</td></tr>
<tr>
<td><code>@interpolate(linear)</code></td><td>Screen-space linear blend.</td><td>2D UI effects, post-processing.</td></tr>
</tbody>
</table>
</div><h3 id="heading-custom-attributes-in-rust">Custom Attributes in Rust</h3>
<ol>
<li><p><strong>Define:</strong> <code>pub const MY_ATTR: MeshVertexAttribute = MeshVertexAttribute::new("Name", UNIQUE_ID, VertexFormat::Float32x2);</code></p>
</li>
<li><p><strong>Insert:</strong> <code>mesh.insert_attribute(MY_ATTR, vec![[0.0, 0.0], ...]);</code></p>
</li>
<li><p><strong>Specialize:</strong> In <code>impl Material</code>, use <code>get_layout</code> to map <code>MY_ATTR.at_shader_location(N)</code> and update the <code>RenderPipelineDescriptor</code>.</p>
</li>
</ol>
<h3 id="heading-normal-transformation-in-wgsl">Normal Transformation in WGSL</h3>
<pre><code class="lang-rust"><span class="hljs-comment">// Always use Bevy's helper to correctly handle non-uniform scaling.</span>
<span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(
    <span class="hljs-keyword">in</span>.normal,
    <span class="hljs-keyword">in</span>.instance_index
);
</code></pre>
]]></content:encoded></item><item><title><![CDATA[2.2 - Camera and Projection Matrices]]></title><description><![CDATA[What We're Learning
In the previous article, we seized control of our geometry. We learned how to manipulate vertex positions in local space to create dynamic waves, twists, and other deformations. To get our transformed geometry onto the screen, we ...]]></description><link>https://blog.hexbee.net/22-camera-and-projection-matrices</link><guid isPermaLink="true">https://blog.hexbee.net/22-camera-and-projection-matrices</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Fri, 31 Oct 2025 23:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1762553572115/49aef711-e216-4a47-8e7e-e506080d3276.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>In the previous article, we seized control of our geometry. We learned how to manipulate vertex positions in local space to create dynamic waves, twists, and other deformations. To get our transformed geometry onto the screen, we relied on a convenient Bevy helper function: <code>position_world_to_clip</code>. This function acted as a "black box," handling the complex math of camera positioning and lens projection for us. Now, it's time to open that box.</p>
<p>This article is about mastering the final, crucial steps of the rendering pipeline: the journey from a shared 3D world into the 2D plane of your monitor. The camera is your window into the scene; it dictates what the player sees, from what angle, and with what sense of perspective. By understanding and building the matrices that power it - the <strong>View Matrix</strong> and the <strong>Projection Matrix</strong> - you move from simply using a camera to creating bespoke visual experiences. This knowledge is the foundation for custom camera effects, non-standard rendering styles, and debugging tricky visual artifacts.</p>
<p>By the end of this article, you will be able to:</p>
<ul>
<li><p><strong>Explain the role of the View Matrix</strong> and manually construct one using the "look-at" method to position and orient the camera.</p>
</li>
<li><p><strong>Differentiate between Perspective and Orthographic projection</strong>, understanding when and why to use each.</p>
</li>
<li><p><strong>Build a Perspective Projection Matrix from scratch</strong>, controlling key lens parameters like Field of View (FOV), aspect ratio, and clipping planes.</p>
</li>
<li><p><strong>Understand the "Perspective Divide"</strong> and how the <code>w</code> coordinate creates the illusion of depth.</p>
</li>
<li><p><strong>Implement the full Model-View-Projection (MVP) pipeline</strong> in a shader to gain complete control over vertex transformation.</p>
</li>
<li><p><strong>Recognize how this theory maps directly</strong> to Bevy's <code>Camera</code> and <code>Projection</code> components.</p>
</li>
</ul>
<h2 id="heading-the-transformation-pipeline-revisited">The Transformation Pipeline Revisited</h2>
<p>To understand the camera's role, we must complete the map of the journey each vertex takes from a 3D model file to a pixel on your screen. This journey is a series of coordinate space transformations, each handled by a specific matrix.</p>
<p>In the last article, we focused on the first step: using a <strong>Model Matrix</strong> to move vertices from their private <code>Local Space</code> into the shared scene, or <code>World Space</code>. We then handed off the result to Bevy's <code>position_world_to_clip</code> function. Let's now deconstruct that function and complete the picture.</p>
<pre><code class="lang-plaintext">Local Space (A model's private coordinates)
    │
    └─[Model Matrix]───────&gt; Places the model in the scene.
    │
World Space (The shared scene's coordinates)
    │
    └─[View Matrix]────────&gt; Moves the entire world so the camera is at the origin.
    │
View Space (The world from the camera's perspective)
    │
    └─[Projection Matrix] -&gt; Flattens the 3D view into a 2D image with perspective.
    │
Clip Space (A standardized cube, ready for the GPU)
</code></pre>
<p>The Bevy helper function we used, <code>position_world_to_clip</code>, encapsulates the last two, most crucial steps of this process. It is simply a convenient shortcut for two sequential matrix multiplications:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// This single Bevy function...</span>
<span class="hljs-keyword">let</span> clip_position = position_world_to_clip(world_position.xyz);

<span class="hljs-comment">// ...is a shortcut for this:</span>
<span class="hljs-keyword">let</span> view_position = view_matrix * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(world_position.xyz, <span class="hljs-number">1.0</span>);
<span class="hljs-keyword">let</span> clip_position = projection_matrix * view_position;
</code></pre>
<p>The goal of this article is to build <code>view_matrix</code> and <code>projection_matrix</code> from first principles. Once you master these, you will have complete, end-to-end control over the rendering pipeline. Let's start with the View Matrix.</p>
<h2 id="heading-part-1-the-view-matrix-positioning-the-camera">Part 1: The View Matrix - Positioning the Camera</h2>
<p>The <strong>View Matrix</strong> has a single, crucial job: to transform the entire world from its shared <code>World Space</code> coordinates into <code>View Space</code>, a new coordinate system defined from the camera's unique perspective. In essence, it repositions every vertex in the scene so that the camera becomes the new center of the universe, with everything else arranged around it.</p>
<h3 id="heading-the-inverse-relationship">The Inverse Relationship</h3>
<p>Here is the most critical concept to understand: the view matrix is the <strong>mathematical inverse</strong> of the camera's own transformation matrix in the world.</p>
<p>Think about it intuitively:</p>
<ul>
<li><p>If you move your camera 10 units to the <strong>right</strong> (<code>+X</code>), the entire world appears to shift 10 units to the <strong>left</strong> (<code>-X</code>) from your perspective.</p>
</li>
<li><p>If you rotate your camera 30 degrees <strong>clockwise</strong>, the world appears to rotate 30 degrees <strong>counter-clockwise</strong>.</p>
</li>
</ul>
<p>The view matrix applies this opposite, or inverse, transformation to every vertex in the world. This is what creates the illusion of a moving camera.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764382414172/756cb413-1115-43f5-84fa-78214258b4e0.png" alt class="image--center mx-auto" /></p>
<p>Mathematically, the relationship is simple and elegant: <code>view_matrix = inverse(camera_world_matrix)</code>. This camera_world_matrix is the standard model matrix that would place and orient the camera object itself in world space.</p>
<h3 id="heading-understanding-view-space">Understanding View Space</h3>
<p>To grasp what the view matrix does, you must first understand its destination: View Space. This is a standardized coordinate system where the camera is <strong>always</strong> at the origin, looking in a fixed direction.</p>
<ul>
<li><p><strong>Origin</strong> <code>(0, 0, 0)</code>: The camera's exact position.</p>
</li>
<li><p><strong>-Z Axis:</strong> The direction the camera is looking (forward).</p>
</li>
<li><p><strong>+Y Axis:</strong> The camera's "up" direction.</p>
</li>
<li><p><strong>+X Axis:</strong> The direction to the camera's right.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764382637775/2f09a843-db20-468d-9f37-3d4619cfb7e6.png" alt class="image--center mx-auto" /></p>
<p>This convention of "looking down the negative Z-axis" is a long-standing practice in graphics, stemming from the math of right-handed coordinate systems.</p>
<h3 id="heading-constructing-a-look-at-view-matrix">Constructing a "Look-At" View Matrix</h3>
<p>While <code>inverse(camera_world_matrix)</code> is conceptually correct, calculating a full matrix inverse is computationally expensive and unnecessary. A more direct and efficient method exists. Most of the time, it's far more intuitive to define a camera's orientation by stating:</p>
<ul>
<li><p>Where the camera <strong>is</strong> (<code>eye</code>).</p>
</li>
<li><p>What it's <strong>looking at</strong> (<code>target</code>).</p>
</li>
<li><p>Which general direction is <strong>"up"</strong> (usually the world's <code>up</code> vector, <code>vec3(0.0, 1.0, 0.0)</code>).</p>
</li>
</ul>
<p>From these three pieces of information, we can derive the necessary <code>forward</code>, <code>right</code>, and <code>up</code> vectors for the camera's local coordinate system and construct our view matrix directly. This is universally known as a "look-at" function.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">look_at</span></span>(
    eye: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,      <span class="hljs-comment">// The camera's world position</span>
    target: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,   <span class="hljs-comment">// The point the camera is looking at</span>
    world_up: vec3&lt;<span class="hljs-built_in">f32</span>&gt;  <span class="hljs-comment">// The world's up direction (e.g., vec3(0.0, 1.0, 0.0))</span>
) -&gt; mat4x4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Calculate the forward vector (z-axis of the camera's space).</span>
    <span class="hljs-comment">// This is the direction from the target TO the eye.</span>
    <span class="hljs-comment">// It points OUT of the screen, aligning with our desired +Z view space axis.</span>
    <span class="hljs-keyword">let</span> z_axis = normalize(eye - target);

    <span class="hljs-comment">// 2. Calculate the right vector (x-axis).</span>
    <span class="hljs-comment">// The cross product gives a vector perpendicular to two others.</span>
    <span class="hljs-keyword">let</span> x_axis = normalize(cross(world_up, z_axis));

    <span class="hljs-comment">// 3. Recalculate the true camera up vector (y-axis).</span>
    <span class="hljs-comment">// This ensures all three axes are mutually perpendicular (an orthonormal basis).</span>
    <span class="hljs-keyword">let</span> y_axis = cross(z_axis, x_axis);

    <span class="hljs-comment">// 4. Construct the matrix columns.</span>
    <span class="hljs-comment">// The first three columns define the inverse rotation by using the camera's</span>
    <span class="hljs-comment">// axes as the basis vectors.</span>
    <span class="hljs-keyword">let</span> col0 = vec4(x_axis, <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> col1 = vec4(y_axis, <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> col2 = vec4(z_axis, <span class="hljs-number">0.0</span>);

    <span class="hljs-comment">// The fourth column defines the inverse translation. It moves the world</span>
    <span class="hljs-comment">// in the opposite direction of the camera's position.</span>
    <span class="hljs-keyword">let</span> col3 = vec4(
        -dot(x_axis, eye),
        -dot(y_axis, eye),
        -dot(z_axis, eye),
        <span class="hljs-number">1.0</span>
    );

    <span class="hljs-comment">// The WGSL mat4x4 constructor takes columns, not rows.</span>
    <span class="hljs-keyword">return</span> mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;(col0, col1, col2, col3);
}
</code></pre>
<p><strong>Why the negative dot products?</strong> The <code>dot()</code> product calculates how far along one vector another vector lies. <code>-dot(x_axis, eye)</code> tells us "how much of the camera's position is in its own 'right' direction?" and then negates it. By doing this for all three axes, we find the exact opposite translation required to move the camera back to the origin <code>(0,0,0)</code>.</p>
<p><strong>Why recalculate up?</strong> The initial <code>world_up</code> vector is a guide. If the camera is looking straight up or down, the initial <code>x_axis</code> calculation could fail (the cross product of two parallel vectors is zero). By recalculating the <code>y_axis</code> from the new <code>z_axis</code> and <code>x_axis</code>, we guarantee the camera's local axes form a perfect, stable, 90-degree coordinate system.</p>
<h3 id="heading-testing-your-view-matrix">Testing Your View Matrix</h3>
<p>A correctly constructed view matrix will transform world-space coordinates into view-space. You can verify this with a few key checks:</p>
<ul>
<li><p>A vertex at the camera's world position should be transformed to the origin <code>(0, 0, 0)</code>.</p>
</li>
<li><p>A vertex located directly in front of the camera should be transformed to a position with a negative Z value.</p>
</li>
<li><p>A vertex to the right of the camera should have a positive X value.</p>
</li>
<li><p>A vertex above the camera should have a positive Y value.</p>
</li>
</ul>
<h2 id="heading-part-2-projection-matrices-from-3d-to-2d">Part 2: Projection Matrices - From 3D to 2D</h2>
<p>We have successfully transformed our world into a camera-centric view. Now, we face the final challenge: how do we represent this 3D view on a 2D screen? This is the job of the <strong>Projection Matrix</strong>. It takes our 3D view-space coordinates and squashes them into a standardized 2D space that the GPU can map to pixels.</p>
<p>This process is analogous to how a real camera lens works, focusing light from a three-dimensional world onto a flat two-dimensional sensor. In computer graphics, we primarily use two types of "lenses" or projections.</p>
<blockquote>
<p>(Note: The following matrix functions are the classic implementations, perfect for learning the fundamental concepts. In the next section, we'll see how Bevy uses a slightly modified "Reverse-Z" version for improved precision.)</p>
</blockquote>
<h3 id="heading-orthographic-projection-parallel-lines-stay-parallel">Orthographic Projection: Parallel Lines Stay Parallel</h3>
<p>An orthographic projection is the simplest type. It maps 3D coordinates directly to 2D coordinates without any perspective. This means an object's size on screen does not change with its distance from the camera. Parallel lines in the 3D world remain parallel on the 2D screen.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764382837616/39ea691b-f7f7-4b4d-a3d0-0985acf8ed37.png" alt class="image--center mx-auto" /></p>
<p><strong>When to use orthographic projection:</strong></p>
<ul>
<li><p>2D games, user interfaces (UI), and sprite-based rendering.</p>
</li>
<li><p>Architectural blueprints and CAD (Computer-Aided Design) applications.</p>
</li>
<li><p>Strategy games with top-down or isometric views.</p>
</li>
</ul>
<p>The orthographic projection matrix transforms a rectangular box of view space (defined by left, right, top, bottom, near, and far planes) into the GPU's normalized clip space cube.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">orthographic_projection</span></span>(
    left: <span class="hljs-built_in">f32</span>, right: <span class="hljs-built_in">f32</span>,
    bottom: <span class="hljs-built_in">f32</span>, top: <span class="hljs-built_in">f32</span>,
    near: <span class="hljs-built_in">f32</span>, far: <span class="hljs-built_in">f32</span>
) -&gt; mat4x4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> width = right - left;
    <span class="hljs-keyword">let</span> height = top - bottom;
    <span class="hljs-keyword">let</span> depth = far - near;

    <span class="hljs-comment">// Column-major construction</span>
    <span class="hljs-keyword">let</span> col0 = vec4(<span class="hljs-number">2.0</span> / width, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> col1 = vec4(<span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span> / height, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> col2 = vec4(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, -<span class="hljs-number">2.0</span> / depth, <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> col3 = vec4(
        -(right + left) / width,
        -(top + bottom) / height,
        -(far + near) / depth,
        <span class="hljs-number">1.0</span>
    );

    <span class="hljs-keyword">return</span> mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;(col0, col1, col2, col3);
}
</code></pre>
<p>This matrix effectively scales and shifts the view volume. Crucially, the fourth component of a transformed position vector (w) remains 1.0. This is the key reason there is no perspective.</p>
<h3 id="heading-perspective-projection-realistic-depth">Perspective Projection: Realistic Depth</h3>
<p>A perspective projection mimics how the human eye and real-world cameras work: objects that are farther away appear smaller. This is the standard projection for virtually all 3D games and simulations.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764382996805/8dfe032f-d2e8-4290-b965-5c4226feab0e.png" alt class="image--center mx-auto" /></p>
<p><strong>When to use perspective projection:</strong></p>
<ul>
<li><p>First-person and third-person 3D games.</p>
</li>
<li><p>Realistic simulations and visualizations.</p>
</li>
<li><p>Any application where depth perception is important.</p>
</li>
</ul>
<p>Instead of defining a box, we define a "frustum" using more intuitive parameters like the camera's field of view.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">perspective_projection</span></span>(
    fov_y_radians: <span class="hljs-built_in">f32</span>, <span class="hljs-comment">// Vertical field of view</span>
    aspect_ratio: <span class="hljs-built_in">f32</span>,  <span class="hljs-comment">// Viewport width / height</span>
    near: <span class="hljs-built_in">f32</span>,          <span class="hljs-comment">// Near clipping plane distance</span>
    far: <span class="hljs-built_in">f32</span>            <span class="hljs-comment">// Far clipping plane distance</span>
) -&gt; mat4x4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> f = <span class="hljs-number">1.0</span> / tan(fov_y_radians / <span class="hljs-number">2.0</span>);
    <span class="hljs-keyword">let</span> range = <span class="hljs-number">1.0</span> / (near - far);

    <span class="hljs-comment">// Column-major construction</span>
    <span class="hljs-keyword">let</span> col0 = vec4(f / aspect_ratio, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> col1 = vec4(<span class="hljs-number">0.0</span>, f, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> col2 = vec4(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, (near + far) * range, -<span class="hljs-number">1.0</span>);
    <span class="hljs-keyword">let</span> col3 = vec4(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span> * near * far * range, <span class="hljs-number">0.0</span>);

    <span class="hljs-keyword">return</span> mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;(col0, col1, col2, col3);
}
</code></pre>
<p>Look closely at <code>col2</code>: its fourth component (which will be multiplied by the <code>w</code> of the input vector) is set to <code>-1.0</code>. This means that after multiplication, the final <code>w</code> value of our output position will be equal to its negative <code>z</code> value from view space. This is the secret ingredient for perspective.</p>
<h3 id="heading-the-magic-of-the-perspective-divide">The Magic of the Perspective Divide</h3>
<p>The real "magic" of perspective projection happens <code>after</code> our vertex shader is finished. The GPU's fixed-function hardware takes the <code>vec4</code> position we output and automatically performs an operation called the <strong>perspective divide</strong>.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Our vertex shader outputs a clip-space position:</span>
<span class="hljs-comment">// out.position = clip_pos; (a vec4&lt;f32&gt;)</span>

<span class="hljs-comment">// The GPU automatically does this for every vertex:</span>
<span class="hljs-keyword">let</span> final_ndc_pos = clip_pos.xyz / clip_pos.w;
</code></pre>
<p>It divides the <code>x</code>, <code>y</code>, and <code>z</code> components by the <code>w</code> component. Now, let's connect this to our projection matrix. We saw that the matrix was engineered to produce this result:</p>
<p><code>clip_position.w = -view_position.z</code></p>
<p>The <code>z</code> value in view space represents the distance from the camera into the scene. By setting w to this distance, the perspective divide scales our vertex positions accordingly.</p>
<pre><code class="lang-plaintext">A point close to the camera:
  view_position.z = -2.0
  clip_position.w = 2.0
  final_x = clip_x / 2.0  (larger on screen)

A point far from the camera:
  view_position.z = -50.0
  clip_position.w = 50.0
  final_x = clip_x / 50.0 (smaller on screen)
</code></pre>
<p>This simple division is how perspective is achieved in modern graphics.</p>
<h3 id="heading-understanding-field-of-view-fov">Understanding Field of View (FOV)</h3>
<p>Field of View, or FOV, is the extent of the observable world seen at any given moment. In our projection matrix, it's the vertical angle of the camera's frustum. It's analogous to the zoom lens on a camera.</p>
<ul>
<li><p><strong>Low FOV (30-50°):</strong> Creates a "telephoto" or zoomed-in effect.</p>
</li>
<li><p><strong>Medium FOV (60-90°):</strong> A standard view that feels natural for most games.</p>
</li>
<li><p><strong>High FOV (90-120°):</strong> A wide-angle view. Can cause "fisheye" distortion at the edges of the screen.</p>
</li>
</ul>
<h3 id="heading-understanding-aspect-ratio">Understanding Aspect Ratio</h3>
<p>Aspect ratio is the ratio of the viewport's width to its height (<code>width</code> / <code>height</code>). A 1920x1080 screen has an aspect ratio of 16/9 or ~1.777. Our projection matrix needs this value to prevent the image from being stretched. The <code>fov_y_radians</code> parameter defines the vertical opening of our view. We use the aspect ratio to calculate the correct horizontal opening to match the screen's shape. The matrix corrects for this by scaling the X-coordinate: <code>f / aspect_ratio</code>. This makes the view wider than it is tall, matching the viewport's dimensions.</p>
<h3 id="heading-understanding-near-and-far-planes">Understanding Near and Far Planes</h3>
<p>The <code>near</code> and <code>far</code> parameters define the boundaries of the camera's view frustum. They create two clipping planes.</p>
<ul>
<li><p>Anything closer to the camera than the <strong>near plane</strong> is discarded ("clipped").</p>
</li>
<li><p>Anything farther from the camera than the <strong>far plane</strong> is also discarded.</p>
</li>
</ul>
<p>These planes are not just for culling geometry; they are essential for the <strong>depth buffer</strong>. The depth buffer is a texture that stores a depth value (from 0.0 to 1.0) for every pixel. Before drawing a new pixel, the GPU checks the depth buffer. If the new pixel is farther away than the one already there, it's discarded. This is how the GPU correctly sorts overlapping objects.</p>
<p>The projection matrix maps the view-space Z range <code>[-near, -far]</code> to the clip-space Z range <code>[0, 1]</code>. However, this mapping is <strong>non-linear</strong>. It's designed to give more precision to objects closer to the camera. This leads to a critical trade-off:</p>
<p><strong>Depth buffer precision is not distributed evenly.</strong></p>
<p>Imagine the depth buffer as a ruler. In a classic perspective projection, the tick marks on the ruler are densely packed near the camera and spread out farther away.</p>
<ul>
<li><p><strong>Setting the</strong> <code>near</code> plane too close (e.g., 0.01): You are cramming an enormous amount of the depth buffer's precision into the tiny space right in front of the camera. This leaves very little precision for the rest of the scene, causing distant objects with similar depths to flicker back and forth. This artifact is called <strong>Z-fighting</strong>.</p>
</li>
<li><p><strong>Setting the</strong> <code>far</code> plane too far: You are stretching a finite amount of precision over a vast distance, which also reduces accuracy and can cause Z-fighting.</p>
</li>
</ul>
<p><strong>Best Practice:</strong> Keep the <code>far / near</code> ratio as small as possible for your scene's needs (ideally under 1000). Push the <code>near</code> plane out as far as you can without clipping into objects the player should see.</p>
<h2 id="heading-part-3-reverse-z-projection">Part 3: Reverse-Z Projection</h2>
<p>In the last section, we discussed how the classic projection matrix maps the view-space depth range <code>[-near, -far]</code> to the depth buffer's <code>[0, 1]</code> range. This traditional method has a significant drawback related to how computers store numbers.</p>
<h3 id="heading-traditional-z-mapping">Traditional Z-Mapping</h3>
<ul>
<li><p>The <code>near</code> plane is mapped to a depth of <code>0.0</code>.</p>
</li>
<li><p>The <code>far</code> plane is mapped to a depth of <code>1.0</code>.</p>
</li>
<li><p>Standard floating-point numbers (like <code>f32</code>) have the most precision near zero.</p>
</li>
<li><p><strong>Result:</strong> Almost all of your depth precision is clustered right in front of the camera, leaving very little for the distant parts of your scene. This is the primary cause of the Z-fighting artifact.</p>
</li>
</ul>
<p>To solve this, modern rendering pipelines, including Bevy's, use a clever technique called <strong>"Reverse-Z"</strong>. The idea is simple but highly effective: we just flip the mapping.</p>
<h3 id="heading-reverse-z-mapping">Reverse-Z Mapping</h3>
<ul>
<li><p>The <code>near</code> plane is mapped to a depth of <code>1.0</code>.</p>
</li>
<li><p>The <code>far</code> plane is mapped to a depth of <code>0.0</code>.</p>
</li>
<li><p><strong>Result:</strong> The high precision of floating-point numbers (near zero) is now distributed across the far end of the view frustum. This results in a much more even and usable distribution of depth precision across the entire visible range, significantly reducing Z-fighting artifacts.</p>
</li>
</ul>
<p>The implementation is a small tweak to the perspective projection matrix. Bevy also commonly uses an "infinite" far plane, meaning geometry is never clipped for being too far away, which simplifies the matrix further.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A common form for an infinite far plane with Reverse-Z, which Bevy uses.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">reverse_z_perspective</span></span>(
    fov_y_radians: <span class="hljs-built_in">f32</span>,
    aspect_ratio: <span class="hljs-built_in">f32</span>,
    near: <span class="hljs-built_in">f32</span>
) -&gt; mat4x4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> f = <span class="hljs-number">1.0</span> / tan(fov_y_radians / <span class="hljs-number">2.0</span>);

    <span class="hljs-comment">// Column-major construction</span>
    <span class="hljs-keyword">let</span> col0 = vec4(f / aspect_ratio, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
    <span class="hljs-keyword">let</span> col1 = vec4(<span class="hljs-number">0.0</span>, f, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
    <span class="hljs-comment">// The Z-mapping components are different from the classic matrix</span>
    <span class="hljs-keyword">let</span> col2 = vec4(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, -<span class="hljs-number">1.0</span>);
    <span class="hljs-keyword">let</span> col3 = vec4(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, near, <span class="hljs-number">0.0</span>);

    <span class="hljs-keyword">return</span> mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;(col0, col1, col2, col3);
}
</code></pre>
<p>You don't need to implement this yourself when using Bevy's built-in camera, but it's crucial to know that this is happening under the hood. It explains why Bevy's rendering is robust against depth artifacts by default and is a key piece of context for anyone diving deep into the engine's rendering code. From this point forward, when we discuss "the projection matrix," you can assume it's this more robust, modern version.</p>
<h2 id="heading-part-4-custom-projection-effects">Part 4: Custom Projection Effects</h2>
<p>Understanding how projection matrices are constructed gives you the power to break the rules. By manipulating the transformation pipeline, you can create non-standard camera effects that would be impossible with a standard projection matrix alone.</p>
<h3 id="heading-fish-eye-effect">Fish-Eye Effect</h3>
<p>A fisheye lens captures an extremely wide field of view, causing straight lines to appear curved. This "barrel distortion" is the lens's signature characteristic. A standard perspective projection matrix is fundamentally incapable of creating this effect because it is a linear transformation, meaning it is designed to preserve straight lines.</p>
<p>To create a true fisheye effect, we must introduce a non-linear step into our vertex shader.</p>
<ul>
<li><p><strong>Perspective Projection's Logic:</strong> The distance of a point from the center of the screen is proportional to <code>tan(theta)</code>, where <code>theta</code> is the angle of that point from the camera's forward axis. This preserves lines.</p>
</li>
<li><p><strong>Fisheye Projection's Logic:</strong> The distance from the center is proportional directly to the angle <code>theta</code> itself. This bends lines.</p>
</li>
</ul>
<p><strong>Implementation in the Vertex Shader:</strong></p>
<p>The most accurate way to implement this is to interrupt the standard transformation pipeline. We transform our vertex into view space, apply our custom non-linear distortion, and then apply the final projection matrix.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// --- In the vertex shader ---</span>

<span class="hljs-comment">// 1. Transform vertex from world space to view space as usual.</span>
<span class="hljs-keyword">let</span> view_pos = view_matrix * world_position;

<span class="hljs-comment">// 2. Apply the non-linear fisheye distortion.</span>
<span class="hljs-comment">// Calculate the distance from the center of the view and the angle from the forward axis.</span>
<span class="hljs-keyword">let</span> xy_distance = length(view_pos.xy);
<span class="hljs-comment">// -view_pos.z is the distance "into" the screen</span>
<span class="hljs-keyword">let</span> theta = atan2(xy_distance, -view_pos.z); 

<span class="hljs-comment">// 3. Determine the new, distorted distance from the center.</span>
<span class="hljs-comment">// Instead of tan(theta), we just use theta.</span>
<span class="hljs-comment">// The focal_length is derived from the camera's FOV.</span>
<span class="hljs-keyword">let</span> focal_length = <span class="hljs-number">1.0</span> / tan(fov_y_radians * <span class="hljs-number">0.5</span>);
<span class="hljs-keyword">let</span> fisheye_radius = theta * focal_length;

<span class="hljs-comment">// 4. Calculate a scaling factor and apply it.</span>
var distorted_view_pos = view_pos;
<span class="hljs-comment">// Avoid division by zero at the very center of the view.</span>
<span class="hljs-keyword">if</span> (xy_distance &gt; <span class="hljs-number">0.001</span>) {
    <span class="hljs-keyword">let</span> scale = fisheye_radius / xy_distance;
    distorted_view_pos.xy *= scale;
}

<span class="hljs-comment">// 5. Now that the view-space position is distorted, apply the standard projection.</span>
<span class="hljs-keyword">let</span> clip_pos = projection_matrix * distorted_view_pos;
</code></pre>
<h3 id="heading-dolly-zoom-the-vertigo-effect">Dolly Zoom (The "Vertigo" Effect)</h3>
<p>Popularized by Alfred Hitchcock's film Vertigo, the dolly zoom is a dramatic cinematic technique. It's achieved by moving the camera towards or away from a subject while simultaneously adjusting the lens's zoom (or FOV) to keep the subject the same size in the frame. The result is that the subject appears stationary while the background seems to either compress or expand dramatically.</p>
<p>This effect isn't a custom shader trick, but rather a manipulation of the camera and projection data you send to the shader each frame from your Rust code.</p>
<p><strong>Implementation (in your Rust code):</strong></p>
<ol>
<li><p><strong>Move the camera:</strong> In your update system, change the camera's <code>Transform</code> to move it closer to or farther from your target.</p>
</li>
<li><p><strong>Adjust the FOV:</strong> In the same system, change the <code>fov</code> property of the <code>PerspectiveProjection</code> component.</p>
</li>
</ol>
<ul>
<li><p>As the camera moves <strong>closer</strong>, you must <strong>increase</strong> the FOV (zoom out) to keep the subject the same size.</p>
</li>
<li><p>As the camera moves <strong>away</strong>, you must <strong>decrease</strong> the FOV (zoom in).</p>
</li>
</ul>
<p>The shader simply receives a different <code>projection_matrix</code> each frame and renders the scene accordingly, creating the iconic effect. Here is what a simple Bevy system to control a dolly zoom might look like.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A resource to control the dolly zoom effect</span>
<span class="hljs-meta">#[derive(Resource)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">DollyZoom</span></span> {
    target_entity: Entity,
    <span class="hljs-comment">// The value that must remain constant: distance_to_target * tan(fov / 2)</span>
    initial_product: <span class="hljs-built_in">f32</span>,
    <span class="hljs-comment">// A timer to drive the animation, 0.0 to 1.0</span>
    progress: <span class="hljs-built_in">f32</span>,
    start_distance: <span class="hljs-built_in">f32</span>,
    end_distance: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">dolly_zoom_system</span></span>(
    time: Res&lt;Time&gt;,
    <span class="hljs-keyword">mut</span> dolly: ResMut&lt;DollyZoom&gt;,
    <span class="hljs-keyword">mut</span> camera_query: Query&lt;(&amp;<span class="hljs-keyword">mut</span> Transform, &amp;<span class="hljs-keyword">mut</span> Projection), With&lt;Camera3d&gt;&gt;,
    target_query: Query&lt;&amp;GlobalTransform&gt;,
) {
    <span class="hljs-keyword">let</span> <span class="hljs-literal">Ok</span>((<span class="hljs-keyword">mut</span> camera_transform, <span class="hljs-keyword">mut</span> projection)) = camera_query.get_single_mut() <span class="hljs-keyword">else</span> { <span class="hljs-keyword">return</span> };
    <span class="hljs-keyword">let</span> <span class="hljs-literal">Ok</span>(target_transform) = target_query.get(dolly.target_entity) <span class="hljs-keyword">else</span> { <span class="hljs-keyword">return</span> };

    <span class="hljs-comment">// Animate the effect over a few seconds</span>
    dolly.progress = (dolly.progress + time.delta_secs() * <span class="hljs-number">0.2</span>).fract();
    <span class="hljs-keyword">let</span> current_distance = dolly.start_distance.lerp(dolly.end_distance, dolly.progress);

    <span class="hljs-comment">// 1. Move the camera</span>
    <span class="hljs-keyword">let</span> direction_to_target = (target_transform.translation() - camera_transform.translation).normalize();
    camera_transform.translation = target_transform.translation() - direction_to_target * current_distance;

    <span class="hljs-comment">// 2. Adjust the FOV to compensate</span>
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> Projection::Perspective(<span class="hljs-keyword">ref</span> <span class="hljs-keyword">mut</span> pers) = *projection {
        <span class="hljs-comment">// Solve for the new FOV using the core relationship</span>
        <span class="hljs-keyword">let</span> new_half_fov_tan = dolly.initial_product / current_distance;
        <span class="hljs-keyword">let</span> new_fov_rad = <span class="hljs-number">2.0</span> * new_half_fov_tan.atan();
        pers.fov = new_fov_rad;
    }
}
</code></pre>
<h2 id="heading-part-5-accessing-bevys-view-and-projection">Part 5: Accessing Bevy's View and Projection</h2>
<p>While building matrices from scratch in WGSL is a fantastic learning exercise, it's not something you'll do every day. Bevy's renderer, of course, already calculates the view and projection matrices for every active camera. Our job is to get that data from Bevy into our shader.</p>
<p>There are two primary ways to do this, each with its own use case: accessing Bevy's global view uniform directly, and passing the data through our own custom material.</p>
<h3 id="heading-the-global-view-uniform-with-a-big-caveat">The Global View Uniform (With a Big Caveat)</h3>
<p>Bevy prepares a large uniform buffer containing all the data for the current view and binds it for many of its internal rendering passes. This View uniform is available at a well-known location: bind group <code>0</code>, binding <code>0</code>.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Bevy's built-in View uniform struct (simplified)</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">View</span></span> {
    view_proj: mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;,       <span class="hljs-comment">// The final combined view * projection matrix</span>
    inverse_view_proj: mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;,
    view: mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;,             <span class="hljs-comment">// The view matrix only</span>
    inverse_view: mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;,     <span class="hljs-comment">// The camera's world matrix</span>
    projection: mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;,       <span class="hljs-comment">// The projection matrix only</span>
    inverse_projection: mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;,
    world_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,     <span class="hljs-comment">// The camera's world position</span>
    <span class="hljs-comment">// ... and many more fields for time, viewport size, etc.</span>
};

@group(<span class="hljs-number">0</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; view: View;
</code></pre>
<p>You could, in theory, add this to your shader and use Bevy's data directly:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// This calculates the final position just like Bevy's internal shaders.</span>
<span class="hljs-keyword">let</span> clip_pos = view.view_proj * world_position;
</code></pre>
<p><strong>WARNING: Do NOT do this in a standard</strong> <code>Material</code>!</p>
<p>Bevy's material system uses bind group <code>1</code> for material-specific data and bind group <code>2</code> for mesh-level data. Bind group <code>0</code> is reserved for view-level data managed by Bevy's PBR pipeline. If you try to define <code>@group(0) @binding(0)</code> in your custom material's shader, it will cause a <strong>binding conflict</strong> with the data Bevy is already providing, leading to crashes or unpredictable behavior.</p>
<p><strong>When is it safe to use the global</strong> <code>View</code> uniform?</p>
<ul>
<li><p>In compute shaders.</p>
</li>
<li><p>In full-screen post-processing effects.</p>
</li>
<li><p>In custom render pipelines where you are not using Bevy's <code>Material</code> trait.</p>
</li>
</ul>
<p>For our purposes in this curriculum, we will avoid this method and use the safer, more flexible approach.</p>
<h3 id="heading-the-safe-approach-material-uniforms">The Safe Approach: Material Uniforms</h3>
<p>The correct and most robust way to get camera data into a custom material is to <strong>pass it in yourself</strong>. We treat the camera's matrices just like any other data we want to control, like a color or a time value.</p>
<p>This involves three steps:</p>
<p><strong>1. Define a uniform struct in your material:</strong></p>
<pre><code class="lang-rust"><span class="hljs-comment">// In your material's Rust code</span>
<span class="hljs-comment">// ...</span>

<span class="hljs-meta">#[derive(ShaderType, Clone)]</span> <span class="hljs-comment">// ShaderType is crucial</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">CameraData</span></span> {
    <span class="hljs-keyword">pub</span> view_proj: Mat4,
    <span class="hljs-keyword">pub</span> position: Vec3,
}

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">MyCustomMaterial</span></span> {
    <span class="hljs-comment">// This will be bound to @group(1) @binding(0) by default</span>
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> camera: CameraData,
    <span class="hljs-meta">#[uniform(1)]</span>
    <span class="hljs-keyword">pub</span> color: Color,
}
</code></pre>
<p><strong>2. Create a Bevy system to update this data every frame:</strong></p>
<p>This system queries for the active camera, gets its transform and projection data, and iterates through all assets of your material type, updating them with the latest values.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In your app's systems</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_material_camera_data</span></span>(
    camera_query: Query&lt;(&amp;GlobalTransform, &amp;Projection), With&lt;Camera3d&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;MyCustomMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> <span class="hljs-literal">Ok</span>((camera_transform, projection)) = camera_query.get_single() <span class="hljs-keyword">else</span> { <span class="hljs-keyword">return</span> };

    <span class="hljs-keyword">let</span> view_matrix = camera_transform.compute_matrix().inverse();
    <span class="hljs-keyword">let</span> view_proj = projection.get_projection_matrix() * view_matrix;

    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        material.camera.view_proj = view_proj;
        material.camera.position = camera_transform.translation();
    }
}
</code></pre>
<p><strong>3. Use the data in your shader:</strong></p>
<p>Now your shader can access this data from its own bind group (<code>@group(1)</code> for a Material that also uses mesh data, or <code>@group(2)</code> if it's a <code>Material</code> on a <code>Mesh3d</code> without a <code>StandardMaterial</code> handle), completely avoiding any conflicts with Bevy's internal bindings.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In your shader.wgsl</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">CameraData</span></span> {
    view_proj: mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;,
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
};

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">MyMaterial</span></span> {
    camera: CameraData,
    color: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
};

<span class="hljs-comment">// Assuming this material is used with Mesh3d/MeshMaterial3d</span>
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: MyMaterial;

<span class="hljs-comment">// ... in your vertex function</span>
<span class="hljs-keyword">let</span> clip_pos = material.camera.view_proj * world_position;
</code></pre>
<p>This pattern is more work to set up initially, but it is the correct, conflict-free way to work with the <code>Material</code> trait. It also gives you the flexibility to send different camera data to different materials if you ever needed to.</p>
<pre><code>
---

## Complete Example: Interactive Camera Explorer

Now, <span class="hljs-keyword">let</span><span class="hljs-string">'s put all this theory into practice. We will build an interactive demo that allows you to switch between perspective and orthographic projection on the fly. You will be able to orbit a scene of simple cubes, adjust the field of view, and see exactly how these changes affect the final rendering.

This project will solidify your understanding of how camera matrices are not just theoretical constructs but are the primary tools for defining the look and feel of a 3D scene.

### Our Goal

We will create a custom material and shader that visualizes our camera logic. A Rust system will build the View and Projection matrices from scratch based on interactive controls. We will use Bevy'</span>s standard transformation <span class="hljs-keyword">for</span> the geometry to ensure stability, but we will pass our custom camera parameters to the fragment shader to drive distance-based fog and color coding, helping us visualize the difference between projection modes.

### What This Project Demonstrates

* **Manual Matrix Construction:** Building <span class="hljs-string">`look_at`</span> (view) and <span class="hljs-string">`perspective`</span>/<span class="hljs-string">`orthographic`</span> (projection) matrices <span class="hljs-keyword">in</span> Rust.

* **Uniform Data Flow:** Passing complex camera data <span class="hljs-keyword">from</span> a Rust system into a custom <span class="hljs-string">`Material`</span><span class="hljs-string">'s uniform buffer.

* **Complete Vertex Transformation:** Implementing the full `projection * view * model * position` pipeline in a WGSL vertex shader for all modes.

* **Shader-Based Branching:** Using a `u32` uniform to switch between different rendering modes (perspective, ortho, fisheye) inside the shader.

* **Interactive Feedback:** Connecting keyboard inputs to camera parameters (FOV, distance, projection type) to provide a tangible feel for each concept.


### The Shader (`assets/shaders/d02_02_multi_projection.wgsl`)

The vertex shader uses Bevy'</span>s built-<span class="hljs-keyword">in</span> <span class="hljs-string">`position_world_to_clip`</span> <span class="hljs-keyword">for</span> the geometry, ensuring our mesh is placed correctly on screen. However, we pass our custom camera data to the fragment shader to visualize the different modes: Perspective mode gets distance-based fog (which relies on camera position), <span class="hljs-keyword">while</span> Orthographic mode gets a distinct flat coloring style.

<span class="hljs-string">``</span><span class="hljs-string">`rust
#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations
#import bevy_pbr::forward_io::VertexOutput

struct CameraUniforms {
    view_matrix: mat4x4&lt;f32&gt;,
    projection_matrix: mat4x4&lt;f32&gt;,
    camera_position: vec3&lt;f32&gt;,
    projection_type: u32,  // 0=perspective, 1=orthographic
    fov: f32,  // Field of view in radians
    ortho_size: f32,
    time: f32,
}

@group(2) @binding(0)
var&lt;uniform&gt; camera: CameraUniforms;

@vertex
fn vertex(
    @builtin(instance_index) instance_index: u32,
    @location(0) position: vec3&lt;f32&gt;,
    @location(1) normal: vec3&lt;f32&gt;,
) -&gt; 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&lt;f32&gt;(position, 1.0)
    );

    out.position = bevy_pbr::view_transformations::position_world_to_clip(world_position.xyz);

    // Pass data to fragment shader
    out.world_position = world_position;
    out.world_normal = mesh_functions::mesh_normal_local_to_world(normal, instance_index);

    return out;
}

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

    // Calculate distance from camera
    let to_camera = in.world_position.xyz - camera.camera_position;
    let distance = length(to_camera);

    // Color based on projection type
    var base_color = vec3&lt;f32&gt;(0.0);

    if camera.projection_type == 0u {
        // Perspective - blue
        base_color = vec3&lt;f32&gt;(0.3, 0.5, 1.0);
    } else{
        // Orthographic - green
        base_color = vec3&lt;f32&gt;(0.3, 1.0, 0.5);
    }

    // Simple lighting
    let light_dir = normalize(vec3&lt;f32&gt;(
        cos(camera.time),
        0.5,
        sin(camera.time)
    ));
    let diffuse = max(0.3, dot(normal, light_dir));

    // Distance-based fog for perspective
    if camera.projection_type == 0u {
        let fog_start = 10.0;
        let fog_end = 40.0;
        let fog_factor = clamp((distance - fog_start) / (fog_end - fog_start), 0.0, 1.0);
        base_color = mix(base_color, vec3&lt;f32&gt;(0.5, 0.5, 0.6), fog_factor * 0.5);
    }

    return vec4&lt;f32&gt;(base_color * diffuse, 1.0);
}</span>
</code></pre><h3 id="heading-the-rust-material-srcmaterialsd0202multiprojectionrs">The Rust Material (<code>src/materials/d02_02_multi_projection.rs</code>)</h3>
<p>This file defines the data structure that will be passed from the CPU to the GPU. It contains our manually constructed matrices, the camera's position for lighting calculations, and several parameters to control the projection modes. Note the padding fields, which are necessary to ensure the struct's memory layout in Rust matches WGSL's expectations.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-keyword">mod</span> uniforms {
    <span class="hljs-meta">#![allow(dead_code)]</span>

    <span class="hljs-keyword">use</span> bevy::prelude::*;
    <span class="hljs-keyword">use</span> bevy::render::render_resource::ShaderType;

    <span class="hljs-meta">#[derive(ShaderType, Debug, Clone)]</span>
    <span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">CameraUniforms</span></span> {
        <span class="hljs-keyword">pub</span> view_matrix: Mat4,
        <span class="hljs-keyword">pub</span> projection_matrix: Mat4,
        <span class="hljs-keyword">pub</span> camera_position: Vec3,
        <span class="hljs-keyword">pub</span> projection_type: <span class="hljs-built_in">u32</span>,
        <span class="hljs-keyword">pub</span> fov: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> fisheye_strength: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> ortho_size: <span class="hljs-built_in">f32</span>,
        <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
    }

    <span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> CameraUniforms {
        <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
            <span class="hljs-keyword">Self</span> {
                view_matrix: Mat4::IDENTITY,
                projection_matrix: Mat4::IDENTITY,
                camera_position: Vec3::ZERO,
                projection_type: <span class="hljs-number">0</span>,
                fov: <span class="hljs-number">60.0</span>,
                fisheye_strength: <span class="hljs-number">0.5</span>,
                ortho_size: <span class="hljs-number">10.0</span>,
                time: <span class="hljs-number">0.0</span>,
            }
        }
    }
}

<span class="hljs-keyword">pub</span> <span class="hljs-keyword">use</span> uniforms::CameraUniforms;

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">MultiProjectionMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> camera: CameraUniforms,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> MultiProjectionMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_02_multi_projection.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_02_multi_projection.wgsl"</span>.into()
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other materials</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_02_multi_projection;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0202multiprojectionrs">The Demo Module (<code>src/demos/d02_02_multi_projection.rs</code>)</h3>
<p>The Rust code sets up our scene and contains the logic for interactivity. The key system is <code>update_materials</code>. It takes the user-controlled parameters, <strong>builds the final view and projection matrices from scratch using our own helper functions</strong>, and then iterates through every instance of our custom material to update their uniform data.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d02_02_multi_projection::{CameraUniforms, MultiProjectionMaterial};
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> std::<span class="hljs-built_in">f32</span>::consts::PI;

<span class="hljs-meta">#[derive(Resource)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">CameraParams</span></span> {
    distance: <span class="hljs-built_in">f32</span>, <span class="hljs-comment">// Distance from target</span>
    angle: <span class="hljs-built_in">f32</span>,    <span class="hljs-comment">// Horizontal rotation angle</span>
    height: <span class="hljs-built_in">f32</span>,   <span class="hljs-comment">// Vertical height</span>
    target: Vec3,  <span class="hljs-comment">// Look-at target</span>
    fov_degrees: <span class="hljs-built_in">f32</span>,
    projection_type: <span class="hljs-built_in">u32</span>, <span class="hljs-comment">// 0=perspective, 1=orthographic</span>
    ortho_size: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-keyword">impl</span> <span class="hljs-built_in">Default</span> <span class="hljs-keyword">for</span> CameraParams {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">default</span></span>() -&gt; <span class="hljs-keyword">Self</span> {
        <span class="hljs-keyword">Self</span> {
            distance: <span class="hljs-number">15.0</span>,
            angle: <span class="hljs-number">0.0</span>,
            height: <span class="hljs-number">5.0</span>,
            target: Vec3::ZERO,
            fov_degrees: <span class="hljs-number">60.0</span>,
            projection_type: <span class="hljs-number">0</span>,
            ortho_size: <span class="hljs-number">10.0</span>,
        }
    }
}

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;MultiProjectionMaterial&gt;::default())
        .init_resource::&lt;CameraParams&gt;()
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (
                handle_input,
                update_camera_transform,
                update_materials,
                update_ui,
            ),
        )
        .run();
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;MultiProjectionMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> standard_materials: ResMut&lt;Assets&lt;StandardMaterial&gt;&gt;,
) {
    <span class="hljs-comment">// Create a large grid of cubes to show projection effects</span>
    <span class="hljs-keyword">for</span> x <span class="hljs-keyword">in</span> -<span class="hljs-number">10</span>..=<span class="hljs-number">10</span> {
        <span class="hljs-keyword">for</span> z <span class="hljs-keyword">in</span> -<span class="hljs-number">10</span>..=<span class="hljs-number">10</span> {
            <span class="hljs-keyword">let</span> distance = ((x * x + z * z) <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span>).sqrt();
            <span class="hljs-keyword">let</span> height = (distance * <span class="hljs-number">0.3</span>).sin() * <span class="hljs-number">0.5</span> + <span class="hljs-number">0.5</span>;

            commands.spawn((
                Mesh3d(meshes.add(Cuboid::new(<span class="hljs-number">0.8</span>, height + <span class="hljs-number">0.3</span>, <span class="hljs-number">0.8</span>))),
                MeshMaterial3d(materials.add(MultiProjectionMaterial {
                    camera: CameraUniforms::default(),
                })),
                Transform::from_xyz(x <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> * <span class="hljs-number">1.5</span>, height * <span class="hljs-number">0.5</span>, z <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> * <span class="hljs-number">1.5</span>),
            ));
        }
    }

    <span class="hljs-comment">// Add reference spheres at different distances</span>
    <span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..=<span class="hljs-number">8</span> {
        <span class="hljs-keyword">let</span> i = i - <span class="hljs-number">4</span>;
        <span class="hljs-keyword">let</span> distance = i <span class="hljs-keyword">as</span> <span class="hljs-built_in">f32</span> * <span class="hljs-number">3.0</span>;
        commands.spawn((
            Mesh3d(meshes.add(Sphere::new(<span class="hljs-number">0.5</span>))),
            MeshMaterial3d(standard_materials.add(StandardMaterial {
                base_color: Color::srgb(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.2</span>),
                ..default()
            })),
            Transform::from_xyz(<span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span>, -distance),
        ));
    }

    <span class="hljs-comment">// Light</span>
    commands.spawn((
        DirectionalLight {
            illuminance: <span class="hljs-number">10000.0</span>,
            shadows_enabled: <span class="hljs-literal">true</span>,
            ..default()
        },
        Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -PI / <span class="hljs-number">4.0</span>, PI / <span class="hljs-number">4.0</span>, <span class="hljs-number">0.0</span>)),
    ));

    <span class="hljs-comment">// Camera</span>
    <span class="hljs-keyword">let</span> params = CameraParams::default();
    <span class="hljs-keyword">let</span> position = Vec3::new(
        params.distance * params.angle.cos(),
        params.height,
        params.distance * params.angle.sin(),
    );
    commands.spawn((
        Camera3d::default(),
        Transform::from_translation(position).looking_at(params.target, Vec3::Y),
    ));

    <span class="hljs-comment">// UI</span>
    commands.spawn((
        Text::new(<span class="hljs-string">""</span>),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">10.0</span>),
            left: Val::Px(<span class="hljs-number">10.0</span>),
            ..default()
        },
        TextFont {
            font_size: <span class="hljs-number">16.0</span>,
            ..default()
        },
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_camera_transform</span></span>(
    params: Res&lt;CameraParams&gt;,
    <span class="hljs-keyword">mut</span> camera_query: Query&lt;(&amp;<span class="hljs-keyword">mut</span> Transform, &amp;<span class="hljs-keyword">mut</span> Projection), With&lt;Camera3d&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> <span class="hljs-literal">Ok</span>((<span class="hljs-keyword">mut</span> transform, <span class="hljs-keyword">mut</span> projection)) = camera_query.single_mut() <span class="hljs-keyword">else</span> {
        <span class="hljs-keyword">return</span>;
    };

    <span class="hljs-comment">// Calculate camera position from polar coordinates</span>
    <span class="hljs-keyword">let</span> position = Vec3::new(
        params.distance * params.angle.cos(),
        params.height,
        params.distance * params.angle.sin(),
    );

    <span class="hljs-comment">// Update camera position and orientation</span>
    *transform = Transform::from_translation(position).looking_at(params.target, Vec3::Y);

    <span class="hljs-comment">// Update camera projection based on type</span>
    <span class="hljs-keyword">match</span> params.projection_type {
        <span class="hljs-number">0</span> =&gt; {
            <span class="hljs-comment">// Perspective projection</span>
            *projection = Projection::Perspective(PerspectiveProjection {
                fov: params.fov_degrees.to_radians(),
                near: <span class="hljs-number">0.1</span>,
                far: <span class="hljs-number">1000.0</span>,
                aspect_ratio: <span class="hljs-number">1.0</span>, <span class="hljs-comment">// Will be updated by Bevy</span>
            });
        }
        <span class="hljs-number">1</span> =&gt; {
            <span class="hljs-comment">// Orthographic projection</span>
            <span class="hljs-keyword">let</span> scale = params.ortho_size;
            *projection = Projection::Orthographic(OrthographicProjection {
                near: -<span class="hljs-number">1000.0</span>,
                far: <span class="hljs-number">1000.0</span>,
                viewport_origin: Vec2::new(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>),
                scaling_mode: bevy::render::camera::ScalingMode::FixedVertical {
                    viewport_height: scale * <span class="hljs-number">2.0</span>,
                },
                scale: <span class="hljs-number">1.0</span>,
                area: bevy::math::Rect {
                    min: Vec2::new(-scale, -scale),
                    max: Vec2::new(scale, scale),
                },
            });
        }
        _ =&gt; {}
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_materials</span></span>(
    time: Res&lt;Time&gt;,
    params: Res&lt;CameraParams&gt;,
    windows: Query&lt;&amp;Window&gt;,
    camera_query: Query&lt;&amp;Transform, With&lt;Camera3d&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;MultiProjectionMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> <span class="hljs-literal">Ok</span>(window) = windows.single() <span class="hljs-keyword">else</span> {
        <span class="hljs-keyword">return</span>;
    };
    <span class="hljs-keyword">let</span> <span class="hljs-literal">Ok</span>(camera_transform) = camera_query.single() <span class="hljs-keyword">else</span> {
        <span class="hljs-keyword">return</span>;
    };

    <span class="hljs-keyword">let</span> aspect = window.width() / window.height();
    <span class="hljs-keyword">let</span> position = camera_transform.translation;

    <span class="hljs-comment">// Build view matrix</span>
    <span class="hljs-keyword">let</span> view_matrix = build_view_matrix(position, params.target, Vec3::Y);

    <span class="hljs-comment">// Build projection matrix based on type</span>
    <span class="hljs-keyword">let</span> projection_matrix = <span class="hljs-keyword">match</span> params.projection_type {
        <span class="hljs-number">0</span> =&gt; build_perspective_matrix(params.fov_degrees, aspect, <span class="hljs-number">0.1</span>, <span class="hljs-number">1000.0</span>),
        <span class="hljs-number">1</span> =&gt; build_orthographic_matrix(params.ortho_size, aspect, -<span class="hljs-number">1000.0</span>, <span class="hljs-number">1000.0</span>),
        _ =&gt; build_perspective_matrix(params.fov_degrees, aspect, <span class="hljs-number">0.1</span>, <span class="hljs-number">1000.0</span>),
    };

    <span class="hljs-comment">// Update all materials</span>
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        material.camera.view_matrix = view_matrix;
        material.camera.projection_matrix = projection_matrix;
        material.camera.camera_position = position;
        material.camera.projection_type = params.projection_type;
        material.camera.fov = params.fov_degrees.to_radians(); <span class="hljs-comment">// Convert to radians for shader</span>
        material.camera.ortho_size = params.ortho_size;
        material.camera.time = time.elapsed_secs();
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">build_view_matrix</span></span>(eye: Vec3, target: Vec3, up: Vec3) -&gt; Mat4 {
    <span class="hljs-keyword">let</span> forward = (eye - target).normalize();
    <span class="hljs-keyword">let</span> right = up.cross(forward).normalize();

    <span class="hljs-keyword">let</span> camera_up = right.cross(forward);

    Mat4::from_cols(
        right.extend(<span class="hljs-number">0.0</span>),
        camera_up.extend(<span class="hljs-number">0.0</span>),
        forward.extend(<span class="hljs-number">0.0</span>),
        Vec4::new(-right.dot(eye), -camera_up.dot(eye), -forward.dot(eye), <span class="hljs-number">1.0</span>),
    )
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">build_perspective_matrix</span></span>(fov_degrees: <span class="hljs-built_in">f32</span>, aspect: <span class="hljs-built_in">f32</span>, near: <span class="hljs-built_in">f32</span>, far: <span class="hljs-built_in">f32</span>) -&gt; Mat4 {
    <span class="hljs-keyword">let</span> fov_rad = fov_degrees * PI / <span class="hljs-number">180.0</span>;
    <span class="hljs-keyword">let</span> f = <span class="hljs-number">1.0</span> / (fov_rad / <span class="hljs-number">2.0</span>).tan();
    <span class="hljs-keyword">let</span> range = <span class="hljs-number">1.0</span> / (near - far);

    Mat4::from_cols(
        Vec4::new(f / aspect, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        Vec4::new(<span class="hljs-number">0.0</span>, f, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        Vec4::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, (near + far) * range, -<span class="hljs-number">1.0</span>),
        Vec4::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span> * near * far * range, <span class="hljs-number">0.0</span>),
    )
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">build_orthographic_matrix</span></span>(size: <span class="hljs-built_in">f32</span>, aspect: <span class="hljs-built_in">f32</span>, near: <span class="hljs-built_in">f32</span>, far: <span class="hljs-built_in">f32</span>) -&gt; Mat4 {
    <span class="hljs-keyword">let</span> width = size * aspect;
    <span class="hljs-keyword">let</span> height = size;
    <span class="hljs-keyword">let</span> depth = far - near;

    Mat4::from_cols(
        Vec4::new(<span class="hljs-number">2.0</span> / width, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        Vec4::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">2.0</span> / height, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
        Vec4::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, -<span class="hljs-number">2.0</span> / depth, <span class="hljs-number">0.0</span>),
        Vec4::new(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, -(far + near) / depth, <span class="hljs-number">1.0</span>),
    )
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    <span class="hljs-keyword">mut</span> params: ResMut&lt;CameraParams&gt;,
    time: Res&lt;Time&gt;,
) {
    <span class="hljs-keyword">let</span> delta = time.delta_secs();

    <span class="hljs-comment">// Switch projection type</span>
    <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Space) {
        params.projection_type = (params.projection_type + <span class="hljs-number">1</span>) % <span class="hljs-number">2</span>;
        <span class="hljs-comment">// params.projection_type = params.projection_type + 1;</span>
    }

    <span class="hljs-comment">// Camera rotation (around target)</span>
    <span class="hljs-keyword">let</span> rotation_speed = <span class="hljs-number">2.0</span> * delta;
    <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowLeft) {
        params.angle -= rotation_speed;
    }
    <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowRight) {
        params.angle += rotation_speed;
    }

    <span class="hljs-comment">// Camera height</span>
    <span class="hljs-keyword">let</span> height_speed = <span class="hljs-number">5.0</span> * delta;
    <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowUp) {
        params.height = (params.height + height_speed).min(<span class="hljs-number">20.0</span>);
    }
    <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowDown) {
        params.height = (params.height - height_speed).max(<span class="hljs-number">1.0</span>);
    }

    <span class="hljs-comment">// Camera distance</span>
    <span class="hljs-keyword">let</span> distance_speed = <span class="hljs-number">5.0</span> * delta;
    <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::Equal) {
        params.distance = (params.distance - distance_speed).max(<span class="hljs-number">3.0</span>);
    }
    <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::Minus) {
        params.distance = (params.distance + distance_speed).min(<span class="hljs-number">50.0</span>);
    }

    <span class="hljs-comment">// FOV/ortho size adjustment</span>
    <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyQ) {
        params.fov_degrees = (params.fov_degrees - <span class="hljs-number">30.0</span> * delta).max(<span class="hljs-number">10.0</span>);
        params.ortho_size = (params.ortho_size - <span class="hljs-number">5.0</span> * delta).max(<span class="hljs-number">1.0</span>);
    }
    <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyE) {
        params.fov_degrees = (params.fov_degrees + <span class="hljs-number">30.0</span> * delta).min(<span class="hljs-number">120.0</span>);
        params.ortho_size = (params.ortho_size + <span class="hljs-number">5.0</span> * delta).min(<span class="hljs-number">50.0</span>);
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(params: Res&lt;CameraParams&gt;, <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;) {
    <span class="hljs-keyword">if</span> !params.is_changed() {
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
        <span class="hljs-keyword">let</span> proj_name = <span class="hljs-keyword">match</span> params.projection_type {
            <span class="hljs-number">0</span> =&gt; <span class="hljs-string">"Perspective"</span>.to_string(),
            <span class="hljs-number">1</span> =&gt; <span class="hljs-string">"Orthographic"</span>.to_string(),
            _ =&gt; <span class="hljs-string">"Unknown"</span>.to_string(),
        };

        <span class="hljs-keyword">let</span> zoom_info = <span class="hljs-keyword">match</span> params.projection_type {
            <span class="hljs-number">0</span> =&gt; <span class="hljs-built_in">format!</span>(<span class="hljs-string">"FOV: {:.0}deg"</span>, params.fov_degrees),
            <span class="hljs-number">1</span> =&gt; <span class="hljs-built_in">format!</span>(<span class="hljs-string">"Size: {:.1}"</span>, params.ortho_size),
            _ =&gt; <span class="hljs-built_in">String</span>::new(),
        };

        **text = <span class="hljs-built_in">format!</span>(
            <span class="hljs-string">"[SPACE]: Perspective (blue) / Orthographic (green)\n\
             [Arrow Keys] Rotate Camera | [=/-] Camera Distance\n\
             [Q/E] FOV/Zoom\n\
             Projection: {}\n\
             {} | Distance: {:.1}\n\
             Angle: {:.0}deg | Height: {:.1}"</span>,
            proj_name,
            zoom_info,
            params.distance,
            params.angle.to_degrees(),
            params.height
        );
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other demoss</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_02_multi_projection;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"2.2"</span>,
    title: <span class="hljs-string">"Camera and Projection Matrices"</span>,
    run: demos::d02_02_multi_projection::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>When you run the application, you will be greeted by a grid of cubes. You can use the keyboard to manipulate the camera and projection in real-time.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key(s)</td><td>Action</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Space</strong></td><td>Toggle between Perspective and Orthographic modes</td></tr>
<tr>
<td><strong>Arrow Keys</strong></td><td>Orbit the camera around the center of the scene</td></tr>
<tr>
<td><strong>W / S</strong></td><td>Increase / Decrease the camera's height</td></tr>
<tr>
<td><strong>\= / -</strong></td><td>Move the camera closer or farther away</td></tr>
<tr>
<td><strong>Q / E</strong></td><td>Decrease / Increase FOV or Orthographic Size</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763674080662/454a0ee1-5144-48ad-8976-8ebac5adbcc1.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763674094127/8f788b54-8e64-4863-8538-c338a5f3f749.png" alt class="image--center mx-auto" /></p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Mode</td><td>Description</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Perspective</strong></td><td>(Blue Objects) Standard perspective. Notice how the parallel lines of the grid appear to converge at a vanishing point in the distance. Objects farther away are smaller.</td></tr>
<tr>
<td><strong>Orthographic</strong></td><td>(Green Objects) All cubes appear the same size, no matter their distance. Parallel lines remain perfectly parallel. The scene looks flat, like a technical diagram.</td></tr>
</tbody>
</table>
</div><p><strong>FOV/Size:</strong> Experiment with <code>Q</code> and <code>E</code> in each mode. In perspective/fisheye, you are changing the "zoom" of the lens. In orthographic, you are changing the size of the visible rectangular area.</p>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>This article demystified the "black box" of camera transformations. Before moving on, ensure you have a solid grasp of these core concepts:</p>
<ol>
<li><p><strong>The Full MVP Pipeline:</strong> You now understand the complete vertex transformation journey: <strong>Model → World → View → Clip</strong>. You can implement this full <code>projection * view * model * position</code> multiplication chain in a shader to gain ultimate control over where a vertex appears on screen.</p>
</li>
<li><p><strong>View Matrix:</strong> Its purpose is to transform the entire world into a camera-centric coordinate system (View Space), where the camera is at the origin looking down the -Z axis. It is the mathematical <strong>inverse</strong> of the camera's world transformation.</p>
</li>
<li><p><strong>Look-At Matrix:</strong> This is the most common way to construct a view matrix, using an <code>eye</code> position, a <code>target</code> point, and an <code>up</code> vector to define the camera's orientation.</p>
</li>
<li><p><strong>Orthographic vs. Perspective:</strong> Orthographic projection preserves size and parallel lines, ideal for 2D or technical views. Perspective projection simulates depth by making distant objects appear smaller.</p>
</li>
<li><p><strong>The Perspective Divide:</strong> The "magic" of perspective comes from the GPU automatically dividing the final <code>xyz</code> coordinates by the <code>w</code> coordinate. The projection matrix is engineered to store the vertex's distance in this <code>w</code> component.</p>
</li>
<li><p><strong>Frustum Parameters:</strong> The shape of the camera's view is defined by its <strong>Field of View (FOV)</strong>, <strong>Aspect Ratio</strong>, and the <strong>Near/Far Clipping Planes</strong>.</p>
</li>
<li><p><strong>Depth Buffer Precision:</strong> The distribution of depth buffer accuracy is non-linear. Setting the <code>near</code> plane too close is a common cause of Z-fighting artifacts. Bevy uses <strong>Reverse-Z</strong> mapping by default to improve this distribution.</p>
</li>
<li><p><strong>Bevy Integration:</strong> The safest and most robust way to get camera data into a custom <code>Material</code> is to pass it in via your own uniform, which is updated each frame by a Rust system, avoiding binding conflicts.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>You now have end-to-end control over the vertex transformation pipeline, from a model's local space all the way to the screen's clip space. You understand how to place an object in the world and how to define the camera that views it.</p>
<p>In the next article, we will shift our focus. Instead of just transforming the <code>position</code> of a vertex, we will learn how to read, interpret, and pass along other crucial pieces of data embedded in our meshes - like normals for lighting, UVs for texturing, and vertex colors for unique styling. This will unlock a whole new dimension of visual effects and prepare us to add color and texture to our custom geometry.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/23-working-with-vertex-attributes"><strong><em>2.3 - Working with Vertex Attributes</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<h3 id="heading-core-transformations">Core Transformations</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Matrix</td><td>Input Space</td><td>Output Space</td><td>Primary Role</td><td>Key Insight</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Model</strong></td><td>Local Space</td><td>World Space</td><td>Places and orients the object in the scene.</td><td><code>World = Model * Local</code></td></tr>
<tr>
<td><strong>View</strong></td><td>World Space</td><td>View Space</td><td>Moves the entire world so the camera is at the origin.</td><td><strong>Inverse</strong> of the camera's world transform.</td></tr>
<tr>
<td><strong>Projection</strong></td><td>View Space</td><td>Clip Space</td><td>Flattens the scene, applying perspective or ortho rules.</td><td>Sets the vertex's distance in the <strong>W</strong> component.</td></tr>
<tr>
<td><strong>MVP</strong></td><td>Local Space</td><td>Clip Space</td><td>The combined transformation: <code>Proj * View * Model</code>.</td><td>Final position sent to the GPU.</td></tr>
</tbody>
</table>
</div><h3 id="heading-projection-types">Projection Types</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Type</td><td>Visual Effect</td><td>W Component</td><td>Precision</td><td>Ideal Use Case</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Perspective</strong></td><td>Distant objects appear smaller. Parallel lines converge.</td><td>Calculated as -Z (distance)</td><td>Non-linear, front-loaded (unless Reverse-Z).</td><td>3D games, realistic rendering.</td></tr>
<tr>
<td><strong>Orthographic</strong></td><td>All objects appear same size. Parallel lines remain parallel.</td><td>Fixed at 1.0</td><td>Linear (precision is evenly distributed).</td><td>2D/UI, technical drawings.</td></tr>
</tbody>
</table>
</div>]]></content:encoded></item><item><title><![CDATA[2.1 - Vertex Transformation Deep Dive]]></title><description><![CDATA[What We're Learning
Welcome to Phase 2! In Phase 1, we built our foundation: we mastered WGSL fundamentals, mapped the graphics pipeline, and solidified the essential math concepts that underpin all 3D graphics. We learned the rules of the rendering ...]]></description><link>https://blog.hexbee.net/21-vertex-transformation-deep-dive</link><guid isPermaLink="true">https://blog.hexbee.net/21-vertex-transformation-deep-dive</guid><category><![CDATA[wgsl]]></category><category><![CDATA[webgpu]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Fri, 24 Oct 2025 22:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1762462276439/4dc05f6c-214d-49c3-bf74-8b3a6f0b6f7e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>Welcome to Phase 2! In Phase 1, we built our foundation: we mastered WGSL fundamentals, mapped the graphics pipeline, and solidified the essential math concepts that underpin all 3D graphics. We learned the rules of the rendering pipeline and how to correctly pass data through it.</p>
<p>Now, it's time to start bending those rules. We are ready to take direct control of our geometry by mastering the <strong>vertex shader</strong>.</p>
<p>Think of the vertex shader as your personal <strong>geometry engine</strong>. It's the stage in the pipeline where you, the developer, get to control the shape, position, and orientation of every single vertex in your scene. It's the engine that drives all 3D motion and deformation.</p>
<p>In our previous articles, we learned the theory behind transformations and relied on Bevy's standard functions to apply them. Now, we're going to peel back those layers of abstraction. We will move beyond simply using Bevy's built-in matrices and learn to implement the full transformation pipeline ourselves. This knowledge is the key that unlocks the ability to create powerful, custom vertex effects that bring your scenes to life in unique and dynamic ways.</p>
<p>By the end of this article, you will be able to:</p>
<ul>
<li><p><strong>Manually implement</strong> the complete transformation pipeline from a model's local space all the way to the screen's clip space.</p>
</li>
<li><p><strong>Apply powerful vertex displacement techniques</strong> to create dynamic effects like procedural waves, noise-based deformations, and twists.</p>
</li>
<li><p><strong>Master camera-facing techniques</strong> to create billboards, essential for particles, 2D sprites, and nameplates in a 3D world.</p>
</li>
<li><p><strong>Calculate and transform normal vectors correctly</strong>, ensuring your custom geometry reacts realistically to light.</p>
</li>
<li><p><strong>Employ crucial optimization strategies</strong> to keep your vertex shaders fast and efficient, even when deforming complex meshes.</p>
</li>
<li><p><strong>Compose multiple displacement functions</strong> to build sophisticated, layered visual effects.</p>
</li>
</ul>
<h2 id="heading-the-vertex-shader-your-geometry-engine">The Vertex Shader: Your Geometry Engine</h2>
<p>Think of the vertex shader as your <strong>geometry engine</strong>. It takes the raw, static vertex data from your 3D models and forges it into its final form and position within the 3D world.</p>
<p>At its heart, this engine has one primary, non-negotiable job: to calculate the final position of each vertex in a special coordinate system called <strong>clip space</strong>. This final output, which you'll always see decorated with <code>@builtin(position)</code>, is the <code>vec4&lt;f32&gt;</code> value that the GPU's rasterizer needs to figure out where on your 2D screen that vertex belongs.</p>
<p>Everything else a vertex shader might do - deforming a mesh into a waving flag, calculating normals for beautiful lighting, or passing UV coordinates for texturing - is ultimately in service of this one critical task. Get the clip space position right, and your object appears on screen. Get it wrong, and it vanishes.</p>
<h3 id="heading-the-entry-point-signature">The Entry Point Signature</h3>
<p>Let's re-examine the anatomy of a typical vertex shader entry point. This function is the main gate to our geometry engine; it takes raw mesh data in and pushes the final, transformed data out.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Input: Data for a single vertex, as read from the mesh buffer on the GPU.</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    <span class="hljs-comment">// Other attributes like normals (@location(1)) or UVs (@location(2)) go here.</span>
}

<span class="hljs-comment">// Output: Data for the rasterizer and, optionally, the fragment shader.</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    <span class="hljs-comment">// This is the one REQUIRED output for the rasterizer!</span>
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,

    <span class="hljs-comment">// Other data (e.g., world position, normals, UVs) can be passed "downstream"</span>
    <span class="hljs-comment">// to the fragment shader for coloring and texturing.</span>
    @location(<span class="hljs-number">0</span>) world_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-comment">// --- The core transformation logic happens here ---</span>

    out.clip_position = <span class="hljs-comment">/* the final vec4&lt;f32&gt; result */</span>;
    <span class="hljs-keyword">return</span> out;
}
</code></pre>
<ul>
<li><p>The <code>VertexInput</code> struct defines the "raw materials" for our engine. This data - position, normal, UVs - is read directly from the mesh asset's buffers on the GPU. The <code>@location(N)</code> decorator tells the GPU which buffer to read from for that specific attribute.</p>
</li>
<li><p>The <code>VertexOutput</code> struct is the "finished product." It's a package of data we're sending to the next stages of the pipeline.</p>
<ul>
<li><p>The special <code>@builtin(position)</code> field is for the rasterizer.</p>
</li>
<li><p>Any other fields, marked with <code>@location(N)</code>, are passed downstream to the fragment shader, where they will be interpolated across the surface of the triangle.</p>
</li>
</ul>
</li>
</ul>
<p>Our entire focus in this article is on what happens inside that transformation logic. How do we get from a simple <code>vec3&lt;f32&gt;</code> in a model file to the final <code>vec4&lt;f32&gt;</code> that the hardware needs? The journey begins with a step-by-step tour through the coordinate spaces of the 3D world.</p>
<h2 id="heading-the-transformation-journey-step-by-step">The Transformation Journey, Step-by-Step</h2>
<p>Before we can start creating custom effects, we must fully understand and manually rebuild the standard transformation pipeline. Every vertex in a 3D scene embarks on a journey through multiple coordinate systems, moving from a static model file to a dynamic position on your screen. Our job in the vertex shader is to act as its guide.</p>
<h3 id="heading-the-standard-pipeline">The Standard Pipeline</h3>
<p>This is the path every vertex must travel. Each step is a change in its frame of reference, managed by a specific matrix multiplication.</p>
<pre><code class="lang-plaintext">Local Space (The model's private blueprint coordinates)
    ↓ [Model Matrix]
World Space (The shared scene coordinates where all objects coexist)
    ↓ [View Matrix]
View Space (The world from the camera's unique perspective)
    ↓ [Projection Matrix]
Clip Space (A normalized 3D cube ready for the GPU to process)
    ↓ [Perspective Divide &amp; Viewport Transform - automatic GPU steps]
Screen Space (The final 2D pixel coordinates on your monitor)
</code></pre>
<p>In previous articles, we relied on Bevy's high-level functions to handle this entire journey in a single step. Now, we're going to peel back the layers of abstraction and build this pipeline ourselves.</p>
<h3 id="heading-step-1-from-local-space-to-world-space-the-model-matrix">Step 1: From Local Space to World Space (The Model Matrix)</h3>
<p>Every 3D model begins in its own private universe called <strong>Local Space</strong> (or Model Space). In this space, the model is the center of everything, typically with its pivot point at the origin <code>(0,0,0)</code>. The position attribute we receive in our vertex shader is defined in these local coordinates.</p>
<p>To build a scene, we must place all these individual models into a single, shared universe called <strong>World Space</strong>. This is the common coordinate system where all objects, lights, and the camera coexist. The magic that moves, rotates, and scales a model from its local origin to its final place in the scene is the <strong>Model Matrix</strong>.</p>
<p>As we learned in our math deep dive, this matrix is the result of applying Scale, then Rotation, then Translation:</p>
<p><code>Model Matrix = Translation Matrix * Rotation Matrix * Scale Matrix</code></p>
<p>When you set a <code>Transform</code> on an entity in Bevy, you are defining the components that Bevy's engine will use to construct this exact matrix on the CPU. It does this once per object, per frame, which is far more efficient than us trying to build it for every single vertex. This pre-calculated matrix is then made available to our shader as a uniform.</p>
<h4 id="heading-accessing-bevys-model-matrix-in-wgsl">Accessing Bevy's Model Matrix in WGSL</h4>
<p>By importing <code>bevy_pbr::mesh_functions</code>, we gain access to a helper that retrieves the pre-built model matrix for the object currently being rendered.</p>
<pre><code class="lang-rust">#import bevy_pbr::mesh_functions

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(
    <span class="hljs-comment">// We need the instance_index to get the correct model matrix for this object.</span>
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    <span class="hljs-comment">// ... other inputs</span>
) -&gt; ... {
    <span class="hljs-comment">// Fetch the 4x4 model matrix for this specific mesh instance.</span>
    <span class="hljs-keyword">let</span> model_matrix = mesh_functions::get_world_from_local(instance_index);

    <span class="hljs-comment">// ... now we can use this matrix to transform our vertex position ...</span>
}
</code></pre>
<p>Now we have the two key ingredients: the vertex position in local space (<code>in.position</code>) and the <code>model_matrix</code>. To perform the transformation, we multiply them:</p>
<ol>
<li><p><strong>Promote to</strong> <code>vec4</code>: A <code>mat4x4</code> matrix multiplication requires a four-component vector. We convert our <code>vec3</code> position to a <code>vec4</code>, setting the <code>w</code> component to <code>1.0</code>. This <code>1.0</code> is crucial; it acts as the "on-switch" for translation, signifying that this is a <strong>point in space</strong> that should be moved.</p>
</li>
<li><p><strong>Multiply</strong>: We apply the model matrix. Remember, in WGSL (and most graphics math), the order is always <code>matrix * vector</code>.</p>
</li>
</ol>
<pre><code class="lang-rust"><span class="hljs-comment">// Inside the vertex function:</span>

<span class="hljs-comment">// 1. Fetch the model matrix.</span>
<span class="hljs-keyword">let</span> model_matrix = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);

<span class="hljs-comment">// 2. Promote local position to vec4 with w=1.0.</span>
<span class="hljs-keyword">let</span> local_position_vec4 = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">1.0</span>);

<span class="hljs-comment">// 3. Transform from local space to world space.</span>
<span class="hljs-keyword">let</span> world_position = model_matrix * local_position_vec4;
</code></pre>
<p>That's it! The <code>world_position</code> variable now holds the vertex's precise coordinates within the global scene.</p>
<h3 id="heading-step-2-amp-3-from-world-space-to-clip-space-the-view-and-projection-matrices">Step 2 &amp; 3: From World Space to Clip Space (The View and Projection Matrices)</h3>
<p>With our vertex now in world space, our next goal is to get it into the final <strong>Clip Space</strong> required by the rasterizer. This involves two more transformations:</p>
<ol>
<li><p>The <strong>View Matrix</strong> reorients the entire world so that it is relative to the camera's position and orientation. It's like moving the whole world so the camera is at <code>(0,0,0)</code> looking down a specific axis.</p>
</li>
<li><p>The <strong>Projection Matrix</strong> simulates the camera's lens (e.g., perspective or orthographic). It squashes the 3D scene into a normalized cube (typically from -1 to +1 on all axes) and, for perspective cameras, makes distant objects appear smaller.</p>
</li>
</ol>
<p>For now, we will continue to use a single Bevy PBR helper function that conveniently combines both the View and Projection transformations for us. We will deconstruct this function in the next article, which is dedicated to camera and projection mathematics.</p>
<pre><code class="lang-rust">#import bevy_pbr::view_transformations::position_world_to_clip

<span class="hljs-comment">// ... inside the vertex shader, after calculating world_position ...</span>

<span class="hljs-comment">// This single function handles both the View and Projection matrix multiplications.</span>
<span class="hljs-comment">// It takes a vec3 because it operates on the position part of the vector.</span>
out.clip_position = position_world_to_clip(world_position.xyz);
</code></pre>
<h3 id="heading-common-vertex-transformation-mistakes">Common Vertex Transformation Mistakes</h3>
<p>This process is straightforward, but a few common mistakes can trip you up.</p>
<h4 id="heading-mistake-1-multiplying-a-mat4x4-by-a-vec3">Mistake 1: Multiplying a <code>mat4x4</code> by a <code>vec3</code></h4>
<p>This is the most frequent error and will cause your shader to fail compilation. The dimensions don't match.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ✗ WRONG</span>
<span class="hljs-comment">// Error: cannot multiply mat4x4&lt;f32&gt; by vec3&lt;f32&gt;</span>
<span class="hljs-keyword">let</span> world_pos = model_matrix * <span class="hljs-keyword">in</span>.position;

<span class="hljs-comment">// ✓ CORRECT</span>
<span class="hljs-keyword">let</span> world_pos = model_matrix * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">1.0</span>);
</code></pre>
<p><strong>Solution</strong>: The <code>mat4x4</code> is specifically designed to handle 3D translation using a mathematical tool called homogeneous coordinates. For this to work, it <strong>requires</strong> a four-component vector as input. That fourth component, <code>w</code>, is what allows a single matrix multiplication to handle rotation, scale, <em>and</em> translation simultaneously.</p>
<h4 id="heading-mistake-2-transforming-a-direction-vector-as-a-position">Mistake 2: Transforming a Direction Vector as a Position</h4>
<p>What if you're transforming something that isn't a point in space, like a surface normal or a tangent? These are <strong>directions</strong>, and they should not be affected by translation. If you move an object, its surface normals should rotate with it, but they shouldn't shift their origin. To achieve this, you must set the <code>w</code> component of the vector to <code>0.0</code>. This acts as an "off-switch" for translation.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Transforming a normal vector (a direction)</span>
<span class="hljs-keyword">let</span> local_normal = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.normal, <span class="hljs-number">0.0</span>); <span class="hljs-comment">// w = 0.0 for directions!</span>
<span class="hljs-keyword">let</span> world_normal = model_matrix * local_normal;
</code></pre>
<p><strong>Solution</strong>: Before transforming any <code>vec3</code>, ask yourself: "<strong>What does this vector represent?</strong>"</p>
<ul>
<li><p>If it's a <strong>position</strong> (a vertex's location), use <code>w = 1.0</code> to ensure translation is correctly applied.</p>
</li>
<li><p>If it's a <strong>direction</strong> (a surface normal, a tangent, a light vector), use <code>w = 0.0</code> to ensure only rotation and scale are applied, ignoring translation.</p>
</li>
</ul>
<p>This mental check is crucial for achieving correct lighting and other advanced effects.</p>
<blockquote>
<p>Note: As we learned in <a target="_blank" href="https://hexbee.hashnode.dev/18-essential-shader-math-concepts">article 1.8</a>, this is still not the fully correct way to transform normals if non-uniform scaling is involved. For that, we need the inverse transpose of the model matrix. But the <code>w=0.0</code> principle is the essential first step.</p>
</blockquote>
<h4 id="heading-mistake-3-incorrect-multiplication-order">Mistake 3: Incorrect Multiplication Order</h4>
<p>Matrix multiplication is not commutative. <code>A * B</code> is not the same as <code>B * A</code>. The transformation matrix always comes first.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ✗ WRONG (and won't compile in WGSL, but is a common logic error in math)</span>
<span class="hljs-keyword">let</span> world_pos = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">1.0</span>) * model_matrix;

<span class="hljs-comment">// ✓ CORRECT</span>
<span class="hljs-keyword">let</span> world_pos = model_matrix * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">1.0</span>);
</code></pre>
<p><strong>Solution</strong>: The standard order is <code>matrix * vector</code>. Stick to it.</p>
<h2 id="heading-taking-control-our-first-custom-displacement">Taking Control: Our First Custom Displacement</h2>
<p>We have now successfully rebuilt Bevy's standard transformation pipeline. We take a local position, apply the model matrix to get to world space, and then use a helper function to get to the final clip space.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// The standard pipeline we just built:</span>
<span class="hljs-keyword">let</span> model_matrix = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
<span class="hljs-keyword">let</span> world_position = model_matrix * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">1.0</span>);
out.clip_position = position_world_to_clip(world_position.xyz);
</code></pre>
<p>This is powerful, but so far, we've only recreated what Bevy already does for us. The real magic begins when we <strong>intervene</strong> in this process. <strong>Vertex displacement</strong> is the technique of modifying a vertex's position after reading it from the mesh but before the final transformation. This allows us to create dynamic and custom geometric effects directly on the GPU.</p>
<p>The ideal moment to apply a custom displacement is in <strong>local space</strong>. Why? Because any effect applied in local space becomes an intrinsic part of the model. When the model rotates, the effect rotates with it. When it scales, the effect scales too. If we were to apply a wave effect in world space, the waves would remain stationary while the object moved through them, which is a completely different (and usually undesirable) result.</p>
<p>Let's create our first, simple effect: making an object gently bob up and down. We can achieve this with a single line of code by hijacking the pipeline, using a sine wave driven by time.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Assume we have a material uniform providing the elapsed time.</span>
@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: MyMaterial; <span class="hljs-comment">// This struct contains a 'time: f32' field.</span>

<span class="hljs-comment">// --- Inside the vertex shader ---</span>

<span class="hljs-comment">// 1. Start with a mutable copy of the original local position.</span>
var local_position = <span class="hljs-keyword">in</span>.position;

<span class="hljs-comment">// 2. THE DISPLACEMENT: Modify the Y-coordinate.</span>
<span class="hljs-comment">//    sin() naturally oscillates between -1.0 and 1.0. We scale it down</span>
<span class="hljs-comment">//    to create a gentle up-and-down motion.</span>
<span class="hljs-keyword">let</span> bob_amount = sin(material.time * <span class="hljs-number">3.0</span>) * <span class="hljs-number">0.2</span>;
local_position.y += bob_amount;

<span class="hljs-comment">// 3. Continue with the standard pipeline, but use our MODIFIED position.</span>
<span class="hljs-keyword">let</span> model_matrix = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
<span class="hljs-keyword">let</span> world_position = model_matrix * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(local_position, <span class="hljs-number">1.0</span>);
out.clip_position = position_world_to_clip(world_position.xyz);
</code></pre>
<p>By inserting that one line, we've fundamentally changed the behavior of our shader. We are no longer just relaying data; we are <strong>generating motion</strong>. This is the core principle behind every advanced vertex effect you will ever create. The position that comes from the mesh asset is not the final word - it's just a starting point for our imagination.</p>
<p>Now that we understand this fundamental concept, let's build a library of more sophisticated displacement techniques.</p>
<h2 id="heading-vertex-displacement-a-library-of-effects">Vertex Displacement: A Library of Effects</h2>
<p>Vertex displacement is the foundation of countless visual effects, from rippling water to procedural terrain. The key is to displace vertices in a way that looks natural or achieves a specific artistic goal. Most techniques involve moving a vertex along its <strong>normal vector</strong> - the vector that points directly "out" from the surface at that vertex's location. This creates the effect of inflating, deflating, or waving from the surface.</p>
<p>Let's explore some common and powerful displacement patterns.</p>
<h3 id="heading-wave-displacement">Wave Displacement</h3>
<p>This is the classic technique for creating flowing, organic motion like water surfaces or waving flags. We use sine and cosine functions, which naturally produce smooth, oscillating values that repeat over space and time.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_wave_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Create waves based on the vertex's X and Z position and the current time.</span>
    <span class="hljs-comment">// Using different frequencies and directions for each wave adds complexity.</span>
    <span class="hljs-keyword">let</span> wave_x = sin(position.x * <span class="hljs-number">4.0</span> + time * <span class="hljs-number">2.0</span>);
    <span class="hljs-keyword">let</span> wave_z = cos(position.z * <span class="hljs-number">4.0</span> + time * <span class="hljs-number">1.5</span>);

    <span class="hljs-comment">// Combine the waves. We multiply by 0.5 to keep the final result</span>
    <span class="hljs-comment">// in a predictable -1.0 to 1.0 range.</span>
    <span class="hljs-keyword">let</span> combined_waves = (wave_x + wave_z) * <span class="hljs-number">0.5</span>;

    <span class="hljs-comment">// Define how much the wave should push the vertex out.</span>
    <span class="hljs-keyword">let</span> wave_amplitude = <span class="hljs-number">0.1</span>;

    <span class="hljs-comment">// Displace the position along its normal by the final wave amount.</span>
    <span class="hljs-keyword">return</span> position + normal * combined_waves * wave_amplitude;
}
</code></pre>
<h3 id="heading-noise-based-displacement">Noise-Based Displacement</h3>
<p>While waves are periodic and regular, noise functions create more complex, natural-looking deformations. This is perfect for effects like bubbling lava, flickering energy shields, or randomized terrain. A good noise function is an essential tool in any graphics programmer's toolkit.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A simple hash function to generate pseudo-random numbers.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash</span></span>(n: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">return</span> fract(sin(n) * <span class="hljs-number">43758.5453123</span>);
}

<span class="hljs-comment">// A simple 3D value noise function. It's not as high-quality as Perlin or </span>
<span class="hljs-comment">// Simplex noise, but it's very fast and effective for many real-time effects.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">noise3d</span></span>(p: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> ip = floor(p);
    var fp = fract(p);
    <span class="hljs-comment">// Use smoothstep for smoother interpolation between grid points.</span>
    fp = fp * fp * (<span class="hljs-number">3.0</span> - <span class="hljs-number">2.0</span> * fp);

    <span class="hljs-keyword">let</span> n = ip.x + ip.y * <span class="hljs-number">57.0</span> + ip.z * <span class="hljs-number">113.0</span>;
    <span class="hljs-keyword">let</span> res = mix(
        mix(
            mix(hash(n + <span class="hljs-number">0.0</span>), hash(n + <span class="hljs-number">1.0</span>), fp.x),
            mix(hash(n + <span class="hljs-number">57.0</span>), hash(n + <span class="hljs-number">58.0</span>), fp.x),
            fp.y
        ),
        mix(
            mix(hash(n + <span class="hljs-number">113.0</span>), hash(n + <span class="hljs-number">114.0</span>), fp.x),
            mix(hash(n + <span class="hljs-number">170.0</span>), hash(n + <span class="hljs-number">171.0</span>), fp.x),
            fp.y
        ),
        fp.z
    );
    <span class="hljs-comment">// Remap the result from a 0.0 to 1.0 range to a -1.0 to 1.0 range.</span>
    <span class="hljs-keyword">return</span> res * <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>;
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_noise_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
    strength: <span class="hljs-built_in">f32</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Sample the noise function. Scaling the position makes the noise pattern</span>
    <span class="hljs-comment">// larger or smaller, and adding time makes it evolve.</span>
    <span class="hljs-keyword">let</span> noise_val = noise3d(position * <span class="hljs-number">2.0</span> + time);

    <span class="hljs-comment">// Displace the position along the normal by the noise value.</span>
    <span class="hljs-keyword">return</span> position + normal * noise_val * strength;
}
</code></pre>
<h3 id="heading-twist-displacement">Twist Displacement</h3>
<p>Twisting is a great example of a deformation that isn't based on normals. Instead, it rotates vertices around an axis, with the amount of rotation increasing along that axis. This is perfect for effects like a tornado, a drill, or wringing out a wet cloth.</p>
<p>This function is more complex than the others because it must be robust against a common mathematical edge case.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_twist</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    twist_amount: <span class="hljs-built_in">f32</span>,
    axis: vec3&lt;<span class="hljs-built_in">f32</span>&gt;
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Find the vertex's height along the twist axis.</span>
    <span class="hljs-keyword">let</span> height = dot(position, axis);

    <span class="hljs-comment">// 2. Calculate the rotation angle. It increases with height.</span>
    <span class="hljs-keyword">let</span> angle = height * twist_amount;
    <span class="hljs-keyword">let</span> c = cos(angle);
    <span class="hljs-keyword">let</span> s = sin(angle);

    <span class="hljs-comment">// 3. Separate the position into a part parallel to the axis</span>
    <span class="hljs-comment">//    and a part perpendicular to it. We only want to rotate the perpendicular part.</span>
    <span class="hljs-keyword">let</span> parallel = axis * height;
    <span class="hljs-keyword">let</span> perpendicular = position - parallel;

    <span class="hljs-comment">// 4. Create a robust, temporary 2D coordinate system on the perpendicular plane.</span>
    <span class="hljs-comment">//    This is the key to avoiding a singularity.</span>
    <span class="hljs-keyword">let</span> arbitrary_vec = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
    <span class="hljs-comment">//    If our axis is too close to arbitrary_vec, use a different one!</span>
    <span class="hljs-keyword">let</span> safe_arbitrary = mix(
        arbitrary_vec, 
        vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>), 
        step(<span class="hljs-number">0.999</span>, abs(dot(axis, arbitrary_vec)))
    );
    <span class="hljs-keyword">let</span> right = normalize(cross(axis, safe_arbitrary));
    <span class="hljs-keyword">let</span> forward = cross(axis, right);

    <span class="hljs-comment">// 5. Project the perpendicular part onto our new 2D basis.</span>
    <span class="hljs-keyword">let</span> x = dot(perpendicular, right);
    <span class="hljs-keyword">let</span> y = dot(perpendicular, forward);

    <span class="hljs-comment">// 6. Perform a standard 2D rotation on the projected coordinates.</span>
    <span class="hljs-keyword">let</span> x_rotated = x * c - y * s;
    <span class="hljs-keyword">let</span> y_rotated = x * s + y * c;

    <span class="hljs-comment">// 7. Reconstruct the full 3D position from the parallel and rotated perpendicular parts.</span>
    <span class="hljs-keyword">return</span> parallel + right * x_rotated + forward * y_rotated;
}
</code></pre>
<h4 id="heading-a-note-on-robustness-avoiding-the-singularity">A Note on Robustness: Avoiding the "Singularity"</h4>
<p>The code in Step 4 looks more complex than you might expect. This complexity is crucial for making our function <strong>robust</strong> by avoiding a mathematical problem known as a <strong>singularity</strong>.</p>
<ul>
<li><p><strong>The Problem:</strong> To rotate the <code>perpendicular</code> component, we need to define a 2D coordinate system on the plane of rotation. We do this by taking the <code>cross</code> product between our <code>axis</code> and some other arbitrary vector. In our code, we initially chose the world Z-axis <code>(0,0,1)</code>. But what happens if the user wants to twist the mesh around the Z-axis itself? The cross product of two parallel vectors is the zero vector (<code>vec3(0.0, 0.0, 0.0)</code>). When we then try to <code>normalize()</code> this zero vector, we are performing a division by zero, which results in <code>NaN</code> (Not a Number) values. These <code>NaNs</code> propagate through the rest of the calculation, corrupting the mesh and likely causing it to disappear. This specific edge case is a singularity.</p>
</li>
<li><p><strong>The Solution:</strong> Our safeguard in Step 4 prevents this. It uses the <code>dot</code> product to check if our <code>axis</code> is almost parallel to our chosen <code>arbitrary_vec</code>.</p>
<ul>
<li><p>If they are not parallel, <code>abs(dot(...))</code> is less than <code>0.999</code>, so step returns <code>0.0</code>. The <code>mix</code> function then selects our default <code>arbitrary_vec</code>.</p>
</li>
<li><p>If they are parallel, <code>step</code> returns <code>1.0</code>, and mix intelligently switches to a different arbitrary vector (the world X-axis, <code>1,0,0</code>), which is guaranteed not to be parallel.</p>
</li>
</ul>
</li>
</ul>
<p>This ensures we never try to take the cross product of two parallel vectors, making our function safe to use with any axis. This is a common and important practice in graphics programming: always think about the edge cases!</p>
<h3 id="heading-inflation-or-breathing">Inflation (or "Breathing")</h3>
<p>Sometimes, the simplest effects are the most useful. Inflation moves every vertex outward along its normal by a uniform amount. By animating this amount over time, you can create a "breathing" or pulsing effect, useful for highlighting objects or indicating damage.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_inflation</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    amount: <span class="hljs-built_in">f32</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Simply push the vertex along its normal vector.</span>
    <span class="hljs-keyword">return</span> position + normal * amount;
}
</code></pre>
<h3 id="heading-combining-multiple-displacements">Combining Multiple Displacements</h3>
<p>The true power of this approach comes from composition. You can layer multiple displacement effects to create much more sophisticated and detailed results.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_combined_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    var displaced_pos = position;

    <span class="hljs-comment">// First, apply a twist to the base geometry.</span>
    <span class="hljs-keyword">let</span> twist_axis = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>);
    displaced_pos = apply_twist(displaced_pos, sin(time) * <span class="hljs-number">1.0</span>, twist_axis);

    <span class="hljs-comment">// Next, layer some wave motion on top of the twisted shape.</span>
    <span class="hljs-comment">// <span class="hljs-doctag">NOTE:</span> We pass the *original normal* here. Calculating a new normal</span>
    <span class="hljs-comment">// after the twist is complex and often not necessary for visual effects.</span>
    displaced_pos = apply_wave_displacement(displaced_pos, normal, time * <span class="hljs-number">0.5</span>);

    <span class="hljs-comment">// Finally, add a subtle "breathing" pulse to the whole thing.</span>
    <span class="hljs-keyword">let</span> pulse_amount = (sin(time * <span class="hljs-number">2.0</span>) + <span class="hljs-number">1.0</span>) * <span class="hljs-number">0.05</span>; <span class="hljs-comment">// from 0.0 to 0.1</span>
    displaced_pos = apply_inflation(displaced_pos, normal, pulse_amount);

    <span class="hljs-keyword">return</span> displaced_pos;
}
</code></pre>
<h2 id="heading-camera-facing-techniques-billboards">Camera-Facing Techniques: Billboards</h2>
<p>Sometimes you don't want an object to behave like a normal 3D model. Instead, you want a 2D image or quad to always face the camera, no matter how the camera moves. This technique is called <strong>billboarding</strong>, and it's essential for effects like particles, nameplates, lens flares, and 2D sprites in a 3D world.</p>
<p>The core idea is to <strong>completely override</strong> the model's local rotation. Instead of transforming the vertex by a model matrix, we manually construct its world position using the camera's orientation vectors as our guide.</p>
<h3 id="heading-full-billboarding-always-face-the-camera">Full Billboarding: Always Face the Camera</h3>
<p>A full billboard is a flat quad that perfectly mimics the camera's orientation. As the camera rotates, the quad rotates with it, always presenting its flat face to the viewer.</p>
<p>To build this, we need three pieces of information from the CPU, passed in as uniforms:</p>
<ol>
<li><p>The desired center of our billboard in world space (<code>world_center</code>).</p>
</li>
<li><p>The camera's "right" vector (<code>camera_right</code>).</p>
</li>
<li><p>The camera's "up" vector (<code>camera_up</code>).</p>
</li>
</ol>
<p>The vertex shader then uses the mesh's local <code>XY</code> coordinates not as positions, but as 2D offsets from the center along the camera's axes.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">billboard_transform</span></span>(
    local_position_xy: vec2&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// The 2D position on the quad's surface.</span>
    world_center: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,      <span class="hljs-comment">// The 3D point where the billboard is anchored.</span>
    camera_right: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,      <span class="hljs-comment">// The camera's right-pointing vector.</span>
    camera_up: vec3&lt;<span class="hljs-built_in">f32</span>&gt;         <span class="hljs-comment">// The camera's up-pointing vector.</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Calculate the offset along the camera's right axis.</span>
    <span class="hljs-keyword">let</span> right_offset = camera_right * local_position_xy.x;

    <span class="hljs-comment">// 2. Calculate the offset along the camera's up axis.</span>
    <span class="hljs-keyword">let</span> up_offset = camera_up * local_position_xy.y;

    <span class="hljs-comment">// 3. Add these offsets to the world center to get the final position.</span>
    <span class="hljs-keyword">return</span> world_center + right_offset + up_offset;
}
</code></pre>
<h4 id="heading-crucial-implementation-details">Crucial Implementation Details</h4>
<ol>
<li><p><strong>Use the Right Mesh:</strong> For this technique to work intuitively, you must use a mesh that lies flat on the <strong>XY plane</strong> in local space, like Bevy's Rectangle primitive. This ensures that the mesh's local x coordinate correctly maps to the camera's right direction, and its y maps to up. If you were to use a Plane3d mesh, which lies on the XZ plane, its z coordinate would map to the camera's up direction, resulting in a sideways orientation.</p>
</li>
<li><p><strong>Passing Camera Data:</strong> You might be tempted to access Bevy's global View uniform to get camera data. <strong>Do not do this in a custom Material.</strong> Bevy's material system manages its own bindings, and trying to add another global View binding will lead to conflicts and unpredictable behavior. The correct and robust approach is to add the camera's position, right, and up vectors directly to your material's uniform struct. Then, you can use a Rust system to update these values on your material every frame.</p>
</li>
</ol>
<h3 id="heading-cylindrical-billboarding">Cylindrical Billboarding</h3>
<p>Full billboarding is perfect for things that have no connection to the ground, like particles or text floating in space. But what happens when you use it for an object that should feel grounded, like a 2D tree sprite in a 3D world?</p>
<p>If you use a full billboard for a tree, it will always face the camera perfectly. This sounds good, but watch what happens when your camera flies up high and looks down: the tree sprite will tilt backwards, pointing up at the camera. It will no longer look like it's growing out of the ground. This breaks the illusion.</p>
<p>The solution is <strong>cylindrical billboarding</strong>.</p>
<h4 id="heading-the-core-concept-turn-dont-tilt">The Core Concept: Turn, Don't Tilt</h4>
<p>Imagine a person standing still. As you walk around them, they can turn their body to keep facing you. This is rotation around a vertical axis (their spine). However, if you climb a ladder, they don't lean their whole body backwards to look up at you; they just tilt their head.</p>
<p>Cylindrical billboarding is like that person's body. It allows an object to rotate around a fixed axis (usually the world's "up" vector) to face the camera's horizontal position, but it forbids the object from tilting along any other axis.</p>
<h4 id="heading-how-it-works-a-step-by-step-breakdown">How It Works: A Step-by-Step Breakdown</h4>
<p>To achieve this, we need to effectively ignore the camera's vertical position and only consider its position on a flat, horizontal plane.</p>
<pre><code class="lang-rust"><span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">cylindrical_billboard</span></span>(
    local_position_xy: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    world_center: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    camera_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    up_axis: vec3&lt;<span class="hljs-built_in">f32</span>&gt; <span class="hljs-comment">// The fixed axis of rotation, e.g., (0, 1, 0).</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// 1. Get the true direction from the object's center to the camera.</span>
    <span class="hljs-keyword">let</span> to_camera = camera_position - world_center;

    <span class="hljs-comment">// 2. Project this vector onto the horizontal plane by removing its vertical component.</span>
    <span class="hljs-comment">//    Think of this as finding the "shadow" of the to_camera vector on the ground.</span>
    <span class="hljs-keyword">let</span> vertical_part = dot(to_camera, up_axis) * up_axis;
    <span class="hljs-keyword">let</span> horizontal_dir = to_camera - vertical_part;

    <span class="hljs-comment">// 3. From this purely horizontal direction, create a new forward-facing vector.</span>
    <span class="hljs-comment">//    This is the direction the billboard should face.</span>
    <span class="hljs-keyword">let</span> forward = normalize(horizontal_dir);

    <span class="hljs-comment">// 4. Create a right-facing vector perpendicular to both our new forward and the fixed up axis.</span>
    <span class="hljs-keyword">let</span> right = normalize(cross(forward, up_axis));

    <span class="hljs-comment">// 5. Build the final position.</span>
    <span class="hljs-comment">//    Notice the key difference from full billboarding: we use our new `right`</span>
    <span class="hljs-comment">//    vector, but the original, fixed `up_axis` instead of the camera's up vector.</span>
    <span class="hljs-keyword">return</span> world_center + right * local_position_xy.x + up_axis * local_position_xy.y;
}
</code></pre>
<p>Let's focus on <strong>Step 2</strong>, which is the clever part:</p>
<ul>
<li><p><code>dot(to_camera, up_axis)</code> calculates how much of the <code>to_camera</code> vector points in the <code>up</code> direction (its vertical magnitude).</p>
</li>
<li><p>Multiplying this scalar value by the <code>up_axis</code> vector reconstructs that vertical component as a vector (e.g., <code>vec3(0.0, 5.2, 0.0)</code>).</p>
</li>
<li><p><code>to_camera - vertical_part</code> subtracts only the vertical part of the direction, leaving a vector that is perfectly flat on the horizontal plane.</p>
</li>
</ul>
<p>The rest of the function is similar to the full billboard, but with one critical difference in <strong>Step 5</strong>: we construct the final position using our calculated right vector but the original <code>up_axis</code>. This is what forces the billboard to remain upright, achieving the "turn, don't tilt" behavior we wanted.</p>
<h2 id="heading-keeping-the-lights-on-transforming-normals-correctly">Keeping the Lights On: Transforming Normals Correctly</h2>
<p>A <strong>normal vector</strong> is a unit vector that points directly "out" from a mesh's surface at a vertex's location. The lighting engine uses this vector to determine how much light from various sources should reflect off that point.</p>
<ul>
<li><p>If the normal points towards a light, the surface is bright.</p>
</li>
<li><p>If the normal points away, the surface is dark.</p>
</li>
</ul>
<p><strong>The Problem:</strong> When we displace a vertex with a wave or noise function, we are changing the slope of the surface. However, the original normal vector stored in the mesh data still points in the old direction. If we use this outdated normal, our wavy surface will be lit as if it were still perfectly flat, completely destroying the illusion of depth.</p>
<p>So, how do we update the normals? The correct method depends on what kind of transformation we're performing.</p>
<h3 id="heading-case-1-standard-transformations-no-custom-displacement">Case 1: Standard Transformations (No Custom Displacement)</h3>
<p>If you are only using the standard model_matrix to scale, rotate, and translate your mesh, there is a mathematically pure solution. Due to the complexities of non-uniform scaling (e.g., <code>scale = vec3(2.0, 1.0, 1.0)</code>), you cannot simply multiply the normal by the model matrix.</p>
<p>The correct tool is the <strong>Normal Matrix</strong>, which is the <strong>inverse transpose</strong> of the upper-left 3x3 portion of the model matrix. While the math is fascinating, you don't need to implement it yourself. Bevy provides a function that handles this perfectly.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Bevy handles the Normal Matrix calculation internally on the CPU.</span>
<span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(
    <span class="hljs-keyword">in</span>.normal, <span class="hljs-comment">// The original vec3&lt;f32&gt; normal</span>
    <span class="hljs-keyword">in</span>.instance_index
);
</code></pre>
<p><strong>Rule:</strong> If you are not applying custom displacement, always use <code>mesh_normal_local_to_world</code> to get the correct world-space normal.</p>
<h3 id="heading-case-2-custom-displacement">Case 2: Custom Displacement</h3>
<p>This is our situation. When we apply a wave, twist, or noise effect, <code>mesh_normal_local_to_world</code> is no longer enough. It can correctly transform <em>the original normal</em>, but it has no knowledge of how our custom displacement function has altered the surface's slope.</p>
<p>We must calculate a new normal. There are two main approaches.</p>
<h4 id="heading-the-practical-approach-normal-perturbation">The Practical Approach: Normal Perturbation</h4>
<p>For most real-time effects, the most efficient solution is to <strong>perturb</strong> (or "nudge") the original normal based on the logic of our displacement function. We don't need a perfectly recalculated normal; we just need one that's a good enough approximation to look correct.</p>
<p>For a wave, we can use the mathematical derivative of our wave function to find its gradient (the direction of steepest ascent). This gradient gives us a vector that lies on the surface's new slope, which we can use to adjust the original normal.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Perturb a normal to account for our specific wave displacement function.</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">perturb_normal_for_waves</span></span>(
    original_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;, <span class="hljs-comment">// The original, pre-displacement position</span>
    time: <span class="hljs-built_in">f32</span>,
    frequency: <span class="hljs-built_in">f32</span>,
    amplitude: <span class="hljs-built_in">f32</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Our wave is roughly: wave_amplitude * sin(position.x * frequency + time)</span>
    <span class="hljs-comment">// The derivative of sin(x) with respect to x is cos(x).</span>
    <span class="hljs-comment">// By the chain rule, the derivative of sin(ax) is a*cos(ax).</span>

    <span class="hljs-comment">// Calculate the gradient (slope) of the wave in the X and Z directions.</span>
    <span class="hljs-keyword">let</span> dx = amplitude * frequency * cos(position.x * frequency + time * <span class="hljs-number">2.0</span>);
    <span class="hljs-keyword">let</span> dz = -amplitude * frequency * sin(position.z * frequency + time * <span class="hljs-number">1.5</span>);

    <span class="hljs-comment">// This gives us a vector tangent to the new surface slope.</span>
    <span class="hljs-comment">// We construct a new surface normal from the partial derivatives.</span>
    <span class="hljs-keyword">let</span> new_normal = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(-dx, <span class="hljs-number">1.0</span>, -dz);

    <span class="hljs-comment">// Combine with the original and re-normalize to get the new direction.</span>
    <span class="hljs-comment">// This is a simplified approach but effective for many cases.</span>
    <span class="hljs-keyword">return</span> normalize(original_normal + new_normal);
}
</code></pre>
<p>This method is fast and produces visually convincing results, making it the preferred choice for performance-critical applications.</p>
<h4 id="heading-the-brute-force-approach-numerical-derivatives">The Brute-Force Approach: Numerical Derivatives</h4>
<p>What if your displacement function is too complex to calculate the derivative analytically? You can fall back on a brute-force method that approximates the normal by sampling the displacement function at nearby points.</p>
<ol>
<li><p>Calculate the final displaced position for the current vertex (<code>P</code>).</p>
</li>
<li><p>Pick a tiny distance (<code>epsilon</code>).</p>
</li>
<li><p>Calculate the displaced position for a virtual vertex slightly offset on the X-axis (<code>Px</code>).</p>
</li>
<li><p>Calculate the displaced position for a virtual vertex slightly offset on the Z-axis (<code>Pz</code>).</p>
</li>
<li><p>Create two vectors: one from <code>P</code> to <code>Px</code> and one from <code>P</code> to <code>Pz</code>. These two vectors lie on the new surface.</p>
</li>
<li><p>The <code>cross</code> product of these two vectors gives us a vector perpendicular to the new surface - our new normal!</p>
</li>
</ol>
<p>While this works for any displacement function, it has a significant performance cost: you must run your entire displacement logic <strong>three times for every single vertex</strong>. For this reason, it's generally avoided in real-time shaders unless absolutely necessary. Perturbing the normal is almost always the better option.</p>
<h4 id="heading-a-quick-clarification-geometric-vs-shading-normals">A Quick Clarification: Geometric vs. Shading Normals</h4>
<p>The techniques we are discussing here are for updating the <strong>geometric normal</strong> - the true orientation of the mesh after we've deformed it in the vertex shader.</p>
<p>This is distinct from a popular technique called <strong>normal mapping</strong> (or bump mapping), which happens in the <strong>fragment shader</strong>. Normal mapping uses a texture to fake fine-grained surface detail on a low-poly mesh, creating the illusion of bumps and dents without actually moving any vertices.</p>
<p>We will cover normal mapping in depth when we move on to Phase 4, which is focused on advanced texturing techniques. For now, know that we are dealing with the actual, physical slope of our geometry.</p>
<h2 id="heading-performance-and-optimization">Performance and Optimization</h2>
<p>Vertex shaders are a performance-critical part of the rendering pipeline. Your shader code will be executed for <strong>every single vertex</strong> of a mesh, potentially millions of times per frame. A small inefficiency in your code can quickly multiply into a major performance bottleneck.</p>
<p>Here are the most important strategies for writing fast, optimized vertex shaders.</p>
<h3 id="heading-1-do-the-work-once">1. Do the Work Once</h3>
<p>This is the golden rule of optimization. If you need the result of a calculation in more than one place, store it in a local variable and reuse it. Avoid calling the same function or performing the same complex math multiple times.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ✗ Inefficient: The same expensive noise function is called twice.</span>
@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    <span class="hljs-keyword">let</span> displacement = some_expensive_noise_calculation(<span class="hljs-keyword">in</span>.position, material.time);
    out.position = <span class="hljs-keyword">in</span>.position + <span class="hljs-keyword">in</span>.normal * displacement;

    <span class="hljs-comment">// The same value is wastefully re-calculated for coloring.</span>
    <span class="hljs-keyword">let</span> color_intensity = some_expensive_noise_calculation(<span class="hljs-keyword">in</span>.position, material.time);
    out.color = vec3(color_intensity);
    <span class="hljs-comment">// ...</span>
}

<span class="hljs-comment">// ✓ Efficient: Calculate once, store the result, and reuse it.</span>
@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    <span class="hljs-keyword">let</span> noise_result = some_expensive_noise_calculation(<span class="hljs-keyword">in</span>.position, material.time);
    out.position = <span class="hljs-keyword">in</span>.position + <span class="hljs-keyword">in</span>.normal * noise_result;
    out.color = vec3(noise_result);
    <span class="hljs-comment">// ...</span>
}
</code></pre>
<h3 id="heading-2-use-built-in-functions">2. Use Built-in Functions</h3>
<p>The built-in WGSL functions (<code>normalize</code>, <code>length</code>, <code>dot</code>, <code>sin</code>, <code>mix</code>, <code>smoothstep</code>, etc.) are your best friends. They are implemented at a low level, often directly in the GPU hardware, and are guaranteed to be significantly faster than any manual implementation you could write yourself.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ✓ Good: Uses the highly optimized, built-in normalize() function.</span>
<span class="hljs-keyword">let</span> normalized_vec = normalize(my_vector);

<span class="hljs-comment">// ✗ Bad: Manually implementing the function is much slower and less precise.</span>
<span class="hljs-keyword">let</span> len = sqrt(dot(my_vector, my_vector));
<span class="hljs-keyword">let</span> normalized_vec = my_vector / len; <span class="hljs-comment">// Also risks division by zero!</span>
</code></pre>
<h3 id="heading-3-avoid-divergent-branching">3. Avoid Divergent Branching</h3>
<p>GPUs achieve their incredible speed by executing the same instruction on large groups of threads (vertices) in parallel. This is called SIMD (Single Instruction, Multiple Data). A conditional <code>if/else</code> statement can break this parallel execution if the condition depends on per-vertex data, a problem known as <strong>thread divergence</strong>.</p>
<p>If one vertex in a group takes the <code>if</code> path and another takes the <code>else</code> path, the GPU has to run <strong>both</strong> branches sequentially for the whole group, with threads disabling themselves for the path they didn't take. This can effectively kill your parallelism.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ✗ Less efficient: This `if` statement depends on vertex position,</span>
<span class="hljs-comment">// causing threads in the same group to diverge, hurting performance.</span>
var displacement: <span class="hljs-built_in">f32</span>;
<span class="hljs-keyword">if</span> (<span class="hljs-keyword">in</span>.position.x &gt; <span class="hljs-number">0.0</span>) {
    displacement = expensive_calculation_A(<span class="hljs-keyword">in</span>.position);
} <span class="hljs-keyword">else</span> {
    displacement = expensive_calculation_B(<span class="hljs-keyword">in</span>.position);
}

<span class="hljs-comment">// ✓ More efficient: Calculate both outcomes and use a branchless function like</span>
<span class="hljs-comment">//   mix() or select() to blend between them. All threads execute the same instructions.</span>
<span class="hljs-keyword">let</span> outcome_A = expensive_calculation_A(<span class="hljs-keyword">in</span>.position);
<span class="hljs-keyword">let</span> outcome_B = expensive_calculation_B(<span class="hljs-keyword">in</span>.position);
<span class="hljs-comment">// The third argument to mix() acts as the selector. step() returns 0 or 1.</span>
<span class="hljs-keyword">let</span> t = step(<span class="hljs-number">0.0</span>, <span class="hljs-keyword">in</span>.position.x);
<span class="hljs-keyword">let</span> displacement = mix(outcome_B, outcome_A, t);
</code></pre>
<p>However, there is a crucial exception: <strong>uniform-based branching is perfectly fine.</strong> If the <code>if</code> condition is based on a uniform value (which is the same for all vertices being rendered in a draw call), then all threads will take the same path. There is no divergence, and the shader compiler can optimize the unused branch away completely. This is the technique we will use in our final example.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ✓ OK: This branch is based on a uniform. All vertices will take the</span>
<span class="hljs-comment">// same path, so there is no performance penalty.</span>
<span class="hljs-keyword">if</span> (material.effect_mode == <span class="hljs-number">1</span>u) {
    <span class="hljs-comment">// All vertices do this...</span>
} <span class="hljs-keyword">else</span> {
    <span class="hljs-comment">// ...or all vertices do this.</span>
}
</code></pre>
<h3 id="heading-4-offload-work-to-the-cpu-via-vertex-attributes">4. Offload Work to the CPU via Vertex Attributes</h3>
<p>The GPU is a massively parallel processor, but the CPU is often better for complex, sequential, or one-off tasks. If you need a value that is unique to each vertex but doesn't change every frame, <strong>pre-calculate it on the CPU</strong> and pass it to the shader as a custom vertex attribute.</p>
<p><strong>Scenario:</strong> Imagine you want each blade of grass in a field to sway with a slightly different timing and direction.</p>
<ul>
<li><p><strong>The Slow Way:</strong> Try to invent a "random" number inside the vertex shader based on <code>in.position</code> or <code>@builtin(vertex_index)</code>. This is computationally awkward and often produces repetitive, low-quality patterns.</p>
</li>
<li><p><strong>The Fast Way (in Rust):</strong> When you build the grass Mesh on the CPU, add a new custom attribute. For each vertex, generate a single random <code>f32</code> and store it in that attribute's buffer.</p>
</li>
</ul>
<pre><code class="lang-rust"><span class="hljs-comment">// In Rust, when creating your mesh:</span>
<span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> random_seeds = <span class="hljs-built_in">Vec</span>::with_capacity(vertex_count);
<span class="hljs-keyword">for</span> _ <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..vertex_count {
    random_seeds.push(rand::random::&lt;<span class="hljs-built_in">f32</span>&gt;());
}
<span class="hljs-comment">// Store this data in the mesh asset itself.</span>
<span class="hljs-comment">// Note: It's common to reuse an existing attribute slot</span>
<span class="hljs-comment">// if it's not otherwise needed (e.g., skinning attributes).</span>
my_mesh.insert_attribute(
    Mesh::ATTRIBUTE_JOINT_INDICES, 
    random_seeds,
);
</code></pre>
<p>Then, in your shader, you simply read this pre-calculated value.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// In your shader's VertexInput struct:</span>
@location(<span class="hljs-number">4</span>) random_seed: <span class="hljs-built_in">f32</span>, <span class="hljs-comment">// Or whichever location you chose</span>

<span class="hljs-comment">// In the vertex function:</span>
<span class="hljs-comment">// The random offset is instantly available, no calculation needed!</span>
<span class="hljs-keyword">let</span> sway_offset = sin(material.time + <span class="hljs-keyword">in</span>.random_seed * <span class="hljs-number">6.28</span>); <span class="hljs-comment">// 6.28 is 2*PI</span>
</code></pre>
<p>This is a powerful optimization principle: do the work once on the CPU during setup, not every frame for every vertex on the GPU.</p>
<h3 id="heading-5-scale-work-with-distance-lod">5. Scale Work with Distance (LOD)</h3>
<p>It is wasteful to execute a complex, detailed vertex displacement effect on an object so far away that it only covers a few pixels on screen. Smartly reducing or eliminating this work for distant objects is a core optimization strategy known as <strong>LOD (Level of Detail)</strong>.</p>
<p>The first step is always to calculate a <code>lod_factor</code> based on the object's distance from the camera. The <code>smoothstep</code> function is perfect for creating a smooth falloff range.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Assume camera_position is passed in via a uniform.</span>
<span class="hljs-comment">// We get the world position of the vertex *before* any displacement.</span>
<span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
<span class="hljs-keyword">let</span> world_pos_vec4 = model * vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-keyword">in</span>.position, <span class="hljs-number">1.0</span>);
<span class="hljs-keyword">let</span> distance_from_camera = length(world_pos_vec4.xyz - material.camera_position);

<span class="hljs-comment">// Create a blend factor. It will be 0 for objects closer than 20 units,</span>
<span class="hljs-comment">// 1 for objects farther than 100 units, and smoothly transition in between.</span>
<span class="hljs-keyword">let</span> lod_factor = smoothstep(<span class="hljs-number">20.0</span>, <span class="hljs-number">100.0</span>, distance_from_camera);
</code></pre>
<p>Simply multiplying your final displacement by <code>(1.0 - lod_factor)</code> will scale the visual intensity of the effect, but it <strong>does not reduce the amount of calculation</strong> and therefore provides no performance benefit on its own. The real performance gain comes from using this lod_factor to avoid doing work.</p>
<h4 id="heading-technique-1-conditional-execution">Technique 1: Conditional Execution</h4>
<p>We can use the <code>lod_factor</code> to create a branch that completely skips the expensive calculations for distant objects.</p>
<pre><code class="lang-rust">var final_position = <span class="hljs-keyword">in</span>.position;

<span class="hljs-comment">// Only run the expensive displacement functions if the object is close enough.</span>
<span class="hljs-keyword">if</span> (lod_factor &lt; <span class="hljs-number">0.999</span>) {
    <span class="hljs-comment">// Perform all expensive calculations inside this block.</span>
    var displaced_position = apply_wave_displacement(<span class="hljs-keyword">in</span>.position, ...);
    displaced_position = apply_noise_displacement(displaced_position, ...);

    <span class="hljs-comment">// We can still use the lod_factor to smoothly fade the effect out as it</span>
    <span class="hljs-comment">// approaches the cutoff distance, preventing a sudden "pop".</span>
    <span class="hljs-keyword">let</span> fade = <span class="hljs-number">1.0</span> - lod_factor;
    final_position = mix(<span class="hljs-keyword">in</span>.position, displaced_position, fade);
}
<span class="hljs-comment">// If lod_factor is &gt;= 0.999, the code inside the 'if' is never executed.</span>
<span class="hljs-comment">// The shader becomes extremely cheap, only performing the basic model transformation.</span>
</code></pre>
<p>This is a safe and effective use of branching. Because the <code>lod_factor</code> will be very similar for all vertices in a distant object, the entire group of threads will likely take the same "do nothing" path, avoiding the thread divergence problem while saving a massive amount of computation.</p>
<h4 id="heading-technique-2-swapping-materials-the-best-method">Technique 2: Swapping Materials (The Best Method)</h4>
<p>The most robust and performant LOD systems are implemented on the CPU. Instead of a single complex shader, you create multiple versions:</p>
<ol>
<li><p><code>effect_lod0.wgsl</code>: The full-quality shader with all effects enabled.</p>
</li>
<li><p><code>effect_lod1.wgsl</code>: A cheaper version with only the most prominent effect (e.g., just the wave).</p>
</li>
<li><p><code>effect_lod2.wgsl</code>: A "fall-back" shader that does no displacement at all, only the standard transformations.</p>
</li>
</ol>
<p>Then, a system in your Rust application is responsible for checking the entity's distance from the camera and swapping the <code>Handle&lt;CustomMaterial&gt;</code> to the appropriate, cheaper version. This architectural approach ensures the GPU is only ever running the absolute minimum code necessary for the required level of detail, providing the best possible performance.</p>
<hr />
<h2 id="heading-complete-example-advanced-vertex-effect-system">Complete Example: Advanced Vertex Effect System</h2>
<p>It's time to combine everything we've learned into a single, powerful shader controlled by a Bevy application. This project will allow you to cycle through different vertex effects in real-time and adjust their parameters to see the immediate impact of your changes.</p>
<h3 id="heading-our-goal">Our Goal</h3>
<p>We will create a custom material that can apply several distinct vertex shader effects to a mesh: a classic sine-wave deformation, organic noise, a "breathing" pulse, a rotational twist, and a camera-facing billboard. This single, versatile shader will serve as a playground for experimenting with the core techniques of vertex manipulation.</p>
<h3 id="heading-what-this-project-demonstrates">What This Project Demonstrates</h3>
<ul>
<li><p><strong>Manual Transformation Pipeline:</strong> Implementing the <code>Model -&gt; World -&gt; View -&gt; Clip</code> journey in our shader for all standard deformation effects.</p>
</li>
<li><p><strong>Uniform-Based Branching:</strong> Using a <code>material.effect_mode</code> uniform to safely and efficiently switch between different effects without performance loss from thread divergence.</p>
</li>
<li><p><strong>Effect Composition:</strong> Demonstrating how to layer multiple displacement functions for more complex and interesting results.</p>
</li>
<li><p><strong>Normal Perturbation:</strong> Calculating new normals for our wave effect to ensure lighting reacts correctly to the deformed surface.</p>
</li>
<li><p><strong>Passing Camera Data:</strong> Correctly sending the camera's transform vectors to our material via uniforms to build a robust billboard, avoiding GPU binding conflicts.</p>
</li>
<li><p><strong>CPU-Side Logic:</strong> Using a Bevy system to dynamically swap a mesh between a sphere and a plane to best suit the selected effect (e.g., a plane for the billboard).</p>
</li>
</ul>
<h3 id="heading-the-shader-assetsshadersd0201advancedvertexeffectswgsl">The Shader (<code>assets/shaders/d02_01_advanced_vertex_effects.wgsl</code>)</h3>
<p>This is the heart of our project. The WGSL code contains our library of displacement functions (<code>apply_wave_displacement</code>, <code>noise3d</code>, <code>apply_twist</code>) and the main <code>@vertex</code> function. The vertex shader's logic is controlled by a large <code>if/else</code> if chain that checks the material.effect_mode uniform. This is a performant way to manage multiple effects in one shader, as all vertices in a draw call will take the same code path.</p>
<p>For most effects, it modifies the vertex's <code>local_position</code> before applying the standard model-to-world transformation. For the billboard mode (mode 5), it uses a completely different logic path, ignoring the model matrix entirely and constructing the world position from the camera's right and up vectors. The fragment shader provides simple lighting and visualizes the intensity of each effect with color.</p>
<pre><code class="lang-rust">#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::{position_world_to_clip, position_view_to_world}

<span class="hljs-comment">// Advanced vertex effect parameters</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexEffectMaterial</span></span> {
    effect_mode: <span class="hljs-built_in">u32</span>,
    time: <span class="hljs-built_in">f32</span>,
    wave_frequency: <span class="hljs-built_in">f32</span>,
    wave_amplitude: <span class="hljs-built_in">f32</span>,
    noise_strength: <span class="hljs-built_in">f32</span>,
    inflation: <span class="hljs-built_in">f32</span>,
    twist_amount: <span class="hljs-built_in">f32</span>,
    billboard_size: <span class="hljs-built_in">f32</span>,
    camera_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    _padding: <span class="hljs-built_in">f32</span>,
    camera_right: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    _padding2: <span class="hljs-built_in">f32</span>,
    camera_up: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    _padding3: <span class="hljs-built_in">f32</span>,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: VertexEffectMaterial;

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) world_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) uv: vec2&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">3</span>) effect_intensity: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-comment">// ===== Displacement Functions =====</span>

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_wave_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
    frequency: <span class="hljs-built_in">f32</span>,
    amplitude: <span class="hljs-built_in">f32</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> wave1 = sin(position.x * frequency + time * <span class="hljs-number">2.0</span>);
    <span class="hljs-keyword">let</span> wave2 = cos(position.z * frequency + time * <span class="hljs-number">1.5</span>);
    <span class="hljs-keyword">let</span> combined_wave = (wave1 + wave2) * <span class="hljs-number">0.5</span>;

    <span class="hljs-keyword">return</span> position + normal * combined_wave * amplitude;
}

<span class="hljs-comment">// Simple hash function for noise</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash</span></span>(n: <span class="hljs-built_in">f32</span>) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">return</span> fract(sin(n) * <span class="hljs-number">43758.5453123</span>);
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">noise3d</span></span>(p: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> ip = floor(p);
    var fp = fract(p);
    fp = fp * fp * (<span class="hljs-number">3.0</span> - <span class="hljs-number">2.0</span> * fp);

    <span class="hljs-keyword">let</span> n = ip.x + ip.y * <span class="hljs-number">57.0</span> + ip.z * <span class="hljs-number">113.0</span>;
    <span class="hljs-keyword">return</span> mix(
        mix(
            mix(hash(n + <span class="hljs-number">0.0</span>), hash(n + <span class="hljs-number">1.0</span>), fp.x),
            mix(hash(n + <span class="hljs-number">57.0</span>), hash(n + <span class="hljs-number">58.0</span>), fp.x),
            fp.y
        ),
        mix(
            mix(hash(n + <span class="hljs-number">113.0</span>), hash(n + <span class="hljs-number">114.0</span>), fp.x),
            mix(hash(n + <span class="hljs-number">170.0</span>), hash(n + <span class="hljs-number">171.0</span>), fp.x),
            fp.y
        ),
        fp.z
    );
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_noise_displacement</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
    strength: <span class="hljs-built_in">f32</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> noise_pos = position * <span class="hljs-number">2.0</span> + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(time * <span class="hljs-number">0.3</span>);
    <span class="hljs-keyword">let</span> noise = noise3d(noise_pos) * <span class="hljs-number">2.0</span> - <span class="hljs-number">1.0</span>; <span class="hljs-comment">// -1 to 1</span>

    <span class="hljs-keyword">return</span> position + normal * noise * strength;
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">apply_twist</span></span>(
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    amount: <span class="hljs-built_in">f32</span>,
    axis: vec3&lt;<span class="hljs-built_in">f32</span>&gt;
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-comment">// Calculate height along axis</span>
    <span class="hljs-keyword">let</span> height = dot(position, axis);

    <span class="hljs-comment">// Rotation amount increases with height</span>
    <span class="hljs-keyword">let</span> angle = height * amount;
    <span class="hljs-keyword">let</span> c = cos(angle);
    <span class="hljs-keyword">let</span> s = sin(angle);

    <span class="hljs-comment">// Get perpendicular components</span>
    <span class="hljs-keyword">let</span> parallel = height * axis;
    <span class="hljs-keyword">let</span> perpendicular = position - parallel;

    <span class="hljs-comment">// Rotate in the perpendicular plane</span>
    <span class="hljs-comment">// We need a consistent coordinate system perpendicular to the axis</span>
    <span class="hljs-keyword">let</span> right = normalize(cross(axis, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>)));
    <span class="hljs-keyword">let</span> forward = cross(axis, right);

    <span class="hljs-keyword">let</span> perp_x = dot(perpendicular, right);
    <span class="hljs-keyword">let</span> perp_y = dot(perpendicular, forward);

    <span class="hljs-keyword">let</span> rotated_x = perp_x * c - perp_y * s;
    <span class="hljs-keyword">let</span> rotated_y = perp_x * s + perp_y * c;

    <span class="hljs-keyword">return</span> parallel + right * rotated_x + forward * rotated_y;
}

<span class="hljs-comment">// ===== Normal Perturbation =====</span>

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">perturb_normal_for_waves</span></span>(
    normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    time: <span class="hljs-built_in">f32</span>,
    frequency: <span class="hljs-built_in">f32</span>,
    amplitude: <span class="hljs-built_in">f32</span>
) -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> epsilon = <span class="hljs-number">0.01</span>;

    <span class="hljs-comment">// Calculate wave gradients</span>
    <span class="hljs-keyword">let</span> grad_x = cos(position.x * frequency + time * <span class="hljs-number">2.0</span>) * frequency;
    <span class="hljs-keyword">let</span> grad_z = cos(position.z * frequency + time * <span class="hljs-number">1.5</span>) * frequency;

    <span class="hljs-keyword">let</span> tangent_offset = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(grad_x, <span class="hljs-number">0.0</span>, grad_z) * amplitude;

    <span class="hljs-keyword">return</span> normalize(normal + tangent_offset * <span class="hljs-number">0.5</span>);
}

<span class="hljs-comment">// ===== Camera Utilities =====</span>

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_camera_right</span></span>() -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> normalize(material.camera_right);
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">get_camera_up</span></span>() -&gt; vec3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> normalize(material.camera_up);
}

<span class="hljs-comment">// ===== Main Vertex Shader =====</span>

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);

    var local_position = <span class="hljs-keyword">in</span>.position;
    var local_normal = <span class="hljs-keyword">in</span>.normal;
    var effect_intensity = <span class="hljs-number">0.0</span>;
    var world_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;;

    <span class="hljs-comment">// Mode 5: Billboard (Camera-facing quad) - handle differently!</span>
    <span class="hljs-keyword">if</span> material.effect_mode == <span class="hljs-number">5</span>u {
        <span class="hljs-comment">// For billboard with Rectangle mesh (XY plane), rebuild position in world space</span>
        <span class="hljs-keyword">let</span> center = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);

        <span class="hljs-keyword">let</span> camera_right = get_camera_right();
        <span class="hljs-keyword">let</span> camera_up = get_camera_up();

        <span class="hljs-comment">// Rectangle mesh has vertices in XY plane, use them directly as 2D offsets</span>
        <span class="hljs-keyword">let</span> billboard_pos = center
                          + camera_right * <span class="hljs-keyword">in</span>.position.x * material.billboard_size
                          + camera_up * <span class="hljs-keyword">in</span>.position.y * material.billboard_size;

        <span class="hljs-comment">// Use billboard position directly as world position (no model transform!)</span>
        world_position = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(billboard_pos, <span class="hljs-number">1.0</span>);

        <span class="hljs-comment">// Billboard normal faces camera</span>
        local_normal = normalize(material.camera_position - billboard_pos);

        effect_intensity = <span class="hljs-number">1.0</span>;
    }
    <span class="hljs-comment">// All other modes: normal transformation pipeline</span>
    <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// Mode 0: Wave Displacement</span>
        <span class="hljs-keyword">if</span> material.effect_mode == <span class="hljs-number">0</span>u {
            local_position = apply_wave_displacement(
                local_position,
                local_normal,
                material.time,
                material.wave_frequency,
                material.wave_amplitude
            );

            local_normal = perturb_normal_for_waves(
                local_normal,
                <span class="hljs-keyword">in</span>.position,
                material.time,
                material.wave_frequency,
                material.wave_amplitude
            );

            effect_intensity = (sin(<span class="hljs-keyword">in</span>.position.x * material.wave_frequency + material.time * <span class="hljs-number">2.0</span>) + <span class="hljs-number">1.0</span>) * <span class="hljs-number">0.5</span>;
        }
        <span class="hljs-comment">// Mode 1: Noise Displacement</span>
        <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.effect_mode == <span class="hljs-number">1</span>u {
            local_position = apply_noise_displacement(
                local_position,
                local_normal,
                material.time,
                material.noise_strength
            );

            effect_intensity = noise3d(<span class="hljs-keyword">in</span>.position * <span class="hljs-number">2.0</span> + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(material.time * <span class="hljs-number">0.3</span>));
        }
        <span class="hljs-comment">// Mode 2: Inflation (Breathing effect)</span>
        <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.effect_mode == <span class="hljs-number">2</span>u {
            <span class="hljs-keyword">let</span> pulse = (sin(material.time * <span class="hljs-number">2.0</span>) + <span class="hljs-number">1.0</span>) * <span class="hljs-number">0.5</span>;
            <span class="hljs-keyword">let</span> inflate_amount = material.inflation * pulse;

            local_position = local_position + local_normal * inflate_amount;
            effect_intensity = pulse;
        }
        <span class="hljs-comment">// Mode 3: Twist</span>
        <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.effect_mode == <span class="hljs-number">3</span>u {
            <span class="hljs-keyword">let</span> axis = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>);
            local_position = apply_twist(
                local_position,
                material.twist_amount * sin(material.time),
                axis
            );

            effect_intensity = abs(sin(material.time));
        }
        <span class="hljs-comment">// Mode 4: Combined Effects</span>
        <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.effect_mode == <span class="hljs-number">4</span>u {
            <span class="hljs-comment">// Layer multiple effects</span>
            local_position = apply_wave_displacement(
                local_position,
                local_normal,
                material.time,
                material.wave_frequency * <span class="hljs-number">0.5</span>,
                material.wave_amplitude * <span class="hljs-number">0.5</span>
            );

            local_position = apply_noise_displacement(
                local_position,
                local_normal,
                material.time,
                material.noise_strength * <span class="hljs-number">0.3</span>
            );

            <span class="hljs-keyword">let</span> pulse = (sin(material.time * <span class="hljs-number">2.0</span>) + <span class="hljs-number">1.0</span>) * <span class="hljs-number">0.5</span>;
            local_position = local_position + local_normal * material.inflation * pulse * <span class="hljs-number">0.3</span>;

            local_normal = perturb_normal_for_waves(
                local_normal,
                <span class="hljs-keyword">in</span>.position,
                material.time,
                material.wave_frequency * <span class="hljs-number">0.5</span>,
                material.wave_amplitude * <span class="hljs-number">0.5</span>
            );

            effect_intensity = pulse;
        }

        <span class="hljs-comment">// Transform to world space for non-billboard modes</span>
        world_position = mesh_functions::mesh_position_local_to_world(
            model,
            vec4&lt;<span class="hljs-built_in">f32</span>&gt;(local_position, <span class="hljs-number">1.0</span>)
        );
    }

    <span class="hljs-comment">// Transform normal to world space (for all modes)</span>
    <span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(
        local_normal,
        <span class="hljs-keyword">in</span>.instance_index
    );

    <span class="hljs-comment">// Transform to clip space</span>
    out.clip_position = position_world_to_clip(world_position.xyz);
    out.world_position = world_position.xyz;
    out.world_normal = normalize(world_normal);
    out.uv = <span class="hljs-keyword">in</span>.uv;
    out.effect_intensity = effect_intensity;

    <span class="hljs-keyword">return</span> out;
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(<span class="hljs-keyword">in</span>: VertexOutput) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);

    <span class="hljs-comment">// Simple lighting</span>
    <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
    <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.3</span>, dot(normal, light_dir));

    <span class="hljs-comment">// Base color with effect intensity</span>
    var base_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.3</span>, <span class="hljs-number">0.6</span>, <span class="hljs-number">0.9</span>);

    <span class="hljs-comment">// Modulate color based on effect intensity</span>
    <span class="hljs-keyword">let</span> effect_color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.7</span>, <span class="hljs-number">0.3</span>);
    base_color = mix(base_color, effect_color, <span class="hljs-keyword">in</span>.effect_intensity * <span class="hljs-number">0.5</span>);

    <span class="hljs-comment">// Special rendering for billboard mode - add a clear directional pattern</span>
    <span class="hljs-keyword">if</span> material.effect_mode == <span class="hljs-number">5</span>u {
        <span class="hljs-comment">// Create an arrow pattern using UV coordinates to show orientation</span>
        <span class="hljs-comment">// Rectangle mesh has UVs from 0 to 1 in both X and Y</span>
        <span class="hljs-keyword">let</span> uv = <span class="hljs-keyword">in</span>.uv;

        <span class="hljs-comment">// Vertical stripe in center (arrow shaft)</span>
        <span class="hljs-keyword">let</span> center_stripe = step(abs(uv.x - <span class="hljs-number">0.5</span>), <span class="hljs-number">0.08</span>);
        <span class="hljs-keyword">let</span> shaft = center_stripe * step(uv.y, <span class="hljs-number">0.6</span>);

        <span class="hljs-comment">// Arrow head at top (two diagonal lines forming V shape)</span>
        <span class="hljs-keyword">let</span> arrow_region = step(<span class="hljs-number">0.55</span>, uv.y) * step(uv.y, <span class="hljs-number">0.8</span>);

        <span class="hljs-comment">// Left diagonal of arrow head</span>
        <span class="hljs-keyword">let</span> left_dist = abs((uv.x - <span class="hljs-number">0.3</span>) - (uv.y - <span class="hljs-number">0.55</span>) * <span class="hljs-number">0.5</span>);
        <span class="hljs-keyword">let</span> left_arm = step(left_dist, <span class="hljs-number">0.04</span>) * arrow_region;

        <span class="hljs-comment">// Right diagonal of arrow head</span>
        <span class="hljs-keyword">let</span> right_dist = abs((uv.x - <span class="hljs-number">0.7</span>) + (uv.y - <span class="hljs-number">0.55</span>) * <span class="hljs-number">0.5</span>);
        <span class="hljs-keyword">let</span> right_arm = step(right_dist, <span class="hljs-number">0.04</span>) * arrow_region;

        <span class="hljs-comment">// Combine all parts of arrow</span>
        <span class="hljs-keyword">let</span> arrow = max(shaft, max(left_arm, right_arm));

        <span class="hljs-comment">// Yellow arrow on blue background</span>
        base_color = mix(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.2</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">1.0</span>), vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>), arrow);
    }

    <span class="hljs-keyword">let</span> final_color = base_color * diffuse;

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(final_color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0201advancedvertexeffectsrs">The Rust Material (<code>src/materials/d02_01_advanced_vertex_effects.rs</code>)</h3>
<p>The Rust <code>Material</code> definition is the bridge to our shader. The AdvancedVertexEffectsMaterial struct mirrors the uniform block in WGSL, including the necessary padding to satisfy memory alignment rules. This allows Bevy to correctly format our data for the GPU.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">AdvancedVertexEffectsMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> effect_mode: <span class="hljs-built_in">u32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> wave_frequency: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> wave_amplitude: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> noise_strength: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> inflation: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> twist_amount: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> billboard_size: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> camera_position: Vec3,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> _padding: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> camera_right: Vec3,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> _padding2: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> camera_up: Vec3,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> _padding3: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> AdvancedVertexEffectsMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_01_advanced_vertex_effects.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d02_01_advanced_vertex_effects.wgsl"</span>.into()
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other materials</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_01_advanced_vertex_effects;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0201advancedvertexeffectsrs">The Demo Module (<code>src/demos/d02_01_advanced_vertex_effects.rs</code>)</h3>
<p>The Rust code sets up our scene and contains the logic for interactivity. Key systems include:</p>
<ul>
<li><p><code>update_time</code>: Updates the <code>time</code> uniform and, crucially, queries the <code>Camera3d</code>'s <code>Transform</code> to pass its current position, right, and up vectors to the material every frame.</p>
</li>
<li><p><code>cycle_effect_mode</code> &amp; <code>adjust_parameters</code>: Listen for keyboard input to change the <code>effect_mode</code> and tweak the various effect parameters in real-time.</p>
</li>
<li><p><code>swap_mesh_for_billboard</code>: An intelligent system that watches for the <code>effect_mode</code> changing. When the billboard mode is selected, it swaps the entity's <code>Mesh3d</code> handle to a <code>Rectangle</code>; for all other modes, it ensures a <code>Sphere</code> is used.</p>
</li>
</ul>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d02_01_advanced_vertex_effects::AdvancedVertexEffectsMaterial;
<span class="hljs-keyword">use</span> bevy::prelude::*;

<span class="hljs-comment">// Marker component for our demo object</span>
<span class="hljs-meta">#[derive(Component)]</span>
<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">EffectDemoObject</span></span>;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins.set(AssetPlugin {
            watch_for_changes_override: <span class="hljs-literal">Some</span>(<span class="hljs-literal">true</span>),
            ..default()
        }))
        .add_plugins(MaterialPlugin::&lt;AdvancedVertexEffectsMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (
                rotate_camera,
                update_time,
                cycle_effect_mode,
                adjust_parameters,
                display_controls,
                swap_mesh_for_billboard,
            ),
        )
        .run();
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;AdvancedVertexEffectsMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> standard_materials: ResMut&lt;Assets&lt;StandardMaterial&gt;&gt;,
) {
    <span class="hljs-comment">// Spawn a sphere with vertex effects</span>
    commands.spawn((
        Mesh3d(meshes.add(Sphere::new(<span class="hljs-number">1.0</span>).mesh().uv(<span class="hljs-number">64</span>, <span class="hljs-number">32</span>))),
        MeshMaterial3d(materials.add(AdvancedVertexEffectsMaterial {
            effect_mode: <span class="hljs-number">0</span>,
            time: <span class="hljs-number">0.0</span>,
            wave_frequency: <span class="hljs-number">3.0</span>,
            wave_amplitude: <span class="hljs-number">0.2</span>,
            noise_strength: <span class="hljs-number">0.3</span>,
            inflation: <span class="hljs-number">0.3</span>,
            twist_amount: <span class="hljs-number">2.0</span>,
            billboard_size: <span class="hljs-number">2.0</span>,
            camera_position: Vec3::ZERO,
            _padding: <span class="hljs-number">0.0</span>,
            camera_right: Vec3::X,
            _padding2: <span class="hljs-number">0.0</span>,
            camera_up: Vec3::Y,
            _padding3: <span class="hljs-number">0.0</span>,
        })),
        EffectDemoObject,
    ));

    <span class="hljs-comment">// Add a reference cube to show the camera is actually moving</span>
    <span class="hljs-comment">// This cube will rotate from the camera's perspective</span>
    commands.spawn((
        Mesh3d(meshes.add(Cuboid::new(<span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">0.5</span>))),
        MeshMaterial3d(standard_materials.add(StandardMaterial {
            base_color: Color::srgb(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.3</span>, <span class="hljs-number">0.3</span>),
            ..default()
        })),
        Transform::from_xyz(<span class="hljs-number">2.5</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>),
    ));

    <span class="hljs-comment">// Light</span>
    commands.spawn((
        PointLight {
            shadows_enabled: <span class="hljs-literal">true</span>,
            intensity: <span class="hljs-number">2000.0</span>,
            ..default()
        },
        Transform::from_xyz(<span class="hljs-number">4.0</span>, <span class="hljs-number">8.0</span>, <span class="hljs-number">4.0</span>),
    ));

    <span class="hljs-comment">// Camera</span>
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(-<span class="hljs-number">3.0</span>, <span class="hljs-number">2.5</span>, <span class="hljs-number">6.0</span>).looking_at(Vec3::ZERO, Vec3::Y),
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rotate_camera</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> camera_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Transform, With&lt;Camera3d&gt;&gt;) {
    <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> transform <span class="hljs-keyword">in</span> camera_query.iter_mut() {
        <span class="hljs-keyword">let</span> radius = <span class="hljs-number">6.0</span>;
        <span class="hljs-keyword">let</span> angle = time.elapsed_secs() * <span class="hljs-number">0.3</span>;
        transform.translation.x = angle.cos() * radius;
        transform.translation.z = angle.sin() * radius;
        transform.look_at(Vec3::ZERO, Vec3::Y);
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_time</span></span>(
    time: Res&lt;Time&gt;,
    camera_query: Query&lt;&amp;Transform, With&lt;Camera3d&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;AdvancedVertexEffectsMaterial&gt;&gt;,
) {
    <span class="hljs-comment">// Get camera transform - unwrap because we expect exactly one camera</span>
    <span class="hljs-keyword">let</span> camera_transform = camera_query.single().unwrap();

    <span class="hljs-comment">// Calculate camera axes from transform</span>
    <span class="hljs-keyword">let</span> camera_right = camera_transform.right().as_vec3();
    <span class="hljs-keyword">let</span> camera_up = camera_transform.up().as_vec3();
    <span class="hljs-keyword">let</span> camera_position = camera_transform.translation;

    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        material.time = time.elapsed_secs();
        material.camera_position = camera_position;
        material.camera_right = camera_right;
        material.camera_up = camera_up;
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">cycle_effect_mode</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;AdvancedVertexEffectsMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Space) {
        <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
            material.effect_mode = (material.effect_mode + <span class="hljs-number">1</span>) % <span class="hljs-number">6</span>;
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">adjust_parameters</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;AdvancedVertexEffectsMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-comment">// Wave frequency</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyQ) {
            material.wave_frequency = (material.wave_frequency - <span class="hljs-number">0.1</span>).max(<span class="hljs-number">0.5</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyW) {
            material.wave_frequency = (material.wave_frequency + <span class="hljs-number">0.1</span>).min(<span class="hljs-number">10.0</span>);
        }

        <span class="hljs-comment">// Wave amplitude</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyA) {
            material.wave_amplitude = (material.wave_amplitude - <span class="hljs-number">0.01</span>).max(<span class="hljs-number">0.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyS) {
            material.wave_amplitude = (material.wave_amplitude + <span class="hljs-number">0.01</span>).min(<span class="hljs-number">1.0</span>);
        }

        <span class="hljs-comment">// Noise strength</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyZ) {
            material.noise_strength = (material.noise_strength - <span class="hljs-number">0.01</span>).max(<span class="hljs-number">0.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyX) {
            material.noise_strength = (material.noise_strength + <span class="hljs-number">0.01</span>).min(<span class="hljs-number">1.0</span>);
        }

        <span class="hljs-comment">// Inflation</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyE) {
            material.inflation = (material.inflation - <span class="hljs-number">0.01</span>).max(<span class="hljs-number">0.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyR) {
            material.inflation = (material.inflation + <span class="hljs-number">0.01</span>).min(<span class="hljs-number">1.0</span>);
        }

        <span class="hljs-comment">// Twist amount</span>
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyC) {
            material.twist_amount = (material.twist_amount - <span class="hljs-number">0.1</span>).max(<span class="hljs-number">0.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::KeyV) {
            material.twist_amount = (material.twist_amount + <span class="hljs-number">0.1</span>).min(<span class="hljs-number">5.0</span>);
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">display_controls</span></span>(
    materials: Res&lt;Assets&lt;AdvancedVertexEffectsMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> commands: Commands,
    text_query: Query&lt;Entity, With&lt;Text&gt;&gt;,
) {
    <span class="hljs-comment">// Remove old text</span>
    <span class="hljs-keyword">for</span> entity <span class="hljs-keyword">in</span> text_query.iter() {
        commands.entity(entity).despawn();
    }

    <span class="hljs-comment">// Get current material state</span>
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, material)) = materials.iter().next() {
        <span class="hljs-keyword">let</span> mode_text = <span class="hljs-keyword">match</span> material.effect_mode {
            <span class="hljs-number">0</span> =&gt; <span class="hljs-string">"Wave Displacement"</span>,
            <span class="hljs-number">1</span> =&gt; <span class="hljs-string">"Noise Displacement"</span>,
            <span class="hljs-number">2</span> =&gt; <span class="hljs-string">"Inflation (Breathing)"</span>,
            <span class="hljs-number">3</span> =&gt; <span class="hljs-string">"Twist"</span>,
            <span class="hljs-number">4</span> =&gt; <span class="hljs-string">"Combined Effects"</span>,
            <span class="hljs-number">5</span> =&gt; <span class="hljs-string">"Billboard (switches to plane mesh)"</span>,
            _ =&gt; <span class="hljs-string">"Unknown"</span>,
        };

        <span class="hljs-keyword">let</span> controls = <span class="hljs-built_in">format!</span>(
            <span class="hljs-string">"SPACE: Cycle Effect Mode (Current: {})\n\
             Q/W: Wave Frequency ({:.1}) | A/S: Wave Amplitude ({:.2})\n\
             Z/X: Noise Strength ({:.2}) | E/R: Inflation ({:.2})\n\
             C/V: Twist Amount ({:.1})"</span>,
            mode_text,
            material.wave_frequency,
            material.wave_amplitude,
            material.noise_strength,
            material.inflation,
            material.twist_amount
        );

        commands.spawn((
            Text::new(controls),
            Node {
                position_type: PositionType::Absolute,
                top: Val::Px(<span class="hljs-number">10.0</span>),
                left: Val::Px(<span class="hljs-number">10.0</span>),
                ..default()
            },
        ));
    }
}

<span class="hljs-comment">// System to swap mesh between sphere and plane for billboard mode</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">swap_mesh_for_billboard</span></span>(
    materials: Res&lt;Assets&lt;AdvancedVertexEffectsMaterial&gt;&gt;,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> demo_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Mesh3d, With&lt;EffectDemoObject&gt;&gt;,
    <span class="hljs-keyword">mut</span> last_mode: Local&lt;<span class="hljs-built_in">Option</span>&lt;<span class="hljs-built_in">u32</span>&gt;&gt;,
) {
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, material)) = materials.iter().next() {
        <span class="hljs-comment">// Only swap if mode changed</span>
        <span class="hljs-keyword">if</span> *last_mode != <span class="hljs-literal">Some</span>(material.effect_mode) {
            <span class="hljs-keyword">let</span> <span class="hljs-keyword">mut</span> mesh = demo_query.single_mut().unwrap();

            <span class="hljs-keyword">if</span> material.effect_mode == <span class="hljs-number">5</span> {
                <span class="hljs-comment">// Create a simple quad mesh for billboard</span>
                <span class="hljs-comment">// Use Rectangle which is a 2D shape in the XY plane</span>
                mesh.<span class="hljs-number">0</span> = meshes.add(Rectangle::new(<span class="hljs-number">2.0</span>, <span class="hljs-number">2.0</span>));
            } <span class="hljs-keyword">else</span> {
                <span class="hljs-comment">// Use sphere for all other modes</span>
                mesh.<span class="hljs-number">0</span> = meshes.add(Sphere::new(<span class="hljs-number">1.0</span>).mesh().uv(<span class="hljs-number">64</span>, <span class="hljs-number">32</span>));
            }

            *last_mode = <span class="hljs-literal">Some</span>(material.effect_mode);
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other demos</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d02_01_advanced_vertex_effects;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"2.1"</span>,
    title: <span class="hljs-string">"Vertex Transformation Deep Dive"</span>,
    run: demos::d02_01_advanced_vertex_effects::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>When you run this application, you will see a blue sphere at the center of the scene. As the camera orbits, the lighting on the sphere will change. Use the keyboard to interact with the demo.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key</td><td>Action</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Space</strong></td><td>Cycle through the different effect modes.</td></tr>
<tr>
<td><strong>Q / W</strong></td><td>Decrease / Increase wave frequency.</td></tr>
<tr>
<td><strong>A / S</strong></td><td>Decrease / Increase wave amplitude.</td></tr>
<tr>
<td><strong>Z / X</strong></td><td>Decrease / Increase noise strength.</td></tr>
<tr>
<td><strong>E / R</strong></td><td>Decrease / Increase inflation amount.</td></tr>
<tr>
<td><strong>C / V</strong></td><td>Decrease / Increase twist amount.</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762462383944/c5b786f4-9a3c-4f3a-8807-c8846ffc7b2b.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762462394500/706c6171-7b42-443d-9de6-084a71475e59.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762462408041/30a5681d-db48-4bbe-8e7e-5ccc0d95cad4.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762462442189/52698b71-cdcd-4f14-b825-a308212bec5c.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762462466071/be0ed50e-0008-4035-89e9-71a802b50079.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762462484616/b3f4fb07-e664-49b8-8661-edc9558c4634.png" alt class="image--center mx-auto" /></p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Mode</td><td>Concept Visualized</td><td>What to Do &amp; Look For</td></tr>
</thead>
<tbody>
<tr>
<td><strong>0</strong></td><td><strong>Wave Displacement</strong></td><td>The sphere's surface ripples with smooth waves. Notice that the lighting correctly follows the new contours, because our shader is perturbing the normals to match the wave.</td></tr>
<tr>
<td><strong>1</strong></td><td><strong>Noise Displacement</strong></td><td>The sphere deforms with an organic, lumpy motion. This effect uses the original normals for lighting, which is a common approximation that works well for low-frequency noise.</td></tr>
<tr>
<td><strong>2</strong></td><td><strong>Inflation</strong></td><td>See the sphere pulse in and out rhythmically. This is a simple but powerful effect that displaces every vertex along its original normal.</td></tr>
<tr>
<td><strong>3</strong></td><td><strong>Twist</strong></td><td>The sphere twists around its vertical (Y) axis as if it's being wrung out. This effect manipulates vertex positions directly without using normals for displacement.</td></tr>
<tr>
<td><strong>4</strong></td><td><strong>Combined Effects</strong></td><td>This mode layers waves, noise, and inflation to create a more complex and dynamic result, demonstrating the power of composition.</td></tr>
<tr>
<td><strong>5</strong></td><td><strong>Billboard</strong></td><td>The mesh automatically swaps from a sphere to a flat rectangle. Notice how this rectangle always rotates to perfectly face the orbiting camera. The yellow arrow pattern helps visualize its orientation, always pointing "up" relative to the camera's view.</td></tr>
</tbody>
</table>
</div><h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>You have now worked through one of the most fundamental and creatively empowering topics in shader development. Before moving on, take a moment to solidify your understanding of these core concepts:</p>
<ol>
<li><p><strong>The Full Pipeline:</strong> You know how to manually transform a vertex from its local, model-space coordinates all the way to the final clip-space position required by the GPU, following the path: <strong>Local → World → View → Clip</strong>.</p>
</li>
<li><p><strong>The Power of Displacement:</strong> You understand that vertex effects are created by intervening in the transformation pipeline and modifying a vertex's position before applying the model matrix. You can create waves, noise, twists, and other deformations.</p>
</li>
<li><p><strong>Correct Normal Transformation:</strong> You know that when geometry deforms, its normals must be updated for lighting to work correctly. For standard transforms, you use Bevy's <code>mesh_normal_local_to_world</code>, and for custom displacements, you can <strong>perturb</strong> the normal based on the effect's logic.</p>
</li>
<li><p><strong>Camera-Relative Logic:</strong> You can create camera-facing billboards by abandoning the model matrix and instead constructing a vertex's world position from the camera's <code>up</code> and <code>right</code> vectors.</p>
</li>
<li><p><strong>Performance is Paramount:</strong> You are aware of the key optimization strategies: calculate once and reuse, prefer built-in functions, avoid divergent branching, and use Level of Detail (LOD) techniques to reduce work on distant objects.</p>
</li>
<li><p><strong>Composition is Key:</strong> The most interesting effects are often born from layering several simpler displacement functions together.</p>
</li>
<li><p><strong>Safe Shader Bindings:</strong> You understand the importance of passing camera data and other global state through your <code>Material</code>'s uniforms rather than trying to bind Bevy's global <code>View</code> uniform, which prevents binding conflicts.</p>
</li>
</ol>
<h2 id="heading-whats-next">What's Next?</h2>
<p>We have now manually rebuilt most of the standard transformation pipeline, but one crucial piece remains a "black box": the <code>position_world_to_clip</code> function. We know that it takes our world-space position and magically transforms it for the screen, but how does it work? What are the <strong>View</strong> and <strong>Projection</strong> matrices that live inside it, and how do they give us camera perspective, depth, and field-of-view?</p>
<p>In the next article, we will complete our mastery of the transformation pipeline by deconstructing that final step. You will learn to build the <strong>Model-View-Projection (MVP)</strong> matrix from scratch, giving you ultimate control over the virtual camera. This knowledge is the key to unlocking advanced effects like custom camera projections, fisheye lenses, and precision control over how your 3D world is mapped onto your 2D screen.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/22-camera-and-projection-matrices"><strong><em>2.2 - Camera and Projection Matrices</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<h3 id="heading-the-full-transformation-pipeline">The Full Transformation Pipeline</h3>
<p>The goal is always to calculate clip space position. The complete formula is:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> clip_pos = projection_matrix * view_matrix * model_matrix * vec4(local_pos, <span class="hljs-number">1.0</span>);
</code></pre>
<h3 id="heading-positions-vs-directions-w-component">Positions vs. Directions (<code>w</code> component)</h3>
<ul>
<li><p>Use <code>w = 1.0</code> for <strong>positions</strong> so they are affected by translation.<br />  <code>vec4(in.position, 1.0)</code></p>
</li>
<li><p>Use <code>w = 0.0</code> for <strong>directions</strong> (like normals) to ignore translation.<br />  <code>vec4(in.normal, 0.0)</code></p>
</li>
</ul>
<h3 id="heading-the-standard-displacement-pattern">The Standard Displacement Pattern</h3>
<p>Always modify the vertex position in <strong>local space</strong>, before applying the model matrix. The most common pattern is displacing along the normal.</p>
<pre><code class="lang-rust">var local_pos = <span class="hljs-keyword">in</span>.position;
local_pos += <span class="hljs-keyword">in</span>.normal * sin(time) * amplitude;
<span class="hljs-comment">// Now apply model matrix to the modified local_pos...</span>
<span class="hljs-keyword">let</span> world_pos = model * vec4(local_pos, <span class="hljs-number">1.0</span>);
</code></pre>
<h3 id="heading-the-billboard-pattern">The Billboard Pattern</h3>
<p>Ignore the model matrix completely for rotation. Construct the world position directly from the camera's <code>right</code> and <code>up</code> vectors, anchored at the object's world-space center.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Get world center from model matrix's translation column</span>
<span class="hljs-keyword">let</span> center = model[<span class="hljs-number">3</span>].xyz;
<span class="hljs-keyword">let</span> world_pos = center + camera_right * local_pos.x + camera_up * local_pos.y;
</code></pre>
<h3 id="heading-the-normal-correction-rule">The Normal Correction Rule</h3>
<p>If you displace vertices, the lighting will be wrong unless you also update the normals to match the new surface slope. Use analytical derivatives or approximations (perturbation) for this.</p>
<h3 id="heading-the-performance-golden-rule">The Performance Golden Rule</h3>
<p>Avoid <code>if/else</code> statements that depend on per-vertex data (like <code>in.position</code>). This causes threads to diverge and kills performance. Use <code>mix()</code>, <code>step()</code>, or <code>select()</code> for conditional logic instead. Branching on uniforms is safe and fast.</p>
]]></content:encoded></item><item><title><![CDATA[1.8 - Essential Shader Math Concepts]]></title><description><![CDATA[What We're Learning
Until now, we've treated transformation matrices as black boxes - essential tools for getting our models on screen, but with mysterious inner workings. This chapter opens that box.
We are about to master the engine that drives all...]]></description><link>https://blog.hexbee.net/18-essential-shader-math-concepts</link><guid isPermaLink="true">https://blog.hexbee.net/18-essential-shader-math-concepts</guid><category><![CDATA[webgpu]]></category><category><![CDATA[wgsl]]></category><category><![CDATA[bevy]]></category><category><![CDATA[shader]]></category><category><![CDATA[Rust]]></category><dc:creator><![CDATA[Xavier Basty Kjellberg]]></dc:creator><pubDate>Fri, 17 Oct 2025 22:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1762449733976/8ade5df2-59eb-4332-a1d1-96fd6ab82133.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-were-learning">What We're Learning</h2>
<p>Until now, we've treated transformation matrices as black boxes - essential tools for getting our models on screen, but with mysterious inner workings. This chapter opens that box.</p>
<p>We are about to master the engine that drives all 3D graphics: <strong>mathematics</strong>. This isn't a dry theory lesson; it's a creative awakening. Understanding this math is like learning the grammar of a language. Once you know the rules, you can stop reciting phrases and start writing poetry. You gain the power to fix lighting bugs, invent mind-bending visual effects, and build shaders that are not just correct, but elegant and efficient.</p>
<p>Ever wanted to make a billboard sprite that always faces the camera? Or create a shockwave effect that procedurally ripples across your scene? These aren't just buttons in an editor; they are the direct result of applying a few lines of vector and matrix math. We will demystify the core concepts that power every transformation in every shader you will ever write, shifting your perspective from "moving objects" to "transforming space itself."</p>
<p>By the end of this article, you will have a solid, practical understanding of:</p>
<ul>
<li><p><strong>The Dual Role of Vectors:</strong> Understand how a single data structure represents both a position in space and an abstract direction, and why the distinction is crucial.</p>
</li>
<li><p><strong>The Geometric Power of</strong> <code>dot</code> and <code>cross</code>: Uncover the intuitive, geometric meaning behind the two most important vector operations: the <code>dot</code> product ("how aligned are two vectors?") and the <code>cross</code> product ("what's the perpendicular direction?").</p>
</li>
<li><p><strong>Matrices as Transformation Recipes:</strong> See a 4x4 matrix not as a grid of numbers, but as a complete recipe for transforming space, encoding scale, rotation, and translation all in one.</p>
</li>
<li><p><strong>Why Transformation Order is Everything:</strong> Learn why <code>translation * rotation * scale</code> is the universal standard and how reversing the order creates an orbiting effect instead of a local rotation.</p>
</li>
<li><p><strong>The Magic of the 'W' Component:</strong> Demystify homogeneous coordinates and the fourth <code>w</code> component, the clever trick that allows matrices to handle translation and create perspective.</p>
</li>
<li><p><strong>Why Normals Are Special:</strong> Discover why surface normals break under non-uniform scaling and must be transformed with a special "inverse transpose" matrix to keep lighting correct.</p>
</li>
</ul>
<h2 id="heading-part-1-the-building-blocks-vectors">Part 1: The Building Blocks - Vectors</h2>
<p>Before we can transform objects, we need a way to describe their properties in space. In 3D graphics, every position, direction, and orientation is built upon a single, fundamental concept: the <strong>vector</strong>. Mastering vector operations is the first and most crucial step to understanding shader math.</p>
<h3 id="heading-1-introduction-to-vectors">1. Introduction to Vectors</h3>
<h4 id="heading-what-is-a-vector">What is a vector?</h4>
<p>At its core, a vector is a quantity that possesses both <strong>magnitude</strong> (length) and <strong>direction</strong>. Think of it as an arrow in space. It points somewhere, and it has a specific length. Crucially, a pure vector does not have a fixed starting position. The instruction "move 3 units forward and 2 units up" describes a vector; the displacement is the same no matter where you start.</p>
<p>In graphics programming, we use this single concept for two primary, related purposes:</p>
<ol>
<li><p><strong>As a Direction:</strong> To represent things that have an orientation but no specific location. Examples include the direction of a light ray, the direction a camera is facing, or a surface normal (the direction a surface is pointing).</p>
</li>
<li><p><strong>As a Position (or Point):</strong> While mathematically distinct from a free-floating vector, we use the same data structure to store the coordinates of a point in space (like a vertex position). You can visualize this as a <strong>position vector</strong> - an arrow stretching from the world's origin <code>(0,0,0)</code> to that specific point.</p>
</li>
</ol>
<h4 id="heading-vector-components">Vector Components</h4>
<p>We describe vectors numerically using <strong>components</strong>. Each component corresponds to a value along one of the coordinate system's axes.</p>
<ul>
<li><p>A 2D vector <code>(x, y)</code> has two components.</p>
</li>
<li><p>A 3D vector <code>(x, y, z)</code> has three components.</p>
</li>
<li><p>A 4D vector <code>(x, y, z, w)</code> has four components. The <code>w</code> component is special and will become indispensable when we discuss perspective and matrices.</p>
</li>
</ul>
<p>For example, the vector <code>vec3(1.0, 0.0, 0.0)</code> represents a pure direction pointing one unit along the positive X-axis.</p>
<h4 id="heading-representing-vectors-in-wgsl">Representing Vectors in WGSL</h4>
<p>In WGSL, we use built-in types to define vectors of 32-bit floating-point numbers:</p>
<ul>
<li><p><code>vec2&lt;f32&gt;</code>: A two-component vector.</p>
</li>
<li><p><code>vec3&lt;f32&gt;</code>: A three-component vector.</p>
</li>
<li><p><code>vec4&lt;f32&gt;</code>: A four-component vector.</p>
</li>
</ul>
<pre><code class="lang-rust"><span class="hljs-comment">// A 3D vector representing a direction</span>
<span class="hljs-keyword">let</span> light_direction: vec3&lt;<span class="hljs-built_in">f32</span>&gt; = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.5</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.2</span>);

<span class="hljs-comment">// A 4D vector representing a position.</span>
<span class="hljs-comment">// (We'll see why the 'w' is 1.0 for positions later)</span>
<span class="hljs-keyword">let</span> vertex_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt; = vec4&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">10.0</span>, <span class="hljs-number">5.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);

<span class="hljs-comment">// Accessing components is easy</span>
<span class="hljs-keyword">let</span> x_pos = vertex_position.x;
<span class="hljs-keyword">let</span> y_pos = vertex_position.y;

<span class="hljs-comment">// You can also "swizzle" components to create new, smaller vectors.</span>
<span class="hljs-comment">// This is a common and convenient shorthand.</span>
<span class="hljs-keyword">let</span> xy_pos: vec2&lt;<span class="hljs-built_in">f32</span>&gt; = vertex_position.xy;
<span class="hljs-keyword">let</span> xyz_pos: vec3&lt;<span class="hljs-built_in">f32</span>&gt; = vertex_position.xyz;
</code></pre>
<h3 id="heading-2-defining-our-space-right-handed-coordinates">2. Defining Our Space: Right-Handed Coordinates</h3>
<p>Before we can operate on vectors, we must agree on the layout of our 3D world. Which way is "up"? Which way is "forward"? This is defined by the <strong>handedness</strong> of our coordinate system. It's an arbitrary but critical convention, like deciding which side of the road to drive on. If your game engine and your 3D modeling software disagree on handedness, you'll end up with mirrored or upside-down models.</p>
<h4 id="heading-right-handed-system-bevy-blender-vulkan">Right-Handed System (Bevy, Blender, Vulkan)</h4>
<p>This is the standard for Bevy, Vulkan, OpenGL, and Blender. You can determine the axis directions using your <strong>right hand</strong>:</p>
<ol>
<li><p>Point your <strong>index finger</strong> straight out. This is the <strong>Positive X</strong> axis (Right).</p>
</li>
<li><p>Curl your <strong>middle finger</strong> 90 degrees inward. This is the <strong>Positive Y</strong> axis (Up).</p>
</li>
<li><p>Point your <strong>thumb</strong> straight up. This is the <strong>Positive Z</strong> axis (Forward/Out of the screen).</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764378840590/664c7750-8e51-4b35-9512-fb3d9b0bc2db.png" alt class="image--center mx-auto" /></p>
<h4 id="heading-left-handed-system">Left-Handed System</h4>
<p>This system is used by DirectX and Unity. The rule is the same, but you use your <strong>left hand</strong>. The result is that the Z-axis points in the opposite direction (into the screen).</p>
<p>For this entire series, <strong>we will always assume a right-handed coordinate system</strong>. This consistency is what allows operations like the cross product to have a predictable, reliable outcome.</p>
<h3 id="heading-3-basic-vector-operations">3. Basic Vector Operations</h3>
<p>These are the everyday operations you'll perform constantly in shaders.</p>
<h4 id="heading-vector-addition-and-subtraction">Vector Addition and Subtraction</h4>
<p>These operations are performed component-wise, meaning you simply add or subtract the corresponding <code>x</code>, <code>y</code>, and <code>z</code> components.</p>
<ul>
<li><p><code>a + b = (a.x + b.x, a.y + b.y, ...)</code></p>
</li>
<li><p><code>a - b = (a.x - b.x, a.y - b.y, ...)</code></p>
</li>
</ul>
<p>Geometrically, adding <code>a</code> and <code>b</code> is like placing the tail of vector <code>b</code> at the head of vector <code>a</code>. The result is the vector from the tail of <code>a</code> to the new head of <code>b</code>.</p>
<p>Vector subtraction is one of the most useful tools in your arsenal. The expression <code>A - B</code> gives you the vector that points <strong>from point</strong> <code>B</code> to point <code>A</code>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764379696880/8a417d09-d285-4731-99cc-09a09ebb9409.png" alt class="image--center mx-auto" /></p>
<h4 id="heading-scalar-multiplication-and-division">Scalar Multiplication and Division</h4>
<p>Multiplying or dividing a vector by a single number (a <strong>scalar</strong>) scales its magnitude. The operation is component-wise: <code>scalar * v = (scalar * v.x, scalar * v.y, ...)</code>.</p>
<ul>
<li><p>If <code>scalar &gt; 1</code>, the vector gets longer.</p>
</li>
<li><p>If <code>0 &lt; scalar &lt; 1</code>, the vector gets shorter.</p>
</li>
<li><p>If <code>scalar &lt; 0</code>, the vector flips and points in the opposite direction.</p>
</li>
</ul>
<h4 id="heading-vector-normalization">Vector Normalization</h4>
<p>Normalization is the process of adjusting a vector's length to be exactly <code>1</code> while preserving its direction. The result is called a <strong>unit vector</strong>. Unit vectors are essential in graphics because they represent <strong>pure direction</strong>, with magnitude factored out. This is critical for lighting calculations where only the direction to the light matters, not the distance.</p>
<p>In WGSL, you use the <code>normalize()</code> built-in function.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// A vector with an arbitrary length</span>
<span class="hljs-keyword">let</span> v = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">3.0</span>, <span class="hljs-number">4.0</span>, <span class="hljs-number">0.0</span>);

<span class="hljs-comment">// a_unit will be (0.6, 0.8, 0.0), which has a length of 1.0</span>
<span class="hljs-keyword">let</span> a_unit = normalize(v);
</code></pre>
<h4 id="heading-vector-length-magnitude">Vector Length / Magnitude</h4>
<p>The length of a vector is calculated using the Pythagorean theorem: <code>sqrt(x*x + y*y + z*z)</code>. In WGSL, you use the <code>length()</code> function. Normalizing a vector is mathematically equivalent to dividing the vector by its own length: <code>normalize(v)</code> is simply a highly optimized version of <code>v / length(v)</code>.</p>
<h3 id="heading-4-advanced-vector-operations">4. Advanced Vector Operations</h3>
<p>These two operations are the heart of 3D graphics, forming the basis for everything from lighting models to procedural generation.</p>
<h4 id="heading-dot-product">Dot Product</h4>
<p>The dot product takes two vectors and returns a single scalar value. It answers the question: <strong>"How much do these two vectors point in the same direction?"</strong></p>
<p>It measures the alignment, or "agreement," between the two vectors:</p>
<ul>
<li><p><code>dot(a, b)</code> &gt; 0: The angle between the vectors is less than 90°. They point in a generally similar direction.</p>
</li>
<li><p><code>dot(a, b)</code> = 0: The vectors are exactly perpendicular (orthogonal) to each other.</p>
</li>
<li><p><code>dot(a, b)</code> &lt; 0: The angle is greater than 90°. They point in generally opposite directions.</p>
</li>
</ul>
<p>For <strong>normalized (unit) vectors</strong>, the dot product has a powerful, specific meaning: <strong>it gives you the cosine of the angle between them.</strong> This is a cornerstone of nearly all lighting calculations.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764380082004/9284e7eb-659f-4f80-a187-4a84666ec8ff.png" alt class="image--center mx-auto" /></p>
<p>How does the simple formula <code>(a.x*b.x) + (a.y*b.y) + (a.z*b.z)</code> manage to measure alignment? Think of it as calculating an "agreement score" for each axis and summing the results.</p>
<ul>
<li><p>The term <code>a.x * b.x</code> checks for agreement on the X-axis. If both <code>a.x</code> and <code>b.x</code> are positive (or both are negative), their product is positive - they agree. If they have opposite signs, the product is negative - they disagree.</p>
</li>
<li><p>The same logic applies to the Y and Z axes.</p>
</li>
<li><p>The final sum is the net agreement across all three dimensions. A large positive result means high overall agreement, while a large negative result means they point in opposite directions.</p>
</li>
</ul>
<p>This simple component-wise multiplication and sum is a computationally cheap yet powerful way to geometrically project one vector onto another and measure the result.</p>
<p>Multiply the corresponding components of two vectors and sum the results.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> a = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">2.0</span>, <span class="hljs-number">3.0</span>);
<span class="hljs-keyword">let</span> b = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">4.0</span>, <span class="hljs-number">5.0</span>, <span class="hljs-number">6.0</span>);

<span class="hljs-comment">// result = (1*4) + (2*5) + (3*6) = 4 + 10 + 18 = 32.0</span>
<span class="hljs-keyword">let</span> result = dot(a, b);
</code></pre>
<ul>
<li><p><strong>Diffuse Lighting:</strong> How much light should a surface receive? Calculate the dot product of the surface normal and the light direction. A high value means the surface squarely faces the light; a low or negative value means it's angled away or in shadow.</p>
</li>
<li><p><strong>Checking Visibility:</strong> Is an enemy facing the player? Calculate the dot product of the enemy's "forward" vector and the vector pointing from the enemy to the player. If the result is positive, the player is generally in front of the enemy.</p>
</li>
</ul>
<h4 id="heading-cross-product">Cross Product</h4>
<p>The cross product takes two 3D vectors and returns a new 3D vector that is <strong>perpendicular</strong> to both of the inputs. It's how you generate a third direction from two existing ones.</p>
<p>The direction of the resulting vector follows the <strong>right-hand rule</strong>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764380929418/52afae3f-dd32-4bb2-8dc2-486cc04a3ec9.png" alt class="image--center mx-auto" /></p>
<p>To find the direction of <code>cross(a, b)</code> with your right hand:</p>
<ol>
<li><p>Point your index finger in the direction of vector <code>a</code>.</p>
</li>
<li><p>Curl your middle finger in the direction of vector <code>b</code>.</p>
</li>
<li><p>Your thumb will now point in the direction of <code>cross(a, b)</code>.</p>
</li>
</ol>
<p><strong>Crucially, the order matters!</strong> <code>cross(a, b)</code> points in the exact opposite direction of <code>cross(b, a)</code>.</p>
<p>The cross product's formula might seem arbitrary, but it's a clever algebraic construction designed to solve a very specific puzzle: find a new vector <code>c</code> that is perpendicular to both <code>a</code> and <code>b</code>.</p>
<p>In vector math, "perpendicular" means the dot product is zero. Therefore, the formula for <code>cross(a, b)</code> was engineered to produce a vector <code>c</code> that is guaranteed to satisfy two conditions:</p>
<ol>
<li><p><code>dot(c, a) = 0</code></p>
</li>
<li><p><code>dot(c, b) = 0</code></p>
</li>
</ol>
<p>The formula achieves this by mixing the components of the input vectors in a very particular way (for example, the <code>z</code> component of the result, <code>c.z</code>, is calculated as <code>a.x*b.y - a.y*b.x</code>). While you don't need to memorize the formula, understanding that it's a purpose-built solution to the "find a perpendicular vector" problem is key.</p>
<p>The formula mixes components in a specific way to mathematically guarantee the result is perpendicular to both inputs. You don't need to memorize it, just use the built-in function.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> a = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>); <span class="hljs-comment">// Positive X-axis</span>
<span class="hljs-keyword">let</span> b = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>); <span class="hljs-comment">// Positive Y-axis</span>

<span class="hljs-comment">// c will be (0.0, 0.0, 1.0) -&gt; the Positive Z-axis,</span>
<span class="hljs-comment">// which is perpendicular to both X and Y in a right-handed system.</span>
<span class="hljs-keyword">let</span> c = cross(a, b);
</code></pre>
<p><strong>Note</strong>: The cross product is only defined for <code>vec3</code>.</p>
<ul>
<li><p><strong>Calculating Normals:</strong> If you have two vectors representing the edges of a triangle on a mesh, you can use the cross product to calculate the normal vector for that triangle's surface.</p>
</li>
<li><p><strong>Building a Coordinate System:</strong> A fundamental technique for orienting objects. If you know an object's "forward" direction and its desired "up" direction, you can use a cross product to find its "right" direction (<code>right = cross(forward, up)</code>). This gives you a complete, stable 3D orientation, essential for cameras and character controllers.</p>
</li>
</ul>
<h2 id="heading-part-2-transforming-space-matrices">Part 2: Transforming Space - Matrices</h2>
<p>Now that we understand vectors, we can learn how to manipulate them. We need a tool that can rotate, scale, and move our vectors to position objects in a 3D scene. That tool is the <strong>matrix</strong>. It's best to think of a matrix not as a grid of numbers, but as a <strong>description of a transformed coordinate system</strong>.</p>
<h3 id="heading-5-introduction-to-matrices">5. Introduction to Matrices</h3>
<h4 id="heading-what-is-a-matrix">What is a matrix?</h4>
<p>A matrix is a rectangular grid of numbers, arranged in rows and columns. In 3D graphics, we primarily use 4x4 matrices. Forget thinking of it as just a table of data and start thinking of it as a <strong>transformation recipe</strong>. It's a compact, powerful set of instructions that tells the GPU how to take an input vector and produce a new, transformed output vector.</p>
<p>The first three columns of a typical transformation matrix define the new directions for the X, Y, and Z axes, respectively. The fourth column defines the new origin (translation).</p>
<h4 id="heading-representing-matrices-in-wgsl">Representing Matrices in WGSL</h4>
<p>Just like vectors, WGSL has built-in types for matrices:</p>
<ul>
<li><p><code>mat2x2&lt;f32&gt;</code>: A 2x2 matrix (2 columns, 2 rows).</p>
</li>
<li><p><code>mat3x3&lt;f32&gt;</code>: A 3x3 matrix.</p>
</li>
<li><p><code>mat4x4&lt;f32&gt;</code>: A 4x4 matrix, the standard for 3D transformations.</p>
</li>
</ul>
<h4 id="heading-wgsl-matrix-construction-column-major-order">WGSL Matrix Construction (Column-Major Order)</h4>
<p>This is a critical concept that often trips up newcomers: <strong>WGSL, like OpenGL and Vulkan, is column-major.</strong> This means when you construct a matrix from a list of numbers, you are defining the columns one by one, from top to bottom.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Constructing a 4x4 matrix in WGSL</span>
<span class="hljs-keyword">let</span> my_matrix = mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;(
    <span class="hljs-comment">// Column 0</span>
    <span class="hljs-number">1.0</span>, <span class="hljs-number">2.0</span>, <span class="hljs-number">3.0</span>, <span class="hljs-number">4.0</span>,
    <span class="hljs-comment">// Column 1</span>
    <span class="hljs-number">5.0</span>, <span class="hljs-number">6.0</span>, <span class="hljs-number">7.0</span>, <span class="hljs-number">8.0</span>,
    <span class="hljs-comment">// Column 2</span>
    <span class="hljs-number">9.0</span>, <span class="hljs-number">10.0</span>, <span class="hljs-number">11.0</span>, <span class="hljs-number">12.0</span>,
    <span class="hljs-comment">// Column 3</span>
    <span class="hljs-number">13.0</span>, <span class="hljs-number">14.0</span>, <span class="hljs-number">15.0</span>, <span class="hljs-number">16.0</span>
);
</code></pre>
<p>In standard mathematical notation (which is often written row-by-row), this matrix looks like this:</p>
<pre><code class="lang-plaintext">┌                        ┐
│  1.0  5.0   9.0  13.0  │  &lt;- Row 0
│  2.0  6.0  10.0  14.0  │  &lt;- Row 1
│  3.0  7.0  11.0  15.0  │  &lt;- Row 2
│  4.0  8.0  12.0  16.0  │  &lt;- Row 3
└                        ┘
   ^    ^     ^     ^
 Col0  Col1  Col2  Col 3
</code></pre>
<p>Notice how the first four numbers in the constructor <code>(1.0, 2.0, 3.0, 4.0)</code> became the first <strong>column</strong>, not the first row.</p>
<h4 id="heading-accessing-matrix-elements">Accessing Matrix Elements</h4>
<p>You can access an entire column vector using a single index:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Accessing Column 1</span>
<span class="hljs-comment">// result will be vec4&lt;f32&gt;(5.0, 6.0, 7.0, 8.0)</span>
<span class="hljs-keyword">let</span> col1 = my_matrix[<span class="hljs-number">1</span>];
</code></pre>
<p>To access an individual element, you use a second index. The syntax is <code>matrix[columnIndex][rowIndex]</code>.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Accessing the element in Column 1, Row 2</span>
<span class="hljs-comment">// This is the value 11.0 in our example matrix</span>
<span class="hljs-keyword">let</span> element_1_2 = my_matrix[<span class="hljs-number">2</span>][<span class="hljs-number">1</span>]; <span class="hljs-comment">// This was a mistake in the original article, it should be 10.0</span>
<span class="hljs-comment">// This is the value 7.0 in our example matrix</span>
<span class="hljs-comment">// let element_1_2 = my_matrix[1][2];</span>
</code></pre>
<p>Remembering this <code>[col][row]</code> indexing is essential for manually reading or manipulating matrix elements.</p>
<h3 id="heading-6-matrix-vector-multiplication-applying-transformations">6. Matrix-Vector Multiplication: Applying Transformations</h3>
<p>The operation <code>matrix * vector</code> is the cornerstone of shader math. Its purpose is to take a vector defined in one coordinate system and find its new coordinates after the system has been transformed by the matrix.</p>
<h4 id="heading-the-conceptual-model-a-linear-combination">The Conceptual Model: A Linear Combination</h4>
<p>Think of a vector's components, like <code>v = (2, 3, 0)</code>, as a set of instructions: "Starting from the origin, move 2 units along the standard X-axis, then 3 units along the standard Y-axis." These instructions are relative to a standard, untransformed space.</p>
<p>A matrix <code>M</code> describes a new, transformed coordinate system with its own basis vectors (a new X-axis, Y-axis, etc.). The multiplication <code>M * v</code> answers the question:</p>
<blockquote>
<p>If we follow the exact same instructions (2, 3, 0) but use the new axes defined by matrix M, where do we end up?</p>
</blockquote>
<p>The calculation <code>M * v</code> is a <strong>linear combination</strong> of the matrix's columns, using the vector's components as weights:</p>
<p><code>result = (v.x * M's_X_axis_column) + (v.y * M's_Y_axis_column) + (v.z * M's_Z_axis_column) + (v.w * M's_Origin_column)</code></p>
<p>This provides a powerful geometric intuition: you are remapping a point from an old grid onto a new, transformed grid.</p>
<h4 id="heading-the-mechanical-process">The Mechanical Process</h4>
<p>While the linear combination is the <em>what</em>, the GPU needs a concrete algorithm for the <em>how</em>. To find each component of the output vector, it calculates the dot product of the input vector and one of the matrix's <strong>rows</strong>.</p>
<ul>
<li><p><code>result.x = dot(v, M's_first_row)</code></p>
</li>
<li><p><code>result.y = dot(v, M's_second_row)</code></p>
</li>
<li><p><code>result.z = dot(v, M's_third_row)</code></p>
</li>
<li><p><code>result.w = dot(v, M's_fourth_row)</code></p>
</li>
</ul>
<p>This "sum of products" procedure is the algorithm that computes the conceptual linear combination we described above. It's a different way of looking at the same calculation, but one that is much more suited for hardware implementation. You will simply use the <code>*</code> operator, but now you know both the "why" (linear combination) and the "how" (row-vector dot products) of the operation.</p>
<p>Thankfully, you never have to write this manually. The GPU's hardware is built to do this incredibly fast. You just write:</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> transformed_vector = my_matrix * v;
</code></pre>
<h4 id="heading-homogeneous-coordinates-the-magic-of-w">Homogeneous Coordinates (The Magic of W)</h4>
<p>You might wonder why we use 4D vectors and 4x4 matrices for 3D graphics. The fourth component, <code>w</code>, is called a <strong>homogeneous coordinate</strong>. It's a clever mathematical trick that lets us perform all our transformations within a single, unified system.</p>
<p>The value of <code>w</code> distinguishes between two fundamental concepts:</p>
<ul>
<li><p>For a <strong>Position</strong> (a point in space), we set <code>w = 1.0</code>. We want a point to be affected by the entire transformation, including translation.</p>
</li>
<li><p>For a <strong>Direction</strong> (a vector with no location), we set <code>w = 0.0</code>. A direction should be affected by rotation and scale, but moving the entire object shouldn't change the direction of its surface normals or light rays.</p>
</li>
</ul>
<p>Look at the last column of a standard transformation matrix - the translation part <code>(tx, ty, tz, 1)</code>. When we multiply, the <code>w</code> component of our vector determines if this translation is applied:</p>
<pre><code class="lang-plaintext">// For a Position (w=1):
new_x = (rotation/scale part) + (tx * 1.0); // Translation IS applied

// For a Direction (w=0):
new_x = (rotation/scale part) + (tx * 0.0); // Translation IS IGNORED
</code></pre>
<p>This elegant system is fundamental to how 3D graphics pipelines work.</p>
<h3 id="heading-7-basic-transformation-matrices">7. Basic Transformation Matrices</h3>
<p>Let's see what the most common transformation recipes look like.</p>
<h4 id="heading-identity-matrix">Identity Matrix</h4>
<p>The identity matrix is the equivalent of the number 1 in multiplication. It does nothing. Multiplying any vector by the identity matrix gives you the same vector back. It has 1s on the diagonal and 0s everywhere else. Conceptually, its basis vectors are the standard X, Y, and Z axes, and its origin is at <code>(0,0,0)</code>.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">let</span> identity = mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;(
    <span class="hljs-comment">// Col 0: X-axis is (1,0,0)</span>
    <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>,
    <span class="hljs-comment">// Col 1: Y-axis is (0,1,0)</span>
    <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>,
    <span class="hljs-comment">// Col 2: Z-axis is (0,0,1)</span>
    <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>,
    <span class="hljs-comment">// Col 3: Origin is at (0,0,0)</span>
    <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>
);
</code></pre>
<p><strong>When is it useful?</strong></p>
<ul>
<li><p><strong>Initialization:</strong> It's the perfect starting point for building a more complex transformation.</p>
</li>
<li><p><strong>Default Value:</strong> It serves as a safe default for an optional transformation.</p>
</li>
<li><p><strong>Resetting:</strong> You can reset an object's transformation by setting its matrix back to the identity.</p>
</li>
</ul>
<h4 id="heading-translation-matrix">Translation Matrix</h4>
<p>A translation matrix simply moves a point. The translation values <code>(tx, ty, tz)</code> go in the fourth column, effectively setting a new origin. The basis vectors (the first three columns) remain unchanged.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Move 5 units right (X), 3 units up (Y)</span>
<span class="hljs-keyword">let</span> tx = <span class="hljs-number">5.0</span>; <span class="hljs-keyword">let</span> ty = <span class="hljs-number">3.0</span>; <span class="hljs-keyword">let</span> tz = <span class="hljs-number">0.0</span>;
<span class="hljs-keyword">let</span> translation = mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;(
    <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-comment">// X-axis</span>
    <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-comment">// Y-axis</span>
    <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-comment">// Z-axis</span>
    tx,  ty,  tz,  <span class="hljs-number">1.0</span>  <span class="hljs-comment">// New origin</span>
);
</code></pre>
<h4 id="heading-scale-matrix">Scale Matrix</h4>
<p>A scale matrix changes the size of an object by stretching or shrinking the basis vectors. The scale factors for each axis go along the diagonal of the first three columns.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Scale 2x in X, 0.5x in Y, 1x in Z (no change)</span>
<span class="hljs-keyword">let</span> sx = <span class="hljs-number">2.0</span>; <span class="hljs-keyword">let</span> sy = <span class="hljs-number">0.5</span>; <span class="hljs-keyword">let</span> sz = <span class="hljs-number">1.0</span>;
<span class="hljs-keyword">let</span> scale = mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;(
    sx,  <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-comment">// X-axis is now 2 units long</span>
    <span class="hljs-number">0.0</span>, sy,  <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-comment">// Y-axis is now 0.5 units long</span>
    <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, sz,  <span class="hljs-number">0.0</span>, <span class="hljs-comment">// Z-axis is unchanged</span>
    <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>
);
</code></pre>
<h4 id="heading-rotation-matrices">Rotation Matrices</h4>
<p>Rotation matrices pivot the basis vectors around an axis without changing their length. They are more complex, using trigonometric functions <code>sin</code> and <code>cos</code> to mix coordinate values in a way that corresponds to circular motion. A rotation matrix is simply a container for these new, rotated basis vectors.</p>
<p>Here are the standard matrices for performing a counter-clockwise rotation around the cardinal axes by an angle <code>θ</code> (theta).</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Common setup for all rotation matrices</span>
<span class="hljs-keyword">let</span> angle = ...; <span class="hljs-comment">// The angle of rotation, in radians</span>
<span class="hljs-keyword">let</span> c = cos(angle);
<span class="hljs-keyword">let</span> s = sin(angle);

<span class="hljs-comment">// Rotation around the X-axis</span>
<span class="hljs-keyword">let</span> rotation_x = mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;(
    <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>,
    <span class="hljs-number">0.0</span>,  c,   s,  <span class="hljs-number">0.0</span>,
    <span class="hljs-number">0.0</span>, -s,   c,  <span class="hljs-number">0.0</span>,
    <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>
);

<span class="hljs-comment">// Rotation around the Y-axis</span>
<span class="hljs-keyword">let</span> rotation_y = mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;(
     c,  <span class="hljs-number">0.0</span>, -s,  <span class="hljs-number">0.0</span>,
    <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>,
     s,  <span class="hljs-number">0.0</span>,  c,  <span class="hljs-number">0.0</span>,
    <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>
);

<span class="hljs-comment">// Rotation around the Z-axis (standard 2D rotation)</span>
<span class="hljs-keyword">let</span> rotation_z = mat4x4&lt;<span class="hljs-built_in">f32</span>&gt;(
     c,   s,  <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>,
    -s,   c,  <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>,
    <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>,
    <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>
);
</code></pre>
<h3 id="heading-8-matrix-matrix-multiplication-combining-transformations">8. Matrix-Matrix Multiplication: Combining Transformations</h3>
<p>What if you want to scale an object, then rotate it, and finally move it into position? You combine these transformations by multiplying their matrices together. The result is a single matrix that contains the entire sequence of transformations, which is far more efficient than applying them one by one.</p>
<h4 id="heading-order-matters">Order Matters!</h4>
<p>This is the most important rule of combining transformations: <strong>Matrix multiplication is not commutative</strong>. This means <code>A * B</code> is not the same as <code>B * A</code>.</p>
<p>When you multiply matrices, the transformations are applied from <strong>right to left</strong>. This works just like function composition in mathematics, where <code>f(g(x))</code> applies <code>g</code> first, then <code>f</code>.</p>
<p><code>final_transform = translation * rotation * scale;</code></p>
<p>When you apply this to a vector <code>v</code>, it is evaluated as:</p>
<p><code>final_vector = (translation * (rotation * (scale * v)))</code></p>
<ol>
<li><p>First, the <code>scale</code> matrix is applied to the vector.</p>
</li>
<li><p>Then, the <code>rotation</code> matrix is applied to that scaled result.</p>
</li>
<li><p>Finally, the <code>translation</code> matrix is applied to the scaled-and-rotated result.</p>
</li>
</ol>
<p>Let's see why this is critical:</p>
<p><strong>Correct Order:</strong> <code>translation * rotation * scale</code></p>
<p>Here, the object rotates around its own local origin, and then the fully-rotated object is moved to its final position. This is usually what you want.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764381256680/673ab17f-a801-48ef-8c75-c64939ed7177.png" alt class="image--center mx-auto" /></p>
<p><strong>Incorrect Order:</strong> <code>rotation * translation</code></p>
<p>If you reverse the order, the object first moves away from the world origin, and then it rotates around that distant world origin. This makes the object orbit, which is almost never the desired behavior for positioning a single object.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764381371318/8974f1cf-650a-4bea-ac5d-ec4df3b34479.png" alt class="image--center mx-auto" /></p>
<p>The universal order for a "model" matrix (which positions a single model in the world) is <code>translation * rotation * scale</code>. Read from right-to-left, this correctly:</p>
<ol>
<li><p><strong>Scales</strong> the object in place (around its local origin).</p>
</li>
<li><p><strong>Rotates</strong> the scaled object (around its local origin).</p>
</li>
<li><p><strong>Translates</strong> the scaled-and-rotated object to its final position in the world.</p>
</li>
</ol>
<h2 id="heading-part-3-advanced-matrix-concepts-amp-practical-applications">Part 3: Advanced Matrix Concepts &amp; Practical Applications</h2>
<p>With a solid grasp of how matrices are built and combined, we can now assemble them into the full pipeline used by GPUs to render a 3D scene. This part covers the journey of a vertex from a model's local space all the way to your screen and the special matrix math required along the way.</p>
<h3 id="heading-9-the-model-view-projection-mvp-chain">9. The Model-View-Projection (MVP) Chain</h3>
<p>The transformation of a single vertex from its position in a 3D model file to the final 2D pixel on your screen is a journey through multiple coordinate spaces. This journey is managed by a sequence of three key matrices, known as the <strong>Model-View-Projection (MVP)</strong> chain.</p>
<p>The four coordinate spaces on this journey are:</p>
<ol>
<li><p><strong>Local Space (or Model Space):</strong> The coordinates of a vertex relative to the center of its own model. This is how vertices are defined in a tool like Blender, where the model's pivot is at <code>(0,0,0)</code>.</p>
</li>
<li><p><strong>World Space:</strong> The coordinates of a vertex after its model has been positioned, rotated, and scaled within the larger 3D scene. All objects in the scene share this common space.</p>
</li>
<li><p><strong>View Space (or Camera Space):</strong> The coordinates of a vertex from the perspective of the camera. In this space, the camera is at the origin <code>(0,0,0)</code>, looking down its own -Z axis. Everything else in the world is moved and rotated to be relative to the camera.</p>
</li>
<li><p><strong>Clip Space:</strong> The final coordinate system before the rasterizer. This is a normalized box (typically a cube from -1 to +1 on all axes) where anything outside is "clipped" and discarded. This space also encodes perspective information in the <code>w</code> component.</p>
</li>
</ol>
<p>The MVP chain uses three matrices to manage these transitions:</p>
<ul>
<li><p><strong>Model Matrix</strong>: Transforms vertices from <strong>Local Space → World Space</strong>. This is the <code>translation * rotation * scale</code> matrix we built previously. Bevy provides this via functions like <code>mesh_functions::mesh_position_local_to_world</code>.</p>
</li>
<li><p><strong>View Matrix</strong>: Transforms vertices from <strong>World Space → View Space</strong>. This matrix positions and orients the entire world to be seen from the camera's perspective.</p>
</li>
<li><p><strong>Projection Matrix</strong>: Transforms vertices from <strong>View Space → Clip Space</strong>. This matrix applies perspective, making distant objects appear smaller, and prepares the coordinates for the screen.</p>
</li>
</ul>
<p>In a Bevy vertex shader, you typically get the final clip-space position by calling a single helper function that combines all these matrices for you:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// Bevy provides a function that handles the full MVP transformation.</span>
<span class="hljs-comment">// It takes the local position and applies the Model, View, and Projection</span>
<span class="hljs-comment">// matrices in the correct order.</span>
<span class="hljs-keyword">let</span> clip_position = position_world_to_clip(world_position.xyz);
</code></pre>
<p>Why this specific order? Because, reading from right to left, it perfectly mirrors the vertex's journey:</p>
<p><code>clip_position = Projection * (View * (Model * local_position))</code></p>
<ol>
<li><p><code>Model * local_position</code>: First, take the vertex from local space and position the object in the world. The result is <code>world_position</code>.</p>
</li>
<li><p><code>View * world_position</code>: Next, take that world position and view it from the camera's perspective. The result is <code>view_position</code>.</p>
</li>
<li><p><code>Projection * view_position</code>: Finally, apply perspective to get the final <code>clip_position</code>.</p>
</li>
</ol>
<h3 id="heading-10-projection-matrices-creating-depth">10. Projection Matrices: Creating Depth</h3>
<p>The projection matrix is responsible for how your 3D scene is flattened onto your 2D screen.</p>
<h4 id="heading-orthographic-projection">Orthographic Projection</h4>
<p>This projection creates a "flat" view, mapping 3D coordinates directly to the screen without any perspective. Objects have the same size regardless of how near or far they are from the camera. This is the standard for 2D games, user interface elements, or technical diagrams where preserving parallel lines and relative sizes is essential.</p>
<p>Mathematically, an orthographic matrix manipulates the <code>x, y, z</code> coordinates but always sets the output <code>w</code> to <code>1.0</code>. When the GPU performs the automatic <strong>Perspective Divide</strong> (dividing <code>x, y, z</code> by <code>w</code>), dividing by <code>1</code> changes nothing. The vertex's distance from the camera has no effect on its final screen size.</p>
<h4 id="heading-perspective-projection">Perspective Projection</h4>
<p>This projection mimics how human eyes and cameras work, making distant objects appear smaller. The magic of a perspective matrix is how it manipulates the w component.</p>
<p>Here is what a typical perspective matrix looks like conceptually:</p>
<pre><code class="lang-plaintext">┌                                 ┐
│ scale_x    0        0       0   │
│    0    scale_y     0       0   │
│    0       0      f(z,n)  g(z,n)│  &lt;- Remaps Z for the depth buffer
│    0       0       -1       0   │  &lt;- The secret sauce!
└                                 ┘
</code></pre>
<p>The key is that the fourth row is <code>(0, 0, -1, 0)</code>. Let's see what happens when we multiply a view-space position <code>(x, y, z, 1)</code> by this matrix. When we calculate the final <code>w</code> component of the output, we get:</p>
<p><code>w_clip = (0 * x) + (0 * y) + (-1 * z) + (0 * 1) = -z</code></p>
<p>The <code>w</code> component of our clip-space position is now equal to the <strong>negative of its distance from the camera!</strong></p>
<p>After your vertex shader runs, the GPU performs a non-optional step called the <strong>Perspective Divide</strong>. It automatically divides the <code>x</code>, <code>y</code>, and <code>z</code> components of the clip space position by its <code>w</code> component:</p>
<ul>
<li><p><code>screen_x = x_clip / w_clip</code></p>
</li>
<li><p><code>screen_y = y_clip / w_clip</code></p>
</li>
<li><p><code>screen_z = z_clip / w_clip</code> (This value is used for the depth buffer)</p>
</li>
</ul>
<p>Since <code>w_clip</code> is the distance (<code>-z</code>), dividing by it makes objects that are farther away (larger <code>z</code>) have smaller final screen coordinates. This single, automatic division is how 3D perspective is achieved.</p>
<h3 id="heading-11-matrix-inverse-undoing-transformations">11. Matrix Inverse: Undoing Transformations</h3>
<p>For every transformation matrix <code>M</code> that performs an operation (like rotating 45°), there often exists an <strong>inverse matrix</strong>, written as <code>inverse(M)</code>, that does the exact opposite (rotates -45°).</p>
<p>If <code>M</code> transforms point <code>A</code> to point <code>B</code>, then <code>inverse(M)</code> transforms point <code>B</code> back to point <code>A</code>. Multiplying a matrix by its inverse results in the identity matrix: <code>M * inverse(M) = Identity</code>.</p>
<p><strong>When do you need an inverse matrix?</strong></p>
<ul>
<li><p><strong>Calculating the View Matrix:</strong> The view matrix transforms the world so it's relative to the camera. This is the same as transforming the camera by the inverse of its own world position and rotation. <code>view_matrix = inverse(camera_world_matrix)</code>. This is why cameras seem to work "backwards."</p>
</li>
<li><p><strong>World Space to Local Space:</strong> To find out how a world-space effect (like an explosion) affects a specific model, you need to bring the explosion's position into the model's local space. <code>local_pos = inverse(model_matrix) * world_pos</code>.</p>
</li>
<li><p><strong>Normal Transformations:</strong> The inverse is a key part of the special matrix used to correctly transform surface normals.</p>
</li>
</ul>
<p><strong>Performance Note</strong>: Calculating a matrix inverse is an expensive operation. Avoid doing it in a shader if at all possible. It should be pre-computed on the CPU and passed as a uniform.</p>
<h3 id="heading-12-the-determinant-transformation-properties">12. The Determinant: Transformation Properties</h3>
<p>The <strong>determinant</strong> of a matrix is a single number that reveals key properties of the transformation it represents. While you rarely calculate it yourself in a shader, its geometric meaning is very insightful.</p>
<h4 id="heading-geometric-meaning">Geometric Meaning</h4>
<h5 id="heading-volume-change">Volume Change</h5>
<p>The absolute value, <code>abs(det(M))</code>, tells you by what factor the volume of any shape is scaled.</p>
<ul>
<li><p><code>|det| = 1</code>: Preserves volume (e.g., a pure rotation).</p>
</li>
<li><p><code>|det| &gt; 1</code>: Expands volume (e.g., scaling up).</p>
</li>
<li><p><code>|det| &lt; 1</code>: Shrinks volume (e.g., scaling down).</p>
</li>
<li><p><code>det = 0</code>: Collapses volume to zero (e.g., squashing a 3D object into a 2D plane). A matrix with a determinant of 0 is called <strong>singular</strong> and cannot be inverted because information has been lost.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764381688910/ba7b8bcc-1812-4815-836d-b203b8d77b1f.png" alt class="image--center mx-auto" /></p>
<h5 id="heading-orientation-handedness">Orientation (Handedness)</h5>
<p>The sign of the determinant tells you if the transformation has "flipped" or mirrored the coordinate system.</p>
<ul>
<li><p><code>det &gt; 0</code>: Preserves orientation. A right-handed coordinate system stays right-handed.</p>
</li>
<li><p><code>det &lt; 0</code>: Flips orientation (a mirror image). A right-handed system becomes left-handed. This is important for correctly rendering mirrored objects.</p>
</li>
</ul>
<h3 id="heading-13-normal-transformation-why-normals-are-special">13. Normal Transformation: Why Normals Are Special</h3>
<p>A surface normal is a vector that points perpendicular to a surface, and it is essential for lighting calculations. When we transform a model, we must also transform its normals. However, there's a catch: <strong>you cannot simply multiply a normal by the model matrix.</strong></p>
<h4 id="heading-the-problem">The Problem</h4>
<p>This naive approach works for uniform scaling and rotation, but it breaks as soon as you apply <strong>non-uniform scaling</strong>. If you squash a sphere horizontally, simply scaling its normals by the same amount will cause them to no longer be perpendicular to the surface.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764381905703/21c39edf-0f32-45cc-bd35-de820adb102f.png" alt class="image--center mx-auto" /></p>
<h4 id="heading-the-solution-the-normal-matrix">The Solution: The Normal Matrix</h4>
<p>To transform normals correctly under all conditions, you must multiply them by the <strong>normal matrix</strong>, which is the <strong>inverse transpose</strong> of the upper 3x3 part of the model matrix.</p>
<p><code>normal_matrix = transpose(inverse(mat3x3(model_matrix)))</code></p>
<p>While the math behind why the inverse transpose works is complex, the rule is simple: <strong>Always use the normal matrix to transform normals.</strong></p>
<p>In practice, you never calculate this in a shader. The CPU is much better suited for it. Bevy calculates the final world-space normal for you and provides it through a helper function.</p>
<pre><code class="lang-rust"><span class="hljs-comment">// This is the correct way to transform a normal in Bevy's WGSL shaders.</span>
<span class="hljs-comment">// This function internally uses the inverse transpose of the model matrix.</span>
<span class="hljs-keyword">let</span> world_normal = mesh_functions::mesh_normal_local_to_world(
    local_normal,
    instance_index <span class="hljs-comment">// Needed for instanced rendering</span>
);
</code></pre>
<h2 id="heading-part-4-performance-considerations">Part 4: Performance Considerations</h2>
<p>Understanding the theory is one thing, but applying it efficiently is just as important. Shader math, especially matrix operations, happens millions or even billions of times per second. Writing efficient code is key.</p>
<h3 id="heading-14-performance-hierarchy">14. Performance Hierarchy</h3>
<p>Not all math operations are created equal. Some are significantly more "expensive" for the GPU to compute than others. A rough hierarchy from most to least expensive is:</p>
<ul>
<li><p><strong>Very Expensive (Avoid in shaders if possible):</strong></p>
<ul>
<li><p><strong>Matrix Inversion</strong> <code>inverse(m)</code>: A computationally intensive operation that is significantly slower than multiplication.</p>
</li>
<li><p><strong>Determinant</strong> <code>determinant(m)</code>: Also complex, though generally faster than a full inversion.</p>
</li>
</ul>
</li>
<li><p><strong>Moderately Expensive (Use in Vertex Shader, Avoid in Fragment Shader):</strong></p>
<ul>
<li><strong>Matrix Multiplication:</strong> While fast, multiplying several matrices per-pixel in a fragment shader is extremely costly because it runs for every single pixel of an object. The vertex shader, by contrast, only runs for each vertex.</li>
</ul>
</li>
<li><p><strong>Cheap (Fast Everywhere):</strong></p>
<ul>
<li><p><strong>Vector-Matrix Multiplication:</strong> A core GPU capability, highly optimized in hardware.</p>
</li>
<li><p><strong>Vector Operations (</strong><code>dot</code>, <code>cross</code>, <code>normalize</code>): These are trivial for the GPU and are considered very cheap.</p>
</li>
<li><p><strong>Basic Arithmetic (</strong><code>+</code>, <code>-</code>, <code>*</code>, <code>/</code>): The fastest operations available.</p>
</li>
</ul>
</li>
</ul>
<h3 id="heading-15-optimization-tips">15. Optimization Tips</h3>
<ol>
<li><p><strong>Pre-compute on the CPU:</strong> This is the golden rule. Any matrix that is constant for an entire object's draw call (<code>model</code>, view, <code>projection</code>, <code>mvp</code>, <code>normal_matrix</code>) should be calculated <strong>once per frame on the CPU</strong> and sent to the GPU as a uniform. Bevy and other game engines do this for you automatically. Your job is to leverage the results they provide.</p>
</li>
<li><p><strong>Do Math in the Vertex Shader:</strong> Always perform transformations and normal calculations in the vertex shader. The results can then be passed to the fragment shader as interpolated values. This is fundamentally more efficient than re-calculating the same values for every pixel.</p>
</li>
<li><p><strong>Use Special Cases:</strong> For a rotation-only matrix, its inverse is its <strong>transpose</strong>: <code>inverse(rot) = transpose(rot)</code>. A transpose is computationally trivial - it just reorders elements. If you know you are only dealing with rotation, this is a huge optimization over a general <code>inverse()</code>.</p>
</li>
<li><p><strong>Normalize Only When Needed:</strong> If you know your transformation matrix is <strong>orthonormal</strong> (i.e., it only contains rotation and uniform scale), the transformed normal will still have a length of 1, so you don't need to normalize it again. If there is any non-uniform scaling involved, you must normalize the result. When in doubt, normalizing is the safe option.</p>
</li>
</ol>
<hr />
<h2 id="heading-part-5-complete-example-transformation-visualizer">Part 5: Complete Example - Transformation Visualizer</h2>
<p>Let's bring these abstract mathematical concepts to life. Theory is essential, but seeing the direct visual consequences of these rules is what truly builds intuition. This interactive demo is a visual playground for shader math, designed to prove why the principles we've discussed are not just academic, but critical for creating correct and compelling graphics.</p>
<h3 id="heading-our-goal">Our Goal</h3>
<p>We will create a single, powerful material that can switch between several visualization modes. Each mode uses the shader to color a sphere based on a different mathematical concept, allowing us to see the effects of the dot product, the determinant, the normal matrix, and transformation order in real-time.</p>
<h3 id="heading-what-this-project-demonstrates">What This Project Demonstrates</h3>
<ul>
<li><p><strong>Normal Matrix vs. Naive Transform:</strong> Visually proves why you must use the correct normal matrix for lighting when non-uniform scaling is applied by highlighting the incorrect areas in bright orange.</p>
</li>
<li><p><strong>Dot Product as Lighting:</strong> Shows a direct, real-time visualization of <code>dot(normal, light_dir)</code> as a light source orbits the object, forming the basis of all diffuse lighting.</p>
</li>
<li><p><strong>Determinant as Volume &amp; Orientation:</strong> Colors the object based on whether its volume is being compressed or expanded and adds a visual "glitch" effect when its orientation is flipped (mirrored).</p>
</li>
<li><p><strong>The "Orbit vs. Rotate" Problem:</strong> Clearly demonstrates why <code>translation * rotation</code> is the correct order by showing the classic orbiting mistake that happens when the order is reversed.</p>
</li>
</ul>
<h3 id="heading-the-shader-assetsshadersd0108transformdemowgsl">The Shader (<code>assets/shaders/d01_08_transform_demo.wgsl</code>)</h3>
<p>This is where all the logic lives. The shader uses a <code>demo_mode</code> uniform to control its behavior.</p>
<p>The <strong>vertex shader</strong> is responsible for the core mathematical calculations for each mode. It calculates different values and passes them to the fragment shader via the VertexOutput struct:</p>
<ul>
<li><p>In <strong>Mode 0</strong>, it calculates two versions of the normal: the <code>naive_normal</code> (incorrectly transformed) and the <code>world_normal</code> (correctly transformed) so they can be compared.</p>
</li>
<li><p>In <strong>Mode 2</strong>, it calculates the <code>determinant</code> of the final transformation matrix.</p>
</li>
<li><p>In <strong>Mode 3</strong>, it deliberately applies transformations in the wrong order - translate first, then rotate - to create an orbiting effect.</p>
</li>
</ul>
<p>The <strong>fragment shader</strong> then uses this data to color the pixels in a way that visualizes the underlying math. It highlights the error between the two normals in Mode 0, uses the determinant to select colors and effects in Mode 2, and so on.</p>
<pre><code class="lang-rust">#import bevy_pbr::mesh_functions
#import bevy_pbr::view_transformations::position_world_to_clip

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">TransformDemoMaterial</span></span> {
    demo_mode: <span class="hljs-built_in">u32</span>,
    time: <span class="hljs-built_in">f32</span>,
    custom_scale: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
}

@group(<span class="hljs-number">2</span>) @binding(<span class="hljs-number">0</span>)
var&lt;uniform&gt; material: TransformDemoMaterial;

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexInput</span></span> {
    @builtin(instance_index) instance_index: <span class="hljs-built_in">u32</span>,
    @location(<span class="hljs-number">0</span>) position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
}

<span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">VertexOutput</span></span> {
    @builtin(position) clip_position: vec4&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">0</span>) world_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">1</span>) naive_normal: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">2</span>) world_position: vec3&lt;<span class="hljs-built_in">f32</span>&gt;,
    @location(<span class="hljs-number">3</span>) determinant: <span class="hljs-built_in">f32</span>,
}

<span class="hljs-comment">// Create a custom scaling matrix</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">make_scale_matrix</span></span>(scale: vec3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; mat3x3&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">return</span> mat3x3&lt;<span class="hljs-built_in">f32</span>&gt;(
        scale.x, <span class="hljs-number">0.0</span>,     <span class="hljs-number">0.0</span>,
        <span class="hljs-number">0.0</span>,     scale.y, <span class="hljs-number">0.0</span>,
        <span class="hljs-number">0.0</span>,     <span class="hljs-number">0.0</span>,     scale.z
    );
}

<span class="hljs-comment">// Calculate determinant of 3x3 matrix</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">determinant_3x3</span></span>(m: mat3x3&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">return</span> m[<span class="hljs-number">0</span>][<span class="hljs-number">0</span>] * (m[<span class="hljs-number">1</span>][<span class="hljs-number">1</span>] * m[<span class="hljs-number">2</span>][<span class="hljs-number">2</span>] - m[<span class="hljs-number">1</span>][<span class="hljs-number">2</span>] * m[<span class="hljs-number">2</span>][<span class="hljs-number">1</span>])
         - m[<span class="hljs-number">0</span>][<span class="hljs-number">1</span>] * (m[<span class="hljs-number">1</span>][<span class="hljs-number">0</span>] * m[<span class="hljs-number">2</span>][<span class="hljs-number">2</span>] - m[<span class="hljs-number">1</span>][<span class="hljs-number">2</span>] * m[<span class="hljs-number">2</span>][<span class="hljs-number">0</span>])
         + m[<span class="hljs-number">0</span>][<span class="hljs-number">2</span>] * (m[<span class="hljs-number">1</span>][<span class="hljs-number">0</span>] * m[<span class="hljs-number">2</span>][<span class="hljs-number">1</span>] - m[<span class="hljs-number">1</span>][<span class="hljs-number">1</span>] * m[<span class="hljs-number">2</span>][<span class="hljs-number">0</span>]);
}

@vertex
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex</span></span>(<span class="hljs-keyword">in</span>: VertexInput) -&gt; VertexOutput {
    var out: VertexOutput;

    <span class="hljs-keyword">let</span> model = mesh_functions::get_world_from_local(<span class="hljs-keyword">in</span>.instance_index);
    <span class="hljs-keyword">let</span> model_3x3 = mat3x3&lt;<span class="hljs-built_in">f32</span>&gt;(model[<span class="hljs-number">0</span>].xyz, model[<span class="hljs-number">1</span>].xyz, model[<span class="hljs-number">2</span>].xyz);

    var position = <span class="hljs-keyword">in</span>.position;
    var normal = <span class="hljs-keyword">in</span>.normal;

    <span class="hljs-comment">// Mode 3: Transform order demonstration - orbit vs local rotation</span>
    <span class="hljs-keyword">if</span> material.demo_mode == <span class="hljs-number">3</span>u {
        <span class="hljs-comment">// Create rotation matrix</span>
        <span class="hljs-keyword">let</span> angle = material.time;
        <span class="hljs-keyword">let</span> c = cos(angle);
        <span class="hljs-keyword">let</span> s = sin(angle);
        <span class="hljs-keyword">let</span> rotation_y = mat3x3&lt;<span class="hljs-built_in">f32</span>&gt;(
            c,   <span class="hljs-number">0.0</span>, -s,
            <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>,
            s,   <span class="hljs-number">0.0</span>, c
        );

        <span class="hljs-comment">// WRONG ORDER: rotation * translation makes it orbit!</span>
        <span class="hljs-comment">// First translate away from origin</span>
        position = position + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">3.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>);
        <span class="hljs-comment">// Then rotate - causes orbiting behavior</span>
        position = rotation_y * position;

        normal = rotation_y * normal;
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// For other modes, apply custom scaling</span>
        <span class="hljs-keyword">let</span> scale_mat = make_scale_matrix(material.custom_scale);
        position = scale_mat * position;

        <span class="hljs-comment">// Calculate both naive and correct normals for Mode 0</span>
        <span class="hljs-keyword">let</span> naive_transformed = model_3x3 * (scale_mat * normal);
        out.naive_normal = normalize(naive_transformed);

        <span class="hljs-comment">// Correct normal transformation using inverse transpose</span>
        <span class="hljs-keyword">let</span> inv_scale = make_scale_matrix(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(
            <span class="hljs-number">1.0</span> / material.custom_scale.x,
            <span class="hljs-number">1.0</span> / material.custom_scale.y,
            <span class="hljs-number">1.0</span> / material.custom_scale.z
        ));
        <span class="hljs-keyword">let</span> normal_matrix = inv_scale;  <span class="hljs-comment">// For pure scale, inverse is reciprocal</span>
        normal = normal_matrix * normal;

        <span class="hljs-comment">// Calculate determinant</span>
        <span class="hljs-keyword">let</span> combined = model_3x3 * make_scale_matrix(material.custom_scale);
        out.determinant = determinant_3x3(combined);
    }

    <span class="hljs-comment">// Transform to world space</span>
    <span class="hljs-keyword">let</span> world_position = mesh_functions::mesh_position_local_to_world(
        model,
        vec4&lt;<span class="hljs-built_in">f32</span>&gt;(position, <span class="hljs-number">1.0</span>)
    );

    out.clip_position = position_world_to_clip(world_position.xyz);
    out.world_normal = normalize(model_3x3 * normal);
    out.world_position = world_position.xyz;

    <span class="hljs-keyword">return</span> out;
}

<span class="hljs-comment">// Simple noise function for static effect</span>
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">hash</span></span>(p: vec2&lt;<span class="hljs-built_in">f32</span>&gt;) -&gt; <span class="hljs-built_in">f32</span> {
    <span class="hljs-keyword">let</span> p3 = fract(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(p.x, p.y, p.x) * <span class="hljs-number">0.13</span>);
    <span class="hljs-keyword">let</span> p3_dot = dot(p3, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(p3.y + <span class="hljs-number">3.33</span>, p3.z + <span class="hljs-number">3.33</span>, p3.x + <span class="hljs-number">3.33</span>));
    <span class="hljs-keyword">return</span> fract((p3.x + p3.y) * p3_dot);
}

@fragment
<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment</span></span>(
    <span class="hljs-keyword">in</span>: VertexOutput,
    @builtin(front_facing) is_front: <span class="hljs-built_in">bool</span>
) -&gt; @location(<span class="hljs-number">0</span>) vec4&lt;<span class="hljs-built_in">f32</span>&gt; {
    <span class="hljs-keyword">let</span> normal = normalize(<span class="hljs-keyword">in</span>.world_normal);
    var color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.0</span>);

    <span class="hljs-comment">// Mode 0: Normal Matrix - Correct vs. Naive</span>
    <span class="hljs-keyword">if</span> material.demo_mode == <span class="hljs-number">0</span>u {
        <span class="hljs-comment">// Use correct normal for base lighting</span>
        <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
        <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.3</span>, dot(normal, light_dir));
        color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.7</span>, <span class="hljs-number">0.8</span>, <span class="hljs-number">0.9</span>) * diffuse;

        <span class="hljs-comment">// Calculate error - where naive normal differs from correct normal</span>
        <span class="hljs-keyword">let</span> error = length(<span class="hljs-keyword">in</span>.naive_normal - <span class="hljs-keyword">in</span>.world_normal);

        <span class="hljs-comment">// Highlight errors in bright red/orange</span>
        <span class="hljs-keyword">if</span> error &gt; <span class="hljs-number">0.1</span> {
            <span class="hljs-keyword">let</span> error_intensity = clamp(error * <span class="hljs-number">3.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
            color = mix(color, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.3</span>, <span class="hljs-number">0.0</span>), error_intensity);
        }
    }
    <span class="hljs-comment">// Mode 1: Dot Product - Diffuse Lighting</span>
    <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.demo_mode == <span class="hljs-number">1</span>u {
        <span class="hljs-comment">// Classic Lambertian diffuse lighting</span>
        <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(
            cos(material.time * <span class="hljs-number">0.5</span>),
            <span class="hljs-number">0.7</span>,
            sin(material.time * <span class="hljs-number">0.5</span>)
        ));

        <span class="hljs-comment">// The dot product in action!</span>
        <span class="hljs-keyword">let</span> ndot1 = dot(normal, light_dir);
        <span class="hljs-keyword">let</span> diffuse = max(<span class="hljs-number">0.0</span>, ndot1);

        <span class="hljs-comment">// Color the surface based purely on the dot product</span>
        <span class="hljs-comment">// Blue base color with lighting applied</span>
        color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.3</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">1.0</span>) * (<span class="hljs-number">0.2</span> + diffuse * <span class="hljs-number">0.8</span>);

        <span class="hljs-comment">// Add a subtle indicator showing the dot product value</span>
        <span class="hljs-keyword">if</span> diffuse &gt; <span class="hljs-number">0.9</span> {
            <span class="hljs-comment">// Very bright areas get a highlight</span>
            color = color + vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.3</span>, <span class="hljs-number">0.3</span>, <span class="hljs-number">0.0</span>);
        }
    }
    <span class="hljs-comment">// Mode 2: Determinant - Volume &amp; Orientation</span>
    <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.demo_mode == <span class="hljs-number">2</span>u {
        <span class="hljs-keyword">let</span> det = <span class="hljs-keyword">in</span>.determinant;
        <span class="hljs-keyword">let</span> abs_det = abs(det);

        <span class="hljs-comment">// Color based on volume change</span>
        <span class="hljs-keyword">if</span> abs_det &gt; <span class="hljs-number">1.05</span> {
            <span class="hljs-comment">// Expanded - blue</span>
            <span class="hljs-keyword">let</span> t = clamp((abs_det - <span class="hljs-number">1.0</span>) / <span class="hljs-number">2.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
            color = mix(vec3(<span class="hljs-number">0.2</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.2</span>), vec3(<span class="hljs-number">0.2</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">1.0</span>), t);
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> abs_det &lt; <span class="hljs-number">0.95</span> {
            <span class="hljs-comment">// Compressed - red</span>
            <span class="hljs-keyword">let</span> t = clamp((<span class="hljs-number">1.0</span> - abs_det) * <span class="hljs-number">2.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>);
            color = mix(vec3(<span class="hljs-number">0.2</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.2</span>), vec3(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.3</span>, <span class="hljs-number">0.2</span>), t);
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-comment">// Near 1.0 - green (neutral)</span>
            color = vec3(<span class="hljs-number">0.2</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.2</span>);
        }

        <span class="hljs-comment">// Add static/noise effect when determinant is negative (orientation flipped)</span>
        <span class="hljs-keyword">if</span> det &lt; <span class="hljs-number">0.0</span> {
            <span class="hljs-keyword">let</span> noise_coord = <span class="hljs-keyword">in</span>.world_position.xy * <span class="hljs-number">50.0</span> + material.time * <span class="hljs-number">10.0</span>;
            <span class="hljs-keyword">let</span> noise = hash(noise_coord);

            <span class="hljs-comment">// Strong static effect</span>
            <span class="hljs-keyword">if</span> noise &gt; <span class="hljs-number">0.5</span> {
                color = mix(color, vec3(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">1.0</span>), <span class="hljs-number">0.7</span>);
            }
        }

        <span class="hljs-comment">// Add basic lighting</span>
        <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
        <span class="hljs-keyword">let</span> brightness = max(<span class="hljs-number">0.4</span>, dot(normal, light_dir));
        color = color * brightness;
    }
    <span class="hljs-comment">// Mode 3: Transform Order - Orbit vs Local Rotation</span>
    <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> material.demo_mode == <span class="hljs-number">3</span>u {
        <span class="hljs-comment">// Color based on whether it's orbiting (which is wrong!)</span>
        <span class="hljs-comment">// The sphere should be red because it's using wrong transform order</span>
        color = vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">0.3</span>, <span class="hljs-number">0.2</span>);

        <span class="hljs-comment">// Add spinning indicator - vertical stripes that spin with the sphere</span>
        <span class="hljs-keyword">let</span> angle = atan2(<span class="hljs-keyword">in</span>.world_position.z, <span class="hljs-keyword">in</span>.world_position.x);
        <span class="hljs-keyword">let</span> stripe = step(<span class="hljs-number">0.5</span>, fract(angle * <span class="hljs-number">3.0</span> / <span class="hljs-number">3.14159</span>));
        color = mix(color, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">0.8</span>, <span class="hljs-number">0.1</span>, <span class="hljs-number">0.1</span>), stripe * <span class="hljs-number">0.3</span>);

        <span class="hljs-comment">// Add text overlay effect</span>
        <span class="hljs-keyword">let</span> text_grid = step(<span class="hljs-number">0.8</span>, fract(<span class="hljs-keyword">in</span>.world_position.y * <span class="hljs-number">10.0</span>));
        color = mix(color, vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">0.0</span>), text_grid * <span class="hljs-number">0.3</span>);

        <span class="hljs-comment">// Basic lighting</span>
        <span class="hljs-keyword">let</span> light_dir = normalize(vec3&lt;<span class="hljs-built_in">f32</span>&gt;(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>));
        <span class="hljs-keyword">let</span> brightness = max(<span class="hljs-number">0.4</span>, dot(normal, light_dir));
        color = color * brightness;
    }

    <span class="hljs-keyword">return</span> vec4&lt;<span class="hljs-built_in">f32</span>&gt;(color, <span class="hljs-number">1.0</span>);
}
</code></pre>
<h3 id="heading-the-rust-material-srcmaterialsd0108transformdemors">The Rust Material (<code>src/materials/d01_08_transform_demo.rs</code>)</h3>
<p>The Rust Material definition is straightforward. It contains fields for <code>demo_mode</code>, <code>time</code>, and <code>custom_scale</code> that directly map to the uniforms in our shader. We also override the <code>specialize</code> function to disable backface culling. This is essential for the determinant demo (Mode 2), as it allows us to see the inside of the sphere when its orientation is flipped by a negative scale, which would otherwise be culled (made invisible).</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> bevy::pbr::MaterialPipelineKey;
<span class="hljs-keyword">use</span> bevy::prelude::*;
<span class="hljs-keyword">use</span> bevy::render::mesh::MeshVertexBufferLayoutRef;
<span class="hljs-keyword">use</span> bevy::render::render_resource::{AsBindGroup, ShaderRef};
<span class="hljs-keyword">use</span> bevy::render::render_resource::{RenderPipelineDescriptor, SpecializedMeshPipelineError};

<span class="hljs-meta">#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]</span>
<span class="hljs-keyword">pub</span> <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">TransformDemoMaterial</span></span> {
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> demo_mode: <span class="hljs-built_in">u32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> time: <span class="hljs-built_in">f32</span>,
    <span class="hljs-meta">#[uniform(0)]</span>
    <span class="hljs-keyword">pub</span> custom_scale: Vec3,
}

<span class="hljs-keyword">impl</span> Material <span class="hljs-keyword">for</span> TransformDemoMaterial {
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">vertex_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d01_08_transform_demo.wgsl"</span>.into()
    }

    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">fragment_shader</span></span>() -&gt; ShaderRef {
        <span class="hljs-string">"shaders/d01_08_transform_demo.wgsl"</span>.into()
    }

    <span class="hljs-comment">// Disable backface culling for mode 2 to see orientation flip</span>
    <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">specialize</span></span>(
        _pipeline: &amp;bevy::pbr::MaterialPipeline&lt;<span class="hljs-keyword">Self</span>&gt;,
        descriptor: &amp;<span class="hljs-keyword">mut</span> RenderPipelineDescriptor,
        _layout: &amp;MeshVertexBufferLayoutRef,
        _key: MaterialPipelineKey&lt;<span class="hljs-keyword">Self</span>&gt;,
    ) -&gt; <span class="hljs-built_in">Result</span>&lt;(), SpecializedMeshPipelineError&gt; {
        descriptor.primitive.cull_mode = <span class="hljs-literal">None</span>;
        <span class="hljs-literal">Ok</span>(())
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/materials/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other materials</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d01_08_transform_demo;
</code></pre>
<h3 id="heading-the-demo-module-srcdemosd0108transformdemors">The Demo Module (<code>src/demos/d01_08_transform_demo.rs</code>)</h3>
<p>The demo module sets up our Bevy scene: a single sphere with our custom material, a camera, and a light. It contains systems to handle user input (<code>handle_input</code>) which allows mode switching via number keys and smooth scaling via arrow keys, and a dedicated <code>update_ui</code> system to visualize the current state.</p>
<pre><code class="lang-rust"><span class="hljs-keyword">use</span> crate::materials::d01_08_transform_demo::TransformDemoMaterial;
<span class="hljs-keyword">use</span> bevy::prelude::*;

<span class="hljs-keyword">pub</span> <span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">run</span></span>() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::&lt;TransformDemoMaterial&gt;::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (rotate_camera, update_time, handle_input, update_ui),
        )
        .run();
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">setup</span></span>(
    <span class="hljs-keyword">mut</span> commands: Commands,
    <span class="hljs-keyword">mut</span> meshes: ResMut&lt;Assets&lt;Mesh&gt;&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;TransformDemoMaterial&gt;&gt;,
) {
    commands.spawn((
        Mesh3d(meshes.add(Sphere::new(<span class="hljs-number">1.0</span>).mesh().uv(<span class="hljs-number">32</span>, <span class="hljs-number">18</span>))),
        MeshMaterial3d(materials.add(TransformDemoMaterial {
            demo_mode: <span class="hljs-number">0</span>,
            time: <span class="hljs-number">0.0</span>,
            custom_scale: Vec3::new(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>),
        })),
    ));

    commands.spawn((
        PointLight {
            shadows_enabled: <span class="hljs-literal">true</span>,
            intensity: <span class="hljs-number">2000.0</span>,
            ..default()
        },
        Transform::from_xyz(<span class="hljs-number">4.0</span>, <span class="hljs-number">8.0</span>, <span class="hljs-number">4.0</span>),
    ));

    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(-<span class="hljs-number">2.5</span>, <span class="hljs-number">4.5</span>, <span class="hljs-number">9.0</span>).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    commands.spawn((
        Text::new(<span class="hljs-string">""</span>),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(<span class="hljs-number">10.0</span>),
            left: Val::Px(<span class="hljs-number">10.0</span>),
            ..default()
        },
    ));
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">rotate_camera</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> camera_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Transform, With&lt;Camera3d&gt;&gt;) {
    <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> transform <span class="hljs-keyword">in</span> camera_query.iter_mut() {
        <span class="hljs-keyword">let</span> radius = <span class="hljs-number">9.0</span>;
        <span class="hljs-keyword">let</span> angle = time.elapsed_secs() * <span class="hljs-number">0.3</span>;
        transform.translation.x = angle.cos() * radius;
        transform.translation.z = angle.sin() * radius;
        transform.look_at(Vec3::ZERO, Vec3::Y);
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_time</span></span>(time: Res&lt;Time&gt;, <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;TransformDemoMaterial&gt;&gt;) {
    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        material.time = time.elapsed_secs();
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">handle_input</span></span>(
    keyboard: Res&lt;ButtonInput&lt;KeyCode&gt;&gt;,
    time: Res&lt;Time&gt;,
    <span class="hljs-keyword">mut</span> materials: ResMut&lt;Assets&lt;TransformDemoMaterial&gt;&gt;,
) {
    <span class="hljs-keyword">let</span> delta = time.delta_secs();

    <span class="hljs-keyword">for</span> (_, material) <span class="hljs-keyword">in</span> materials.iter_mut() {
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit1) {
            <span class="hljs-comment">// Normal Matrix</span>
            material.demo_mode = <span class="hljs-number">0</span>;
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit2) {
            <span class="hljs-comment">// Dot Product</span>
            material.demo_mode = <span class="hljs-number">1</span>;
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit3) {
            <span class="hljs-comment">// Determinant</span>
            material.demo_mode = <span class="hljs-number">2</span>;
        }
        <span class="hljs-keyword">if</span> keyboard.just_pressed(KeyCode::Digit4) {
            <span class="hljs-comment">// Transform Order Matters</span>
            material.demo_mode = <span class="hljs-number">3</span>;
            <span class="hljs-comment">// Reset scale when entering mode 3</span>
            material.custom_scale = Vec3::new(<span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>, <span class="hljs-number">1.0</span>);
        }

        <span class="hljs-comment">// Don't allow scale adjustment in mode 3 (transform order demo)</span>
        <span class="hljs-keyword">if</span> material.demo_mode == <span class="hljs-number">3</span> {
            <span class="hljs-keyword">continue</span>;
        }

        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowUp) {
            material.custom_scale.y = (material.custom_scale.y + delta).min(<span class="hljs-number">3.0</span>);
        }
        <span class="hljs-keyword">if</span> keyboard.pressed(KeyCode::ArrowDown) {
            material.custom_scale.y = (material.custom_scale.y - delta).max(<span class="hljs-number">0.2</span>);
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">fn</span> <span class="hljs-title">update_ui</span></span>(materials: Res&lt;Assets&lt;TransformDemoMaterial&gt;&gt;, <span class="hljs-keyword">mut</span> text_query: Query&lt;&amp;<span class="hljs-keyword">mut</span> Text&gt;) {
    <span class="hljs-keyword">if</span> !materials.is_changed() {
        <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> <span class="hljs-literal">Some</span>((_, material)) = materials.iter().next() {
        <span class="hljs-keyword">for</span> <span class="hljs-keyword">mut</span> text <span class="hljs-keyword">in</span> text_query.iter_mut() {
            <span class="hljs-keyword">let</span> mode_name = <span class="hljs-keyword">match</span> material.demo_mode {
                <span class="hljs-number">0</span> =&gt; {
                    <span class="hljs-string">"1 - Normal Matrix (Correct vs. Naive)\n  Orange highlights show where naive normal * model fails"</span>
                }
                <span class="hljs-number">1</span> =&gt; {
                    <span class="hljs-string">"2 - Dot Product = Diffuse Lighting\n  Watch brightness change as light rotates around sphere"</span>
                }
                <span class="hljs-number">2</span> =&gt; {
                    <span class="hljs-string">"3 - Determinant = Volume + Orientation\n  Green=neutral, Blue=expanded, Red=compressed, MAGENTA STATIC=mirrored!"</span>
                }
                <span class="hljs-number">3</span> =&gt; {
                    <span class="hljs-string">"4 - Transform Order Matters!\n  WRONG ORDER (rotation * translation) makes sphere ORBIT instead of spin in place"</span>
                }
                _ =&gt; <span class="hljs-string">"Unknown"</span>,
            };

            **text = <span class="hljs-built_in">format!</span>(
                <span class="hljs-string">"[1-4]: Change Mode\n\
                UP/DOWN: Adjust Y-scale\n\
                Mode: {}\n\
                Scale: {:.1}, {:.1}, {:.1}"</span>,
                mode_name,
                material.custom_scale.x,
                material.custom_scale.y,
                material.custom_scale.z
            );
        }
    }
}
</code></pre>
<p>Don't forget to add it to <code>src/demos/mod.rs</code>:</p>
<pre><code class="lang-rust"><span class="hljs-comment">// ... other demos</span>
<span class="hljs-keyword">pub</span> <span class="hljs-keyword">mod</span> d01_08_transform_demo;
</code></pre>
<p>And register it in <code>src/main.rs</code>:</p>
<pre><code class="lang-rust">Demo {
    number: <span class="hljs-string">"1.8"</span>,
    title: <span class="hljs-string">"Essential Shader Math Concepts"</span>,
    run: demos::d01_08_transform_demo::run,
},
</code></pre>
<h3 id="heading-running-the-demo">Running the Demo</h3>
<p>When you run the application, you will see a sphere and UI text explaining the controls and the current mode.</p>
<h4 id="heading-controls">Controls</h4>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Key</td><td>Action</td></tr>
</thead>
<tbody>
<tr>
<td><strong>1 - 4</strong></td><td>Switch directly to visualization mode 1, 2, 3 or 4.</td></tr>
<tr>
<td><strong>Up/Down Arrows</strong></td><td>Increase/decrease the Y-axis scale (not in Mode 3).</td></tr>
</tbody>
</table>
</div><h4 id="heading-what-youre-seeing">What You're Seeing</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762449604468/aa98c4a3-8bd4-4544-a9dd-4985ce73d8e6.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762449622760/ab163040-78fa-40f2-b1cd-00647f164152.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762449633850/39b21fae-58fb-45d6-af0c-4a8abce3b729.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762449647173/48ea65d4-b6fc-4825-8177-8061cca82ba4.png" alt class="image--center mx-auto" /></p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Mode</td><td>Concept Visualized</td><td>What to Do &amp; Look For</td></tr>
</thead>
<tbody>
<tr>
<td><strong>0</strong></td><td><strong>Normal Matrix</strong></td><td><strong>Press the UP/DOWN arrows</strong> to stretch the sphere. <strong>Orange highlights</strong> will appear. These show where the naive <code>model * normal</code> calculation is wrong. The underlying lighting, using the correct normal matrix, remains perfect.</td></tr>
<tr>
<td><strong>1</strong></td><td><strong>Dot Product</strong></td><td>The sphere's brightness directly corresponds to the <code>dot</code> product between the surface normal and the orbiting light's direction. Surfaces facing the light are bright; those angled away are dark. This is the raw output of a diffuse lighting model.</td></tr>
<tr>
<td><strong>2</strong></td><td><strong>Determinant</strong></td><td><strong>Green:</strong> Neutral volume.</td></tr>
<tr>
<td><strong>Blue:</strong> Expanded (scale Y &gt; 1).</td><td></td><td></td></tr>
<tr>
<td><strong>Red:</strong> Compressed (scale Y &lt; 1).</td><td></td><td></td></tr>
<tr>
<td><strong>Press DOWN until the sphere inverts:</strong> you'll see <strong>magenta static</strong>, confirming that a negative determinant (<code>det &lt; 0</code>) has flipped the object's orientation.</td><td></td><td></td></tr>
<tr>
<td><strong>3</strong></td><td><strong>Transform Order</strong></td><td>The sphere <strong>ORBITS</strong> the world's center instead of spinning in place. This is because the vertex shader first translates the vertices away from the origin and then rotates them, causing them to revolve around the world's center.</td></tr>
</tbody>
</table>
</div><h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>You have just absorbed the mathematical foundation of all 3D graphics. This was the most theory-heavy part of our journey, and you've made it through. Let's solidify the most critical concepts:</p>
<ul>
<li><p><strong>Vectors are Fundamental:</strong> They represent both directions (for lighting) and positions. The <code>dot</code> product measures alignment, and the <code>cross</code> product finds perpendiculars.</p>
</li>
<li><p><strong>Matrices are Transformation Recipes:</strong> A 4x4 matrix describes a new coordinate system. Multiplying a vector by it re-maps the vector's coordinates onto that new system.</p>
</li>
<li><p><strong>The W Component is Magic:</strong> It allows matrices to distinguish between positions (<code>w=1</code>) and directions (<code>w=0</code>), and it is the key that makes perspective projection work via the Perspective Divide.</p>
</li>
<li><p><strong>ORDER MATTERS!</strong> Matrix multiplication is applied right-to-left. <code>translation * rotation * scale</code> is the standard because it scales an object in place, rotates it on its own axis, and then moves the final result.</p>
</li>
<li><p><strong>The MVP Chain is the Journey:</strong> <code>Projection * View * Model</code> takes a vertex from its local model space all the way to the screen.</p>
</li>
<li><p><strong>Normals are Special:</strong> To transform normals correctly, especially under non-uniform scaling, you must use the normal matrix (transpose(inverse(model_3x3))). Bevy's helper functions handle this for you.</p>
</li>
</ul>
<h2 id="heading-whats-next">What's Next?</h2>
<p>Congratulations! You have successfully completed <strong>Phase 1: Foundations</strong>. You now possess the core knowledge of the graphics pipeline, WGSL syntax, data layout, and the essential mathematics required to write powerful and correct shaders.</p>
<p>Everything we have done in Phase 1 has been about one thing: taking geometry that already exists and drawing it correctly. We've learned the rules of the road - how to respect the pipeline, how to format our data, and how to apply the standard transformations.</p>
<p>Now that we know the rules, it's time to start bending them.</p>
<p>In <strong>Phase 2: Vertex Shaders</strong>, we will shift our focus dramatically. Instead of just passing the vertex position through the MVP chain, we will start to actively manipulate it. We will move beyond just drawing static models and begin to create dynamic, procedural, and animated worlds. We will learn how to:</p>
<ul>
<li><p>Deform meshes with mathematical functions to create waves, pulses, and other organic effects.</p>
</li>
<li><p>Use noise to generate complex vertex displacement, like fluttering flags.</p>
</li>
<li><p>Leverage instancing to draw thousands of unique objects with incredible performance.</p>
</li>
</ul>
<p>You have built a solid foundation. Now, let's start building on top of it.</p>
<p><em>Next up:</em> <a target="_blank" href="https://blog.hexbee.net/21-vertex-transformation-deep-dive"><strong><em>2.1 - Vertex Transformation Deep Dive</em></strong></a></p>
<hr />
<h2 id="heading-quick-reference">Quick Reference</h2>
<h3 id="heading-vector-operations">Vector Operations</h3>
<ul>
<li><p><code>dot(a, b)</code>: Returns a scalar. Measures alignment. For normalized vectors, it returns the cosine of the angle between them (1 for parallel, 0 for perpendicular, -1 for opposite).</p>
</li>
<li><p><code>cross(a, b)</code>: Returns a <code>vec3</code> that is perpendicular to both <code>a</code> and <code>b</code>, following the right-hand rule.</p>
</li>
<li><p><code>normalize(v)</code>: Returns a vector with the same direction as <code>v</code> but with a length of 1 (a unit vector).</p>
</li>
<li><p><code>length(v)</code>: Returns the scalar magnitude (length) of a vector.</p>
</li>
</ul>
<h3 id="heading-matrix-operations">Matrix Operations</h3>
<ul>
<li><p><strong>Order:</strong> <code>C = A * B</code> applies transformation <code>B</code>, then <code>A</code>. Transformations are read from right to left.</p>
</li>
<li><p><strong>MVP Chain:</strong> <code>MVP = Projection * View * Model</code></p>
</li>
<li><p><strong>Model Matrix:</strong> <code>Model = Translation * Rotation * Scale</code></p>
</li>
</ul>
<h3 id="heading-key-formulas-amp-concepts">Key Formulas &amp; Concepts</h3>
<ul>
<li><p><strong>W Component:</strong> <code>w=1.0</code> for positions (affected by translation), <code>w=0.0</code> for directions (ignores translation).</p>
</li>
<li><p><strong>Correct Normal Transform:</strong> Use the inverse transpose of the model matrix. In Bevy WGSL, this is handled by <code>mesh_functions::mesh_normal_local_to_world()</code>.</p>
</li>
<li><p><strong>Determinant:</strong> A scalar value calculated from a matrix.</p>
<ul>
<li><p><strong>Invertibility:</strong> A matrix is invertible if and only if <code>det != 0</code>.</p>
</li>
<li><p><strong>Volume:</strong> <code>abs(det)</code> is the factor by which volume is scaled.</p>
</li>
<li><p><strong>Orientation:</strong> <code>sign(det)</code> indicates if orientation is preserved (<code>+</code>) or flipped (<code>-</code>).</p>
</li>
</ul>
</li>
</ul>
]]></content:encoded></item></channel></rss>