🌍 Immersive 3D Maps

Spatialized founder Jozef Sorocin
Book a consultation ↗
36 min read  •  Updated 01/19/2026

The previous chapter covered WebGL and deck.gl for 2D overlays. This chapter adds the third dimension.

An emergency response dashboard with Google's 3D Tiles, atmospheric sky, and volumetric clouds — rendered in standalone Three.js.

Three ways to get here: native gmp-map-3d for quick wins, deck.gl for data on terrain, or standalone Three.js for full control. We'll cover each, starting simple.

Before writing code, understand the three main ways to build 3D experiences with Google's photorealistic tiles. Each serves different use cases:

Approach Best For Complexity Status
Native 3D Maps (gmp-map-3d) Immersive storytelling, real estate, tourism Low Preview (currently free)
deck.gl + 3D Tiles Data visualization draped on terrain Medium GA (not available in EEA)
Standalone (3DTilesRendererJS or CesiumJS) Flight sims, architectural walkthroughs, branded experiences High GA (not available in EEA)

Two different products: The Map Tiles API (GA since 2023) serves raw tiles from tile.googleapis.com — used by deck.gl and standalone approaches, requires explicit API enablement.

The Maps JavaScript API 3D Maps (gmp-map-3d) is a higher-level Preview wrapper that handles tile fetching internally — just enable Maps JavaScript API.

EEA Restriction: Google's Map Tiles API (Photorealistic 3D Tiles) is not available in the European Economic Area. If your Google Cloud project has an EEA billing address, requests to tile.googleapis.com will return a 403 error.

This affects deck.gl's Tile3DLayer and standalone 3DTilesRendererJS approaches.

The native gmp-map-3d element remains available in EEA because Google handles tile rendering internally. See Google's EEA documentation .

3D adds complexity. Before reaching for it, consider the trade-offs:

  • SEO: WebGL content isn't crawlable by search engines. If discoverability matters, provide a 2D fallback.
  • Device support: Some phones can't render 3D smoothly. Google falls back to 2D automatically, but test on your target devices.
  • Accessibility: Many users prefer reduced motion . Provide 2D alternatives and respect prefers-reduced-motion.
  • Complexity: If a 2D map suffices, don't add 3D for the sake of it. Every feature has a maintenance cost.

Let's start with the easiest approach: Google's native 3D Maps API. With a single HTML element and a few attributes, you can render any location on Earth in photorealistic 3D — complete with buildings, trees, and terrain.

Photorealistic 3D rendering of Manhattan — every building, street, and park rendered from real-world imagery.

Load the maps3d library via the script tag:

<script
  async
  src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&v=beta&libraries=maps3d"
></script>

Or using the @googlemaps/js-api-loader package:

import { Loader } from "@googlemaps/js-api-loader";

const loader = new Loader({ apiKey: "YOUR_API_KEY", version: "beta" });
const { Map3DElement } = await loader.importLibrary("maps3d");

Why v=beta? Google uses two systems: release channels (v=beta, v=weekly, v=quarterly) control how frequently you receive API updates, while launch stages (Experimental, Preview, GA) indicate a feature's maturity — SLA coverage, support, and pricing.

These are independent. 3D Maps is currently in Preview (free, no SLA), but you access it via the beta channel. Google states it "will adopt a usage-based, pay-as-you-go pricing model once it reaches General Availability."

Experimental Preview GA
Alpha channel
Beta channel
Weekly channel
Quarterly channel

The simplest 3D map is just an HTML element. Technically, gmp-map-3d is a Web Component (a custom element that extends HTML), but it's still valid HTML and you use it like any native tag:

<gmp-map-3d
  mode="HYBRID"
  center="40.7128,-74.0060,400"
  range="2000"
  tilt="60"
  heading="45"
></gmp-map-3d>

That's it. A few attributes give you a photorealistic 3D view of Manhattan.

Let's break down the properties:

Property Description Example
mode Required. Rendering mode HYBRID (with labels) or SATELLITE
center The point you're looking at: lat,lng,altitude 40.7128,-74.0060,400 (NYC at 400m altitude)
range Distance from camera to center point (meters) 2000 (2km away)
tilt Angle looking down (0° = top-down, 90° = horizon) 60 (looking down at 60°)
heading Compass direction (0° = North, 90° = East) 45 (looking NE)

Breaking Change (February 2025): The mode property is now mandatory for Map3DElement. You must specify either mode="HYBRID" or mode="SATELLITE". Without this, your map will show an infinite loading spinner. See Google's support page for details.

If you've ever piloted an aircraft (or played a flight simulator), Google's 3D camera properties will feel familiar. They map directly to aviation terminology:

Map Property Aviation Term What It Controls
heading Yaw Compass direction — which way you're facing (0°=North, 90°=East, 180°=South, 270°=West)
tilt Pitch Nose up/down angle — 0° looks straight down, 90° looks at the horizon
Roll Not supported — the camera always stays level (no banking)
center.altitude Altitude Height of the focal point above sea level or ground
range Distance How far the camera sits from the center point

Think of it this way: you're a helicopter pilot hovering in the sky, looking at a specific point (center). You can:

  • Rotate left/right (heading/yaw) to face different compass directions
  • Tilt your view up/down (tilt/pitch) from bird's-eye to street-level perspective
  • Move closer or farther (range) from your focal point
  • Change what you're looking at (center) — a point at a specific lat/lng/altitude

Interactive Controls: Users can manipulate these properties with trackpad/mouse gestures:

  • Drag: Pan the map (changes center)
  • Scroll/Pinch: Zoom in/out (changes range)
  • Shift + Drag: Tilt the view up/down (changes tilt)
  • Ctrl + Drag: Rotate the view (changes heading)

