Apply a vertical exaggeration on a scene.

Parameters


Measurement Value
Z (in scene units) -
Z after Z-scale compensation -
Actual elevation from layer -
100% © U.S. Geological Survey

Vertical exaggeration, also known as Z-scale, is helpful to emphasize the features of terrain. However, properly handling this scale can be tricky, as the equivalence relationship between scene units and actual geospatial coordinates no longer applies. For example, with a z-scale of 200%, a scene coordinate with a Z value of 2 actually means 1 meter of elevation.

index.js
import { MathUtils, Mesh, MeshBasicMaterial, SphereGeometry, Vector3 } from 'three';
import { MapControls } from 'three/examples/jsm/controls/MapControls.js';

import Instance from '@giro3d/giro3d/core/Instance.js';
import Extent from '@giro3d/giro3d/core/geographic/Extent.js';
import ElevationLayer from '@giro3d/giro3d/core/layer/ElevationLayer.js';
import Map from '@giro3d/giro3d/entities/Map.js';
import Inspector from '@giro3d/giro3d/gui/Inspector.js';
import ColorMap from '@giro3d/giro3d/core/layer/ColorMap.js';
import Coordinates from '@giro3d/giro3d/core/geographic/Coordinates.js';
import AxisGrid from '@giro3d/giro3d/entities/AxisGrid.js';
import CogSource from '@giro3d/giro3d/sources/CogSource.js';

/**
 * Create an array of {@link Color}s from the specified colormap preset.
 * @param {string} preset The name of the colormap preset.
 * @param {boolean} [discrete=false] If `true`, the color array will have 10 steps, otherwise 256.
 * @param {boolean} [invert=false] If `true`, the color array will be reversed.
 * @returns {Color[]} The color array.
 */
function makeColorRamp(preset, discrete = false, invert = false, mirror = false) {
    let nshades = discrete ? 10 : 256;

    // eslint-disable-next-line no-undef
    const values = colormap({ colormap: preset, nshades });
    // eslint-disable-next-line no-undef
    const colors = values.map(v => new Color(v));

    if (invert) {
        colors.reverse();
    }

    if (mirror) {
        const mirrored = [...colors, ...colors.reverse()];
        return mirrored;
    }

    return colors;
}

/**
 * Binds a {@link HTMLInputElement} in slider mode.
 * @param {string} id The id of the <input> element.
 * @param {(v: number) => void} onChange The callback when the slider value changes.
 * @returns {(v: number) => void} The function to update the value from outside.
 */
function bindSlider(id, onChange) {
    /** @type {HTMLInputElement} */
    const slider = document.getElementById(id);
    slider.oninput = function oninput() {
        onChange(slider.valueAsNumber);
    };

    return v => {
        slider.valueAsNumber = v;
        onChange(slider.valueAsNumber);
    };
}

/**
 * Binds a toggle switch or checkbox.
 * @param {string} id The id of the <input> element.
 * @param {(v: boolean) => void} onChange The callback when the dropdown value changes.
 * @returns {(v: boolean) => void} The function to update the value from outside.
 */
function bindToggle(id, onChange) {
    /** @type {HTMLInputElement} */
    const toggle = document.getElementById(id);

    toggle.oninput = function oninput() {
        onChange(toggle.checked);
    };

    return v => {
        toggle.checked = v;
        onChange(toggle.checked);
    };
}

/**
 * Binds a button.
 * @param {string} id The id of the <button> element.
 * @param {(button: HTMLButtonElement) => void} onClick The click handler.
 * @returns {HTMLButtonElement} The button element.
 */
function bindButton(id, onClick) {
    const element = document.getElementById(id);
    element.onclick = () => {
        onClick(element);
    };

    return element;
}

const viewerDiv = document.getElementById('viewerDiv');

const instance = new Instance(viewerDiv, { crs: 'EPSG:3857', renderer: { clearColor: false } });

const minAltitude = -1531;
const maxAltitude = 2388;

// create a map
const extent = new Extent(
    instance.referenceCrs,
    -13576103.933,
    -13532051.346,
    5894667.439,
    5939002.826,
).withMargin(-200, -200);

