Pixels2GenAI
Path i Foundations
M 02 · 2.2.4 · hands-on

2.2.4 Distance Fields & SDFs

Keep the actual distance values instead of thresholding them. Subtract a radius to get a signed distance function, then combine SDFs with `min`, `max`, and negation — the algebra behind modern shape composition.

Duration20–25 min
Levelbeginner-intermediate
Load3 core concepts
Prereqs2.1.3 (circle masks), 2.2.1 (broadcasting), 2.2.3 (np.ogrid)

Overview

In 2.1.3 you drew a circle by thresholding a distance — square_distance < r² gave a boolean mask and you threw the actual distance values away. Keep them instead and you have a distance field: a 2D scalar where each pixel stores the distance to the nearest point on a shape. Subtract the radius and you have a signed distance function (SDF): negative inside, positive outside, zero on the boundary [1]. The payoff is composition — three operations (min, max, and negation) let you take unions, intersections, and subtractions of any two SDFs, which is how modern font renderers, demoscene shaders, and Pixar’s recent shape pipelines do CSG without ever building polygons [2, 3].

Learning objectives

  1. Compute and visualise an unsigned distance field — the actual distance from every pixel to a centre point.
  2. Convert a distance field into a signed distance function by subtracting the shape’s defining parameter (e.g. a radius).
  3. Compose SDFs with np.minimum (union), np.maximum (intersection), and negation (subtraction).
  4. Build a multi-shape composition by combining circle and rectangle SDFs.

Quick start — a distance field from one centre

python · quick_start.py
import numpy as np
from PIL import Image

SIZE = 512
CENTER_X, CENTER_Y = 256, 256

Y, X = np.ogrid[0:SIZE, 0:SIZE]

# Distance from every pixel to the centre (no threshold this time)
distance_field = np.sqrt((X - CENTER_X) ** 2 + (Y - CENTER_Y) ** 2)

# Normalise to [0, 255] for grayscale display
normalized = (distance_field / distance_field.max() * 255).astype(np.uint8)

Image.fromarray(normalized, mode='L').save('simple_distance_field.png')
A radial grayscale gradient on a 512 by 512 canvas — dark at the centre, gradually brighter toward the corners
Fig. 1 Distance from the centre, normalised to grayscale. Same arithmetic as the circle mask, minus the threshold.

Core concepts

Concept 1 — Distance field vs. boolean mask

The circle code in 2.1.3 was

python
inside = (X - cx) ** 2 + (Y - cy) ** 2 < radius ** 2   # bool array

The distance-field version drops the comparison and keeps the floats:

python
distance = np.sqrt((X - cx) ** 2 + (Y - cy) ** 2)       # float array

A boolean tells you whether a pixel is in the shape. A distance tells you how far it is from the boundary. The continuous information enables:

  • Soft edges — fade colour smoothly near the boundary rather than snapping.
  • Collision queries — not just “is there overlap?” but “how much room is left?”
  • Procedural texture — use the distance to drive a colour map, a sine wave (concentric rings), or a transparency curve [4].

Concept 2 — Signed distance functions

Subtracting the shape’s defining parameter turns a distance field into a signed one. For a circle of radius r:

SDF_circle(x, y) = sqrt((x - cx)² + (y - cy)²) - r

  • SDF < 0 — inside the shape.
  • SDF = 0 — on the boundary.
  • SDF > 0 — outside.
python · circle_sdf_demo.py
distance = np.sqrt((X - cx) ** 2 + (Y - cy) ** 2)
circle_sdf = distance - radius        # negative inside, positive outside

The “signed” part is what makes composition work. With unsigned distance you can only test “near the shape”; with signed distance you can test “inside” and “outside” separately, and combine those tests algebraically [2].

A diagram of a circle with arrows pointing both inward (negative distances) and outward (positive distances), illustrating the signed distance function
Fig. 2 The sign convention — negative inside, zero on the boundary, positive outside.

Concept 3 — Composing SDFs with min, max, and negation

The whole CSG (Constructive Solid Geometry) toolkit collapses to three NumPy operations on SDFs [2]:

OperationFormulaIntuition
Union (A ∪ B)np.minimum(sdfA, sdfB)The closer of the two — inside if inside either
Intersection (A ∩ B)np.maximum(sdfA, sdfB)The farther — inside if inside both
Subtraction (A − B)np.maximum(sdfA, -sdfB)Inside A and outside B

min is correct for union because the SDF of the union should be negative wherever any component is negative — i.e. wherever the minimum of the components is negative. max is correct for intersection by the same reasoning with the sign flipped. Negating an SDF swaps inside and outside, which is what subtraction needs.

python · sdf_ops_demo.py
circle1 = np.sqrt(x ** 2 + y ** 2) - 80
circle2 = np.sqrt((x - 60) ** 2 + y ** 2) - 80

union        = np.minimum(circle1, circle2)
intersection = np.maximum(circle1, circle2)
subtraction  = np.maximum(circle1, -circle2)
A two-by-two grid of distance-field visualisations: a circle, a rectangle, a union of the two, and an intersection of the two
Fig. 3 Single SDFs and their boolean combinations — same three operations behind every CSG modeller.

