Illustrates the use of three.js physically accurate lights and shadows on Maps.

100% © Mapbox

three.js lights and shadows can be used on Maps.

index.js
import colormap from "colormap";

import {
  AmbientLight,
  ArrowHelper,
  BasicShadowMap,
  BoxGeometry,
  CameraHelper,
  Clock,
  Color,
  DirectionalLight,
  DirectionalLightHelper,
  Group,
  MathUtils,
  Mesh,
  MeshStandardMaterial,
  PointLight,
  PointLightHelper,
  Vector3,
  VSMShadowMap,
} from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js";

import XYZ from "ol/source/XYZ.js";

import ColorMap from "@giro3d/giro3d/core/ColorMap.js";
import { MapLightingMode } from "@giro3d/giro3d/entities/MapLightingOptions.js";
import Instance from "@giro3d/giro3d/core/Instance.js";
import Coordinates from "@giro3d/giro3d/core/geographic/Coordinates.js";
import Extent from "@giro3d/giro3d/core/geographic/Extent.js";
import Sun from "@giro3d/giro3d/core/geographic/Sun.js";
import ColorLayer from "@giro3d/giro3d/core/layer/ColorLayer.js";
import ElevationLayer from "@giro3d/giro3d/core/layer/ElevationLayer.js";
import Map from "@giro3d/giro3d/entities/Map.js";
import MapboxTerrainFormat from "@giro3d/giro3d/formats/MapboxTerrainFormat.js";
import Inspector from "@giro3d/giro3d/gui/Inspector.js";
import TiledImageSource from "@giro3d/giro3d/sources/TiledImageSource.js";

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

function bindColorPicker(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() {
    // Let's change the classification color with the color picker value
    const hexColor = element.value;
    onChange(new Color(hexColor));
  };

  const externalFunction = (v) => {
    element.value = `#${new Color(v).getHexString()}`;
    onChange(element.value);
  };

  return [externalFunction, new Color(element.value), element];
}

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

  element.onchange = () => {
    onChange(element.value);
  };

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

  const setOptions = (options) => {
    const items = options.map(
      (opt) =>
        `<option value=${opt.id} ${opt.selected ? "selected" : ""}>${opt.name}</option>`,
    );
    element.innerHTML = items.join("\n");
  };

  return [callback, element.value, element, setOptions];
}

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

  element.onchange = () => {
    onChange(parseInt(element.value));
  };

  const callback = (v) => {
    element.value = v.toString();
    onChange(parseInt(element.value));
  };

  return [callback, parseInt(element.value), element];
}

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 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 updateLabel(id, text) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLLabelElement)) {
    throw new Error(
      "invalid binding element: expected HTMLLabelElement, got: " +
        element.constructor.name,
    );
  }

  element.innerText = text;
}

Instance.registerCRS(
  "EPSG:3482",
  "+proj=tmerc +lat_0=31 +lon_0=-113.75 +k=0.999933333 +x_0=213360 +y_0=0 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs",
);

const EXTENT_SIZE = 20_000;

const min = 1500;
const max = 2000;

// Monument Valley coordinates
const center = new Coordinates("EPSG:4326", -110.08252, 36.98715)
  .as("EPSG:3857")
  .toVector3();

const extent = Extent.fromCenterAndSize(
  "EPSG:3857",
  center,
  EXTENT_SIZE,
  EXTENT_SIZE,
);

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

const map = new Map({
  extent,
  // Enables light-based shading on this map
  lighting: {
    enabled: true,
    mode: MapLightingMode.LightBased,
  },
  discardNoData: true,
  segments: 64,
  subdivisionThreshold: 1,
  backgroundColor: "#c0bfbc",
});

instance.add(map);

const northArrow = new ArrowHelper(
  new Vector3(0, 1, 0),
  new Vector3(center.x, extent.north + 500, min),
  EXTENT_SIZE * 0.5,
  "yellow",
  EXTENT_SIZE * 0.1,
  EXTENT_SIZE * 0.02,
);