const map = new Map('planar', {
    extent,
    backgroundColor: 'gray',
    hillshading: {
        enabled: true,
        intensity: 0.75,
        zFactor: 1,
        azimuth: 254,
    },
    discardNoData: true,
    doubleSided: true,
});

// Forces the map to subdivide more than usual, for better readability of tiles.
map.subdivisionThreshold = 0.75;

instance.add(map);

// Use an elevation COG with nodata values
const source = new CogSource({
    // https://www.sciencebase.gov/catalog/item/632a9a9ad34e71c6d67b95a3
    url: 'https://3d.oslandia.com/giro3d/rasters/topobathy.cog.tiff',
    crs: extent.crs(),
});

const elevationLayer = new ElevationLayer({
    source,
    minmax: { min: minAltitude, max: maxAltitude },
    preloadImages: true,
    colorMap: new ColorMap(makeColorRamp('bathymetry'), minAltitude + 200, maxAltitude - 200),
});

map.addLayer(elevationLayer);

const axisGrid = new AxisGrid('axis-grid', {
    volume: {
        extent: map.extent,
        floor: -2000,
        ceiling: 2500,
    },
    ticks: {
        x: 10_000,
        y: 10_000,
        z: 500,
    },
});

instance.add(axisGrid);

const center = extent.centerAsVector2();

instance.camera.camera3D.position.set(-13609580, 5858793, 32757);
const lookAt = new Vector3(center.x, center.y, 0);
instance.camera.camera3D.lookAt(lookAt);

instance.notifyChange(instance.camera.camera3D);