You can customize or disable these defaults using gesture handling .

Try It Live: The companion demo below is an interactive 3D Maps Explorer with camera controls, Places search, and 3D model markers. Experiment with camera properties in real-time to see how center, tilt, heading, and range affect the view.

API Key Required: All interactive demos in this chapter require a Google Maps API key with the following APIs enabled: Maps JavaScript API, Places API, and Map Tiles API. Enter your key when prompted — it's stored in your browser's localStorage and never sent to our servers.

You can define the map in HTML and get a reference to it, or create it entirely in JavaScript. Both approaches give you the same Map3DElement instance to work with.

Option 1: Query an existing HTML element

If you defined the map in HTML, use querySelector to get a reference:

// Wait for the custom element to be defined
await customElements.whenDefined("gmp-map-3d");

// Get reference to existing HTML element
const map3D = document.querySelector("gmp-map-3d");

Option 2: Create programmatically

For more control, create the map entirely in JavaScript:

async function init3DMap() {
  const { Map3DElement } = await google.maps.importLibrary("maps3d");

  const map3D = new Map3DElement({
    mode: "HYBRID",  // Required since Feb 2025
    center: {
      lat: 40.7128,
      lng: -74.0060,
      altitude: 400
    },
    range: 2000,
    tilt: 60,
    heading: 45,
  });

  document.getElementById("map-container").appendChild(map3D);

  return map3D;
}

Once you have a reference to the map, you can listen for camera property changes to keep your UI in sync:

map3D.addEventListener("gmp-centerchange", () => {
  console.log("Center:", map3D.center);
});
map3D.addEventListener("gmp-tiltchange", () => {
  console.log("Tilt:", map3D.tilt);
});
map3D.addEventListener("gmp-headingchange", () => {
  console.log("Heading:", map3D.heading);
});
map3D.addEventListener("gmp-rangechange", () => {
  console.log("Range:", map3D.range);
});

For smooth, cinematic camera movements, use flyCameraTo() :

map3D.flyCameraTo({
  endCamera: {
    center: { lat: 40.7484, lng: -73.9857, altitude: 50 },
    range: 1500,
    tilt: 65,
    heading: 0,
  },
  durationMillis: 2000,  // 2 second animation
});

This creates a smooth, Google Earth-style flight from the current camera position to the destination. Perfect for:

  • Flying to search results (e.g., from Places Autocomplete)
  • Guided tours between waypoints
  • "Fly to my location" buttons
  • Property flyovers in real estate apps

To orbit the camera around a fixed point (great for showcasing a building or landmark), use flyCameraAround() :

map3D.flyCameraAround({
  camera: {
    center: { lat: 40.7484, lng: -73.9857, altitude: 50 },
    range: 800,
    tilt: 60,
    heading: 0,  // starting heading
  },
  durationMillis: 15000,  // 15 seconds for full orbit
  rounds: 1,              // number of complete rotations
});

The camera maintains its range and tilt while rotating heading through 360° × rounds. You can listen for completion using the gmp-animationend event:

map3D.addEventListener("gmp-animationend", (event) => {
  if (event.detail.type === "flyCameraAround") {
    console.log("Orbit complete");
  }
});

For guided tours through multiple locations, chain flyCameraTo() calls using the gmp-animationend event:

interface CameraPosition {
  center: { lat: number; lng: number; altitude: number };
  range: number;
  tilt: number;
  heading: number;
}

function createGuidedTour(
  map3D: google.maps.maps3d.Map3DElement,
  waypoints: CameraPosition[],
  durationPerWaypoint: number = 3000,
  onComplete?: () => void
) {
  let currentIndex = 0;

  function flyToNext() {
    if (currentIndex >= waypoints.length) {
      onComplete?.();
      return;
    }

    map3D.flyCameraTo({
      endCamera: waypoints[currentIndex],
      durationMillis: durationPerWaypoint,
    });
    currentIndex++;
  }

  // Listen for animation end to trigger next waypoint
  const handler = (event: Event) => {
    const detail = (event as CustomEvent).detail;
    if (detail?.type === "flyCameraTo") {
      if (currentIndex >= waypoints.length) {
        map3D.removeEventListener("gmp-animationend", handler);
        onComplete?.();
      } else {
        flyToNext();
      }
    }
  };

  map3D.addEventListener("gmp-animationend", handler);
  flyToNext();
}

// Usage: NYC landmarks tour
const nycTour: CameraPosition[] = [
  { center: { lat: 40.7484, lng: -73.9857, altitude: 50 }, range: 800, tilt: 65, heading: 0 },    // Empire State
  { center: { lat: 40.7587, lng: -73.9787, altitude: 30 }, range: 600, tilt: 70, heading: 45 },   // Rockefeller
  { center: { lat: 40.7580, lng: -73.9855, altitude: 20 }, range: 500, tilt: 60, heading: 180 },  // Times Square
  { center: { lat: 40.7484, lng: -73.9857, altitude: 200 }, range: 2000, tilt: 45, heading: 0 },  // Pull back
];

createGuidedTour(map3D, nycTour, 4000, () => {
  console.log("Tour complete!");
});

For complete control — smooth curves, custom easing, looping — you can bypass Google's built-in animations and drive the camera directly with requestAnimationFrame:

interface CameraWaypoint {
  center: { lat: number; lng: number; altitude: number };
  range: number;
  tilt: number;
  heading: number;
}