instance.add(northArrow);

northArrow.updateMatrixWorld(true);

const token =
  "pk.eyJ1IjoidG11Z3VldCIsImEiOiJjbGJ4dTNkOW0wYWx4M25ybWZ5YnpicHV6In0.KhDJ7W5N3d1z3ArrsDjX_A";

const elevationLayer = new ElevationLayer({
  extent,
  preloadImages: true,
  minmax: { min, max },
  colorMap: new ColorMap({ colors: makeColorRamp("turbidity"), min, max }),
  source: new TiledImageSource({
    extent,
    format: new MapboxTerrainFormat(),
    source: new XYZ({
      projection: "EPSG:3857",
      url: `https://api.mapbox.com/v4/mapbox.terrain-rgb/{z}/{x}/{y}.pngraw?access_token=${token}`,
    }),
  }),
});
map.addLayer(elevationLayer).catch(console.error);

const colorLayer = new ColorLayer({
  extent,
  preloadImages: true,
  source: new TiledImageSource({
    extent,
    source: new XYZ({
      projection: "EPSG:3857",
      url: `https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.webp?access_token=${token}`,
    }),
  }),
});
map.addLayer(colorLayer).catch(console.error);

instance.view.camera.position.set(-12254256, 4417664, 9400);

const controls = new MapControls(instance.view.camera, instance.domElement);
controls.target.set(center.x, center.y, 1600);
controls.saveState();
controls.enableDamping = true;
controls.dampingFactor = 0.2;
instance.view.setControls(controls);

// Light & shadow management
const lightParams = {
  zenith: 45,
  azimuth: 315,
  shadowBias: -0.0001,
  normalBias: 0,

  shadowMapType: VSMShadowMap,
  enableShadows: true,

  lightType: "directional",
  shadowIntensity: 1,
  distance: EXTENT_SIZE * 4,
  directionalLightIntensity: 3,
  pointLightIntensity: 20000000,
  ambientIntensity: 0.5,
  shadowVolumeSize: EXTENT_SIZE,
  shadowVolumeNear: 50000,
  shadowVolumeFar: 150000,
  shadowMapResolution: 512,
  color: new Color("white"),
  ambientColor: new Color("white"),
  showHelpers: false,
};

instance.renderer.shadowMap.enabled = true;
instance.renderer.shadowMap.type = BasicShadowMap;

let directionalLight;

let pointLight;

let pointLightHelper;

let directionalLightHelper;

let shadowCameraHelper;

const ambientLight = new AmbientLight("#dbf1ff", 0.5);

instance.add(ambientLight);