// Creates controls
const controls = new MapControls(instance.camera.camera3D, instance.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.2;

// you need to use these 2 lines each time you change the camera lookAt or position programatically
controls.target.copy(lookAt);
controls.saveState();

instance.useTHREEControls(controls);

Inspector.attach(document.getElementById('panelDiv'), instance);

const sphere = new Mesh(new SphereGeometry(1), new MeshBasicMaterial({ color: 'red' }));

const tmpOrigin = new Vector3();
const tmpSize = new Vector3();
const tmpPosition = new Vector3();

// Make the sphere constant size on the screen
sphere.onBeforeRender = function onBeforeRender(renderer, _scene, camera) {
    const origin = camera.getWorldPosition(tmpOrigin);
    const dist = origin.distanceTo(sphere.getWorldPosition(tmpPosition));

    const fovRads = MathUtils.degToRad(camera.fov);
    const fieldOfViewHeight = Math.tan(fovRads) * dist;

    const size = renderer.getSize(tmpSize);

    const radius = 5; // pixels
    const pixelRatio = radius / size.y;

    const scale = fieldOfViewHeight * pixelRatio;

    // We also have to apply a counteracting z-scale to the
    // sphere in order to keep its round shape, otherwise
    // it will be squeezed by the z-scale.
    sphere.scale.set(scale, scale, scale / instance.scene.scale.z);

    sphere.updateMatrixWorld(true);
};

instance.add(sphere);

function getRawPickedPoint(mouseEvent) {
    const picked = instance.pickObjectsAt(mouseEvent, { where: [map] });
    if (picked.length > 0) {
        const first = picked[0];
        const point = first.point;
        return point;
    }

    return null;
}

function sampleElevationOnMap(point) {
    const getElevation = map.getElevation({
        coordinates: new Coordinates(instance.referenceCrs, point.x, point.y),
    });
    if (getElevation.samples.length > 0) {
        getElevation.samples.sort((a, b) => a.resolution - b.resolution);
        return getElevation.samples[0].elevation;
    }

    return null;
}

function updateMeasurements(mouseEvent) {
    const point = getRawPickedPoint(mouseEvent);

    const rawHeightCell = document.getElementById('raw-height');
    const adjustedHeightCell = document.getElementById('adjusted-height');
    const rasterHeightCell = document.getElementById('raster-height');

    if (point) {
        // The raw Z value is in scene units.
        const rawZ = point.z;

        // To obtain the actual elevation in geospatial
        // units (meters), we need to divided by the z-scale.
        const unscaledZ = rawZ / instance.scene.scale.z;

        // We can also compare those values with the elevation
        // sampled directly on elevation data (rasters).
        const sampledZ = sampleElevationOnMap(point);

        // Warning! Here we have to position the sphere to the unscaled Z value
        // because the entire scene is already scaled. Applying the raw Z value means
        // that the Z-scale will be applied twice on the sphere !
        sphere.position.set(point.x, point.y, unscaledZ);

        // We also have to apply a counteracting scale to the
        // sphere in order to keep its round shape, otherwise
        // it will be squeezed by the z-scale.
        sphere.scale.setZ(1 / instance.scene.scale.z);

        sphere.updateMatrixWorld();

        rawHeightCell.innerText = `${rawZ?.toFixed(2)}`;
        adjustedHeightCell.innerText = `${unscaledZ?.toFixed(2)} m`;
        rasterHeightCell.innerText = `${sampledZ?.toFixed(3)} m`;

        sphere.visible = true;
    } else {
        rawHeightCell.innerText = '-';
        adjustedHeightCell.innerText = '-';
        rasterHeightCell.innerText = '-';

        sphere.visible = false;
    }

    instance.notifyChange();
}

instance.domElement.addEventListener('mousemove', updateMeasurements);

// Bind events

const showColliders = bindToggle('show-colliders', v => {
    map.materialOptions.showColliderMeshes = v;
    instance.notifyChange(map);
});

const showGrid = bindToggle('show-grid', v => {
    axisGrid.visible = v;
    instance.notifyChange();
});

const setGeometricResolution = bindSlider('geometric-resolution', v => {
    map.segments = 2 ** v;
    instance.notifyChange(map);

    document.getElementById('label-geometric-resolution').innerText =
        `Terrain mesh resolution: ${map.segments}`;
});

const setWireframe = bindToggle('wireframe', v => {
    map.wireframe = v;
    map.traverseMaterials(m => (m.wireframe = v));
    instance.notifyChange(map);
});

const setVerticalExaggeration = bindSlider('vertical-exaggeration', v => {
    // Vertical exaggerations simply means that the entire scene is scaled vertically.
    instance.scene.scale.setZ(v);

    // Changing the position, rotation or scale of an object requires the
    // recomputation of the transformation matrices of the object and its descendants.
    // Since the scene is the root object of the entire instance, updating it will
    // update all the objects in the scene as well.
    instance.scene.updateWorldMatrix(true, true);

    // By default, vertical exaggeration has no effect on shading,
    // so let's apply it to hillshading to increase the shading intensity
    // when the vertical exaggeration increases.
    map.materialOptions.hillshading.zFactor = v;

    instance.notifyChange(map);

    const percent = Math.round(v * 100);

    document.getElementById('label-vertical-exaggeration').innerHTML =
        `Z-scale: <span class="fw-bold ${percent === 100 ? 'text-success' : ''}">${percent}%</span>`;
});

instance.addEventListener('before-render', () => {
    const camera = instance.camera.camera3D;
    camera.near = 1000;
    camera.far = 200000;
});

bindButton('reset', () => {
    showGrid(true);
    showColliders(false);
    setVerticalExaggeration(1);
    setGeometricResolution(5);
    setWireframe(false);
});
index.html
<!doctype html>
<html lang="en">
    <head>
        <title>Vertical exaggeration</title>
        <meta charset="UTF-8" />
        <meta name="name" content="map_vertical_exaggeration" />
        <meta name="description" content="Apply a vertical exaggeration on a scene." />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />

        <link rel="icon" href="https://giro3d.org/images/favicon.svg" />
        <link href="https://giro3d.org/assets/bootstrap-custom.css" rel="stylesheet" />
        <script src="https://giro3d.org/assets/bootstrap.bundle.min.js"></script>
        <link
            rel="stylesheet"
            type="text/css"
            href="https://giro3d.org/next/css/example.css"
        />

        <style>
        #viewerDiv canvas {
          background: rgb(132, 170, 182);
          background: radial-gradient(circle, rgba(132, 170, 182, 1) 0%, rgba(37, 44, 48, 1) 100%);
        }
        </style>
    </head>

    <body>
        <div id="viewerDiv" class="m-0 p-0 w-100 h-100"></div>
        <div id="panelDiv" class="position-absolute top-0 start-0 mh-100 overflow-auto"></div>

        <div class="side-pane-with-status-bar pe-none">
            <div class="card pe-auto">
                <div class="card-header">
                    Parameters
                    <button type="button" id="reset" class="btn btn-sm btn-primary rounded float-end">
                        reset
                    </button>
                </div>

                <div class="card-body">
                    <label for="vertical-exaggeration" id="label-vertical-exaggeration" class="form-label"
                        >Z-scale: <span class="fw-bold text-success">100%</span></label
                    >
                    <div class="input-group">
                        <input
                            type="range"
                            min="0.01"
                            max="10"
                            step="0.01"
                            value="1"
                            class="form-range"
                            id="vertical-exaggeration"
                            autocomplete="off"
                        />
                    </div>

                    <hr />

                    <div class="form-check form-switch">
                        <input
                            class="form-check-input"
                            type="checkbox"
                            role="switch"
                            checked
                            id="show-grid"
                            autocomplete="off"
                        />
                        <label class="form-check-label" for="show-grid">Show grid</label>
                    </div>

                    <div class="form-check form-switch">
                        <input
                            class="form-check-input"
                            type="checkbox"
                            role="switch"
                            id="show-colliders"
                            autocomplete="off"
                        />
                        <label class="form-check-label" for="show-colliders">Show collider meshes</label>
                    </div>

                    <div class="form-check form-switch">
                        <input
                            class="form-check-input"
                            type="checkbox"
                            role="switch"
                            id="wireframe"
                            autocomplete="off"
                        />
                        <label class="form-check-label" for="wireframe">Wireframe</label>
                    </div>

                    <label for="geometric-resolution" id="label-geometric-resolution" class="form-label"
                        >Terrain mesh resolution: 32</label
                    >
                    <div class="input-group">
                        <input
                            type="range"
                            min="1"
                            max="7"
                            step="1"
                            value="5"
                            class="form-range"
                            id="geometric-resolution"
                            autocomplete="off"
                        />
                    </div>

                    <hr />

                    <table class="table" style="width: 25rem">
                        <thead>
                            <tr>
                                <th scope="col">Measurement</th>
                                <th scope="col">Value</th>
                            </tr>
                        </thead>
                        <tbody>
                            <tr>
                                <th scope="row">Z (in scene units)</th>
                                <td id="raw-height">-</td>
                            </tr>
                            <tr>
                                <th scope="row">Z after Z-scale compensation</th>
                                <td id="adjusted-height">-</td>
                            </tr>
                            <tr>
                                <th scope="row">Actual elevation from layer</th>
                                <td style="width: 8rem" id="raster-height">-</td>
                            </tr>
                        </tbody>
                    </table>
                </div>
            </div>
        </div>

        <script type="module" src="index.js"></script>
        <script>
            /* activate popovers */
            const popoverTriggerList = [].slice.call(
                document.querySelectorAll('[data-bs-toggle="popover"]'),
            );
            popoverTriggerList.map(
                // bootstrap is used as script in the template, disable warning about undef
                // eslint-disable-next-line no-undef
                popoverTriggerEl =>
                    new bootstrap.Popover(popoverTriggerEl, {
                        trigger: 'hover',
                        placement: 'left',
                        content: document.getElementById(
                            popoverTriggerEl.getAttribute('data-bs-content'),
                        ).innerHTML,
                        html: true,
                    }),
            );
        </script>
    </body>
</html>
package.json
{
    "name": "map_vertical_exaggeration",
    "dependencies": {
        "colormap": "^2.3.2",
        "@giro3d/giro3d": "git+https://gitlab.com/giro3d/giro3d.git"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}