Skip to content

Interior Explore

Result

Learn how to explore the interior of 3D Tiles buildings while controlling a character with PersonViewPlugin from @navaramap/three_plugins. The plugin lets you wire up character controls with minimal code.

What you will learn in this tutorial:

  • Loading 3D Tiles building models
  • Driving a GLTF character with PersonViewPlugin
  • Letting the character move underground and through buildings
  • Switching between third-person and first-person view
  • Teleporting the character when the scene changes

First, build a scene for building exploration. Create a ThreeView with shadow and background color settings.

import ThreeView, { Color, JAPAN_GSI_ELEVATION_DECODER } from "@navaramap/three";
import {
DefaultPlugin,
type DefaultDescriptions,
} from "@navaramap/three_default_plugin";
import { PersonViewPlugin } from "@navaramap/three_plugins";
const plugin = new DefaultPlugin();
const view = new ThreeView<DefaultDescriptions>({
shadow: true,
backgroundColor: new Color().setStyle("#475668"),
});
view.addPlugin(plugin);

We will register the PersonViewPlugin next, before calling view.init().

The plugin handles the character (model, animation, movement) and the camera. Set minAlt to a negative value so the character can walk below ground level, and choose a tight cameraDistance that works inside a building.

const startLat = 35.6341630282;
const startLng = 139.7420527162;
const startHeight = 23.0;
const startHeading = Math.PI * 1.6;
const personView = new PersonViewPlugin({
character: {
modelUrl: "/Soldier.glb",
animation: {
idleClip: "Idle",
walkClip: "Walk",
dashClip: "Run",
speed: 1.0,
crossfadeDuration: 0.3,
},
modelRotationOffset: { x: Math.PI / 2, y: 0, z: 0 },
modelScale: 1,
castShadow: true,
receiveShadow: true,
},
moveSpeed: 5,
altSpeed: 5,
rotationSpeed: 2,
cameraDistance: 10,
cameraLerpSpeed: 4,
minAlt: -1000,
maxAlt: 5000,
startLat,
startLng,
startHeight,
startHeading,
allowCameraControl: true,
});
view.addPlugin(personView);
await view.init();
view.atmosphere.date.setHours(8);
view.toneMappingExposure = 10;
const layers = plugin.addDefaultPhotorealScene();
layers.sun.update({ sun: { castShadow: true } });

Add terrain and satellite imagery tiles for the exploration area. Turn off the terrain skirt so the underground portion of buildings stays visible.

const terrainSource = view.addSource({
type: "raster-dem",
// Credit:
// - Geospatial Information Authority of Japan Tiles - Digital Elevation Map
// https://maps.gsi.go.jp/development/ichiran.html
url: "https://cyberjapandata.gsi.go.jp/xyz/dem_png/{z}/{x}/{y}.png",
elevationDecoder: JAPAN_GSI_ELEVATION_DECODER(),
minZoom: 6,
maxZoom: 15,
});
view.addLayer({
type: "terrain",
source: terrainSource,
terrain: {
castShadow: true,
receiveShadow: true,
skirt: false,
},
});
const hillshadeSource = view.addSource({
type: "raster-dem",
// Credit:
// - Geospatial Information Authority of Japan Tiles - Digital Elevation Map
// https://maps.gsi.go.jp/development/ichiran.html
url: "https://cyberjapandata.gsi.go.jp/xyz/dem_png/{z}/{x}/{y}.png",
elevationDecoder: JAPAN_GSI_ELEVATION_DECODER(),
minZoom: 6,
maxZoom: 15,
});
view.addLayer({
type: "raster",
source: hillshadeSource,
hillshade: {},
});
const photoSource = view.addSource({
type: "raster-tile",
// Credit:
// - Geospatial Information Authority of Japan Tiles - Latest Nationwide Photo (Seamless)
// https://maps.gsi.go.jp/development/ichiran.html
url: "https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg",
maxZoom: 18,
});
view.addLayer({
type: "raster",
source: photoSource,
});

Load a Cesium 3D Tiles building such as those published by PLATEAU and enable shadows so the interior reads as a real space.

const buildingSource = view.addSource({
type: "3d-tiles",
// Credit:
// - [UC23-11] Advanced Area Management Using Storytelling GIS - MLIT PLATEAU
// https://www.geospatial.jp/ckan/dataset/plateau-uc23-11
url: "https://assets.cms.plateau.reearth.io/assets/c1/28f9ff-e9d0-44df-b092-88ac7ebdfa42/tngw_4gaiku/tileset.json",
});
view.addLayer({
type: "3d-tiles",
source: buildingSource,
model: {
show: true,
castShadow: true,
receiveShadow: true,
height: -35, // Ellipsoidal height adjustment
},
});

Once the scene is configured, start the plugin. This loads the GLTF model and begins the per-frame loop that drives the character and camera.

personView.start();

That’s it — the character now responds to the keyboard and the camera follows it.

Default key bindings

