3.5 - Distance Functions (SDFs)

What We're Learning
In previous articles, we created patterns using explicit boundaries: "Is this pixel inside the square?" But there is a more powerful way to define shapes in computer graphics: "How far is this pixel from the edge?"
This is the world of Signed Distance Functions (SDFs). Instead of defining shapes as rigid masks, we define them as mathematical fields of distance. This shift unlocks capabilities that are impossible with traditional texture sprites or simple grid logic.
In this article, you will learn:
The SDF Mindset: Why treating shapes as distance fields is superior to hard masks.
Primitive Shapes: How to build circles, rectangles, and polygons using simple math.
Boolean Operations: Combining shapes using
min()andmax()instead of if statements.Smooth Blending: How to melt shapes together like liquid mercury.
Distance-Based Effects: Creating free outlines, glows, and soft shadows from a single value.
Infinite Resolution: Why SDFs look perfectly crisp at any zoom level or rotation.
Understanding Distance Fields
Before writing code, we need to understand the fundamental concept.
What is a Distance Field?
In a standard image (like a PNG texture), every pixel stores a color.
In a distance field, every pixel stores a number: the distance to the nearest edge of a shape.
For a circle, the "field" looks like a gradient radiating from the center.

The "Signed" Part
The "Signed" in SDF is crucial. It gives us orientation:
Negative (d < 0.0): Inside the shape.
Zero (d = 0.0): Exactly on the surface/edge.
Positive (d > 0.0): Outside the shape.
This sign tells us immediately not just where we are, but relationship to the shape.
// Example: A Circle SDF
// A point at the exact center has a distance of -radius.
// A point on the edge has a distance of 0.0.
let dist = length(p - center) - radius;
Visualizing the Field
Because an SDF is just a number, we can visualize it directly in the shader to debug our math.
1. The "Heatmap" View
If we output the distance directly as color, the "inside" (negative) becomes black (clamped to 0.0), and the "outside" (positive) becomes a gradient from black to white.
let dist = sdf_circle(uv, center, radius);
return vec4<f32>(vec3<f32>(dist), 1.0);
2. The "Contour" View
We can use math to draw lines at specific distance intervals, like a topographic map.
let dist = sdf_circle(uv, center, radius);
// Create repeating rings every 0.1 units
let contour = step(0.9, fract(dist * 10.0));
return vec4<f32>(vec3<f32>(contour), 1.0);
Why This Paradigm is Powerful
Why go through the trouble of calculating distances instead of just drawing a circle?
Infinite Resolution: SDFs are mathematical. They have no pixels. You can zoom in 1000x, and the edge will remain mathematically perfect.
Free Effects: Once you have the
distvariable, you can create a stroke (outline) just by checking ifabs(dist) < width. You can create a glow by checkingdist < glow_radius.Smooth Operations: You can blend shapes together smoothly by mathematically interpolating their distance values, something that is incredibly hard to do with standard geometry or textures.

Building Primitive SDFs
An SDF is just a function that takes a position (p) and returns a distance (f32).
The Coordinate System
To make the math easy, SDF functions usually assume the shape is centered at (0,0). Since our UV coordinates range from (0,0) to (1,0), we need to adjust them before passing them to the SDF.
// 1. Center the coordinates
// 0.0 to 1.0 --> -0.5 to 0.5
let p = in.uv - 0.5;
// 2. Correct aspect ratio (if your quad isn't a square)
// p.x *= aspect_ratio;
// 3. Calculate distance
let dist = sdf_circle(p, radius);
1. The Circle
The circle is our baseline. It relies on length(), which is the Euclidean distance formula ($\sqrt{x2+y2}$).
fn sdf_circle(p: vec2<f32>, radius: f32) -> f32 {
return length(p) - radius;
}
How it works: We calculate the distance from the origin to our pixel. By subtracting the radius, we shift the "zero point" from the center of the circle out to its edge.

If the pixel is at the center, distance is
0.0. Result:-radius(Deep inside).If the pixel is on the edge, distance is radius. Result:
0.0(On edge).If the pixel is far away, distance is huge. Result:
Positive(Outside).
2. The Rectangle
Rectangles introduce the concept of component-wise distance. We analyze the X distance and Y distance separately.
fn sdf_rectangle(p: vec2<f32>, half_size: vec2<f32>) -> f32 {
// 1. Shift origin to the corner of the box
// Inside the box, 'd' is negative. Outside, it's positive.
let d = abs(p) - half_size;
// 2. Calculate Outside Distance (for points outside the box)
// If d.x or d.y is negative (we are next to an edge), clamp it to 0.
// If both are positive (we are near a corner), length() gives precise distance to corner.
let outside = length(max(d, vec2<f32>(0.0)));
// 3. Calculate Inside Distance (for points inside the box)
// We want the distance to the CLOSEST edge, which is the larger (less negative) value.
// We clamp to 0 so this doesn't affect outside points.
let inside = min(max(d.x, d.y), 0.0);
return outside + inside;
}
How it works:
Imagine extending the sides of the rectangle to infinity. This creates a grid of 9 zones. The math handles each zone differently.

