Drawing 320 lots in three.js: instancing, a window shader and two performance mistakes
We build a small 3D city in the browser with three.js and react-three-fiber. You can walk and drive through it. This is how the buildings are drawn, plus two performance mistakes we made. File names are given where they help. The city is a function of a seed The map is a 9 by 9 grid of blocks. The centre block is a park, so there are 80 built blocks with four lots each: 320 lots. Zones come from…
Three.js was used to create a small 3D city in the browser, which can be explored and navigated. The city consists of 320 lots arranged in a 9x9 grid of blocks. The central block features a park, leaving 80 built blocks, each containing four lots. Various zones are determined by their distance from the central park: the ring surrounding it is classified as downtown, the next ring as midtown, and the remaining areas as outskirts.
The city is constructed using three.js and react-three-fiber, and all elements are generated dynamically. Each lot generates its own random seed for unique elements like houses, roofs, chimneys, and trees, ensuring the city remains consistent across visits while requiring no storage or transmission of data.
The construction of the city occurs within the generate() function in src/city/layout.ts, which builds the entire city upon the initial call and caches the result. Each lot possesses its own seeded generator, allowing for consistent outcomes while avoiding any storage or transmission of data. The uniquity of each lot is determined by its id, and changing the sequence of random draws makes every house distinct.
Each lot type comprises various meshes, including volumes, roofs, roofs' clutter, placeholder signs, and posts. The placeholder houses and low-rise blocks are rendered using InstancedMesh, which combines one mesh for volumes, one for roofs, one for roof clutter, one for placeholder signs, and one for posts. A helper function, place(), updates the instance matrix for color, facade style, and matrix computation for frustum culling.
Building a lot requires replacing its placeholder with an actual house. This is achieved by setting the instance's scale to zero and adjusting the matrices. Despite the zero-scale instance still being a part of the draw call, this method helps maintain performance. Window placement is handled by a shader, which determines their size and positioning.
The shader reads the instance matrix to compute window sizes, allowing for a consistent appearance across buildings. Additionally, window opacity is controlled by a hash-based function that considers the face size and a per-face seed, adjusting lighting based on the time of night.
Several performance issues were identified during the development process. The first issue involved the number of draw calls, which initially stood at around 1,430 per frame. After implementing various optimizations, the count reduced to approximately 1,100. One significant optimization was reducing the number of draw calls by utilizing InstancedMesh, which combines multiple objects into a single draw call.
This change significantly reduced the workload on the GPU, enhancing performance. Additionally, the use of a shader to manage window sizes and placements reduced the need for numerous draw calls, contributing to the overall improvement in frame rates.
Written by urgent.news from Dev.to's reporting — not their text. Machine-written — may contain errors; check the original before relying on it.
