Low Poly Art: How to Create Stunning Geometric Landscapes With Code
Low poly art strips a scene down to its geometric bones. Flat-shaded triangles. Hard edges. Bold colour blocks. No textures, no gradients within a face, no attempt to hide the polygons. The style borrows its name from 3D modelling, where "low polygon count" once meant hardware limitation. In the early PlayStation era, developers squeezed entire worlds into a few hundred triangles because that was all the GPU could handle. Crash Bandicoot, Spyro, the original Tomb Raider—they were low poly by necessity.
Then something shifted. Around 2012, designers and illustrators started using the aesthetic deliberately. Timothy J. Reynolds made low poly landscapes that looked like dioramas carved from coloured glass. flat-shaded animals appeared on Dribbble every other day. The constraint became a choice, and the style took on a life of its own—clean, geometric, oddly calming.
For creative coders, low poly art is an excellent subject. The core algorithm is Delaunay triangulation: scatter points, connect them into triangles where no point falls inside another triangle's circumscribed circle. Colour each triangle with the average colour of the region it covers (or pick a palette procedurally), and you get an image that reads as simplified but intentional. The maths are well understood. The visual results are immediately satisfying. And the parameter space—point density, distribution strategy, colour mapping—is wide enough to keep you experimenting for hours.
In this guide we build eight low poly programs from scratch. Every example is self-contained, runs on a plain HTML Canvas with no libraries, and stays under 50KB. For related techniques, see the Voronoi diagram guide, the geometric art guide, or the procedural generation 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 triangulation
Before we reach for Delaunay, let us see what a naive approach looks like. Scatter random points, then connect each point to its nearest neighbours to form triangles. The result is messy—overlapping edges, slivers, gaps. But it teaches us why Delaunay matters by showing what happens without it.
This first example scatters 80 points and builds triangles by walking through the points sorted by x-coordinate, connecting each to its two nearest neighbours. The triangles get random pastel colours. Click to regenerate.
const pts = [];
for (let i = 0; i < 80; i++) pts.push([Math.random() * W, Math.random() * H]);
// Add corners so edges are covered
pts.push([0,0],[W,0],[W,H],[0,H]);
function dist(a, b) { return Math.hypot(a[0]-b[0], a[1]-b[1]); }
function nearestN(p, n) {
return pts.filter(q => q !== p)
.sort((a,b) => dist(p,a) - dist(p,b))
.slice(0, n);
}
function draw() {
ctx.clearRect(0, 0, W, H);
const used = new Set();
for (const p of pts) {
const near = nearestN(p, 3);
for (let i = 0; i < near.length - 1; i++) {
const key = [p, near[i], near[i+1]].map(q =>
q[0].toFixed(0)+','+q[1].toFixed(0)).sort().join('|');
if (used.has(key)) continue;
used.add(key);
ctx.beginPath();
ctx.moveTo(p[0], p[1]);
ctx.lineTo(near[i][0], near[i][1]);
ctx.lineTo(near[i+1][0], near[i+1][1]);
ctx.closePath();
const h = Math.random() * 360;
ctx.fillStyle = `hsl(${h}, 60%, 75%)`;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
}
draw();
c.onclick = () => {
pts.length = 0;
for (let i = 0; i < 80; i++) pts.push([Math.random() * W, Math.random() * H]);
pts.push([0,0],[W,0],[W,H],[0,H]);
draw();
};
Notice the gaps and overlapping triangles. The nearest-neighbour heuristic does not guarantee full coverage. We need a proper triangulation.
2. Delaunay triangulation
The Delaunay triangulation is the dual of the Voronoi diagram. Given a set of points, it connects them into triangles such that no point lies inside the circumscribed circle of any triangle. This maximises the minimum angle of all triangles, which means fewer slivers and a more even mesh. It is the foundation of almost all low poly art generators.
We implement the Bowyer–Watson algorithm: start with a super-triangle that contains all points, then insert points one at a time. For each new point, find all triangles whose circumcircle contains it, remove them, and re-triangulate the resulting polygonal hole. The result is a clean, gap-free triangulation. Points are placed with a blue-noise-like distribution (Poisson disc sampling) for even spacing.
// Bowyer-Watson Delaunay triangulation
function delaunay(points) {
const st = [[-W*2, -H*2], [W*4, -H*2], [W/2, H*4]]; // super-triangle
let triangles = [[0, 1, 2]];
const allPts = [...st, ...points];
for (let i = 3; i < allPts.length; i++) {
const p = allPts[i];
const bad = [];
for (let t = 0; t < triangles.length; t++) {
const [a, b, cc] = triangles[t].map(j => allPts[j]);
if (inCircumcircle(p, a, b, cc)) bad.push(t);
}
const edges = [];
for (const t of bad) {
const tri = triangles[t];
for (let j = 0; j < 3; j++) {
const e = [tri[j], tri[(j+1)%3]].sort((a,b)=>a-b);
const dup = edges.findIndex(f => f[0]===e[0] && f[1]===e[1]);
if (dup !== -1) edges.splice(dup, 1);
else edges.push(e);
}
}
for (let t = bad.length - 1; t >= 0; t--)
triangles.splice(bad[t], 1);
for (const [a, b] of edges)
triangles.push([a, b, i]);
}
return triangles.filter(t =>
t.every(i => i >= 3)).map(t => t.map(i => allPts[i]));
}
function inCircumcircle(p, a, b, cc) {
const ax = a[0]-p[0], ay = a[1]-p[1];
const bx = b[0]-p[0], by = b[1]-p[1];
const cx = cc[0]-p[0], cy = cc[1]-p[1];
const det = ax*(by*cx*cx + by*cy*cy - cy*bx*bx - cy*by*by)
- bx*(ay*cx*cx + ay*cy*cy - cy*ax*ax - cy*ay*ay)
+ cx*(ay*bx*bx + ay*by*by - by*ax*ax - by*ay*ay);
// Proper circumcircle test
const d = 2*(ax*(by-cy)+bx*(cy-ay)+cx*(ay-by));
if (Math.abs(d) < 1e-10) return false;
const ux = ((ax*ax+ay*ay)*(by-cy)+(bx*bx+by*by)*(cy-ay)+(cx*cx+cy*cy)*(ay-by))/d;
const uy = ((ax*ax+ay*ay)*(cx-bx)+(bx*bx+by*by)*(ax-cx)+(cx*cx+cy*cy)*(bx-ax))/d;
const r2 = (ax-ux)**2 + (ay-uy)**2;
return (p[0]-a[0]+ux)**2 + (p[1]-a[1]+uy)**2 < r2 + 1e-10;
// Simplified: use determinant sign
}
// Generate well-spaced points
const points = [];
const spacing = 50;
for (let y = 0; y < H; y += spacing) {
for (let x = 0; x < W; x += spacing) {
points.push([
x + (Math.random() - 0.5) * spacing * 0.8,
y + (Math.random() - 0.5) * spacing * 0.8
]);
}
}
// Add boundary points
for (let i = 0; i <= W; i += spacing/2) {
points.push([i, 0], [i, H]);
}
for (let i = 0; i <= H; i += spacing/2) {
points.push([0, i], [W, i]);
}
const tris = delaunay(points);
// Colour by centroid position
for (const [a, b, cc] of tris) {
const cx = (a[0]+b[0]+cc[0])/3;
const cy = (a[1]+b[1]+cc[1])/3;
const h = (cx/W * 40 + cy/H * 160 + 180) % 360;
const l = 45 + (cy/H) * 25;
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(b[0], b[1]);
ctx.lineTo(cc[0], cc[1]);
ctx.closePath();
ctx.fillStyle = `hsl(${h}, 55%, ${l}%)`;
ctx.fill();
ctx.strokeStyle = `hsl(${h}, 55%, ${l-5}%)`;
ctx.lineWidth = 0.3;
ctx.stroke();
}
The result is a clean mesh of triangles with no gaps and no overlaps. The colours shift from cool blues at the top to warm greens at the bottom, creating a gradient landscape effect. This is the skeleton of every low poly generator.
3. Image-to-low-poly converter
The most popular low poly application: feed in a photograph and get back a triangulated, flat-shaded version. The trick is placing more points where detail matters—at edges. We use a Sobel edge detector to find high-contrast regions, then scatter points proportional to edge intensity. Each triangle gets the average colour of the pixels it covers.
This example loads a generated gradient image (since we cannot load external images in a standalone demo) and converts it to low poly. The edge detection concentrates triangles where colour transitions happen.
// Generate a sample image with shapes
const img = ctx.createImageData(W, H);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
const cx1 = W*0.3, cy1 = H*0.4, r1 = 150;
const cx2 = W*0.7, cy2 = H*0.6, r2 = 120;
const d1 = Math.hypot(x-cx1, y-cy1);
const d2 = Math.hypot(x-cx2, y-cy2);
if (d1 < r1) {
img.data[i] = 220; img.data[i+1] = 80; img.data[i+2] = 60;
} else if (d2 < r2) {
img.data[i] = 60; img.data[i+1] = 130; img.data[i+2] = 220;
} else {
const g = 30 + (y/H) * 60;
img.data[i] = g; img.data[i+1] = g + 30; img.data[i+2] = g + 10;
}
img.data[i+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
const pixels = ctx.getImageData(0, 0, W, H).data;
// Sobel edge detection
function edgeAt(x, y) {
if (x < 1 || x >= W-1 || y < 1 || y >= H-1) return 0;
const g = (px, py) => {
const i = (py*W+px)*4;
return pixels[i]*0.3 + pixels[i+1]*0.59 + pixels[i+2]*0.11;
};
const gx = -g(x-1,y-1) + g(x+1,y-1) - 2*g(x-1,y) + 2*g(x+1,y) - g(x-1,y+1) + g(x+1,y+1);
const gy = -g(x-1,y-1) - 2*g(x,y-1) - g(x+1,y-1) + g(x-1,y+1) + 2*g(x,y+1) + g(x+1,y+1);
return Math.sqrt(gx*gx + gy*gy);
}
// Scatter points weighted by edge intensity
const points = [];
const maxPts = 600;
// Uniform base
for (let i = 0; i < maxPts * 0.3; i++) {
points.push([Math.random()*W, Math.random()*H]);
}
// Edge-weighted
let attempts = 0;
while (points.length < maxPts && attempts < maxPts * 20) {
const x = Math.random() * W | 0;
const y = Math.random() * H | 0;
const e = edgeAt(x, y) / 255;
if (Math.random() < e * 0.8 + 0.05) {
points.push([x, y]);
}
attempts++;
}
// Boundary
for (let i = 0; i < W; i += 40) { points.push([i,0],[i,H]); }
for (let i = 0; i < H; i += 40) { points.push([0,i],[W,i]); }
points.push([0,0],[W,0],[W,H],[0,H]);
// Reuse Delaunay from example 2 (simplified inline)
function circumTest(p, a, b, c) {
const d = 2*(a[0]*(b[1]-c[1])+b[0]*(c[1]-a[1])+c[0]*(a[1]-b[1]));
if (!d) return false;
const ux = ((a[0]**2+a[1]**2)*(b[1]-c[1])+(b[0]**2+b[1]**2)*(c[1]-a[1])+(c[0]**2+c[1]**2)*(a[1]-b[1]))/d;
const uy = ((a[0]**2+a[1]**2)*(c[0]-b[0])+(b[0]**2+b[1]**2)*(a[0]-c[0])+(c[0]**2+c[1]**2)*(b[0]-a[0]))/d;
return Math.hypot(p[0]-ux,p[1]-uy) < Math.hypot(a[0]-ux,a[1]-uy)+1e-6;
}
function triangulate(pts) {
const st = [[-1e4,-1e4],[1e4+W,-1e4],[W/2,1e4+H]];
const all = [...st,...pts];
let tris = [[0,1,2]];
for (let i = 3; i < all.length; i++) {
const bad = [], edges = [];
for (let t = 0; t < tris.length; t++)
if (circumTest(all[i], all[tris[t][0]], all[tris[t][1]], all[tris[t][2]]))
bad.push(t);
for (const t of bad) {
const tri = tris[t];
for (let j = 0; j < 3; j++) {
const e = [tri[j],tri[(j+1)%3]].sort((a,b)=>a-b);
const d = edges.findIndex(f=>f[0]===e[0]&&f[1]===e[1]);
d!==-1 ? edges.splice(d,1) : edges.push(e);
}
}
for (let t = bad.length-1; t>=0; t--) tris.splice(bad[t],1);
for (const [a,b] of edges) tris.push([a,b,i]);
}
return tris.filter(t=>t.every(i=>i>=3)).map(t=>t.map(i=>all[i]));
}
const tris = triangulate(points);
ctx.clearRect(0, 0, W, H);
for (const [a, b, cc] of tris) {
// Sample colour from centroid
const cx = (a[0]+b[0]+cc[0])/3 | 0;
const cy = (a[1]+b[1]+cc[1])/3 | 0;
const ci = (Math.max(0,Math.min(H-1,cy))*W + Math.max(0,Math.min(W-1,cx)))*4;
ctx.beginPath();
ctx.moveTo(a[0],a[1]); ctx.lineTo(b[0],b[1]); ctx.lineTo(cc[0],cc[1]);
ctx.closePath();
ctx.fillStyle = `rgb(${pixels[ci]},${pixels[ci+1]},${pixels[ci+2]})`;
ctx.fill();
}
More triangles cluster around the edges of the circles, preserving shape boundaries while the flat areas stay coarse. This is the fundamental insight: low poly art is about choosing where to put detail, not about having less detail everywhere.
4. Gradient mesh terrain
Low poly landscapes are the genre's signature. Rolling hills, layered mountains, setting suns. The aesthetic works because terrain is inherently triangulated—height maps decompose naturally into triangle meshes. This example builds a 3D-looking terrain using a grid with height-based shading, giving each triangle a flat colour that depends on its elevation and facing direction.
// Low poly terrain with height-based colouring
const cols = 24, rows = 16;
const cellW = W / cols, cellH = H / rows;
// Height function: layered sine waves
function height(x, y) {
const nx = x / W, ny = y / H;
return Math.sin(nx*3.5)*Math.cos(ny*2.8)*0.4
+ Math.sin(nx*7+1)*Math.cos(ny*5+2)*0.15
+ Math.sin(nx*13+5)*0.08;
}
// Generate grid with jittered points
const grid = [];
for (let r = 0; r <= rows; r++) {
grid[r] = [];
for (let cl = 0; cl <= cols; cl++) {
let x = cl * cellW, y = r * cellH;
if (r > 0 && r < rows && cl > 0 && cl < cols) {
x += (Math.random()-0.5) * cellW * 0.4;
y += (Math.random()-0.5) * cellH * 0.4;
}
const h = height(x, y);
grid[r][cl] = { x, y, h };
}
}
// Palette by elevation
function terrainColour(h, faceUp) {
const base = (h + 0.6) / 1.2; // normalise to 0-1
const light = faceUp ? 1.0 : 0.82;
if (base < 0.3) return `hsl(210, 45%, ${25*light+base*30}%)`; // deep water
if (base < 0.4) return `hsl(195, 50%, ${40*light}%)`; // shallow water
if (base < 0.5) return `hsl(50, 55%, ${60*light}%)`; // sand
if (base < 0.7) return `hsl(120, 40%, ${35*light+base*15}%)`; // grass
if (base < 0.85) return `hsl(100, 30%, ${30*light}%)`; // forest
return `hsl(0, 0%, ${70*light+base*20}%)`; // snow
}
// Draw triangulated grid
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, W, H);
for (let r = 0; r < rows; r++) {
for (let cl = 0; cl < cols; cl++) {
const tl = grid[r][cl], tr = grid[r][cl+1];
const bl = grid[r+1][cl], br = grid[r+1][cl+1];
// Two triangles per cell
const drawTri = (a, b, c) => {
const avgH = (a.h + b.h + c.h) / 3;
// Simple face normal: cross product z-component
const faceUp = ((b.x-a.x)*(c.y-a.y) - (b.y-a.y)*(c.x-a.x)) > 0;
ctx.beginPath();
ctx.moveTo(a.x, a.y - a.h*80);
ctx.lineTo(b.x, b.y - b.h*80);
ctx.lineTo(c.x, c.y - c.h*80);
ctx.closePath();
ctx.fillStyle = terrainColour(avgH, faceUp);
ctx.fill();
ctx.strokeStyle = terrainColour(avgH, faceUp);
ctx.lineWidth = 0.5;
ctx.stroke();
};
// Alternate diagonal direction for variety
if ((r + cl) % 2 === 0) {
drawTri(tl, tr, bl);
drawTri(tr, br, bl);
} else {
drawTri(tl, tr, br);
drawTri(tl, br, bl);
}
}
}
The height function offsets each vertex upward, creating the illusion of depth on a 2D canvas. Face direction (the sign of the cross product) determines whether a triangle gets the lighter or darker shade, faking directional lighting without any actual 3D maths.
5. Animated low poly waves
Static low poly art is striking. Animated low poly art is hypnotic. This example takes the terrain concept and makes the height function depend on time, creating an ocean of slowly rolling geometric waves. The colour shifts from deep blue in the troughs to white-capped peaks.
const cols = 30, rows = 20;
const cellW = W / cols, cellH = H / rows;
const baseGrid = [];
for (let r = 0; r <= rows; r++) {
baseGrid[r] = [];
for (let cl = 0; cl <= cols; cl++) {
const jx = (r > 0 && r < rows && cl > 0 && cl < cols) ? (Math.random()-0.5)*cellW*0.3 : 0;
const jy = (r > 0 && r < rows && cl > 0 && cl < cols) ? (Math.random()-0.5)*cellH*0.3 : 0;
baseGrid[r][cl] = { bx: cl*cellW + jx, by: r*cellH + jy };
}
}
function animate(t) {
ctx.fillStyle = '#0b1628';
ctx.fillRect(0, 0, W, H);
const s = t * 0.001;
for (let r = 0; r < rows; r++) {
for (let cl = 0; cl < cols; cl++) {
const pts = [
baseGrid[r][cl], baseGrid[r][cl+1],
baseGrid[r+1][cl], baseGrid[r+1][cl+1]
].map(p => {
const h = Math.sin(p.bx*0.015 + s*1.5)*Math.cos(p.by*0.012 + s*0.8)*30
+ Math.sin(p.bx*0.008 - s*0.6)*15
+ Math.cos(p.by*0.02 + s*1.2)*10;
return { x: p.bx, y: p.by + h, h };
});
const [tl, tr, bl, br] = pts;
const drawTri = (a, b, cc) => {
const avgH = (a.h + b.h + cc.h) / 3;
const norm = (avgH + 55) / 110; // 0..1
const hue = 210 - norm * 20;
const sat = 60 + norm * 20;
const lit = 20 + norm * 50;
ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(cc.x, cc.y);
ctx.closePath();
ctx.fillStyle = `hsl(${hue}, ${sat}%, ${lit}%)`;
ctx.fill();
};
drawTri(tl, tr, bl);
drawTri(tr, br, bl);
}
}
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
The wave motion comes from two overlapping sine functions with different frequencies and speeds. The jittered base grid prevents the mesh from looking like a regular checkerboard, which is key for organic-looking low poly surfaces.
6. Low poly portrait silhouette
Low poly portraits are one of the most popular applications of the style. Typically you would trace a photograph, but here we generate a face-like shape procedurally and triangulate it with denser points near the features. The portrait uses a warm skin-tone palette with the background in contrasting cool tones.
// Procedural face silhouette with dense triangulation
const points = [];
const faceX = W/2, faceY = H*0.45, faceW = 160, faceH = 200;
// Face outline (ellipse points)
for (let a = 0; a < Math.PI*2; a += 0.15) {
const rx = faceW + Math.sin(a*3)*15;
const ry = faceH + Math.cos(a*2)*10;
points.push([faceX + Math.cos(a)*rx, faceY + Math.sin(a)*ry]);
}
// Dense points inside face
for (let i = 0; i < 200; i++) {
const a = Math.random() * Math.PI * 2;
const r = Math.random();
points.push([
faceX + Math.cos(a) * faceW * r * 0.9,
faceY + Math.sin(a) * faceH * r * 0.9
]);
}
// Eyes
for (let side = -1; side <= 1; side += 2) {
const ex = faceX + side * 65, ey = faceY - 30;
for (let i = 0; i < 20; i++) {
const a = Math.random() * Math.PI * 2;
points.push([ex + Math.cos(a)*25*Math.random(), ey + Math.sin(a)*15*Math.random()]);
}
}
// Nose and mouth
for (let i = 0; i < 15; i++) {
points.push([faceX + (Math.random()-0.5)*20, faceY + 20 + Math.random()*50]);
}
for (let i = 0; i < 15; i++) {
points.push([faceX + (Math.random()-0.5)*70, faceY + 80 + Math.random()*20]);
}
// Background (sparse)
for (let i = 0; i < 100; i++) {
points.push([Math.random()*W, Math.random()*H]);
}
// Boundary
for (let i = 0; i < W; i += 50) { points.push([i,0],[i,H]); }
for (let i = 0; i < H; i += 50) { points.push([0,i],[W,i]); }
// Triangulate (reuse Bowyer-Watson from example 3)
function circumTest(p,a,b,c){const d=2*(a[0]*(b[1]-c[1])+b[0]*(c[1]-a[1])+c[0]*(a[1]-b[1]));if(!d)return false;const ux=((a[0]**2+a[1]**2)*(b[1]-c[1])+(b[0]**2+b[1]**2)*(c[1]-a[1])+(c[0]**2+c[1]**2)*(a[1]-b[1]))/d;const uy=((a[0]**2+a[1]**2)*(c[0]-b[0])+(b[0]**2+b[1]**2)*(a[0]-c[0])+(c[0]**2+c[1]**2)*(b[0]-a[0]))/d;return Math.hypot(p[0]-ux,p[1]-uy)<Math.hypot(a[0]-ux,a[1]-uy)+1e-6}
function triangulate(pts){const st=[[-1e4,-1e4],[1e4+W,-1e4],[W/2,1e4+H]];const all=[...st,...pts];let tris=[[0,1,2]];for(let i=3;i<all.length;i++){const bad=[],edges=[];for(let t=0;t<tris.length;t++)if(circumTest(all[i],all[tris[t][0]],all[tris[t][1]],all[tris[t][2]]))bad.push(t);for(const t of bad){const tri=tris[t];for(let j=0;j<3;j++){const e=[tri[j],tri[(j+1)%3]].sort((a,b)=>a-b);const d=edges.findIndex(f=>f[0]===e[0]&&f[1]===e[1]);d!==-1?edges.splice(d,1):edges.push(e)}}for(let t=bad.length-1;t>=0;t--)tris.splice(bad[t],1);for(const[a,b]of edges)tris.push([a,b,i])}return tris.filter(t=>t.every(i=>i>=3)).map(t=>t.map(i=>all[i]))}
const tris = triangulate(points);
// Colour based on distance from face centre
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, W, H);
for (const [a, b, cc] of tris) {
const cx = (a[0]+b[0]+cc[0])/3;
const cy = (a[1]+b[1]+cc[1])/3;
const d = Math.hypot(cx-faceX, (cy-faceY)*0.8) / faceW;
let fill;
if (d < 0.85) {
// Face area - warm tones
const eyeL = Math.hypot(cx-(faceX-65), cy-(faceY-30));
const eyeR = Math.hypot(cx-(faceX+65), cy-(faceY-30));
const mouth = Math.hypot(cx-faceX, cy-(faceY+85));
if (Math.min(eyeL, eyeR) < 30) {
fill = `hsl(220, 30%, ${18+Math.random()*8}%)`; // dark eyes
} else if (mouth < 40 && cy > faceY + 65) {
fill = `hsl(0, 45%, ${38+Math.random()*10}%)`; // lips
} else {
const h = 22 + (Math.random()-0.5)*10;
const s = 40 + d*15;
const l = 62 - d*15 + (Math.random()-0.5)*5;
fill = `hsl(${h}, ${s}%, ${l}%)`;
}
} else {
// Background - cool tones
const h = 220 + (Math.random()-0.5)*30;
const l = 12 + Math.random()*10;
fill = `hsl(${h}, 25%, ${l}%)`;
}
ctx.beginPath();
ctx.moveTo(a[0],a[1]); ctx.lineTo(b[0],b[1]); ctx.lineTo(cc[0],cc[1]);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
}
The triangle density is much higher around the eyes and mouth, giving those features definition while the background stays sparse. This variable-density approach is what separates good low poly portraits from flat, uniform triangulations.
7. Low poly planet
A sphere rendered as low poly triangles, rotating slowly. We project a 3D icosphere onto the 2D canvas and colour each triangle based on latitude (poles get ice caps, equator gets warm tones). Back-face culling hides triangles facing away from the camera.
// Low poly planet - icosphere projection
const TAU = Math.PI * 2;
// Generate icosphere vertices
function icosphere(subdivisions) {
const t = (1 + Math.sqrt(5)) / 2;
let verts = [
[-1,t,0],[1,t,0],[-1,-t,0],[1,-t,0],
[0,-1,t],[0,1,t],[0,-1,-t],[0,1,-t],
[t,0,-1],[t,0,1],[-t,0,-1],[-t,0,1]
].map(v => { const l = Math.hypot(...v); return v.map(c => c/l); });
let faces = [
[0,11,5],[0,5,1],[0,1,7],[0,7,10],[0,10,11],
[1,5,9],[5,11,4],[11,10,2],[10,7,6],[7,1,8],
[3,9,4],[3,4,2],[3,2,6],[3,6,8],[3,8,9],
[4,9,5],[2,4,11],[6,2,10],[8,6,7],[9,8,1]
];
for (let s = 0; s < subdivisions; s++) {
const midCache = {};
const newFaces = [];
const midpoint = (a, b) => {
const key = Math.min(a,b) + '-' + Math.max(a,b);
if (midCache[key] !== undefined) return midCache[key];
const m = verts[a].map((v, i) => (v + verts[b][i]) / 2);
const l = Math.hypot(...m);
verts.push(m.map(c => c/l));
midCache[key] = verts.length - 1;
return midCache[key];
};
for (const [a,b,c] of faces) {
const ab = midpoint(a,b), bc = midpoint(b,c), ca = midpoint(c,a);
newFaces.push([a,ab,ca],[b,bc,ab],[c,ca,bc],[ab,bc,ca]);
}
faces = newFaces;
}
return { verts, faces };
}
const { verts, faces } = icosphere(2); // 320 triangles
const R = 280;
let angle = 0;
function render(t) {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, W, H);
angle = t * 0.0003;
// Rotate and project
const projected = verts.map(v => {
const x = v[0]*Math.cos(angle) - v[2]*Math.sin(angle);
const z = v[0]*Math.sin(angle) + v[2]*Math.cos(angle);
const y = v[1];
return { x: W/2 + x*R, y: H/2 - y*R, z, lat: Math.asin(y) };
});
// Sort faces by average z (painter's algorithm)
const sorted = faces.map(f => ({
f,
avgZ: (projected[f[0]].z + projected[f[1]].z + projected[f[2]].z) / 3
})).sort((a,b) => a.avgZ - b.avgZ);
for (const { f } of sorted) {
const [a,b,cc] = f.map(i => projected[i]);
// Back-face culling
const cross = (b.x-a.x)*(cc.y-a.y) - (b.y-a.y)*(cc.x-a.x);
if (cross < 0) continue;
const avgLat = (a.lat + b.lat + cc.lat) / 3;
const absLat = Math.abs(avgLat);
const avgZ = (a.z + b.z + cc.z) / 3;
const light = 0.5 + avgZ * 0.5; // hemisphere lighting
let h, s, l;
if (absLat > 1.1) {
h = 0; s = 0; l = 80 * light; // ice caps
} else if (absLat > 0.7) {
h = 140; s = 25; l = 45 * light; // tundra
} else if (absLat > 0.3) {
h = 130; s = 50; l = 38 * light; // forest
} else {
h = 45; s = 60; l = 55 * light; // desert/savanna
}
// Add water based on noise-like pattern
const nx = (a.x + b.x + cc.x) / 3;
if (Math.sin(nx*0.05 + avgLat*3) > 0.3) {
h = 210; s = 55; l = 35 * light; // ocean
}
ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(cc.x, cc.y);
ctx.closePath();
ctx.fillStyle = `hsl(${h}, ${s}%, ${l}%)`;
ctx.fill();
ctx.strokeStyle = `hsl(${h}, ${s}%, ${l*0.9}%)`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
requestAnimationFrame(render);
}
requestAnimationFrame(render);
The icosphere is built by recursively subdividing an icosahedron (20 faces) twice, giving 320 triangles—enough to read as a sphere but clearly geometric. The latitude-based biome colouring creates a planet that feels recognisable despite being built from flat polygons.
8. Generative low poly landscape composition
The final example combines everything: layered mountains with parallax depth, a gradient sky, a low poly sun, and animated clouds drifting across. Each layer has different triangle density and colour palette, creating depth. Click to generate a new landscape.
let seed = Date.now();
function rand() { seed = (seed * 16807 + 0) % 2147483647; return seed / 2147483647; }
function generate() {
seed = Date.now();
ctx.fillStyle = '#1a0a2e';
ctx.fillRect(0, 0, W, H);
// Sky gradient
for (let y = 0; y < H * 0.6; y++) {
const t = y / (H * 0.6);
const r = 15 + t * 60;
const g = 10 + t * 40;
const b = 50 + t * 80;
ctx.fillStyle = `rgb(${r|0},${g|0},${b|0})`;
ctx.fillRect(0, y, W, 1);
}
// Sun
const sunX = W * (0.3 + rand()*0.4);
const sunY = H * (0.15 + rand()*0.15);
const sunR = 40 + rand() * 30;
const sunPts = [];
for (let a = 0; a < TAU; a += TAU/8) {
sunPts.push([sunX + Math.cos(a)*sunR, sunY + Math.sin(a)*sunR]);
}
sunPts.push([sunX, sunY]); // centre
// Draw sun as triangle fan
for (let i = 0; i < sunPts.length - 1; i++) {
const a = sunPts[i], b = sunPts[(i+1) % (sunPts.length-1)];
const cc = sunPts[sunPts.length-1];
ctx.beginPath();
ctx.moveTo(a[0],a[1]); ctx.lineTo(b[0],b[1]); ctx.lineTo(cc[0],cc[1]);
ctx.closePath();
const l = 75 + rand() * 15;
ctx.fillStyle = `hsl(35, 90%, ${l}%)`;
ctx.fill();
}
// Mountain layers (back to front)
const layers = [
{ y: 0.35, h: 0.25, hue: 260, sat: 20, lit: 18, density: 50 },
{ y: 0.45, h: 0.2, hue: 250, sat: 25, lit: 22, density: 45 },
{ y: 0.55, h: 0.18, hue: 180, sat: 30, lit: 28, density: 40 },
{ y: 0.65, h: 0.15, hue: 140, sat: 35, lit: 32, density: 35 },
];
const TAU = Math.PI * 2;
for (const layer of layers) {
const pts = [];
const baseY = H * layer.y;
const mh = H * layer.h;
// Mountain ridge
const ridgePts = 12;
for (let i = 0; i <= ridgePts; i++) {
const x = (i / ridgePts) * W;
const y = baseY - (Math.sin(i*0.8+rand()*2)*0.5+0.5) * mh
- rand() * mh * 0.3;
pts.push([x, y]);
}
// Fill points below ridge
for (let i = 0; i < 60; i++) {
const x = rand() * W;
const ridgeY = baseY - (Math.sin(x/W*ridgePts*0.8+rand())*0.5+0.5) * mh * 0.5;
const y = ridgeY + rand() * (H - ridgeY) * 0.3;
pts.push([x, Math.min(y, H)]);
}
// Bottom edge
for (let x = 0; x < W; x += layer.density) {
pts.push([x, H]);
}
pts.push([0, H], [W, H], [0, baseY + mh*0.5], [W, baseY + mh*0.5]);
// Triangulate
function circumTest(p,a,b,c){const d=2*(a[0]*(b[1]-c[1])+b[0]*(c[1]-a[1])+c[0]*(a[1]-b[1]));if(!d)return false;const ux=((a[0]**2+a[1]**2)*(b[1]-c[1])+(b[0]**2+b[1]**2)*(c[1]-a[1])+(c[0]**2+c[1]**2)*(a[1]-b[1]))/d;const uy=((a[0]**2+a[1]**2)*(c[0]-b[0])+(b[0]**2+b[1]**2)*(a[0]-c[0])+(c[0]**2+c[1]**2)*(b[0]-a[0]))/d;return Math.hypot(p[0]-ux,p[1]-uy)<Math.hypot(a[0]-ux,a[1]-uy)+1e-6}
function tri(pts){const st=[[-1e4,-1e4],[1e4+W,-1e4],[W/2,1e4+H]];const all=[...st,...pts];let tris=[[0,1,2]];for(let i=3;i<all.length;i++){const bad=[],edges=[];for(let t=0;t<tris.length;t++)if(circumTest(all[i],all[tris[t][0]],all[tris[t][1]],all[tris[t][2]]))bad.push(t);for(const t of bad){const tr=tris[t];for(let j=0;j<3;j++){const e=[tr[j],tr[(j+1)%3]].sort((a,b)=>a-b);const d=edges.findIndex(f=>f[0]===e[0]&&f[1]===e[1]);d!==-1?edges.splice(d,1):edges.push(e)}}for(let t=bad.length-1;t>=0;t--)tris.splice(bad[t],1);for(const[a,b]of edges)tris.push([a,b,i])}return tris.filter(t=>t.every(i=>i>=3)).map(t=>t.map(i=>all[i]))}
const tris = tri(pts);
for (const [a, b, cc] of tris) {
const cy = (a[1]+b[1]+cc[1])/3;
if (cy < baseY - mh * 1.2) continue; // skip triangles above mountains
const elev = 1 - (cy - baseY + mh) / (H - baseY + mh);
const l = layer.lit + elev * 12 + (rand()-0.5) * 6;
ctx.beginPath();
ctx.moveTo(a[0],a[1]); ctx.lineTo(b[0],b[1]); ctx.lineTo(cc[0],cc[1]);
ctx.closePath();
ctx.fillStyle = `hsl(${layer.hue + (rand()-0.5)*15}, ${layer.sat}%, ${l}%)`;
ctx.fill();
}
}
// Water/foreground
const waterY = H * 0.78;
for (let y = waterY; y < H; y += 20) {
for (let x = 0; x < W; x += 30) {
const x2 = x + 30 + (rand()-0.5)*10;
const y2 = y + 20 + (rand()-0.5)*8;
ctx.beginPath();
ctx.moveTo(x, y); ctx.lineTo(x2, y); ctx.lineTo(x + 15, y2);
ctx.closePath();
const l = 15 + (y-waterY)/(H-waterY)*10 + rand()*5;
ctx.fillStyle = `hsl(210, 40%, ${l}%)`;
ctx.fill();
}
}
}
generate();
c.onclick = generate;
Every click generates a unique landscape with different mountain profiles, sun position, and colour variations. The layered approach—sky, sun, four mountain ranges, water—creates surprising depth from simple flat-shaded triangles.
Tips for better low poly art
- Point placement matters more than triangle count. A hundred well-placed points beat a thousand random ones. Concentrate density where detail matters: edges, features, transitions.
- Jitter your grid. A perfectly regular grid produces boring, uniform triangles. Adding random offset to grid points immediately makes the mesh feel organic.
- Flat shading is the point. Do not smooth, do not interpolate, do not anti-alias triangle edges. The hard facets are the aesthetic.
- Limit your palette. Low poly art thrives on restricted colour ranges. A mountain range in five shades of blue looks more cohesive than a rainbow.
- Use elevation for lighting. In terrain, you can fake directional light by making upward-facing triangles lighter and downward-facing ones darker. The cross product of two edge vectors gives you the face normal.
- Layer from back to front. The painter's algorithm (draw distant objects first) handles occlusion without depth buffers.
- Think about silhouette. The outline of a low poly object is often more recognisable than its interior detail. Spend your triangle budget on the edges that define the shape.
Explore more generative art on Lumitree, where every branch is a unique micro-world built from code. For more geometric techniques, try the Voronoi diagram guide, the geometric art guide, or the procedural generation guide.