The Corners (Zones 1, 3, 7, 9):
Here, the pixel is outside both the X range and the Y range.d.xis positive,d.yis positive.length(d)calculates the diagonal distance to the corner point.
The Edges (Zones 2, 4, 6, 8):
Here, the pixel is aligned with the box on one axis, but outside on the other.Example Zone 2:
d.xis negative (aligned),d.yis positive (above).max(d, 0.0)clamps the negative X to0.0.The vector becomes
(0.0, d.y). The length is justd.y. This gives us a straight linear distance.
The Inside (Zone 5):
Here, the pixel is inside the box.d.xandd.yare both negative.The outside calculation becomes
length(0,0)which is0.The inside calculation picks the largest value (e.g., -0.1 is larger than -5.0). This ensures we measure distance to the closest edge.
3. The Rounded Box
Once you have a rectangle, making it rounded is incredibly simple. You don't change the math of the box; you change the definition of the "edge."
fn sdf_rounded_box(p: vec2<f32>, half_size: vec2<f32>, radius: f32) -> f32 {
// A rounded box is just a standard box...
let d = sdf_rectangle(p, half_size);
// ...with the edge "inflated" by the radius.
return d - radius;
}
How it works

In a sharp box, the value 0.0 is at the sharp walls.
By subtracting radius from the result, the value 0.0 moves outwards.
The straight walls move out by
radius.The corners, which previously measured distance from a single point, now measure distance radius away from that point... creating a perfect circular arc.
4. The Line Segment
Drawing a line between two arbitrary points (A and B) is essential for debug drawing, lasers, or skeletal animation.
fn sdf_line(p: vec2<f32>, a: vec2<f32>, b: vec2<f32>, thickness: f32) -> f32 {
let pa = p - a;
let ba = b - a;
// Project point p onto the line 'ba'.
// Clamp ensures we stop at the endpoints A and B.
let h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
// Distance from P to the closest point on the line
return length(pa - ba * h) - thickness;
}
How it works
This uses the Dot Product projection technique.
We project the pixel's position onto the infinite line passing through
AandB.The
clamp(..., 0.0, 1.0)restricts that projection to the segment itself. If you project pastB, it snaps back toB.We calculate the distance to that snapped point.

5. The Cross (Union of Boxes)
Complex shapes can often be built by combining simple ones. A cross is just a horizontal box merged with a vertical box.
fn sdf_cross(p: vec2<f32>, size: f32, thick: f32) -> f32 {
// 1. Horizontal Box
let rect_a = sdf_box(p, vec2<f32>(size, thick));
// 2. Vertical Box
let rect_b = sdf_box(p, vec2<f32>(thick, size));
// 3. Union (min)
return min(rect_a, rect_b);
}
How it works
We calculate two separate distance fields: one for a wide, short box, and one for a tall, thin box.
By taking the min(), we effectively weld them together. Any pixel inside either box is considered inside the cross.

6. The Hexagon
Now we enter the realm of complex shapes. Instead of calculating distance to 6 different lines, we use Domain Folding.
fn sdf_hexagon(p: vec2<f32>, r: f32) -> f32 {
// 1. Define symmetry constants (sin(60), cos(60), tan(60))
let k = vec3<f32>(-0.866025404, 0.5, 0.577350269);
// 2. Fold the space!
// We reflect the coordinate system so all 6 sectors map to 1.
var p_adj = abs(p);
p_adj -= 2.0 * min(dot(k.xy, p_adj), 0.0) * k.xy;
// 3. Calculate distance to the single remaining edge
let d = length(p_adj - vec2<f32>(clamp(p_adj.x, -k.z * r, k.z * r), r)) * sign(p_adj.y - r);
return d;
}
How it works
Imagine a kaleidoscope. A hexagon has 6 identical triangular sectors.