const createLights = () => {
  if (directionalLight) {
    directionalLight.target.removeFromParent();
    directionalLight.dispose();
    directionalLight.removeFromParent();
  }

  if (directionalLightHelper) {
    directionalLightHelper.dispose();
    directionalLightHelper.removeFromParent();
  }

  if (shadowCameraHelper) {
    shadowCameraHelper.dispose();
    shadowCameraHelper.removeFromParent();
  }

  if (pointLight) {
    pointLight.dispose();
    pointLight.removeFromParent();
  }

  if (pointLightHelper) {
    pointLightHelper.dispose();
    pointLightHelper.removeFromParent();
  }

  directionalLight = new DirectionalLight(
    lightParams.color,
    lightParams.directionalLightIntensity,
  );

  instance.add(directionalLight);
  instance.add(directionalLight.target);

  directionalLight.name = "sunlight";
  directionalLight.target.name = "sunlight target";

  directionalLight.castShadow = true;
  directionalLight.position.set(center.x, center.y, lightParams.distance);
  directionalLight.target.position.set(center.x, center.y, 2200);

  const size = lightParams.shadowMapResolution;
  directionalLight.shadow.mapSize.set(size, size);

  directionalLight.shadow.bias = lightParams.shadowBias;
  directionalLight.shadow.normalBias = lightParams.normalBias;
  directionalLight.shadow.intensity = lightParams.shadowIntensity;

  instance.renderer.shadowMap.type = lightParams.shadowMapType;

  directionalLight.shadow.camera.top = lightParams.shadowVolumeSize;
  directionalLight.shadow.camera.bottom = -lightParams.shadowVolumeSize;
  directionalLight.shadow.camera.left = -lightParams.shadowVolumeSize;
  directionalLight.shadow.camera.right = lightParams.shadowVolumeSize;
  directionalLight.shadow.camera.near = lightParams.shadowVolumeNear;
  directionalLight.shadow.camera.far = lightParams.shadowVolumeFar;

  directionalLight.updateMatrixWorld(true);
  directionalLight.target.updateMatrixWorld(true);

  directionalLight.shadow.updateMatrices(directionalLight);

  directionalLightHelper = new DirectionalLightHelper(
    directionalLight,
    200,
    lightParams.color,
  );
  instance.add(directionalLightHelper);

  shadowCameraHelper = new CameraHelper(directionalLight.shadow.camera);
  instance.add(shadowCameraHelper);

  pointLight = new PointLight(lightParams.color, 20_000_000, 4000);
  pointLight.castShadow = true;
  pointLight.shadow.bias = lightParams.shadowBias;
  pointLight.shadow.normalBias = lightParams.normalBias;
  pointLight.shadow.intensity = lightParams.shadowIntensity;
  pointLight.shadow.camera.near = 1;
  pointLight.shadow.camera.far = 10000;
  pointLight.shadow.mapSize.set(size, size);
  pointLight.position.set(center.x, center.y, min + 400);

  pointLight.updateMatrixWorld(true);

  pointLightHelper = new PointLightHelper(pointLight, 200, "black");
  instance.add(pointLightHelper);
  pointLightHelper.updateMatrixWorld(true);

  instance.add(pointLight);

  updateLightsAndHelpers();
};

createLights();

// Example GUI

function updatePointLight() {
  pointLight.visible = lightParams.lightType === "point";

  pointLight.intensity = lightParams.pointLightIntensity;
  pointLight.shadow.intensity = lightParams.shadowIntensity;
  pointLightHelper.visible = pointLight.visible && lightParams.showHelpers;

  instance.notifyChange();
}

function updateDirectionalLight() {
  const pos = Sun.getLocalPosition({
    point: center,
    zenith: lightParams.zenith,
    azimuth: lightParams.azimuth,
    distance: lightParams.distance,
  });

  directionalLight.position.copy(pos);

  directionalLight.updateMatrixWorld(true);
  directionalLight.target.updateMatrixWorld(true);

  directionalLight.shadow.bias = lightParams.shadowBias;
  directionalLight.shadow.normalBias = lightParams.normalBias;
  directionalLight.shadow.intensity = lightParams.shadowIntensity;

  directionalLight.shadow.camera.top = lightParams.shadowVolumeSize;
  directionalLight.shadow.camera.bottom = -lightParams.shadowVolumeSize;
  directionalLight.shadow.camera.left = -lightParams.shadowVolumeSize;
  directionalLight.shadow.camera.right = lightParams.shadowVolumeSize;
  directionalLight.shadow.camera.near = lightParams.shadowVolumeNear;
  directionalLight.shadow.camera.far = lightParams.shadowVolumeFar;

  directionalLight.shadow.camera.updateProjectionMatrix();
  directionalLight.shadow.camera.updateMatrix();

  directionalLightHelper.update();
  directionalLightHelper.updateMatrixWorld(true);

  shadowCameraHelper.update();
  shadowCameraHelper.updateMatrixWorld(true);

  directionalLight.intensity = lightParams.directionalLightIntensity;

  directionalLight.visible = lightParams.lightType === "directional";
  shadowCameraHelper.visible =
    directionalLight.visible && lightParams.showHelpers;
  directionalLightHelper.visible =
    directionalLight.visible && lightParams.showHelpers;

  instance.notifyChange();
}

