Skip to main content

Command Palette

Search for a command to run...

3.7 - Fragment Discard and Transparency

Updated
20 min read
3.7 - Fragment Discard and Transparency

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 you want holes, cutouts, or complex silhouettes without adding extra geometry?

This is where the discard 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.

However, transparency isn't just about deleting pixels. There is also alpha blending - 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.

In this article, you will learn:

  • The Discard Statement: How to immediately terminate a pixel's processing.

  • Alpha Testing: Creating hard cutouts for foliage, fences, and grates.

  • Discard vs. Blending: Why discard gives you correct depth sorting for free, while blending creates sorting headaches.

  • Performance: Why "doing nothing" (discarding) can sometimes be slower than drawing.

  • Bevy's Alpha Modes: How to configure StandardMaterial or custom materials for different types of transparency.

Concept: Forward vs. Deferred Rendering

To understand why transparency is tricky, we need to understand the two main ways game engines render scenes.

  1. Forward Rendering: The classic approach. The GPU draws each object one by one and calculates lighting immediately.

    • Pros: Handles transparency (blending) well.

    • Cons: Gets very slow if you have hundreds of dynamic lights.

  2. Deferred Rendering: The modern approach for high-fidelity games. The GPU first renders the geometry data (Position, Normal, Color) of every pixel to a buffer (the "G-Buffer"), and calculates lighting for the entire screen at the end.

    • Pros: Can handle thousands of lights efficiently.

    • Cons: Hates transparency. 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).

Why this matters: When you use discard, the fragment remains "opaque" - it's either there or it isn't. This works perfectly in both pipelines.
When you use alpha blending, 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).

Understanding Fragment Discard

Let's start with the simplest and most dramatic tool: the discard statement.

What discard Does

The discard 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.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // Calculate if we should discard based on arbitrary logic
    let should_hide = in.uv.y > 0.5;

    if should_hide {
        discard;  // STOP. Do not pass Go. Do not write pixel.
    }

    // This code only runs if we didn't discard
    return vec4<f32>(1.0, 0.0, 0.0, 1.0);
}

Critical properties of discard:

  1. Immediate Termination: Code written after the discard statement inside that branch does not execute.

  2. No Depth Write: 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.

  3. Binary Visibility: A fragment is either there (100% opacity) or it isn't (0% opacity). There is no "50% visible" with discard.

Visual Comparison

Basic Example: The Checkerboard

The simplest procedural use case is a checkerboard. We don't need a texture for this; we can calculate it using UVs.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // Create a 10x10 grid pattern
    let tiles = floor(in.uv * 10.0);

    // Check if the sum of x and y indices is even or odd
    let checker = tiles.x + tiles.y;

    // modulo 2.0 to alternate
    if (checker % 2.0) > 0.5 {
        discard;
    }

    // Render the remaining squares as red
    return vec4<f32>(1.0, 0.0, 0.0, 1.0);
}

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.

Performance: When to Discard

It is important to understand that discard is not like a return. It aborts the thread.

Optimization Tip: If you know you are going to discard a pixel, do it as early as possible.

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // 1. Check discard condition FIRST
    if in.uv.y < 0.1 {
        discard;
    }

    // 2. Perform expensive lighting/math AFTER
    // This code won't run for the bottom 10% of the UVs, saving GPU power.
    let lighting = calculate_complex_pbr_lighting(in);

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

Discard and Depth Testing

One of the key features of discard is how it interacts with the depth buffer.

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 discard, that update never happens. This means we don't need to worry about the "draw order" of our triangles.

This makes discard the perfect tool for foliage, chain-link fences, and grates.

Alpha Testing with Conditional Discard

The most common use of discard is Alpha Testing (also called "Cutout" transparency). This technique uses a texture's alpha channel to decide which pixels to keep and which to delete.

The Classic Alpha Test Pattern

Imagine you have a texture of a chain-link fence. The metal parts have an alpha of 1.0 (opaque), and the gaps have an alpha of 0.0 (transparent).

// Define bindings for Group 2 (Material Data)
@group(2) @binding(0) var base_texture: texture_2d<f32>;
@group(2) @binding(1) var base_sampler: sampler;

struct AlphaCutoffMaterial {
    cutoff_threshold: f32, // Usually 0.5
}

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

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // 1. Sample the texture
    let color = textureSample(base_texture, base_sampler, in.uv);

    // 2. Alpha Test: Compare texture alpha against our threshold
    if color.a < material.cutoff_threshold {
        discard;
    }

    // 3. Render the pixel (opaque)
    return color;
}