abs(p)folds the left side onto the right side. Now we have 3 sectors.The dot product math reflects the top-left and top-right sectors down.
We have folded the entire 2D plane into a single small wedge. We only need to calculate the distance to one vertical edge in that wedge, and the math applies to all 6 sides automatically.
7. The Star
To create a star with any number of points ($N$), we usually think in terms of Polar Domain Repetition. Instead of defining the whole shape, we define one "pie slice" and repeat it.
However, a naive implementation using atan2 produces a distorted distance field - distances grow faster as you move away from the center. This ruins effects like outlines and soft shadows.
For a high-quality, sharp star, we combine the concept of repetition with Folding Geometry. We fold the space using symmetry lines defined by the star's angles (e.g., $360/5=72360/5=72\textdegree$), then measure the exact Euclidean distance to the edge of the triangle wedge.
fn sdf_star(p: vec2<f32>, r: f32, n: f32, m: f32) -> f32 {
// 1. Define the angle of one sector (e.g. 72 degrees for N=5)
let an = 3.141593 / n;
let en = 3.141593 / m;
// 2. Get the angle of the current pixel using Polar Coordinates
let angle = atan2(p.x, p.y) + an;
// 3. Repeat the sector (The "Pie Slice" Logic)
// This maps the full 360 degrees to a single sector index.
// By using 'fract', a pixel at 350° is treated exactly the same as 10°.
let sector = floor(angle / (2.0 * an));
let a = fract(angle / (2.0 * an)) - 0.5;
// 4. Rotate pixel into local sector space
// Now every pixel "thinks" it is in the top slice!
let p_rot = vec2<f32>(cos(a * 2.0 * an), sin(a * 2.0 * an)) * length(p);
// 5. Calculate exact distance (Folding Logic)
// We now only need to measure distance to ONE line segment.
// Because of step #3, this logic automatically applies to all N points.
// ... (Exact segment math omitted for brevity) ...
}
How it works

Polar Coordinates: We convert the pixel's position from
(x,y)to(angle, radius)usingatan2.Slicing the Pie: We divide the full circle ($2\pi$) by $N$ (e.g., 5). This gives us the width of one sector.
Modulo Arithmetic: By using
fract(), we discard the specific sector number. We are left with a single triangular wedge.Folding for Precision: Instead of just using the angle (which distorts distance), we rotate the pixel into that wedge and measure the real geometric distance to the star's edge.
Combining SDFs: Boolean Operations
This is where SDFs truly shine. In traditional geometry, merging two shapes requires complex mesh generation. In SDFs, it is just min() and max().
1. Union (min)
To combine two shapes into one compound shape, we simply ask: "What is the distance to the nearest edge?"
fn op_union(d1: f32, d2: f32) -> f32 {
return min(d1, d2);
}
How it works
The min() function effectively stitches the distance fields together.
Wherever Shape A is closer,
d1wins.Wherever Shape B is closer,
d2wins.At the junction where they intersect,
d1 == d2, creating a seamless seam.

2. Intersection (max)
To find the overlap, we ask: "How far do I have to travel to be inside both shapes?"
This is the largest distance. If you are inside Shape A (-5) but outside Shape B (+2), the max is +2 (Outside). You are only "Inside" (negative) if both are negative. The logic is simple: take the maximum value.
fn op_intersection(d1: f32, d2: f32) -> f32 {
return max(d1, d2);
}
How it works
If you are outside both,
max()returns the distance to the shape that is furthest away (because you have to cross both boundaries to get inside).If you are inside one but outside the other,
max()returns the positive value (Outside).If you are inside both,
max()returns the value closest to0(closest to the edge).

3. Subtraction
To carve Shape B out of Shape A (like using a cookie cutter), we use a clever trick involving inversion.
In an SDF, "Inside" is negative and "Outside" is positive.
If we put a minus sign in front of a distance (-d), we invert the world: Inside becomes Outside, and Outside becomes Inside.
Therefore, "Subtraction" is just the Intersection of Shape A and the Inverse of Shape B.
We want the region that is Inside A AND Outside B.
fn op_subtraction(d1: f32, d2: f32) -> f32 {
// "Intersection of d1 and NOT d2"
return max(d1, -d2);
}
How it works
d1: Distance to the base shape.-d2: Distance to the "anti-shape".max(): Returns the intersection. The result is only negative (inside) if you are inside A (d1 < 0) AND inside the anti-B (-d2 < 0, which meansd2 > 0, i.e., outside B).