function updateLightsAndHelpers() {
  updateDirectionalLight();
  updatePointLight();

  northArrow.visible = lightParams.showHelpers;

  instance.notifyChange();
}

const [setColorLayerToggle] = bindToggle("colorLayers", (state) => {
  map.lighting.elevationLayersOnly = !state;
  instance.notifyChange(map);
});

const [setAzimuth] = bindSlider("azimuth", (azimuth) => {
  map.lighting.hillshadeAzimuth = azimuth;
  lightParams.azimuth = azimuth;
  updateLightsAndHelpers();
  updateLabel("azimuth-label", `Azimuth: ${Math.round(azimuth)}°`);
  instance.notifyChange(map);
});

const [setZenith] = bindSlider("zenith", (zenith) => {
  map.lighting.hillshadeZenith = zenith;
  lightParams.zenith = zenith;
  updateLightsAndHelpers();
  updateLabel("zenith-label", `Zenith: ${Math.round(zenith)}°`);
  instance.notifyChange(map);
});

const [setLightColor] = bindColorPicker("color", (v) => {
  lightParams.color = new Color(v);
  directionalLight.color = lightParams.color;
  pointLight.color = lightParams.color;
  instance.notifyChange();
});

const [setAmbientColor] = bindColorPicker("ambient-color", (v) => {
  lightParams.ambientColor = new Color(v);
  ambientLight.color = lightParams.ambientColor;
  instance.notifyChange();
});

const [setShadowMapResolution] = bindSlider("shadow-map-resolution", (size) => {
  lightParams.shadowMapResolution = size;

  createLights();

  instance.notifyChange();
});

const [setShadowMapBias] = bindSlider("shadow-map-bias", (bias) => {
  lightParams.shadowBias = bias;

  updateLightsAndHelpers();

  instance.notifyChange();
});

const [setShadowMapNormalBias] = bindSlider(
  "shadow-map-normal-bias",
  (bias) => {
    lightParams.normalBias = bias;

    updateLightsAndHelpers();

    instance.notifyChange();
  },
);

const [setShadowVolumeSize] = bindSlider("shadow-volume-size", (size) => {
  lightParams.shadowVolumeSize = size;

  updateLightsAndHelpers();

  instance.notifyChange();
});

const [setLightType] = bindDropDown("light-type", (type) => {
  lightParams.lightType = type;

  document.getElementById("point-light-params").style.display =
    lightParams.lightType === "point" ? "block" : "none";
  document.getElementById("directional-light-params").style.display =
    lightParams.lightType === "directional" ? "block" : "none";

  updateLightsAndHelpers();
});

const [setEnableShadows] = bindToggle("enable-shadows", (v) => {
  lightParams.enableShadows = v;
  directionalLight.castShadow = v;
  pointLight.castShadow = v;

  instance.notifyChange();
});

const [setShadowMapType] = bindNumericalDropDown("shadow-map-type", (type) => {
  lightParams.shadowMapType = type;

  instance.renderer.shadowMap.type = type;
});

const [setMode] = bindNumericalDropDown("mode", (newMode) => {
  const simpleGroup = document.getElementById("simpleGroup");
  const realisticGroup = document.getElementById("realisticGroup");
  const shadingGroup = document.getElementById("shadingParams");
  const shadowGroup = document.getElementById("group-shadows");
  const noShadowGroup = document.getElementById("group-noshadows");

  switch (newMode) {
    case -1:
      map.lighting.enabled = false;
      shadingGroup.style.display = "none";
      shadowGroup.style.display = "none";
      noShadowGroup.style.display = "block";
      break;
    case MapLightingMode.Hillshade:
      shadingGroup.style.display = "block";
      simpleGroup.style.display = "block";
      realisticGroup.style.display = "none";
      map.lighting.enabled = true;
      map.lighting.mode = MapLightingMode.Hillshade;
      shadowGroup.style.display = "none";
      noShadowGroup.style.display = "block";
      break;
    case MapLightingMode.LightBased:
      shadingGroup.style.display = "block";
      simpleGroup.style.display = "none";
      realisticGroup.style.display = "block";
      map.lighting.enabled = true;
      map.lighting.mode = MapLightingMode.LightBased;
      shadowGroup.style.display = "block";
      noShadowGroup.style.display = "none";
      break;
  }

  instance.notifyChange(map);
});