function animateCameraPath(
  map3D: google.maps.maps3d.Map3DElement,
  waypoints: CameraWaypoint[],
  durationMs: number,
  onComplete?: () => void
) {
  const startTime = performance.now();

  function animate() {
    const elapsed = performance.now() - startTime;
    const t = Math.min(elapsed / durationMs, 1);

    // Easing (ease-in-out)
    const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;

    // Interpolate between waypoints
    const camera = interpolateWaypoints(waypoints, eased);
    map3D.center = camera.center;
    map3D.range = camera.range;
    map3D.tilt = camera.tilt;
    map3D.heading = camera.heading;

    if (t < 1) {
      requestAnimationFrame(animate);
    } else {
      onComplete?.();
    }
  }

  requestAnimationFrame(animate);
}

// Linear interpolation between waypoints (for smooth curves, use Catmull-Rom splines)
function interpolateWaypoints(waypoints: CameraWaypoint[], t: number): CameraWaypoint {
  const segment = t * (waypoints.length - 1);
  const i = Math.min(Math.floor(segment), waypoints.length - 2);
  const localT = segment - i;

  const a = waypoints[i];
  const b = waypoints[i + 1];

  return {
    center: {
      lat: a.center.lat + (b.center.lat - a.center.lat) * localT,
      lng: a.center.lng + (b.center.lng - a.center.lng) * localT,
      altitude: a.center.altitude + (b.center.altitude - a.center.altitude) * localT,
    },
    range: a.range + (b.range - a.range) * localT,
    tilt: a.tilt + (b.tilt - a.tilt) * localT,
    heading: a.heading + (b.heading - a.heading) * localT,
  };
}

Usage:

const cinematicPath: CameraWaypoint[] = [
  { center: { lat: 40.7128, lng: -74.0060, altitude: 500 }, range: 5000, tilt: 45, heading: 0 },
  { center: { lat: 40.7484, lng: -73.9857, altitude: 100 }, range: 800, tilt: 70, heading: 180 },
  { center: { lat: 40.7484, lng: -73.9857, altitude: 400 }, range: 2000, tilt: 50, heading: 270 },
];

animateCameraPath(map3D, cinematicPath, 15000, () => {
  console.log("Flythrough complete!");
});

Smoother curves: The example above uses linear interpolation, which creates straight-line segments between waypoints. For cinematic curved paths, replace interpolateWaypoints with a Catmull-Rom spline implementation — or use Three.js's built-in CatmullRomCurve3 if you're already using Three.js.

When to use each approach:

Approach Best For
flyCameraTo() Single point-to-point transitions
flyCameraAround() Orbiting a fixed point (showcase a building)
Chained flyCameraTo() Multi-stop tours with Google's smooth interpolation
Manual spline animation Curved paths, custom easing, progress callbacks, looping

See Google's camera control documentation for additional options like camera restrictions (bounds, minAltitude, maxTilt) and gesture handling.

Unlike 2D markers that sit on the map surface, 3D markers can float at any altitude. This opens up use cases like flight paths, building heights, or atmospheric measurements.

async function addMarker3D(map3D: google.maps.maps3d.Map3DElement) {
  const { Marker3DElement } = await google.maps.importLibrary("maps3d");

  const marker = new Marker3DElement({
    position: {
      lat: 40.7484,
      lng: -73.9857,
      altitude: 443  // Empire State Building height
    },
    altitudeMode: "ABSOLUTE",  // altitude is meters above sea level
    extruded: true,  // draw a line down to ground
    label: "Empire State Building",
  });

  map3D.appendChild(marker);
}

The altitudeMode property controls how the altitude value is interpreted:

Mode Behavior
ABSOLUTE Altitude is meters above sea level
RELATIVE_TO_GROUND Altitude is meters above terrain
CLAMP_TO_GROUND Ignores altitude, pins to surface

The extruded: true option draws a vertical line from the marker down to the ground — helpful for showing the actual position when markers float high above.

3D marker floating at altitude with extruded line connecting to ground level on Google Maps

A Marker3DElement floating 150 meters above Madison Square Garden with extruded: true — the vertical line shows exactly where the marker sits above the terrain.

3D marker floating at altitude with extruded line connecting to ground level on Google Maps

Polylines in 3D can follow terrain, float at a fixed altitude, or trace a path through 3D space:

async function addFlightPath(map3D: google.maps.maps3d.Map3DElement) {
  const { Polyline3DElement } = await google.maps.importLibrary("maps3d");

  const flightPath = new Polyline3DElement({
    altitudeMode: "ABSOLUTE",
    strokeColor: "#00ff99",
    strokeWidth: 8,
    extruded: true,  // creates a "curtain" down to ground
  });

  // Flight from JFK to LAX with altitude
  flightPath.coordinates = [
    { lat: 40.6413, lng: -73.7781, altitude: 0 },      // JFK takeoff
    { lat: 40.8, lng: -74.5, altitude: 5000 },         // climbing
    { lat: 41.0, lng: -80.0, altitude: 10000 },        // cruising
    { lat: 38.0, lng: -100.0, altitude: 11000 },       // cruising
    { lat: 35.0, lng: -115.0, altitude: 8000 },        // descending
    { lat: 33.9425, lng: -118.4081, altitude: 0 },     // LAX landing
  ];

  map3D.appendChild(flightPath);
}

Polygons can represent areas at altitude — perfect for airspace restrictions, flood zones at different elevations, or building footprints:

import circle from "@turf/circle";

