Illustrates the use of the camera-controls plugin.

100% © IGN

The camera-controls is a powerful navigation controller for three.js that supports touch and mouse/keyboard-based navigation and is highly configurable.

index.js
import {
  Clock,
  CubeTextureLoader,
  Vector3,
  Vector2,
  Vector4,
  Quaternion,
  Matrix4,
  Spherical,
  Box3,
  Sphere,
  Raycaster,
} from "three";

import CameraControls from "camera-controls";

import Instance from "@giro3d/giro3d/core/Instance.js";
import Tiles3D from "@giro3d/giro3d/entities/Tiles3D.js";
import ColorLayer from "@giro3d/giro3d/core/layer/ColorLayer.js";
import PointCloudMaterial, {
  MODE,
} from "@giro3d/giro3d/renderer/PointCloudMaterial.js";
import Tiles3DSource from "@giro3d/giro3d/sources/Tiles3DSource.js";
import Inspector from "@giro3d/giro3d/gui/Inspector.js";
import Panel from "@giro3d/giro3d/gui/Panel.js";
import WmsSource from "@giro3d/giro3d/sources/WmsSource.js";

CameraControls.install({
  THREE: {
    Vector2,
    Vector3,
    Vector4,
    Quaternion,
    Matrix4,
    Spherical,
    Box3,
    Sphere,
    Raycaster,
  },
});

Instance.registerCRS(
  "EPSG:3946",
  "+proj=lcc +lat_1=45.25 +lat_2=46.75 +lat_0=46 +lon_0=3 +x_0=1700000 " +
    "+y_0=5200000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs",
);

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

const material = new PointCloudMaterial({ size: 4, mode: MODE.TEXTURE });

const pointcloud = new Tiles3D(
  new Tiles3DSource(
    "https://3d.oslandia.com/3dtiles/lyon.3dtiles/tileset.json",
  ),
  { material },
);

const source = new WmsSource({
  url: "https://data.geopf.fr/wms-r",
  projection: "EPSG:3946",
  layer: "HR.ORTHOIMAGERY.ORTHOPHOTOS",
  imageFormat: "image/jpeg",
});

const colorLayer = new ColorLayer({ source });

instance.add(pointcloud).then((pc) => pc.attach(colorLayer));

// Configure our controls
const controls = new CameraControls(instance.view.camera, instance.domElement);

controls.dollyToCursor = true;
controls.verticalDragToForward = true;

controls.mouseButtons.left = CameraControls.ACTION.TRUCK;
controls.mouseButtons.right = CameraControls.ACTION.ROTATE;
controls.mouseButtons.wheel = CameraControls.ACTION.DOLLY;
controls.mouseButtons.middle = CameraControls.ACTION.DOLLY;

const clock = new Clock();

// Update controls from event loop - this replaces the requestAnimationFrame logic from
// camera-controls sample code
instance.addEventListener("before-camera-update", () => {
  // Called from Giro3D
  const delta = clock.getDelta();
  const hasControlsUpdated = controls.update(delta);
  if (hasControlsUpdated) {
    instance.notifyChange(instance.view.camera);
  }
});
// As Giro3D runs the event loop only when needed, we need to notify Giro3D when
// the controls update the view.
// We need both events to make sure the view is updated from user interactions and from animations
controls.addEventListener("update", () =>
  instance.notifyChange(instance.view.camera),
);
controls.addEventListener("control", () =>
  instance.notifyChange(instance.view.camera),
);

// place camera
controls.setLookAt(1842456, 5174330, 735, 1841993, 5175493, 188);

// And now we can add some custom behavior

const executeInteraction = (callback) => {
  // Execute the interaction
  const res = callback() ?? Promise.resolve();

  // As mainloop can pause, before-camera-update can be triggered irregularly
  // Make sure to "reset" the clock to enable smooth transitions with camera-controls
  clock.getDelta();
  // Dispatch events so Giro3D gets notified
  controls.dispatchEvent({ type: "update" });
  return res;
};

