Three.js programmatic 3D city modeling [OpenStreetMap]

For my research project at Howest, I decided to build a 3D version of Lucas Bebber's "Animated Map Paths for Interactive Storytelling" project.

I will use vector silhouettes from OSM to extrude the shapes of the buildings and add them to the 3js scene which I will then animate

insert image description here

Recommendation: Use NSDT editor to quickly build programmable 3D scenes

1. Development environment settings

For working with Node and npm packages, I chose to use Vite.js. Vite is a build tool designed to provide a faster and leaner development experience for modern web projects. It consists of two main parts:

  • The development server provides rich feature enhancements over native ES modules, such as extremely fast Hot Module Replacement (HMR).
  • A build command that bundles code with Rollup, preconfigured to output highly optimized static assets for production.

I chose Vite because I've used it in the past for some Vue.js projects, so I'm familiar with it, and it's proven to be fast and reliable.

Three.js was chosen as the framework of choice for this project due to its popularity, which has resulted in extensive documentation and tutorials.

Since I want to be able to integrate this research project into my own website in the future, I decided to develop it as an NPM package. This involved making two separate projects - the first for the actual 3D application and the other for a test website implementing the application.

In the project folder, the npm init command is used to create the package.json file, which contains metadata for the package, such as name, version, dependencies, entry points, and other information. Index.js will serve as the entry point for the package, with the src folder containing the code and the examples folder containing the default resources.

The plan is to split the functionality into separate JavaScript modules for clarity and maintainability, eventually being initialized, globals, cities, animations and paths.

2. Initialize the module

Start by initializing the module. The initialize() function creates and configures scene, camera, light, and renderer objects, selects a canvas element from the DOM, and attaches the renderer to it. Also, it is used to enable or disable debug information such as FPS counter and Axis visualizer.

This module is then used to initialize the MapControls and create animation loops, but see the Interactivity section for more.

This npm package is called Storymap

3. Create a management page

To test if the newly created npm package works, npm create vite@latest is used.

Initializes a separate demo project that will act as a consumer of the package.

To install the local package in this project, a symlink to Storymap is created in the demo's node_modules using npm link.

This project will be used to create and test an Admin panel where web developers can create paths for new or updated tours.

After creating the canvas element in the index.html file and adding it to the configuration of the storymap, the test can be started by typing npm run dev in the terminal and going to localhost:3000 in the browser, where we see an empty Three.js scene.

Since the storymap package is symlinked, changes we make to it will automatically be propagated to the demo project, so you can keep the demo project running and keep writing code and testing changes, allowing for an efficient development process.

4. OSM data

I originally planned to use OSM Buildings to fetch the data, but I found their documentation was outdated and I decided to switch to Overpass Turbo so I could start developing faster and figure out how to use OSMB. OSMB uses GeoJSON and Overpass Turbo allows exporting its data to JSON format.

GeoJSON files contain various elements of the map, such as roads, parks, buildings represented by various coordinates and arrays of metadata. Buildings have profiles, openings, sections, heights or levels, and many other properties.
insert image description here

An example model of a building and its metadata

JSON is a good choice because its popularity on the web means native support for parsing it. Figuring out how to use the OverPass API took some time, as the documentation doesn't make it clear how to structure its query language, and using it can still be 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;

5. OSM architectural rendering

Building generation is divided into city modules. It parses GeoJson files to find buildings. Buildings are represented as arrays of latitude and longitude coordinates of polygons forming the outline of the building, eventually more polygons representing holes in that shape, like an interior garden or overhang.

The first big challenge is caused by JavaScript itself and the coordinate system in Three.js. The center of the scene is represented by 0,0,0, and the farther away from it the less accurate and unstable it is, mainly due to JavaScript's poor floating point precision.

GeoJSON coordinates are global latitude and longitude coordinates, and they are large floats with small differences, exacerbating the precision problem.

Therefore, it is necessary to normalize the global coordinates to the local space. Occupies the center of the region, using a third-party library called geolib

Computes the distance from the center to each point, resulting in coordinates normalized to the origin, with a larger standard deviation.
insert image description here

grid showing coordinates relative to the origin

With this normalized data, I can create Three.js shapes and geometries. Using Three.js' built-in functionality, I was able to easily "punch" holes out of contours. This geometry is then used along with materials to create a mesh, which is then added to the scene.