async function addAirspaceZone(map3D: google.maps.maps3d.Map3DElement) {
  const { Polygon3DElement } = await google.maps.importLibrary("maps3d");

  const restrictedAirspace = new Polygon3DElement({
    altitudeMode: "ABSOLUTE",
    fillColor: "#ff000044",  // semi-transparent red
    strokeColor: "#ff0000",
    strokeWidth: 2,
    extruded: true,  // fills volume from ground to altitude
  });

  // Generate circle coordinates using Turf.js
  const center = [-73.7781, 40.6413];  // JFK [lng, lat]
  const turfCircle = circle(center, 5, { units: "kilometers", steps: 36 });
  const altitude = 500;

  // Convert GeoJSON to Map3D coordinates
  restrictedAirspace.outerCoordinates = turfCircle.geometry.coordinates[0].map(
    ([lng, lat]) => ({ lat, lng, altitude })
  );

  map3D.appendChild(restrictedAirspace);
}

Turf.js for Geospatial Operations: We use Turf.js to generate the circle coordinates. This library handles geodesic calculations correctly, accounting for Earth's curvature. For more on Turf.js and other geospatial utilities, see the Turf.js and GeoJSON chapter .

You can load your own 3D models to place anywhere on the map. Google's Model3DElement supports the glTF format — the "JPEG of 3D" — which has become the industry standard for web-based 3D content.

Format Description Best For
glTF (.gltf) JSON file + separate binary/texture files Development, debugging
GLB (.glb) Single binary file with everything embedded Production, easier deployment

GLB is usually preferred because it's a single file that's easy to serve and cache. Most 3D tools (Blender, Maya, 3ds Max) can export to GLB directly.

async function add3DModel(map3D: google.maps.maps3d.Map3DElement) {
  const { Model3DElement } = await google.maps.importLibrary("maps3d");

  const model = new Model3DElement({
    src: "/models/windmill.glb",
    position: {
      lat: 40.7484,
      lng: -73.9857,
      altitude: 0
    },
    altitudeMode: "RELATIVE_TO_GROUND",
    orientation: {
      heading: 45,  // yaw: rotate around vertical axis
      tilt: 0,      // pitch: tip forward/backward
      roll: 0       // roll: rotate around forward axis
    },
    scale: 1,  // uniform scale, or { x: 1, y: 1, z: 1 }
  });

  map3D.appendChild(model);
}

Many 3D models are authored with different "up" axes. If your model appears sideways or upside-down, adjust the orientation:

// Model lying on its side? Try rotating it upright
orientation: {
  tilt: -90,  // rotate 90° around X axis
  roll: 0,
  heading: 0
}

The orientation properties use the same aviation terms as the camera:

  • heading (yaw): Rotates the model to face a compass direction
  • tilt (pitch): Tips the model forward/backward
  • roll: Rotates the model around its forward axis

Here's where it gets tricky: 3D models don't have a standard unit system. A model might be "100 units tall" — but is that 100 meters, 100 centimeters, or 100 arbitrary units?

To calculate the correct scale:

  1. Find the model's native dimensions using Three.js or a GLB inspector
  2. Decide the real-world size you want (e.g., 100 meters tall for a wind turbine)
  3. Calculate: scale = desired_size / native_size
// Using Three.js to inspect model dimensions
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { Box3, Vector3 } from 'three';

const loader = new GLTFLoader();
loader.load('model.glb', (gltf) => {
  const box = new Box3().setFromObject(gltf.scene);
  const size = new Vector3();
  box.getSize(size);

  console.log(`Model dimensions: X=${size.x}, Y=${size.y}, Z=${size.z}`);

  // Calculate scale for desired height (e.g., 100 meters)
  const desiredHeight = 100;
  const scale = desiredHeight / size.y;  // assuming Y is up
  console.log(`Scale for ${desiredHeight}m: ${scale}`);
});

Online tools can also help:

Example calculation:

Model Native Size Desired Size Scale
Wind turbine 1872 units 100m tall 100 / 1872 = 0.053
Rubber duck 2 units 20m tall (giant!) 20 / 2 = 10
Fox character 155 units 3m tall 3 / 155 = 0.02
Custom 3D glTF model rendered on Google Maps 3D using Model3DElement

A rubber duck's GLB model sitting on the edge of The Edge in NYC. The model's scale and orientation are adjusted to appear correctly on the terrain.

Custom 3D glTF model rendered on Google Maps 3D using Model3DElement

Finding 3D Models:

CORS and Self-Hosting: Loading models from external domains may fail due to CORS restrictions. For production, host GLB files on your own domain or a CDN with proper CORS headers.

Let's combine these elements into a more practical example. Imagine a fire breaks out near Krilo Beach on Croatia's Dalmatian Coast. Emergency services need to dispatch the nearest fire brigade from the station in Omiš, about 4km away. The dashboard visualizes the incident in real-time: the fire zone, an evacuation perimeter, and the response route draped over photorealistic 3D terrain.

Emergency response dashboard showing fire incident zone and response route on Google Maps 3D

Emergency response visualization on Croatia's Dalmatian Coast: the red polygon marks the fire incident zone, the orange perimeter defines the evacuation area, and the yellow polyline shows the response route from Omiš fire station draped over 3D terrain.

Emergency response dashboard showing fire incident zone and response route on Google Maps 3D

Let's start by defining the map container with our overlay elements. The mode="SATELLITE" gives us a cleaner view without POI labels cluttering the emergency visualization:

<!-- Tested: Maps JS API v3.61, January 2026 -->
<gmp-map-3d
  id="emergency-map"
  mode="SATELLITE"
  heading="25"
  range="2500"
  tilt="45"
  center="43.4612,16.5985,165"
