Skip to main content

Command Palette

Search for a command to run...

3.8 - Advanced Color Techniques

Updated
21 min read
3.8 - Advanced Color Techniques

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 manipulation techniques that can transform the look and feel of your renders from "functional" to "cinematic."

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.

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.

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

  • Color Correction Fundamentals: How to mathematically adjust brightness, contrast, saturation, and gamma.

  • Color Grading with LUTs: Using 3D Lookup Tables to apply complex cinematic looks cheaply.

  • Stylized Effects: Implementing posterization (quantization) for retro or comic-book aesthetics.

  • Channel Manipulation: How to isolate and shift specific color channels.

  • Lens Simulation: Creating chromatic aberration (color fringing) to simulate physical lens imperfections.

  • Edge Detection: Using the Sobel operator to find outlines based on color differences.

  • Custom Blend Modes: Implementing mathematical blends beyond standard alpha mixing (Overlay, Screen, Multiply).

  • Post-Processing Preview: Building a complete "Instagram-style" filter system using a render-to-texture pipeline.

Color Correction Fundamentals

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.

Understanding Color Spaces

Most of our shader math happens in RGB space, but understanding how to navigate other representations makes specific tasks much easier.

  1. RGB (Red, Green, Blue):

    • Hardware Native: This is how monitors display light.

    • Additive: 100% Red + 100% Green = Yellow.

    • Good for: Technical operations, texture storage, and lighting math.

    • Bad for: "Make this 10% brighter" or "Shift the hue slightly."

  2. HSV (Hue, Saturation, Value):

    • Perceptual: Modeled after how humans describe color.

    • Cylindrical: Hue is an angle (0-360°), Saturation is a radius, Value is height.

    • Good for: "Make this color more vivid" (Saturation) or "Change this red to blue" (Hue).

    • Bad for: Lighting calculations.

We will often convert RGB to HSV, modify it, and convert back to RGB.

Brightness Adjustment

The simplest correction is adding or subtracting light.

fn adjust_brightness(color: vec3<f32>, amount: f32) -> vec3<f32> {
    // amount: -1.0 (black) to 1.0 (white)
    return color + amount;
}

Key Insight: This is a linear offset. It shifts the entire histogram. While simple, it can wash out blacks (if positive) or crush whites (if negative).

Safety First: Color operations can easily push values below 0.0 or above 1.0. In a StandardMaterial pipeline, values > 1.0 create "bloom" (glow). If that isn't intended, always clamp your final result.

fn adjust_brightness_safe(color: vec3<f32>, amount: f32) -> vec3<f32> {
    return clamp(color + amount, vec3<f32>(0.0), vec3<f32>(1.0));
}

Contrast Adjustment

Contrast defines the separation between light and dark values. High contrast makes darks darker and lights lighter. Low contrast makes everything gray.

fn adjust_contrast(color: vec3<f32>, contrast: f32) -> vec3<f32> {
    // contrast = 1.0: Neutral
    // contrast > 1.0: High Contrast
    // contrast < 1.0: Low Contrast

    // We scale the color relative to "Middle Gray" (0.5)
    let midpoint = vec3<f32>(0.5);

    return (color - midpoint) * contrast + midpoint;
}

Visualizing the Math:
Imagine a color value of 0.6 (slightly bright).

  • Subtract 0.5 → 0.1

  • Multiply by 2.0 (High Contrast) → 0.2

  • Add 0.5 → 0.7
    The value moved from 0.6 to 0.7 (brighter).

Imagine a color value of 0.4 (slightly dark).

  • Subtract 0.5 → -0.1

  • Multiply by 2.0 → -0.2

  • Add 0.5 → 0.3
    The value moved from 0.4 to 0.3 (darker).

Saturation Adjustment

Saturation controls the intensity of the color. To adjust it, we interpolate between the original color and a grayscale version of itself.

fn adjust_saturation(color: vec3<f32>, saturation: f32) -> vec3<f32> {
    // 0.0 = Grayscale
    // 1.0 = Neutral
    // >1.0 = Vivid

    // 1. Calculate Luminance (Perceived Brightness)
    // We use the Rec. 709 luma coefficients (modern standard for sRGB)
    // Green contributes most to brightness, Blue contributes least.
    let luminance = dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
    let grayscale = vec3<f32>(luminance);

    // 2. Mix between gray and color
    return mix(grayscale, color, saturation);
}

