Felt World — how the felt look is made
The code that makes the game look like felt, wool, knitting and stitches. The game uses no image files: code draws every texture when the page starts. Play the game · Tech stack and tests · Source on GitHub
In short: the felt look comes from seven small layers. Each layer copies one thing you see on a real felt model: soft colour, fibres, flat light bands, quilt seams, knitted stitches, thread outlines, and a fuzzy edge.
All code is in feltworld/index.html. Line links go to commit 92ab92f. The library is three.js 0.170.0.
The cat pictures on this page are rendered live in your browser by code copied from feltworld/index.html. Each picture adds one layer to the picture before it. Changes for this small scene: the quilt squares are 3 m (36 m in the game); the camera field of view is 40° (62° in the game); there is no shadow map, as on Low quality. The game has no cat. The cat uses the same simple shapes as the game's sheep.
1. The seven layers, in the order of one frame
- Wool palette and soft light. Muted dyed-wool colours, a warm sky light and a warm sun, with tone mapping off.
- Felt texture. A picture of 5,200 short fibres, drawn on a canvas, on almost every surface.
- Toon ramp. Light falls in 4 flat bands.
- Patterns in the shaders. Quilt squares with stitched seams on fields, knit rows on mountains, stitched ripples on the water, light patterns on the sea floor.
- Knitted road texture. Rows of V stitches with a stitched centre line.
- Running-stitch outlines. A dark thread line round each object, cut into dashes.
- The felt pass (post-process). After the 3D scene is drawn, one more shader blurs the edges into fibres, adds felt grain and seams, and sews a fabric border round the screen.
A shader is a small program that runs on the graphics card. It computes the colour of each pixel. The layers 3, 4, 6 and 7 are shader code. The layers 2 and 5 are canvas drawings. Layer 1 is settings.
MeshStandardMaterial in bright pure colours, a white ambient light (0.5) and a white directional light (3.0). Tone mapping is ACES Filmic. The background is black. The cat has 21 parts, made from 5 simple shapes: sphere, cone, cylinder, torus and box.2. Wool palette and soft light
All colours come from one table, PAL (line 353). The table has 21 colours with low saturation, for example oat #d9c9a8, mustard #d4a636, sage #9caf88, terra #c46a4a. The outline colour is thread, #5a3e36, a dark brown. Houses, trees and coral pick from short lists of these colours.
- Light. A hemisphere light (cream
#fff2dcfrom above, brown#8a6e58from below, strength 1.4) and a warm sun (#fff0d8, strength 2.2). The brown light from below keeps shadows warm, like dyed wool in a room. - Tone mapping off.
renderer.toneMapping = THREE.NoToneMapping. Tone mapping would push colours toward photo contrast. Without it, the palette stays as chosen. - Fog. The fog colour and the background colour are the same (
#cfe0dcby day). Far objects fade into a pale haze. Low quality starts the fog end at 700 m, Medium at 900 m, High at 1,150 m. - Antialiasing off. The renderer has
antialias: false. The felt pass in layer 7 breaks every edge into fibres, so sharp edges do not show.
PAL: terra body, cream muzzle and paws. The scarf is the grey-blue road base colour (#7a7f8e). The ground is the terrain field colour (#b0b47c). The hemisphere light (1.4) and the warm sun (2.2) replace the two white lights. Tone mapping is off. The background is #cfe0dc. The fog starts at 200 m, so it has no effect in this 9 m scene.3. The felt texture (drawn on a canvas)
At start, the code draws a 256 × 256 pixel picture on a hidden 2D canvas. It fills the picture with a pale grey #e9e4dc. Then it draws 5,200 short, curved strokes in random places and directions:
for (let i = 0; i < 5200; i++) {
const x = texRand() * w, y = texRand() * h, a = texRand() * Math.PI * 2, l = 2 + texRand() * 7;
const v = texRand() < 0.5 ? 255 : 170 + texRand() * 40; // half light, half darker
g.strokeStyle = `rgba(${v},${v - 4},${v - 10},${0.18 + texRand() * 0.25})`;
g.lineWidth = 0.6 + texRand() * 0.8;
g.beginPath(); g.moveTo(x, y);
g.quadraticCurveTo(/* a bend at the middle */ ..., x + Math.cos(a) * l, y + Math.sin(a) * l);
g.stroke();
}
- Each stroke is 2 to 9 pixels long and 0.6 to 1.4 pixels wide. A curve with one bend makes it look like a loose fibre.
- Half the strokes are white. The other half are grey (value 170 to 210). The colour is a little warm: red is 4 more than green and 10 more than blue.
- Each stroke is 18 % to 43 % opaque, so fibres cross and build up like pressed wool.
- The random numbers come from a seeded generator (
mulberry32(777)), so the texture is the same at each start.
The texture is grey. Each object multiplies it by its own colour. So one texture gives felt in all 21 palette colours. The texture repeats, and it has 4× anisotropic filtering, so fibres stay sharp on surfaces seen at a low angle.
Two more canvas textures use the same method: the knitted roads (section 6) and the sewing-button face (a rim, four holes and a thread cross, line 438).
4. The toon ramp: light in flat bands
const ramp = new THREE.DataTexture(new Uint8Array([110, 165, 215, 255]), 4, 1, THREE.RedFormat);
ramp.minFilter = ramp.magFilter = THREE.NearestFilter;
Normal 3D light changes smoothly from bright to dark across a round surface, which makes the surface look like plastic. A felt model has soft, flat patches of light. The ramp is a picture only 4 pixels wide. three.js MeshToonMaterial looks up the light level in it. NearestFilter stops it from blending between the 4 values, so the light snaps to one of 4 levels: 43 %, 65 %, 84 % and 100 % (110, 165, 215 and 255 out of 255).
The darkest band is 43 %, not black. This keeps shadowed sides soft, like light that goes through wool.
Almost every material in the game is a MeshToonMaterial with map: feltTex, gradientMap: ramp: props, terrain, water, animals, cars, the player models.
MeshToonMaterial with the 4-step ramp replaces MeshStandardMaterial. The light on the body and the head now falls in flat bands, with hard steps between them.5. Patterns in the shaders
three.js gives each material a built-in shader. The game changes that shader with onBeforeCompile. This hook gets the shader text before it is compiled. The game inserts its own lines at named places, for example after #include <color_fragment>. So the patterns keep all normal three.js features: lights, shadows, fog, the toon ramp.
A helper, addWorldPos (line 452), sends the world position of each pixel to the fragment shader. The patterns use world position, not texture coordinates. So a pattern does not stretch on a slope, and it lines up across the borders of terrain chunks.
5.1 Quilt squares on the fields
- The ground is cut into squares of 36 × 36 m. A hash of the square's grid cell picks one of 7 quilt colours.
- Furrow rows: inside each square, stripes 3 m wide alternate between 93 % and 100 % brightness. A second hash turns the stripes along x or along z, so squares next to each other do not all match.
- Seam: the outer 0.5 m of each square is darker, at 72 % brightness.
- Running stitch: between 0.55 m and 1.35 m in from the edge, a cream thread (
vec3(0.95, 0.9, 0.8)) shows as dashes. The dash repeats every 2.6 m and is on for 55 % of it.
float e = min(min(f.x, f.y), min(36.0 - f.x, 36.0 - f.y)); // distance to the nearest edge
float stitch = step(e, 1.35) * step(0.45, fract(along / 2.6)) * step(0.55, e);
q = mix(q, q * 0.72, step(e, 0.5)); // darker seam
q = mix(q, pow(vec3(0.95,0.9,0.8), vec3(2.2)), stitch); // cream thread
Where the quilt shows is set per terrain vertex, in an extra attribute aMat (line 805). The quilt fades in with the field mask. It is off below 3 m height and on roads. The same attribute holds a second weight for mountains.
5.2 Knit rows on mountains
On mountain slopes, a zig-zag pattern on height makes rows that look like knitting. The rows repeat about every 1.8 m of height (vWP.y * 0.55). The pattern changes brightness between 86 % and 104 %.
5.3 Light patterns on the sea floor
Below 1 m under the surface, two moving sine waves make thin bright lines. They copy the light patterns (caustics) that waves make on a real sea floor. The lines get weaker with depth. Below 119 m they stay at 15 % strength.
5.4 Stitched water
The water is one opaque sheet of felt, 9,000 × 9,000 m, that follows the camera. Two slow sine waves make curved bands. Where a band is thin, a pale thread line shows, cut into dashes. From below, the back face shows a lighter "ceiling" colour, so the surface looks like a felt sky from under the water.
5.5 Kelp that sways
The kelp shader moves each vertex sideways. The move grows with the square of the height, so the base stays in place and the tip moves most. Each kelp plant gets its own phase from its position, so a field of kelp does not move as one block.
6. The knitted roads
A 128 × 256 pixel canvas holds 8 columns × 16 rows of V shapes, drawn with round line caps. Rows of V stitches are what a knitted scarf looks like up close. Then the code adds:
- darker bands, 10 pixels wide, at both edges;
- a centre line in dashes (22 pixels on, 14 off, 5 pixels wide), like a thick running stitch;
- a thin cream stitch line near each edge (8 on, 7 off).
There are two versions: grey-blue roads with a mustard centre line, and pink town streets with a cream centre line.
knitRoadTex canvas: rows of V stitches, dark edge bands and a mustard centre line in dashes. The game puts this texture on its roads. The scarf is only on this page.7. Running-stitch outlines
Felt toys often have a visible thread round their edges. The game makes this with an inverted hull: it draws each object a second time, a little larger, in the thread colour, and shows only the back faces. The front of the real object covers the middle of the copy. So only a thin rim of the copy shows round the edge, as an outline.
float _d = length(cameraPosition - _w.xyz);
_w.xyz += _wn * (0.05 + _d * 0.0021); // push each vertex out along its normal
- The push grows with distance to the camera: 0.05 m, plus 0.0021 m for each metre. At 100 m it is 0.26 m. This keeps the line about the same width on the screen, near and far.
- Dashes: for props that do not move, the fragment shader throws away (
discard) part of the outline in a world-space stripe pattern. The stripes are 0.73 m apart along the direction (0.83, 0.61, 0.71). In each stripe, 58 % is kept and 42 % is thrown away. The kept parts look like a running stitch. - Solid for moving things. Cars, walkers and animals get a solid outline (
OL_SOLID). The dash pattern is fixed in the world, so on a moving object the dashes would slide along its edge. - Cost control. The outline copy shares the object's instance matrices, so it costs no extra memory for positions. Outlines turn off past 560 m. Small props and their outlines turn off past 380 m (line 1297).
#5a3e36), cut into dashes. The nose, the eyes and the inner ears have no outline. The game gives moving animals a solid outline. This cat stays still, so it gets the dashed outline of a static prop.Why simple shapes are enough
Every prop is built from 10 simple shapes: box, icosahedron, cone, cylinder, sphere, a 4-sided roof, a rock (dodecahedron), kelp, torus and disc (line 578). A tree is a cylinder and some icosahedrons. A house is a box and a roof. The shapes have few faces (a sphere has 14 × 10 segments, a cone 9 sides). With the felt texture, the ramp and the outline, low-detail shapes read as hand-cut felt pieces. All props in a chunk with the same shape go into one InstancedMesh, with one colour per instance (Batch, line 594). Animals and cars use the same method (Crowd, line 1872).
8. The sky
The sky is a sphere of radius 3,000 m with its own shader. The colour goes from a pale horizon (#f0e2c8) to a soft blue top (#8cb8c8). The sun is a disc with a soft glow. Two crossed sine waves change the brightness by ±3 %, which gives faint fibre streaks, as if the sky were a stretched cloth. At night, a hash on a grid of directions places stars, and each star twinkles on its own phase.
9. The felt pass (post-process)
Line 670 · render order at line 1600
The game does not draw the 3D scene to the screen directly. It draws the scene into an off-screen picture (a render target with a depth texture, in half-float colour). Then it draws one rectangle over the whole screen with the felt-pass shader. That shader reads the off-screen picture and its depth, and makes the final pixels:
function render() {
renderer.setRenderTarget(rt); renderer.render(scene, camera); // 1. scene to a picture
renderer.setRenderTarget(null); renderer.render(postScene, postCam); // 2. felt pass to the screen
}
The shader does these steps, in this order:
| Step | What it does | Numbers |
|---|---|---|
| Fibre fuzz | Moves each pixel's lookup point a little, by smooth noise plus a blocky random value. Edges break up into short fibres, like the edge of a felt piece. | Up to about ±1.8 px (×2.2 px of jitter, noise −0.5 to 0.5, block value −0.3 to 0.3) |
| Colour bleed | Mixes in the average of 4 neighbours, like dye that spreads into the fibres. | Neighbours 2 px away, 30 % mix |
| Soft seams | Finds places where depth jumps (the Laplacian of the linear depth: the 4 neighbours against the centre). It darkens them to a warm brown, like the fold where two felt pieces are sewn. | Starts at 0.04, full at 0.2; fades out from 300 to 900 m; up to 55 % toward (0.62, 0.52, 0.48) |
| Felt grain | Three layers of noise at different sizes change the brightness in the screen, so flat areas are never smooth. | Brightness 94 % to 105 % |
| Under water | A small wave moves the picture and a blue-green tint covers it. | Wave 0.16 % of the screen; tint 60 % |
| Vignette | Makes the corners a little darker. | About 27 % darker in the corners |
| Stitched border | Draws a teal fabric band round the screen, a shadow inside it, and a cream running stitch along it. The game screen looks like a felt picture sewn into a frame. | Band 14 px; stitch 9 px from the screen edge, on for 55 % of every 13 px |
| Fade | Mixes to the border teal for the intro and for changes of scene. | — |
All pixel sizes are divided by the device pixel ratio (dpr). So the fibres, the grain and the border are the same size on a normal screen and on a high-density screen.
10. What the layers cost
- The canvas textures are drawn once, at start. The felt texture has 5,200 strokes on 256 × 256 pixels.
- The inverted-hull outline draws most props twice. The distance limits (560 m for outlines, 380 m for small props) keep this down.
- The felt pass reads the colour picture 5 times and the depth picture 5 times for each screen pixel.
- The quality tiers change the pixel ratio (Low 0.75, Medium 1.0, High 1.5) and the shadows (none, 1,024 px, 2,048 px). The felt layers are the same on all tiers.
- The frame time of each single layer was not measured. The frame times in the tech stack page are for the whole game.
11. Where to change the look
| To change | Edit |
|---|---|
| How fuzzy the felt surface is | The stroke count (5,200), length (2 to 9 px) and opacity (0.18 to 0.43) in feltTex |
| How flat the light is | The 4 values in ramp. Fewer steps = flatter. A higher first value = lighter shadows. |
| The quilt | Square size 36.0, the 7 colours in quilt(), stitch period 2.6 |
| Outline width | 0.05 + _d * 0.0021 in makeOutline |
| Outline dashes | The scale 1.1 and the cut 0.58 in makeOutline |
| Fuzz at edges | 2.2 in the felt pass (fibre fuzz) |
| Seam darkness | seam * 0.55 in the felt pass |
| Screen border | bw = 14.0 and the stitch period 13.0 in the felt pass |
| Colours | The PAL table |