>
  <gmp-polyline-3d id="response-route" altitude-mode="clamp-to-ground"
    stroke-color="#FFFF00" stroke-width="12" extruded="true"></gmp-polyline-3d>

  <gmp-polygon-3d id="fire-zone" altitude-mode="clamp-to-ground"
    fill-color="#ff000055" stroke-color="#FF0000" stroke-width="3"></gmp-polygon-3d>

  <gmp-polygon-3d id="evacuation-zone" altitude-mode="clamp-to-ground"
    fill-color="#ffaa0033" stroke-color="#ffaa00" stroke-width="2"></gmp-polygon-3d>

  <gmp-model-3d id="fire-truck" src="/models/fire-truck.glb"
    altitude-mode="relative-to-ground" scale="132"></gmp-model-3d>
</gmp-map-3d>

For the incident zones, we use Turf.js to generate geodesically accurate circles. This matters because a naive approach using constant lat/lng offsets would produce ellipses at higher latitudes:

import circle from "@turf/circle";

const INCIDENT = { lat: 43.4612, lng: 16.5985 };

function generateCircle(center, radiusKm) {
  const turfCircle = circle([center.lng, center.lat], radiusKm, {
    units: "kilometers",
    steps: 64,
  });
  return turfCircle.geometry.coordinates[0].map(([lng, lat]) => ({
    lat, lng, altitude: 0,
  }));
}

fireZone.path = generateCircle(INCIDENT, 0.267);   // 267m fire radius
evacZone.path = generateCircle(INCIDENT, 0.5);     // 500m evacuation perimeter

To position the fire truck for this demo's sake, we find the first point where the response route enters the evacuation zone using Turf's point-in-polygon test:

import booleanPointInPolygon from "@turf/boolean-point-in-polygon";

const evacCircle = circle([INCIDENT.lng, INCIDENT.lat], 0.5, { units: "km" });
const truckIndex = ROUTE.findIndex((pt) =>
  booleanPointInPolygon([pt.lng, pt.lat], evacCircle)
);

Finally, we calculate the truck's heading using geodesic bearing so it faces the direction of travel. The -90 offset accounts for the model's default orientation:

import bearing from "@turf/bearing";

const truckPos = ROUTE[truckIndex];
const nextPos = ROUTE[truckIndex + 1];
const heading = bearing([truckPos.lng, truckPos.lat], [nextPos.lng, nextPos.lat]);

fireTruck.position = { ...truckPos, altitude: 30 };
fireTruck.orientation = { heading: heading - 90, tilt: -90, roll: 0 };

Animating Vehicles in Real-Time: The bearing calculation uses the same geodesic formula that powers vehicle animations in ride-sharing apps. To animate a vehicle along a route, interpolate between waypoints using requestAnimationFrame and update the position and heading at each frame.

For implementation details, see this deep-dive on Uber-style car animation covering bearing calculation and linear interpolation. Under the hood, ride-sharing apps also rely on Hidden Markov Models , Kalman filters, and Particle Filters to snap noisy GPS signals to roads and smooth out erratic jumps.

The native gmp-map-3d element covers markers, polygons, models, and camera animation. But if you're already using deck.gl (covered in the previous chapter ), you can load Google's Photorealistic 3D Tiles directly — and drape your existing layers onto 3D terrain.

Native 3D Maps works well for markers and simple overlays, but deck.gl shines when you need:

  • Large datasets draped onto 3D terrain
  • Complex visualizations (heatmaps, arcs, hexbins) in 3D
  • Interactivity (hover tooltips, click handlers)
  • 3D model layers via ScenegraphLayer

Requirements:

The core of deck.gl's 3D tile support is Tile3DLayer . Point it at Google's tileset URL, pass your API key in the headers, and you get the same photorealistic terrain as native 3D Maps — but rendered through deck.gl's pipeline.

import { Deck } from "@deck.gl/core";
import { Tile3DLayer } from "@deck.gl/geo-layers";

const GOOGLE_API_KEY = "YOUR_API_KEY";
const TILESET_URL = "https://tile.googleapis.com/v1/3dtiles/root.json";

const deckgl = new Deck({
  parent: document.getElementById("map"),
  initialViewState: {
    latitude: 43.4612,
    longitude: 16.5985,
    zoom: 16,
    bearing: 370,
    pitch: 85,
    height: 200,
  },
  controller: true,
  layers: [
    new Tile3DLayer({
      id: "google-3d-tiles",
      data: TILESET_URL,
      loadOptions: {
        fetch: {
          headers: { "X-GOOG-API-KEY": GOOGLE_API_KEY },
        },
      },
      operation: "terrain+draw",
    }),
  ],
});

The operation: "terrain+draw" tells deck.gl to both render the tiles and use them as terrain for draping other layers. Without this, your data layers would float above the 3D surface instead of following it.

Attribution: Google requires you to display copyright notices for the tiles. The onTilesetLoad callback lets you extract these from loaded tiles:

onTilesetLoad: (tileset3d) => {
  tileset3d.options.onTraversalComplete = (selectedTiles) => {
    const credits = new Set();
    selectedTiles.forEach((tile) => {
      const { copyright } = tile.content.gltf.asset;
      copyright.split(";").forEach(credits.add, credits);
    });
    document.getElementById("credits").innerHTML = [...credits].join("; ");
    return selectedTiles;
  };
}

With the base tiles in place, you can drape your existing deck.gl layers onto the 3D surface using TerrainExtension . This is where deck.gl pulls ahead of native 3D Maps — you can take any layer type ( GeoJSON , arcs, heatmaps) and have it follow the terrain contours.

import { GeoJsonLayer } from "@deck.gl/layers";
import {
  _TerrainExtension as TerrainExtension,
  PathStyleExtension,
} from "@deck.gl/extensions";

