Table of Contents

Add 3D objects with three.js to a Map

vue-typescript-pinia-three-googlemaps

In this tutorial we will make use of the interactive map application that we built in the previous tutorial which can be found here. Then we will do some adjustments and make use of three.js to randomly add 3D objects to the map at random locations around the world, we can also place them at specific latitude/longitudes if we prefer that.

Tags: Vue 3, Typescript, Three.js, Pinia, Google Maps JS API.

Time to read: 10 min

Prerequisites

  • Familiarity with the command line
  • Install Node.js version 16.0 or higher
  • Google Maps JS API key & MapID

Vector Map instead of Raster map.

For 3D to work on a google map, your MapID must be of the type Vector Map, not Raster Map. Read more on Googles official docs on how to make sure that its a vector map and how to easily adjust it if its not.

Setup project

If you have verified that you have a vector map, you can proceed by pulling the base application which has everything that we covered in the previous tutorial here. This repo will include some more functionality, three.js among them. Which we will use to render 3D objects on to a canvas.

Run the following command in the commandline:

npx degit crunchwrap89/map-app-scaffold new-map-app

this will pull a repo from my github that includes the complete project that we built in the previous tutorial. After running the command do the following:

cd new-map-app
npm install

add your Google maps API key and MapID to a .env file in the root project.

the .env file should look like this below with your own API and map ID, if you have multple mapID's you can add them as comma separated.

VITE_GM_K=AIzaS45345ZGsSCDQF4e9x054656AxPhTa
VITE_GM_MAPIDS=5e99234f92c234d, f6842349db9f234
VITE_GM_MAPID=5e99234f92c234d

verify that its working.

Too much time indoors, in front of the computer? Find an activity to do Outside at GeoQuestr.comGeoQuestr

npm run dev

Setup WebGLOverlayView

Create a new file: "./src/composables/useWebGlOverlayView.ts"

and add the below code to the file.

Its alot of code and alot of it might be hard to understand, i will try to explain each code block in comments below.

import { latLngToVector3 } from '@/utils/mapUtils';
import { ref } from 'vue';
import {
  sRGBEncoding,
  PerspectiveCamera,
  Scene,
  WebGLRenderer,
  PCFSoftShadowMap,
  AmbientLight,
  BoxGeometry,
  MeshPhongMaterial,
  Mesh,
  PointLight,
  HemisphereLight,
  MeshLambertMaterial,
} from 'three';

//by using treeshaking we import only whats necessary from the "three" dependency instead of importing * as THREE,

//we setup our global variables.
let _overlay: google.maps.WebGLOverlayView;
let _scene: Scene;
let _camera: PerspectiveCamera | null;
let _renderer: WebGLRenderer | null;

const _gameIsRunning = ref(false);
const _anchor = { lat: 0, lng: 0, altitude: 0 };
const _rotation = new Float32Array([0, 0, 0]);
const _scale = new Float32Array([1, 1, 1]);

export function useWebGlOverlayView() {
  // this is the function we will call from TheMap component that initializes the 3D Overlay. Since it returns a promise, our loading screen will be showing untill the promise is resolved.
  async function initWebGLOverlay(map: google.maps.Map) {
    return (
      new Promise() <
      string >
      ((resolve) => {
        setupScene();

        _overlay = new google.maps.WebGLOverlayView();
        _renderer = null;
        _camera = null;

        _overlay.onAdd = onAdd_;
        _overlay.onContextLost = onContextLost_;
        _overlay.onContextRestored = onContextRestored_;
        _overlay.onDraw = onDraw_;

        _camera = new PerspectiveCamera();
        _overlay.setMap(map);
        _gameIsRunning.value = true;

        resolve('initialized webGlOverlayView.');
      })
    );
  }

  //this is where we decide what shall be put in the scene.
  function setupScene() {
    _scene = new Scene();
    _scene.rotation.x = Math.PI / 2;

    const hemiLight = new HemisphereLight(0xffffff, 0x444444, 0.8);
    hemiLight.position.set(0, 1, -0.2).normalize();
    _scene.add(hemiLight);
    const geometry = new BoxGeometry(1000000, 1000000, 1000000);
    //Create 50 random boxes and set them to random latLng's
    for (let i = 0; i < 50; i++) {
      const box = new Mesh(geometry, new MeshLambertMaterial({ color: Math.random() * 0xffffff }));
      latLngToVector3(
        {
          lat: getRandomInRange(),
          lng: getRandomInRange(),
        },
        box.position,
      );
      _scene.add(box);
    }
  }

  // a function to randomize lat/lngs so that we can scatter objects randomly on in the world.
  function getRandomInRange() {
    const from: any = -180;
    const to = 180;
    const fixed = 3;
    return (Math.random() * (to - from) + from).toFixed(fixed) * 1;
  }

  const getMap = () => _overlay.getMap();

  const getScene = () => _scene;

  const requestRedraw = () => _overlay.requestRedraw();

  //a hook that will be run when the webGlOverlayView is initialized.
  const onAdd_ = () => requestRedraw();

  //a hook that will be run if the webGL context is lossed and needs to be restored. It will always run on start so this is where we initialize the renderer.
  const onContextRestored_ = ({ gl }: any) => {
    _renderer = new WebGLRenderer(Object.assign({ canvas: gl.canvas, context: gl }, gl.getContextAttributes()));
    _renderer.autoClear = false;
    _renderer.autoClearDepth = false;
    _renderer.shadowMap.enabled = true;
    _renderer.shadowMap.type = PCFSoftShadowMap;
    _renderer.outputEncoding = sRGBEncoding;
    const { width, height } = gl.canvas;
    _renderer.setViewport(0, 0, width / 40, height / 40);
  };

  //this hook will run when the webgl context is lossed, for example if we get a memory leak.
  const onContextLost_ = () => {
    if (!_renderer) return;

    _renderer.dispose();
    _renderer = null;
  };

  //this hook will run at each frame that is fired within the application. Normally that is 24 frames/second but will also depend on your screens hertz.
  function onDraw_({ gl, transformer }: google.maps.WebGLDrawOptions) {
    if (!_renderer || !_camera) return;

    _camera.projectionMatrix.fromArray(transformer.fromLatLngAltitude(_anchor, _rotation, _scale));
    gl.disable(gl.SCISSOR_TEST);
    requestRedraw();

    _renderer.render(_scene, _camera);
    _renderer.resetState();
  }

  return {
    requestRedraw,
    initWebGLOverlay,
    getScene,
    getMap,
  };
}

you can read more about the topic of webgloverlayview in googles official docs. What is different here from their documentation is mainly that we have rewrote the code to fit better in to vue and added some custom three.js code and utilities.

initialize and testrun.

Now that the file is in place, all we need to do is to replace the code in our file here ./src/components/TheMap.vue

with this:

<script setup lang="ts">
  import { initMap } from '@/utils/mapUtils';
  import { tilesLoaded } from '@/utils/mapUtils';
  import { useWebGlOverlayView } from '@/composables/useWebGlOverlayView';
  const { initWebGLOverlay } = useWebGlOverlayView();

  const map = await initMap('map-mount');
  await tilesLoaded(map);
  await initWebGLOverlay(map);
</script>

and now run

npm run dev

There! Now we should have a fully functioning map with random boxes scattered around the world. The limit of what you can do now is only your imagination. Why not add build a game? For some inspirations you can check out my game that i built here

Did you like this tutorial?

You can support me, so that i can continue to make more tutorials like this one.

buy-me-a-coffee