Appearance
Noise Pass: Height & Texture Weights
Wave Function Collapse (WFC) is a constraint solver. It produces valid structure -- roads connect, edges transition cleanly, ground fills the gaps -- but the result is flat and uniform. Every road cell is identical to every other road cell. Every ground cell is identical to every other ground cell. The terrain looks like a diagram, not a landscape.
The noise pass fixes that. It adds per-vertex height displacement and texture blending weights, computed from Perlin noise. Roads stay mostly flat so they read as paths. Open terrain gets rolling variation. The result is a complete vertex data set that the renderer can consume directly.
Goal
For every hex cell in every chunk, compute height and 4-channel splat weights for all 7 vertices (6 corners + center). The output is TileVertexData[] per chunk -- the final data shape that flows into the vertex registry and eventually hits the Graphics Processing Unit (GPU).
Prerequisites
- 01 Foundations --
TileVertexDatainterface, hex math (hexToWorld,hexCorners) - 03 Zone Pass --
ZonePassResultwith zone type per chunk - 04 Tile Pass --
TilePassResultwith tile identity per hex cell
Key Concepts
Why a self-contained Perlin implementation
The game already has a biome noise module in biomes/noise.ts, but it is coupled to the activeBiome singleton and the Entity Component System (ECS) runtime. It reads from BiomeDefinition objects and expects the biome system to be initialized.
The WFC pipeline runs without any ECS context. It is pure data transformation: seed in, vertex arrays out. Importing the biome noise module would drag in runtime dependencies and break that isolation. So the noise pass has its own 2D Perlin + fractional Brownian motion (fBm) implementation -- small, self-contained, no external state. It uses the same NoiseConfig shape as the biome system (both are biome presets), but the noise sampling function is independent.
Perlin noise in brief
Perlin noise produces smooth, continuous random values across 2D space. The algorithm:
- For input point
(x, y), find the surrounding grid cell - At each of the 4 grid corners, select a pseudorandom gradient vector
- Compute the dot product of each gradient with the vector from the corner to the input point
- Interpolate the 4 dot products using a smooth fade function
The result is a value in [-1, 1] that changes smoothly as (x, y) moves. Nearby points have similar values; distant points are uncorrelated.
Grid corners with gradient vectors:
g(0,1) ────────── g(1,1)
│ ↗ │ ↘
│ ·(x,y) │
│ ↖ │ ↗
g(0,0) ────────── g(1,0)
Each corner contributes a dot product.
Smooth interpolation produces the final value.Fractional Brownian motion
A single octave of Perlin noise produces smooth blobs. fBm layers multiple octaves at increasing frequency and decreasing amplitude to add fine detail:
fBm(x, y) = noise(x, y) * 1.0
+ noise(2x, 2y) * 0.5
+ noise(4x, 4y) * 0.25
+ ...
Each octave:
- doubles the frequency (smaller features)
- halves the amplitude (less influence)More octaves = more detail but more computation. Road zones use 2 octaves (subtle variation). Open zones use 3 octaves (richer terrain).
Zone-specific noise configuration
The noise pass selects different parameters based on the zone type. This is how roads stay flat and walkable while open terrain gets dramatic variation.
| Parameter | Road zones | Open zones | What it controls |
|---|---|---|---|
| Frequency | 0.08 | 0.06 | Spatial scale of noise features. Lower = larger blobs. |
| Octaves | 2 | 3 | Detail levels. More = finer features. |
| Height scale | 1.0 | 4.0 | Multiplier on final height. Higher = taller peaks. |
| Exponent | 0.7 | 1.0 | Power curve applied to height. < 1.0 compresses peaks. |
| Domain warp | 0.0 | 1.0 | Distorts input coords for organic shapes. 0 = disabled. |
typescript
type NoiseAlgorithm = 'fbm' | 'ridged' | 'billow' | 'caldera' | 'perlin';
export interface NoiseConfig {
algorithm: NoiseAlgorithm;
frequency: number;
octaves: number;
heightScale: number;
exponent: number;
warpStrength: number;
}
export const ROAD_NOISE: NoiseConfig = {
algorithm: 'fbm',
frequency: 0.08,
octaves: 2,
heightScale: 1,
exponent: 0.7,
warpStrength: 0,
};
export const OPEN_NOISE: NoiseConfig = {
algorithm: 'fbm',
frequency: 0.06,
octaves: 3,
heightScale: 4,
exponent: 1.0,
warpStrength: 1.0,
};This is the same NoiseConfig shape used by the biome system (dev log 07). The WFC noise pass uses biome presets — ROAD_NOISE and OPEN_NOISE are zone-specific presets that happen to use 'fbm', but the algorithm field supports the full range of noise algorithms for future zone types or biome overrides.
The exponent is a key tool. At 0.7, the height curve looks like:
height
^
| ___________
| /
| / <- 0.7 exponent: peaks compressed,
| / most terrain stays low
| /
+─────────────> raw noiseRoad zones with exponent 0.7 stay mostly flat with gentle undulation. Open zones with exponent 1.0 use the full dynamic range.
Domain warping
Domain warping distorts the input coordinates before computing the main noise. Instead of sampling noise(x, y), you sample noise(x + warp_x, y + warp_y) where warp_x and warp_y are themselves noise values at different frequencies.
Without warp: With warp:
┌──────────────┐ ┌──────────────┐
│ ○ ○ ○ ○ ○ │ │ ○ ○ ○ ○ │
│ ○ ○ ○ ○ ○ ○ │ │ ○ ○ ○ ○ ○ │
│ ○ ○ ○ ○ ○ │ │ ○ ○ ○ ○ ○ │
│ ○ ○ ○ ○ ○ ○ │ │ ○ ○ ○ ○ │
└──────────────┘ └──────────────┘
Regular, grid-aligned Organic, flowingDomain warping prevents the terrain from having visible grid alignment. Open zones use warp strength 1.0 for natural-looking terrain. Road zones skip it entirely -- a straight road should look straight.
Splat weight computation
Each vertex gets 4 texture blending weights that sum to 1.0. These weights control which textures show on the terrain surface. The noise pass computes them by sampling fBm at 4 different spatial offsets:
For vertex at world position (wx, wz):
weight[0] = abs(fBm(wx + 0, wz + 0))
weight[1] = abs(fBm(wx + 100, wz + 0))
weight[2] = abs(fBm(wx + 0, wz + 100))
weight[3] = abs(fBm(wx + 100, wz + 100))
Normalize: total = sum of all 4
weight[i] = weight[i] / totalThe offsets (100 units apart) ensure the 4 channels sample unrelated regions of the noise field, producing independent blending patterns. The absolute value keeps weights positive. Normalization ensures they sum to 1.0.
These weights define the shape of texture blending -- which areas on the terrain get which texture channel. The actual texture IDs assigned to each channel are placeholders until biome integration maps them to real textures.
Texture index placeholders
The noise pass assigns generic texture indices [0, 1, 2, 3] to every vertex. These correspond to splat channels 0 through 3. When the biome system is wired in later, it replaces these with real texture IDs from the biome definition (such as grass, rock, sand, dirt).
Until then, the indices are structurally correct -- they identify which channel each weight applies to -- but do not map to meaningful textures. The terrain will render with whatever default textures occupy slots 0-3 in the texture registry.
Tint
Every vertex gets a tint value of [1, 1, 1] (white). Tint is an RGB multiplier applied to the final texture color. White means no modification. Later pipeline stages (biome blending, special effects) can modulate the tint per vertex for color variation, darkening road surfaces, tinting grass seasonally, and so on.
Implementation Steps
Step 1: Implement 2D Perlin noise
File: src/wfc/perlin.ts
A minimal Perlin noise implementation. Uses a permutation table seeded from the pipeline's RNG for determinism.
typescript
/**
* Create a 2D Perlin noise function with a fixed permutation table.
* Returns a function (x, y) => value in [-1, 1].
*/
export function createPerlin2D(seed: number): (x: number, y: number) => number {
// Build a seeded permutation table (256 entries, doubled for overflow)
const perm = buildPermutation(seed);
return function noise(x: number, y: number): number {
// Grid cell coordinates
const xi = Math.floor(x) & 255;
const yi = Math.floor(y) & 255;
// Fractional position within cell
const xf = x - Math.floor(x);
const yf = y - Math.floor(y);
// Fade curves for smooth interpolation
const u = fade(xf);
const v = fade(yf);
// Hash corners
const aa = perm[perm[xi] + yi];
const ab = perm[perm[xi] + yi + 1];
const ba = perm[perm[xi + 1] + yi];
const bb = perm[perm[xi + 1] + yi + 1];
// Gradient dot products, interpolated
return lerp(
v,
lerp(u, grad(aa, xf, yf), grad(ba, xf - 1, yf)),
lerp(u, grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1))
);
};
}
function fade(t: number): number {
return t * t * t * (t * (t * 6 - 15) + 10); // 6t^5 - 15t^4 + 10t^3
}
function lerp(t: number, a: number, b: number): number {
return a + t * (b - a);
}
// Uses 4 gradient directions for simplicity. Upgrade to 8 if you
// see diagonal banding at high zoom -- standard 2D Perlin uses 8
// for better isotropy.
function grad(hash: number, x: number, y: number): number {
const h = hash & 3;
switch (h) {
case 0: return x + y;
case 1: return -x + y;
case 2: return x - y;
case 3: return -x - y;
default: return 0;
}
}
function buildPermutation(seed: number): number[] {
// Simple seeded shuffle of 0-255
const p = Array.from({ length: 256 }, (_, i) => i);
// Mulberry32-based shuffle
let state = seed | 0;
for (let i = 255; i > 0; i--) {
state = (state + 0x6d2b79f5) | 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
const rand = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
const j = Math.floor(rand * (i + 1));
[p[i], p[j]] = [p[j], p[i]];
}
// Double the table for overflow safety
return [...p, ...p];
}The fade function uses Perlin's improved quintic interpolation (6t^5 - 15t^4 + 10t^3), which has zero first and second derivatives at 0 and 1 -- producing smoother results than a simple cubic.
Step 2: Implement fBm with domain warping
File: src/wfc/perlin.ts (continue)
typescript
import type { NoiseConfig } from "./types";
/**
* Compute fBm (fractional Brownian motion) at a point.
* Layers multiple octaves of Perlin noise with optional domain warping.
*/
export function fbm(
noise: (x: number, y: number) => number,
x: number,
y: number,
config: NoiseConfig
): number {
let wx = x;
let wy = y;
// Domain warp: offset input coords by noise at a different frequency
if (config.warpStrength > 0) {
wx += noise(x * 0.03, y * 0.03) * config.warpStrength;
wy += noise(x * 0.03 + 17.1, y * 0.03 + 31.7) * config.warpStrength;
}
let value = 0;
let amplitude = 1;
let frequency = config.frequency;
let maxAmplitude = 0;
for (let i = 0; i < config.octaves; i++) {
value += noise(wx * frequency, wy * frequency) * amplitude;
maxAmplitude += amplitude;
frequency *= 2;
amplitude *= 0.5;
}
// Normalize to [0, 1]
value = (value / maxAmplitude + 1) * 0.5;
// Apply exponent
value = Math.pow(value, config.exponent);
// Scale by height
return value * config.heightScale;
}The warp offset constants (17.1, 31.7) are arbitrary primes that ensure the warp noise samples are uncorrelated with the main noise. Without the offset, the warp would sample the same noise field region and produce a weak distortion.
Step 3: Implement the noise pass
File: src/wfc/noise-pass.ts
typescript
import { hexToWorld, hexCorners, hexChunkCells } from "../utils/hex-math";
import { createPerlin2D, fbm } from "./perlin";
import { ZoneType, TileType } from "./types";
import type { TileVertexData, NoiseConfig } from "./types";
import type { ZoneState } from "./zone-pass";
import { decodeZoneState } from "./zone-pass";
// NoiseConfig defined in types.ts (see Key Concepts above)
const ROAD_NOISE: NoiseConfig = {
algorithm: 'fbm',
frequency: 0.08,
octaves: 2,
heightScale: 1,
exponent: 0.7,
warpStrength: 0,
};
const OPEN_NOISE: NoiseConfig = {
algorithm: 'fbm',
frequency: 0.06,
octaves: 3,
heightScale: 4,
exponent: 1.0,
warpStrength: 1.0,
};
/** Select noise config based on zone type. */
function configForZone(zoneType: ZoneType): NoiseConfig {
switch (zoneType) {
case ZoneType.RoadStraight:
case ZoneType.RoadLeft:
case ZoneType.RoadRight:
return ROAD_NOISE;
case ZoneType.Open:
return OPEN_NOISE;
}
}Step 4: Compute vertex data for a chunk
File: src/wfc/noise-pass.ts (continue)
typescript
/** Splat weight sampling offsets -- 4 channels at distant positions. */
const SPLAT_OFFSETS: Array<{ dx: number; dz: number }> = [
{ dx: 0, dz: 0 },
{ dx: 100, dz: 0 },
{ dx: 0, dz: 100 },
{ dx: 100, dz: 100 },
];
/**
* Compute vertex data for all cells in a single chunk.
*
* @param chunkCol - Chunk column in the zone grid
* @param chunkRow - Chunk row in the zone grid
* @param chunkRadius - Hex radius of the chunk
* @param zoneState - The zone state assigned to this chunk
* @param tileLookup - Cell key -> tile state id for this chunk
* @param seed - Master seed for noise generation
* @returns Array of TileVertexData for every vertex in the chunk
*/
export function computeChunkVertices(
chunkCol: number,
chunkRow: number,
chunkRadius: number,
zoneState: ZoneState,
tileLookup: Map<string, string>,
seed: number
): TileVertexData[] {
const { type: zoneType } = decodeZoneState(zoneState);
const config = configForZone(zoneType);
const noise = createPerlin2D(seed);
const allCells = hexChunkCells(chunkRadius);
const vertices: TileVertexData[] = [];
// Chunk world offset: shift cell coordinates by chunk position
// (Simplified -- real implementation uses chunk-to-world mapping)
const chunkOffsetQ = chunkCol * (chunkRadius * 2 + 1);
const chunkOffsetR = chunkRow * (chunkRadius * 2 + 1);
for (const cell of allCells) {
const worldQ = cell.q + chunkOffsetQ;
const worldR = cell.r + chunkOffsetR;
// Center vertex
const center = hexToWorld(worldQ, worldR);
vertices.push(
computeVertex(center.x, center.z, config, noise)
);
// Corner vertices
const corners = hexCorners(worldQ, worldR);
for (const corner of corners) {
vertices.push(
computeVertex(corner.x, corner.z, config, noise)
);
}
}
return vertices;
}
/**
* Compute a single vertex's data: height, splat weights, texture indices, tint.
*/
function computeVertex(
wx: number,
wz: number,
config: NoiseConfig,
noise: (x: number, y: number) => number
): TileVertexData {
// Height from fBm
const height = fbm(noise, wx, wz, config);
// Splat weights from 4 offset noise samples
const rawWeights: [number, number, number, number] = [0, 0, 0, 0];
for (let i = 0; i < 4; i++) {
const sx = wx + SPLAT_OFFSETS[i].dx;
const sz = wz + SPLAT_OFFSETS[i].dz;
rawWeights[i] = Math.abs(
fbm(noise, sx, sz, {
...config,
heightScale: 1, // Weights don't use height scale
exponent: 1, // Weights don't use exponent
})
);
}
// Normalize weights to sum to 1.0
const sum = rawWeights[0] + rawWeights[1] + rawWeights[2] + rawWeights[3];
const weights: [number, number, number, number] = sum > 0
? [rawWeights[0] / sum, rawWeights[1] / sum, rawWeights[2] / sum, rawWeights[3] / sum]
: [1, 0, 0, 0]; // Fallback if all zero
return {
x: wx,
z: wz,
height,
weights,
textureIndices: [0, 1, 2, 3], // Placeholder until biome integration
tint: [1, 1, 1], // White = no tint
};
}Each cell produces 7 vertices (center + 6 corners). Shared corner vertices between adjacent cells will have identical world positions and therefore identical noise values -- this is important for seamless rendering. The noise function is continuous, so there are no seams between cells or chunks.
Step 5: Wire the noise pass into the orchestrator
File: src/wfc/generate.ts
The orchestrator ties all three passes together:
typescript
import { runZonePass } from "./zone-pass";
import { runTilePass } from "./tile-pass";
import { computeChunkVertices } from "./noise-pass";
import type { LevelConfig, GeneratedLevel, TileVertexData } from "./types";
/**
* Generate a complete level from a seed and config.
* Returns null if generation fails (zone pass exhausts all attempts).
*/
export function generateLevel(config: LevelConfig): GeneratedLevel | null {
// 1. Zone pass: coarse layout
const zoneResult = runZonePass(config.gridWidth, config.gridHeight, config.seed);
if (!zoneResult) return null;
// 2. Tile pass: per-chunk cell identities
const tileResult = runTilePass(zoneResult, config.chunkRadius, config.seed);
// 3. Noise pass: height + texture weights per vertex
const chunks = new Map<string, TileVertexData[]>();
for (const [chunkKey, tileLookup] of tileResult.chunks) {
const [colStr, rowStr] = chunkKey.split(",");
const col = parseInt(colStr, 10);
const row = parseInt(rowStr, 10);
const zoneState = zoneResult.lookup.get(chunkKey);
if (!zoneState) continue;
const vertices = computeChunkVertices(
col, row, config.chunkRadius, zoneState, tileLookup, config.seed
);
chunks.set(chunkKey, vertices);
}
return { zoneGrid: zoneResult.grid, chunks };
}The pipeline is now complete. generateLevel takes a LevelConfig and returns a GeneratedLevel with per-chunk vertex data ready for the renderer.
Step 6: Verify the output structure
After the noise pass runs, every vertex in the output should satisfy these invariants:
typescript
function validateVertex(v: TileVertexData): boolean {
// Position is a valid number
if (!Number.isFinite(v.x) || !Number.isFinite(v.z)) return false;
// Height is finite and non-negative
if (!Number.isFinite(v.height) || v.height < 0) return false;
// Weights sum to ~1.0 (allow floating point tolerance)
const weightSum = v.weights[0] + v.weights[1] + v.weights[2] + v.weights[3];
if (Math.abs(weightSum - 1.0) > 0.001) return false;
// All weights are non-negative
if (v.weights.some((w) => w < 0)) return false;
// Texture indices are valid
if (v.textureIndices.some((i) => i < 0 || i > 3)) return false;
// Tint is valid (values > 1 are allowed for brightening)
if (v.tint.some((c) => c < 0 || !Number.isFinite(c))) return false;
return true;
}Visual Checkpoint
Generate a full level and inspect the vertex data. The key things to verify:
Height variation by zone type:
typescript
import { generateLevel } from "./wfc/generate";
const level = generateLevel({
seed: 42,
gridWidth: 5,
gridHeight: 12,
chunkRadius: 4,
biomeId: "grassland",
});
if (level) {
for (const [key, vertices] of level.chunks) {
const heights = vertices.map((v) => v.height);
const min = Math.min(...heights);
const max = Math.max(...heights);
const avg = heights.reduce((a, b) => a + b, 0) / heights.length;
const zone = level.zoneGrid.cells[/* lookup index */]?.collapsed ?? 'unknown';
console.log(`Chunk ${key} (${zone}): height min=${min.toFixed(2)} max=${max.toFixed(2)} avg=${avg.toFixed(2)}`);
}
}Expected observations:
- Road chunks: heights range roughly 0 to 1.0, average near 0.5. Gentle variation, no steep peaks.
- Open chunks: heights range roughly 0 to 4.0, average near 1.5. Wider range, more dramatic terrain.
Splat weight validation:
typescript
let invalidCount = 0;
for (const [, vertices] of level.chunks) {
for (const v of vertices) {
const sum = v.weights.reduce((a, b) => a + b, 0);
if (Math.abs(sum - 1.0) > 0.001) invalidCount++;
}
}
console.log(`Invalid weight sums: ${invalidCount} (should be 0)`);Quick heightmap visualization:
For a visual sanity check, dump heights from one open chunk as a simple grid of characters:
typescript
// Map height to ASCII brightness
const chars = " .:-=+*#%@";
function heightToChar(h: number, maxH: number): string {
const idx = Math.min(Math.floor((h / maxH) * chars.length), chars.length - 1);
return chars[idx];
}You should see varying character density across the grid -- smooth gradients, no hard edges except at the chunk boundaries between road and open zones. Road chunks should be mostly light characters (low height). Open chunks should show the full range.
What's Next
The WFC terrain pipeline is now complete as a data generator. Given a seed and config, generateLevel produces per-chunk vertex arrays with position, height, splat weights, texture indices, and tint. The data is ready for the renderer -- but it is not connected yet.
The next dev log (06 - Chunk Integration) bridges the gap: it takes the GeneratedLevel output, writes vertex data into the vertexRegistry, spawns chunk entities tagged IsDirty, and lets the existing chunk builder construct BufferGeometry and upload to the GPU. That is where generated terrain first appears on screen.