Note on Coefficients: You might see 0.299, 0.587, 0.114 in older tutorials. Those are for SDTV (Rec. 601). Since Bevy and modern computing use sRGB/Rec. 709, 0.2126, 0.7152, 0.0722 is more mathematically accurate, though the visual difference is subtle.

Gamma Adjustment (Midtones)

Gamma allows you to brighten or darken the image without crushing the blacks or blowing out the whites. It specifically targets the midtones.

fn adjust_gamma(color: vec3<f32>, gamma: f32) -> vec3<f32> {
    // gamma = 1.0: Neutral
    // gamma > 1.0: Brightens midtones
    // gamma < 1.0: Darkens midtones (NOTE: This convention varies!)

    // Bevy note: Standard sRGB decoding uses a power of 2.2.
    // For artistic control, we often simply use power.
    return pow(color, vec3<f32>(1.0 / gamma));
}

Combined Pipeline

Order of operations matters. A standard color grading pipeline usually applies operations in this order to mimic how film is processed:

fn color_correct(
    color: vec3<f32>,
    brightness: f32,
    contrast: f32,
    saturation: f32,
    gamma: f32,
) -> vec3<f32> {
    var result = color;

    // 1. Apply Gamma/Midtones first (shaping the curve)
    result = adjust_gamma(result, gamma);

    // 2. Apply Brightness (offsetting the curve)
    result = result + brightness;

    // 3. Apply Contrast (expanding the curve around center)
    // Doing this after brightness allows you to contrast-stretch the brightened image
    let midpoint = vec3<f32>(0.5);
    result = (result - midpoint) * contrast + midpoint;

    // 4. Apply Saturation (final polish)
    let luminance = dot(result, vec3<f32>(0.2126, 0.7152, 0.0722));
    result = mix(vec3<f32>(luminance), result, saturation);

    return clamp(result, vec3<f32>(0.0), vec3<f32>(1.0));
}

Color Grading with Lookup Tables (LUTs)

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.

Enter the Lookup Table (LUT). A LUT is essentially a translation dictionary for color.

What is a LUT?

Imagine a function f(color) -> 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.

  • Input: The original pixel color (Red, Green, Blue).

  • Lookup: The shader uses the Input RGB values as coordinates to look up a position in the table.

  • Output: The color found at that position.

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.

The 3D Texture approach

In modern graphics, we typically use 3D LUTs.
Think of a 3D LUT as a cube of colors:

  • X-axis represents Red (0.0 to 1.0).

  • Y-axis represents Green (0.0 to 1.0).

  • Z-axis represents Blue (0.0 to 1.0).

To use a LUT, we simply take our pixel's color and use it as the (u, v, w) texture coordinates to sample this 3D texture.

Applying a 3D LUT in WGSL

The WGSL implementation is surprisingly simple because the hardware does the heavy lifting (interpolation) for us.

@group(2) @binding(1) var lut_texture: texture_3d<f32>;
@group(2) @binding(2) var lut_sampler: sampler;

fn apply_lut(original_color: vec3<f32>, strength: f32) -> vec3<f32> {
    // 1. Clamp input to valid texture coordinate range [0.0, 1.0]
    // If we don't clamp, bright values might wrap around or sample invalid data.
    let lut_coords = clamp(original_color, vec3<f32>(0.0), vec3<f32>(1.0));

    // 2. Sample the 3D texture
    // The GPU automatically interpolates between the stored colors
    // if our specific color falls between the grid points.
    let graded_color = textureSample(lut_texture, lut_sampler, lut_coords).rgb;

    // 3. Mix based on strength (Intensity slider)
    return mix(original_color, graded_color, strength);
}

Generating a LUT in Rust

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.

use bevy::prelude::*;
use bevy::render::render_asset::RenderAssetUsages;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};

fn generate_identity_lut(size: u32) -> Image {
    // Standard sizes: 16, 32, or 64. 
    // 32 is a good balance of quality vs memory (32x32x32 pixels).
    let mut data = Vec::with_capacity((size * size * size * 4) as usize);

    for z in 0..size {
        for y in 0..size {
            for x in 0..size {
                // Normalize coordinates to 0.0 - 1.0 range
                let r = x as f32 / (size - 1) as f32;
                let g = y as f32 / (size - 1) as f32;
                let b = z as f32 / (size - 1) as f32;

                // Store the color. For an identity LUT, the Color IS the Coordinate.
                // To make a "Sepia" LUT, you would do math on r,g,b here before storing.
                data.push((r * 255.0) as u8);
                data.push((g * 255.0) as u8);
                data.push((b * 255.0) as u8);
                data.push(255); // Alpha
            }
        }
    }

    Image::new(
        Extent3d {
            width: size,
            height: size,
            depth_or_array_layers: size, // Depth is the Blue axis
        },
        TextureDimension::D3,
        data,
        TextureFormat::Rgba8Unorm, // Standard 8-bit color
        RenderAssetUsages::default(),
    )
}