const routeLayer = new GeoJsonLayer({
  id: "navigation",
  data: "https://example.com/route.geojson",
  getLineWidth: 7,
  getLineColor: [255, 255, 0],
  getDashArray: [4, 2],
  dashJustified: true,
  extensions: [new TerrainExtension(), new PathStyleExtension({ dash: true })],
});

Add this layer to your layers array after the Tile3DLayer. The TerrainExtension samples the terrain height at each vertex and repositions the geometry to match. The optional PathStyleExtension adds dashed line support.

For 3D models (vehicles, buildings, markers), use ScenegraphLayer . Unlike native 3D Maps' Model3DElement, ScenegraphLayer is data-driven — pass it any data source (GeoJSON, arrays, URLs) and it renders a model at each point:

import { ScenegraphLayer } from "@deck.gl/mesh-layers";

const markersLayer = new ScenegraphLayer({
  id: "scenegraph-layer",
  data: "https://example.com/points.geojson",
  scenegraph: "https://example.com/pin-model.gltf",
  getPosition: (d) => [...d.geometry.coordinates, 70],  // [lng, lat, altitude]
  getOrientation: () => [0, 0, -180],
  sizeScale: 7,
  pickable: true,
});

The scenegraph prop accepts both .gltf and .glb files (via loaders.gl). This scales better than creating individual Model3DElement instances — deck.gl batches the rendering, so you can display hundreds of markers without performance issues.

Let's recreate the Krilo Beach emergency response scene entirely in deck.gl. Instead of a fire truck model, we use ScenegraphLayer to place 3D pin markers above boats in the marina — points of interest that emergency responders might need to reach. The navigation route and fire incident zone are draped onto the terrain just like the native version.

deck.gl visualization showing 3D pin markers, navigation route, and fire incident zone draped on Google Photorealistic 3D Tiles at Krilo Beach, Croatia

The Krilo Beach scene rendered in deck.gl: 3D pin markers via ScenegraphLayer, a dashed navigation route, and a fire incident zone — all draped onto Google's Photorealistic 3D Tiles using TerrainExtension.

deck.gl visualization showing 3D pin markers, navigation route, and fire incident zone draped on Google Photorealistic 3D Tiles at Krilo Beach, Croatia

EEA Restriction: This demo requires a Google Cloud project with a non-EEA billing account. The Map Tiles API (Photorealistic 3D Tiles) is not available in the European Economic Area.

Both approaches render the same 3D terrain. The difference is how you add your data on top.

Feature Native 3D Maps deck.gl
Markers/Pins Marker3DElement: one element per marker, declarative HTML ScenegraphLayer: data-driven, batch renders from arrays, GeoJSON, URLs, or any iterable
Polygons Polygon3DElement: set coordinates directly, supports extrusion GeoJsonLayer / PolygonLayer + TerrainExtension: accepts GeoJSON, binary data, or custom formats
Lines/Routes Polyline3DElement: fixed styling, terrain-following GeoJsonLayer / PathLayer + PathStyleExtension: dashed lines, custom widths, any data source
3D Models Model3DElement: one model per element, manual positioning ScenegraphLayer: batch rendering, data-driven placement from any data source
Performance Good for dozens of elements Optimized for thousands of data points
Learning curve Low (HTML elements + simple JS) Medium (requires understanding deck.gl's layer system)
Zoomed-out views Atmospheric haze, sky gradient, overlays stay visible Hard tile boundaries, no sky, overlays may disappear
Side-by-side comparison: Native 3D Maps (left) shows atmospheric haze, sky gradient, and visible markers at the horizon, while deck.gl with 3D Tiles (right) has an abrupt tile cutoff with no sky and missing overlays

Native 3D Maps (left) vs deck.gl with 3D Tiles (right) at low zoom. Native renders atmospheric haze, a sky gradient, and keeps markers visible out of the box. deck.gl shows hard tile boundaries and loses the overlays. Overlay visibility can be fixed with sizeUnits: 'meters' and sizeMinPixels. For sky rendering, see Adding Atmosphere and Clouds in the Standalone Three.js section.

Side-by-side comparison: Native 3D Maps (left) shows atmospheric haze, sky gradient, and visible markers at the horizon, while deck.gl with 3D Tiles (right) has an abrupt tile cutoff with no sky and missing overlays

Which to pick? Start with native 3D Maps if you're new to 3D or have a handful of markers and polygons. Switch to deck.gl when you're dealing with hundreds of data points, need advanced styling, or already have a deck.gl codebase.

deck.gl and Three.js both load tiles from the same tile.googleapis.com endpoint — neither uses the Google Maps JavaScript API. The difference is the rendering pipeline: deck.gl's layer system vs Three.js's scene graph.

Choose Three.js when you need the full Three.js ecosystem: custom shaders, physics engines, post-processing effects, or creative applications like Ubilabs' 2025 Christmas snow globe .

  • Full scene control — any shader, any effect, any camera rig
  • Three.js ecosystem — physics engines, post-processing, VR/AR support
  • Custom interactions — build your own navigation and UI
  • Creative applications — architectural walkthroughs, games, rocket flight simulations

3DTilesRendererJS is NASA's library for loading 3D tile formats into Three.js. It handles tile streaming, level-of-detail management, and works with Google's tiles out of the box:

import * as THREE from "three";
import { TilesRenderer } from "3d-tiles-renderer";
import { GoogleCloudAuthPlugin } from "3d-tiles-renderer/plugins";

const GOOGLE_TILES_URL = "https://tile.googleapis.com/v1/3dtiles/root.json";

// Create Three.js scene
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 1e12);
const renderer = new THREE.WebGLRenderer({
  antialias: true,
  logarithmicDepthBuffer: true  // Essential for Earth-scale z-fighting
});

