3.2 - Color Spaces and Operations

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 physics, human perception, and decades of display technology evolution. Understanding these details is the difference between colors that look okay and colors that look correct.
This article will take you from simply setting colors to truly understanding 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.
By the end of this article, you'll have mastered:
The foundational RGB color model and the role of the alpha channel.
The critical difference between Linear and sRGB color spaces, and why gamma correction is so important for lighting.
How to perform mathematical operations on colors to tint, brighten, and blend them.
Using the
mix()function to create smooth, powerful gradients.How to work with the artist-friendly HSV (Hue, Saturation, Value) color model.
How to use color temperature to give your lighting a realistic warm or cool feel.
The concept of High Dynamic Range (HDR) color and why it's essential for realistic lighting.
Understanding the RGB Color Space
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 Red, Green, and Blue light.
The Additive Color Model
RGB is an additive model, which means you start with black (no light) and add 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.
No Light: (R:0, G:0, B:0) results in black.
Full Light: (R:1, G:1, B:1) results in white.
By combining the three primary colors of light, we can form secondary colors:

RGB in WGSL and Bevy
In WGSL, we represent an RGB color as a three-component floating-point vector, vec3<f32>, where each component typically ranges from 0.0 to 1.0.
// Primary colors
let red: vec3<f32> = vec3(1.0, 0.0, 0.0);
let green: vec3<f32> = vec3(0.0, 1.0, 0.0);
let blue: vec3<f32> = vec3(0.0, 0.0, 1.0);
// Secondary colors
let yellow: vec3<f32> = vec3(1.0, 1.0, 0.0); // Red + Green
let magenta: vec3<f32> = vec3(1.0, 0.0, 1.0); // Red + Blue
let cyan: vec3<f32> = vec3(0.0, 1.0, 1.0); // Green + Blue
// Other examples
let orange: vec3<f32> = vec3(1.0, 0.5, 0.0); // Full red, half green
let white: vec3<f32> = vec3(1.0, 1.0, 1.0);
let gray: vec3<f32> = vec3(0.5, 0.5, 0.5);
This maps directly to how you often define colors in Bevy. When you write Color::srgb(1.0, 0.5, 0.0) in Rust, you are providing the same three values that will eventually be sent to a vec3<f32> in your shader.
Why [0.0, 1.0] Instead of [0, 255]?
You might be more familiar with colors represented as integers from 0 to 255. Shaders use normalized floating-point values for several critical reasons:
Precision: Floats allow for incredibly smooth gradients and subtle color variations that would be impossible with only 256 integer steps.
Mathematical Simplicity: Performing math like blending or scaling is far more natural with values in a
[0, 1]range. Multiplying0.5 * 0.5is simpler than128 * 128 / 255.High Dynamic Range (HDR): As we'll see later, floats allow us to represent brightness values greater than 1.0, which is essential for realistic lighting.
Hardware Optimization: GPUs are massively parallel processors designed from the ground up to excel at floating-point vector math.
Component Access and Swizzling
WGSL provides a convenient way to access and rearrange the components of a vector, known as swizzling.
let color = vec3<f32>(0.8, 0.3, 0.2);
// Access individual components by name
let r = color.r; // 0.8
let g = color.g; // 0.3
let b = color.b; // 0.2
// You can also use .x, .y, .z
let x = color.x; // 0.8
// Swizzling: reorder or duplicate components to create new vectors
let bgr = color.bgr; // vec3(0.2, 0.3, 0.8)
let rrr = color.rrr; // vec3(0.8, 0.8, 0.8) - a gray value
let gb = color.gb; // vec2(0.3, 0.2)
The Problem: Perception vs. Reality
Here is a critical insight that sets the stage for our next topic: the RGB color space is not perceptually uniform. 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.
Mathematical steps: What we perceive:
0.0 → 0.25 → 0.5 Dark → Still quite dark → Suddenly much brighter!
The perceived jump in brightness from 0.0 to 0.5 is much smaller than the jump from 0.5 to 1.0.
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 gamma correction and color spaces.
The Alpha Channel: RGBA and Transparency
To handle transparency, the three-component RGB model is extended with a fourth component: Alpha. This creates the RGBA color model, which is the standard output format (vec4<f32>) for fragment shaders.
Understanding Alpha
The alpha channel represents a fragment's opacity. It's a value from 0.0 to 1.0 that dictates how much it obscures what is behind it.
Alpha = 1.0: Fully opaque. The fragment completely replaces the color behind it.
Alpha = 0.5: 50% transparent. The final color is a mix of the fragment's color and the background color.
Alpha = 0.0: Fully transparent. The fragment is effectively invisible; the background color is unchanged.
In WGSL, we use a vec4<f32> to represent RGBA color.
// An opaque orange
let opaque_orange = vec4<f32>(1.0, 0.5, 0.0, 1.0);
// A 50% transparent blue
let transparent_blue = vec4<f32>(0.0, 0.0, 1.0, 0.5);
// An invisible green (the RGB values don't matter when alpha is 0)
let invisible_green = vec4<f32>(0.0, 1.0, 0.0, 0.0);
How Alpha Blending Works
When a transparent fragment is rendered, the GPU performs a blend operation 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.
The formula is:
Final Color = (Source Color × Source Alpha) + (Destination Color × (1 - Source Alpha))
Let's break this down. Imagine rendering a 50% transparent red fragment over a solid blue background:
let source = vec4<f32>(1.0, 0.0, 0.0, 0.5); // Red, 50% alpha
let destination = vec3<f32>(0.0, 0.0, 1.0); // Blue background
// GPU performs this math:
let source_contribution = source.rgb * source.a; // (1,0,0) * 0.5 = (0.5, 0.0, 0.0)
let dest_contribution = destination * (1.0 - source.a); // (0,0,1) * 0.5 = (0.0, 0.0, 0.5)
let result = source_contribution + dest_contribution; // (0.5, 0.0, 0.5) -> Purple
The result is purple, an equal mix of red and blue, which is exactly what we'd expect.
Enabling Transparency in Bevy
Simply returning an alpha value less than 1.0 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.
This is done by overriding the alpha_mode function in your Material implementation in Rust.
// In your material's .rs file
use bevy::pbr::AlphaMode;
impl Material for MyTransparentMaterial {
// ... other functions ...
fn alpha_mode(&self) -> AlphaMode {
AlphaMode::Blend // This enables traditional alpha blending
}
}
Bevy offers several AlphaMode options for different effects:
AlphaMode::Opaque: The default. Fastest performance. Ignores the alpha channel.AlphaMode::Blend: Normal alpha blending. Essential for glass, water, and smooth fades.AlphaMode::Mask(f32): "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.AlphaMode::Add: Additive blending. Adds the source color to the destination. Perfect for fire, sparks, and glowing effects.
Performance Note: Transparency is expensive. Enabling any alpha mode other than Opaque 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.
Linear vs. sRGB: The Most Important Topic in Color
This is the most critical concept you will learn about color. Understanding the difference between Linear and sRGB 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.
The Problem: Physics vs. Perception
As we discussed, there's a mismatch between how light behaves physically and how our eyes perceive it.
Physical Light (Linear Space): 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 (
1 + 1 = 2). All physics-based calculations, especially for lighting, must be done in this linear space to be correct.Human Vision (Non-Linear): 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.
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.
The Solution: sRGB and Gamma Correction
To solve this, nearly all images you see (.png, .jpg), colors you pick, and monitors you use operate in a non-linear color space called sRGB. The sRGB standard includes a process called gamma correction.
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.

