Halftones: Printing SDF Scenes as Ink Plates
halftones is a small TypeScript library that turns a 3D scene into a print. You describe the scene as signed distance fields with colours, choose some inks and a paper, and get back one plate of dots per ink: separate layers you could export, inspect or even have made into a stencil. It has no runtime dependencies and writes SVG (in the browser or in Node) or draws to a canvas.
There is an interactive playground, a gallery, docs and a Python editor at halftones.pages.dev. Every picture in this post is traced and screened live in your browser with the published package.
Halftones in a paragraph
A printing press cannot print grey. A plate either puts ink on the paper or it does not. Halftoning fakes the in-between tones with dots on a regular grid (the screen): small dots for light tones, large ones that merge for dark tones. From a normal reading distance your eye averages them out. Colour printing does this once per ink, and each ink's screen is turned to a different angle. If the screens lined up, small registration errors would produce coarse moiré patterns; at well-separated angles the dots instead form small rosettes. Classic process printing uses cyan at 15°, magenta at 75°, yellow at 0° and black at 45°, and those are the library's defaults.
Getting started
pnpm add halftones
A scene is a pair of functions: sdf(point) gives the signed distance to the nearest surface, and material(point) gives its colour. You rarely write those by hand at first, because the library has primitives and combinators that build them. Here is the scene above, as it is in the gallery, with the camera brought a little closer:
import {
material,
palettes,
renderHalftones,
smoothUnion,
sphere,
surface,
toSvg,
translate,
} from 'halftones'
const scene = smoothUnion(
smoothUnion(
surface(
translate(sphere(0.95), [-0.65, -0.35, 0]),
material({ colour: '#ed6794' }),
),
surface(
translate(sphere(0.8), [0.6, -0.1, 0]),
material({ colour: '#647ac4' }),
),
0.65,
),
surface(
translate(sphere(0.65), [-0.2, 0.9, 0]),
material({ colour: '#f7af4c' }),
),
0.7,
)
const print = renderHalftones(scene, {
...palettes.risograph,
camera: { position: [3.1, 2.3, 4.6], lookAt: [0, 0.1, 0] },
width: 720,
height: 540,
})
const svg = toSvg(print)
smoothUnion blends the distances of two shapes, so they melt into each other instead of meeting at a crease. It blends their materials with the same weights, which is where the pink, blue and orange run into one another.
renderHalftones does three things in turn:
- Sample. It sphere-traces the scene at a modest resolution (320 pixels wide by default), with ambient and diffuse lighting and hard shadows, to get a lit colour for every sample.
- Separate. It turns each colour into a coverage between 0 and 1 for each ink.
- Screen. For each ink it lays a rotated grid over the output and places a dot in each cell, with an area matching the coverage there.
The result is a Print: its size, the paper colour and a list of plates, each just an ink and an array of marks (x, y and size). toSvg composites them with mix-blend-mode: multiply, as overlapping inks would on paper. drawToCanvas does the same on a canvas, and is what draws the pictures here.
Plates
Because the plates are independent, you can pull a print apart. These are the three plates of the print at the top, each on its own:
toSvg(print, { plates: [0], background: false }) gives a single plate on a transparent background, and adding monochrome: true gives a black positive: the artwork you would need to burn a screen for screen printing or to make a risograph master.
The palette above is not CMYK, so no textbook separation applies. For custom inks the library fits coverages in optical density relative to the paper: it finds how much of each ink, layered by multiplication, best approximates each colour. That is approximate, but it means you can give it any set of one to sixteen inks and get something sensible out.
Sample once, screen many times
Tracing is the expensive part. Separation and screening are cheap, and they do not need the scene again. So the library exposes the two halves separately: sampleScene traces once, and screenScene can then turn the same samples into as many prints as you like.
import { palettes, sampleScene, screenScene } from 'halftones'
const sample = sampleScene(scene, { width: 320, height: 240 })
const prints = Object.values(palettes).map((palette) =>
screenScene(sample, { ...palette, width: 720, height: 540 }),
)
The buttons below do exactly that with the built-in palettes. The first print waits for the trace; the others are screened from the samples already made.
The Carbon palette (palettes.mono), a single black ink, is a good reminder that halftones are an old answer to a very practical problem: getting a photograph into a newspaper.
A ringed planet
The primitives are a sphere, a box and a torus, but an Sdf is just a function from a point to a number, so you can write your own. A ring system is a flat annulus: within half a unit of radius 1.85, and very thin.
import { material, surface, union, type Sdf, type Vec3 } from 'halftones'
/** The planet is tilted; work in its own frame. */
function planetFrame([x, y, z]: Vec3): Vec3 {
const c = Math.cos(0.36)
const s = Math.sin(0.36)
return [x * c + y * s, -x * s + y * c, z]
}
const rings: Sdf = (p) => {
const [x, y, z] = planetFrame(p)
const r = Math.hypot(x, z)
return Math.max(Math.abs(r - 1.85) - 0.5, Math.abs(y) - 0.02)
}
const ringMaterial = (p: Vec3) => {
const [x, , z] = planetFrame(p)
const r = Math.hypot(x, z)
// A dark gap, then fine banding across the rest.
const gap = Math.abs(r - 1.98) < 0.05
const t = 0.5 + 0.5 * Math.sin(r * 38)
return material({
// blend() mixes two hex colours into an [r, g, b] tuple.
colour: gap ? '#3d4a63' : blend('#f4e6c8', '#b98b5e', t * 0.7),
ambient: 0.35,
diffuse: 0.65,
})
}
const planetWithRings = union(
surface(([x, y, z]) => Math.hypot(x, y, z) - 1, globeMaterial),
surface(rings, ringMaterial),
)
Materials can be functions of the point too, which is how the rings and the planet get their bands. The planet's material, left out above, is a sine of height with a little wobble, mixing cream with ochre on one side and blue-grey on the other, plus a small red storm. The material and the distance use the same tilted frame, so the bands follow the planet's axis rather than the world's.
The sky is a background pattern. Where a ray misses everything, the library can fill in a gradient, stripes or seeded noise instead of leaving bare paper. Patterns can share the scene's inks or, as here, have plates of their own:
const scene: Scene = {
// The moon is one more sphere, translated and painted.
...union(planetWithRings, moon),
backgroundPattern: {
type: 'noise',
colours: ['#1c2440', '#fff6e5'],
seed: 11,
scale: 60,
octaves: 2,
// A hard split: high noise values become paper, so stars.
threshold: 0.8,
softness: 0,
falloff: 0.5,
},
backgroundInks: [
{
name: 'Midnight',
colour: '#1c2440',
angle: 45,
spacing: 5,
shape: 'line',
},
],
}
The planet itself uses three spot inks: Prussian blue, vermilion and sunflower. Dots can be circles, squares or lines, and a line screen makes the sky look ruled, like an engraving.
The fourth plate is the background's. It is appended after the scene's plates and marked role: 'background', so it is easy to export or leave out on its own.
A cairn
Real presses are never in perfect register. Each ink takes an offset in output pixels, and a slight drift between plates is often what makes a print look printed rather than rendered. Here, five flattened stones (another hand-written SDF, an ellipsoid) are stacked on a low mound of sand, printed in slate, ochre and clay, each plate nudged a little further than the last. The ochre uses square dots.
const inks = (
[
{ name: 'Slate', colour: '#36617f', angle: 15 },
{ name: 'Ochre', colour: '#e0a93f', angle: 75, shape: 'square' },
{ name: 'Clay', colour: '#c4583f', angle: 45 },
] as Ink[]
).map(
(ink, i): Ink => ({
...ink,
spacing: 7,
offset: [i * 1.2, i * -0.6],
opacity: 0.92,
}),
)
The sky is a gradient. This time there are no backgroundInks, so the sky shares the three plates with the stones.
Separation can also be a function of your own. It is given the sampled colour, the inks and the paper, and returns a coverage for each ink. With one black ink, a line screen and a separation that is just darkness with a slight curve, the same samples become something like a wood engraving:
const engraving = screenScene(sample, {
background: '#f5f0e6',
inks: [
{
name: 'Lamp black',
colour: '#1f1d1a',
angle: 30,
spacing: 5,
shape: 'line',
},
],
separation: ([r, g, b]) => [
Math.pow(1 - (0.2126 * r + 0.7152 * g + 0.0722 * b), 1.25),
],
width: 720,
height: 540,
})
Writing scenes in Python
The Code page on the website has a different way in: a Python editor. It runs Pyodide, CPython compiled to WebAssembly, in a worker, against a small halftones Python module. There is nothing to install, and the preview updates a moment after you stop typing.
Here is the ringed planet again, in Python. Paste it into the editor to try it:
# A ringed planet, a moon and a night sky ruled in lines.
def bands(p):
wobble = sin(p.x * 2.5 + p.z * 3) * 0.7
band = sin(p.y * 10 + wobble)
warm = mix("#f2d7a6", "#e9a35a", band)
cool = mix("#f2d7a6", "#7d9fb5", -band)
return where(band > 0, warm, cool)
def ring_colour(p):
r = length(p.xz)
banding = mix("#f4e6c8", "#b98b5e", (0.5 + 0.5 * sin(r * 38)) * 0.7)
return where(abs(r - 1.98) < 0.05, "#3d4a63", banding)
globe = sphere(1).paint(bands)
rings = sdf(lambda p: max(abs(length(p.xz) - 1.85) - 0.5, abs(p.y) - 0.02))
planet = (globe | rings.paint(ring_colour)).rotate_z(0.36)
moon = sphere(0.32).translate(-2.55, 1.45, -1.2).paint("#9fc1c9")
show(
planet | moon,
inks=[
ink("#245078", 25, name="Prussian blue", spacing=6),
ink("#ed603e", 70, name="Vermilion", spacing=6),
ink("#f6bc36", 0, name="Sunflower", spacing=6),
],
paper="#fff6e5",
camera=camera((0.3, 0.9, 7), look_at=(-0.15, 0.05, 0)),
light=(-5, 3.5, 6),
background=backgrounds.noise(
["#1c2440", "#fff6e5"], scale=60, seed=11, octaves=2, threshold=0.8, softness=0, falloff=0.5
),
background_inks=[ink("#1c2440", 45, name="Midnight", spacing=5, shape="line")],
)
Everything from the module is already imported. A few things are worth pointing out:
- Shapes are values with methods.
sphere(1).paint(bands)makes a sphere and paints it;.translate(),.rotate_z()and the rest each return a new shape, applied in the order you write them. Operators combine them:a | bis a union,a & ban intersection anda - bcutsbout ofa. There are more primitives than in the TypeScript library (cylinders, cones, capsules, ellipsoids and planes), plus helpers such asrepeat,twistanddisplace. - Transforms carry their paint.
rotate_z(0.36)turns the painted planet and rings together, bands and all. That replaces the hand-writtenplanetFramefrom the TypeScript version. - Functions of
pare not run per pixel. This is the interesting part.bands,ring_colourand the lambda insidesdf()look like ordinary Python, but calling Python from the tracer hundreds of thousands of times would be far too slow. Instead each function runs once, on a symbolic point.p.y * 10does not compute a number; it records an operation. The arithmetic is captured as an expression and compiled to JavaScript, so it costs no more to trace than the built-in shapes. - So use the field functions. That is why the code uses
sin,length,maxandmixfrom the module rather than frommath, andwhere(condition, a, b)rather thanif: a Pythonifwould need to know whetherband > 0while the point is still symbolic.mixandwherework on numbers, vectors, colours and whole materials. show()prints it. It takes the shape and everything about the print: inks (or apalette), paper, camera, light, background and background inks. The editor then samples and screens it with the same library, in a second worker.
The page has completion and hover docs for the whole module, ten examples (including Soft connections, here made of seven random blobs) and exports. You can download the print as SVG or PNG, single plates or black positives, scene.py, or the same scene as a TypeScript module that imports the npm package. The Python is a way to sketch; the export is the way out.
Caveats
This is a stylised print simulation, not a colour-managed proof. Colours are blended in sRGB with multiply. There are no ICC profiles, dot gain or trapping. Subtractive inks cannot lighten paper, and a set of spot inks cannot reproduce colours outside their gamut. Rendering is synchronous, so for anything heavy, sample in a worker as this page does. Scenes are made of functions, which cannot be posted to a worker, so the worker has to import them itself.
The docs cover the rest of the API. How it works walks through sampling, separation and screening with diagrams. The playground lets you change inks, angles, spacing and paper on any of the gallery scenes.