How Townscaper Works Under the Hood
A deep technical breakdown of Oskar Stålberg's procedural town engine: from relaxed irregular quad grids and Dual Marching Cubes to Wave Function Collapse and zero-draw-call dynamic mesh batching.
Interactive 3D Model Explorer
Explore all 114 real 3D models extracted directly from the game package. These low-poly props are dynamically placed into procedural sockets based on neighbor context.
Asset Inspection & Topology
In Townscaper, walls and roofs are not pre-baked static meshes. Only the decorative attachments (windows, doors, roofs, benches, spires, lanterns) exist as discrete 3D models.
Procedural Attachment Rules (`PropPlacer`)
When a wall face is exposed to air, PropPlacer examines surrounding geometry:
-
Ground Level ExteriorAttaches
Door_0toDoor_Store, addsDoorstep, and spawns wall lanterns. -
Upper Story FacadeCalculates facade width and selects between
Window_1x1,Window_2x1, orWindow_2x2. -
Isolated Tower TopCrowns the cell with church belfry spires (
Spire_PointyorSpire_Sphere).
The Irregular Relaxed Quad Grid
Why Townscaper doesn't look like Minecraft: streets curve naturally because the underlying world is a relaxed quadrilateral dual grid rather than a rigid Cartesian grid.
Dual Grid Generation Algorithm
-
Hexagonal / Triangular SeedStarts with a regular triangular lattice and applies random 2D jitter.
-
Lloyd's Relaxation SmoothingEvery interior vertex iteratively steps toward the centroid of its neighbors, creating an organic, even spacing.
-
Quad-Dual SubdivisionEvery triangle is split into 3 quadrilaterals by connecting edge midpoints to the triangle centroid. Corner vertices meet with valencies of 3, 4, 5, or 6!
The "Qube" & Dual Marching Cubes (256 Topologies)
Each vertical cell layer is a "Qube" defined by 8 corner nodes. Toggle the corners below to see how binary states determine walls, roofs, archways, and rooms.
Binary Bitmask Classification
With 8 binary corners (0 = air, 1 = solid), there are exactly 2⁸ = 256 unique corner states. Townscaper collapses rotational and reflectional symmetries into canonical module meshes stored in ModuleLibrary.
Canonical Topologies
Notice how simple combinations resolve complex architecture:
-
Vertical Face (e.g. c0, c1, c4, c5)Generates a flat exterior wall with window sockets.
-
Bottom Ring Only (c0, c1, c2, c3)Air above + solid below = terrace roof with railings and chimneys.
-
Top Ring Only (c4, c5, c6, c7)Air below + solid above = cobblestone archway or stilt foundation!
Wave Function Collapse (WFC) Step-by-Step Solver
How Townscaper ensures adjacent qubes match seamlessly: each side has a profile hash. Click any cell to place a building or watch the solver auto-propagate constraints!
WFC State Metrics
In Townscaper's code, WaveFunctionCollapse.Iterate0() and Iterate1() manage this constraint queue:
-
Observation / CollapsePicks the cell with minimum Shannon entropy (fewest valid candidate modules remaining) and collapses it to a single state.
-
Profile Matching (`sideProfiles`)Checks the 4 horizontal and 2 vertical interfaces. If a candidate module has an incompatible socket with its neighbor, it is removed from possibilities.
-
Ripple PropagationWhenever a neighbor's possibilities shrink, that neighbor is queued to re-evaluate its own adjacent cells.
Barycentric Trilinear Mesh Deformation
Canonical modules are authored in a normalized cube. Drag the 4 corner handles below to see how windows, doors, and walls dynamically stretch and warp to fit any quad without tearing!
The Trilinear Coordinate Formula
Each vertex $(u, v, w) \in [0, 1]^3$ in the canonical mesh is transformed to world position $P$ using the 8 real corner coordinates:
P(u, v) = (1 - u)(1 - v) · C₀ +
u(1 - v) · C₁ +
u · v · C₂ +
(1 - u) · v · C₃Why Seams Never Crack: On any edge (e.g. $v = 0$), the formula simplifies to $(1-u)C_0 + uC_1$, which depends strictly on the two shared endpoints. Thus, neighboring cells match with 100% mathematical precision!
The Optimization Techniques That Make It Fast
How Townscaper renders thousands of buildings and tens of thousands of details at 60 FPS in WebGL.
1. `BigMeshMaster` Dynamic Batching
Instead of instantiating individual GameObjects for every wall, roof, and archway, BigMeshMaster dynamically merges hundreds of procedural mesh fragments into single unified vertex/index buffers. This turns 5,000 potential draw calls into fewer than 10.
2. 128×128 Palette Shader (`House.png`)
Townscaper never swaps textures or materials. The entire game uses a tiny 128×128 palette lookup texture. Mesh UV.y coordinates select the building color (from the 15 UI swatches), while UV.x selects the surface type (plaster, roof tile, foundation, window trim).
Zero Material Swaps3. Vertex-Color Ambient Occlusion (AO)
Traditional shadow maps and Screen-Space Ambient Occlusion (SSAO) are heavy on WebGL and mobile. Townscaper pre-computes cavity and ground occlusion at corner nodes and bakes the darkness directly into vertex color channels ($RGBA$).
Zero Shadow Passes4. Bitwise State Flags & Zero-GC
Every cell state, corner presence, and adjacency profile is stored as primitive 8-bit or 16-bit bitmasks. Checking if two modules connect is a bitwise AND operation, preventing garbage collection (GC) allocation spikes during real-time placement.
5. Spatial Pooling (`PropPlacer`)
Windows, seaside ladders, cafe chairs, and bird models are pooled inside propPools. When a building section is demolished or reconfigured, props are returned to pools rather than instantiated or destroyed.
6. Lazy Patch Motivation (`GridGenerator`)
The infinite ocean grid is partitioned into patches and clusters. Methods like MotivatePatch() and UnmotivatePatch() dynamically generate and relax only the grid quads within the camera frustum and near the player's cursor.
Production Code Blueprints for Your Game
Ready-to-use algorithms in GLSL, Three.js/JavaScript, and Unity C# to replicate Townscaper's procedural engine in your own project.
// GLSL Palette Lookup Shader (Matches Townscaper's House.png workflow) precision mediump float; uniform sampler2D u_PaletteTex; // 128x128 House.png uniform float u_ColorRow; // Selected color row [0.0 - 1.0] varying vec2 v_Uv; // UV coordinate (x = surface type) varying vec4 v_Color; // Vertex Color (contains baked AO in .a) varying vec3 v_Normal; void main() { // Sample palette: X = material type (wall, roof, trim), Y = building color swatch vec2 paletteCoord = vec2(v_Uv.x, u_ColorRow); vec4 albedo = texture2D(u_PaletteTex, paletteCoord); // Multiply by vertex color AO float ao = v_Color.a; vec3 finalColor = albedo.rgb * ao; gl_FragColor = vec4(finalColor, 1.0); }
// Transforms canonical unit-cube vertex (u, v, w) in [0, 1]³ to deformed quad voxel world space export function deformVertex(u, v, w, corners) { // corners: Array of 8 Vector3 positions [0..3 bottom CCW, 4..7 top CCW] const w0 = (1 - u) * (1 - v) * (1 - w); const w1 = u * (1 - v) * (1 - w); const w2 = u * v * (1 - w); const w3 = (1 - u) * v * (1 - w); const w4 = (1 - u) * (1 - v) * w; const w5 = u * (1 - v) * w; const w6 = u * v * w; const w7 = (1 - u) * v * w; return { x: w0*corners[0].x + w1*corners[1].x + w2*corners[2].x + w3*corners[3].x + w4*corners[4].x + w5*corners[5].x + w6*corners[6].x + w7*corners[7].x, y: w0*corners[0].y + w1*corners[1].y + w2*corners[2].y + w3*corners[3].y + w4*corners[4].y + w5*corners[5].y + w6*corners[6].y + w7*corners[7].y, z: w0*corners[0].z + w1*corners[1].z + w2*corners[2].z + w3*corners[3].z + w4*corners[4].z + w5*corners[5].z + w6*corners[6].z + w7*corners[7].z }; }
using UnityEngine; public class QubeModuleBuilder : MonoBehaviour { // Evaluates 8 corner presence into an 8-bit index (0..255) public int GetModuleIndex(bool[] corners) { int mask = 0; for (int i = 0; i < 8; i++) { if (corners[i]) mask |= (1 << i); } return mask; } // Apply canonical module mesh to distorted quad cell public void DeformMesh(Mesh srcMesh, Vector3[] cellCorners, Mesh targetMesh) { Vector3[] verts = srcMesh.vertices; Vector3[] deformed = new Vector3[verts.Length]; for (int i = 0; i < verts.Length; i++) { Vector3 p = verts[i]; // normalized [-1, 1] float u = (p.x + 1f) * 0.5f; float v = (p.z + 1f) * 0.5f; float w = (p.y + 1f) * 0.5f; deformed[i] = TrilinearInterpolate(u, v, w, cellCorners); } targetMesh.vertices = deformed; targetMesh.triangles = srcMesh.triangles; targetMesh.uv = srcMesh.uv; targetMesh.RecalculateNormals(); } }