The Golden Rule for Shaders
This creates a critical workflow for rendering:
Input: All color textures and color values start in sRGB space (because that's how artists create them and how they're stored).
Conversion to Linear: Before any math is performed, these sRGB colors must be converted to Linear space. This is called "gamma decoding".
Calculations: All lighting, blending, and mixing math is performed in Linear space, where the physics is correct.
Conversion to sRGB: After all calculations are complete, the final linear color must be converted back to sRGB space. This is "gamma encoding".
Output: The final, gamma-encoded color is sent to the monitor, which then displays it correctly.
This might sound complicated, but Bevy and the GPU do almost all of this for you!
How Bevy Manages Color Spaces
Bevy is designed to make this process seamless as long as you follow its conventions.
Textures: When you load a standard image texture, Bevy tells the GPU it's in sRGB format. The GPU hardware then automatically converts it to linear space for you every time you sample it in the shader.
Colors from Rust: When you define a color with
Color::srgb(...)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.Final Output: Bevy configures the screen's framebuffer to expect sRGB output. This tells the GPU to automatically convert the final linear color you return from your fragment shader into sRGB space as it's written to the screen.
Your only responsibility is to follow the golden rule:
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.
Here’s a visual example of why this is so important. Let's try to blend red and green.
// ✓ CORRECT: Blending in Linear space (what your shader should do)
// The GPU has already converted the sRGB inputs to linear for you.
let linear_red = vec3<f32>(1.0, 0.0, 0.0);
let linear_green = vec3<f32>(0.0, 1.0, 0.0);
let correct_yellow = mix(linear_red, linear_green, 0.5);
// The result is a bright, correct yellow.
// The GPU will then convert this to sRGB for display.
// ✗ WRONG: Blending raw sRGB values (will produce incorrect results)
let srgb_red = vec3<f32>(1.0, 0.0, 0.0); // Imagine this was a raw sRGB value
let srgb_green = vec3<f32>(0.0, 1.0, 0.0);
let wrong_yellow = pow(mix(pow(srgb_red, vec3(2.2)), pow(srgb_green, vec3(2.2)), 0.5), vec3(1.0/2.2));
// This is what happens if you do the math in sRGB space. The result is a darker, muddy yellow
// because the gamma curve is applied incorrectly relative to the math.
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.
Color Arithmetic
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.
Color Addition (Light Accumulation)
Adding two colors together is like shining two lights onto the same spot. Their light combines, becoming brighter.
let red_light = vec3<f32>(1.0, 0.0, 0.0);
let green_light = vec3<f32>(0.0, 1.0, 0.0);
// Adding the two light colors results in yellow
let yellow_light = red_light + green_light; // vec3(1.0, 1.0, 0.0)
Key Use Cases:
Combining Multiple Lights: 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.
Emissive/Glow Effects: To make a surface glow, you add a color to it after all lighting has been calculated.
Important Note: Adding colors can easily result in values greater than 1.0. 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.
Color Multiplication (Filtering)
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.
// A white light shining on a red surface
let white_light = vec3<f32>(1.0, 1.0, 1.0);
let red_surface_color = vec3<f32>(1.0, 0.0, 0.0);
// The surface "filters" the white light, reflecting only red
let final_color = white_light * red_surface_color; // vec3(1.0, 0.0, 0.0)
The multiplication is done component-wise: (r1*r2, g1*g2, b1*b2). This means that if either color has a 0.0 in a channel, the result will also have a 0.0 in that channel.
Key Use Cases:
Applying Textures: 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.
Tinting: Multiplying a scene by a specific color can apply a tint.
Shadowing: A simple way to apply shadows is to multiply the final color by a shadow factor (e.g.,
0.5for 50% shadow,0.0for full shadow).
You can also multiply a color by a single scalar value (f32) to scale its brightness.
let orange = vec3<f32>(1.0, 0.5, 0.0);
let darker = orange * 0.5; // vec3(0.5, 0.25, 0.0)
let brighter = orange * 2.0; // vec3(2.0, 1.0, 0.0) - An HDR color
Color Subtraction
Subtraction is less common but can be useful for specific effects. It's like removing specific colors of light.
let white_light = vec3<f32>(1.0, 1.0, 1.0);
let red_light = vec3<f32>(1.0, 0.0, 0.0);
// Removing red light from white light leaves cyan
let cyan_light = white_light - red_light; // vec3(0.0, 1.0, 1.0)
Key Use Cases:
Creating complementary colors.
Certain types of color correction or filtering effects.
Be aware that subtraction can result in negative values, which are physically meaningless for color. You should usually clamp the result to zero.
let color = vec3<f32>(0.2, 0.8, 0.5);
let to_remove = vec3<f32>(0.3, 0.1, 0.0);
// Using max() prevents any component from going below 0.0
let result = max(color - to_remove, vec3<f32>(0.0));
The mix() Function: Smooth Color Blending
One of the most powerful and frequently used built-in functions in WGSL for color work is mix(). It performs linear interpolation 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.
How mix() Works
The function signature is mix(a, b, t), where:
ais the starting value (e.g., the first color).bis the ending value (e.g., the second color).tis the interpolation factor, a float typically in the[0.0, 1.0]range that determines the blend amount.
The underlying math is: result = a * (1.0 - t) + b * t.
When
tis0.0, the result isa * 1.0 + b * 0.0, which is 100%a.When
tis1.0, the result isa * 0.0 + b * 1.0, which is 100%b.When
tis0.5, the result isa * 0.5 + b * 0.5, an even 50/50 blend ofaandb.
let red = vec3<f32>(1.0, 0.0, 0.0);
let blue = vec3<f32>(0.0, 0.0, 1.0);
// A 25% blend from red to blue (mostly red)
let result = mix(red, blue, 0.25); // vec3(0.75, 0.0, 0.25)
// A 75% blend from red to blue (mostly blue)
let result = mix(red, blue, 0.75); // vec3(0.25, 0.0, 0.75)
Use Case 1: Creating Gradients
The most common use for mix() is creating gradients. By varying the t factor across a surface using its UV coordinates, you can create smooth color transitions.
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let color_a = vec3<f32>(1.0, 0.8, 0.0); // Gold
let color_b = vec3<f32>(0.8, 0.0, 1.0); // Purple
// Use the horizontal texture coordinate (0.0 on left, 1.0 on right)
// as the blend factor.
let t = in.uv.x;
let gradient_color = mix(color_a, color_b, t);
return vec4<f32>(gradient_color, 1.0);
}
This simple code will produce a smooth horizontal gradient from gold to purple across your mesh.
Use Case 2: Multi-Color Gradients
You can chain mix() calls to create gradients with more than two colors. A common way is to check which segment the t value is in and remap it to a [0, 1] range for that segment.
fn three_color_gradient(color_a: vec3<f32>, color_b: vec3<f32>, color_c: vec3<f32>, t: f32) -> vec3<f32> {
if t < 0.5 {
// First half of the gradient: blend from A to B
// We remap t from [0.0, 0.5] to [0.0, 1.0] by multiplying by 2.
return mix(color_a, color_b, t * 2.0);
} else {
// Second half of the gradient: blend from B to C
// We remap t from [0.5, 1.0] to [0.0, 1.0] by subtracting 0.5 and then multiplying by 2.
return mix(color_b, color_c, (t - 0.5) * 2.0);
}
}
Use Case 3: Conditional Blending
mix() 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.
// Assume 'normal' is the surface normal and 'up' is vec3(0.0, 1.0, 0.0)
let grass_color = vec3<f32>(0.1, 0.6, 0.2);
let rock_color = vec3<f32>(0.5, 0.5, 0.5);
// dot(normal, up) gives 1.0 for flat ground, 0.0 for vertical cliffs.
let flatness = saturate(dot(normal, up));
// Blend between rock and grass based on the flatness.
// Cliffs will be pure rock, flat ground will be pure grass.
let terrain_color = mix(rock_color, grass_color, flatness);
This is far more efficient than an if statement and produces a much more natural, smooth transition between the two colors.
Use Case 4: Animated Effects
By driving the t factor with time, you can create dynamic effects like fades, color cycling, or pulses.
// Make a color pulse by blending towards white and back.
let base_color = vec3<f32>(1.0, 0.0, 0.0);
let pulse_color = vec3<f32>(1.0, 1.0, 1.0); // White
// sin(time) gives a smooth oscillation between -1 and 1.
// We map it to the [0, 1] range to use as a blend factor.
let pulse_t = sin(material.time * 5.0) * 0.5 + 0.5;
let final_color = mix(base_color, pulse_color, pulse_t);
HSV Color Space: An Artist's Perspective
While the RGB model is perfect for computers and displays, it's not very intuitive for humans. If you have an orange color (1.0, 0.5, 0.0) 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.
This is where the HSV (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.
The Three Components of HSV
Hue: 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
[0, 360]degree range to a[0.0, 1.0]float.Saturation: This is the "intensity" or "purity" of the color. A saturation of
1.0is a vibrant, fully saturated color. A saturation of0.0is completely desaturated - a grayscale color (white, gray, or black).Value: This is the "brightness" or "lightness" of the color. A value of
1.0is the full, bright color. A value of0.0is always black, regardless of the hue or saturation.

Why Use HSV?
Working in HSV makes many common color adjustments trivial:
To make a color darker? Decrease the Value.
To make a color paler or more washed-out? Decrease the Saturation.
To shift a color to a neighboring one (e.g., orange to yellow)? Just add a small amount to the Hue, letting it wrap around the color wheel.
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:
Start with a color in RGB.
Convert it to HSV.
Perform your adjustments on the H, S, or V components.
Convert the result back to RGB for the final output.
RGB ↔ HSV Conversion in WGSL
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.
// Converts an RGB color (each component in [0,1]) to HSV
// Returns a vec3<f32> where:
// - x is Hue [0,1]
// - y is Saturation [0,1]
// - z is Value [0,1]
fn rgb_to_hsv(rgb: vec3<f32>) -> vec3<f32> {
let c_max = max(rgb.r, max(rgb.g, rgb.b));
let c_min = min(rgb.r, min(rgb.g, rgb.b));
let delta = c_max - c_min;
var hue: f32;
if (delta == 0.0) {
hue = 0.0;
} else if (c_max == rgb.r) {
hue = ((rgb.g - rgb.b) / delta) % 6.0;
} else if (c_max == rgb.g) {
hue = (rgb.b - rgb.r) / delta + 2.0;
} else {
hue = (rgb.r - rgb.g) / delta + 4.0;
}
hue = hue * 60.0; // convert to degrees
if (hue < 0.0) {
hue += 360.0;
}
let saturation = select(0.0, delta / c_max, c_max > 0.0);
let value = c_max;
return vec3<f32>(hue / 360.0, saturation, value); // Normalize hue to [0,1]
}
// Converts an HSV color (each component in [0,1]) back to RGB
fn hsv_to_rgb(hsv: vec3<f32>) -> vec3<f32> {
let h = hsv.x * 360.0; // convert hue back to degrees
let s = hsv.y;
let v = hsv.z;
let c = v * s;
let x = c * (1.0 - abs((h / 60.0) % 2.0 - 1.0));
let m = v - c;
var rgb_prime: vec3<f32>;
if (h < 60.0) {
rgb_prime = vec3<f32>(c, x, 0.0);
} else if (h < 120.0) {
rgb_prime = vec3<f32>(x, c, 0.0);
} else if (h < 180.0) {
rgb_prime = vec3<f32>(0.0, c, x);
} else if (h < 240.0) {
rgb_prime = vec3<f32>(0.0, x, c);
} else if (h < 300.0) {
rgb_prime = vec3<f32>(x, 0.0, c);
} else {
rgb_prime = vec3<f32>(c, 0.0, x);
}
return rgb_prime + vec3<f32>(m);
}
Practical Color Adjustments with HSV
With these conversion functions, adjusting colors becomes incredibly intuitive.
// Start with an RGB color
let base_color = vec3<f32>(1.0, 0.2, 0.0); // An orange color
// 1. Convert to HSV
var hsv = rgb_to_hsv(base_color);
// 2. Perform intuitive adjustments
// Shift the hue by 10% of the color wheel (orange -> yellow)
hsv.x = fract(hsv.x + 0.1);
// Reduce the saturation by 50% (make it paler)
hsv.y *= 0.5;
// Increase the brightness by 10%
hsv.z = min(hsv.z * 1.1, 1.0); // clamp to 1.0
// 3. Convert back to RGB for output
let adjusted_color = hsv_to_rgb(hsv);
This ability to precisely and predictably manipulate color is invaluable for procedural generation, UI feedback, and creating rich, dynamic visual effects.
Color Temperature and Tinting
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 color temperature, and simulating it is a powerful way to establish the mood and realism of a scene.
Understanding Color Temperature
Color temperature is a concept from physics that describes the color of light emitted by an idealized object (a "black body") as it's heated. It's measured in Kelvin (K).
Low Kelvin (1000K - 3000K): Corresponds to lower heat and produces "warm" light, shifting towards red, orange, and yellow. Think of candle flames, fire, and old incandescent light bulbs.
Mid Kelvin (5000K - 6500K): Corresponds to neutral, "daylight" white. This is the temperature of direct sunlight or a photography flash.
High Kelvin (7000K - 10000K+): Corresponds to higher heat and produces "cool" light, shifting towards blue. Think of an overcast sky or the deep blue of a clear sky.

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.
The "Hot is Cool" Paradox
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.
A perfect real-world example is a flame.
A butane lighter flame burns at around 1600 K. It has a soft, yellow-orange color which we artistically call "warm".
An oxy-acetylene torch flame burns at over 3500 K, more than twice as hot. Its color is a piercing, blue-white which we artistically call "cool".
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).
So, when working with color temperature, just remember the relationship:
Low Kelvin = Physically Cooler = Artistically Warm (a candle)
High Kelvin = Physically Hotter = Artistically Cool (a torch flame)
Kelvin to RGB Conversion in WGSL
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.
This function takes a temperature in Kelvin and returns a corresponding RGB tint color.
// Approximates an RGB color tint from a temperature in Kelvin.
fn kelvin_to_rgb(kelvin: f32) -> vec3<f32> {
// Normalize temperature to a [0,1] range over a typical artistic spectrum (e.g., 1000K to 10000K)
let t = saturate((kelvin - 1000.0) / 9000.0);
// Key color points
let warm_color = vec3<f32>(1.0, 0.6, 0.2); // A rich orange for warm temperatures
let neutral_color = vec3<f32>(1.0, 1.0, 1.0); // Neutral white
let cool_color = vec3<f32>(0.8, 0.9, 1.0); // A soft, cool blue
// Blend between warm and neutral for the first half,
// and neutral and cool for the second half.
if (t < 0.5) {
return mix(warm_color, neutral_color, t * 2.0);
} else {
return mix(neutral_color, cool_color, (t - 0.5) * 2.0);
}
}
Applying the Tint to Lighting
Once you have this tint color, you apply it by multiplying it with your light's base color and intensity.
// A simple lighting calculation
fn calculate_light(light_intensity: f32, temperature_kelvin: f32, surface_color: vec3<f32>) -> vec3<f32> {
// 1. Get the color of the light based on its temperature
let light_tint = kelvin_to_rgb(temperature_kelvin);
// 2. The full light color is its tint multiplied by its brightness
let final_light_color = light_tint * light_intensity;
// 3. The light reflects off the surface
let final_surface_color = surface_color * final_light_color;
return final_surface_color;
}
// Example usage
let lit_color = calculate_light(1.5, 2700.0, my_material.color); // Lit by a warm bulb
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.
From LDR to HDR: Handling Real-World Brightness
So far, we've mostly treated colors as being within the [0.0, 1.0] range. This is known as Low Dynamic Range (LDR). It's simple, but it doesn't accurately represent how light works in the real world. This is where High Dynamic Range (HDR) comes in, and understanding it is key to creating realistic lighting.
The Limits of LDR
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.
The white paper reflects a certain amount of light.
The lightbulb emits much more light.
The sun's reflection is orders of magnitude brighter still.
In a strict LDR world, all of these would be clamped to the same maximum value: vec3<f32>(1.0, 1.0, 1.0). 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.
What is High Dynamic Range (HDR)?
HDR rendering solves this problem by getting rid of the artificial 1.0 limit. Since our shaders use floating-point numbers, we can output color values far greater than 1.0 to represent physically-based light intensity.
White Paper:
vec3<f32>(1.0, 1.0, 1.0)Bright Lightbulb:
vec3<f32>(15.0, 12.0, 8.0)Sun Reflection:
vec3<f32>(100.0, 100.0, 95.0)
By preserving this high dynamic range of brightness, our lighting calculations become far more realistic and enable advanced visual effects.
The Challenge: Displaying HDR on an LDR Monitor
There's a catch: your monitor is an LDR device. It can only display colors in the [0, 1] range. So how do we show these HDR values?
The naive approach is to simply clamp the final color, throwing away any brightness above 1.0.
// ✗ BAD: Destructive clamping
let final_color = clamp(hdr_color, vec3<f32>(0.0), vec3<f32>(1.0));
This is a destructive operation that crushes all of our carefully calculated brightness information into a flat, uniform white, as shown below.