Stylized Effects: Posterization & Quantization

Sometimes you don't want realism - you want a style. Posterization (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.

Basic Posterization

The math relies on floor() to snap values to the nearest "step."

fn posterize(color: vec3<f32>, steps: f32) -> vec3<f32> {
    // steps: Number of color bands (e.g., 4.0, 8.0, 16.0)

    // 1. Scale up (0.0 - 1.0 becomes 0.0 - 4.0)
    // 2. Floor (0.0, 1.0, 2.0, 3.0, 4.0)
    // 3. Scale down (0.0, 0.25, 0.50, 0.75, 1.0)
    return floor(color * steps) / steps;
}

Dithered Posterization

The problem with simple posterization is "banding" - large, flat areas of single color that look artificial. Dithering adds noise before quantization to break up these edges, creating the illusion of more colors through pixel patterns.

// A pseudo-random hash function for noise
fn hash(uv: vec2<f32>) -> f32 {
    return fract(sin(dot(uv, vec2<f32>(12.9898, 78.233))) * 43758.5453);
}

fn posterize_dithered(
    color: vec3<f32>, 
    steps: f32, 
    uv: vec2<f32>,
    dither_strength: f32
) -> vec3<f32> {
    // Generate noise between -0.5 and 0.5
    let noise = (hash(uv) - 0.5) * dither_strength;

    // Add noise BEFORE quantization
    let noisy_color = color + noise;

    return floor(noisy_color * steps) / steps;
}

Chromatic Aberration

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.

In games, we add it intentionally to make the camera feel "physical" and imperfect, adding realism or unease.

The Technique

Instead of sampling the texture once at uv, we sample it three times - once for each color channel - with slightly different coordinates.

fn chromatic_aberration(
    base_texture: texture_2d<f32>,
    base_sampler: sampler,
    uv: vec2<f32>,
    strength: f32
) -> vec3<f32> {
    // 1. Calculate direction from center
    let center = vec2<f32>(0.5);
    let dist_vector = uv - center;

    // 2. Calculate distortion amount
    // Aberration is usually stronger at the edges (squared distance)
    let dist_sq = dot(dist_vector, dist_vector);
    let offset = dist_vector * dist_sq * strength;

    // 3. Sample channels separately
    // Red channel shifts OUTWARD
    let r = textureSample(base_texture, base_sampler, uv + offset).r;

    // Green channel stays centered (or shifts slightly)
    let g = textureSample(base_texture, base_sampler, uv).g;

    // Blue channel shifts INWARD
    let b = textureSample(base_texture, base_sampler, uv - offset).b;

    return vec3<f32>(r, g, b);
}

Edge Detection with Sobel

Edge detection is fundamental for outline shaders (toon shading) and image analysis. We use the Sobel Operator, which looks at a pixel's neighbors to determine if there is a sudden change in brightness.

The Sobel operator uses two "kernels" (grids) to measure the gradient (change) in the X and Y directions.

Sobel Implementation

fn get_luminance(color: vec3<f32>) -> f32 {
    return dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
}