How it works:

  1. We sample the texture at the current UV.

  2. We check if the alpha value is too low (e.g., < 0.5).

  3. If it is, we discard. If not, we draw the pixel at full opacity.

Choosing the Right Threshold

The cutoff threshold determines how "strict" the discard is.

  • 0.5: The standard default. Balanced.

  • 0.1: "Generous." Keeps almost everything, even faint wisps. Good for preserving details but might leave "dirty" edges.

  • 0.9: "Strict." Only keeps the most solid parts. Good for shrinking an object visually.

Alpha Channel Gradient:   0.0 ... 0.3 ... 0.5 ... 0.7 ... 1.0

Threshold 0.5 keeps:                      [------ KEEP -----]
Threshold 0.9 keeps:                                  [KEEP ]

The Problem: Binary Edges

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.

To fix this cheaply without enabling expensive Alpha Blending, we can use Dithering.

Advanced: Dithered Alpha Testing

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.

// Helper: 4x4 Bayer Matrix for ordered dithering
fn bayer_dither(position: vec2<f32>) -> f32 {
    let x = u32(position.x) % 4u;
    let y = u32(position.y) % 4u;

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

    return bayer[index];
}

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let color = textureSample(base_texture, base_sampler, in.uv);

    // Get a dither value based on SCREEN position (in.position)
    // -0.5 to +0.5 range centered around 0
    let dither_noise = bayer_dither(in.position.xy) - 0.5;

    // Modulate the threshold slightly per pixel
    // "0.1" controls how wide the "fuzzy" edge is
    let noisy_threshold = material.cutoff_threshold + (dither_noise * 0.1);

    if color.a < noisy_threshold {
        discard;
    }

    return color;
}

This effectively "softens" the hard edge. From a distance, the dithered pixels blend together in your eye, making the edge look smooth.

Creating Cutout Effects

Alpha testing is the standard technique for specific types of objects in games.

Leaves and Foliage

Leaves are almost always rendered as simple quads (rectangles) with a texture.

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.

Fences and Grates

Chain-link fences are just flat planes with a repeating texture.

discard creates sharp, metallic edges. Semi-transparent blending would make the metal look like ghost-fence.

Procedural Holes

You don't always need a texture. You can punch holes mathematically using discard.

// Example: Swiss Cheese Shader
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // Create a repeating grid
    let grid_uv = fract(in.uv * 10.0);

    // Calculate distance from center of the grid cell
    let dist = length(grid_uv - vec2<f32>(0.5));

    // If inside the circle radius, discard
    if dist < 0.4 {
        discard;
    }

    return vec4<f32>(1.0, 1.0, 0.0, 1.0); // Yellow cheese
}

Discard vs. Alpha Blending Trade-offs

Now we reach the critical question: when should you use Alpha Testing (discard) versus standard Alpha Blending?

In Bevy, you choose between these behaviors using the AlphaMode setting on your material, but understanding what happens under the hood is vital for performance and visual quality.

Alpha Blending Recap

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.

// Alpha Blending (No discard)
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    let color = textureSample(base_texture, base_sampler, in.uv);

    // Return color with alpha.
    // The GPU hardware handles the mixing automatically.
    return color; 
}

Equation: FinalColor = (SourceColor * Alpha) + (DestinationColor * (1.0 - Alpha))

Comparison Table

FeatureAlpha Testing (discard)Alpha Blending (mix)
Edge QualitySharp, jagged edges (Pixelated).Smooth, soft edges (Anti-aliased).
TransparencyBinary (On/Off).Continuous (0% to 100%).
Depth BufferWrites to depth.Usually Read-Only.
SortingNot Required.Required (Back-to-Front).
Use CasesFoliage, Fences, Grates.Glass, Water, Holograms, Smoke.
Bevy ModeAlphaMode::Mask(0.5)AlphaMode::Blend

The Sorting Problem

The single biggest downside of Alpha Blending is that draw order matters.

If you draw a red glass pane in front of a blue glass pane:

  • Correct (Back-to-Front): Draw Blue, then Draw Red on top. Result: Purple.

  • Incorrect (Front-to-Back): Draw Red. Because it is transparent, it doesn't write to the depth buffer. Then Draw Blue. Since nothing wrote to the depth buffer, Blue draws on top of Red. Result: Blue looks like it is in front of Red.