The Solution: Tone Mapping
The correct solution is tone mapping. 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.
How Bevy Handles It: The Post-Processing Pipeline
Crucially, tone mapping is not 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 post-processing step.
Bevy's default rendering pipeline handles this for you automatically. 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.
By default, Bevy 0.16 uses an operator called TonyMcMapface, 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 AcesFitted (a popular film industry standard) and AgX (another excellent modern curve).
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.
Your Role in the Fragment Shader
This makes your job in the fragment shader simple but very important:
Calculate the correct final color in linear space and do not clamp it.
Trust Bevy's renderer to handle the HDR-to-LDR conversion correctly. By returning values greater than 1.0 for bright surfaces, you are providing the renderer with the necessary information to create realistic bloom, glow, and exposure effects.
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.
Complete Example: Interactive Color Mixer
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.
Our Goal
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.
What This Project Demonstrates
Live Color Manipulation: How to control a shader's color properties from Rust in real-time.
RGB vs. HSV Blending: A direct visual comparison of blending colors in different color spaces.
Practical Color Operations: See how Add, Multiply, Screen, and Overlay blend modes affect colors.
HSV Adjustments: Get a feel for the intuitive power of adjusting Hue, Saturation, and Value.
HDR in Action: Directly observe the destructive nature of clamping versus the detail-preserving power of tone mapping operators when using HDR values.
Shader Logic: How to use a uniform to switch between different behaviors within a single fragment shader.
The Shader (assets/shaders/d03_02_color_mixer.wgsl)
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 if/else if block that reads the material.mix_mode uniform. This single integer, controlled from Rust, determines which of the five demonstration modes is active.
Notice the ColorMixerMaterial struct at the top. It includes several _padding 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 1.7 - Uniforms and GPU Memory Layout.
#import bevy_pbr::forward_io::VertexOutput
struct ColorMixerMaterial {
time: f32,
mix_mode: u32,
exposure: f32,
tonemap_mode: u32,
rgb_a: vec3<f32>,
_padding1: f32,
rgb_b: vec3<f32>,
_padding2: f32,
hsv_a: vec3<f32>,
_padding3: f32,
hsv_b: vec3<f32>,
_padding4: f32,
temperature: f32,
mix_factor: f32,
operation_mode: u32,
_padding5: f32,
_padding6: f32,
}
@group(2) @binding(0)
var<uniform> material: ColorMixerMaterial;
// RGB to HSV conversion
fn rgb_to_hsv(rgb: vec3<f32>) -> vec3<f32> {
let K = vec4<f32>(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
let p = mix(vec4<f32>(rgb.bg, K.wz), vec4<f32>(rgb.gb, K.xy), step(rgb.b, rgb.g));
let q = mix(vec4<f32>(p.xyw, rgb.r), vec4<f32>(rgb.r, p.yzx), step(p.x, rgb.r));
let d = q.x - min(q.w, q.y);
let e = 1.0e-10;
return vec3<f32>(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}
// HSV to RGB conversion
fn hsv_to_rgb(hsv: vec3<f32>) -> vec3<f32> {
let K = vec4<f32>(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
let p = abs(fract(hsv.xxx + K.xyz) * 6.0 - K.www);
return hsv.z * mix(
K.xxx,
clamp(p - K.xxx, vec3<f32>(0.0), vec3<f32>(1.0)),
hsv.y
);
}
// Kelvin to RGB (simplified)
fn kelvin_to_rgb(kelvin: f32) -> vec3<f32> {
let t = clamp((kelvin - 1000.0) / 9000.0, 0.0, 1.0);
let warm = vec3<f32>(1.0, 0.7, 0.4);
let neutral = vec3<f32>(1.0, 1.0, 1.0);
let cool = vec3<f32>(0.7, 0.8, 1.0);
if t < 0.5 {
return mix(warm, neutral, t * 2.0);
} else {
return mix(neutral, cool, (t - 0.5) * 2.0);
}
}
// Color blending operations
fn blend_add(a: vec3<f32>, b: vec3<f32>) -> vec3<f32> {
return min(a + b, vec3<f32>(1.0));
}
fn blend_multiply(a: vec3<f32>, b: vec3<f32>) -> vec3<f32> {
return a * b;
}
fn blend_screen(a: vec3<f32>, b: vec3<f32>) -> vec3<f32> {
return vec3<f32>(1.0) - (vec3<f32>(1.0) - a) * (vec3<f32>(1.0) - b);
}
fn blend_overlay(a: vec3<f32>, b: vec3<f32>) -> vec3<f32> {
var result = vec3<f32>(0.0);
for (var i = 0; i < 3; i++) {
if a[i] < 0.5 {
result[i] = 2.0 * a[i] * b[i];
} else {
result[i] = 1.0 - 2.0 * (1.0 - a[i]) * (1.0 - b[i]);
}
}
return result;
}
// Tone mapping operators
fn tonemap_reinhard(hdr: vec3<f32>) -> vec3<f32> {
return hdr / (vec3<f32>(1.0) + hdr);
}
fn tonemap_aces(hdr: vec3<f32>) -> vec3<f32> {
let a = 2.51;
let b = 0.03;
let c = 2.43;
let d = 0.59;
let e = 0.14;
return clamp(
(hdr * (a * hdr + b)) / (hdr * (c * hdr + d) + e),
vec3<f32>(0.0),
vec3<f32>(1.0)
);
}
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
var final_color = vec3<f32>(0.0);
// UV-based mixing for visualization
let t = in.uv.x;
if material.mix_mode == 0u {
// RGB Mix Mode
final_color = mix(material.rgb_a, material.rgb_b, t);
} else if material.mix_mode == 1u {
// HSV Mix Mode
let hsv_mixed = mix(material.hsv_a, material.hsv_b, t);
final_color = hsv_to_rgb(hsv_mixed);
} else if material.mix_mode == 2u {
// Color Temperature Mode
let base_color = mix(material.rgb_a, material.rgb_b, t);
let temp_color = kelvin_to_rgb(material.temperature);
final_color = base_color * temp_color;
} else if material.mix_mode == 3u {
// Color Operations Mode
let rgb_a = material.rgb_a;
let rgb_b = material.rgb_b;
// Split screen: show both colors and blend in middle
if t < 0.33 {
final_color = rgb_a;
} else if t > 0.67 {
final_color = rgb_b;
} else {
// Middle section: show operation result
if material.operation_mode == 0u {
final_color = blend_add(rgb_a, rgb_b);
} else if material.operation_mode == 1u {
final_color = blend_multiply(rgb_a, rgb_b);
} else if material.operation_mode == 2u {
final_color = blend_screen(rgb_a, rgb_b);
} else {
final_color = blend_overlay(rgb_a, rgb_b);
}
}
} else if material.mix_mode == 4u {
// HDR and Tone Mapping Mode
// Create HDR colors (values can exceed 1.0)
let hdr_a = material.rgb_a * material.exposure;
let hdr_b = material.rgb_b * material.exposure;
// Mix in HDR
let hdr_mixed = mix(hdr_a, hdr_b, t);
// Show different sections: left=clamped, middle=Reinhard, right=ACES
if material.tonemap_mode == 0u {
// Clamp (shows loss of detail)
final_color = clamp(hdr_mixed, vec3<f32>(0.0), vec3<f32>(1.0));
} else if material.tonemap_mode == 1u {
// Reinhard
final_color = tonemap_reinhard(hdr_mixed);
} else {
// ACES
final_color = tonemap_aces(hdr_mixed);
}
} else if material.mix_mode != 4u {
var hsv = rgb_to_hsv(material.rgb_a);
// Shift hue, wrapping around the color wheel
hsv.x = fract(hsv.x + material.hsv_a.x);
// Adjust saturation (0.0 = grayscale, 1.0 = normal, >1.0 = super-saturated)
hsv.y = hsv.y * material.hsv_a.y;
// Adjust brightness
hsv.z = hsv.z * material.hsv_a.z;
let adjusted_color = hsv_to_rgb(saturate(hsv)); // Saturate HSV to keep it valid
final_color = mix(adjusted_color, material.rgb_a, t);
}
// Add animated pulse in bottom section
if in.uv.y < 0.2 {
let pulse = sin(material.time * 2.0) * 0.5 + 0.5;
final_color = mix(final_color, vec3<f32>(1.0), pulse * 0.2);
}
return vec4<f32>(final_color, 1.0);
}
The Rust Material (src/materials/d03_02_color_mixer.rs)
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.
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)]
pub struct ColorMixerMaterial {
pub time: f32,
pub mix_mode: u32,
pub exposure: f32,
pub tonemap_mode: u32,
pub rgb_a: Vec3,
pub _padding1: f32,
pub rgb_b: Vec3,
pub _padding2: f32,
pub hsv_a: Vec3,
pub _padding3: f32,
pub hsv_b: Vec3,
pub _padding4: f32,
pub temperature: f32,
pub mix_factor: f32,
pub operation_mode: u32,
pub _padding5: f32,
pub _padding6: f32,
}
impl Default for ColorMixerMaterial {
fn default() -> Self {
Self {
time: 0.0,
mix_mode: 0,
exposure: 1.0,
tonemap_mode: 1, // Default to Reinhard
rgb_a: Vec3::new(1.0, 0.0, 0.0), // Red
rgb_b: Vec3::new(0.0, 0.0, 1.0), // Blue
hsv_a: Vec3::new(0.0, 1.0, 1.0), // Red
hsv_b: Vec3::new(0.5, 1.0, 1.0), // Cyan
temperature: 5500.0,
mix_factor: 0.5,
operation_mode: 0,
_padding1: 0.0,
_padding2: 0.0,
_padding3: 0.0,
_padding4: 0.0,
_padding5: 0.0,
_padding6: 0.0,
}
}
}
}
pub use uniforms::ColorMixerMaterial as ColorMixerUniforms;
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct ColorMixerMaterial {
#[uniform(0)]
pub uniforms: ColorMixerUniforms,
}
impl Material for ColorMixerMaterial {
fn fragment_shader() -> ShaderRef {
"shaders/d03_02_color_mixer.wgsl".into()
}
}
Don't forget to add it to src/materials/mod.rs:
// ... other materials
pub mod d03_02_color_mixer;
The Demo Module (src/demos/d03_02_color_mixer.rs)
The demo module sets up our scene, which consists of a single large plane mesh, a camera, and a UI.
Notice that in our Material implementation, we only specified a fragment_shader(). We didn't provide a vertex_shader(). 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 (in.uv) 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.
The update systems listen for keyboard input, modify the uniforms on our material asset, and update the UI text to reflect the current state.
use crate::materials::d03_02_color_mixer::{ColorMixerMaterial, ColorMixerUniforms};
use bevy::prelude::*;
pub fn run() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(MaterialPlugin::<ColorMixerMaterial>::default())
.add_systems(Startup, setup)
.add_systems(Update, (update_time, handle_input, update_ui))
.run();
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMixerMaterial>>,
) {
// Create a plane to display the color mixing
let plane = Plane3d::default().mesh().size(8.0, 4.0).build();
commands.spawn((
Mesh3d(meshes.add(plane)),
MeshMaterial3d(materials.add(ColorMixerMaterial {
uniforms: ColorMixerUniforms::default(),
})),
// Rotate plane to face camera (Plane3d defaults to XZ plane)
Transform::from_xyz(0.0, 0.0, 0.0)
.with_rotation(Quat::from_rotation_x(std::f32::consts::FRAC_PI_2)),
));
// Camera
commands.spawn((
Camera3d::default(),
Transform::from_xyz(0.0, 0.0, 8.0).looking_at(Vec3::ZERO, Vec3::Y),
));
// UI
commands.spawn((
Text::new(
"[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",
),
Node {
position_type: PositionType::Absolute,
top: Val::Px(10.0),
left: Val::Px(10.0),
padding: UiRect::all(Val::Px(10.0)),
..default()
},
TextFont {
font_size: 14.0,
..default()
},
TextColor(Color::WHITE),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
));
}
fn update_time(time: Res<Time>, mut materials: ResMut<Assets<ColorMixerMaterial>>) {
for (_, material) in materials.iter_mut() {
material.uniforms.time = time.elapsed_secs();
}
}
fn handle_input(
keyboard: Res<ButtonInput<KeyCode>>,
time: Res<Time>,
mut materials: ResMut<Assets<ColorMixerMaterial>>,
) {
let alt = keyboard.pressed(KeyCode::AltLeft) || keyboard.pressed(KeyCode::AltRight);
let delta = if keyboard.pressed(KeyCode::ShiftLeft) || keyboard.pressed(KeyCode::ShiftRight) {
-time.delta_secs()
} else {
time.delta_secs()
};
for (_, material) in materials.iter_mut() {
// Mix mode selection
if keyboard.just_pressed(KeyCode::Digit1) {
material.uniforms.mix_mode = 0; // RGB
}
if keyboard.just_pressed(KeyCode::Digit2) {
material.uniforms.mix_mode = 1; // HSV
}
if keyboard.just_pressed(KeyCode::Digit3) {
material.uniforms.mix_mode = 2; // Temperature
}
if keyboard.just_pressed(KeyCode::Digit4) {
material.uniforms.mix_mode = 3; // Operations
}
if keyboard.just_pressed(KeyCode::Digit5) {
material.uniforms.mix_mode = 4; // HDR
}
if keyboard.just_pressed(KeyCode::Digit6) {
material.uniforms.mix_mode = 5; // Color grading
}
// RGB adjustments
if !alt {
// Color A adjustments
if keyboard.pressed(KeyCode::KeyR) {
material.uniforms.rgb_a.x = (material.uniforms.rgb_a.x + delta).max(0.0).min(1.0);
}
if keyboard.pressed(KeyCode::KeyG) {
material.uniforms.rgb_a.y = (material.uniforms.rgb_a.y + delta).max(0.0).min(1.0);
}
if keyboard.pressed(KeyCode::KeyB) {
material.uniforms.rgb_a.z = (material.uniforms.rgb_a.z + delta).max(0.0).min(1.0);
}
} else {
// Color B adjustments
if keyboard.pressed(KeyCode::KeyR) {
material.uniforms.rgb_b.x = (material.uniforms.rgb_b.x + delta).max(0.0).min(1.0);
}
if keyboard.pressed(KeyCode::KeyG) {
material.uniforms.rgb_b.y = (material.uniforms.rgb_b.y + delta).max(0.0).min(1.0);
}
if keyboard.pressed(KeyCode::KeyB) {
material.uniforms.rgb_b.z = (material.uniforms.rgb_b.z + delta).max(0.0).min(1.0);
}
}
// HSV adjustments
if !alt {
// Color A adjustments
if keyboard.pressed(KeyCode::KeyH) {
material.uniforms.hsv_a.x =
(material.uniforms.hsv_a.x + delta * 0.3).max(0.0).min(1.0);
}
if keyboard.pressed(KeyCode::KeyS) {
material.uniforms.hsv_a.y = (material.uniforms.hsv_a.y + delta).max(0.0).min(1.0);
}
if keyboard.pressed(KeyCode::KeyV) {
material.uniforms.hsv_a.z = (material.uniforms.hsv_a.z + delta).max(0.0).min(1.0);
}
} else {
// Color B adjustments
if keyboard.pressed(KeyCode::KeyH) {
material.uniforms.hsv_b.x =
(material.uniforms.hsv_b.x + delta * 0.3).max(0.0).min(1.0);
}
if keyboard.pressed(KeyCode::KeyS) {
material.uniforms.hsv_b.y = (material.uniforms.hsv_b.y + delta).max(0.0).min(1.0);
}
if keyboard.pressed(KeyCode::KeyV) {
material.uniforms.hsv_b.z = (material.uniforms.hsv_b.z + delta).max(0.0).min(1.0);
}
}
// Temperature
if keyboard.pressed(KeyCode::KeyK) {
material.uniforms.temperature = (material.uniforms.temperature + delta * 1000.0)
.max(1000.0)
.min(10000.0);
}
// Exposure (HDR)
if keyboard.pressed(KeyCode::KeyE) {
material.uniforms.exposure = (material.uniforms.exposure + delta).max(0.1).min(5.0);
}
// Tone mapping mode (cycles through modes)
if keyboard.just_pressed(KeyCode::KeyM) {
material.uniforms.tonemap_mode = (material.uniforms.tonemap_mode + 1) % 3;
}
// Operation mode (cycles through modes)
if keyboard.just_pressed(KeyCode::KeyO) {
material.uniforms.operation_mode = (material.uniforms.operation_mode + 1) % 4;
}
// Reset
if keyboard.just_pressed(KeyCode::Space) {
material.uniforms = ColorMixerUniforms::default();
}
}
}
mod ui {
use super::*;
// [K] Temperature | [E] Exposure (HDR) | [M] Tone Map Mode | [O] Operation | [Space] Reset\n\
// Hold Shift to decrease values\n\
// \n\
// Operation: {} | Tone Map: {}\n\
// {}
// {}
// Temperature: {:.0}K | Exposure: {:.2}x
pub fn rgb(material: &ColorMixerMaterial) -> String {
format!(
"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})",
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,
)
}
pub fn hsv(material: &ColorMixerMaterial) -> String {
format!(
"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})",
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,
)
}
pub fn temperature(material: &ColorMixerMaterial) -> String {
format!(
"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",
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,
)
}
pub fn operations(material: &ColorMixerMaterial) -> String {
let operation_name = match material.uniforms.operation_mode {
0 => "Add",
1 => "Multiply",
2 => "Screen",
3 => "Overlay",
_ => "Unknown",
};
format!(
"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: {}",
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,
)
}
pub fn hsv_adjustments(material: &ColorMixerMaterial) -> String {
format!(
"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}",
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,
)
}
pub fn tone_mapping(material: &ColorMixerMaterial) -> String {
let tonemap_name = match material.uniforms.tonemap_mode {
0 => "Clamp (Bad)",
1 => "Reinhard",
2 => "ACES Filmic",
_ => "Unknown",
};
format!(
"5 - HDR & 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",
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,
)
}
}
fn update_ui(materials: Res<Assets<ColorMixerMaterial>>, mut text_query: Query<&mut Text>) {
if !materials.is_changed() {
return;
}
if let Some((_, material)) = materials.iter().next() {
let ui = match material.uniforms.mix_mode {
0 => ui::rgb(material),
1 => ui::hsv(material),
2 => ui::temperature(material),
3 => ui::operations(material),
4 => ui::tone_mapping(material),
5 => ui::hsv_adjustments(material),
_ => "Unknown".to_string(),
};
for mut text in text_query.iter_mut() {
**text = format!("[1-6] Mix Mode -> {}", ui);
}
}
}
Don't forget to add it to src/demos/mod.rs:
// ... other demos
pub mod d03_02_color_mixer;
And register it in src/main.rs:
Demo {
number: "3.2",
title: "Color Spaces and Operations",
run: demos::d03_02_color_mixer::run,
},
Running the Demo
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.
Controls
| Key(s) | Action |
| 1-6 | Select the active visualization mode. |
| R, G, B | Hold to adjust RGB for Color A. |
| Alt + R, G, B | Hold to adjust RGB for Color B. |
| H, S, V | Hold to adjust HSV for Color A (or as adjustments in Mode 6). |
| Alt + H, S, V | Hold to adjust HSV for Color B. |
| Shift | Hold with any adjustment key to decrease the value. |
| K | Adjust the Color Temperature (in Temperature Mode). |
| E | Adjust the Exposure (in HDR Mode). |
| O | Cycle through the blend operations (in Operations Mode). |
| M | Cycle through the tone mapping operators (in HDR Mode). |
| Spacebar | Reset all values to their defaults. |
What You're Seeing