fn sobel_edge_detection(
    tex: texture_2d<f32>,
    samp: sampler,
    uv: vec2<f32>,
    resolution: vec2<f32> // Screen dimensions in pixels
) -> f32 {
    let step = 1.0 / resolution; // Size of one pixel in UV space

    // Sample the 8 neighbors + center
    // We only need luminance for simple edge detection
    let tl = get_luminance(textureSample(tex, samp, uv + vec2<f32>(-step.x, -step.y)).rgb);
    let t  = get_luminance(textureSample(tex, samp, uv + vec2<f32>( 0.0,    -step.y)).rgb);
    let tr = get_luminance(textureSample(tex, samp, uv + vec2<f32>( step.x, -step.y)).rgb);

    let l  = get_luminance(textureSample(tex, samp, uv + vec2<f32>(-step.x,  0.0)).rgb);
    let r  = get_luminance(textureSample(tex, samp, uv + vec2<f32>( step.x,  0.0)).rgb);

    let bl = get_luminance(textureSample(tex, samp, uv + vec2<f32>(-step.x,  step.y)).rgb);
    let b  = get_luminance(textureSample(tex, samp, uv + vec2<f32>( 0.0,     step.y)).rgb);
    let br = get_luminance(textureSample(tex, samp, uv + vec2<f32>( step.x,  step.y)).rgb);

    // Sobel Kernels
    // Horizontal (Gx)     Vertical (Gy)
    // -1  0  1           -1 -2 -1
    // -2  0  2            0  0  0
    // -1  0  1            1  2  1

    let gx = (tr + 2.0 * r + br) - (tl + 2.0 * l + bl);
    let gy = (bl + 2.0 * b + br) - (tl + 2.0 * t + tr);

    // Magnitude of the gradient vector
    // High value = Strong Edge
    return sqrt(gx * gx + gy * gy);
}

If the return value is close to 0.0, the area is flat. If it is > 0.5, there is a sharp edge.

Custom Blend Modes

Finally, let's look at how to blend a filter color with the original image. Standard alpha blending mix(a, b, alpha) is boring. Photoshop-style blend modes offer more artistic control.

These functions take a base color (the image) and a blend color (the filter tint).

// MULTIPLY: Darkens the image. Good for shadows or vignetting.
// Formula: A * B
fn blend_multiply(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
    return base * blend;
}

// SCREEN: Lightens the image. Good for glows.
// Formula: 1 - (1-A) * (1-B)
fn blend_screen(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
    return vec3<f32>(1.0) - (vec3<f32>(1.0) - base) * (vec3<f32>(1.0) - blend);
}

// OVERLAY: Increases contrast. Combines Multiply (for darks) and Screen (for lights).
fn blend_overlay(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
    // If base is dark (< 0.5), multiply. If bright, screen.
    let is_bright = step(vec3<f32>(0.5), base);

    let dark_mix = 2.0 * base * blend;
    let bright_mix = 1.0 - 2.0 * (1.0 - base) * (1.0 - blend);

    return mix(dark_mix, bright_mix, is_bright);
}

Complete Example: Instagram-Style Filter System

To demonstrate these techniques, we will build a comprehensive "Photo Filter" application. This will include everything we discussed: manual color correction (brightness/contrast/saturation), artistic presets, chromatic aberration, posterization, and edge detection.

Important Architecture Note:
To apply these effects to the entire scene at once, we need a technique called Render-to-Texture (RTT).

  1. We create a special Image asset manually in Rust (our "film roll").

  2. We tell our Main Camera (Layer 0) to render into that image instead of to the screen.

  3. We spawn a second "Post-Processing Camera" (Layer 1).

  4. We place a large flat rectangle in front of this second camera.

  5. We apply our custom shader to this rectangle, using the Image from step 1 as a texture.

This is a simplified version of how professional post-processing pipelines work!

The Shader (assets/shaders/d03_08_photo_filter.wgsl)

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.

#import bevy_pbr::forward_io::VertexOutput

struct PhotoFilterMaterial {
    // 1. Manual Color Correction
    brightness: f32,
    contrast: f32,
    saturation: f32,

    // 2. Artistic Presets
    filter_mode: u32,     // 0=None, 1=Vintage, 2=Noir
    filter_strength: f32, // Mix amount (0.0 to 1.0)

    // 3. Advanced Effects
    aberration_strength: f32, // Offset amount
    posterize_steps: f32,     // 0.0 = disabled
    edge_show: u32,           // 0 = Off, 1 = On

    _padding: f32,            // Alignment padding
}

@group(2) @binding(0) var<uniform> material: PhotoFilterMaterial;
@group(2) @binding(1) var base_texture: texture_2d<f32>;
@group(2) @binding(2) var base_sampler: sampler;

// --- Helper Math ---
fn get_luminance(color: vec3<f32>) -> f32 {
    // Rec. 709 coefficients
    return dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
}

fn adjust_contrast(color: vec3<f32>, contrast: f32) -> vec3<f32> {
    return (color - 0.5) * contrast + 0.5;
}

fn adjust_saturation(color: vec3<f32>, saturation: f32) -> vec3<f32> {
    let grey = vec3<f32>(get_luminance(color));
    return mix(grey, color, saturation);
}

// --- Effects ---

