Three.js terrain library with PBR texture blending, animated water, and heightmap brush editing.
TerrainRegion owns the terrain mesh, TSL node materials, and height data. Your app owns the scene graph, camera, input, and raycasting.
npm
npm install metaverse-terrain three
Requires three >= 0.160 as a peer dependency. The runtime uses Three.js Shading Language (TSL), so host apps must use WebGPURenderer from three/webgpu (with Three’s WebGL fallback where available). Use with Vite, Webpack, or any bundler — no import map needed.
jsDelivr
No install step. Load from cdn.jsdelivr.net/npm/metaverse-terrain in the browser using an import map (see below).
Browsers cannot resolve bare imports like 'metaverse-terrain' on their own. Add an import map in your HTML, then load your app in a <script type="module">.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three/build/three.module.js",
"three/webgpu": "https://cdn.jsdelivr.net/npm/three/build/three.webgpu.js",
"three/tsl": "https://cdn.jsdelivr.net/npm/three/build/three.tsl.js",
"metaverse-terrain": "https://cdn.jsdelivr.net/npm/metaverse-terrain/index.js"
}
}
</script>
</head>
<body>
<script type="module">
import * as THREE from 'three/webgpu';
import { TerrainRegion } from 'metaverse-terrain';
// your app here
</script>
</body>
</html>
Serve over HTTP (not file://). The snippets below go inside that <script type="module"> block.
Provide all five layers: sand, grass, rock, snow, water. Reference PNGs are on jsDelivr:
const TEXTURE_BASE = 'https://cdn.jsdelivr.net/npm/metaverse-terrain/texture';
const textures = {
sand: `${TEXTURE_BASE}/terrain-sand.png`,
grass: `${TEXTURE_BASE}/terrain-grass.png`,
rock: `${TEXTURE_BASE}/terrain-rock.png`,
snow: `${TEXTURE_BASE}/terrain-snow.png`,
water: `${TEXTURE_BASE}/terrain-water.png`,
};
PBR is optional — albedo-only terrain still works. To enable physically based shading, provide per-layer normal, metallic, roughness, and AO maps (or a pre-packed MRAO texture). Reference maps ship with the package:
const pbrTextures = {
sand: {
metal: `${TEXTURE_BASE}/terrain-sand_metal.png`,
roughness: `${TEXTURE_BASE}/terrain-sand_roughness.png`,
normal: `${TEXTURE_BASE}/terrain-sand_normal.png`,
ao: `${TEXTURE_BASE}/terrain-sand_ao.png`,
},
grass: {
metal: `${TEXTURE_BASE}/terrain-grass_metal.png`,
roughness: `${TEXTURE_BASE}/terrain-grass_roughness.png`,
normal: `${TEXTURE_BASE}/terrain-grass_normal.png`,
ao: `${TEXTURE_BASE}/terrain-grass_ao.png`,
},
rock: {
metal: `${TEXTURE_BASE}/terrain-rock_metal.png`,
roughness: `${TEXTURE_BASE}/terrain-rock_roughness.png`,
normal: `${TEXTURE_BASE}/terrain-rock_normal.png`,
ao: `${TEXTURE_BASE}/terrain-rock_ao.png`,
},
snow: {
metal: `${TEXTURE_BASE}/terrain-snow_metal.png`,
roughness: `${TEXTURE_BASE}/terrain-snow_roughness.png`,
normal: `${TEXTURE_BASE}/terrain-snow_normal.png`,
ao: `${TEXTURE_BASE}/terrain-snow_ao.png`,
},
water: {
metal: `${TEXTURE_BASE}/terrain-water_metal.png`,
roughness: `${TEXTURE_BASE}/terrain-water_roughness.png`,
normal: `${TEXTURE_BASE}/terrain-water_normal.png`,
ao: `${TEXTURE_BASE}/terrain-water_ao.png`,
},
};
loadPBRTextureSet() loads the maps and packs metallic, roughness, and AO into a single MRAO texture per layer:
import { TerrainRegion, loadPBRTextureSet } from 'metaverse-terrain';
const packedPBR = await loadPBRTextureSet(pbrTextures);
const region = new TerrainRegion({
textures,
pbrTextures: packedPBR,
normalStrength: 1.0,
terrainAOIntensity: 1.0,
});
For best results with WebGPURenderer, set scene.environment from an equirectangular HDRI (texture.mapping = THREE.EquirectangularReflectionMapping). The bundled examples load a Venice sunset HDR via Three.js RGBELoader. PBR terrain uses MeshStandardNodeMaterial; water uses a TSL MeshBasicNodeMaterial with fresnel, specular/glint, wave, foam, and normal detail. Disable PBR water at runtime with region.setPBREnabled(false) on low-end devices.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three/build/three.module.js",
"three/webgpu": "https://cdn.jsdelivr.net/npm/three/build/three.webgpu.js",
"three/tsl": "https://cdn.jsdelivr.net/npm/three/build/three.tsl.js",
"metaverse-terrain": "https://cdn.jsdelivr.net/npm/metaverse-terrain/index.js"
}
}
</script>
<style>body { margin: 0; } canvas { display: block; }</style>
</head>
<body>
<script type="module">
import * as THREE from 'three/webgpu';
import { TerrainRegion } from 'metaverse-terrain';
const TEXTURE_BASE = 'https://cdn.jsdelivr.net/npm/metaverse-terrain/texture';
const textures = {
sand: `${TEXTURE_BASE}/terrain-sand.png`,
grass: `${TEXTURE_BASE}/terrain-grass.png`,
rock: `${TEXTURE_BASE}/terrain-rock.png`,
snow: `${TEXTURE_BASE}/terrain-snow.png`,
water: `${TEXTURE_BASE}/terrain-water.png`,
};
const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init();
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9fb7d5);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 900);
camera.position.set(132, 108, 168);
camera.lookAt(0, 10, 0);
const region = new TerrainRegion({ textures, seed: 42 });
scene.add(region.group);
function animate(time) {
region.update(time);
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate(0);
</script>
</body>
</html>
The library does not handle pointer events. Raycast the terrain, then call paintAt:
import { TerrainRegion, getTerrainHitFromPointer } from 'metaverse-terrain';
const TEXTURE_BASE = 'https://cdn.jsdelivr.net/npm/metaverse-terrain/texture';
const textures = { /* sand, grass, rock, snow, water — see above */ };
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const canvas = renderer.domElement;
const region = new TerrainRegion({ textures });
let isPainting = false;
canvas.addEventListener('pointerdown', (event) => {
const hit = getTerrainHitFromPointer(
region, canvas, camera, raycaster, pointer, event.clientX, event.clientY,
);
if (!hit) return;
isPainting = true;
region.beginStroke();
region.paintAt(hit.point, { temporaryLower: event.shiftKey });
});
canvas.addEventListener('pointermove', (event) => {
if (!isPainting) return;
const hit = getTerrainHitFromPointer(
region, canvas, camera, raycaster, pointer, event.clientX, event.clientY,
);
if (hit) region.paintAt(hit.point, { temporaryLower: event.shiftKey });
});
canvas.addEventListener('pointerup', () => {
region.endStroke();
isPainting = false;
});
import { TerrainRegion } from 'metaverse-terrain';
const region = new TerrainRegion({
textures,
regionSize: 256,
seed: 29,
waterLevel: 28,
textureDensity: 10,
});
region.setBrushMode('raise');
region.setBrushRadius(8);
region.setWaterLevel(24);
region.downloadHeightmap();
import { bindTerrainPainting, bindTextureDrop } from 'metaverse-terrain';
bindTerrainPainting(region, { domElement: canvas, camera, raycaster, pointer });
bindTextureDrop(region); // needs [data-texture-drop="grass"] elements in HTML
import * as THREE from 'three/webgpu';
import { TerrainRegion, loadPBRTextureSet } from 'metaverse-terrain';
import sand from 'metaverse-terrain/texture/terrain-sand.png';
import grass from 'metaverse-terrain/texture/terrain-grass.png';
import rock from 'metaverse-terrain/texture/terrain-rock.png';
import snow from 'metaverse-terrain/texture/terrain-snow.png';
import water from 'metaverse-terrain/texture/terrain-water.png';
import sandMetal from 'metaverse-terrain/texture/terrain-sand_metal.png';
import sandRoughness from 'metaverse-terrain/texture/terrain-sand_roughness.png';
import sandNormal from 'metaverse-terrain/texture/terrain-sand_normal.png';
import sandAO from 'metaverse-terrain/texture/terrain-sand_ao.png';
// ... same pattern for grass, rock, snow, water
const pbrTextures = await loadPBRTextureSet({
sand: { metal: sandMetal, roughness: sandRoughness, normal: sandNormal, ao: sandAO },
// grass, rock, snow, water ...
});
const region = new TerrainRegion({
textures: { sand, grass, rock, snow, water },
pbrTextures,
seed: 42,
});
scene.add(region.group);
Omit pbrTextures for albedo-only rendering.
| Export | Purpose |
|---|---|
TerrainRegion |
Terrain mesh, water, heightmap, brush state |
loadPBRTextureSet |
Load and pack PBR maps (metal, roughness, normal, AO → MRAO) |
getTerrainHitFromPointer |
Screen coords → terrain intersection |
bindTerrainPainting |
Wire pointer events to brush painting |
bindTextureDrop |
Drag-and-drop images onto texture swatches |
TERRAIN_TEXTURE_LAYERS |
['sand', 'grass', 'rock', 'snow', 'water'] |
PBR_CHANNELS |
['metal', 'roughness', 'normal', 'ao'] |
DEFAULT_TEXTURE_HEIGHTS |
Default height blend thresholds |
TerrainRegion methods
| Method | Description |
|---|---|
raycast(raycaster) |
Intersect terrain mesh |
paintAt(point, options?) |
Apply brush and emit onHeightmapChange |
setBrushMode / setBrushRadius / setBrushStrength |
Brush settings |
setWaterLevel / setWaterEnabled |
Water plane |
setTextureDensity |
Texture tiling density |
setTextureHeights |
Sand/grass/rock/snow height bands |
setNormalStrength / setTerrainAOIntensity |
PBR normal and AO intensity |
setPBREnabled / setWaterIOR |
PBR water toggle and index of refraction |
setTerrainTexture(layer, source) |
Replace a texture at runtime |
randomize(seed?) / level(height?) |
Regenerate or flatten heightmap |
drawHeightmapPreview(canvas) / downloadHeightmap() |
Export heightmap |
update(elapsedTime) |
Animate water |
dispose() |
Free GPU resources |
TypeScript types ship in index.d.ts.
git clone https://github.com/richardanaya/metaverse-terrain.git
cd metaverse-terrain
python3 -m http.server 8080
MIT — Richard Anaya. See LICENSE.
Reference textures: MIT — Richard Anaya. See ASSET_LICENSES.md.