4. Smooth Blending (The "Liquid" Effect)
The functions above create sharp corners where shapes meet. But because SDFs are continuous fields, we can blend them mathematically.
Instead of a hard min(), we use a polynomial mix to smooth out the junction.
fn op_smooth_union(d1: f32, d2: f32, k: f32) -> f32 {
// k controls the "goo factor" or blend radius.
// h calculates a weight from 0.0 to 1.0 based on how close d1 and d2 are.
let h = clamp(0.5 + 0.5 * (d2 - d1) / k, 0.0, 1.0);
// 1. mix(d2, d1, h): Linearly blends the distances (like a bevel)
// 2. - k * h * (1.0 - h): Subtracts a bit more to create the curved "fillet"
return mix(d2, d1, h) - k * h * (1.0 - h);
}
Visualizing Smooth Union
Imagine two drops of water touching. They don't just intersect; surface tension pulls them into a single smooth blob. op_smooth_union replicates this perfectly. It is commonly used for organic shapes, biological effects, or gooey UI elements.

Using SDFs for Anti-Aliasing
One of the greatest superpowers of SDFs is infinite resolution. Whether your shape is 10 pixels wide or 1000 pixels wide, the math is the same. However, if we simply cut the shape at 0.0, we get jagged, pixelated edges.
To fix this, we need to blur the edge slightly - but only by the width of one pixel.
1. The Hard Edge (Aliased)
Using step() acts like a binary switch.
Distance -0.0001 → Color (Inside)
Distance +0.0001 → Black (Outside)
let dist = sdf_circle(p, 0.5);
// Result is either 0.0 or 1.0. Nothing in between.
let alpha = step(0.0, -dist);
2. The Smooth Edge (Anti-Aliased)
We want the pixels lying exactly on the boundary to be partially transparent (gray).
To do this, we need to map the distance to opacity using smoothstep.
But how wide should the smooth transition be?
Too narrow? Still jagged.
Too wide? The shape looks blurry.
The answer is fwidth(dist).
3. The fwidth Magic
fwidth(dist) allows the GPU to peek at the neighbor pixels. It asks: "How much does the dist value change from this pixel to the one next to it?"
This value tells us exactly how "wide" one pixel is in terms of distance units.
let dist = sdf_circle(p, 0.5);
// Calculate the width of one pixel in distance space
let edge_width = fwidth(dist);
// Create a smooth transition exactly 2 pixels wide centered on the edge
// smoothstep(lower_bound, upper_bound, value)
let alpha = smoothstep(-edge_width, edge_width, -dist);
Why this works
If you zoom in on your shape, dist changes very slowly between pixels. fwidth becomes small. The transition stays sharp (1 pixel wide).
If you zoom out, dist changes rapidly. fwidth becomes large. The transition widens to match the pixel size.
The result: perfectly crisp edges at any zoom level.

Distance Field Effects
Since dist tells us exactly how far we are from the edge, we can map that number to colors to create effects that would be complex with textures.
1. Outlines (Borders)
To create an outline, we don't care if we are inside or outside. we only care that we are near the edge.
Mathematically, this means looking at the Absolute Value of the distance (abs(dist)).

let dist = sdf_circle(p, 0.5);
// Define border thickness
let border_width = 0.02;
// Calculate "Distance from Border"
// abs(dist) is 0 at the edge, and grows as we move away in EITHER direction.
// We effectively create a "V" shape distance field centered on the line.
let d_border = abs(dist);
// Create the mask
// We use fwidth for anti-aliasing again!
let w = fwidth(dist);
let border_mask = smoothstep(border_width + w, border_width - w, d_border);
// Apply color
let final_color = mix(fill_color, border_color, border_mask);
2. Outer Glow
A glow is just light fading over distance. We take the positive distance (outside) and map it to brightness.

// Option A: Smoothstep Glow (Contained)
// Glow starts at edge (0.0) and fades to nothing at 0.2
let glow = smoothstep(0.2, 0.0, dist);
// Option B: Exponential Glow (Natural)
// Looks more like a light source.
// The multiplier (10.0) controls tightness. Higher = tighter glow.
// We max(dist, 0.0) so we don't glow on the inside.
let glow = exp(-10.0 * max(dist, 0.0));
// Add glow to color
color += glow_color * glow;
3. Inner Shadow / Inset
To create an inner shadow (like a button pressed in), we look at the negative distance (inside).

