How I build a 3D city on the web with three.js and open street maps.
For my research project at Howest, I decided to build a 3D version Lucas Bebber’s “Animated Map Path for Interactive Storytelling” project.
I will be using vector outlines from OSM to extrude the shape of buildings and add them to the 3js scene, which I will sebsequently anima
Setup
To work with node and npm packages I chose to use Vite.js. Vite is a build tool that aims to provide a faster and leaner development experience for modern web projects. It consists of two major parts:
• A dev server that provides rich feature enhancements over native ES modules, for example extremely fast Hot Module Replacement (HMR).
• A build command that bundles your code with Rollup, pre-configured to output highly optimized static assets for production.
Vite was chosen due to my pre-existing familiarity with it from using it on some Vue.js projects in the past, where it has proved to be fast and reliable.
Given its popularity, three.js was chosen as the preferred framework for this project, as that popularity gives rise to a large amount of documentation and tutorials.
Because I wanted the ability to integrate this research project into my own website later, I decided to develop it as an NPM package. This involved making two separate projects — the primary one for the actual 3D application, and one for a test website that would implement it.
In the project folder, the npm init command was used to create a package.json file, that would contain the package’s metadata such as its name, version, dependencies, entry point and otherinformation. Index.js will serve as the entry point to the package, with the src folder containing the code and sample folder containing default assets.
The plan was to split the functionality into individual JavaScript modules for better clarity and
maintainability, which ended up being initialize, globals, city, animate and path.
Initialization
Starting with the initialize module, it, for a lack of a better term, initializing three.js. The initialize() function creates and configures the Scene, Camera, Light and Renderer objects, selects the canvas element from the DOM and then attaches the renderer to it. Additionally, it is used to enable or disable debug information like an FPS counter and Axis visualizer.
Later, the module was used to initialize MapControls and create the animation loop, but more on that in the Interactivity section.
This npm package is called and will be referred to as Storymap
Admin Page (where you create a path)
To test whether the newly created npm package was working, npm create vite@latest was used
to initialize a separate demo project that would serve as the consumer of the package.
To install the local package in this project, npm link was used to create a symlink to storymap in the node_modules of the demo.
This project will be used to create and test the “administration” panel, where the web developer could create paths for new or updated tours.
After creating a canvas element in the index.html file and adding it to storymap’s configuration it’s possible to start testing by typing npm run dev in the terminal and going to localhost:3000 in the browser, where we see an empty three.js scene.
Because the storymap package is symlinked, the changes we make to it will automatically propagate to the demo project, so it is possible to keep the demo project running, and continuously write code and test changes, which results in an efficient developer process.
Data
I initially planned on using OSM Buildings to get the data, however I found
their documentation to be outdated, and I decided to switch to Overpass
Turbo so I could start development sooner and figure out how to use OSMB
later. OSMB uses GeoJSON, and Overpass Turbo allows exporting its data in
that format.
GeoJSON files contain various elements of the map like roads, parks, buildings represented by an array of various coordinates and metadata.
Buildings have outlines, holes, section, height or levels, and many other attributes.
in ‘Simple 3D Building’-format
JSON is a good choice as it’s prevalence on the web means native support for parsing it. Figuring out how to use the OverPass API took time, as the documentation doesn’t make it clear how to structure its query language, and using it remains challenging. You can see the final query below.
[out:json]
[bbox:{{bbox}}]
[timeout:30];
(
way["building"]({{bbox}});
relation["building"]["type"="multipolygon"]({{bbox}});
way["highway"]({{bbox}});
relation["highway"]["type"="polygon"]({{bbox}});
way["natural"="water"]({{bbox}});
way["water"="lake"]({{bbox}});
way["natural"="coastline"]({{bbox}});
way["waterway"="riverbank"]({{bbox}});
way["leisure"="park"]({{bbox}});
way["leisure"="garden"]({{bbox}});
);
out;
>;
out qt;
Rendering
The generation of buildings was split out into the city module. It parses the GeoJson file looking for buildings. The buildings are represented as an array of latitude and longitude coordinates of a polygon that forms the outline of the building, eventually more polygons that represents holes in that shape,
like an inside garden or overhang.
The first big challenge was caused by JavaScript itself, alongside the coordinate system in Three.js. The scene’s center is represented by 0,0,0 and the further away you move from it, the more inaccuracy and instability you will have, caused, among other things, by JavaScript’s poor floating-point precision.
The GeoJSON coordinates are in global latitude and longitude coordinates which are large floating-point numbers with a very small differences, which exacerbates the precision issue. [40]
Because of this, it was neccesary to normalize the global coordinates to local space. Taking the center of the area, a 3 rd party library called geolib was used
to calculate the distance from the center to each point, resulting in coordinates normalized to an origin, with a much larger standard deviation.
With this normalized data, I could then create a Three.js Shape and Geometry. Using Three.js’ built-in function, I was able to easily “punch out” the holes from the outline. This Geometry, together with a Material was then used to create a Mesh, and subsequently added to the scene. Due to three.js’s
XYZ orientation, I also had to rotate it 90 and 180 degrees in the X and Z axes respectively. Using this approach, I now had a very flat first version of my city. To make it 3D I used the levels attribute which represents the number of floors multiplied with an arbitrary to extrude those shapes.
Performance & Interaction
Now that the city was generated an issue popped up — the performance was terrible, with the fps going down to single digits. The solution was to merge all the buildings into one big mesh, instead of spawning individual objects for each building. This approach causes a side effect that all buildings will
have the same material, but it was worth it due to the massively improved performance. This fix was applied to all geometry that was generated later like roads, green areas, and waterways
The web developer needs to be able to load and browse the whole area and set waypoints for the tour path. This can be achieved by using 3js’ Map Controls.
It’s a subset of 3js’ Orbit Controls that works in a similar way to Google Maps and other popular digital mapping software, allowing the camera to orbit around the center of the page using the right click, and drag the camera around using the left click. They were imported and initialized in the initialize module. To then make them work, they need to update every frame, so they were added to the animation loop.
For creating the waypoints for the path, you need to have roads to select, and a way to translate 2D mouse coordinates into 3D space. For the latter, a Raycaster can be used to cast a ray from the mouse position towards the 3D scene and see what intersects it. From there, you can filter it to only be able to select roads.
For the former, a similar technique was used for the buildings. Using Overpass Turbo instead of OSM Buildings turned out to blessing in disguise, as OSM’s API only gives GeoJSON of buildings, so I would’ve needed to find and implement an additional API to get the road data. The Overpass API
query was adapted to include roads, their coordinates were normalized, and they were generated and added to the scene in the city module. Because the city looked drab, OverPass was also used to get data for green areas and waterways.
The path module was used to implement the Raycaster and other necessary controls to draw and export the path.
Three.js has a built-in Raycaster, so it was easy to implement it. It is linked to
an OnMouseMove event listener to continuously cast rays, to show whether
the user has a road selected or not.
Left mouse click is used to set a waypoint, with various keyboard
buttons responsible for Saving, Resetting, Undo and Redo functionality.
The path exports into a JSON array. Once the developer has started creating
a path, I need to make it follow the mouse. Because of this, until clicked, the
last point of the path is linked to the mouse position, also using the OnMouseMove event listener.
Client (where you can scroll to animate the path)
To test the client-side implementation, I created a new project and configured it in a similar manner as the admin project, using vite to bootstrap the project and using npm link to install the storymap package. The one change was that this time, instead of taking up the whole page, the scene took half, with the other half being used to show information about the Points of Interest.
For the client, the only control they should have over the interaction is scrolling in the browser. They shouldn’t be able to interact with the three.js scene directly, like how the users can’t interact with the canvas in Lucas’ demo.
In short, when the client scrolls down in the browser, the path should appear and animate out from the starting position and finish once the user scrolls to the bottom with the camera following along.
This was one of the most difficult parts of the project.
To summarize, the path is drawn as one Line object with multiple points. As the user scrolls down, the last point is continually updated to make it appear to animate. To get the coordinates of this point, a getScrollPercentage() function is attached to the scroll event listener and used to calculate the
browser scroll position.
This percentage is then used to continuously manipulate which pair of points is selected as the start and end point, with the lerpVectors function used to interpolate between
them and calculate what the current last point of the path should be.
Let’s make it clear with an example. Let’s take a path with 11 individual points. The first one is drawn from the start, so you must divide the total page height in percent by 10.
1. This means that for every 10% we scroll, we pass a point. The global scroll percentage
determines between which pair of points we need to interpolate.
2. If the user has scrolled to 26.53 percent, we need to, using interpolation, calculate the
coordinates of the point at 65.3% between the 3 rd and 4 th points.
3. This coordinate is used to update the last position of the Path. This happens every time the user
scrolls, giving the illusion of the path shrinking or growing.
4. The camera’s position was offset to behind the to the side of the path, with the last path
position acting as the target, and it was moved together with the path.
The final result of the research consists of 3 distinct projects: the storymap npm package that is responsible for generating and parsing the map data, an admin project that allows for the creation of paths on the map, and a client
project that animates the created path through the city.
Try it!
Demos are available online at:
• https://tania.tours/demo
The source code is available on Github at https://github.com/storytellingmap