The rectangle SDF is the same idea applied per axis: np.maximum(np.abs(x) - half_w, np.abs(y) - half_h). The maximum of the two axis-aligned signed distances is itself a valid SDF, with the corner subtleties handled by the absolute value [2].

Exercises

Three exercises in Execute → Modify → Create order: visualise a distance field, vary the centre and shape, then compose multiple SDFs.

EXECUTE I.

Run the distance field

Run simple_distance_field.py and inspect the output.

python · exercise1_execute.py
import numpy as np
from PIL import Image

SIZE = 512
CENTER_X, CENTER_Y = 256, 256

Y, X = np.ogrid[0:SIZE, 0:SIZE]
distance_field = np.sqrt((X - CENTER_X) ** 2 + (Y - CENTER_Y) ** 2)

normalized = (distance_field / distance_field.max() * 255).astype(np.uint8)
Image.fromarray(normalized, mode='L').save('simple_distance_field.png')

print(f"Min distance: {distance_field.min():.1f}")
print(f"Max distance: {distance_field.max():.1f}")

Reflection questions

  • How do pixel brightnesses vary with distance from the centre?
  • Why is the gradient circular on a square canvas?
  • Where is the maximum distance, and what is its approximate value?
MODIFY II.

Move it, stretch it, invert it

Edit the quick-start code to produce these three pictures.

Goals

  1. Off-centre — move the source point to (50, 50) (top-left corner).
  2. Elliptical field — stretch the gradient vertically by scaling the y-component.
  3. Inverted — bright at the centre, dark at the edges.
CREATE III.

SDF composition

Build a composition with at least two SDFs and one boolean operation. Suggestion: a ring (outer circle minus inner circle) crossed by a vertical bar (a tall rectangle SDF), unioned together.

python · exercise3_starter.py
import numpy as np
from PIL import Image

SIZE = 512
CENTER = SIZE // 2

Y, X = np.ogrid[0:SIZE, 0:SIZE]
x = X - CENTER
y = Y - CENTER

# TODO 1: outer circle SDF (radius 180)
# outer = np.sqrt(x**2 + y**2) - 180

# TODO 2: inner circle SDF (radius 100)
# inner = np.sqrt(x**2 + y**2) - 100

# TODO 3: ring = outer minus inner (use np.maximum and negation)
# ring = np.maximum(outer, -inner)

# TODO 4: vertical rectangle SDF — half-width 30, half-height 200
# rect = np.maximum(np.abs(x) - 30, np.abs(y) - 200)

# TODO 5: union the ring and the rectangle (np.minimum)
# combined = np.minimum(ring, rect)

# Visualisation: clip to [-150, 150], shift to [0, 300], scale to [0, 255]
# normalized = np.clip(combined, -150, 150)
# normalized = ((normalized + 150) / 300 * 255).astype(np.uint8)
# Image.fromarray(normalized, mode='L').save('sdf_combination.png')

Make it your own

  • Colour map. Visualise with matplotlib and a diverging colormap: plt.imsave('out.png', combined, cmap='coolwarm'). Blue = inside, red = outside, white = boundary.
  • Concentric rings. Apply a sinusoid to the distance: np.sin(distance / 20) * 127 + 128. Smaller divisors give tighter rings.
  • Voronoi-style. Take the minimum across multiple point SDFs: np.minimum.reduce([sdf1, sdf2, sdf3]). Each pixel’s value is its distance to the nearest of several centres.

Downloads

simple_distance_field.py — quick start sdf_shapes.py — circle/rect/union/intersection sdf_combination_solution.py — Exercise 3 reference

Summary

Common pitfalls to avoid

  • Forgetting to subtract the radius — leaves you with an unsigned distance, which is not enough for composition.
  • Using np.minimum where you wanted np.maximum (or vice versa) — the union/intersection swap.
  • Misnormalising for visualisation — distance values that go negative will wrap when cast to uint8 without clipping/shifting.
  • Trusting the rectangle SDF for corner cases — it underestimates distance just outside a corner. Good enough for visualisation, but not for ray-marching where precision matters.

References

  1. [1] Hart, J. C. (1996). Sphere tracing: A geometric method for the antialiased ray tracing of implicit surfaces. The Visual Computer, 12(10), 527–545. doi:10.1007/s003710050084
  2. [2] Quilez, I. (2008). Distance functions. Inigo Quilez Articles. iquilezles.org/articles/distfunctions
  3. [3] Green, C. (2007). Improved alpha-tested magnification for vector textures and special effects. ACM SIGGRAPH 2007 Courses, Article 9. doi:10.1145/1281500.1281665
  4. [4] Gonzalez, R. C., & Woods, R. E. (2018). Digital Image Processing (4th ed.). Pearson.
  5. [5] Harris, C. R., et al. (2020). Array programming with NumPy. Nature, 585(7825), 357–362. doi:10.1038/s41586-020-2649-2
  6. [6] NumPy Community. (2024). numpy.ogrid. NumPy Documentation. numpy.org
  7. [7] Pearson, M. (2011). Generative Art: A Practical Guide Using Processing. Manning.