How Bevy handles this:
Bevy's renderer automatically calculates the distance from the camera to every object with AlphaMode::Blend and sorts them every frame.

  • Limitation 1: It sorts objects, not triangles. If a large complex mesh intersects itself, it will glitch.

  • Limitation 2: It costs CPU time to sort thousands of objects every frame.

Why discard wins here:
Because discard allows the opaque pixels to write to the depth buffer, order does not matter. 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.

Performance Considerations

When choosing a transparency mode, it helps to view them in a hierarchy of cost.

1. Opaque (AlphaMode::Opaque) - The Speed King

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.

Verdict: Use this whenever possible.

2. Discard / Alpha Mask (AlphaMode::Mask) - The Optimization Breaker

You might think: "Discarding pixels means I don't write them, so it should be faster than Opaque!"
Not always!

To know if a pixel should be discarded, the GPU must 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.

Verdict: Slower than Opaque, but much faster than Blending.

3. Alpha Blending (AlphaMode::Blend) - The Bandwidth Eater

This is the most expensive mode.

  • CPU Cost: Bevy must calculate distances and sort every object every frame.

  • GPU Cost: 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 Memory Bandwidth, which is often the bottleneck on mobile devices and integrated graphics.

Verdict: Use sparingly.

Summary Rule of Thumb

  • If it's solid, use AlphaMode::Opaque.

    • If it has holes but hard edges (leaves, fences), use AlphaMode::Mask.

    • Only use AlphaMode::Blend if you absolutely need partial transparency (glass, water).

Bevy's Alpha Mode Configuration

In Bevy, you control this behavior directly on your material struct. Whether you use StandardMaterial or a custom Material, the API is the same.

use bevy::prelude::*;

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

    // This function tells Bevy's pipeline how to handle your shader
    fn alpha_mode(&self) -> AlphaMode {
        // Option A: Opaque (Default)
        // Fastest. Writes Depth. Ignores alpha channel.
        AlphaMode::Opaque

        // Option B: Alpha Mask / Testing
        // Writes Depth. Enables `discard` behavior in the generic shader.
        // The f32 value is the cutoff threshold (usually 0.5).
        AlphaMode::Mask(0.5)

        // Option C: Alpha Blending
        // No Depth Write. CPU Sorting enabled. Smooth edges.
        AlphaMode::Blend

        // Option D: Additive (Good for glowing particles/fire)
        AlphaMode::Add
    }
}

Note: If you are writing a completely custom fragment shader (like we are in the next section), setting AlphaMode::Mask in Rust does not automatically add the discard keyword to your WGSL code. You must write the if (alpha < threshold) { discard; } logic yourself!

The AlphaMode in Rust primarily tells the render pipeline:

  1. Whether to enable Depth Writes.

  2. Whether to enable the CPU Sorter.

  3. Which Blend State (Mix, Add, Multiply) to configure on the GPU.


Complete Example: The Teleportation Chamber

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.

  1. Safety Grates (Alpha Mask): We will create a perforated metal floor and wall. Since metal is solid (it's either there or it isn't), we use Alpha Testing. We will also disable backface culling so we can see the grates from both sides.

  2. The Hero (Blend + Discard): We will create a holographic character. Since holograms are made of light, they are semi-transparent (Alpha Blending). However, when the character teleports, they will "dematerialize" using a noise-based Discard effect.

This example relies entirely on procedural math (sine waves and hash functions), so no texture assets are required.

1. The Grate Material (Alpha Testing)

This material generates a "Perforated Metal" look. We use AlphaMode::Mask because we want the edges of the holes to be razor-sharp.

The Shader (assets/shaders/d03_07_simple_grate.wgsl)

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 hole_radius, we discard the pixel.

To make it look 3D, we add a fake "bevel" highlight around the rim of the hole using smoothstep. This makes the flat geometry feel like a thick metal plate.

#import bevy_pbr::forward_io::VertexOutput

struct GrateMaterial {
    color: vec4<f32>,
    bar_width: f32, // Controls hole size (0.0 to 0.5)
    _padding: vec3<f32>,
}

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

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // 1. Create a repeating grid
    // We offset by 0.5 so the center of the UV cell is (0,0)
    let grid_uv = fract(in.uv * 20.0) - 0.5;

    // 2. Calculate distance from the center of the cell
    let dist = length(grid_uv);

    // 3. Alpha Test (The Hole)
    // If we are inside the circle radius defined by bar_width, discard.
    // This creates "Swiss Cheese" perforated metal.
    if dist < material.bar_width {
        discard;
    }

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

    // Mix a dark shadow color with the material color based on the bevel
    let final_color = mix(material.color.rgb * 0.5, material.color.rgb, bevel);

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