Mode 1 (RGB Mix): 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.
Mode 2 (HSV Mix): 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.
Mode 3 (Color Temperature): Applies a global tint to the base gradient. Use K to change the temperature and see how it affects the mood, from a warm, fire-lit feel to a cool, sterile one.
Mode 4 (Operations): The screen is split to show Color A, Color B, and the result of a blend operation in the middle. Press O to cycle through them and see how they combine colors.
Mode 5 (HDR & Tone Mapping):
Hold E to increase the Exposure above 1.0.
Press M to cycle the tone mappers.
On "Clamp (Bad)", bright areas will flatten into a detail-less white.
On "Reinhard" and "ACES Filmic", the gradient remains smooth and detailed, showing the power of tone mapping.
Mode 6 (HSV Adjustments): 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 Saturation) or shifting its hue (change Hue) and see how intuitive it is.
Key Takeaways
You've now covered the essential theory and practice of color in modern rendering. Before moving on, ensure these core concepts are clear:
Linear is for Math, sRGB is for Humans: All lighting and blending calculations must happen in linear color space to be physically correct. sRGB is a non-linear space used in images and on monitors that is optimized for human perception.
Bevy Handles the Conversions: 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.
Operations Have Physical Meanings: Adding colors is like combining lights. Multiplying colors is like filtering light through a material.
mix()is the correct way to blend between them.HSV is for Intuition: 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.
Don't Clamp Your Output: To achieve realistic lighting effects like bloom, your fragment shader must be able to output HDR color values greater than
1.0. Tone mapping, a final post-processing step handled by Bevy, is responsible for safely compressing this HDR range for your LDR monitor.
What's Next?
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?
In the next article, we will move beyond simple colors and learn how to use UV coordinates to create procedural patterns directly in the shader. We will master functions like fract(), step(), and length() to generate everything from simple stripes and checkerboards to complex circular and tiled designs, laying the foundation for procedural texturing.
Next up: 3.3 - UV-Based Patterns
Quick Reference
Color Representation in WGSL:
// RGB color
let red = vec3<f32>(1.0, 0.0, 0.0);
// RGBA color (with alpha)
let transparent_blue = vec4<f32>(0.0, 0.0, 1.0, 0.5);
Common Color Operations (in Linear Space):
// Add (combine light)
let yellow = vec3(1.0,0.,0.) + vec3(0.,1.,0.);
// Multiply (filter/tint)
let final_color = light_color * surface_color;
// Scale Brightness
let darker = final_color * 0.5;
let brighter_hdr = final_color * 2.0; // Can exceed 1.0
// Linear Interpolation (Blend)
let blended = mix(color_a, color_b, 0.5);
Desaturation (Grayscale):
// Perceptually-correct luminance for linear RGB
let luminance = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
let grayscale = vec3<f32>(luminance);
// Partial desaturation
let less_vibrant = mix(grayscale, color.rgb, 0.5);
Clamping to LDR (Use Sparingly):
// Clamp to [0,1] range. Avoid on final output in an HDR pipeline.
let ldr_color = saturate(hdr_color);
HSV Conversion:
// Use the full functions provided in the article for accurate conversion
let hsv = rgb_to_hsv(rgb_color);
let rgb = hsv_to_rgb(hsv_color);
// Manipulate HSV components for intuitive changes
hsv.x = fract(hsv.x + 0.1); // Shift Hue
hsv.y *= 0.5; // Desaturate
hsv.z *= 1.2; // Brighten
Color Temperature (Approximate):
// Get a tint color from a Kelvin value to multiply with your light
let light_tint = kelvin_to_rgb(2700.0); // Warm light bulb
Bevy Color Creation (Rust):
// In Rust, create colors in sRGB space (most common)
Color::srgb(1.0, 0.5, 0.0);
// For advanced cases, specify a color already in linear space
Color::linear_rgb(1.0, 0.25, 0.0);
Enabling Transparency (Rust):
use bevy::pbr::AlphaMode;
impl Material for MyMaterial {
fn alpha_mode(&self) -> AlphaMode {
AlphaMode::Blend // Or ::Mask(0.5), ::Add, etc.
}
}