const [setOpacity, , opacitySlider] = bindSlider("opacity", (percentage) => {
  const opacity = percentage / 100.0;
  colorLayer.opacity = opacity;
  instance.notifyChange(map);
  opacitySlider.innerHTML = `${percentage}%`;
});

const [setIntensity] = bindSlider("intensity", (intensity) => {
  map.lighting.hillshadeIntensity = intensity;
  instance.notifyChange(map);
});

const [setDirectionalLightIntensity] = bindSlider(
  "directional-light-intensity",
  (v) => {
    lightParams.directionalLightIntensity = v;
    directionalLight.intensity = v;
    instance.notifyChange();
  },
);

const [setPointLightIntensity] = bindSlider("point-light-intensity", (v) => {
  lightParams.pointLightIntensity = v;
  pointLight.intensity = v;
  instance.notifyChange();
});

const [setShadowVolumeNear] = bindSlider("shadow-volume-near", (v) => {
  lightParams.shadowVolumeNear = v;
  directionalLight.shadow.camera.near = v;
  updateLightsAndHelpers();
  instance.notifyChange();
});

const [setShadowVolumeFar] = bindSlider("shadow-volume-far", (v) => {
  lightParams.shadowVolumeFar = v;
  directionalLight.shadow.camera.far = v;
  updateLightsAndHelpers();
  instance.notifyChange();
});

const [setAmbientIntensity] = bindSlider("ambient-intensity", (v) => {
  lightParams.ambientIntensity = v;
  ambientLight.intensity = v;
  instance.notifyChange();
});

const [setShadowIntensity] = bindSlider("shadow-map-intensity", (v) => {
  lightParams.shadowIntensity = v;
  updateLightsAndHelpers();
  instance.notifyChange();
});

const [setZFactor] = bindSlider("zFactor", (zFactor) => {
  map.lighting.zFactor = zFactor;
  instance.notifyChange(map);
});

const [setShowHelpers] = bindToggle("show-helpers", (enabled) => {
  lightParams.showHelpers = enabled;
  updateLightsAndHelpers();
  instance.notifyChange();
});

const cubes = [];

const reset = () => {
  cubes.forEach((c) => {
    c.geometry.dispose();
    c.material.dispose();
    c.removeFromParent();
  });

  setColorLayerToggle(true);
  setLightColor("white");
  setAmbientColor("white");
  setLightType("directional");
  setIntensity(1);
  setEnableShadows(true);
  setShadowMapType(VSMShadowMap);
  setShadowVolumeSize(EXTENT_SIZE);
  setShadowMapResolution(4096);
  setShadowMapBias(-0.0001);
  setShadowMapNormalBias(0);
  setShadowVolumeNear(50000);
  setShadowVolumeFar(150000);
  setDirectionalLightIntensity(5);
  setPointLightIntensity(20000000);
  setAmbientIntensity(1);
  setShadowIntensity(1);
  setZFactor(1);
  setOpacity(100);
  setMode(MapLightingMode.LightBased);
  setShowHelpers(false);
  setAzimuth(252);
  setZenith(71);

  updateLightsAndHelpers();
};

bindButton("reset", () => {
  reset();
});

