Appearance
Biome Blending: Noise-Driven Textures
After chunk integration, terrain appears on screen -- but it looks like a colored tile grid. Every road chunk uses the same stone texture. Every open chunk uses the same grass texture. There is no variation, no organic feel, nothing that makes the terrain look like actual terrain. Biome blending fixes that.
The biome system replaces uniform texture assignments with noise-driven per-vertex weights. Each depth tier gets its own visual identity through a BiomeDefinition that controls four independent texture layers, each with its own noise field, coverage bias, tint color, and optional variant texture. The result is terrain where grass fades into dirt near the road, rock creeps through in patches, and sand fills in the gaps -- all driven by layered noise functions.
Goal
Wire the biome system into the level population flow so that each chunk's vertex data carries noise-driven texture weights and tier-appropriate texture assignments. After this step, the same terrain from the previous dev log looks alive with distinct, blending textures.
Prerequisites
- 06 Chunk Integration -- terrain renders on screen with placeholder textures
- Familiarity with the
TERRAIN_TEXTURESregistry and how the Three.js Shading Language (TSL) shader samples textures
Key Concepts
The BiomeDefinition shape
This is the core data structure. Every visual decision about a terrain tier flows through it:
typescript
type NoiseAlgorithm = 'fbm' | 'ridged' | 'billow' | 'caldera' | 'perlin';
interface NoiseConfig {
algorithm: NoiseAlgorithm;
frequency: number; // spatial frequency -- higher = more detail
octaves: number; // noise layers stacked for detail
heightScale: number; // vertical displacement multiplier
exponent: number; // < 1 flattens terrain, > 1 sharpens peaks
warpStrength: number; // 0 = no domain warp, higher = more distortion
}
interface TextureLayer {
textureId: string; // registry ID (e.g. 'grass_2')
path: string; // asset path (e.g. '/textures/terrain/grass_2.jpg')
tint: [number, number, number]; // Red Green Blue (RGB) multiplier applied per vertex
noise: NoiseConfig; // independent noise for this layer's weight map
coverage: number; // bias in [-1, 1] added before clamping
variant?: { // optional secondary texture blended by noise
textureId: string;
path: string;
frequency: number; // world-space noise frequency for blend
};
}
interface BiomeDefinition {
id: string;
name: string;
noise: NoiseConfig; // terrain height noise (drives vertex Y displacement)
layers: [TextureLayer, TextureLayer, TextureLayer, TextureLayer];
}The four layers correspond directly to the four splat channels in the terrain material. Each layer has its own independent noise configuration -- this is what makes the blending organic. The grass layer might use low-frequency smooth fractional Brownian motion (fBm) noise while the rock layer uses high-frequency ridged noise, and they overlap naturally because their noise fields are uncorrelated.
Notice there are no height thresholds. Unlike terrain systems that say "rock above 500m, snow above 800m," the weights here are entirely noise-computed per vertex. The coverage bias is the main artistic lever: a coverage of 0.65 makes a layer dominant across most of the surface, while -0.4 makes it a sparse accent that only appears where its noise peaks.
A real biome definition
Here is the grassland biome from the codebase, annotated:
typescript
const grassland: BiomeDefinition = {
id: 'grassland',
name: 'Grassland',
noise: { // terrain height noise
algorithm: 'fbm',
frequency: 0.01, // very low frequency = rolling hills
octaves: 6, // lots of detail layers
heightScale: 10, // 10 world units of vertical range
exponent: 0.6, // < 1 = flattened, gentle terrain
warpStrength: 0.3, // subtle domain warping
},
layers: [
{
textureId: 'grass_2', // channel 0: dominant grass
path: '/textures/terrain/grass_2.jpg',
tint: [0.42, 0.78, 0.28], // green tint
noise: { algorithm: 'fbm', frequency: 0.008, octaves: 5, ... },
coverage: 0.65, // high coverage = this layer is everywhere
variant: { textureId: 'grass_2', path: '...', frequency: 0.3 },
},
{
textureId: 'rock_2', // channel 1: rock patches
tint: [0.62, 0.58, 0.50], // warm grey
noise: { algorithm: 'fbm', frequency: 0.016, octaves: 4, ... },
coverage: 0.15, // moderate = appears in patches
},
{
textureId: 'sand_1', // channel 2: sparse sand accent
tint: [0.95, 0.86, 0.62], // warm yellow
noise: { algorithm: 'fbm', frequency: 0.012, octaves: 4, ... },
coverage: 0.05, // low = rare accent
},
{
textureId: 'grass_2', // channel 3: dry grass variant
tint: [0.80, 0.74, 0.42], // yellow-brown tint
noise: { algorithm: 'billow', frequency: 0.03, octaves: 3, ... },
coverage: -0.1, // negative = very sparse
},
],
};The first layer (grass) with coverage 0.65 dominates the surface. Rock at 0.15 appears in moderate patches. Sand at 0.05 is a subtle accent. Dry grass at -0.1 barely shows up, adding variety only where its billow noise happens to peak.
The texture pipeline
The rendering side of biome blending involves three components:
TERRAIN_TEXTURES registry terrainTexUniforms[] TSL shader
(string ID -> Texture) --> (uniform sampler2D[]) --> (select by index)
|
getTextureIndex(id) -> intTERRAIN_TEXTURES is the global catalogue. Each texture is loaded as an individual Texture object and wrapped in a uniformTexture TSL node. The registry maps string IDs (like 'grass_2') to integer indices.
getTextureIndex(id) converts a string texture ID to the integer index the shader expects. When writing vertex data, you call this function to convert the biome definition's textureId strings into the integer textureIndices values stored per vertex.
The TSL shader reads four per-vertex attributes and produces blended terrain:
| Attribute | Type | Purpose |
|---|---|---|
weights | vec4 | Splat weights for 4 channels (sum to ~1.0) |
textureIndices | vec4 (flat interpolation) | Integer index per channel into uniform array |
tint | vec3 | Per-vertex RGB color multiplier |
The shader samples four textures by index, applies depth blending to the weights, and produces the final blended color. You never need to modify the shader -- just ensure your texture IDs are registered.
How splat weights work
Each vertex stores four weights and four texture indices. The weights determine how much of each texture appears at that vertex. The shader samples all four textures and blends them:
finalColor = tex0 * w0 + tex1 * w1 + tex2 * w2 + tex3 * w3In practice, the shader applies depth blending on top of the raw weights. Depth blending adds per-slot noise to the weights and then applies a max-blend threshold -- only the strongest weights survive within a BLEND_WIDTH window of the peak value. This prevents muddy four-way blends and creates sharp, natural-looking transitions:
typescript
// Depth blending pseudocode (runs in the TSL shader)
const DEPTH_FREQ = 0.4; // noise frequency for blend competition
const DEPTH_STRENGTH = 0.08; // how strongly noise offsets weights
const BLEND_WIDTH = 0.1; // transition sharpness
// Add noise offset to each weight
h0 = noise(worldPos * DEPTH_FREQ) * DEPTH_STRENGTH + w0;
h1 = noise(worldPos * DEPTH_FREQ + offset1) * DEPTH_STRENGTH + w1;
h2 = noise(worldPos * DEPTH_FREQ + offset2) * DEPTH_STRENGTH + w2;
h3 = noise(worldPos * DEPTH_FREQ + offset3) * DEPTH_STRENGTH + w3;
// Max-blend: suppress weights far from the peak
maxH = max(h0, h1, h2, h3);
threshold = maxH - BLEND_WIDTH;
dw0 = max(h0 - threshold, 0); // only survives if close to peak
// ... normalize dw0-dw3 to sum to 1.0The phase offsets (offset1, offset2, offset3) keep each channel's noise uncorrelated, so the blend boundaries are different for each texture pair.
Per-vertex weight computation
During level population, each vertex's splat weights are computed from the active biome's layer noise configurations:
For each vertex at position (x, z):
For each layer i in [0, 1, 2, 3]:
raw[i] = sampleNoise(x, z, layer[i].noise)
biased[i] = clamp(raw[i] + layer[i].coverage, 0, 1)
total = biased[0] + biased[1] + biased[2] + biased[3]
weight[i] = biased[i] / total // normalize to sum to 1.0Each layer's noise is sampled independently using its own NoiseConfig. The coverage bias shifts the output up or down -- a high coverage makes the layer appear everywhere, a low or negative coverage makes it rare. After biasing, the four values are normalized so they sum to 1.0.
The activeBiome singleton
The runtime biome state lives in a mutable singleton:
typescript
// biomes/registry.ts
export const BIOMES: Record<string, BiomeDefinition> = {};
export const activeBiome: BiomeDefinition = {} as BiomeDefinition;
export function registerBiome(def: BiomeDefinition): void {
BIOMES[def.id] = def;
}
export function loadBiomePreset(id: string): void {
const preset = BIOMES[id];
if (!preset) return;
const clone: BiomeDefinition = JSON.parse(JSON.stringify(preset));
Object.assign(activeBiome, clone);
}loadBiomePreset deep-clones a preset into activeBiome. Systems read from activeBiome directly. Because it is a deep clone, you can safely mutate activeBiome at runtime -- tweak noise frequencies in a debug panel, adjust coverage values live, change tint colors -- without corrupting the original preset. Call loadBiomePreset again to reset to defaults.
Tier-specific biomes
Different depth tiers get different biome presets. The tier configuration maps level number ranges to biome IDs:
Tier 0: Hollows levels 1-10 warm stone, amber tints, dusty paths
Tier 1: Verdant Deep levels 11-20 deep greens, bioluminescent accents
Tier 2+: (future) levels 21+ to be definedWhen the level manager starts a new level, it calls loadBiomePreset with the tier's biome ID before generating terrain. The biome weight computation runs as a post-processing step after populateLevel writes the WFC output to the vertex registry. It replaces the placeholder weights from the noise pass with biome-driven values using activeBiome.
Variant textures
The optional variant field on a TextureLayer allows a secondary texture to blend within the same channel. The variant uses its own noise frequency to create sub-patterns within the layer:
typescript
variant: {
textureId: 'grass_2',
path: '/textures/terrain/grass_2.jpg',
frequency: 0.3, // higher frequency = smaller patches
}When a variant is present, the layer alternates between its primary textureId and the variant's textureId based on noise sampled at the variant's frequency. This adds micro-variation within a single splat channel -- patches of slightly different grass within the "grass" layer, for example.
Implementation Steps
Step 1: Verify the biome registry is populated
The biome definitions in src/biomes/definitions/ are imported with side effects in src/biomes/index.ts. Each import calls registerBiome, which adds the definition to the BIOMES map. Verify that importing src/biomes/index.ts in your level generation code populates the registry:
typescript
import '../biomes'; // side-effect: registers all biome definitions
import { BIOMES, loadBiomePreset } from '../biomes/registry';
console.log(Object.keys(BIOMES)); // should list: grassland, forest, desert, ...If the registry is empty, check that the definition files call registerBiome and that the index re-exports trigger the side effects.
Step 2: Load the appropriate biome before generation
In the level generation flow, call loadBiomePreset with the tier's biome ID before running the Wave Function Collapse (WFC) pipeline:
typescript
const TIER_BIOMES: string[] = ['grassland', 'forest']; // placeholder until tier-specific biomes exist
function generateAndPopulate(world: World, levelNumber: number, seed: number): void {
const tierIndex = Math.floor((levelNumber - 1) / 10);
const biomeId = TIER_BIOMES[tierIndex] ?? TIER_BIOMES[0];
loadBiomePreset(biomeId);
const config: LevelConfig = {
seed,
gridWidth: 10,
gridHeight: 10,
chunkRadius: 4,
biomeId,
};
const level = generateLevel(config);
populateLevel(world, level);
}The biome weight post-processing reads from activeBiome, so loading the preset before generation is essential.
Step 3: Compute per-vertex weights from biome noise
As a post-processing step after populateLevel, compute splat weights for each vertex using the active biome's layer noise configs:
typescript
import { activeBiome } from '../biomes/registry';
import { sampleNoise } from '../biomes/noise';
function computeBiomeWeights(
x: number, z: number
): { weights: [number, number, number, number]; indices: [number, number, number, number] } {
const raw = activeBiome.layers.map((layer) =>
Math.max(0, Math.min(1, sampleNoise(x, z, layer.noise) + layer.coverage))
);
const total = raw[0] + raw[1] + raw[2] + raw[3];
const weights: [number, number, number, number] = [
raw[0] / total,
raw[1] / total,
raw[2] / total,
raw[3] / total,
];
const indices: [number, number, number, number] = [
getTextureIndex(activeBiome.layers[0].textureId),
getTextureIndex(activeBiome.layers[1].textureId),
getTextureIndex(activeBiome.layers[2].textureId),
getTextureIndex(activeBiome.layers[3].textureId),
];
return { weights, indices };
}The sampleNoise function dispatches to the appropriate noise algorithm (fBm, ridged, billow, caldera, perlin) based on the NoiseConfig.algorithm field. Each layer gets its own noise evaluation, producing uncorrelated weight fields.
Step 4: Update vertex data with biome weights
After computing weights, write them back into the vertex data. This can happen either during the noise pass or as a second pass over the vertex registry after populateLevel:
typescript
function applyBiomeWeights(chunkKey: string): void {
const vertices = vertexRegistry.get(chunkKey);
if (!vertices) return;
for (const v of vertices) {
const { weights, indices } = computeBiomeWeights(v.x, v.z);
v.weights = weights;
v.textureIndices = indices;
v.tint = computeTint(v.x, v.z, weights, activeBiome.layers);
}
}The tint computation blends the per-layer tint colors weighted by the splat weights, so dominant layers contribute more to the final vertex color.
Step 5: Handle zone-specific overrides
Road zones should look different from open terrain even after biome blending. Override the texture indices for road vertices to use road-specific textures while keeping the noise-driven weight distribution:
typescript
function applyZoneOverrides(vertices: VertexData[], zoneType: ZoneType): void {
const isRoad = zoneType === ZoneType.RoadStraight
|| zoneType === ZoneType.RoadLeft
|| zoneType === ZoneType.RoadRight;
if (isRoad) {
for (const v of vertices) {
v.textureIndices[0] = getTextureIndex('stone_01');
v.textureIndices[1] = getTextureIndex('dirt_01');
// Weights stay noise-driven for organic road surface variation
}
}
}This preserves the weight variation (so the road surface still has visual interest) while forcing road-appropriate textures.
Step 6: Re-tag chunks as IsDirty after biome pass
If the biome weight computation runs as a separate pass after initial chunk integration, the chunks need to be re-tagged IsDirty so buildDirtyChunks rebuilds their geometry with the updated vertex data:
typescript
world.query(ChunkCoord).forEach((entity) => {
if (!entity.has(IsDirty)) {
entity.add(IsDirty);
}
});If biome weights are computed during the noise pass (before populateLevel writes to the registry), this step is unnecessary -- the chunks are already tagged IsDirty on spawn.
Step 7: Create tier-specific biome definitions
Create new biome definition files for each depth tier. Start from existing presets and adjust:
src/biomes/definitions/
grassland.ts -- existing (use as Tier 0 placeholder)
forest.ts -- existing (use as Tier 1 placeholder)
hollows.ts -- new: warm stone, amber tints for Tier 0
verdant-deep.ts -- new: deep greens, bioluminescent blues for Tier 1Each definition follows the same BiomeDefinition shape. The creative work is in tuning the noise parameters, coverage biases, and tint values to sell the fantasy of each tier.
Visual Checkpoint
Load the same WFC-generated level from the previous dev log, but now with biome blending active:
- Distinct texture variation across the terrain surface -- not uniform flat color per zone
- Smooth transitions between texture regions -- zoom in and verify no hard edges where grass meets rock
- Road zones look different from open ground -- they use road-specific textures but still have surface variation
- The dominant layer dominates -- if using the grassland biome, green grass should cover most of the surface with rock, sand, and dry grass as accents
- Different biomes look different -- switch the biome preset (via
loadBiomePreset) and regenerate. The terrain should have a completely different visual character - No missing textures -- check the console for warnings about unregistered texture IDs
If the terrain looks muddy with all four textures blending equally everywhere, check the coverage values. The primary layer should have a high coverage (0.4+) to dominate, with accent layers at lower or negative values.
What's Next
The terrain looks rich and varied, but without a camera we are staring at it from a fixed viewpoint. Camera System adds orbit navigation so the player can rotate, zoom, and pan to explore the generated terrain.