// Add some controls on keyboard
const keys = {
  LEFT: "ArrowLeft",
  UP: "ArrowUp",
  RIGHT: "ArrowRight",
  BOTTOM: "ArrowDown",
};
instance.domElement.addEventListener("keydown", (e) => {
  let forwardDirection = 0;
  let truckDirectionX = 0;
  const factor = e.ctrlKey || e.metaKey || e.shiftKey ? 200 : 20;
  switch (e.code) {
    case keys.UP:
      forwardDirection = 1;
      break;

    case keys.BOTTOM:
      forwardDirection = -1;
      break;

    case keys.LEFT:
      truckDirectionX = -1;
      break;

    case keys.RIGHT:
      truckDirectionX = 1;
      break;

    default:
    // do nothing
  }
  if (forwardDirection) {
    executeInteraction(() =>
      controls.forward(forwardDirection * controls.truckSpeed * factor, true),
    );
  }
  if (truckDirectionX) {
    executeInteraction(() =>
      controls.truck(truckDirectionX * controls.truckSpeed * factor, 0, true),
    );
  }
});

// Make rotation around where the user clicked
instance.domElement.addEventListener("contextmenu", (e) => {
  const picked = instance.pickObjectsAt(e, {
    limit: 1,
    radius: 20,
    filter: (p) =>
      // Make sure we pick a valid point
      Number.isFinite(p.point.x) &&
      Number.isFinite(p.point.y) &&
      Number.isFinite(p.point.z),
  })[0];

  if (picked) {
    controls.setOrbitPoint(picked.point.x, picked.point.y, picked.point.z);
  }
});

// add a skybox background
const cubeTextureLoader = new CubeTextureLoader();
cubeTextureLoader.setPath("image/skyboxsun25deg_zup/");
const cubeTexture = cubeTextureLoader.load([
  "px.jpg",
  "nx.jpg",
  "py.jpg",
  "ny.jpg",
  "pz.jpg",
  "nz.jpg",
]);

instance.scene.background = cubeTexture;

const inspector = Inspector.attach("inspector", instance);

class ControlsInspector extends Panel {
  constructor(gui, _instance, _controls) {
    super(gui, _instance, "Controls");

    this.controls = _controls;
    this.target = new Vector3();
    this.controls.getTarget(this.target);

    this.addController(this.controls, "enabled").name("Enabled");
    this.addController(this.controls, "active").name("Active");

    const target = this.gui.addFolder("Target");
    target.close();
    this._controllers.push(target.add(this.target, "x"));
    this._controllers.push(target.add(this.target, "y"));
    this._controllers.push(target.add(this.target, "z"));

    this._eventhandlers = {
      control: () => this.controls.getTarget(this.target),
    };

    this.addController(this.controls, "distance").name("Distance");
    this.addController(this.controls, "polarAngle").name("Polar angle");
    this.addController(this.controls, "azimuthAngle").name("Azimuth angle");

    this.needsUpdate = false;

    this.controls.addEventListener("update", this._eventhandlers.control);
  }

  dispose() {
    this.controls.removeEventListener("update", this._eventhandlers.control);
    super.dispose();
  }
}

const controlsInspector = new ControlsInspector(
  inspector.gui,
  instance,
  controls,
);
inspector.addPanel(controlsInspector);

// Add some animations
document.getElementById("animate").onclick = () => {
  executeInteraction(async () => {
    await controls.rotate((Math.random() - 0.5) * (Math.PI / 2), 0, true);
    await controls.rotatePolarTo(Math.PI / 8, true);
    await controls.dolly((Math.random() - 0.5) * 1000, true);
  });
};
index.html
<!doctype html>
<html lang="en">
  <head>
    <title>Custom controls</title>
    <meta charset="UTF-8" />
    <meta name="name" content="camera_controls" />
    <meta
      name="description"
      content="Illustrates the use of the &lt;code&gt;camera-controls&lt;/code&gt; plugin."
    />
    <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"
    />
  </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>

    <!-- Screenshot's icon from Lucide: https://github.com/lucide-icons/lucide -->
    <div class="side-pane-with-status-bar">
      <button class="btn btn-primary" id="animate">Animate</button>
    </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": "camera_controls",
    "dependencies": {
        "camera-controls": "^2.9.0",
        "@giro3d/giro3d": "0.41.0"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}