KeyAction
W / SMove forward / backward
A / DTurn left / right
Arrow Up / SpaceAscend
Arrow Down / CtrlDescend
ShiftDash (switches to dashClip)
Alt (hold)Free-orbit camera
VToggle third-person / first-person

The default chase view (TPV) is third person. Press V to switch to first person (FPV); the character mesh is hidden automatically. Hold Alt to take manual control of the camera while keeping it focused on the character.

The plugin emits the current geographic position, heading, speed, and view mode on every frame. Use onStateChange() to drive UI such as a HUD or a minimap.

const unsubscribe = personView.onStateChange((state) => {
console.log(state.lat, state.lng, state.alt, state.heading, state.mode);
});
// Later — when you are done
unsubscribe();

Use teleport({ lng, lat, alt, heading? }) to jump the character to a new place — for example when the user picks a different building from a menu. To rotate in place use setHeading(), and to adjust the camera pitch use setCameraPitch() / setFpvPitch().

personView.teleport({ lng: 139.7397, lat: 35.6352, alt: 45 });

The chase camera snaps to the new location and the state listener fires once with the updated position.

A complete example that combines the plugin with a 3D Tiles building. The code is intentionally short: the plugin owns the input, character, animation, and camera.

import ThreeView, { Color, JAPAN_GSI_ELEVATION_DECODER } from "@navaramap/three";
import {
DefaultPlugin,
type DefaultDescriptions,
} from "@navaramap/three_default_plugin";
import { PersonViewPlugin } from "@navaramap/three_plugins";
const plugin = new DefaultPlugin();
const view = new ThreeView<DefaultDescriptions>({
shadow: true,
backgroundColor: new Color().setStyle("#475668"),
});
const startLat = 35.6341630282;
const startLng = 139.7420527162;
const startHeight = 23.0;
const startHeading = Math.PI * 1.6;
const personView = new PersonViewPlugin({
character: {
modelUrl: "/Soldier.glb",
animation: {
idleClip: "Idle",
walkClip: "Walk",
dashClip: "Run",
speed: 1.0,
crossfadeDuration: 0.3,
},
modelRotationOffset: { x: Math.PI / 2, y: 0, z: 0 },
modelScale: 1,
castShadow: true,
receiveShadow: true,
},
moveSpeed: 5,
altSpeed: 5,
rotationSpeed: 2,
cameraDistance: 10,
cameraLerpSpeed: 4,
minAlt: -1000,
maxAlt: 5000,
startLat,
startLng,
startHeight,
startHeading,
allowCameraControl: true,
});
view.addPlugin(plugin);
view.addPlugin(personView);
await view.init();
view.atmosphere.date.setHours(8);
view.toneMappingExposure = 10;
const layers = plugin.addDefaultPhotorealScene();
layers.sun.update({ sun: { castShadow: true } });
const terrainSource = view.addSource({
type: "raster-dem",
// Credit:
// - Geospatial Information Authority of Japan Tiles - Digital Elevation Map
// https://maps.gsi.go.jp/development/ichiran.html
url: "https://cyberjapandata.gsi.go.jp/xyz/dem_png/{z}/{x}/{y}.png",
elevationDecoder: JAPAN_GSI_ELEVATION_DECODER(),
minZoom: 6,
maxZoom: 15,
});
view.addLayer({
type: "terrain",
source: terrainSource,
terrain: {
castShadow: true,
receiveShadow: true,
skirt: false,
},
});
const hillshadeSource = view.addSource({
type: "raster-dem",
// Credit:
// - Geospatial Information Authority of Japan Tiles - Digital Elevation Map
// https://maps.gsi.go.jp/development/ichiran.html
url: "https://cyberjapandata.gsi.go.jp/xyz/dem_png/{z}/{x}/{y}.png",
elevationDecoder: JAPAN_GSI_ELEVATION_DECODER(),
minZoom: 6,
maxZoom: 15,
});
view.addLayer({
type: "raster",
source: hillshadeSource,
hillshade: {},
});
const photoSource = view.addSource({
type: "raster-tile",
// Credit:
// - Geospatial Information Authority of Japan Tiles - Latest Nationwide Photo (Seamless)
// https://maps.gsi.go.jp/development/ichiran.html
url: "https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg",
maxZoom: 18,
});
view.addLayer({
type: "raster",
source: photoSource,
});
const buildingSource = view.addSource({
type: "3d-tiles",
// Credit:
// - [UC23-11] Advanced Area Management Using Storytelling GIS - MLIT PLATEAU
// https://www.geospatial.jp/ckan/dataset/plateau-uc23-11
url: "https://assets.cms.plateau.reearth.io/assets/c1/28f9ff-e9d0-44df-b092-88ac7ebdfa42/tngw_4gaiku/tileset.json",
});
view.addLayer({
type: "3d-tiles",
source: buildingSource,
model: {
show: true,
castShadow: true,
receiveShadow: true,
height: -35,
},
});
personView.start();