Illustrates the use of the camera-controls plugin.

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
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
Cast shadow
Receive shadow
Error target
Point size
Brightness
Contrast
Saturation
Enabled
Mode
Elevation
Lower bound
Upper bound
Layers
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
Enabled
Active
x
y
z
Distance
Polar angle
Azimuth angle
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 {
  Box3,
  Clock,
  CubeTextureLoader,
  Matrix4,
  Quaternion,
  Raycaster,
  Sphere,
  Spherical,
  Vector2,
  Vector3,
  Vector4,
} from "three";

import CameraControls from "camera-controls";

import Instance from "@giro3d/giro3d/core/Instance.js";
import ColorLayer from "@giro3d/giro3d/core/layer/ColorLayer.js";
import Tiles3D from "@giro3d/giro3d/entities/Tiles3D.js";
import Inspector from "@giro3d/giro3d/gui/Inspector.js";
import Panel from "@giro3d/giro3d/gui/Panel.js";
import { MODE } from "@giro3d/giro3d/renderer/PointCloudMaterial.js";
import WmtsSource from "@giro3d/giro3d/sources/WmtsSource.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 pointcloud = new Tiles3D({
  url: "https://3d.oslandia.com/3dtiles/lyon.3dtiles/tileset.json",
  pointCloudMode: MODE.TEXTURE,
  errorTarget: 15,
});

instance.add(pointcloud).then((pc) => {
  const url =
    "https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetCapabilities";

  // Let's build the color layer from the WMTS capabilities
  WmtsSource.fromCapabilities(url, {
    layer: "HR.ORTHOIMAGERY.ORTHOPHOTOS",
  })
    .then((orthophotoWmts) => {
      pc.setColorLayer(
        new ColorLayer({
          name: "color",
          source: orthophotoWmts,
        }),
      );
    })
    .catch(console.error);
});

// 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.42.4"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}