Learn Creative Coding (#27) - Texture and Depth: Paper, Grain, and Hatching
Learn Creative Coding (#27) - Texture and Depth: Paper, Grain, and Hatching

Digital art often looks too clean. Too perfect. You generate a gorgeous composition with your seed system from episode 24, lay it out with the subdivision algorithm from episode 25, render some beautiful typography from last episode -- and something still feels off. The shapes are right, the colors are right, but it looks like a screenshot from a browser. Not like art.
The missing ingredient is texture. The subtle imperfections that make something feel like it was made with hands and materials, even when it was made entirely with code. Paper fibers, ink grain, pencil hatching, watercolor pooling. These are the surface qualities that make the difference between "clean digital" and "I'd print that and frame it."
This episode is about faking analog. It's one of those topics where a tiny amount of effort -- sometimes literally five lines of code on top of a finished piece -- makes a massive difference in how the output feels. We're going to simulate paper, add grain overlays, implement crosshatching algorithms, fake watercolor, and build atmospheric depth. Every technique here layers on top of whatever you've already got.
Why imperfection matters
Look at a watercolor painting. The pigment pools unevenly. The paper shows through in the light areas. The edges aren't crisp -- they bleed and feather. Your brain reads all of this as "real," as something that exists in the physical world and was made by a person.
Now look at a flat digital gradient. Perfect. Clean. And somehow... lifeless.
The gap between "digital" and "art" is often just texture. A noise overlay, some grain, slightly wobbly lines -- suddenly the same composition feels intentional and crafted instead of computer-generated. Think about the generative artists we talked about in episode 23 -- Vera Molnar, Tyler Hobbs, Dmitri Cherniak. Their work almost always has some form of texture or surface quality that makes it feel physical. Fidenza's flow fields have a certain softness. Ringers has line weight variation. These aren't accidents. They're deliberate design choices.
Our brains evolved to process the physical world. We respond emotionally to things that look like they exist in real space. A flat rectangle of color registers as "graphic." That same rectangle with subtle grain, slightly uneven edges, and a paper texture behind it registers as "object." The second one makes you want to reach out and touch it. That emotional response is what we're after.
Paper texture from scratch
The simplest approach: generate paper as your background layer before drawing anything else. We're combining the pixel manipulation techniques from episode 10 with multi-scale noise thinking:
function drawPaperTexture(ctx, w, h) {
let imageData = ctx.getImageData(0, 0, w, h);
let data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
// base warm white
data[i] = 245; // R
data[i + 1] = 240; // G
data[i + 2] = 230; // B
data[i + 3] = 255; // A
// subtle random noise per pixel
let noise = (Math.random() - 0.5) * 12;
data[i] += noise;
data[i + 1] += noise;
data[i + 2] += noise;
}
ctx.putImageData(imageData, 0, 0);
}
That gives you fine grain -- a base warm white with tiny per-pixel variation. The (Math.random() - 0.5) * 12 creates deviations of plus or minus 6 brightness levels. Subtle enough that you can't see individual dots, but textured enough that the surface doesn't look flat. The warm tint (245, 240, 230) already feels more like paper than pure white (255, 255, 255) would. Real paper absorbs blue light, so it reads slightly yellow-warm.
But real paper has structure at multiple scales. Individual fibers, fiber bundles, density patches. For that, we need layered noise -- the same multi-scale thinking we used back in episode 12 when building Perlin noise:
function drawPaperWithFiber(ctx, w, h, noiseFunc) {
let imageData = ctx.getImageData(0, 0, w, h);
let data = imageData.data;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
let i = (y * w + x) * 4;
// base brightness
let base = 242;
// large-scale variation (paper density patches)
let large = noiseFunc(x * 0.005, y * 0.005) * 15 - 7;
// medium-scale (fiber bundles)
let medium = noiseFunc(x * 0.02, y * 0.03) * 8 - 4;
// fine grain (individual fibers)
let fine = (Math.random() - 0.5) * 6;
let value = base + large + medium + fine;
data[i] = value + 3; // slightly warm
data[i + 1] = value;
data[i + 2] = value - 5; // slightly less blue
data[i + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
}
Three scales of noise working together. The large-scale (0.005 frequency) creates gentle light and dark patches across the whole sheet -- like how a real piece of paper is slightly thicker in some areas. The medium-scale (0.02, 0.03) simulates fiber bundles -- notice the slightly different X and Y frequencies, which makes the fibers run slightly more horizontally than vertically, like real laid paper. The fine grain is per-pixel random, no coherence needed. Together they produce something that genuinely looks like a scan of physical paper.
The noiseFunc parameter is our seeded Perlin noise from episode 24. If you're working in p5.js, you can use noise() directly. The key insight is that paper texture is fractal -- detail at every scale, combined additively. Same principle as the noise octaves we explored in episode 12.
Grain overlay: the lazy genius approach
Sometimes you don't want to build paper from scratch. You've already got a finished piece and just want to rough it up. The grain overlay approach adds texture on TOP of whatever you've drawn:
function addGrain(ctx, w, h, intensity) {
let imageData = ctx.getImageData(0, 0, w, h);
let data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
let grain = (Math.random() - 0.5) * intensity;
data[i] += grain;
data[i + 1] += grain;
data[i + 2] += grain;
}
ctx.putImageData(imageData, 0, 0);
}
// usage: after ALL your drawing is done
addGrain(ctx, canvas.width, canvas.height, 25);
Five lines of actual logic. Intensity of 15-25 is subtle but visible -- the kind of grain you'd see on a high-ISO photograph. 40+ starts looking like old film stock, grainy and moody. 80+ is aggressive texture that dominates the piece. I usually start around 20 and adjust from there.
The same grain applies equally to light and dark areas. For more realistic film grain, you can make the intensity proportional to brightness -- darker areas get less grain, brighter areas get more:
function addFilmGrain(ctx, w, h, intensity) {
let imageData = ctx.getImageData(0, 0, w, h);
let data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
// average brightness of this pixel
let brightness = (data[i] + data[i + 1] + data[i + 2]) / 3;
// grain scales with brightness (brighter = more grain)
let scaled = intensity * (0.3 + brightness / 255 * 0.7);
let grain = (Math.random() - 0.5) * scaled;
data[i] += grain;
data[i + 1] += grain;
data[i + 2] += grain;
}
ctx.putImageData(imageData, 0, 0);
}
In p5.js you can also do it with semi-transparent dots. Less efficient but easier to tweak visually:
function addGrainP5(intensity, density) {
loadPixels();
for (let i = 0; i < width * height * density; i++) {
let x = random(width);
let y = random(height);
let grain = random(-intensity, intensity);
let px = Math.floor(x);
let py = Math.floor(y);
let idx = (py * width + px) * 4;
pixels[idx] += grain;
pixels[idx + 1] += grain;
pixels[idx + 2] += grain;
}
updatePixels();
}
The density parameter (0 to 1) controls what fraction of pixels get grain. At 1.0, every pixel gets touched. At 0.3, only 30% of pixels are affected, which creates a speckled pattern rather than uniform grain. Both look good -- depends on the aesthetic you're after.
Crosshatching: digital pen and ink
Hatching fills areas with parallel lines. Crosshatching adds a second set at a different angle. It's how old engravings and pen-and-ink drawings create tonal gradation -- denser lines mean darker areas, sparser lines mean lighter areas. No solid fills, just lines implying tone. It's a beautiful technique to bring into generative art because it makes digital output look handmade.
Simple directional hatching
function hatchRect(ctx, x, y, w, h, angle, spacing, darkness) {
ctx.save();
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.clip();
let cos_a = Math.cos(angle);
let sin_a = Math.sin(angle);
let diagonal = Math.sqrt(w * w + h * h);
ctx.strokeStyle = `rgba(30, 25, 20, ${darkness})`;
ctx.lineWidth = 0.8;
for (let d = -diagonal; d < diagonal; d += spacing) {
let startX = x + w / 2 + cos_a * d - sin_a * diagonal;
let startY = y + h / 2 + sin_a * d + cos_a * diagonal;
let endX = x + w / 2 + cos_a * d + sin_a * diagonal;
let endY = y + h / 2 + sin_a * d - cos_a * diagonal;
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.stroke();
}
ctx.restore();
}
The cos/sin math here is the same polar coordinate rotation we used in episode 13 -- we're sweeping a line across the rectangle at the specified angle, stepping spacing pixels between each line. The ctx.clip() constrains the lines to the rectangle boundary so they don't bleed out everywhere. The darkness parameter (0 to 1) controls the alpha of each line.
Density-based hatching for tonal control
The real power of hatching: vary line spacing based on a value. Darker areas get denser lines, lighter areas get sparser lines. For circular shapes:
function hatchCircle(cx, cy, radius, value) {
// value 0-1: 0 = no hatching, 1 = dense hatching
if (value < 0.1) return;
let spacing = map(value, 0, 1, 20, 3);
let angle = PI / 4; // 45 degrees
push();
stroke(30, 25, 20, value * 200);
strokeWeight(0.7 + value * 0.5);
for (let d = -radius; d < radius; d += spacing) {
// find intersection with circle boundary
let halfChord = sqrt(max(0, radius * radius - d * d));
let px = cx + cos(angle + HALF_PI) * d;
let py = cy + sin(angle + HALF_PI) * d;
let x1 = px + cos(angle) * halfChord;
let y1 = py + sin(angle) * halfChord;
let x2 = px - cos(angle) * halfChord;
let y2 = py - sin(angle) * halfChord;
line(x1, y1, x2, y2);
}
// crosshatch: second pass at different angle for darker areas
if (value > 0.4) {
let angle2 = -PI / 4;
let spacing2 = spacing * 1.3;
for (let d = -radius; d < radius; d += spacing2) {
let halfChord = sqrt(max(0, radius * radius - d * d));
let px = cx + cos(angle2 + HALF_PI) * d;
let py = cy + sin(angle2 + HALF_PI) * d;
let x1 = px + cos(angle2) * halfChord;
let y1 = py + sin(angle2) * halfChord;
let x2 = px - cos(angle2) * halfChord;
let y2 = py - sin(angle2) * halfChord;
line(x1, y1, x2, y2);
}
}
pop();
}
Light areas (value < 0.4) get sparse lines at one angle. Medium to dark areas get a second set of lines crossing at -45 degrees -- that's the "cross" in crosshatch. The halfChord calculation (Pythagorean theorem -- remember episode 13?) finds exactly where each hatch line intersects the circle boundary, so lines are clipped to the shape mathematically rather than relying on a clipping region.
The spacing ranges from 20 pixels (very sparse, nearly invisible hatching) to 3 pixels (dense, almost solid-looking). That's a huge tonal range from a single technique. Traditional printmakers mapped the entire grayscale this way -- no solid fills at all, just varying line density. Beautiful constraint.
Making lines wobble
Perfectly straight hatch lines look mechanical. Real pen strokes aren't perfectly straight -- the hand wobbles, the paper resists. Add noise displacement along the line path for a hand-drawn feel:
function wobblyLine(x1, y1, x2, y2, wobble) {
let steps = dist(x1, y1, x2, y2) / 3;
beginShape();
for (let i = 0; i <= steps; i++) {
let t = i / steps;
let x = lerp(x1, x2, t) + (noise(i * 0.5, y1 * 0.01) - 0.5) * wobble;
let y = lerp(y1, y2, t) + (noise(i * 0.5 + 100, x1 * 0.01) - 0.5) * wobble;
vertex(x, y);
}
endShape();
}
Replace the line() calls in the hatching function with wobblyLine() and suddenly the whole thing looks like someone drew it with a steel-nib pen on slightly textured paper. A wobble of 1-3 pixels is subtle enough to be unconcious but powerful enough to feel human. Push it to 5+ and it starts looking intentionally shaky, like a rough sketch. Both are valid aesthetics.
The lerp here is the same interpolation function from episode 16. The noise sampling at i * 0.5 creates smooth, coherent wobble -- each point along the line is displaced slightly from its neighbors, but not randomly. The wobble has flow and continuity, like a real hand tremor.
Watercolor simulation
Fake watercolor by drawing many semi-transparent, slightly displaced copies of a shape. Each layer is barely visible on its own, but 40-60 layers build up natural density variation:
function watercolorBlob(cx, cy, radius, color, layers) {
for (let i = 0; i < layers; i++) {
let r = red(color);
let g = green(color);
let b = blue(color);
fill(r, g, b, 3); // barely visible per layer
noStroke();
beginShape();
let segments = 30;
for (let j = 0; j < segments; j++) {
let angle = (j / segments) * TWO_PI;
// each layer has a slightly different edge
let noiseR = radius * (0.7 + noise(cos(angle) + i * 0.3,
sin(angle) + i * 0.3) * 0.6);
let x = cx + cos(angle) * noiseR + random(-2, 2);
let y = cy + sin(angle) * noiseR + random(-2, 2);
curveVertex(x, y);
}
// close the curve (curveVertex needs overlap)
for (let j = 0; j < 3; j++) {
let angle = (j / segments) * TWO_PI;
let noiseR = radius * (0.7 + noise(cos(angle) + i * 0.3,
sin(angle) + i * 0.3) * 0.6);
curveVertex(cx + cos(angle) * noiseR + random(-2, 2),
cy + sin(angle) * noiseR + random(-2, 2));
}
endShape(CLOSE);
}
}
Each of the 40-60 layers has alpha 3 (out of 255 -- nearly invisible). But where many layers overlap, the color accumulates. The center of the blob gets hit by almost every layer, so it's darkest. The edges only get hit by the layers whose noise-displaced boundary extends that far, so they're lighter and feathery. This is exactly how real watercolor works -- pigment pools densely at the center and fades toward the edges.
The noise-displaced radius (0.7 + noise(...) * 0.6) means each layer has a different edge shape. Some bulge out here, contract there. The random(-2, 2) adds a tiny jitter on top of that. Together they create the organic, blobby edge that makes watercolor look like watercolor. No two layers are the same shape, but they all cluster around the same center.
Real watercolors are also darker at their very edges where pigment collects as the water evaporates. You can fake that by drawing the outline separately with slightly more opacity:
function watercolorEdge(cx, cy, radius, color) {
noFill();
let r = red(color);
let g = green(color);
let b = blue(color);
for (let i = 0; i < 15; i++) {
stroke(r * 0.6, g * 0.6, b * 0.6, 5);
strokeWeight(random(0.5, 2));
beginShape();
let segments = 40;
for (let j = 0; j <= segments; j++) {
let angle = (j / segments) * TWO_PI;
let noiseR = radius * (0.95 + noise(cos(angle) * 2 + i * 0.2,
sin(angle) * 2 + i * 0.2) * 0.1);
curveVertex(cx + cos(angle) * noiseR, cy + sin(angle) * noiseR);
}
endShape();
}
}
Call watercolorBlob() first for the fill, then watercolorEdge() on top. The darker, tighter outlines reinforce the edge and create that characteristic ring of accumulated pigment. It's a small detail but it sells the illusion hard.
Atmospheric depth through layering
Create the impression of depth by rendering elements in layers with decreasing contrast as they recede. Distant things are faded, desaturated, and small. Near things are vivid, saturated, and large. This is aerial perspective -- the same effect that makes distant mountains look blue and hazy:
function drawWithDepth(layers) {
background(245, 240, 230);
for (let layer = layers; layer >= 0; layer--) {
let depth = layer / layers; // 1 = far away, 0 = right here
// far things: low contrast, low saturation, smaller
let alpha = map(depth, 0, 1, 255, 40);
let saturation = map(depth, 0, 1, 80, 20);
let detail = map(depth, 0, 1, 1, 0.3);
let scale = map(depth, 0, 1, 1, 0.5);
for (let i = 0; i < 20; i++) {
let x = random(width);
let y = random(height);
colorMode(HSB, 360, 100, 100, 255);
fill(200 + depth * 30, saturation, 60 + depth * 20, alpha);
noStroke();
ellipse(x, y, 20 * scale + random(5) * detail);
}
colorMode(RGB, 255);
}
}
We render back-to-front (far layers first). Background layers are ghostly -- alpha 40, saturation 20, half-scale. Foreground layers are bold -- alpha 255, saturation 80, full-scale. The hue shifts toward blue for distant layers (200 + depth * 30) which mimics how atmospheric scattering makes faraway objects blue-shifted. The detail parameter scales the random size variation -- distant elements are more uniform (less visible detail), near elements have more variation.
This techinque works with any visual elements. Circles, lines, particles, typography from last episode, flow fields from episode 12. Whatever your content is, rendering it in depth layers with decreasing contrast instantly creates the feeling of a 3D space on a flat canvas.
Putting it all together: the texture stack
The real impact comes from combining multiple texture techniques. Here's the order I use on almost every finished piece:
function finishedPiece() {
// 1. paper texture base
drawPaperWithFiber(ctx, width, height, noiseFunc);
// 2. your actual artwork goes here
// ... shapes, lines, composition, typography ...
// 3. hatching for shading
for (let shape of shapes) {
let shadow = calculateShadow(shape);
hatchCircle(shape.x, shape.y, shape.r, shadow);
}
// 4. grain overlay
addGrain(ctx, width, height, 18);
// 5. subtle vignette (darker edges)
let gradient = ctx.createRadialGradient(
width/2, height/2, width * 0.3,
width/2, height/2, width * 0.7
);
gradient.addColorStop(0, 'rgba(0,0,0,0)');
gradient.addColorStop(1, 'rgba(0,0,0,0.15)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
}
Paper base, then artwork, then hatching, then grain, then vignette. Five layers of texture. Each one is simple -- a few lines of code at most. But stacked together they transform the output from "screenshot of a browser canvas" to "carefully produced print." The vignette at the end is just a radial gradient from transparent center to slightly dark edges. It focuses the viewer's attention toward the center and gives the whole piece a framed feel. Photographers have used vignetting for over a century. It works just as well for generative art.
The order matters. Paper goes first because everything else sits on top of it. Grain goes after the artwork because it should affect the art as well as the paper. Vignette goes last because it should darken everything uniformly, including the grain. If you did grain after vignette, the edges would look grainy-dark instead of smoothly dark. Small differences, but they're the kind of details that separate polished work from rough drafts.
When NOT to add texture
Quick thought: texture isn't always the answer. Some pieces benefit from being clean and crisp. Geometric work in the style of Piet Mondrian or Josef Albers -- hard edges, flat color, mathematical precision -- gets muddied by grain and wobble. Shader-based work (like what we explored in episode 21) has its own inherent texture from the math. Adding grain on top of a fragment shader can look like compression artifacts rather than artistic texture.
The rule of thumb: if your piece is trying to feel physical and handmade, add texture. If it's trying to feel digital and precise, leave it clean. And if you're not sure, try both and see which version you like better. That's the curation mindset from episode 23 -- generate options, pick the best one.
't Komt erop neer...
- Paper texture = multi-scale noise (large density patches + fiber bundles + fine grain)
- Grain overlay after drawing adds analog feel with minimal effort -- intensity 15-25 for subtle, 40+ for moody
- Crosshatching: parallel lines at one angle, second set at different angle for darker areas
- Line spacing maps directly to tonal value -- denser = darker, sparser = lighter
- Wobbly lines (noise displacement along path) = instant hand-drawn feel with 1-3 pixels of displacement
- Watercolor = many semi-transparent layers (alpha 3) with slightly different noise-displaced edges
- Edge darkening on watercolor blobs simulates pigment pooling
- Atmospheric depth: far = faded, desaturated, small, blue-shifted; near = vivid, large, warm
- The texture stack: paper, art, hatching, grain, vignette -- order matters
- Not every piece needs texture -- geometric and shader-based work can benefit from staying clean
All of these techniques add surface quality. But there's another layer of generative art that we haven't covered yet -- how to choose and manipulate color programmatically. Extracting palettes from images, generating harmonious colors from math, using curated color data as raw material. Color is arguably the single biggest factor in whether a piece feels amateurish or professional, and doing it well is less about taste and more about algorithms than you'd think :-)
Sallukes! Thanks for reading.
X