fn chromatic_aberration(uv: vec2<f32>, strength: f32) -> vec3<f32> {
    let center = vec2<f32>(0.5);

    // Linear falloff: Effect increases linearly towards edges
    // This makes the effect strong and clearly visible
    let offset = (uv - center) * strength;

    let r = textureSample(base_texture, base_sampler, uv - offset).r;
    let g = textureSample(base_texture, base_sampler, uv).g; // Green is anchor
    let b = textureSample(base_texture, base_sampler, uv + offset).b;

    return vec3<f32>(r, g, b);
}

fn posterize(color: vec3<f32>, steps: f32) -> vec3<f32> {
    return floor(color * steps) / steps;
}

fn sobel_edge_detection(uv: vec2<f32>) -> f32 {
    let dims = vec2<f32>(textureDimensions(base_texture));
    let step = 1.0 / dims;

    // Simplified Sobel (Horizontal + Vertical)
    let t = get_luminance(textureSample(base_texture, base_sampler, uv + vec2<f32>(0.0, -step.y)).rgb);
    let b = get_luminance(textureSample(base_texture, base_sampler, uv + vec2<f32>(0.0, step.y)).rgb);
    let l = get_luminance(textureSample(base_texture, base_sampler, uv + vec2<f32>(-step.x, 0.0)).rgb);
    let r = get_luminance(textureSample(base_texture, base_sampler, uv + vec2<f32>(step.x, 0.0)).rgb);

    let gy = t - b;
    let gx = l - r;

    return sqrt(gx*gx + gy*gy);
}

// --- Filter Presets ---
fn filter_vintage(color: vec3<f32>) -> vec3<f32> {
    var c = color;
    c = adjust_contrast(c, 1.2);
    c = adjust_saturation(c, 0.6);
    c *= vec3<f32>(1.1, 1.0, 0.8); // Sepia tint
    return c;
}

fn filter_noir(color: vec3<f32>) -> vec3<f32> {
    let lum = get_luminance(color);
    var c = vec3<f32>(lum);
    c = adjust_contrast(c, 1.5); // High contrast B&W
    return c;
}

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    // 1. Chromatic Aberration (modifies UV sampling)
    var color = vec3<f32>(0.0);
    if material.aberration_strength > 0.0 {
        color = chromatic_aberration(in.uv, material.aberration_strength);
    } else {
        color = textureSample(base_texture, base_sampler, in.uv).rgb;
    }

    // 2. Manual Color Correction
    color = color + material.brightness;
    color = adjust_contrast(color, material.contrast);
    color = adjust_saturation(color, material.saturation);

    // 3. Filter Presets
    if material.filter_mode > 0u {
        var filtered = color;
        switch material.filter_mode {
            case 1u: { filtered = filter_vintage(color); }
            case 2u: { filtered = filter_noir(color); }
            default: { filtered = color; }
        }
        color = mix(color, filtered, material.filter_strength);
    }

    // 4. Posterization (Stylized)
    if material.posterize_steps > 0.0 {
        color = posterize(color, material.posterize_steps);
    }

    // 5. Edge Detection Overlay
    if material.edge_show > 0u {
        let edge = sobel_edge_detection(in.uv);
        // Green edges overlaid on top
        color = mix(color, vec3<f32>(0.0, 1.0, 0.0), edge * 5.0);
    }

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

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

We map our Rust struct to the WGSL layout.

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

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct PhotoFilterMaterial {
    // Manual Correction
    #[uniform(0)]
    pub brightness: f32,
    #[uniform(0)]
    pub contrast: f32,
    #[uniform(0)]
    pub saturation: f32,

    // Artistic Presets
    #[uniform(0)]
    pub filter_mode: u32,
    #[uniform(0)]
    pub filter_strength: f32,

    // Advanced Effects
    #[uniform(0)]
    pub aberration_strength: f32,
    #[uniform(0)]
    pub posterize_steps: f32,
    #[uniform(0)]
    pub edge_show: u32,
    #[uniform(0)]
    pub _padding: f32,

    // The Render Target
    #[texture(1)]
    #[sampler(2)]
    pub base_texture: Handle<Image>,
}

impl Default for PhotoFilterMaterial {
    fn default() -> Self {
        Self {
            brightness: 0.0,
            contrast: 1.0,
            saturation: 1.0,
            filter_mode: 0,
            filter_strength: 1.0,
            aberration_strength: 0.0,
            posterize_steps: 0.0,
            edge_show: 0,
            _padding: 0.0,
            base_texture: Handle::default(),
        }
    }
}

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

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