// Invert distance so "deep inside" is positive
let inside_dist = -dist;
// Smoothstep from edge (0.0) to deep inside (0.1)
let shadow = smoothstep(0.0, 0.1, inside_dist);
// Darken the color
color *= (1.0 - shadow * 0.5);
Complete Example: Animated Shape Morphing
To demonstrate the versatility of distance fields, we will build a "shapeshifter" material. Unlike standard mesh morphing - which requires complex vertex animation - SDF morphing is as simple as blending two numbers.
Our Goal
We want to create a dynamic glyph that:
Morphs smoothly between 6 different primitive shapes.
Rendering Effects: Applies a glowing outline and a distance-based pattern.
Perfect Edges: Renders with infinite resolution and anti-aliasing.
The Shader (assets/shaders/d03_05_sdf_morphing.wgsl)
This shader acts as our SDF engine. It defines the mathematical formulas for all our primitive shapes - Circle, Box, Hexagon, Cross, and Star.
The fragment entry point orchestrates the effect:
Coordinate Setup: It centers the UVs so
(0,0)is in the middle of the quad.Distance Calculation: It computes the distance field for both the Start Shape and the Target Shape.
Morphing: It blends these two distances using
mix(). We apply an ease-in-out curve to the time factor so the transition feels organic rather than robotic.Rendering: Finally, it uses the blended distance value to generate the anti-aliased fill, the white outline, and the outer glow.
#import bevy_pbr::forward_io::VertexOutput
struct SdfMaterial {
time: f32,
morph_factor: f32,
shape_a: u32,
shape_b: u32,
glow_intensity: f32,
outline_width: f32,
// Colors
primary_color: vec4<f32>,
secondary_color: vec4<f32>,
background_color: vec4<f32>,
glow_color: vec4<f32>,
outline_color: vec4<f32>,
}
@group(2) @binding(0)
var<uniform> material: SdfMaterial;
// --- 1. SDF PRIMITIVE LIBRARY ---
fn sdf_circle(p: vec2<f32>, r: f32) -> f32 {
return length(p) - r;
}
fn sdf_box(p: vec2<f32>, b: vec2<f32>) -> f32 {
let d = abs(p) - b;
return length(max(d, vec2<f32>(0.0))) + min(max(d.x, d.y), 0.0);
}
fn sdf_rounded_box(p: vec2<f32>, b: vec2<f32>, r: f32) -> f32 {
// Note: This effectively inflates the box by r
return sdf_box(p, b) - r;
}
fn sdf_hexagon(p: vec2<f32>, r: f32) -> f32 {
let k = vec3<f32>(-0.866025404, 0.5, 0.577350269);
var p_adj = abs(p);
p_adj -= 2.0 * min(dot(k.xy, p_adj), 0.0) * k.xy;
let d = length(p_adj - vec2<f32>(clamp(p_adj.x, -k.z * r, k.z * r), r)) * sign(p_adj.y - r);
return d;
}
fn sdf_cross(p: vec2<f32>, size: f32, thick: f32) -> f32 {
let rect_a = sdf_box(p, vec2<f32>(size, thick));
let rect_b = sdf_box(p, vec2<f32>(thick, size));
return min(rect_a, rect_b);
}
fn sdf_star(p: vec2<f32>, r: f32, factor: f32) -> f32 {
let k1 = vec2<f32>(0.809016994375, -0.587785252292);
let k2 = vec2<f32>(-k1.x, k1.y);
var p_adj = p;
p_adj.x = abs(p_adj.x);
p_adj -= 2.0 * max(dot(k1, p_adj), 0.0) * k1;
p_adj -= 2.0 * max(dot(k2, p_adj), 0.0) * k2;
p_adj.x = abs(p_adj.x);
p_adj.y -= r;
let ba = factor * vec2<f32>(-k1.y, k1.x) - vec2<f32>(0.0, 1.0);
let h = clamp(dot(p_adj, ba) / dot(ba, ba), 0.0, r);
return length(p_adj - ba * h) * sign(p_adj.y * ba.x - p_adj.x * ba.y);
}
fn get_dist(id: u32, p: vec2<f32>) -> f32 {
let size = 0.30;
switch id {
case 0u: { return sdf_circle(p, size); }
case 1u: { return sdf_box(p, vec2<f32>(size, size)); }
// For rounded box, subtract radius from size so visual size stays ~0.3
case 2u: { return sdf_rounded_box(p, vec2<f32>(size - 0.1, size - 0.1), 0.1); }
case 3u: { return sdf_hexagon(p, size); }
case 4u: { return sdf_cross(p, size, size * 0.35); }
case 5u: { return sdf_star(p, size + 0.1, 0.45); }
default: { return sdf_circle(p, size); }
}
}
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let p = in.uv - 0.5;
// 1. Morph Logic
let d1 = get_dist(material.shape_a, p);
let d2 = get_dist(material.shape_b, p);
let t = smoothstep(0.0, 1.0, material.morph_factor);
let dist = mix(d1, d2, t);
// 2. Rendering Effects
let aa = fwidth(dist);
// Fill
let fill_mask = 1.0 - smoothstep(-aa, aa, dist);
let pattern_val = sin(dist * 40.0 - material.time * 3.0) * 0.5 + 0.5;
let fill_color = mix(material.primary_color, material.secondary_color, pattern_val);
// Outline
let outline_mask = smoothstep(material.outline_width + aa, material.outline_width, abs(dist));
// Outer Glow
let glow_mask = exp(-5.0 * max(dist, 0.0)) * material.glow_intensity;
let glow = material.glow_color * glow_mask;
// 3. Composition
var final_color = material.background_color.rgb;
final_color = mix(final_color, fill_color.rgb, fill_mask);
if (material.outline_width > 0.001) {
final_color = mix(final_color, material.outline_color.rgb, outline_mask);
}
final_color += glow.rgb;
return vec4<f32>(final_color, 1.0);
}
The Rust Material (src/materials/d03_05_sdf_morphing.rs)
This file bridges the gap between the CPU and GPU. It defines the SdfUniforms struct, which maps 1:1 to the SdfMaterial struct in our shader.
We use a submodule with #![allow(dead_code)] to encapsulate the uniforms. This is a handy pattern in Bevy shader development to suppress compiler warnings about unused fields or methods generated by the ShaderType derive macro, keeping your build output clean.
use bevy::prelude::*;
use bevy::render::render_resource::{AsBindGroup, ShaderRef};
pub mod uniforms {
#![allow(dead_code)]
use bevy::prelude::*;
use bevy::render::render_resource::ShaderType;
#[derive(ShaderType, Debug, Clone)]
pub struct SdfUniforms {
pub time: f32,
pub morph_factor: f32,
pub shape_a: u32,
pub shape_b: u32,
pub glow_intensity: f32,
pub outline_width: f32,
pub primary_color: LinearRgba,
pub secondary_color: LinearRgba,
pub background_color: LinearRgba,
pub glow_color: LinearRgba,
pub outline_color: LinearRgba,
}
}
pub use uniforms::SdfUniforms;
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct SdfMorphMaterial {
#[uniform(0)]
pub uniforms: SdfUniforms,
}
impl Default for SdfMorphMaterial {
fn default() -> Self {
Self {
uniforms: SdfUniforms {
time: 0.0,
morph_factor: 0.0,
shape_a: 5, // Star
shape_b: 0, // Circle
glow_intensity: 0.8,
outline_width: 0.02,
primary_color: LinearRgba::new(0.0, 0.8, 1.0, 1.0), // Cyan
secondary_color: LinearRgba::new(1.0, 0.0, 0.8, 1.0), // Magenta
background_color: LinearRgba::new(0.02, 0.02, 0.05, 1.0),
glow_color: LinearRgba::new(0.0, 0.6, 0.8, 1.0),
outline_color: LinearRgba::WHITE,
},
}
}
}
impl Material for SdfMorphMaterial {
fn fragment_shader() -> ShaderRef {
"shaders/d03_05_sdf_morphing.wgsl".into()
}
}
Don't forget to add it to src/materials/mod.rs:
pub mod d03_05_sdf_morphing;
The Demo Module (src/demos/d03_05_sdf_morphing.rs)
This module sets up the interactive environment. It spawns the camera, the quad mesh, and the UI overlay.
The core logic lies in the handle_input and animate_shader systems. Instead of just playing a fixed animation, these systems read the keyboard state and directly modify the properties of the SdfMorphMaterial asset. This allows for real-time control over the morphing speed, the active shapes, and the visual effects.
use crate::materials::d03_05_sdf_morphing::SdfMorphMaterial;
use bevy::prelude::*;
pub fn run() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(MaterialPlugin::<SdfMorphMaterial>::default())
.insert_resource(AnimationState {
auto_morph: true,
morph_speed: 0.5,
manual_t: 0.0,
ping_pong_direction: 1.0,
})
.add_systems(Startup, setup)
.add_systems(Update, (handle_input, animate_shader, update_ui))
.run();
}
#[derive(Resource)]
struct AnimationState {
auto_morph: bool,
morph_speed: f32,
manual_t: f32,
ping_pong_direction: f32,
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<SdfMorphMaterial>>,
) {
commands.spawn((
Camera3d::default(),
Transform::from_xyz(0.0, 0.0, 2.0).looking_at(Vec3::ZERO, Vec3::Y),
));
commands.spawn((
Mesh3d(meshes.add(Rectangle::new(2.0, 2.0))),
MeshMaterial3d(materials.add(SdfMorphMaterial::default())),
Transform::default(),
));
commands.spawn((
Text::new("SDF Morphing Demo"),
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: 16.0,
..default()
},
TextColor(Color::WHITE),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
));
}
fn handle_input(
keyboard: Res<ButtonInput<KeyCode>>,
mut state: ResMut<AnimationState>,
mut materials: ResMut<Assets<SdfMorphMaterial>>,
) {
for (_, material) in materials.iter_mut() {
let u = &mut material.uniforms;
let shift = keyboard.pressed(KeyCode::ShiftLeft) || keyboard.pressed(KeyCode::ShiftRight);
if keyboard.just_pressed(KeyCode::Space) {
state.auto_morph = !state.auto_morph;
if !state.auto_morph {
state.manual_t = u.morph_factor;
}
}
// Shape Selection
if keyboard.just_pressed(KeyCode::Digit1) {
if shift {
u.shape_b = 0;
} else {
u.shape_a = 0;
}
}
if keyboard.just_pressed(KeyCode::Digit2) {
if shift {
u.shape_b = 1;
} else {
u.shape_a = 1;
}
}
if keyboard.just_pressed(KeyCode::Digit3) {
if shift {
u.shape_b = 2;
} else {
u.shape_a = 2;
}
}
if keyboard.just_pressed(KeyCode::Digit4) {
if shift {
u.shape_b = 3;
} else {
u.shape_a = 3;
}
}
if keyboard.just_pressed(KeyCode::Digit5) {
if shift {
u.shape_b = 4;
} else {
u.shape_a = 4;
}
}
if keyboard.just_pressed(KeyCode::Digit6) {
if shift {
u.shape_b = 5;
} else {
u.shape_a = 5;
}
}
// Toggles
if keyboard.just_pressed(KeyCode::KeyG) {
u.glow_intensity = if u.glow_intensity > 0.0 { 0.0 } else { 0.8 };
}
if keyboard.just_pressed(KeyCode::KeyO) {
u.outline_width = if u.outline_width > 0.0 { 0.0 } else { 0.02 };
}
// Manual Morph
if !state.auto_morph {
let delta = 0.02;
if keyboard.pressed(KeyCode::ArrowRight) {
state.manual_t = (state.manual_t + delta).min(1.0);
u.morph_factor = state.manual_t;
}
if keyboard.pressed(KeyCode::ArrowLeft) {
state.manual_t = (state.manual_t - delta).max(0.0);
u.morph_factor = state.manual_t;
}
}
// Speed
let speed_delta = 0.01;
if keyboard.pressed(KeyCode::ArrowUp) {
state.morph_speed = (state.morph_speed + speed_delta).min(5.0);
}
if keyboard.pressed(KeyCode::ArrowDown) {
state.morph_speed = (state.morph_speed - speed_delta).max(0.0);
}
}
}
fn animate_shader(
time: Res<Time>,
mut state: ResMut<AnimationState>,
mut materials: ResMut<Assets<SdfMorphMaterial>>,
) {
if !state.auto_morph {
for (_, material) in materials.iter_mut() {
material.uniforms.time = time.elapsed_secs();
}
return;
}
let delta = time.delta_secs();
for (_, material) in materials.iter_mut() {
let u = &mut material.uniforms;
u.time = time.elapsed_secs();
u.morph_factor += delta * state.morph_speed * state.ping_pong_direction;
if u.morph_factor >= 1.0 {
u.morph_factor = 1.0;
state.ping_pong_direction = -1.0;
} else if u.morph_factor <= 0.0 {
u.morph_factor = 0.0;
state.ping_pong_direction = 1.0;
}
}
}
fn get_shape_name(id: u32) -> &'static str {
match id {
0 => "Circle",
1 => "Box",
2 => "RoundBox",
3 => "Hexagon",
4 => "Cross",
5 => "Star",
_ => "Unknown",
}
}
fn update_ui(
state: Res<AnimationState>,
materials: Res<Assets<SdfMorphMaterial>>,
mut text_query: Query<&mut Text>,
) {
if let Some((_, mat)) = materials.iter().next() {
let u = &mat.uniforms;
for mut text in text_query.iter_mut() {
**text = format!(
"CONTROLS:\n\
[1-6] Set Shape A\n\
[Shift + 1-6] Set Shape B\n\
[Space] Auto-Loop: {}\n\
[Left/Right] Manual Morph\n\
[Up/Down] Morph Speed\n\
[G] Glow: {} | [O] Outline: {}\n\
\n\
STATUS:\n\
Morph: {} <---> {}\n\
Factor: {:.0}% | Speed: {:.2}",
if state.auto_morph { "ON" } else { "MANUAL" },
if u.glow_intensity > 0.0 { "ON" } else { "OFF" },
if u.outline_width > 0.0 { "ON" } else { "OFF" },
get_shape_name(u.shape_a),
get_shape_name(u.shape_b),
u.morph_factor * 100.0,
state.morph_speed
);
}
}
}
Don't forget to add it to src/demos/mod.rs:
pub mod d03_05_sdf_morphing;
And register it in src/main.rs:
Demo {
number: "3.5",
title: "SDF Morphing",
run: demos::d03_05_sdf_morphing::run,
},
Running the Demo
Controls
| Key | Action | Description |
| Space | Toggle Loop | Switch between automatic ping-pong animation and manual control. |
| 1-6 | Set Shape A | Select the starting shape (Circle, Box, RoundBox, Hexagon, Cross, Star). |
| Shift + 1-6 | Set Shape B | Select the target shape. |
| Left / Right | Manual Morph | Scrub through the morph transition (when Loop is OFF). |
| Up / Down | Speed | Adjust the auto-animation speed. |
| G / O | Effects | Toggle Glow and Outline. |
What You're Seeing