bindButton("create-cube", (btn) => {
  btn.disabled = true;

  const size = Math.random() * 500 + 100;
  const cube = new Mesh(
    new BoxGeometry(size, size, size),
    new MeshStandardMaterial({
      color: new Color().setHSL(Math.random(), 0.5, 0.5),
    }),
  );
  cube.castShadow = true;
  cube.receiveShadow = true;

  cube.material.opacity = 0.5;
  cube.material.transparent = true;

  instance.add(cube);
  cubes.push(cube);

  const onMouseMove = (e) => {
    const picked = instance.pickObjectsAt(e, {
      sortByDistance: true,
      filter: (p) => p.object !== cube,
    })[0];
    if (picked) {
      const { x, y, z } = picked.point;

      cube.position.set(x, y, z + size / 2);
      cube.updateMatrixWorld(true);

      instance.notifyChange();
    }
  };

  instance.domElement.addEventListener("mousemove", onMouseMove);

  instance.domElement.addEventListener("mousedown", (e) => {
    cube.material.opacity = 1;
    cube.material.transparent = false;

    btn.disabled = false;

    instance.domElement.removeEventListener("mousemove", onMouseMove);

    instance.notifyChange();
  });
});

reset();

instance.domElement.addEventListener("mousemove", (e) => {
  const picked = instance.pickObjectsAt(e, { sortByDistance: true })[0];
  if (picked) {
    const { x, y, z } = picked.point;

    pointLight.position.set(x, y, z + 200);
    pointLight.updateMatrixWorld(true);
    pointLightHelper.update();
    pointLightHelper.updateMatrixWorld(true);

    instance.notifyChange();
  }
});