pub mod d03_08_photo_filter;

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

We've added an OrbitCamera system so you can rotate around the scene using the Left/Right Arrow keys. We also use an Orthographic Projection for the Post-Processing camera to ensure our filter quad fills the window perfectly.

use crate::materials::d03_08_photo_filter::PhotoFilterMaterial;
use bevy::prelude::*;
use bevy::render::camera::{RenderTarget, ScalingMode};
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat, TextureUsages};
use bevy::render::view::RenderLayers;

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

#[derive(Resource)]
struct FilterState {
    material_handle: Handle<PhotoFilterMaterial>,
}

#[derive(Component)]
struct Rotator;

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

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<PhotoFilterMaterial>>,
    mut standard_materials: ResMut<Assets<StandardMaterial>>,
    mut images: ResMut<Assets<Image>>,
) {
    // 1. Setup Render Target (High Res for quality)
    let size = Extent3d {
        width: 1920,
        height: 1080,
        ..default()
    };

    let mut image = Image {
        texture_descriptor: bevy::render::render_resource::TextureDescriptor {
            label: Some("render_target"),
            size,
            dimension: TextureDimension::D2,
            format: TextureFormat::Rgba8UnormSrgb,
            mip_level_count: 1,
            sample_count: 1,
            usage: TextureUsages::TEXTURE_BINDING
                | TextureUsages::COPY_DST
                | TextureUsages::RENDER_ATTACHMENT,
            view_formats: &[],
        },
        ..default()
    };
    image.resize(size);
    let image_handle = images.add(image);

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

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

    // 4. The Screen Quad (Layer 1)
    // Height 1.0 matches Camera height. Width 16/9 matches render target aspect.
    let filter_mat = materials.add(PhotoFilterMaterial {
        base_texture: image_handle,
        ..default()
    });

    commands.spawn((
        Mesh3d(meshes.add(Rectangle::new(1.0, 1.0))),
        MeshMaterial3d(filter_mat.clone()),
        Transform::from_scale(Vec3::new(16.0 / 9.0, 1.0, 1.0)),
        RenderLayers::layer(1),
    ));

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

    // 5. The Scene Content (Layer 0)
    commands.spawn((
        Mesh3d(meshes.add(Cuboid::default())),
        MeshMaterial3d(standard_materials.add(Color::srgb(1.0, 0.1, 0.1))),
        Transform::from_xyz(-1.2, 0.0, 0.0),
        Rotator,
        RenderLayers::layer(0),
    ));

    commands.spawn((
        Mesh3d(meshes.add(Sphere::default())),
        MeshMaterial3d(standard_materials.add(Color::srgb(0.1, 1.0, 0.1))),
        Transform::from_xyz(1.2, 0.0, 0.0),
        Rotator,
        RenderLayers::layer(0),
    ));

    commands.spawn((
        PointLight {
            intensity: 2_000_000.0,
            ..default()
        },
        Transform::from_xyz(2.0, 5.0, 3.0),
        RenderLayers::layer(0),
    ));

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

fn rotate_objects(time: Res<Time>, mut query: Query<&mut Transform, With<Rotator>>) {
    for mut t in &mut query {
        t.rotate_y(time.delta_secs());
        t.rotate_x(time.delta_secs() * 0.5);
    }
}

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 &mut query {
        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, 1.5, z);
        transform.look_at(Vec3::new(0.0, 0.0, 0.0), Vec3::Y);
    }
}