Geometric Morphing: The transitions are now mathematically perfect. The Star (6) is sharp and precise. When morphing to a Circle (1), the points retract and the edges bulge outward until it is perfectly round.
The Outline: Notice the white outline. It is generated by
abs(dist). Even when the shape is half-morphed and looks like a strange blob, the outline stays perfectly consistent and sharp.Ripples: The internal pattern visualizes the distance field itself. The ripples act like contour lines on a map, showing you exactly how the distance value changes from the center to the edge.
Key Takeaways
Think in Fields, Not Pixels: An SDF doesn't define where the shape is. It defines how far every pixel is from the edge. This shift in thinking unlocks powerful effects.
Infinite Resolution: Because SDFs are mathematical functions, they remain perfectly sharp at any zoom level. By using
fwidth(), we can create edges that are always exactly 1 pixel soft, regardless of scale.Boolean Algebra: You don't need complex mesh algorithms to combine shapes. You just need
min()(Union),max()(Intersection), andmax(d1, -d2)(Subtraction).Free Effects: Once you have a distance field, adding an outline is just
abs(dist). Adding a glow is justexp(-dist). The "hard work" of geometry gives you these effects for free.
What's Next?
We've mastered precise geometric shapes. But nature is rarely perfect. To create clouds, fire, terrain, or water, we need controlled chaos. In the next article, we will dive into Procedural Noise, learning how to generate organic textures using randomness and fractals.
Next up: 3.6 - Procedural Noise
Quick Reference
1. The SDF Sign Convention
The most important rule to remember. The sign tells you where you are.
Negative (
<0): Inside the shape.Zero (
=0): Exactly on the edge.Positive (
>0): Outside the shape.
2. Boolean Logic Cheat Sheet
Combine shapes by comparing their distance values.
Union (Merge) ->
min(a, b)- Logic: "I am closest to shape A or shape B."
Intersection (Overlap) ->
max(a, b)- Logic: "I am strictly inside only if I am inside both."
Subtraction (Cut) ->
max(a, -b)- Logic: "Intersect Shape A with the inverse of Shape B."
3. The Golden Rule of Anti-Aliasing
Never use step(0.0, dist) if you want smooth edges.
The magic formula for perfect edges at any zoom level is to smooth over the width of one pixel:
let aa = fwidth(dist);
let alpha = 1.0 - smoothstep(-aa, aa, dist);
4. Creating Effects
Once you have the distance d, you can derive effects without extra geometry:
Outline:
abs(d)creates a V-shape field centered on the edge.Glow:
exp(-d)creates a natural light falloff.Inset:
-d(inverted distance) lets you render effects inside the shape.
5. Domain Manipulation
Don't model complex repetition; warp the space instead.
Mirror Symmetry:
p.x = abs(p.x)folds the left side onto the right.Radial Symmetry: Use
atan2to convert to polar coordinates, then repeat the angle.