The Rust Code (src/materials/d03_07_simple_grate.rs)

In the Rust material, we do two important things:

  1. We set AlphaMode::Mask(0.5) to enable the cutouts.

  2. We override the specialize method to disable cull_mode. 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.

use bevy::pbr::{MaterialPipeline, MaterialPipelineKey};
use bevy::prelude::*;
use bevy::render::mesh::MeshVertexBufferLayoutRef;
use bevy::render::render_resource::{
    AsBindGroup, RenderPipelineDescriptor, ShaderRef, SpecializedMeshPipelineError,
};

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct GrateMaterial {
    #[uniform(0)]
    pub color: LinearRgba,
    #[uniform(0)]
    pub hole_radius: f32, // Renamed for clarity (was bar_width)
    #[uniform(0)]
    pub _padding: Vec3,
}

impl Default for GrateMaterial {
    fn default() -> Self {
        Self {
            color: LinearRgba::new(0.6, 0.6, 0.7, 1.0), // Steel Blue-Grey
            hole_radius: 0.35,                          // Nice perforation size
            _padding: Vec3::ZERO,
        }
    }
}

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

    fn alpha_mode(&self) -> AlphaMode {
        AlphaMode::Mask(0.5)
    }

    // ENABLE DOUBLE-SIDED RENDERING
    fn specialize(
        _pipeline: &MaterialPipeline<Self>,
        descriptor: &mut RenderPipelineDescriptor,
        _layout: &MeshVertexBufferLayoutRef,
        _key: MaterialPipelineKey<Self>,
    ) -> Result<(), SpecializedMeshPipelineError> {
        // None = Draw both front and back faces
        descriptor.primitive.cull_mode = None;
        Ok(())
    }
}

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

pub mod d03_07_simple_grate;

2. The Teleport Material (Blend + Discard)

This material renders the "Hero". It combines three distinct effects:

  1. Hologram Scanlines: A sine wave moving vertically to simulate projection interference.

  2. Rim Lighting: A Fresnel calculation to make the edges of the model glow, enhancing the 3D volume.

  3. Teleport Dissolve: A noise-based discard effect that eats away the geometry.

The Shader (assets/shaders/d03_07_teleport_body.wgsl)

Note that we calculate the Fresnel effect using the dot product between the view vector (camera direction) and the normal vector. This adds that classic sci-fi "edge glow."

The dissolve effect happens before the lighting. If the noise value is below our threshold, we discard immediately. If the pixel survives, we then apply the holographic blending.

#import bevy_pbr::forward_io::VertexOutput
#import bevy_pbr::mesh_view_bindings::view

struct TeleportMaterial {
    color: vec4<f32>,
    dissolve_amount: f32,
    time: f32,
    _padding: vec2<f32>,
}

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

fn hash(p: vec2<f32>) -> f32 {
    let n = dot(p, vec2<f32>(12.9898, 78.233));
    return fract(sin(n) * 43758.5453);
}

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // 1. Dissolve (Discard)
    let noise = hash(in.uv * 50.0 + material.time);
    if noise < material.dissolve_amount {
        discard;
    }

    // 2. Hologram Scanlines
    let scanline = sin(in.world_position.y * 50.0 - material.time * 5.0);
    let scan_strength = 0.7 + 0.3 * scanline;

    // 3. Fresnel Rim Light (3D effect)
    let view_dir = normalize(view.world_position.xyz - in.world_position.xyz);
    let normal = normalize(in.world_normal);
    let fresnel = 1.0 - max(dot(view_dir, normal), 0.0);
    let rim_glow = pow(fresnel, 3.0) * 2.0;

    // 4. Combine
    let final_alpha = material.color.a * scan_strength + rim_glow;
    let final_color = material.color.rgb + vec3<f32>(rim_glow);

    return vec4<f32>(final_color, final_alpha);
}

The Rust Code (src/materials/d03_07_teleport_body.rs)

We use AlphaMode::Blend. Even though we use discard inside the shader, we need Blending enabled to make the hologram look like semi-transparent light.

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

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct TeleportMaterial {
    #[uniform(0)]
    pub color: LinearRgba,
    #[uniform(0)]
    pub dissolve_amount: f32,
    #[uniform(0)]
    pub time: f32,
    #[uniform(0)]
    pub _padding: Vec2,
}