Inspector.attach("inspector", instance);
index.html
<!doctype html>
<html lang="en">
  <head>
    <title>Physical lights and shadow maps</title>
    <meta charset="UTF-8" />
    <meta name="name" content="map_shadows" />
    <meta
      name="description"
      content="Illustrates the use of three.js physically accurate lights and shadows on &lt;code&gt;Map&lt;/code&gt;s."
    />
    <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/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" style="width: 20rem">
      <!--Parameters -->
      <div class="card-body">
        <!-- Accordion -->
        <div class="accordion" id="accordion">
          <!-- Section: map -->
          <div class="accordion-item">
            <h2 class="accordion-header">
              <button
                class="accordion-button"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#section-map"
                aria-expanded="true"
                aria-controls="section-map"
              >
                Options
              </button>
            </h2>
            <div
              id="section-map"
              class="accordion-collapse collapse show p-3"
              data-bs-parent="#accordion"
            >
              <button type="button" id="reset" class="btn btn-warning w-100">
                <i class="bi bi-trash"></i>
                Reset scene
              </button>

              <button
                type="button"
                id="create-cube"
                class="btn btn-primary w-100 mt-2"
              >
                <i class="bi bi-box"></i>
                Create cube
              </button>

              <!--  Shade color layers toggle -->
              <div class="form-check form-switch mt-2">
                <input
                  class="form-check-input"
                  type="checkbox"
                  checked="true"
                  role="switch"
                  id="colorLayers"
                  autocomplete="off"
                />
                <label class="form-check-label" for="colorLayers"
                  >Shade color layers</label
                >
              </div>

              <!--  Show helpers -->
              <div class="form-check form-switch mt-2">
                <input
                  class="form-check-input"
                  type="checkbox"
                  role="switch"
                  id="show-helpers"
                  autocomplete="off"
                />
                <label class="form-check-label" for="show-helpers"
                  >Show helpers</label
                >
              </div>

              <label for="opacity" class="form-label mt-2"
                >Color layer opacity</label
              >
              <input
                type="range"
                min="0"
                max="100"
                value="100"
                class="form-range"
                id="opacity"
                autocomplete="off"
              />
            </div>
          </div>

          <!-- Section: lights -->
          <div class="accordion-item">
            <h2 class="accordion-header">
              <button
                class="accordion-button"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#section-lights"
                aria-expanded="true"
                aria-controls="section-lights"
              >
                Lights
              </button>
            </h2>
            <div
              id="section-lights"
              class="accordion-collapse collapse show p-3"
              data-bs-parent="#accordion"
            >
              <!-- Shading mode -->
              <div class="input-group">
                <label class="input-group-text" for="mode">Shading model</label>
                <select class="form-select" id="mode" autocomplete="off">
                  <option value="-1">Disabled</option>
                  <option value="0">Hillshading</option>
                  <option value="1" selected>Light-based</option>
                </select>
              </div>

              <div id="shadingParams">
                <!-- Azimuth slider -->
                <label id="azimuth-label" for="azimuth" class="form-label mt-2"
                  >Azimuth: 315°</label
                >
                <input
                  type="range"
                  min="0"
                  max="360"
                  step="1"
                  value="315"
                  class="form-range"
                  id="azimuth"
                  autocomplete="off"
                />

                <!-- Zenith slider -->
                <label id="zenith-label" for="zenith" class="form-label mt-2"
                  >Zenith: 45°</label
                >
                <input
                  type="range"
                  min="0.1"
                  step="1"
                  max="90"
                  value="45"
                  class="form-range"
                  id="zenith"
                  autocomplete="off"
                />

                <!-- Z-factor -->
                <label for="zFactor" class="form-label mt-2">Z-factor</label>
                <input
                  type="range"
                  min="0"
                  max="10"
                  value="1"
                  step="0.1"
                  class="form-range"
                  id="zFactor"
                  autocomplete="off"
                />

                <div id="simpleGroup">
                  <!-- Simple shading intensity -->
                  <label for="intensity" class="form-label"
                    >Shade opacity</label
                  >
                  <input
                    type="range"
                    min="0"
                    max="1"
                    value="1"
                    step="0.1"
                    class="form-range"
                    id="intensity"
                    autocomplete="off"
                  />
                </div>

                <!-- Group for light-based settings -->
                <div id="realisticGroup">
                  <!-- Light type (only if realistic mode) -->
                  <div class="input-group mt-2">
                    <label class="input-group-text" for="light-type"
                      >Light type</label
                    >
                    <select
                      class="form-select"
                      id="light-type"
                      autocomplete="off"
                    >
                      <option value="directional" selected>Directional</option>
                      <option value="point">Point</option>
                    </select>
                  </div>

                  <!-- Directional light intensity -->
                  <div class="mt-2" id="directional-light-params">
                    <label for="directional-light-intensity" class="form-label"
                      >Light intensity</label
                    >
                    <input
                      type="number"
                      min="0"
                      max="99"
                      step="0.01"
                      value="3"
                      class="form-control"
                      id="directional-light-intensity"
                      autocomplete="off"
                    />
                  </div>

                  <!-- Point light intensity -->
                  <div class="mt-2" id="point-light-params">
                    <label for="point-light-intensity" class="form-label"
                      >Light intensity</label
                    >
                    <input
                      type="number"
                      min="0"
                      max="20000000"
                      step="1000"
                      value="20000000"
                      class="form-control"
                      id="point-light-intensity"
                      autocomplete="off"
                    />
                  </div>

                  <!-- Light color -->
                  <label for="color" class="form-label mt-2">Light color</label>
                  <input
                    type="color"
                    class="form-control form-control-color w-100"
                    id="color"
                    value="#ffffff"
                    title="color"
                  />

                  <!-- Ambient light intensity -->
                  <label for="ambient-intensity" class="form-label mt-2"
                    >Ambient intensity</label
                  >
                  <input
                    type="number"
                    min="0"
                    max="2"
                    step="0.5"
                    value="0.5"
                    class="form-control"
                    id="ambient-intensity"
                    autocomplete="off"
                  />

                  <!-- Ambient light color -->
                  <label for="ambient-color" class="form-label mt-2"
                    >Ambient color</label
                  >
                  <input
                    type="color"
                    class="form-control form-control-color w-100"
                    id="ambient-color"
                    value="#ffffff"
                    title="color"
                  />
                </div>
              </div>
            </div>
          </div>

          <!-- Section: shadows -->
          <div class="accordion-item">
            <h2 class="accordion-header">
              <button
                class="accordion-button"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#section-shadows"
                aria-expanded="false"
                aria-controls="section-shadows"
              >
                Shadows
              </button>
            </h2>

            <div
              id="section-shadows"
              class="accordion-collapse collapse p-3"
              data-bs-parent="#accordion"
            >
              <div id="group-shadows">
                <!--  Toggle shadows -->
                <div class="form-check form-switch">
                  <input
                    class="form-check-input"
                    type="checkbox"
                    checked="true"
                    role="switch"
                    id="enable-shadows"
                    autocomplete="off"
                  />
                  <label class="form-check-label" for="enable-shadows"
                    >Enable shadows</label
                  >
                </div>

                <!-- Shadow type -->
                <div class="input-group mt-2">
                  <label class="input-group-text" for="shadow-map-type"
                    >Shadow type</label
                  >

                  <select
                    class="form-select"
                    id="shadow-map-type"
                    autocomplete="off"
                  >
                    <option value="0">BasicShadowMap</option>
                    <option value="1">PCFShadowMap</option>
                    <option value="2">PCFSoftShadowMap</option>
                    <option value="3" selected>VSMShadowMap</option>
                  </select>
                </div>

                <!-- Shadow map texture size -->
                <label for="shadow-map-resolution" class="form-label mt-2"
                  >Texture size</label
                >
                <input
                  type="number"
                  min="64"
                  max="4096"
                  value="4096"
                  class="form-control"
                  id="shadow-map-resolution"
                  autocomplete="off"
                />

                <!-- Shadow map camera volume size (meters) -->
                <label for="shadow-volume-size" class="form-label mt-2"
                  >Volume size</label
                >
                <input
                  type="number"
                  min="100"
                  max="100000"
                  value="100000"
                  class="form-control"
                  id="shadow-volume-size"
                  autocomplete="off"
                />

                <!-- Shadow volume near plane -->
                <label for="shadow-volume-near" class="form-label mt-2"
                  >Camera near plane</label
                >
                <input
                  type="number"
                  min="100"
                  max="100000"
                  value="5000"
                  class="form-control"
                  id="shadow-volume-near"
                  autocomplete="off"
                />

                <!-- Shadow volume near plane -->
                <label for="shadow-volume-far" class="form-label mt-2"
                  >Camera far plane</label
                >
                <input
                  type="number"
                  min="100"
                  max="100000"
                  value="50000"
                  class="form-control"
                  id="shadow-volume-far"
                  autocomplete="off"
                />

                <!-- Shadow intensity -->
                <label for="shadow-map-intensity" class="form-label mt-2"
                  >Intensity</label
                >
                <input
                  type="range"
                  min="0"
                  max="1"
                  value="1"
                  step="0.01"
                  class="form-range"
                  id="shadow-map-intensity"
                  autocomplete="off"
                />

                <!-- Shadow map bias -->
                <label for="shadow-map-bias" class="form-label mt-2"
                  >Bias</label
                >
                <input
                  type="number"
                  min="-0.01"
                  max="0.01"
                  step="0.0001"
                  value="-0.0001"
                  class="form-control"
                  id="shadow-map-bias"
                  autocomplete="off"
                />

                <!-- Shadow map normal bias -->
                <label for="shadow-map-normal-bias" class="form-label mt-2"
                  >Normal bias</label
                >
                <input
                  type="number"
                  min="-10"
                  max="10"
                  step="0.1"
                  value="0"
                  class="form-control"
                  id="shadow-map-normal-bias"
                  autocomplete="off"
                />
              </div>

              <div id="group-noshadows" style="display: none">
                Shadows are only available in light-based shading mode
              </div>
            </div>
          </div>
        </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_shadows",
    "dependencies": {
        "@giro3d/giro3d": "git+https://gitlab.com/giro3d/giro3d.git"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}