Because of the three.jsXYZ orientation, I also have to rotate it 90 and 180 degrees on the X and Z axes respectively. Using this method, I now have a very flat first version of the city. To make it 3D, I used the level property, which represents the number of floors multiplied by an arbitrary value to extrude these shapes.

insert image description here

Cities in your browser!

6. Performance and interaction

Now that the cities are generated, a problem arises - performance sucks, FPS drops into single digits. The solution is to combine all buildings into one big mesh instead of generating separate objects for each building. This approach has the side effect that all buildings will have the same material, but it's worth it due to the massive performance increase. This fix applies to all subsequent generated geometry such as roads, green spaces and waterways

Web developers need to be able to load and navigate entire areas and set waypoints for tour paths. This can be achieved by using the three.js map control. It's a subset of the 3js Orbit control that works similarly to Google Maps and other popular digital mapping software, allowing right-click to rotate the camera around the center of the page and left-click to drag the camera. They are imported and initialized in the initialization module. In order for them to work, they need to update every frame, so they are added to the animation loop.

In order to create waypoints for a path, you need to select the road, and a method to convert 2D mouse coordinates to 3D space. For the latter, a raycaster can be used to cast a ray from the mouse position into the 3D scene and see what it intersects. From there, you can filter it to only select roads.

For the former, similar techniques are used for buildings. Using Overpass Turbo instead of OSM Buildings turned out to be a blessing in disguise, since OSM's API only provides GeoJSON for buildings, I needed to find and implement an additional API to get road data.

Overpass API queries are adjusted to include roads, their coordinates are normalized, they are generated and added to the scene in the city module. With the city looking drab, OverPass is also being used to capture data on green spaces and waterways.
insert image description here

the path being drawn

The path module is used to implement the Raycaster and other necessary controls to draw and export paths. Three.js has Raycaster built in, so it's easy to implement. It's linked to an OnMouseMove event listener that continuously casts a ray to show if the user has selected a path.

Left mouse clicks are used to set waypoints, with various keyboard buttons for save, reset, undo and redo functions.

Paths are exported into a JSON array. Once the developer starts creating a path, I need it to follow the mouse. So, before clicking, the last point of the path is linked to the mouse position, also using the OnMouseMove event listener.

7. Client

To test the client implementation, I created a new project and configured it in a similar way to the admin project, using vite to bootstrap the project and using npm link to install the Storymap package. The only change is that instead of taking up the entire page this time, the scene takes up half, with the other half being used to display information about points of interest.

For the client, their only control over the interaction is scrolling in the browser. They shouldn't be able to interact directly with the Three.js scene, just like the user couldn't interact with the canvas in the Lucas demo.

In short, as the client scrolls down in the browser, the path should appear and animate out from the start position, and finish when the user scrolls to the bottom and the camera follows.

This was one of the most difficult parts of the project.

insert image description here

Diagram showing how paths are calculated

In summary, a path is drawn as a Line object with multiple points. As the user scrolls down, the last dot keeps updating, making it appear to animate.

In order to get the coordinates of the point, the getScrollPercentage() function is attached to the scroll event listener and used to calculate the browser scroll position. Then use that percentage to continuously manipulate which pair of points to use as start and end points, and use the lerpVectors function to interpolate between the two and calculate what the last point of the current path should be.

Let us make this clear with an example. Let's choose a path with 11 individual points. The first one is drawn from scratch, so you have to divide the total page height (in percent) by 10.

  • This means that for every 10% we scroll, a point is passed. The global scroll percentage determines which pair of points we need to interpolate between.
  • If the user scrolls to 26.53%, we need to use interpolation to calculate the coordinates of the point that is 65.3% between points 3 and 4.
  • This coordinate is used to update the Path's last position. This happens every time the user scrolls, giving the illusion that the path is shrinking or growing.
  • The camera position is offset to the back of one side of the path, and the last path position is used as the target, which moves with the path.

insert image description here

project structure

The end result of the research consisted of 3 distinct projects: the Storymap npm package responsible for generating and parsing map data, the management project allowing creation of paths on the map, and the client.

The project animates the created paths through the city.
insert image description here

You can click here to view the online demo, and the source code is available from GitHub .


Original link: OSM+three.js to create a 3D city—BimAnt

Guess you like

Origin blog.csdn.net/shebao3333/article/details/132384145