Stippling Art: How to Create Beautiful Dot-Based Art With Code
Stippling art is the technique of building tone, texture, and form entirely from dots. Each dot is a single mark — no lines, no continuous shading — yet thousands of dots together can describe a portrait, a landscape, or an abstract composition with extraordinary subtlety. The density of dots controls darkness: pack dots tightly for shadows, spread them apart for highlights, and the viewer’s eye blends them into smooth gradients.
The technique has deep roots. Georges Seurat and Paul Signac developed pointillism art in the 1880s, applying tiny dabs of pure colour that blended optically at a distance. Scientific and medical illustrators adopted stipple drawing in the 19th century because stippled images reproduced cleanly on printing presses when halftone screens degraded — biological specimens, geological cross-sections, and anatomical diagrams were all rendered in dots. Today, stippling art remains a staple of pen-and-ink illustration, editorial art, and tattoo design.
For creative coders, stippling is a perfect meeting point of aesthetics and algorithms. The core question — “where should I place the next dot?” — connects to Poisson disc sampling, Voronoi tessellation, Lloyd relaxation, and rejection sampling. A simple dot art sketch can be written in twenty lines; a publication-quality stipple portrait requires hundreds of thousands of carefully placed points and sophisticated distribution algorithms.
In this guide we build eight interactive stippling art simulations, progressing from naive random placement to a full generative composition that combines multiple techniques. Every example is self-contained, runs on a plain HTML Canvas with no external libraries, and stays under 50KB. For more related techniques, see the Voronoi diagram guide, the drawing with code guide, or the math art guide.
Setting up
Every example uses this minimal HTML setup:
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
// ... example code goes here ...
</script>
Paste each example into the script section. All code is vanilla JavaScript with the Canvas 2D API.
1. Random stippling: the clumpy baseline
The simplest approach to stippling art is placing dots at purely random coordinates. This produces a recognisable texture instantly, but it has a fundamental problem: truly random points cluster. The Poisson distribution means some areas get dense clumps while others get empty voids. This “clumpy” look is immediately visible and feels unnatural compared to hand-placed dot art.
Understanding why random placement fails is essential before we can appreciate better algorithms. This first example lets you see the clumping effect directly and establishes a baseline to compare against.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
ctx.fillStyle = '#faf8f5';
ctx.fillRect(0, 0, W, H);
const dotCount = 8000;
const minR = 0.8;
const maxR = 2.5;
ctx.fillStyle = '#1a1a1a';
for (let i = 0; i < dotCount; i++) {
const x = Math.random() * W;
const y = Math.random() * H;
const r = minR + Math.random() * (maxR - minR);
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
// Draw a label showing the clumping problem
ctx.fillStyle = '#888';
ctx.font = '14px sans-serif';
ctx.fillText('Random stippling: notice clumps and voids', 20, H - 20);
</script>
Notice how some regions are noticeably denser than others, while gaps appear where no dots landed. This is not a bug — it is the expected behaviour of uniform random sampling. The human eye is extremely sensitive to these irregularities, which is why stipple artists and algorithms both need better strategies.
2. Poisson disc sampling: blue-noise stipple drawing
Poisson disc sampling is the gold standard for stippling art. The idea is simple: every dot must be at least a minimum distance r from every other dot. This produces a “blue noise” distribution — evenly spaced but not grid-aligned — that looks natural and organic. Robert Bridson published an efficient O(n) algorithm in 2007 that makes this practical even for tens of thousands of dots.
Bridson’s algorithm works by maintaining a grid of cells (each cell is r/√2 wide, guaranteeing at most one point per cell) and an “active list” of points that might still have room for neighbours. For each active point, it tries k random candidates in the annulus between radius r and 2r. If a candidate is far enough from all existing points (checked via the grid), it becomes a new point. If all k candidates fail, the point is removed from the active list. The process stops when the active list is empty.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
ctx.fillStyle = '#faf8f5';
ctx.fillRect(0, 0, W, H);
function poissonDisc(width, height, minDist, k) {
const cellSize = minDist / Math.SQRT2;
const gridW = Math.ceil(width / cellSize);
const gridH = Math.ceil(height / cellSize);
const grid = new Array(gridW * gridH).fill(-1);
const points = [];
const active = [];
function gridIndex(x, y) {
return Math.floor(y / cellSize) * gridW + Math.floor(x / cellSize);
}
function addPoint(x, y) {
const i = points.length;
points.push([x, y]);
active.push(i);
grid[gridIndex(x, y)] = i;
}
function isValid(x, y) {
if (x < 0 || x >= width || y < 0 || y >= height) return false;
const gx = Math.floor(x / cellSize);
const gy = Math.floor(y / cellSize);
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
const nx = gx + dx;
const ny = gy + dy;
if (nx < 0 || nx >= gridW || ny < 0 || ny >= gridH) continue;
const idx = grid[ny * gridW + nx];
if (idx === -1) continue;
const ddx = points[idx][0] - x;
const ddy = points[idx][1] - y;
if (ddx * ddx + ddy * ddy < minDist * minDist) return false;
}
}
return true;
}
addPoint(width / 2, height / 2);
while (active.length > 0) {
const ai = Math.floor(Math.random() * active.length);
const pi = active[ai];
const px = points[pi][0];
const py = points[pi][1];
let found = false;
for (let t = 0; t < k; t++) {
const angle = Math.random() * Math.PI * 2;
const dist = minDist + Math.random() * minDist;
const nx = px + Math.cos(angle) * dist;
const ny = py + Math.sin(angle) * dist;
if (isValid(nx, ny)) {
addPoint(nx, ny);
found = true;
break;
}
}
if (!found) {
active.splice(ai, 1);
}
}
return points;
}
const pts = poissonDisc(W, H, 10, 30);
ctx.fillStyle = '#1a1a1a';
for (let i = 0; i < pts.length; i++) {
ctx.beginPath();
ctx.arc(pts[i][0], pts[i][1], 1.8, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#888';
ctx.font = '14px sans-serif';
ctx.fillText('Poisson disc sampling: ' + pts.length + ' evenly spaced dots', 20, H - 20);
</script>
Compare this to the random version: the dots are spread much more evenly, with no visible clumps or voids. This blue-noise pattern is what your eye expects from a quality stipple drawing. Poisson disc sampling is used in everything from halftone printing to terrain generation in video games.
3. Weighted density stippling: tone from dot density
Real stippling art conveys tone — light and dark areas — by varying the density of dots. Darker regions get more dots packed closely together; lighter regions get fewer, more widely spaced dots. This is the fundamental principle behind pointillism art and every newspaper halftone you have ever seen.
The algorithm is rejection sampling: generate a random candidate point, compute the “darkness” at that location (from a gradient, function, or image), then accept the point with probability proportional to that darkness value. Dark pixels accept almost every candidate; bright pixels reject almost all of them. The result is a stipple drawing where dot density maps directly to tonal value.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
ctx.fillStyle = '#faf8f5';
ctx.fillRect(0, 0, W, H);
// Create a grayscale source: radial gradient with bright center, dark edges
function darkness(x, y) {
const cx = W / 2, cy = H / 2;
const maxDist = Math.sqrt(cx * cx + cy * cy);
const dx = x - cx, dy = y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
// Add sine wave modulation for visual interest
const wave = 0.15 * Math.sin(x * 0.02) * Math.sin(y * 0.02);
const base = dist / maxDist;
return Math.min(1, Math.max(0, base + wave));
}
const dots = [];
const maxDots = 25000;
let attempts = 0;
const maxAttempts = 500000;
while (dots.length < maxDots && attempts < maxAttempts) {
const x = Math.random() * W;
const y = Math.random() * H;
const d = darkness(x, y);
if (Math.random() < d) {
dots.push([x, y]);
}
attempts++;
}
ctx.fillStyle = '#1a1a1a';
for (let i = 0; i < dots.length; i++) {
const r = 0.8 + darkness(dots[i][0], dots[i][1]) * 1.2;
ctx.beginPath();
ctx.arc(dots[i][0], dots[i][1], r, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#888';
ctx.font = '14px sans-serif';
ctx.fillText('Weighted density: ' + dots.length + ' dots via rejection sampling', 20, H - 20);
</script>
The centre is bright (few dots) and the edges are dark (densely packed dots), with subtle wave modulation adding texture. This technique is the bridge between abstract dot distributions and representational stippling art — once you can map any grayscale image to dot density, you can stipple anything.
4. Lloyd relaxation: animated convergence to perfection
Lloyd relaxation (also called Voronoi iteration) is an elegant algorithm that transforms a messy random point set into a beautifully even distribution. The process is simple: compute the Voronoi diagram of the current points, move each point to the centroid (centre of mass) of its Voronoi cell, and repeat. With each iteration, the points spread out more evenly, and the Voronoi cells become more regular — converging toward a centroidal Voronoi tessellation.
This example animates the relaxation process so you can watch the dots settle into their equilibrium positions. We use a simplified approximation: instead of computing exact Voronoi cells, we estimate each cell’s centroid by sampling nearby random points and assigning them to the nearest seed. This is slower than Fortune’s algorithm but far simpler to implement.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
const N = 500;
const points = [];
for (let i = 0; i < N; i++) {
points.push([Math.random() * W, Math.random() * H]);
}
let iteration = 0;
const maxIterations = 80;
function findNearest(sx, sy) {
let best = 0;
let bestD = Infinity;
for (let i = 0; i < points.length; i++) {
const dx = points[i][0] - sx;
const dy = points[i][1] - sy;
const d = dx * dx + dy * dy;
if (d < bestD) {
bestD = d;
best = i;
}
}
return best;
}
function relaxStep() {
const sumX = new Float64Array(N);
const sumY = new Float64Array(N);
const count = new Uint32Array(N);
const samples = 20000;
for (let s = 0; s < samples; s++) {
const sx = Math.random() * W;
const sy = Math.random() * H;
const nearest = findNearest(sx, sy);
sumX[nearest] += sx;
sumY[nearest] += sy;
count[nearest]++;
}
for (let i = 0; i < N; i++) {
if (count[i] > 0) {
points[i][0] = sumX[i] / count[i];
points[i][1] = sumY[i] / count[i];
}
}
}
function draw() {
ctx.fillStyle = '#faf8f5';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#1a1a1a';
for (let i = 0; i < N; i++) {
ctx.beginPath();
ctx.arc(points[i][0], points[i][1], 3, 0, Math.PI * 2);
ctx.fill();
}
// Draw faint Voronoi edges by sampling
ctx.strokeStyle = 'rgba(100,100,100,0.1)';
ctx.lineWidth = 0.5;
const step = 4;
for (let y = 0; y < H; y += step) {
for (let x = 0; x < W - step; x += step) {
const a = findNearest(x, y);
const b = findNearest(x + step, y);
const d = findNearest(x, y + step < H ? y + step : y);
if (a !== b || a !== d) {
ctx.fillStyle = 'rgba(100,100,100,0.15)';
ctx.fillRect(x, y, step, step);
}
}
}
ctx.fillStyle = '#1a1a1a';
for (let i = 0; i < N; i++) {
ctx.beginPath();
ctx.arc(points[i][0], points[i][1], 3, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#888';
ctx.font = '14px sans-serif';
ctx.fillText('Lloyd relaxation: iteration ' + iteration + ' / ' + maxIterations, 20, H - 20);
}
function animate() {
if (iteration < maxIterations) {
relaxStep();
iteration++;
draw();
requestAnimationFrame(animate);
}
}
draw();
requestAnimationFrame(animate);
</script>
Watch how the dots shift from a chaotic random scatter to an almost crystalline regularity. The Voronoi cells become roughly equal in area, and every dot settles roughly equidistant from its neighbours. Lloyd relaxation is used in stippling art tools, mesh generation, colour quantisation, and even the placement of cell towers.
5. Stipple portrait: turning a shape into dots
A stipple portrait converts a source image into a field of dots where density represents brightness. Since we cannot load external images in a standalone Canvas snippet, we generate a simple synthetic “face” — an oval with darker regions for eyes, nose shadow, and mouth — and then stipple it. The technique combines the density mapping from example 3 with Poisson-like minimum spacing to avoid clumping in dark areas.
The approach: first we paint the synthetic face to an offscreen canvas and read its pixel data. Then we use weighted Poisson disc sampling — the minimum distance between dots scales with brightness. In dark areas the minimum distance is small (dots pack tightly); in bright areas it is large (dots spread out). This produces a clean stipple drawing with excellent tonal range.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
// Generate synthetic face on an offscreen canvas
const off = document.createElement('canvas');
off.width = W;
off.height = H;
const ox = off.getContext('2d');
// Background: white
ox.fillStyle = '#fff';
ox.fillRect(0, 0, W, H);
// Face oval
ox.fillStyle = '#d4b896';
ox.beginPath();
ox.ellipse(W / 2, H / 2, 220, 300, 0, 0, Math.PI * 2);
ox.fill();
// Eyes (dark)
ox.fillStyle = '#2a2a2a';
ox.beginPath();
ox.ellipse(W / 2 - 80, H / 2 - 60, 30, 20, 0, 0, Math.PI * 2);
ox.fill();
ox.beginPath();
ox.ellipse(W / 2 + 80, H / 2 - 60, 30, 20, 0, 0, Math.PI * 2);
ox.fill();
// Nose shadow
ox.fillStyle = '#a08060';
ox.beginPath();
ox.ellipse(W / 2, H / 2 + 30, 15, 40, 0, 0, Math.PI * 2);
ox.fill();
// Mouth
ox.fillStyle = '#8a4040';
ox.beginPath();
ox.ellipse(W / 2, H / 2 + 110, 50, 15, 0, 0, Math.PI * 2);
ox.fill();
// Hair (dark mass on top)
ox.fillStyle = '#1a1a1a';
ox.beginPath();
ox.ellipse(W / 2, H / 2 - 240, 240, 120, 0, 0, Math.PI);
ox.fill();
// Read pixel data
const imgData = ox.getImageData(0, 0, W, H).data;
function getBrightness(px, py) {
const ix = Math.floor(Math.max(0, Math.min(W - 1, px)));
const iy = Math.floor(Math.max(0, Math.min(H - 1, py)));
const idx = (iy * W + ix) * 4;
return (imgData[idx] + imgData[idx + 1] + imgData[idx + 2]) / (3 * 255);
}
// Weighted Poisson disc: minDist depends on brightness
const minDistDark = 4;
const minDistBright = 20;
const cellSize = minDistDark / Math.SQRT2;
const gridW = Math.ceil(W / cellSize);
const gridH = Math.ceil(H / cellSize);
const grid = new Array(gridW * gridH).fill(-1);
const points = [];
const active = [];
function gi(x, y) {
return Math.floor(y / cellSize) * gridW + Math.floor(x / cellSize);
}
function addPt(x, y) {
const i = points.length;
points.push([x, y]);
active.push(i);
grid[gi(x, y)] = i;
}
function localMinDist(x, y) {
const b = getBrightness(x, y);
return minDistDark + b * (minDistBright - minDistDark);
}
function isValid(x, y) {
if (x < 0 || x >= W || y < 0 || y >= H) return false;
const md = localMinDist(x, y);
const checkR = Math.ceil(md / cellSize);
const gx = Math.floor(x / cellSize);
const gy = Math.floor(y / cellSize);
for (let dy = -checkR; dy <= checkR; dy++) {
for (let dx = -checkR; dx <= checkR; dx++) {
const nx = gx + dx, ny = gy + dy;
if (nx < 0 || nx >= gridW || ny < 0 || ny >= gridH) continue;
const idx = grid[ny * gridW + nx];
if (idx === -1) continue;
const ddx = points[idx][0] - x;
const ddy = points[idx][1] - y;
const dist2 = ddx * ddx + ddy * ddy;
const reqDist = Math.min(md, localMinDist(points[idx][0], points[idx][1]));
if (dist2 < reqDist * reqDist) return false;
}
}
return true;
}
addPt(W / 2, H / 2);
const k = 30;
while (active.length > 0) {
const ai = Math.floor(Math.random() * active.length);
const pi = active[ai];
const px = points[pi][0], py = points[pi][1];
const md = localMinDist(px, py);
let found = false;
for (let t = 0; t < k; t++) {
const angle = Math.random() * Math.PI * 2;
const dist = md + Math.random() * md;
const nx = px + Math.cos(angle) * dist;
const ny = py + Math.sin(angle) * dist;
if (isValid(nx, ny)) {
addPt(nx, ny);
found = true;
break;
}
}
if (!found) active.splice(ai, 1);
}
// Draw result
ctx.fillStyle = '#faf8f5';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#1a1a1a';
for (let i = 0; i < points.length; i++) {
const b = getBrightness(points[i][0], points[i][1]);
if (b > 0.95) continue; // skip pure white
const r = 0.6 + (1 - b) * 1.8;
ctx.beginPath();
ctx.arc(points[i][0], points[i][1], r, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#888';
ctx.font = '14px sans-serif';
ctx.fillText('Stipple portrait: ' + points.length + ' weighted Poisson dots', 20, H - 20);
</script>
The result is a recognisable face built entirely from dots. Dark features like the eyes and hair are packed with tiny points, while the bright background is nearly empty. This is the core technique behind professional stipple portrait tools — the only difference is that production tools use real photographs as source images.
6. Cross-hatching: lines for tone
Cross-hatching is stippling’s cousin: instead of dots, you use short parallel lines to build tone. One layer of parallel lines creates a light tone; overlapping layers at different angles create progressively darker values. This classic illustration technique — used by Albrecht Dürer, Rembrandt, and countless pen-and-ink artists — is beautifully suited to algorithmic generation.
In this example we divide the canvas into a grid of cells. For each cell we compute a “darkness” value from a radial gradient and then draw zero to four layers of hatching lines depending on the darkness. Layer 1 is horizontal, layer 2 is diagonal at 45°, layer 3 is vertical, and layer 4 is diagonal at 135°. More layers means darker tone.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
ctx.fillStyle = '#faf8f5';
ctx.fillRect(0, 0, W, H);
const cellSize = 16;
const cols = Math.floor(W / cellSize);
const rows = Math.floor(H / cellSize);
function darkness(cx, cy) {
const dx = cx - W / 2;
const dy = cy - H / 2;
const dist = Math.sqrt(dx * dx + dy * dy);
const maxD = Math.sqrt(W * W + H * H) / 2;
const radial = dist / maxD;
const wave = 0.2 * Math.sin(cx * 0.015) * Math.cos(cy * 0.015);
return Math.min(1, Math.max(0, radial + wave));
}
const angles = [0, Math.PI / 4, Math.PI / 2, 3 * Math.PI / 4];
const spacing = 3;
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = 0.7;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x0 = col * cellSize;
const y0 = row * cellSize;
const cx = x0 + cellSize / 2;
const cy = y0 + cellSize / 2;
const d = darkness(cx, cy);
// Number of hatch layers: 0-4 based on darkness
const layers = Math.floor(d * 4.99);
if (layers === 0) continue;
ctx.save();
ctx.beginPath();
ctx.rect(x0, y0, cellSize, cellSize);
ctx.clip();
for (let layer = 0; layer < layers; layer++) {
const angle = angles[layer];
const cos = Math.cos(angle);
const sin = Math.sin(angle);
// Draw parallel lines across the cell
const diag = cellSize * 2;
const lineCount = Math.floor(diag / spacing);
for (let li = 0; li < lineCount; li++) {
const offset = -diag / 2 + li * spacing;
const lx = cx + cos * offset;
const ly = cy + sin * offset;
ctx.beginPath();
ctx.moveTo(lx - sin * diag, ly + cos * diag);
ctx.lineTo(lx + sin * diag, ly - cos * diag);
ctx.stroke();
}
}
ctx.restore();
}
}
ctx.fillStyle = '#888';
ctx.font = '14px sans-serif';
ctx.fillText('Cross-hatching: 4 angle layers for tonal range', 20, H - 20);
</script>
The result has a distinctly different character from dot-based stippling art — more energetic and directional, with visible texture from the line angles. Cross-hatching is particularly effective for representing surfaces with directionality, like fabric folds or wood grain, because the line angle can follow the surface contour.
7. Stipple shading on 3D forms: sphere and cylinder
Stipple drawing becomes truly expressive when used to describe three-dimensional form. Scientific illustrators have used this technique for centuries: denser dots in shadow regions, sparser dots in highlights, with the density gradient following the curvature of the surface. A stippled sphere is one of the classic exercises in illustration courses.
Here we draw a sphere and a cylinder side by side, computing the surface normal at each point to determine the shading value (Lambert diffuse lighting). We then place dots using rejection sampling with acceptance probability proportional to shadow intensity. The dots are smaller in lighter areas and slightly larger in darker areas, further enhancing the illusion of depth.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
ctx.fillStyle = '#faf8f5';
ctx.fillRect(0, 0, W, H);
// Light direction (normalized)
const lx = -0.5, ly = -0.6, lz = 0.6245; // roughly normalized
function stippleRegion(testFn, shadeFn, count, maxTries) {
const dots = [];
let tries = 0;
while (dots.length < count && tries < maxTries) {
const x = Math.random() * W;
const y = Math.random() * H;
if (!testFn(x, y)) { tries++; continue; }
const shade = shadeFn(x, y); // 0 = lit, 1 = shadow
if (Math.random() < shade) {
dots.push([x, y, shade]);
}
tries++;
}
return dots;
}
// Sphere: center (250, 400), radius 180
const scx = 250, scy = 400, sr = 180;
function sphereTest(x, y) {
const dx = x - scx, dy = y - scy;
return dx * dx + dy * dy < sr * sr;
}
function sphereShade(x, y) {
const dx = (x - scx) / sr;
const dy = (y - scy) / sr;
const dz2 = 1 - dx * dx - dy * dy;
if (dz2 <= 0) return 1;
const dz = Math.sqrt(dz2);
// Lambert: dot(normal, light)
const dot = dx * lx + dy * ly + dz * lz;
const shade = 1 - Math.max(0, dot);
return shade * 0.9 + 0.05; // keep some dots everywhere
}
// Cylinder: center x=580, y from 200 to 600, radius 120
const ccx = 580, cyTop = 200, cyBot = 600, cr = 120;
function cylTest(x, y) {
if (y < cyTop || y > cyBot) return false;
const dx = x - ccx;
return Math.abs(dx) < cr;
}
function cylShade(x, y) {
const dx = (x - ccx) / cr;
const dz = Math.sqrt(Math.max(0, 1 - dx * dx));
const dot = dx * lx + dz * lz;
const shade = 1 - Math.max(0, dot);
return shade * 0.85 + 0.05;
}
const sphereDots = stippleRegion(sphereTest, sphereShade, 12000, 400000);
const cylDots = stippleRegion(cylTest, cylShade, 10000, 300000);
ctx.fillStyle = '#1a1a1a';
function drawDots(dots) {
for (let i = 0; i < dots.length; i++) {
const r = 0.5 + dots[i][2] * 1.5;
ctx.beginPath();
ctx.arc(dots[i][0], dots[i][1], r, 0, Math.PI * 2);
ctx.fill();
}
}
drawDots(sphereDots);
drawDots(cylDots);
// Labels
ctx.fillStyle = '#888';
ctx.font = '14px sans-serif';
ctx.fillText('Sphere', scx - 25, scy + sr + 30);
ctx.fillText('Cylinder', ccx - 30, cyBot + 30);
ctx.fillText('Stipple shading on 3D forms: Lambert diffuse lighting', 20, H - 20);
</script>
The sphere shows smooth tonal gradation from highlight to shadow, with a bright spot where the surface faces the light source directly. The cylinder shows a similar gradient but only along the horizontal axis. Both shapes read as convincingly three-dimensional despite being composed entirely of flat dots — a testament to the power of stipple drawing for representing form.
8. Generative stipple art composition: landscape
This final example combines everything we have learned into a single generative stippling art composition: a landscape scene with Poisson-sampled grass dots in the foreground, cross-hatched sky, stipple-shaded trees, and a Lloyd-relaxed midground pattern. Click the canvas to regenerate with a new random seed.
<canvas id="c" width="800" height="800"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
let seed = Date.now();
function seededRandom() {
seed = (seed * 1664525 + 1013904223) & 0xffffffff;
return (seed >>> 0) / 4294967296;
}
function generate() {
seed = Date.now();
ctx.fillStyle = '#faf8f5';
ctx.fillRect(0, 0, W, H);
const horizon = 350 + seededRandom() * 50;
// --- Sky: cross-hatching ---
ctx.strokeStyle = 'rgba(26,26,26,0.3)';
ctx.lineWidth = 0.5;
const skySpacing = 6;
for (let y = 0; y < horizon; y += skySpacing) {
const darkness = 0.15 + 0.4 * (1 - y / horizon);
if (seededRandom() > darkness) continue;
ctx.beginPath();
const wobble = seededRandom() * 3 - 1.5;
ctx.moveTo(0, y + wobble);
ctx.lineTo(W, y + wobble + seededRandom() * 2);
ctx.stroke();
}
// Diagonal sky hatching for darker top
ctx.strokeStyle = 'rgba(26,26,26,0.15)';
for (let i = 0; i < 120; i++) {
const y = seededRandom() * horizon * 0.5;
const x = seededRandom() * W;
const len = 20 + seededRandom() * 40;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + len * 0.7, y + len * 0.7);
ctx.stroke();
}
// --- Sun ---
const sunX = 150 + seededRandom() * 500;
const sunY = 60 + seededRandom() * 100;
ctx.strokeStyle = 'rgba(26,26,26,0.2)';
ctx.lineWidth = 0.4;
for (let r = 15; r < 45; r += 4) {
ctx.beginPath();
ctx.arc(sunX, sunY, r, 0, Math.PI * 2);
ctx.stroke();
}
// --- Mountains / hills on horizon ---
ctx.fillStyle = '#1a1a1a';
const hillCount = 3 + Math.floor(seededRandom() * 3);
for (let h = 0; h < hillCount; h++) {
const hx = seededRandom() * W;
const hw = 100 + seededRandom() * 200;
const hh = 30 + seededRandom() * 80;
// Stipple the hill
const hillDots = 200 + Math.floor(seededRandom() * 400);
for (let d = 0; d < hillDots; d++) {
const dx = hx - hw / 2 + seededRandom() * hw;
const t = (dx - (hx - hw / 2)) / hw;
const maxY = hh * Math.sin(t * Math.PI);
const dy = horizon - seededRandom() * maxY;
const dotR = 0.5 + seededRandom() * 1.2;
ctx.beginPath();
ctx.arc(dx, dy, dotR, 0, Math.PI * 2);
ctx.fill();
}
}
// --- Trees (stipple shaded) ---
const treeCount = 4 + Math.floor(seededRandom() * 5);
for (let t = 0; t < treeCount; t++) {
const tx = 50 + seededRandom() * (W - 100);
const ty = horizon + 20 + seededRandom() * 80;
const th = 80 + seededRandom() * 120;
const tw = 30 + seededRandom() * 50;
// Trunk: a few vertical dots
for (let d = 0; d < 30; d++) {
const dx = tx - 3 + seededRandom() * 6;
const dy = ty + seededRandom() * (th * 0.4);
ctx.beginPath();
ctx.arc(dx, dy, 0.8, 0, Math.PI * 2);
ctx.fill();
}
// Canopy: dense stipple ellipse
const canopyY = ty - th * 0.3;
const canopyDots = 150 + Math.floor(seededRandom() * 250);
for (let d = 0; d < canopyDots; d++) {
const angle = seededRandom() * Math.PI * 2;
const rr = seededRandom();
const dx = tx + Math.cos(angle) * tw * rr;
const dy = canopyY + Math.sin(angle) * (tw * 0.8) * rr;
// Darker on the right side (away from light)
const shade = 0.3 + 0.7 * ((dx - tx) / tw + 1) / 2;
if (seededRandom() < shade) {
const dotR = 0.5 + shade * 1.5;
ctx.beginPath();
ctx.arc(dx, dy, dotR, 0, Math.PI * 2);
ctx.fill();
}
}
}
// --- Foreground: Poisson-sampled grass dots ---
const grassTop = horizon + 100;
const grassPts = [];
const grassMinDist = 8;
const grassAttempts = 15000;
for (let a = 0; a < grassAttempts; a++) {
const gx = seededRandom() * W;
const gy = grassTop + seededRandom() * (H - grassTop - 40);
// Check minimum distance (brute force, ok for demo count)
let ok = true;
for (let j = grassPts.length - 1; j >= Math.max(0, grassPts.length - 200); j--) {
const ddx = grassPts[j][0] - gx;
const ddy = grassPts[j][1] - gy;
if (ddx * ddx + ddy * ddy < grassMinDist * grassMinDist) {
ok = false;
break;
}
}
if (ok) grassPts.push([gx, gy]);
}
for (let i = 0; i < grassPts.length; i++) {
const depth = (grassPts[i][1] - grassTop) / (H - grassTop - 40);
const r = 0.8 + depth * 2;
ctx.beginPath();
ctx.arc(grassPts[i][0], grassPts[i][1], r, 0, Math.PI * 2);
ctx.fill();
}
// --- Midground: Lloyd-relaxed pattern (small region) ---
const midY = horizon + 10;
const midH = 90;
const midPts = [];
const midN = 60;
for (let i = 0; i < midN; i++) {
midPts.push([seededRandom() * W, midY + seededRandom() * midH]);
}
// A few Lloyd iterations
for (let iter = 0; iter < 8; iter++) {
const sx = new Float64Array(midN);
const sy = new Float64Array(midN);
const sc = new Uint32Array(midN);
for (let s = 0; s < 3000; s++) {
const px = seededRandom() * W;
const py = midY + seededRandom() * midH;
let best = 0, bestD = Infinity;
for (let j = 0; j < midN; j++) {
const ddx = midPts[j][0] - px;
const ddy = midPts[j][1] - py;
const dd = ddx * ddx + ddy * ddy;
if (dd < bestD) { bestD = dd; best = j; }
}
sx[best] += px;
sy[best] += py;
sc[best]++;
}
for (let j = 0; j < midN; j++) {
if (sc[j] > 0) {
midPts[j][0] = sx[j] / sc[j];
midPts[j][1] = sy[j] / sc[j];
}
}
}
for (let i = 0; i < midN; i++) {
ctx.beginPath();
ctx.arc(midPts[i][0], midPts[i][1], 2.5, 0, Math.PI * 2);
ctx.fill();
}
// --- Horizon line: dense dots ---
for (let x = 0; x < W; x += 2) {
if (seededRandom() < 0.7) {
ctx.beginPath();
ctx.arc(x + seededRandom() * 2, horizon + seededRandom() * 4 - 2, 0.6 + seededRandom() * 0.8, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.fillStyle = '#888';
ctx.font = '14px sans-serif';
ctx.fillText('Click to regenerate. Combines Poisson, hatching, Lloyd, and stipple shading.', 20, H - 20);
}
generate();
c.addEventListener('click', generate);
</script>
Each click produces a unique landscape: different hill shapes, tree placements, and grass distributions, but all unified by the stippling art aesthetic. The sky uses cross-hatching for atmospheric texture, the trees use density-based stipple shading, the foreground uses Poisson-like spacing for natural ground cover, and the midground uses Lloyd relaxation for an orderly row of evenly-spaced accent dots.
Going further with stippling art
- Adaptive dot size — vary the radius of each dot proportional to its local Voronoi cell area. Larger cells get larger dots, preserving the tonal value while reducing total dot count. This is the technique behind Adrian Secord’s weighted Voronoi stippling paper (2002).
- Hedcut style — the Wall Street Journal’s iconic portrait illustrations use hand-drawn stipple dots that follow facial contours. Replicate this by computing gradient direction at each pixel and aligning dot placement along those flow lines.
- Temporal coherence — for animated stippling, naive per-frame resampling causes dots to flicker. Use persistent point sets with incremental Lloyd relaxation to keep dots stable between frames.
- Multi-scale stippling — use different dot sizes at different scales, like a wavelet decomposition. Large dots capture broad tonal areas; tiny dots refine edges and details. The combination gives more tonal range than single-size dots.
- Colour stippling — Seurat’s pointillism art used colour mixing through dot placement. Place red, blue, and yellow dots that blend optically at a distance. The Canvas globalAlpha property makes it easy to layer semi-transparent coloured circles.
- TSP art — connect stipple dots with a single continuous line by solving the Travelling Salesman Problem on the point set. The result is a continuous-line drawing that still reads as a tonal image, combining stipple drawing with path art.
- GPU acceleration — for real-time stippling of video feeds, move the rejection sampling and Voronoi computation to WebGL shaders. Jump flooding can compute approximate Voronoi diagrams on the GPU in milliseconds.
- Physical media simulation — add slight randomisation to dot shapes (not perfect circles but slightly irregular blobs) and vary ink opacity to simulate pen-and-ink stipple drawing on textured paper.
Explore more generative art on Lumitree, where every branch is a unique micro-world built from code. For more techniques that pair beautifully with stippling, try the Voronoi diagram guide, the drawing with code guide, or the math art guide.