Blending Multiple Textures on Procedural Terrain in Three.js
Based on your procedural terrain generation approach, I can suggest a few methods to blend multiple textures while preserving Three.js lighting and your biome system.
Approach 1: Custom Shader Material with Three.js Lighting
The most flexible approach would be to create a custom shader material that extends Three.js's built-in lighting. You can use ShaderMaterial
with custom uniforms for your textures while incorporating Three.js lighting code.
// Create a custom shader material that extends MeshStandardMaterial
const vertexShader = `
// Include Three.js attributes and uniforms
#include <common>
#include <uv_pars_vertex>
#include <color_pars_vertex>
#include <normal_pars_vertex>
// Add your custom attributes
attribute float biomeWeight;
varying float vBiomeWeight;
varying vec2 vUv;
varying vec3 vPosition;
void main() {
#include <uv_vertex>
#include <color_vertex>
#include <beginnormal_vertex>
#include <defaultnormal_vertex>
#include <normal_vertex>
vUv = uv;
vPosition = position;
vBiomeWeight = biomeWeight; // Pass biome weight to fragment shader
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
// Include Three.js lighting
#include <common>
#include <packing>
#include <lights_pars_begin>
#include <normal_pars_fragment>
#include <lights_physical_pars_fragment>
uniform sampler2D grassTexture;
uniform sampler2D rockTexture;
uniform sampler2D snowTexture;
varying vec2 vUv;
varying vec3 vPosition;
varying float vBiomeWeight;
void main() {
// Sample textures
vec4 grassColor = texture2D(grassTexture, vUv);
vec4 rockColor = texture2D(rockTexture, vUv);
vec4 snowColor = texture2D(snowTexture, vUv);
// Blend based on height or other parameters
float height = vPosition.z;
// Simple height-based blending (customize based on your biome system)
vec4 finalColor;
if (height < 0.3) {
finalColor = grassColor;
} else if (height < 0.7) {
float blend = (height - 0.3) / 0.4;
finalColor = mix(grassColor, rockColor, blend);
} else {
float blend = (height - 0.7) / 0.3;
finalColor = mix(rockColor, snowColor, blend);
}
// Apply Three.js lighting
#include <normal_fragment_begin>
#include <normal_fragment_maps>
// Output final color with lighting
gl_FragColor = finalColor;
#include <lights_fragment_begin>
#include <lights_fragment_maps>
#include <lights_fragment_end>
}
`;
// Create material with your textures
const material = new THREE.ShaderMaterial({
uniforms: THREE.UniformsUtils.merge([
THREE.UniformsLib.lights,
{
grassTexture: { value: new THREE.TextureLoader().load('grass.jpg') },
rockTexture: { value: new THREE.TextureLoader().load('rock.jpg') },
snowTexture: { value: new THREE.TextureLoader().load('snow.jpg') }
}
]),
vertexShader: vertexShader,
fragmentShader: fragmentShader,
lights: true
});
Approach 2: Splatmap Texturing
Another approach is to use a "splatmap" - a texture that defines where each biome texture should appear:
// In your terrain generation code
const splatmap = new Float32Array(vertexCount * 4); // RGBA for 4 biomes
for (let i = 0; i <= vertices.length; i += 3) {
const z = noise(x, y);
vertices[i + 2] = z;
// Instead of colors, calculate biome weights
const biomeWeights = calculateBiomeWeights(z, climate);
// Store weights in splatmap (one per channel)
const index = (i / 3) * 4;
splatmap[index] = biomeWeights.grass;
splatmap[index + 1] = biomeWeights.rock;
splatmap[index + 2] = biomeWeights.snow;
splatmap[index + 3] = biomeWeights.sand;
}
geometry.setAttribute('splatmap', new THREE.BufferAttribute(splatmap, 4));
Then create a custom material that uses this splatmap:
const terrainMaterial = new THREE.ShaderMaterial({
uniforms: {
grassTexture: { value: new THREE.TextureLoader().load('grass.jpg') },
rockTexture: { value: new THREE.TextureLoader().load('rock.jpg') },
snowTexture: { value: new THREE.TextureLoader().load('snow.jpg') },
sandTexture: { value: new THREE.TextureLoader().load('sand.jpg') },
// Add lighting uniforms
...THREE.UniformsLib.lights
},
vertexShader: /* your vertex shader with splatmap attribute */,
fragmentShader: /* your fragment shader with texture blending */,
lights: true
});
Approach 3: Using DataTexture for Biome Mapping
You could also create a DataTexture
that maps your terrain to biome types:
// Create a data texture representing your biome distribution
const biomeMapSize = segments + 1;
const biomeData = new Uint8Array(biomeMapSize * biomeMapSize * 4);
// Fill with biome data based on your terrain generation
for (let y = 0; y < biomeMapSize; y++) {
for (let x = 0; x < biomeMapSize; x++) {
const index = (y * biomeMapSize + x) * 4;
const height = /* get height at this position */;
const biome = predictBiome(height, climate);
// Store biome weights in RGBA channels
biomeData[index] = biome.grassWeight * 255;
biomeData[index + 1] = biome.rockWeight * 255;
biomeData[index + 2] = biome.snowWeight * 255;
biomeData[index + 3] = 255; // Alpha
}
}
const biomeTexture = new THREE.DataTexture(
biomeData,
biomeMapSize,
biomeMapSize,
THREE.RGBAFormat
);
biomeTexture.needsUpdate = true;
Then use this texture in a custom shader material that samples from it based on UV coordinates.
Each of these approaches preserves Three.js lighting while allowing you to blend multiple textures based on your biome system. The first approach is likely the most flexible and powerful for your specific needs.