impl Default for TeleportMaterial {
    fn default() -> Self {
        Self {
            color: LinearRgba::new(0.0, 1.0, 1.0, 0.3), // Cyan, low opacity
            dissolve_amount: 0.0,
            time: 0.0,
            _padding: Vec2::ZERO,
        }
    }
}

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

    fn alpha_mode(&self) -> AlphaMode {
        AlphaMode::Blend
    }
}

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

pub mod d03_07_teleport_body;

3. The Demo Scene (src/demos/d03_07_teleport_demo.rs)

This demo sets up the chamber, spawns the hero, and handles the logic. We add an Orbit Camera so you can rotate around the scene and inspect the transparency sorting from all angles.

When you press Space, we animate the dissolve_amount uniform. The hero dissolves, moves to a new random location while invisible, and then re-materializes.

use crate::materials::d03_07_simple_grate::GrateMaterial;
use crate::materials::d03_07_teleport_body::TeleportMaterial;
use bevy::prelude::*;

pub fn run() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::<GrateMaterial>::default())
        .add_plugins(MaterialPlugin::<TeleportMaterial>::default())
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (
                handle_teleport_input,
                animate_teleport,
                update_time,
                orbit_camera,
            ),
        )
        .run();
}

#[derive(Component)]
struct Hero;

#[derive(Component)]
struct OrbitCamera {
    radius: f32,
    angle: f32,
}

#[derive(Component)]
struct TeleportState {
    target_pos: Vec3,
    is_teleporting: bool,
    dissolve_progress: f32,
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut grate_materials: ResMut<Assets<GrateMaterial>>,
    mut teleport_materials: ResMut<Assets<TeleportMaterial>>,
) {
    // Camera
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(0.0, 5.0, 12.0).looking_at(Vec3::ZERO, Vec3::Y),
        OrbitCamera {
            radius: 12.0,
            angle: 0.0,
        },
    ));

    // Light
    commands.spawn((
        PointLight {
            intensity: 2_000_000.0,
            range: 20.0,
            ..default()
        },
        Transform::from_xyz(0.0, 10.0, 0.0),
    ));

    // 1. The Chamber (Perforated Metal)
    let wall_mesh = meshes.add(Rectangle::new(8.0, 4.0));
    let grate_mat = grate_materials.add(GrateMaterial::default());

    // Back Wall
    commands.spawn((
        Mesh3d(wall_mesh.clone()),
        MeshMaterial3d(grate_mat.clone()),
        Transform::from_xyz(0.0, 2.0, -4.0),
    ));
    // Front Wall
    commands.spawn((
        Mesh3d(wall_mesh),
        MeshMaterial3d(grate_mat),
        Transform::from_xyz(0.0, 2.0, 4.0),
    ));

    // 2. The Hero (Hologram)
    commands.spawn((
        Mesh3d(meshes.add(Capsule3d::default())),
        MeshMaterial3d(teleport_materials.add(TeleportMaterial::default())),
        Transform::from_xyz(0.0, 1.0, 0.0),
        Hero,
        TeleportState {
            target_pos: Vec3::ZERO,
            is_teleporting: false,
            dissolve_progress: 0.0,
        },
    ));

    // UI
    commands.spawn((
        Text::new(
            "CONTROLS:\n\
             [Space] Teleport Hero\n\
             [Left/Right] Orbit Camera",
        ),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(12.0),
            left: Val::Px(12.0),
            ..default()
        },
    ));
}

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

fn orbit_camera(
    time: Res<Time>,
    keyboard: Res<ButtonInput<KeyCode>>,
    mut query: Query<(&mut Transform, &mut OrbitCamera)>,
) {
    let speed = 2.0;
    for (mut transform, mut orbit) in query.iter_mut() {
        if keyboard.pressed(KeyCode::ArrowLeft) {
            orbit.angle -= speed * time.delta_secs();
        }
        if keyboard.pressed(KeyCode::ArrowRight) {
            orbit.angle += speed * time.delta_secs();
        }

        let x = orbit.radius * orbit.angle.sin();
        let z = orbit.radius * orbit.angle.cos();

        transform.translation = Vec3::new(x, 5.0, z);
        transform.look_at(Vec3::new(0.0, 1.0, 0.0), Vec3::Y);
    }
}

