Apply a vertical exaggeration on a scene.

10,000 m
20,000 m
30,000 m
40,000 m
10,000 m
20,000 m
30,000 m
40,000 m
10,000 m
20,000 m
30,000 m
40,000 m
10,000 m
20,000 m
30,000 m
40,000 m
-1,500 m
-1,000 m
-500 m
0 m
500 m
1,000 m
1,500 m
2,000 m
-1,500 m
-1,000 m
-500 m
0 m
500 m
1,000 m
1,500 m
2,000 m
Giro3D version
THREE.js version
OpenLayers version
CRS
Memory usage (CPU)
Memory usage (GPU)
Frames
Clear color
Clear alpha
Status
Local clipping enabled
Capabilities
WebGL 2
Max texture units
Max texture size
Precision
Max fragment shader uniforms
Logarithmic depth buffer
Max shader attributes
Check shader errors
EXT_clip_control
EXT_color_buffer_float
EXT_color_buffer_half_float
EXT_conservative_depth
EXT_depth_clamp
EXT_float_blend
EXT_polygon_offset_clamp
EXT_texture_compression_bptc
EXT_texture_compression_rgtc
EXT_texture_filter_anisotropic
EXT_texture_mirror_clamp_to_edge
EXT_texture_norm16
NV_shader_noperspective_interpolation
OES_draw_buffers_indexed
OES_sample_variables
OES_shader_multisample_interpolation
OES_texture_float_linear
OVR_multiview2
WEBGL_clip_cull_distance
WEBGL_compressed_texture_astc
WEBGL_compressed_texture_etc
WEBGL_compressed_texture_etc1
WEBGL_compressed_texture_s3tc
WEBGL_compressed_texture_s3tc_srgb
WEBGL_debug_renderer_info
WEBGL_debug_shaders
WEBGL_lose_context
WEBGL_multi_draw
WEBGL_polygon_mode
WEBGL_stencil_texturing
MSAA
EDL
EDL Radius
EDL Strength
Inpainting
Inpainting steps
Inpainting depth contrib.
Point cloud occlusion
Type
FOV
Automatic plane computation
Far plane
Near plane
Max far plane
Min near plane
Width (pixels)
Height (pixels)
x
y
z
x
y
z
color
Enable cache
Default TTL (seconds)
Capacity (MB)
Capacity (entries)
Entries
Memory usage (approx)
Pending requests
Memory tracker
Identifier
Memory usage (CPU)
Memory usage (GPU)
Status
Render order
Enable
Plane normal X
Plane normal Y
Plane normal Z
Distance
Helper size
Negate plane
Visible
Freeze updates
Opacity
Show volumes
Volume color
Discard no-data values
Sidedness
DoubleSide
Depth test
Visible tiles
Reachable tiles
Loaded tiles
Cast shadow
Receive shadow
Tile width (pixels)
Tile height (pixels)
Show grid
Background
Background opacity
Show tiles outlines
Tile outline color
Show tile info
Show extent
Extent color
Subdivision threshold
Deformation
Wireframe
Tile subdivisions
Show collider meshes
CPU terrain
Stitching
Geometry pool
Enabled
Mode
Hillshade
Hillshade intensity
Z factor
Hillshade zenith
Hillshade azimuth
Elevation layers only
Enable
Color
Opacity
X step
Y step
X Offset
Y Offset
Thickness
Enable
Color
Thickness
Opacity
Primary interval (m)
Secondary interval (m)
Brightness
Contrast
Saturation
Layer count
Render state
Normal
Layers
Identifier
Memory usage (CPU)
Memory usage (GPU)
Source CRS
Status
Resolution factor
Visible
Frozen
Interpretation
Loaded images
Minimum elevation
Maximum elevation
Show extent
Extent color
Enabled
Mode
Elevation
Lower bound
Upper bound
Type
Color space
Data type
Flip Y
Synchronous
CRS
Memory usage (CPU)
Memory usage (GPU)
URL
Channel mapping
Identifier
Memory usage (CPU)
Memory usage (GPU)
Status
Render order
Enable
Plane normal X
Plane normal Y
Plane normal Z
Distance
Helper size
Negate plane
Visible
Freeze updates
Opacity
color
Font size
Show helpers
Show labels
Adaptive labels
Absolute ticks
Show floor grid
Show ceiling grid
Show side grids
Floor elevation
Ceiling elevation
X ticks
Y ticks
Z ticks
Show helpers
Show hidden objects
Name filter
Hierarchy
Properties
isObject3D
uuid
name
type
matrixAutoUpdate
matrixWorldAutoUpdate
matrixWorldNeedsUpdate
visible
castShadow
receiveShadow
frustumCulled
renderOrder
x
y
z
x
y
z
Parameters


Measurement Value
Z (in scene units) -
Z after Z-scale compensation -
Actual elevation from layer -
50% © 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/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;

  const values = colormap({ colormap: preset, nshades });

  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, min, max, step) => {
    if (min != null && max != null) {
      element.min = min.toString();
      element.max = max.toString();

      if (step != null) {
        element.step = step;
      }
    }
    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,
});

// We are going to change the transform of the scene itself,
// so  we need to enable this, which by default is disabled
// for performance reasons.
instance.scene.matrixWorldAutoUpdate = true;

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",
  lighting: {
    enabled: true,
    hillshadeIntensity: 0.75,
    zFactor: 1,
    hillshadeAzimuth: 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({
    colors: makeColorRamp("bathymetry"),
    min: minAltitude + 200,
    max: 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 hillshade,
  // so let's apply it to hillshading to increase the shading intensity
  // when the vertical exaggeration increases.
  map.lighting.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/examples/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.42.4"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}