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

  1. Wool palette and soft light. Muted dyed-wool colours, a warm sky light and a warm sun, with tone mapping off.
  2. Felt texture. A picture of 5,200 short fibres, drawn on a canvas, on almost every surface.
  3. Toon ramp. Light falls in 4 flat bands.
  4. 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.
  5. Knitted road texture. Rows of V stitches with a stitched centre line.
  6. Running-stitch outlines. A dark thread line round each object, cut into dashes.
  7. 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.

Step 0: the starting point. This is a plain three.js set-up: 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.

Step 1: + wool palette and soft light. The cat colours now come from 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)

Line 406

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();
}

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).

Step 2: + felt texture. Each surface multiplies its colour by the 256 × 256 fibre texture. Short light and dark strokes now show on the cat and on the ground. The ground has one texture tile for each 9 m, as the game terrain has.

4. The toon ramp: light in flat bands

Line 448

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.

Step 3: + toon ramp. 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

Line 484

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.

Step 4: + quilt pattern in the shader. The ground shader now draws quilt squares with furrow rows, darker seams and a cream running stitch. Scale change: the squares are 3 m here and 36 m in the game. All quilt lengths use the same factor, 1/12.

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

Line 533

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

Line 417

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:

There are two versions: grey-blue roads with a mustard centre line, and pink town streets with a cream centre line.

Step 5: + knitted texture. The scarf now uses the 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

Line 550

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
Step 6: + running-stitch outlines. The cat and the scarf now have a dark thread outline (#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

Line 631

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:

StepWhat it doesNumbers
Fibre fuzzMoves 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 bleedMixes in the average of 4 neighbours, like dye that spreads into the fibres.Neighbours 2 px away, 30 % mix
Soft seamsFinds 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 grainThree layers of noise at different sizes change the brightness in the screen, so flat areas are never smooth.Brightness 94 % to 105 %
Under waterA small wave moves the picture and a blue-green tint covers it.Wave 0.16 % of the screen; tint 60 %
VignetteMakes the corners a little darker.About 27 % darker in the corners
Stitched borderDraws 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
FadeMixes 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.

Step 7: + felt pass. The scene now goes to a render target first. Then the full-screen shader breaks the edges into fibres, mixes in the colour of the neighbours and darkens depth seams to brown. It also adds grain, darker corners, and a teal border with a cream stitch. This is the fully felted cat.

10. What the layers cost

11. Where to change the look

To changeEdit
How fuzzy the felt surface isThe stroke count (5,200), length (2 to 9 px) and opacity (0.18 to 0.43) in feltTex
How flat the light isThe 4 values in ramp. Fewer steps = flatter. A higher first value = lighter shadows.
The quiltSquare size 36.0, the 7 colours in quilt(), stitch period 2.6
Outline width0.05 + _d * 0.0021 in makeOutline
Outline dashesThe scale 1.1 and the cut 0.58 in makeOutline
Fuzz at edges2.2 in the felt pass (fibre fuzz)
Seam darknessseam * 0.55 in the felt pass
Screen borderbw = 14.0 and the stitch period 13.0 in the felt pass
ColoursThe PAL table