fn handle_input(
    keyboard: Res<ButtonInput<KeyCode>>,
    state: Res<FilterState>,
    mut materials: ResMut<Assets<PhotoFilterMaterial>>,
    time: Res<Time>,
) {
    let dt = time.delta_secs() / 2.0;

    if let Some(mat) = materials.get_mut(&state.material_handle) {
        // Presets
        if keyboard.just_pressed(KeyCode::Digit1) {
            mat.filter_mode = 0;
        }
        if keyboard.just_pressed(KeyCode::Digit2) {
            mat.filter_mode = 1;
        }
        if keyboard.just_pressed(KeyCode::Digit3) {
            mat.filter_mode = 2;
        }

        // Manual Color Corrections
        if keyboard.pressed(KeyCode::KeyQ) {
            mat.brightness -= dt;
        }
        if keyboard.pressed(KeyCode::KeyW) {
            mat.brightness += dt;
        }

        if keyboard.pressed(KeyCode::KeyA) {
            mat.contrast -= dt;
        }
        if keyboard.pressed(KeyCode::KeyS) {
            mat.contrast += dt;
        }

        if keyboard.pressed(KeyCode::KeyZ) {
            mat.saturation -= dt;
        }
        if keyboard.pressed(KeyCode::KeyX) {
            mat.saturation += dt;
        }

        // Toggles
        if keyboard.just_pressed(KeyCode::KeyC) {
            // 0.10 (10%) shift is a massive glitch effect!
            mat.aberration_strength = if mat.aberration_strength > 0.0 {
                0.0
            } else {
                0.10
            };
        }
        if keyboard.just_pressed(KeyCode::KeyP) {
            mat.posterize_steps = if mat.posterize_steps > 0.0 { 0.0 } else { 8.0 };
        }
        if keyboard.just_pressed(KeyCode::KeyE) {
            mat.edge_show = if mat.edge_show > 0 { 0 } else { 1 };
        }

        // Reset
        if keyboard.just_pressed(KeyCode::KeyR) {
            mat.brightness = 0.0;
            mat.contrast = 1.0;
            mat.saturation = 1.0;
        }
    }
}

fn update_ui(
    state: Res<FilterState>,
    materials: Res<Assets<PhotoFilterMaterial>>,
    mut text_q: Query<&mut Text>,
) {
    if let Some(mat) = materials.get(&state.material_handle) {
        for mut text in &mut text_q {
            let mode = match mat.filter_mode {
                0 => "None",
                1 => "Vintage",
                _ => "Noir",
            };
            **text = format!(
                "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",
                mode,
                mat.brightness,
                mat.contrast,
                mat.saturation,
                if mat.aberration_strength > 0.0 {
                    "ON"
                } else {
                    "OFF"
                },
                if mat.posterize_steps > 0.0 {
                    "ON"
                } else {
                    "OFF"
                },
                if mat.edge_show > 0 { "ON" } else { "OFF" }
            );
        }
    }
}

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

pub mod d03_08_photo_filter;

And register it in src/main.rs:

Demo {
    number: "3.8",
    title: "Advanced Color Techniques",
    run: demos::d03_08_photo_filter::run,
},

Running the Demo

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.

Controls

KeyActionDescription
Left / RightOrbitRotate the camera around the scene.
1-3PresetsSwitch between None, Vintage, and Noir.
Q / WBrightnessIncrease or Decrease Brightness.
A / SContrastIncrease or Decrease Contrast.
Z / XSaturationIncrease or Decrease Saturation.
CAberrationToggle Chromatic Aberration (Strong Glitch Effect).
PPosterizeToggle 8-bit color quantization.
EEdge DetectToggle Sobel Edge Detection overlay.
RResetReset manual color adjustments.

What You're Seeing

  1. Orbit Controls: Use the arrows to fly around. This makes the Edge Detection effect much more obvious, as you can see the green outlines reacting dynamically to the changing silhouette of the cube and sphere.

  2. Full Pipeline: All adjustments happen sequentially in the shader. You can, for example, turn on "Vintage" mode, then manually boost the Contrast using A, and then add Chromatic Aberration with C for a very stylized "broken camera" look.

  3. Render Target: 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).


Key Takeaways

  1. Color Space Mastery: Knowing when to use RGB vs. Luminance vs. HSV is half the battle.

  2. Order Matters: Applying contrast before saturation yields different results than saturation before contrast. A standard pipeline is Gamma → Exposure → Contrast → Saturation.

  3. Render-To-Texture: To apply effects to the whole screen (like Chromatic Aberration), you need to capture the scene into a texture first.

  4. Sampling Tricks: Many cool effects (Aberration, Blur, Edge Detection) come from simply sampling the texture multiple times at slightly different coordinates.

  5. Artistic Control: Shaders aren't just for realism. Tools like Posterization and LUTs allow you to define a unique visual identity for your game.

What's Next?

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.

Next up: 4.1 - Texture Sampling Basics


Quick Reference

Chromatic Aberration (Linear):

let offset = (uv - 0.5) * strength;
let red    = textureSample(tex, s, uv - offset).r;
let blue   = textureSample(tex, s, uv + offset).b;

Luminance (Rec. 709):

let lum = dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));

Sobel Edge Logic:

// 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);