fn handle_teleport_input(
    keyboard: Res<ButtonInput<KeyCode>>,
    mut query: Query<&mut TeleportState, With<Hero>>,
) {
    if keyboard.just_pressed(KeyCode::Space) {
        for mut state in query.iter_mut() {
            if !state.is_teleporting {
                state.is_teleporting = true;
                state.dissolve_progress = 0.0;
                let x = (rand::random::<f32>() - 0.5) * 6.0;
                state.target_pos = Vec3::new(x, 1.0, 0.0);
            }
        }
    }
}

fn animate_teleport(
    time: Res<Time>,
    mut materials: ResMut<Assets<TeleportMaterial>>,
    mut query: Query<(
        &mut Transform,
        &mut TeleportState,
        &MeshMaterial3d<TeleportMaterial>,
    )>,
) {
    for (mut transform, mut state, handle) in query.iter_mut() {
        if state.is_teleporting {
            state.dissolve_progress += time.delta_secs() * 2.0;

            if let Some(material) = materials.get_mut(handle) {
                if state.dissolve_progress < 1.0 {
                    // Dissolve
                    material.dissolve_amount = state.dissolve_progress;
                } else {
                    // Move & Reappear
                    if state.dissolve_progress < 1.1 {
                        transform.translation = state.target_pos;
                    }
                    material.dissolve_amount = 2.0 - state.dissolve_progress;
                }
            }

            if state.dissolve_progress >= 2.0 {
                state.is_teleporting = false;
                if let Some(material) = materials.get_mut(handle) {
                    material.dissolve_amount = 0.0;
                }
            }
        }
    }
}

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

pub mod d03_07_teleport_demo;

And register it in src/main.rs:

Demo {
    number: "3.7",
    title: "Fragment Discard and Transparency",
    run: demos::d03_07_teleport_demo::run,
},

Running the Demo

Controls

KeyAction
SpaceTeleport the Hero to a random location
Arrow Left/RightOrbit the camera around the scene

What You're Seeing

  1. The Grate (Alpha Mask): 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 discard, the depth buffer works perfectly, and the sorting is correct.

  2. The Hologram (Alpha Blend): The hero looks like a volume of light. Notice the scanlines and the rim glow.

  3. The Teleport (Discard): When you press Space, the hero doesn't fade out evenly; they "burn away" into static. This demonstrates that you can use discard inside a blended material to create interesting erosion effects.

Key Takeaways

  1. Discard is distinct from Alpha: Transparency (alpha < 1.0) blends colors. Discard deletes pixels.

  2. Use Masks for Structure: For objects like fences, grates, or foliage, use AlphaMode::Mask (Discard). It's sharp, performant, and handles depth correctly.

  3. Use Blend for Light: For objects that represent glass, ghosts, or energy, use AlphaMode::Blend.

  4. Combine them for Effects: You can use discard inside a Blended shader to create dissolve, burn, or teleportation effects.

  5. Disable Culling for Thin Objects: 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 specialize method.

What's Next?

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.

Next up: 3.8 - Advanced Color Techniques


Quick Reference

Transparency Comparison

Choosing the right AlphaMode is the most important optimization you can make for transparent objects.

FeatureOpaqueAlpha Mask (Discard)Alpha Blend
Best For...Rocks, Walls, CharactersFoliage, Fences, GratesGlass, Water, Holograms
VisualsSolidSolid with holes (Hard edges)See-through (Soft edges)
Depth BufferWrites DepthWrites Depth (for opaque pixels)Read-Only (usually)
SortingNot RequiredNot RequiredRequired (CPU Intensive)
Performance🟢 Fastest (Uses Early-Z)🟡 Medium (Breaks Early-Z)🔴 Slowest (Overdraw + Sorting)

Performance Heuristics

  • The Early-Z Penalty: Using discard in a shader usually disables the GPU's ability to skip hidden pixels (Early-Z). The shader must run for every pixel to decide if it should be deleted.

  • The Overdraw Penalty: 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.

  • The Sorting Penalty: Bevy must sort all Blended objects on the CPU every frame. Avoid thousands of transparent objects.

Common Math for Effects

  • Circular Hole: if length(uv - center) < radius { discard; }

  • Grid: if step(width, sin(uv * scale)) < 0.5 { discard; }

  • Dissolve: if noise(uv) < threshold { discard; }

  • Rim Light: 1.0 - dot(view_dir, normal)