If you've ever inspected a dollar bill, you'll notice that the portrait has some masterfully crafted lines running through them. This is called Intaglio engraving. I wanted to replicate this effect and after a few passes I got something that does a pretty good job. The core idea is: Define a function over the image and let lines exist wherever that function says they should.
Drop an image
Portraits with high contrast and a clean silhouette work best. The four presets are paper-and-ink palettes plus tuning; the sliders perturb from the preset's defaults. Density, weight, contrast, and angle are the obvious ones to scrub. Curve is the form-perturbation strength — at 0 the hatching is mechanical, at 1.2 it bends so hard it stops looking like a face, and somewhere around 0.5 is the sweet spot for most portraits.
Drop a portrait
High-contrast portraits work best. Images stay in your browser.
This is phase-field rasterization. A scalar function φ(x, y), evaluated at every pixel. Lines exist at every location where φ is congruent to zero modulo a spacing constant. Because φ is mathematical, the lines come out parallel by construction — they cannot collide and they cannot bunch.
The simplest φ is a plane wave:
float phi = cos(angle) * pSrc.x + sin(angle) * pSrc.y;
float b = fract(phi / spacing);
bool onLine = min(b, 1.0 - b) * spacing < lineHalfWidth;That gets you graph paper. Five things turn graph paper into engraving.
Form-perturbation from a blurred image
Bending the field around the image is what makes lines follow topology instead of stripes. The instinct is to perturb φ by the image gradient, but the gradient is the wrong signal. Sharp edges (a glasses frame, a collar seam) have huge gradients and lines flick around them; smooth regions (a cheek) have near-zero gradient and lines barely bend.
Pre-blur the source instead. A 12-pixel Gaussian. Bind it as a second texture, sample its luminance, shift φ by (blurred - 0.5):
float blurred = luminance(texture2D(u_blurred, uv).rgb);
phase += curveScale * (blurred - 0.5) * 8.0;The blurred image is the form of the picture — the broad shape of a face — without the noise of any single edge. Lines bend toward darker forms smoothly. They don't twitch around glasses, because the blurred image doesn't know glasses exist. This is the move that turns stripes-following-brightness into lines-following-topology.
Four passes, tuned asymmetrically
Cross-hatching at four angles is obvious. Tuning the four passes asymmetrically is the trick.
hatchPass(angle, spacing, lineWidth, threshold=0.16, curveScale=1.00, widthMod=0.75);
hatchPass(angle + 75°, spacing × 1.05, lineWidth, threshold=0.42, curveScale=0.40, widthMod=0.60);
hatchPass(angle - 45°, spacing × 0.95, lineWidth, threshold=0.62, curveScale=0.30, widthMod=0.50);
hatchPass(angle + 90°, spacing × 0.85, lineWidth × 1.05, threshold=0.78, curveScale=0.20, widthMod=0.40);Three things vary per pass.
Spacing is detuned by ±5–15%. Identical spacings at four angles produce visible Moiré. Slight detuning kills it.
curveScale tapers from 1.0 to 0.2. The base layer follows the form heavily — that's the hatch the eye reads as "the surface." Each subsequent layer bends less. By the deep-shadow pass it's essentially mechanical hatching, because deep shadows in real engravings are mechanical: the engraver isn't sculpting form there, they're filling space with ink.
widthMod falls with each layer. The base layer's lines vary a lot in width — hairline in highlights, thick in shadows. Deeper layers stay more uniform. Same logic. Form-following passes need expressive width; shadow-fill passes don't.
Three smaller details
Variable line width. halfWidth = lineWidth × (0.4 + 0.9 × excess) × 0.5, where excess is how much darker the pixel is than the layer's threshold. Thicker in shadows, hairline in highlights. The eye reads weight from line thickness, not from line count.
Power-curve line taper. pow(lineAlpha, 1.3). A linear ramp from 1.0 at the centerline to 0.0 at the edge looks like inkjet. The power curve concentrates ink at the center and tapers more sharply at the edges. That's wet ink on absorbent paper. Lines stop looking printed and start looking drawn.
Soft-cut visibility. Engraved lines either exist or don't — they're never gray. When a layer transitions from "draws here" to "doesn't draw here," cut hard with a small smoothstep instead of fading. A faded line reads as printer ink running low. A clean cut reads as the engraver picking up the tool. Same frequency content, completely different meaning.
A virtual coordinate basis
Line frequency in the shader is in image pixels, but the canvas can be any size — inline thumbnail, retina full-screen, downloadable PNG. Compute phase from gl_FragCoord directly and the same image gets visibly different line densities at different output sizes.
Fix a virtual basis. Pick a target dimension — 900px on the long edge — scale the source dimensions to it, and use those scaled dimensions everywhere phase is computed:
const scale = 900 / Math.max(img.naturalWidth, img.naturalHeight)
gl.uniform2f(u_imageSize, img.naturalWidth * scale, img.naturalHeight * scale)
// in shader: vec2 pSrc = v_uv * u_imageSizeSame portrait, same line spacing, regardless of output size.
Animation, almost free
Two extra uniforms make the result move.
u_progress runs from 0 to 1 over two seconds on first paint. Each hatch pass gates its lines by smoothstep(phiNorm, phiNorm + 0.06, u_progress). Because each pass uses its own phase direction, the four reveals sweep in from four different edges simultaneously. The layered cross-hatch literally constructs itself in front of you, and the visible wavefront is the math iterating.
u_time runs forever and drives a tiny UV drift on the blurred-sample read:
sampleUV += vec2(sin(u_time * 0.4 + 1.7), cos(u_time * 0.31)) * 0.003;Lines breathe along the form-following axis at roughly 0.3% magnitude. The engraver's hand isn't perfectly still.