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 {
  DoubleSide,
  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 GeoTIFFSource from "@giro3d/giro3d/sources/GeoTIFFSource.js";

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;
}

function bindSlider(id, onChange) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLInputElement)) {
    throw new Error(
      "invalid binding element: expected HTMLInputElement, got: " +
        element.constructor.name,
    );
  }

  element.oninput = function oninput() {
    onChange(element.valueAsNumber);
  };

  const setValue = (v) => {
    element.valueAsNumber = v;
    onChange(element.valueAsNumber);
  };

  const initialValue = element.valueAsNumber;

  return [setValue, initialValue, element];
}

function bindToggle(id, onChange) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLInputElement)) {
    throw new Error(
      "invalid binding element: expected HTMLButtonElement, got: " +
        element.constructor.name,
    );
  }

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

  const callback = (v) => {
    element.checked = v;
    onChange(element.checked);
  };

  return [callback, element.checked, element];
}

function bindButton(id, onClick) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLButtonElement)) {
    throw new Error(
      "invalid binding element: expected HTMLButtonElement, got: " +
        element.constructor.name,
    );
  }

  element.onclick = () => {
    onClick(element);
  };

  return element;
}

const instance = new Instance({
  target: "view",
  crs: "EPSG:3857",
  backgroundColor: null,
});

const minAltitude = -1531;
const maxAltitude = 2388;

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

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

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

instance.add(map);

const source = new GeoTIFFSource({
  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({
  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.view.camera.position.set(-13609580, 5858793, 32757);
const lookAt = new Vector3(center.x, center.y, 0);
instance.view.camera.lookAt(lookAt);

instance.notifyChange(instance.view.camera);

const controls = new MapControls(instance.view.camera, instance.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.2;
controls.target.copy(lookAt);
controls.saveState();
instance.view.setControls(controls);

Inspector.attach("inspector", 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);

const [showColliders] = bindToggle("show-colliders", (v) => {
  map.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;
  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.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.view.camera;
  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/latest/css/example.css"
    />

    <style>
      #view 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="view" class="m-0 p-0 w-100 h-100"></div>
    <div
      id="inspector"
      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": "0.39.0"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}