// Create tiles renderer with Google auth
const tilesRenderer = new TilesRenderer(GOOGLE_TILES_URL);
tilesRenderer.registerPlugin(
  new GoogleCloudAuthPlugin({ apiToken: "YOUR_API_KEY" })
);

// Configure quality
tilesRenderer.errorTarget = 6;  // Lower = more detail
tilesRenderer.maxDepth = 15;

// Connect to camera
tilesRenderer.setCamera(camera);
tilesRenderer.setResolutionFromRenderer(camera, renderer);

// Add to scene
scene.add(tilesRenderer.group);

// Animation loop
function animate() {
  requestAnimationFrame(animate);
  tilesRenderer.update();  // Manages tile loading/unloading
  renderer.render(scene, camera);
}
animate();

Logarithmic Depth Buffer: Earth-scale scenes span millions of meters. Standard depth buffers cause z-fighting (flickering). The logarithmicDepthBuffer: true option distributes depth precision across the full range.

Raw 3D tiles have no sky — just an abrupt edge where tiles end. If you've seen the deck.gl comparison image above, you know what this looks like: photorealistic ground, then nothing.

The three-geospatial library fixes this. It's not an alternative data source — it's a rendering layer that adds atmospheric effects on top of your existing tile stack. Think of it as: Google provides the ground, three-geospatial provides the sky.

The library includes three packages:

  • @takram/three-atmosphere — physically-based sky gradients and aerial perspective (distant objects fade to blue)
  • @takram/three-clouds — volumetric clouds with Beer Shadow Maps that cast shadows onto the terrain
  • @takram/three-geospatial-effects — post-processing effects tuned for geospatial scenes (dithering, tone mapping)

See it in action with Google tiles: Tokyo , London , Manhattan .

three-geospatial originated from Re:Earth , an open-source WebGIS platform that powers Japan's PLATEAU national 3D urban model initiative. The Takram team has done excellent work bringing broadcast-quality atmospheric rendering to the browser. The libraries are modular — use only what you need.

Google's tiles don't always include vertex normals, which breaks atmospheric lighting (tiles render black). Add a plugin to compute them on load:

import type { Tile } from '3d-tiles-renderer';

class TileNormalsPlugin {
  processTileModel(scene: THREE.Object3D, _tile: Tile): void {
    scene.traverse((object) => {
      if (object instanceof THREE.Mesh && object.geometry) {
        if (!object.geometry.getAttribute('normal')) {
          object.geometry.computeVertexNormals();
        }
      }
    });
  }
}

Clouds aren't just decorative — they ground the scene in reality (pun intended). three-geospatial's volumetric clouds use Beer Shadow Maps (BSM) to cast shadows onto the terrain below, creating that shifting light-and-shadow effect you see on partly cloudy days.

import { Clouds } from '@takram/three-clouds/r3f';

// Inside your EffectComposer
<Clouds coverage={0.2} />

The coverage prop controls cloud density (0.0 = clear sky, 1.0 = overcast). Even subtle coverage (0.15–0.25) adds depth without obscuring the terrain.

Clouds that don't cast shadows look flat. three-geospatial's cloud system includes shadow mapping out of the box — the Clouds component projects shadows onto the terrain below automatically.

For time-based lighting, use Atmosphere.updateByDate() which computes sun position from a JavaScript Date object. Set the date to July 15th at 2 PM and the shadows match what you'd see on a summer afternoon. For advanced tuning (cascade counts, map resolution), see the CloudsEffect documentation .

Volumetric clouds casting shadows onto Google 3D Tiles terrain with terrain-draped overlays rendered using three-geospatial

Volumetric clouds cast shadows onto the terrain while the response route and incident zones follow the surface via raycasting — all running in the browser.

Volumetric clouds casting shadows onto Google 3D Tiles terrain with terrain-draped overlays rendered using three-geospatial

Shadows as a Business: Cloud shadows hint at a larger domain — solar and shadow analysis. Entire products are built around computing which surfaces receive sunlight at any given time:

  • Google Solar API calculates solar potential for rooftops, powering solar panel installation estimates
  • Shadowmap.org lets you set any date and time to see which buildings cast shadows — used by real estate buyers checking if that apartment gets afternoon sun, or urban planners assessing how a new tower affects neighboring streets

For building shadows at specific sun angles, you'd extend three-geospatial's CSM with geometry-based shadow volumes from the 3D tile meshes themselves.

Native 3D Maps drapes polylines and polygons onto terrain automatically. In Three.js, you sample terrain elevation by raycasting from above:

function sampleTerrainElevation(
  tilesGroup: THREE.Object3D,
  lat: number,
  lng: number
): number | null {
  // Start 10km above the point
  const highPoint = latLngToECEF(lat, lng, 10000);
  const direction = new THREE.Vector3(0, 0, 0).sub(highPoint).normalize();

  const raycaster = new THREE.Raycaster(highPoint, direction);
  raycaster.firstHitOnly = true;
  const hits = raycaster.intersectObject(tilesGroup, true);

  if (hits.length > 0) {
    const hitPoint = hits[0].point;
    const zeroAltitudePoint = latLngToECEF(lat, lng, 0);
    return hitPoint.length() - zeroAltitudePoint.length();
  }
  return null;
}

For routes and polygons, sample elevation at each vertex and rebuild the geometry periodically as higher-resolution tiles load. The demo below samples every 60 frames to balance accuracy with performance.

