├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── gh-pages.yml │ ├── publish.yml │ └── run-tests-for-prs.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── examples ├── .gitignore ├── README.md ├── assets │ ├── big_buck_bunny_720p_1mb.mp4 │ ├── lowpoly-sedan.glb │ ├── readme-header.gif │ └── ubi-icon.png ├── jsm │ ├── load-maps-api.js │ └── old-browser-handler.js ├── package.json ├── scripts │ └── write-index-html.js └── src │ ├── car.html │ ├── car.js │ ├── cube.html │ ├── cube.js │ ├── video.html │ ├── video.js │ ├── wireframe.html │ └── wireframe.js ├── package-lock.json ├── package.json ├── src ├── geo-utils.ts ├── index.ts ├── threejs-overlay-view.ts └── types.ts └── tsconfig.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy examples to gh-pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build-deploy: 10 | name: 'Build and Deploy Examples to gh-pages' 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '15.x' 17 | - run: npm install 18 | - name: Build Examples 19 | run: npm run build 20 | working-directory: examples 21 | env: 22 | GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} 23 | GOOGLE_MAPS_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }} 24 | - name: Deploy to gh-pages 25 | uses: peaceiris/actions-gh-pages@v3 26 | with: 27 | github_token: ${{ secrets.GITHUB_TOKEN }} 28 | publish_dir: ./examples/dist 29 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | publish: 10 | name: Publish npm-package 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '15.x' 17 | registry-url: 'https://registry.npmjs.org' 18 | - run: npm install 19 | - run: npm test 20 | - run: npm publish 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_BOT_ACCESS_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/run-tests-for-prs.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: '15.x' 18 | registry-url: 'https://registry.npmjs.org' 19 | - run: npm install 20 | - run: npm test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .parcel-cache 4 | /.env 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.* 2 | package-lock.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": false, 9 | "jsxBracketSameLine": true, 10 | "arrowParens": "avoid" 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2021 Ubilabs GmbH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **Please note: this package is no longer maintained.** 2 | > 3 | > The official [`@googlemaps/three`](https://www.npmjs.com/package/@googlemaps/three) 4 | > package provides even more features, almost the same API and will be further maintained. 5 | 6 | # ThreejsOverlayView for Google Maps 7 | 8 | A wrapper for `google.maps.WebGLOverlayView` that takes care of the 9 | integration between three.js and the Google Maps JavaScript API. It lets 10 | you create Google Maps overlays directly with three.js. 11 | 12 |
13 | 14 |
15 | 16 | We recommend that you first make yourself familiar with 17 | [the official documentation][docs] for the WebGLOverlayView. The rest of the 18 | documentation will assume that you know how to load the Google Maps API and 19 | how to configure and create the map. 20 | 21 | To see what can be done with this new API, have a look at the demos we built: 22 | 23 | - [Feature Tour](https://goo.gle/maps-platform-webgl-tour) 24 | - [Travel App Demo](https://goo.gle/maps-platform-webgl-travel-demo) 25 | 26 | [docs]: https://developers.google.com/maps/documentation/javascript/webgl 27 | 28 | ## Installation 29 | 30 | You can install this module via npm: 31 | 32 | ```sh 33 | npm install @ubilabs/threejs-overlay-view 34 | ``` 35 | 36 | ## Examples 37 | 38 | We provide a set of examples to quickly get you going. Those can be found in 39 | [`./examples`](./examples). They are also hosted on 40 | [GitHub Pages](https://ubilabs.github.io/threejs-overlay-view): 41 | 42 | - [Simple Cube](https://ubilabs.github.io/threejs-overlay-view/cube.html) 43 | - [Wireframe Building](https://ubilabs.github.io/threejs-overlay-view/wireframe.html) 44 | - [Animated Car](https://ubilabs.github.io/threejs-overlay-view/car.html) 45 | - [Video Texture](https://ubilabs.github.io/threejs-overlay-view/video.html) 46 | 47 | ## Usage 48 | 49 | Once you have loaded the Google Maps API, you first have to create a new 50 | map. Here's what this typically looks like for a full page map view: 51 | 52 | ```js 53 | const mapOptions = { 54 | mapId: YOUR_MAP_ID_HERE, 55 | disableDefaultUI: true, 56 | gestureHandling: 'greedy', 57 | 58 | center: {lat: 53.554486, lng: 10.007479}, 59 | zoom: 19, 60 | heading: 324, 61 | tilt: 65 62 | }; 63 | 64 | const map = new google.maps.Map(document.querySelector('#map'), mapOptions); 65 | ``` 66 | 67 | After that (or before - the order doesn't matter), you can create the 68 | `ThreejsOverlayView`. The only parameter you need to specify here is the 69 | reference point, which will become the origin of the coordinate system used 70 | by your three.js scene. 71 | 72 | ```js 73 | import ThreejsOverlayView from '@ubilabs/threejs-overlay-view'; 74 | 75 | // ... 76 | 77 | const overlay = new ThreejsOverlayView({ 78 | lat: 53.554486, 79 | lng: 10.007479 80 | }); 81 | ``` 82 | 83 | With both of those initialized, you can add your three.js objects to the 84 | scene, and finally add the overlay to the map: 85 | 86 | ```js 87 | import {BoxGeometry, Mesh, MeshBasicMaterial} from 'three'; 88 | 89 | // ... 90 | 91 | const scene = overlay.getScene(); 92 | const box = new Mesh( 93 | new BoxGeometry(50, 50, 50), 94 | new MeshBasicMaterial({color: 0xff0000}) 95 | ); 96 | scene.add(box); 97 | 98 | overlay.setMap(map); 99 | ``` 100 | 101 | And that's it. With this, you can already add any 3D object to the map. 102 | 103 | Of course, there is a bit more to know about. In the following sections, 104 | we'll talk about the world space coordinates and the relation to 105 | geographic coordinates, about lifecycle hooks and implementing animated 106 | content, and finally about raycasting and implementing interactive elements. 107 | 108 | ### Coordinate System and Georeferencing 109 | 110 | The coordinate system that you see as world coordinates is a right-handed 111 | coordinate system in z-up orientation. The y-axis is pointing true north, 112 | and the x-axis is pointing east. The units are meters. So the point 113 | `new Vector3(0, 50, 10)` would be 10 meters above ground and 50 meters to 114 | the east of your specified reference point. 115 | 116 | Now, we don't always want to use only meters in a map view, quite often we 117 | have to deal with multiple points specified in geographic coordinates. For 118 | this, the ThreejsOverlayView provides two utility functions to convert 119 | between latitude/longitude and world space coordinates: 120 | 121 | ```js 122 | const vector3 = overlay.latLngAltToVector3({lat, lng}); 123 | const {lat, lng, altitude} = overlay.vector3ToLatLngAlt(vector3); 124 | 125 | // as usual in three.js, both methods also accept a target-object 126 | // as the second parameter to prevent unnecessary object creation and 127 | // garbage-collection overhead, for example: 128 | overlay.latLngAltToVector3({lat, lng}, object3d.position); 129 | overlay.vector3ToLatLngAlt(vec3, latLngAltObject); 130 | ``` 131 | 132 | This only works well if points are within a reasonable distance of each 133 | other. At some point, the numeric precision of 32-bit float values in the 134 | shaders will become a problem, and the precision of the position 135 | computations will no longer work. 136 | 137 | If coordinates are thousands of kilometers apart, you could either use 138 | multiple overlay instances or update the reference point (and all objects 139 | in your scene) depending on the current map-viewport using 140 | `overlay.setReferencePoint({lat, lng})`. 141 | 142 | ### Lifecycle Hooks and Animations 143 | 144 | Similar to the `WebGLOverlayView`, the `ThreejsOverlayView` also provides a 145 | set of lifecycle hooks that you can define to react to certain events in 146 | the overlays' lifecycle. There are three of them - all optional: `onAdd`, 147 | `update`, and `onRemove`. They all are called without any parameters and 148 | are not expected to have a return value. 149 | 150 | - `onAdd()` can be used to do some final setup of your scene before it 151 | gets added to the map. Be aware that this happens in the rendering 152 | lifecycle of the map, so you shouldn't do expensive operations here. 153 | - `update()` will be called immediately before each render of the map. 154 | Here you can update the state of your scene. This is mostly useful in 155 | situations where you want to run animations or update the interactive 156 | state of your scene. 157 | - `onRemove()` is symmetrical to onAdd and will be called after the 158 | overlay has been removed from the map. 159 | 160 | By default, the map uses the energy-efficient approach and only renders a 161 | new frame when the camera parameters changed. If you want to run 162 | animations or interactive content, you can manually trigger a full redraw 163 | of the map including all overlays by calling `overlay.requestRedraw()` 164 | whenever there is anything changed in your scene – or just at the end of 165 | the `update`-callback if you have an animation running that has to update 166 | on every frame: 167 | 168 | ```js 169 | let animationRunning = true; 170 | 171 | overlay.update = () => { 172 | // ...update your scene... 173 | 174 | if (animationRunning) { 175 | overlay.requestRedraw(); 176 | } 177 | }; 178 | ``` 179 | 180 | ### Raycasting and Interactions 181 | 182 | If you want to add interactivity to any three.js content, you typically 183 | have to implement raycasting. We took care of that for you, and the 184 | ThreejsOverlayView provides a method `overlay.raycast()` for this. To make 185 | use of this, you first have to keep track of mouse movements on the map: 186 | 187 | ```js 188 | import {Vector2} from 'three'; 189 | 190 | // ... 191 | 192 | const mapDiv = map.getDiv(); 193 | const mousePosition = new Vector2(); 194 | 195 | map.addListener('mousemove', ev => { 196 | const {domEvent} = ev; 197 | const {left, top, width, height} = mapDiv.getBoundingClientRect(); 198 | 199 | const x = domEvent.clientX - left; 200 | const y = domEvent.clientY - top; 201 | 202 | mousePosition.x = 2 * (x / width) - 1; 203 | mousePosition.y = 1 - 2 * (y / height); 204 | 205 | // since the actual raycasting is happening in the update function, 206 | // we have to make sure that it will be called for the next frame. 207 | overlay.requestRedraw(); 208 | }); 209 | ``` 210 | 211 | With the mouse position being always up to date, you can then use the 212 | `raycast()` function in the update callback. In this example, we change the 213 | color of the object under the cursor: 214 | 215 | ```js 216 | const DEFAULT_COLOR = 0xffffff; 217 | const HIGHLIGHT_COLOR = 0xff0000; 218 | 219 | let highlightedObject = null; 220 | 221 | overlay.update = () => { 222 | const intersections = overlay.raycast(mousePosition); 223 | if (highlightedObject) { 224 | highlightedObject.material.color.setHex(DEFAULT_COLOR); 225 | } 226 | 227 | if (intersections.length === 0) return; 228 | 229 | highlightedObject = intersections[0].object; 230 | highlightedObject.material.color.setHex(HIGHLIGHT_COLOR); 231 | }; 232 | ``` 233 | 234 | ## API Reference 235 | 236 | Described above is still not the complete picture, and there are some 237 | details left out for readability reasons. 238 | 239 | We plan to publish full API documentation at some point. Until then, please 240 | refer to the TypeScript source code or declaration files as well as our 241 | demos that contain a lot of comments. 242 | 243 | ## Contributing 244 | 245 | We are always happy to accept contributions from outside collaborators. For 246 | bugs or problems you encounter as well as questions, ideas and 247 | feature requests, please head over to our [issue tracker][]. 248 | 249 | When reporting issues, please try to be as concise as possible and provide 250 | a [reproducible example][] so we can quickly address the problem. 251 | 252 | [issue tracker]: https://github.com/ubilabs/threejs-overlay-view/issues 253 | [reproducible example]: https://stackoverflow.com/help/minimal-reproducible-example 254 | 255 | ### Setting up for Development and Running Examples Locally 256 | 257 | If you want to contribute code to the project, excellent. 258 | 259 | Setting up a local development environment is pretty straightforward and 260 | just needs [Node.js](https://nodejs.org/) to be installed on your system. 261 | 262 | Once you have cloned the repository, run 263 | 264 | ```sh 265 | npm install 266 | ``` 267 | 268 | in the cloned directory to install all dependencies. 269 | 270 | To run the examples, you will need to have an API-Key and MapId for 271 | the Google Maps JavaScript API. It's easiest to store those in a file 272 | `.env` in the project directory: 273 | 274 | ```sh 275 | GOOGLE_MAPS_API_KEY='your API key here' 276 | GOOGLE_MAPS_MAP_ID='your map ID' 277 | ``` 278 | 279 | Once you have that file in place, you can run 280 | 281 | ```sh 282 | npm start 283 | ``` 284 | 285 | to start the dev server. This will start the parcel server on 286 | http://localhost:1234 where you can access the examples (if port 1234 is 287 | already in use, a different URL will be shown in the console). 288 | 289 | ### Running Tests 290 | 291 | Tests are currently limited to checking for formatting and typescript errors. 292 | They can be run with 293 | 294 | ```sh 295 | npm run test 296 | ``` 297 | 298 | Please make sure that all tests are passing before opening a pull request. 299 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | /.parcel-cache 2 | /dist 3 | /node_modules 4 | /src/index.html -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # threejs-overlay-view Examples 2 | 3 | This is a mostly standalone repository with some usage-examples of the 4 | `ThreejsOverlayView`. Mostly here refers to the fact that it installs the 5 | `@ubilabs/threejs-overlay-view` module from the parent-directory instead of 6 | from npm. 7 | 8 | Similar to the `./examples/jsm` directory found in three.js this will also act 9 | as a place for extra functionality that could be useful in some use-cases but 10 | not often enough to warrant being included in the main module. 11 | 12 | ## [cube.html](https://ubilabs.github.io/threejs-overlay-view/cube.html) 13 | 14 | Basically our hello-world example, shows the basic functionality of adding 15 | 3d-objects to a map. 16 | 17 | ## [wireframe.html](https://ubilabs.github.io/threejs-overlay-view/wireframe.html) 18 | 19 | Shows how you can highlight your own buildings by rendering just outside the Google Maps buildings. 20 | 21 | ## [video.html](https://ubilabs.github.io/threejs-overlay-view/video.html) 22 | 23 | Import an HTML video into the 3D scene with the three.js VideoTexture class. 24 | 25 | ## [car.html](https://ubilabs.github.io/threejs-overlay-view/car.html) 26 | 27 | This example shows how you can animate a simple gltf-object along a path 28 | specified in lat/lng coordinates. 29 | -------------------------------------------------------------------------------- /examples/assets/big_buck_bunny_720p_1mb.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubilabs/threejs-overlay-view/88309bc2c52d5ec9a3f9d85d56559fb8904cfeee/examples/assets/big_buck_bunny_720p_1mb.mp4 -------------------------------------------------------------------------------- /examples/assets/lowpoly-sedan.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubilabs/threejs-overlay-view/88309bc2c52d5ec9a3f9d85d56559fb8904cfeee/examples/assets/lowpoly-sedan.glb -------------------------------------------------------------------------------- /examples/assets/readme-header.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubilabs/threejs-overlay-view/88309bc2c52d5ec9a3f9d85d56559fb8904cfeee/examples/assets/readme-header.gif -------------------------------------------------------------------------------- /examples/assets/ubi-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubilabs/threejs-overlay-view/88309bc2c52d5ec9a3f9d85d56559fb8904cfeee/examples/assets/ubi-icon.png -------------------------------------------------------------------------------- /examples/jsm/load-maps-api.js: -------------------------------------------------------------------------------- 1 | import {Loader as MapsApiLoader} from '@googlemaps/js-api-loader'; 2 | 3 | const LOCAL_STORAGE_API_KEY = 'threejs-overlay-view-api-key'; 4 | const LOCAL_STORAGE_MAP_ID = 'threejs-overlay-view-map-id'; 5 | 6 | // fetch order: env > url params > local storage 7 | export function getMapsApiOptions() { 8 | const storage = window.localStorage; 9 | const url = new URL(location.href); 10 | 11 | let apiKey = process.env.GOOGLE_MAPS_API_KEY; 12 | let mapId = process.env.GOOGLE_MAPS_MAP_ID; 13 | 14 | if (!apiKey || !mapId) { 15 | apiKey = url.searchParams.get('apiKey'); 16 | mapId = url.searchParams.get('mapId'); 17 | 18 | apiKey 19 | ? storage.setItem(LOCAL_STORAGE_API_KEY, apiKey) 20 | : (apiKey = storage.getItem(LOCAL_STORAGE_API_KEY)); 21 | mapId 22 | ? storage.setItem(LOCAL_STORAGE_MAP_ID, mapId) 23 | : (mapId = storage.getItem(LOCAL_STORAGE_MAP_ID)); 24 | } 25 | return apiKey && mapId ? {apiKey, mapId} : {}; 26 | } 27 | 28 | export async function loadMapsApi(libraries = []) { 29 | const {apiKey, mapId} = getMapsApiOptions(); 30 | 31 | // fixme: 32 | // - nice to have: The error and UI should also contain information about 33 | // requested libraries (e.g. places-api) 34 | // - nice to have: replace alert with something ui-friendly 35 | 36 | if (!apiKey || !mapId) { 37 | alert(` 38 | Could not find apikey or mapId as URL parameters. 39 | Add them like this to the URL: example.com?apiKey=XXXX&mapId=XXXX. 40 | If you dont have an API key or map ID, visit the Google Maps documentation to find out on how to obtain them: 41 | https://developers.google.com/maps/documentation/javascript/webgl 42 | `); 43 | } 44 | 45 | const loader = new MapsApiLoader({ 46 | version: 'beta', 47 | apiKey, 48 | libraries 49 | }); 50 | 51 | await loader.load(); 52 | } 53 | -------------------------------------------------------------------------------- /examples/jsm/old-browser-handler.js: -------------------------------------------------------------------------------- 1 | function webglIsSupported() { 2 | try { 3 | const canvas = document.createElement('canvas'); 4 | return ( 5 | !!window.WebGLRenderingContext && 6 | (canvas.getContext('webgl') || canvas.getContext('experimental-webgl')) 7 | ); 8 | } catch (e) { 9 | return false; 10 | } 11 | } 12 | function supportsStaticImport() { 13 | const script = document.createElement('script'); 14 | return 'noModule' in script; 15 | } 16 | 17 | if (!supportsStaticImport()) { 18 | alert( 19 | 'Your browser does not support modules. Try updating your browser or switch to a different one.' 20 | ); 21 | } 22 | 23 | if (!webglIsSupported()) { 24 | alert( 25 | 'Your browser does not support WebGL. Try updating your browser or switch to a different one.' 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ubilabs/threejs-overlay-view-examples", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "start": "npm run build:index && parcel ./src/*.html", 7 | "clean": "rm -rf ./dist ./.parcel-cache", 8 | "build": "npm run build:index && npm run clean && parcel build --public-url ./ ./src/*.html && npm run copy-assets", 9 | "copy-assets": "cp -r ./assets ./dist/", 10 | "test": "prettier -c './{src,jsm}/**/*.{ts,js,html}'", 11 | "build:index": "node ./scripts/write-index-html" 12 | }, 13 | "author": "", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@parcel/transformer-image": "^2.0.0-nightly.2312", 17 | "parcel": "^2.0.0-nightly.688" 18 | }, 19 | "dependencies": { 20 | "@googlemaps/js-api-loader": "^1.11.4", 21 | "@ubilabs/threejs-overlay-view": "..", 22 | "three": "^0.129.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/scripts/write-index-html.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | fs.readdir('./src', (err, entries) => { 4 | if (err) { 5 | console.error(err); 6 | process.exit(1); 7 | } 8 | 9 | const html = entries 10 | .filter(entry => entry.endsWith('.html') && entry !== 'index.html') 11 | .map(demo => `${demo}
`) 12 | .join(''); 13 | fs.writeFileSync('./src/index.html', html); 14 | }); 15 | -------------------------------------------------------------------------------- /examples/src/car.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/src/car.js: -------------------------------------------------------------------------------- 1 | import ThreeJSOverlayView from '@ubilabs/threejs-overlay-view'; 2 | import {CatmullRomCurve3, Vector3} from 'three'; 3 | import {GLTFLoader} from 'three/examples/jsm/loaders/GLTFLoader'; 4 | import {Line2} from 'three/examples/jsm/lines/Line2.js'; 5 | import {LineMaterial} from 'three/examples/jsm/lines/LineMaterial.js'; 6 | import {LineGeometry} from 'three/examples/jsm/lines/LineGeometry.js'; 7 | import {getMapsApiOptions, loadMapsApi} from '../jsm/load-maps-api'; 8 | 9 | import CAR_MODEL_URL from 'url:../assets/lowpoly-sedan.glb'; 10 | 11 | const CAR_FRONT = new Vector3(0, 1, 0); 12 | 13 | const VIEW_PARAMS = { 14 | center: {lat: 53.554486, lng: 10.007479}, 15 | zoom: 18, 16 | heading: 40, 17 | tilt: 65 18 | }; 19 | 20 | const ANIMATION_DURATION = 12000; 21 | const ANIMATION_POINTS = [ 22 | {lat: 53.554473, lng: 10.008226}, 23 | {lat: 53.554913, lng: 10.008124}, 24 | {lat: 53.554986, lng: 10.007928}, 25 | {lat: 53.554775, lng: 10.006363}, 26 | {lat: 53.554674, lng: 10.006383}, 27 | {lat: 53.554473, lng: 10.006681}, 28 | {lat: 53.554363, lng: 10.006971}, 29 | {lat: 53.554453, lng: 10.008091}, 30 | {lat: 53.554424, lng: 10.008201}, 31 | {lat: 53.554473, lng: 10.008226} 32 | ]; 33 | 34 | const mapContainer = document.querySelector('#map'); 35 | const tmpVec3 = new Vector3(); 36 | 37 | async function main() { 38 | const map = await initMap(); 39 | 40 | const overlay = new ThreeJSOverlayView(VIEW_PARAMS.center); 41 | const scene = overlay.getScene(); 42 | 43 | overlay.setMap(map); 44 | 45 | // create a Catmull-Rom spline from the points to smooth out the corners 46 | // for the animation 47 | const points = ANIMATION_POINTS.map(p => overlay.latLngAltToVector3(p)); 48 | const curve = new CatmullRomCurve3(points, true, 'catmullrom', 0.2); 49 | curve.updateArcLengths(); 50 | 51 | const trackLine = createTrackLine(curve); 52 | scene.add(trackLine); 53 | 54 | let carModel = null; 55 | loadCarModel().then(obj => { 56 | carModel = obj; 57 | scene.add(carModel); 58 | 59 | // since loading the car-model happened asynchronously, we need to 60 | // explicitly trigger a redraw. 61 | overlay.requestRedraw(); 62 | }); 63 | 64 | // the update-function will animate the car along the spline 65 | overlay.update = () => { 66 | trackLine.material.resolution.copy(overlay.getViewportSize()); 67 | 68 | if (!carModel) return; 69 | 70 | const animationProgress = 71 | (performance.now() % ANIMATION_DURATION) / ANIMATION_DURATION; 72 | 73 | curve.getPointAt(animationProgress, carModel.position); 74 | curve.getTangentAt(animationProgress, tmpVec3); 75 | carModel.quaternion.setFromUnitVectors(CAR_FRONT, tmpVec3); 76 | 77 | overlay.requestRedraw(); 78 | }; 79 | } 80 | 81 | /** 82 | * Load the Google Maps API and create the fullscreen map. 83 | */ 84 | async function initMap() { 85 | const {mapId} = getMapsApiOptions(); 86 | await loadMapsApi(); 87 | 88 | return new google.maps.Map(mapContainer, { 89 | mapId, 90 | disableDefaultUI: true, 91 | backgroundColor: 'transparent', 92 | gestureHandling: 'greedy', 93 | ...VIEW_PARAMS 94 | }); 95 | } 96 | 97 | /** 98 | * Create a mesh-line from the spline to render the track the car is driving. 99 | */ 100 | function createTrackLine(curve) { 101 | const numPoints = 10 * curve.points.length; 102 | const curvePoints = curve.getSpacedPoints(numPoints); 103 | const positions = new Float32Array(numPoints * 3); 104 | 105 | for (let i = 0; i < numPoints; i++) { 106 | curvePoints[i].toArray(positions, 3 * i); 107 | } 108 | 109 | const trackLine = new Line2( 110 | new LineGeometry(), 111 | new LineMaterial({ 112 | color: 0x0f9d58, 113 | linewidth: 5 114 | }) 115 | ); 116 | 117 | trackLine.geometry.setPositions(positions); 118 | 119 | return trackLine; 120 | } 121 | 122 | /** 123 | * Load and prepare the car-model for animation. 124 | */ 125 | async function loadCarModel() { 126 | const loader = new GLTFLoader(); 127 | 128 | return new Promise(resolve => { 129 | loader.load(CAR_MODEL_URL, gltf => { 130 | const group = gltf.scene; 131 | const carModel = group.getObjectByName('sedan'); 132 | 133 | carModel.scale.setScalar(3); 134 | carModel.rotation.set(Math.PI / 2, 0, Math.PI, 'ZXY'); 135 | 136 | resolve(group); 137 | }); 138 | }); 139 | } 140 | 141 | main().catch(err => console.error(err)); 142 | -------------------------------------------------------------------------------- /examples/src/cube.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/src/cube.js: -------------------------------------------------------------------------------- 1 | import {Mesh, MeshStandardMaterial, BoxGeometry} from 'three'; 2 | import ThreeJSOverlayView from '@ubilabs/threejs-overlay-view'; 3 | 4 | import {getMapsApiOptions, loadMapsApi} from '../jsm/load-maps-api'; 5 | 6 | const VIEW_PARAMS = { 7 | center: { 8 | lat: 53.554, 9 | lng: 10.007 10 | }, 11 | tilt: 67.5, 12 | heading: 60, 13 | zoom: 18 14 | }; 15 | 16 | async function main() { 17 | const map = await initMap(); 18 | 19 | const overlay = new ThreeJSOverlayView({ 20 | ...VIEW_PARAMS.center 21 | }); 22 | overlay.setMap(map); 23 | 24 | const scene = overlay.getScene(); 25 | const cube = new Mesh( 26 | new BoxGeometry(20, 20, 20), 27 | new MeshStandardMaterial({color: 0xff0000}) 28 | ); 29 | 30 | const cubeLocation = {...VIEW_PARAMS.center, altitude: 50}; 31 | overlay.latLngAltToVector3(cubeLocation, cube.position); 32 | 33 | scene.add(cube); 34 | } 35 | 36 | async function initMap() { 37 | const {mapId} = getMapsApiOptions(); 38 | await loadMapsApi(); 39 | 40 | return new google.maps.Map(document.querySelector('#map'), { 41 | mapId, 42 | disableDefaultUI: true, 43 | backgroundColor: 'transparent', 44 | gestureHandling: 'greedy', 45 | ...VIEW_PARAMS 46 | }); 47 | } 48 | 49 | main().catch(err => { 50 | console.error('uncaught error in main: ', err); 51 | }); 52 | -------------------------------------------------------------------------------- /examples/src/video.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/src/video.js: -------------------------------------------------------------------------------- 1 | import { 2 | Mesh, 3 | VideoTexture, 4 | MeshBasicMaterial, 5 | FrontSide, 6 | PlaneGeometry 7 | } from 'three'; 8 | import ThreeJSOverlayView from '@ubilabs/threejs-overlay-view'; 9 | import {getMapsApiOptions, loadMapsApi} from '../jsm/load-maps-api'; 10 | import VIDEO_URL from 'url:../assets/big_buck_bunny_720p_1mb.mp4'; 11 | 12 | const SCREEN_SIZE = [50, 25]; 13 | const ALTITUDE_OFFSET = 3; 14 | const SCREEN_POSITION = { 15 | lat: 53.55450742, 16 | lng: 10.0074296368, 17 | altitude: SCREEN_SIZE[1] / 2 + ALTITUDE_OFFSET 18 | }; 19 | const SCREEN_ROTATION = [Math.PI / 2, 0, Math.PI / 13]; 20 | 21 | const VIEW_PARAMS = { 22 | center: { 23 | lat: SCREEN_POSITION.lat, 24 | lng: SCREEN_POSITION.lng 25 | }, 26 | heading: 324, 27 | tilt: 65, 28 | zoom: 19 29 | }; 30 | 31 | async function main() { 32 | const map = await initMap(); 33 | 34 | const overlay = new ThreeJSOverlayView({ 35 | ...VIEW_PARAMS.center 36 | }); 37 | overlay.setMap(map); 38 | 39 | const scene = overlay.getScene(); 40 | const video = document.createElement('video'); 41 | 42 | video.src = VIDEO_URL; 43 | video.loop = true; 44 | video.muted = true; 45 | video.autoplay = true; 46 | video.load(); 47 | video.play(); 48 | 49 | const videoTexture = new VideoTexture(video); 50 | const videoMaterial = new MeshBasicMaterial({ 51 | map: videoTexture, 52 | side: FrontSide 53 | }); 54 | 55 | const screenGeometry = new PlaneGeometry(...SCREEN_SIZE); 56 | const screen = new Mesh(screenGeometry, videoMaterial); 57 | 58 | overlay.latLngAltToVector3(SCREEN_POSITION, screen.position); 59 | screen.rotation.order = 'ZYX'; 60 | screen.rotation.set(...SCREEN_ROTATION); 61 | 62 | scene.add(screen); 63 | overlay.update = () => overlay.requestRedraw(); 64 | } 65 | 66 | async function initMap() { 67 | const {mapId} = getMapsApiOptions(); 68 | await loadMapsApi(); 69 | 70 | return new google.maps.Map(document.querySelector('#map'), { 71 | mapId, 72 | disableDefaultUI: true, 73 | backgroundColor: 'transparent', 74 | gestureHandling: 'greedy', 75 | ...VIEW_PARAMS 76 | }); 77 | } 78 | 79 | main().catch(err => { 80 | console.error('uncaught error in main: ', err); 81 | }); 82 | -------------------------------------------------------------------------------- /examples/src/wireframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/src/wireframe.js: -------------------------------------------------------------------------------- 1 | import { 2 | Mesh, 3 | MeshStandardMaterial, 4 | Vector3, 5 | LineMaterial, 6 | Shape, 7 | ExtrudeGeometry, 8 | TextureLoader, 9 | MeshBasicMaterial, 10 | PlaneGeometry 11 | } from 'three'; 12 | import {LineSegmentsGeometry} from 'three/examples/jsm/lines/LineSegmentsGeometry'; 13 | import {Line2} from 'three/examples/jsm/lines/Line2.js'; 14 | import {LineMaterial} from 'three/examples/jsm/lines/LineMaterial.js'; 15 | import UBI_ICON from 'url:../assets/ubi-icon.png'; 16 | import ThreeJSOverlayView from '@ubilabs/threejs-overlay-view'; 17 | 18 | import {getMapsApiOptions, loadMapsApi} from '../jsm/load-maps-api'; 19 | 20 | const VIEW_PARAMS = { 21 | center: { 22 | lat: 53.55493986295417, 23 | lng: 10.007137126703523 24 | }, 25 | heading: 324.66666666666674, 26 | tilt: 65.66666666666667, 27 | zoom: 19.43375 28 | }; 29 | 30 | const BUILDING_HEIGHT = 31; 31 | const BUILDING_LINE_COLOR = 0xffffff; 32 | const BUILDING_FILL_COLOR = 0x000000; 33 | const Z_FIGHTING_OFFSET = 0.001; 34 | const LOGO_SIZE = [16, 16]; 35 | const LOGO_POSITION = { 36 | lat: 53.55463986295417, 37 | lng: 10.007317126703523, 38 | altitude: BUILDING_HEIGHT + Z_FIGHTING_OFFSET 39 | }; 40 | const LOGO_ROTATION_Z = Math.PI / 12; 41 | const COLOR_CHANGE_DURATION = 30; // completes one hue cycle in x seconds 42 | 43 | async function main() { 44 | const map = await initMap(); 45 | 46 | const overlay = new ThreeJSOverlayView(VIEW_PARAMS.center); 47 | overlay.setMap(map); 48 | 49 | initScene(overlay).then(() => overlay.requestRedraw()); 50 | } 51 | 52 | async function initMap() { 53 | const {mapId} = getMapsApiOptions(); 54 | await loadMapsApi(); 55 | 56 | return new google.maps.Map(document.querySelector('#map'), { 57 | mapId, 58 | disableDefaultUI: true, 59 | backgroundColor: 'transparent', 60 | gestureHandling: 'greedy', 61 | ...VIEW_PARAMS 62 | }); 63 | } 64 | 65 | async function initScene(overlay) { 66 | const scene = overlay.getScene(); 67 | 68 | const wireframePath = [ 69 | [10.00787808793857, 53.554574397774715], 70 | [10.006971374559072, 53.55444226566294], 71 | [10.006565280581388, 53.55467172811441], 72 | [10.006569569754523, 53.55471724470295], 73 | [10.007768697370686, 53.554874083634], 74 | [10.007848668422987, 53.554846301309745], 75 | [10.007913475744536, 53.554604563663226] 76 | ]; 77 | const points = wireframePath.map(([lng, lat]) => 78 | overlay.latLngAltToVector3({lat, lng}) 79 | ); 80 | 81 | const line = getWireframe(points); 82 | scene.add(line); 83 | scene.add(getBuilding(points)); 84 | scene.add(await getLogo(overlay)); 85 | 86 | overlay.update = () => { 87 | const time = performance.now(); 88 | 89 | line.material.resolution.copy(overlay.getViewportSize()); 90 | line.material.color.setHSL( 91 | ((time * 0.001) / COLOR_CHANGE_DURATION) % 1, 92 | 0.69, 93 | 0.5 94 | ); 95 | 96 | overlay.requestRedraw(); 97 | }; 98 | } 99 | 100 | function getWireframe(points) { 101 | const positions = new Float32Array(18 * points.length).fill(0); 102 | 103 | const offset = new Vector3(0, 0, BUILDING_HEIGHT); 104 | const pointsTop = points.map(p => p.clone().add(offset)); 105 | 106 | for (let i = 0, n = points.length; i < n; i++) { 107 | points[i].toArray(positions, 6 * i); 108 | points[(i + 1) % n].toArray(positions, 6 * i + 3); 109 | } 110 | 111 | let topOffset = points.length * 6; 112 | for (let i = 0, n = pointsTop.length; i < n; i++) { 113 | pointsTop[i].toArray(positions, topOffset + 6 * i); 114 | pointsTop[(i + 1) % n].toArray(positions, topOffset + 6 * i + 3); 115 | } 116 | 117 | let vertEdgeOffset = points.length * 12; 118 | for (let i = 0; i < points.length; i++) { 119 | const p = points[i]; 120 | const pTop = pointsTop[i]; 121 | 122 | p.toArray(positions, vertEdgeOffset + 6 * i); 123 | pTop.toArray(positions, vertEdgeOffset + 6 * i + 3); 124 | } 125 | 126 | const lineGeometry = new LineSegmentsGeometry(); 127 | lineGeometry.instanceCount = 3 * points.length; 128 | lineGeometry.setPositions(positions); 129 | const lineMaterial = new LineMaterial({ 130 | color: BUILDING_LINE_COLOR, 131 | linewidth: 3 132 | }); 133 | 134 | const line = new Line2(lineGeometry, lineMaterial); 135 | line.computeLineDistances(); 136 | return line; 137 | } 138 | 139 | function getBuilding(points) { 140 | const buildingMaterial = new MeshStandardMaterial({ 141 | transparent: true, 142 | opacity: 0.5, 143 | color: BUILDING_FILL_COLOR 144 | }); 145 | 146 | const buildingShape = new Shape(); 147 | points.forEach((p, i) => { 148 | i === 0 ? buildingShape.moveTo(p.x, p.y) : buildingShape.lineTo(p.x, p.y); 149 | }); 150 | 151 | const extrudeSettings = { 152 | depth: BUILDING_HEIGHT, 153 | bevelEnabled: false 154 | }; 155 | const buildingGeometry = new ExtrudeGeometry(buildingShape, extrudeSettings); 156 | return new Mesh(buildingGeometry, buildingMaterial); 157 | } 158 | 159 | function getLogo(overlay) { 160 | return new Promise(resolve => { 161 | const loader = new TextureLoader(); 162 | loader.load(UBI_ICON, texture => { 163 | const logoGeometry = new PlaneGeometry(...LOGO_SIZE); 164 | const logoMaterial = new MeshBasicMaterial({ 165 | map: texture, 166 | transparent: true 167 | }); 168 | const logo = new Mesh(logoGeometry, logoMaterial); 169 | overlay.latLngAltToVector3(LOGO_POSITION, logo.position); 170 | logo.rotateZ(LOGO_ROTATION_Z); 171 | resolve(logo); 172 | }); 173 | }); 174 | } 175 | 176 | main().catch(err => { 177 | console.error('uncaught error in main: ', err); 178 | }); 179 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ubilabs/threejs-overlay-view", 3 | "version": "0.7.1", 4 | "description": "A wrapper for the Google Maps WebGLOverlayView that takes care of the integration between three.js and the Google Maps JavaScript API. It lets you create a Google Maps overlays directly with three.js.", 5 | "keywords": [ 6 | "google maps", 7 | "maps", 8 | "three", 9 | "threejs", 10 | "webgl" 11 | ], 12 | "author": "ubilabs GmbH ", 13 | "contributors": [ 14 | "Martin Schuhfuss ", 15 | "Andreas Kofler ", 16 | "Martin Kleppe " 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/ubilabs/threejs-overlay-view.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/ubilabs/threejs-overlay-view/issues" 24 | }, 25 | "homepage": "https://github.com/ubilabs/threejs-overlay-view#readme", 26 | "license": "MIT", 27 | "type": "module", 28 | "source": "src/index.ts", 29 | "exports": "./dist/threejs-overlay-view.modern.js", 30 | "main": "./dist/threejs-overlay-view.js", 31 | "types": "./dist/threejs-overlay-view.d.ts", 32 | "module": "./dist/threejs-overlay-view.module.js", 33 | "unpkg": "./dist/threejs-overlay-view.umd.js", 34 | "workspaces": [ 35 | "./examples" 36 | ], 37 | "scripts": { 38 | "test": "tsc -p tsconfig.json --noEmit && prettier -c './src/**/*.{ts,js}' && npm run test --workspaces", 39 | "prettier": "prettier --write './src/**/*.{ts,js}'", 40 | "start": "npm start --workspace examples", 41 | "build": "microbundle", 42 | "prepack": "npm install && npm run build" 43 | }, 44 | "peerDependencies": { 45 | "three": ">=0.125.0" 46 | }, 47 | "devDependencies": { 48 | "@types/google.maps": "^3.48.0", 49 | "@types/three": "^0.128.0", 50 | "microbundle": "^0.13.0", 51 | "prettier": "^2.3.0", 52 | "typescript": "^4.2.4" 53 | }, 54 | "publishConfig": { 55 | "access": "public" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/geo-utils.ts: -------------------------------------------------------------------------------- 1 | import {MathUtils, Vector3} from 'three'; 2 | import type {LatLngAltitudeLiteral} from './types'; 3 | 4 | // shorthands for math-functions, makes equations more readable 5 | const {sin, cos, pow, sqrt, atan2, asin, sign} = Math; 6 | const {degToRad, radToDeg, euclideanModulo} = MathUtils; 7 | 8 | const EARTH_RADIUS_METERS = 6371008.8; 9 | 10 | /** 11 | * Returns the true bearing (=compass direction) of the point from the origin. 12 | * @param point 13 | */ 14 | function getTrueBearing(point: Vector3): number { 15 | return euclideanModulo(90 - radToDeg(atan2(point.y, point.x)), 360); 16 | } 17 | 18 | /** 19 | * Computes the distance in meters between two coordinates using the 20 | * haversine formula. 21 | * @param from 22 | * @param to 23 | */ 24 | function distance( 25 | from: google.maps.LatLngLiteral, 26 | to: google.maps.LatLngLiteral 27 | ): number { 28 | const {lat: latFrom, lng: lngFrom} = from; 29 | const {lat: latTo, lng: lngTo} = to; 30 | 31 | const dLat = degToRad(latTo - latFrom); 32 | const dLon = degToRad(lngTo - lngFrom); 33 | const lat1 = degToRad(latFrom); 34 | const lat2 = degToRad(latTo); 35 | 36 | const a = 37 | pow(sin(dLat / 2), 2) + pow(sin(dLon / 2), 2) * cos(lat1) * cos(lat2); 38 | 39 | return 2 * atan2(sqrt(a), sqrt(1 - a)) * EARTH_RADIUS_METERS; 40 | } 41 | 42 | /** 43 | * Computes a destination-point from a geographic origin, distance 44 | * and true bearing. 45 | * @param origin 46 | * @param distance 47 | * @param bearing 48 | * @param target optional target to write the result to 49 | */ 50 | function destination( 51 | origin: google.maps.LatLngLiteral, 52 | distance: number, 53 | bearing: number, 54 | target: google.maps.LatLngLiteral = {lat: 0, lng: 0} 55 | ): google.maps.LatLngLiteral { 56 | const lngOrigin = degToRad(origin.lng); 57 | const latOrigin = degToRad(origin.lat); 58 | 59 | const bearingRad = degToRad(bearing); 60 | const radians = distance / EARTH_RADIUS_METERS; 61 | 62 | const latDestination = asin( 63 | sin(latOrigin) * cos(radians) + 64 | cos(latOrigin) * sin(radians) * cos(bearingRad) 65 | ); 66 | const lngDestination = 67 | lngOrigin + 68 | atan2( 69 | sin(bearingRad) * sin(radians) * cos(latOrigin), 70 | cos(radians) - sin(latOrigin) * sin(latDestination) 71 | ); 72 | 73 | target.lat = radToDeg(latDestination); 74 | target.lng = radToDeg(lngDestination); 75 | 76 | return target; 77 | } 78 | 79 | /** 80 | * Converts a point given in lat/lng or lat/lng/altitude-format to world-space coordinates. 81 | * @param point 82 | * @param reference 83 | * @param target optional target to write the result to 84 | */ 85 | export function latLngAltToVector3( 86 | point: LatLngAltitudeLiteral | google.maps.LatLngLiteral, 87 | reference: LatLngAltitudeLiteral, 88 | target: Vector3 = new Vector3() 89 | ): Vector3 { 90 | const dx = distance(reference, {lng: point.lng, lat: reference.lat}); 91 | const dy = distance(reference, {lng: reference.lng, lat: point.lat}); 92 | 93 | const sx = sign(point.lng - reference.lng); 94 | const sy = sign(point.lat - reference.lat); 95 | 96 | const {altitude = 0} = point; 97 | 98 | return target.set(sx * dx, sy * dy, altitude); 99 | } 100 | 101 | /** 102 | * Converts a point given in world-space coordinates into geographic format. 103 | * @param point 104 | * @param sceneAnchor 105 | * @param target optional target to write the result to 106 | */ 107 | export function vector3ToLatLngAlt( 108 | point: Vector3, 109 | sceneAnchor: LatLngAltitudeLiteral, 110 | target: LatLngAltitudeLiteral = {lat: 0, lng: 0, altitude: 0} 111 | ): LatLngAltitudeLiteral { 112 | const distance = point.length(); 113 | const bearing = getTrueBearing(point); 114 | 115 | destination(sceneAnchor, distance, bearing, target); 116 | target.altitude = point.z; 117 | 118 | return target; 119 | } 120 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './threejs-overlay-view'; 2 | export {default} from './threejs-overlay-view'; 3 | -------------------------------------------------------------------------------- /src/threejs-overlay-view.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DirectionalLight, 3 | HemisphereLight, 4 | Intersection, 5 | Matrix4, 6 | Object3D, 7 | PerspectiveCamera, 8 | Raycaster, 9 | Scene, 10 | Vector2, 11 | Vector3, 12 | WebGL1Renderer 13 | } from 'three'; 14 | 15 | import {latLngAltToVector3, vector3ToLatLngAlt} from './geo-utils'; 16 | 17 | import type {LatLngAltitudeLiteral, RaycastOptions} from './types'; 18 | 19 | const projectionMatrixInverse = new Matrix4(); 20 | 21 | /** 22 | * A wrapper for google.maps.WebGLOverlayView handling the details of the 23 | * integration with three.js. 24 | */ 25 | export default class ThreeJSOverlayView { 26 | /** 27 | * The WebGLOverlayView instance being used. Aggregation is used instead 28 | * of extending the class to allow for this class to be parsed before the 29 | * google-maps API has been loaded. 30 | */ 31 | protected readonly overlay: google.maps.WebGLOverlayView; 32 | 33 | /** 34 | * The three.js camera-instance. When interacting with this camera it is 35 | * important to know that the world-matrix doesn't contain any useful 36 | * information. Position and orientation of the camera are instead part 37 | * of the projectionMatrix. 38 | */ 39 | protected readonly camera: PerspectiveCamera; 40 | 41 | /** 42 | * The three.js renderer instance. This is initialized in the 43 | * onContextRestored-callback. 44 | */ 45 | protected renderer: WebGL1Renderer | null; 46 | 47 | /** 48 | * The three.js Scene instance. 49 | */ 50 | protected scene: Scene; 51 | 52 | /** 53 | * The geographic reference-point in latitude/longitude/altitude above ground. 54 | */ 55 | protected referencePoint: LatLngAltitudeLiteral; 56 | 57 | /** 58 | * The viewport size used by the renderer, this is always kept up-to-date. 59 | */ 60 | protected viewportSize: Vector2 = new Vector2(); 61 | 62 | /** 63 | * The raycaster used by the `raycast()` method. 64 | */ 65 | protected raycaster: Raycaster = new Raycaster(); 66 | 67 | /** 68 | * This callback is called when the overlay has been added to the map, but 69 | * before it is first rendered. 70 | */ 71 | onAdd: (() => void) | null = null; 72 | 73 | /** 74 | * This callback is called after the overlay has been removed from the map. 75 | */ 76 | onRemove: (() => void) | null = null; 77 | 78 | /** 79 | * This callback is called for every frame being rendered. 80 | */ 81 | update: (() => void) | null = null; 82 | 83 | /** 84 | * Creates a new ThreejsOverlayView with the specified origin-point. 85 | * @param referencePoint 86 | */ 87 | constructor( 88 | referencePoint: LatLngAltitudeLiteral | google.maps.LatLngLiteral 89 | ) { 90 | this.referencePoint = {altitude: 0, ...referencePoint}; 91 | this.overlay = this.initWebGLOverlayView(); 92 | this.renderer = null; 93 | this.scene = this.initScene(); 94 | this.camera = new PerspectiveCamera(); 95 | } 96 | 97 | /** 98 | * Sets the map-instance this overlay should be shown on. 99 | */ 100 | setMap(map: google.maps.Map) { 101 | this.overlay.setMap(map); 102 | } 103 | 104 | /** 105 | * Sets the geographic coordinates of the reference point where the scene will 106 | * have it's origin. 107 | * @param referencePoint 108 | */ 109 | setReferencePoint( 110 | referencePoint: LatLngAltitudeLiteral | google.maps.LatLngLiteral 111 | ) { 112 | this.referencePoint = {altitude: 0, ...referencePoint}; 113 | } 114 | 115 | /** 116 | * Returns the scene-instance that is rendered on the map. 117 | */ 118 | getScene(): Scene { 119 | return this.scene; 120 | } 121 | 122 | /** 123 | * Returns the viewport-size used by the map and the three.js renderer. 124 | */ 125 | getViewportSize(): Vector2 { 126 | return this.viewportSize; 127 | } 128 | 129 | /** 130 | * Requests a full redraw of the map and all overlays for the next frame. 131 | * 132 | * This has to be called whenever changes to the scene were made to make 133 | * sure they are actually rendered. 134 | */ 135 | requestRedraw() { 136 | this.overlay.requestRedraw(); 137 | } 138 | 139 | /** 140 | * Runs raycasting for the specified screen-coordinate against the scene 141 | * or the optionally specified list of objects. 142 | * @param normalizedScreenPoint the screen-coordinates, x/y in range [-1, 1], 143 | * y pointing up. 144 | * @param objects optional list of objects to consider, raycasts against the 145 | * complete scene if none are specified 146 | * @param options.recursive set to true to also check children of the specified 147 | * objects for intersections. Only applies when a list of objects is 148 | * specified. 149 | * @param options.updateMatrix set this to false to skip updating the 150 | * inverse-projection-matrix (useful if you need to run multiple 151 | * raycasts for the same frame). 152 | * @param options.raycasterParameters parameters to pass on to the raycaster 153 | * @return returns the list of intersections 154 | */ 155 | raycast( 156 | normalizedScreenPoint: Vector2, 157 | objects: Object3D | Object3D[] | null = null, 158 | options: RaycastOptions = {updateMatrix: true, recursive: false} 159 | ): Intersection[] { 160 | let {updateMatrix, recursive, raycasterParameters} = options; 161 | 162 | // the mvp-matrix used to render the previous frame is still stored in 163 | // this.camera.projectionMatrix so we don't need to recompute it. That 164 | // matrix would transform meters in our world-space (relative to 165 | // this.referencePoint) to clip-space/NDC coordinates. The inverse matrix 166 | // created here does the exact opposite and convert NDC-coordinates to 167 | // world-space 168 | if (updateMatrix) { 169 | projectionMatrixInverse.copy(this.camera.projectionMatrix).invert(); 170 | } 171 | 172 | // create two points with different depth from the mouse-position and 173 | // convert to world-space to setup the ray. 174 | this.raycaster.ray.origin 175 | .set(normalizedScreenPoint.x, normalizedScreenPoint.y, 0) 176 | .applyMatrix4(projectionMatrixInverse); 177 | 178 | this.raycaster.ray.direction 179 | .set(normalizedScreenPoint.x, normalizedScreenPoint.y, 0.5) 180 | .applyMatrix4(projectionMatrixInverse) 181 | .sub(this.raycaster.ray.origin) 182 | .normalize(); 183 | 184 | let oldRaycasterParams = this.raycaster.params; 185 | if (raycasterParameters) { 186 | this.raycaster.params = raycasterParameters; 187 | } 188 | 189 | if (objects === null) { 190 | objects = this.scene; 191 | recursive = true; 192 | } 193 | 194 | const results = Array.isArray(objects) 195 | ? this.raycaster.intersectObjects(objects, recursive) 196 | : this.raycaster.intersectObject(objects, recursive); 197 | 198 | // reset raycaster params to whatever they were before 199 | this.raycaster.params = oldRaycasterParams; 200 | 201 | return results; 202 | } 203 | 204 | /** 205 | * Converts geographic coordinates into world-space coordinates. 206 | * Optionally accepts a Vector3 instance as second parameter to write the value to. 207 | * @param point 208 | * @param target optional target the result will be written to 209 | */ 210 | latLngAltToVector3( 211 | point: LatLngAltitudeLiteral | google.maps.LatLngLiteral, 212 | target: Vector3 = new Vector3() 213 | ): Vector3 { 214 | return latLngAltToVector3(point, this.referencePoint, target); 215 | } 216 | 217 | /** 218 | * Converts world-space coordinates to geographic coordinates. 219 | * Optionally accepts a LatLngAltitudeLiteral instance to write the value to. 220 | * @param point 221 | * @param target optional target the result will be written to 222 | */ 223 | vector3ToLatLngAlt( 224 | point: Vector3, 225 | target: LatLngAltitudeLiteral = {lat: 0, lng: 0, altitude: 0} 226 | ): LatLngAltitudeLiteral { 227 | return vector3ToLatLngAlt(point, this.referencePoint, target); 228 | } 229 | 230 | /** 231 | * Initializes the threejs-renderer when the rendering-context becomes available. 232 | * @param gl 233 | */ 234 | protected onContextRestored(stateOptions: google.maps.WebGLStateOptions) { 235 | const {gl} = stateOptions; 236 | const mapGlCanvas = gl.canvas as HTMLCanvasElement; 237 | 238 | let renderer = new WebGL1Renderer({ 239 | canvas: mapGlCanvas, 240 | context: gl, 241 | ...gl.getContextAttributes() 242 | }); 243 | 244 | renderer.autoClear = false; 245 | renderer.autoClearDepth = false; 246 | 247 | const {width, height} = gl.canvas; 248 | this.viewportSize.set(width, height); 249 | this.renderer = renderer; 250 | } 251 | 252 | /** 253 | * Cleans up and destroy the renderer when the context becomes invalid. 254 | */ 255 | protected onContextLost() { 256 | if (!this.renderer) { 257 | return; 258 | } 259 | 260 | this.viewportSize.set(0, 0); 261 | this.renderer.dispose(); 262 | this.renderer = null; 263 | } 264 | 265 | /** 266 | * Renders a new frame. Is called by the maps-api when the camera parameters 267 | * changed or a redraw was requested. 268 | * @param gl 269 | * @param transformer 270 | */ 271 | 272 | protected onDraw(drawOptions: google.maps.WebGLDrawOptions) { 273 | const {gl, transformer} = drawOptions; 274 | 275 | if (!this.scene || !this.renderer) { 276 | return; 277 | } 278 | 279 | // fix: this appears to be a bug in the maps-API. onDraw will 280 | // continue to be called by the api after it has been removed 281 | // from the map. We should remove this once fixed upstream. 282 | if (this.overlay.getMap() === null) { 283 | return; 284 | } 285 | 286 | this.camera.projectionMatrix.fromArray( 287 | transformer.fromLatLngAltitude(this.referencePoint) 288 | ); 289 | 290 | const {width, height} = gl.canvas; 291 | this.viewportSize.set(width, height); 292 | this.renderer.setViewport(0, 0, width, height); 293 | 294 | if (this.update) { 295 | this.update(); 296 | } 297 | 298 | this.renderer.render(this.scene, this.camera); 299 | this.renderer.resetState(); 300 | } 301 | 302 | /** 303 | * Initializes the scene with basic lighting that mimics the lighting used for 304 | * the buildings on the map. 305 | * 306 | * At some point it might be possible to retrieve information about the actual lighting 307 | * used for the buildings (which is dependent on time-of-day) from the api, which 308 | * is why the light-setup is handled by the ThreejsOverlayView. 309 | */ 310 | protected initScene(): Scene { 311 | const scene = new Scene(); 312 | 313 | // create two three.js lights to illuminate the model (roughly approximates 314 | // the lighting of buildings in maps) 315 | const hemiLight = new HemisphereLight(0xffffff, 0x444444, 1); 316 | hemiLight.position.set(0, -0.2, 1).normalize(); 317 | 318 | const dirLight = new DirectionalLight(0xffffff); 319 | dirLight.position.set(0, 10, 100); 320 | 321 | scene.add(hemiLight, dirLight); 322 | 323 | return scene; 324 | } 325 | 326 | /** 327 | * Creates the google.maps.WebGLOverlayView instance 328 | */ 329 | protected initWebGLOverlayView(): google.maps.WebGLOverlayView { 330 | if (!google || !google.maps) { 331 | throw new Error( 332 | 'Google Maps API not loaded. Please make sure to create the ' + 333 | 'overlay after the API has been loaded.' 334 | ); 335 | } 336 | 337 | if (!google.maps.WebGLOverlayView) { 338 | throw new Error( 339 | 'WebGLOverlayView not found. Please make sure to load the ' + 340 | 'beta-channel of the Google Maps API.' 341 | ); 342 | } 343 | 344 | const overlay = new google.maps.WebGLOverlayView(); 345 | 346 | overlay.onAdd = wrapExceptionLogger(() => { 347 | if (this.onAdd === null) return; 348 | this.onAdd(); 349 | }); 350 | 351 | overlay.onRemove = wrapExceptionLogger(() => { 352 | if (this.onRemove === null) return; 353 | this.onRemove(); 354 | }); 355 | 356 | overlay.onDraw = wrapExceptionLogger(this.onDraw.bind(this)); 357 | 358 | overlay.onContextRestored = wrapExceptionLogger( 359 | this.onContextRestored.bind(this) 360 | ); 361 | 362 | overlay.onContextLost = wrapExceptionLogger(this.onContextLost.bind(this)); 363 | 364 | return overlay; 365 | } 366 | } 367 | 368 | // (hopefully) temporary solution to make sure exceptions wont be silently ignored. 369 | function wrapExceptionLogger(fn: T): T { 370 | return ((...args: any[]) => { 371 | try { 372 | return fn(...args); 373 | } catch (err) { 374 | console.error(err); 375 | throw err; 376 | } 377 | }) as any; 378 | } 379 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type {RaycasterParameters} from 'three'; 2 | 3 | export type LatLngAltitudeLiteral = { 4 | lat: number; 5 | lng: number; 6 | altitude: number; 7 | }; 8 | 9 | export interface RaycastOptions { 10 | recursive?: boolean; 11 | raycasterParameters?: RaycasterParameters; 12 | updateMatrix?: boolean; 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/**/*"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "esnext", 6 | "moduleResolution": "node", 7 | "jsx": "preserve", 8 | "baseUrl": "./", 9 | "paths": {}, 10 | "typeRoots": ["./node_modules/@types"], 11 | "allowSyntheticDefaultImports": true, 12 | "importsNotUsedAsValues": "error", 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "strict": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "resolveJsonModule": true, 19 | "useDefineForClassFields": true 20 | } 21 | } 22 | --------------------------------------------------------------------------------