<html lang="en">
<head>
<title>3D Terrain with custom layer</title>
<meta charset='utf-8'>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel='stylesheet' href='https://unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css' />
<script src='https://unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.js'></script>
<style>
body { margin: 0; padding: 0; }
html, body, #map { height: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const EXTENT = 8192;
const TILE_SIZE = 256;
const map = new maplibregl.Map({
container: 'map',
style: {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
sources: {
osm: {
type: 'raster',
tileSize: 256,
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
attribution: '© OpenStreetMap Contributors',
maxzoom: 19
},
terrain: {
type: 'raster-dem',
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 15,
encoding: 'terrarium'
}
},
layers: [
{
id: 'osm',
type: 'raster',
source: 'osm',
paint: {
'raster-opacity': 1.0
}
}
],
terrain: {
source: 'terrain',
exaggeration: 1.0
}
},
zoom: 12,
center: [5.7245, 45.1885],
pitch: 60,
bearing: 0
});
const terrainNormalLayer = {
id: 'terrain-normal',
type: 'custom',
renderingMode: '3d',
shaderMap: new Map(),
onAdd(map, gl) {
this.map = map;
this.gl = gl;
},
getShader(gl, shaderDescription) {
if (this.shaderMap.has(shaderDescription.variantName)) {
return this.shaderMap.get(shaderDescription.variantName);
}
const vertexSource = `#version 300 es
${shaderDescription.vertexShaderPrelude}
${shaderDescription.define}
uniform sampler2D u_image;
uniform vec2 u_dimension;
uniform int u_original_vertex_count;
in vec2 a_pos;
out vec2 v_pos;
out vec3 v_normal;
out float v_elevation;
float getElevation(vec2 pos) {
vec2 texCoord = pos / float(${EXTENT});
vec4 data = texture(u_image, texCoord) * 255.0;
return (data.r * 256.0 + data.g + data.b / 256.0) - 32768.0;
}
void main() {
vec2 pos = a_pos;
float elevation = getElevation(pos);
v_elevation = elevation;
v_pos = pos / float(${EXTENT});
bool isBorder = gl_VertexID >= u_original_vertex_count;
if (isBorder) {
elevation -= 200.0;
}
gl_Position = projectTileFor3D(pos, elevation);
// Simple normal calculation
vec2 epsilon = vec2(1.0) / u_dimension;
float nx = getElevation(pos + vec2(epsilon.x, 0.0)) - getElevation(pos - vec2(epsilon.x, 0.0));
float ny = getElevation(pos + vec2(0.0, epsilon.y)) - getElevation(pos - vec2(0.0, epsilon.y));
v_normal = normalize(vec3(-nx, -ny, 2.0 * length(epsilon)));
}`;
const fragmentSource = `#version 300 es
precision highp float;
in vec2 v_pos;
in vec3 v_normal;
in float v_elevation;
out vec4 fragColor;
void main() {
vec2 distFromEdge = min(v_pos, 1.0 - v_pos);
float borderWidth = 20.0 / float(${EXTENT});
if (distFromEdge.x < borderWidth || distFromEdge.y < borderWidth) {
fragColor = vec4(1.0, 0.0, 0.0, 1.0); // Red borders
} else {
float normalizedElevation = (v_elevation + 500.0) / 1500.0;
normalizedElevation = clamp(normalizedElevation, 0.0, 1.0);
vec3 color = mix(vec3(0.0, 1.0, 0.0), vec3(1.0, 0.0, 0.0), normalizedElevation);
fragColor = vec4(color, 0.9);
}
}`;
const program = gl.createProgram();
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(vertexShader, vertexSource);
gl.compileShader(vertexShader);
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
console.error('Vertex shader compilation failed:', gl.getShaderInfoLog(vertexShader));
return null;
}
gl.shaderSource(fragmentShader, fragmentSource);
gl.compileShader(fragmentShader);
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
console.error('Fragment shader compilation failed:', gl.getShaderInfoLog(fragmentShader));
return null;
}
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Program link failed:', gl.getProgramInfoLog(program));
return null;
}
const uniforms = [
'u_matrix',
'u_projection_matrix',
'u_projection_clipping_plane',
'u_projection_transition',
'u_projection_tile_mercator_coords',
'u_projection_fallback_matrix',
'u_image',
'u_dimension',
'u_original_vertex_count'
];
const locations = {};
for (const uniform of uniforms) {
locations[uniform] = gl.getUniformLocation(program, uniform);
}
const attributes = {
a_pos: gl.getAttribLocation(program, 'a_pos')
};
const result = { program, locations, attributes };
this.shaderMap.set(shaderDescription.variantName, result);
return result;
},
getTileMesh(gl) {
const meshBuffers = maplibregl.createTileMesh({
granularity: 128,
}, '16bit');
const vertices = new Int16Array(meshBuffers.vertices);
const indices = new Uint16Array(meshBuffers.indices);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const ibo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
return {
vbo,
ibo,
indexCount: indices.length,
originalVertexCount: vertices.length / 2
};
},
render(gl, matrix) {
const terrain = this.map.terrain;
if (!terrain) return;
const shader = this.getShader(gl, matrix.shaderData);
gl.useProgram(shader.program);
const renderableTiles = terrain.sourceCache.getRenderableTiles();
gl.disable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// Create mesh once
const mesh = this.getTileMesh(gl);
gl.bindBuffer(gl.ARRAY_BUFFER, mesh.vbo);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, mesh.ibo);
gl.enableVertexAttribArray(shader.attributes.a_pos);
gl.vertexAttribPointer(
shader.attributes.a_pos,
2,
gl.SHORT,
false,
4,
0
);
const renderedCanonical = new Set();
for (const tile of renderableTiles) {
const canonical = tile.tileID.canonical;
const key = `${canonical.z}/${canonical.x}/${canonical.y}`;
if (renderedCanonical.has(key)) continue;
renderedCanonical.add(key);
const terrainData = terrain.getTerrainData(tile.tileID);
if (!terrainData || !terrainData.texture) continue;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, terrainData.texture);
gl.uniform1i(shader.locations.u_image, 0);
const projectionData = this.map.transform.getProjectionData({
overscaledTileID: tile.tileID,
applyGlobeMatrix: true
});
gl.uniform4f(shader.locations.u_projection_clipping_plane, projectionData.clippingPlane);
gl.uniform1f(shader.locations.u_projection_transition, projectionData.projectionTransition);
gl.uniform4f(shader.locations.u_projection_tile_mercator_coords, projectionData.tileMercatorCoords);
gl.uniformMatrix4fv(shader.locations.u_projection_matrix, false, projectionData.mainMatrix);
gl.uniformMatrix4fv(shader.locations.u_projection_fallback_matrix, false, projectionData.fallbackMatrix);
gl.uniform2f(shader.locations.u_dimension, 256, 256);
gl.uniform1i(shader.locations.u_original_vertex_count, mesh.originalVertexCount);
gl.drawElements(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0);
}
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.BLEND);
}
};
map.on('load', () => {
console.log('Map loaded');
map.setTerrain({source: 'terrain', exaggeration: 1.0});
console.log('Terrain set');
map.addLayer(terrainNormalLayer);
console.log('Layer added');
const terrainSource = map.getSource('terrain');
console.log('Terrain source state:', {
loaded: terrainSource._loaded,
hasTiles: !!terrainSource.tiles,
tileSize: terrainSource.tileSize
});
});
map.addControl(new maplibregl.NavigationControl());
</script>
</body>
</html>
Output
300px
You can jump to the latest bin by adding /latest
to your URL
Keyboard Shortcuts
Shortcut | Action |
---|---|
ctrl + [num] | Toggle nth panel |
ctrl + 0 | Close focused panel |
ctrl + enter | Re-render output. If console visible: run JS in console |
Ctrl + l | Clear the console |
ctrl + / | Toggle comment on selected lines |
ctrl + ] | Indents selected lines |
ctrl + [ | Unindents selected lines |
tab | Code complete & Emmet expand |
ctrl + shift + L | Beautify code in active panel |
ctrl + s | Save & lock current Bin from further changes |
ctrl + shift + s | Open the share options |
ctrl + y | Archive Bin |
Complete list of JS Bin shortcuts |
JS Bin URLs
URL | Action |
---|---|
/ | Show the full rendered output. This content will update in real time as it's updated from the /edit url. |
/edit | Edit the current bin |
/watch | Follow a Code Casting session |
/embed | Create an embeddable version of the bin |
/latest | Load the very latest bin (/latest goes in place of the revision) |
/[username]/last | View the last edited bin for this user |
/[username]/last/edit | Edit the last edited bin for this user |
/[username]/last/watch | Follow the Code Casting session for the latest bin for this user |
/quiet | Remove analytics and edit button from rendered output |
.js | Load only the JavaScript for a bin |
.css | Load only the CSS for a bin |
Except for username prefixed urls, the url may start with http://jsbin.com/abc and the url fragments can be added to the url to view it differently. |