Native gmp-map-3d handles terrain draping, lighting, and sky rendering automatically. With standalone Three.js, you're responsible for each piece. Here's what changes:

Task Native 3D Maps Standalone Three.js
Sky rendering Built-in Add @takram/three-atmosphere with <Sky /> and <AerialPerspective />
Terrain draping altitude-mode="clamp-to-ground" Raycast against tiles, sample elevation, add height offset
Model placement Set position.altitude directly Convert lat/lng to ECEF coordinates, compute surface normal for orientation
Lighting Automatic Compute sun position from date/time, configure directional + ambient lights
Clouds Not available Add @takram/three-clouds for volumetric clouds with shadows

The trade-off is clear: more work, but also more control. You can adjust sun angle by the minute, add volumetric clouds that cast shadows across the terrain, or apply custom shaders to the tiles themselves.

Let's recreate the Krilo Beach emergency scene using 3DTilesRendererJS and three-geospatial. The result looks similar to native 3D Maps, but now we have full control: physically-based sky rendering, volumetric clouds with shadows, and the entire Three.js ecosystem at our disposal.

Emergency response dashboard rendered with Three.js, 3DTilesRendererJS and three-geospatial showing photorealistic terrain with atmospheric sky, volumetric clouds, and terrain-draped overlays

The same Krilo Beach emergency scene rendered in standalone Three.js: Google's Photorealistic 3D Tiles provide the terrain, three-geospatial adds atmospheric sky and volumetric clouds, and custom raycasting drapes the overlays onto the terrain surface.

Emergency response dashboard rendered with Three.js, 3DTilesRendererJS and three-geospatial showing photorealistic terrain with atmospheric sky, volumetric clouds, and terrain-draped overlays

EEA Restriction: This demo requires a Google Cloud project with a non-EEA billing account. The Map Tiles API (Photorealistic 3D Tiles) is not available in the European Economic Area.

If you don't need the full Three.js ecosystem, CesiumJS is worth considering. It's a complete geospatial platform with built-in terrain, imagery layers, globe-scale camera controls, and Google 3D Tiles support out of the box.

Choose 3DTilesRendererJS if you're already in Three.js. Choose CesiumJS if you want batteries-included features like terrain clamping, KML support, and time-dynamic data.

Two things to watch for:

  • API Quotas — Google's Tile API has usage limits. If tiles stop loading, check your Cloud Console quota.
  • Memory — 3D tiles are large. Monitor memory and adjust errorTarget/maxDepth if your app struggles.

If you're building something serious with standalone Three.js, you'll eventually hit the underlying formats. Here's what's under the hood:

The tile format: Google's Photorealistic 3D Tiles follow the OGC 3D Tiles specification — a spatial data structure for streaming massive 3D datasets. Each tile contains glTF models (the "JPEG of 3D"), which hold textured meshes from photogrammetry.

Compression: The mesh geometry inside each glTF is Draco-compressed — Google's open-source library that achieves up to 12× compression on vertex positions, normals, and texture coordinates. That's why 3DTilesRendererJS requires a DRACOLoader configured. If you see "DracoLoader not found" errors, that's the culprit.

Meshes vs point clouds: Google's tiles are textured meshes, not point clouds. Point clouds (like LiDAR scans) store raw XYZ coordinates without connectivity. Meshes connect those points into triangles with textures mapped on top — that's what gives you the photorealistic look.

Coordinate systems: The tiles use ECEF (Earth-Centered, Earth-Fixed) coordinates with origin at Earth's center. Converting from lat/lng requires ellipsoid math — libraries like 3DTilesRendererJS handle this, but if you're placing custom objects, you'll need to understand the transform.

For the full deep-dive, start with the 3D Tiles specification and Draco documentation .

Native 3D Maps deck.gl + 3D Tiles Standalone Three.js
What you get gmp-map-3d, markers, polylines, polygons, 3D models Your deck.gl layers draped onto terrain via TerrainExtension Full Three.js scene graph with custom shaders and effects
Complexity Low — HTML elements + simple JS Medium — deck.gl layer system High — raw Three.js/WebGL
Best for Immersive storytelling, real estate, tourism Data visualization on 3D terrain Custom rendering, games, creative projects
POIs / labels ✅ Yes (mode="HYBRID"), clickable via Places API ❌ Raw tiles only ❌ Raw tiles only
Performance Good for dozens of elements Optimized for thousands of data points You control it
Sky / atmosphere Built-in None (hard tile edges) Add with @takram/three-atmosphere
Clouds Not available Not available Add with @takram/three-clouds (includes shadows)
EEA availability ✅ Yes ❌ No (requires Map Tiles API) ❌ No (requires Map Tiles API)
Status Preview (currently free) GA GA

You now have three paths to Google's 3D world:

  1. Native 3D Maps (gmp-map-3d) — Start here. One HTML element gets you photorealistic 3D with markers, polylines, polygons, and models.

  2. deck.gl + 3D Tiles — Best for data visualization. Layer your existing deck.gl visualizations onto photorealistic terrain.

  3. Standalone (3DTilesRendererJS or CesiumJS) — No Google Maps UI, just Google's tiles. Render in pure Three.js or Cesium for flight sims, architectural walkthroughs, or branded experiences.

Start with native 3D Maps for most use cases. Graduate to the other approaches when you hit its limits.


This wraps up the Data Layers and Overlays section. If you used Turf.js in this chapter's demos and want to go deeper, check out Turf.js and Exporting to GeoJSON . For framework integration (React, Vue, Angular), see Working with Frameworks .