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 the distance to the centre: the ring around the park is downtown (32 lots), the next ring is midtown (64) and everything else is outskirts (224). There is no map file. generate() in src/city/layout.ts builds the whole city on first call and caches it. Each lot gets its own seeded generator: const rng = mulberry32(id * 7919 + 13); Houses, roofs, chimneys, flower beds, front doors and the 1,896 trees are all drawn from seeded streams, so the city looks identical on every visit and for every visitor, without storing or sending any of it. The lot id is the only input. The catch: add one rng() call in the middle of makeHouses and every house after it changes. We add new random draws at the end. One mesh per kind of thing The placeholder houses and low-rise blocks have 823 boxes and 516 gabled roofs, but they are not 1,339 meshes. Lowrise.tsx draws them with InstancedMesh: one for the volumes, one for the roofs, one for roof clutter, one for the placeholder signs and one for their posts. A small place() helper writes each instance matrix: tmp.position.set(item.box.x, BASE + item.box.y, item.box.z); tmp.scale.set(item.box.w, item.box.h, item.box.d); tmp.updateMatrix(); mesh.setMatrixAt(i, tmp.matrix); Colour goes in setColorAt, and the facade style (punched windows, brick, ribbon, glass, classical) goes in as an instanced attribute, aStyle. After writing matrices we call computeBoundingSphere() so frustum culling uses the real extent of the instances. When a lot is built on, its placeholder has to disappear. We do not rebuild the buffers. place() sets that instance's scale to zero and rewrites the matrices. The zero-scale instance is still part of the draw call (degenerate triangles), and place() loops over every instance on every change. Windows come from a shader There is no facade texture. A building's wall is a plain box, and the fragment shader in src/city/materials.ts works out the window grid from the face size. The vertex shader reads the size out of the instance matrix: vec3 fScale = vec3(length(fM[0].xyz), length(fM[1].xyz), length(fM[2].xyz)); vFaceW = fSideX ? fScale.z : fScale.x; vFaceH = fScale.y; vLocal = vec2(uv.x * vFaceW, uv.y * vFaceH); So a 4 m house and an 80 m tower use the same material, and windows are always the same size in metres: the shader divides the face into cells of 1.6 m by 1.4 m. Which windows are lit at night comes from a hash of the cell id and a per-face seed, with a threshold that rises as the night gets late, so fewer lights stay on after midnight. Three things in that shader cost us time. Interpolated constants. Per-face values such as the face width were ordinary varyings. Interpolation adds tiny errors, and the window hash amplifies them into static-like noise. Marking them flat varying fixed it. There is a comment in the shader that says so, because we forgot it once. Exact multiples. Face sizes are often an exact multiple of the grid, so floor(width / 1.6) flips between two values on the same face. The shader adds an epsilon (+ 0.01) before the floor. Aliasing at distance. A regular grid of small windows shimmers when far away. Past a threshold on fwidth of the cell coordinate, the shader blends from the real window pattern to its average (0.45), so distant towers read as a lit surface instead of noise. Up close, the same shader does interior mapping: it traces the view ray through a box-shaped room behind each window pane, 2.4 m deep, with the pane set back 0.14 m (0.03 m for glass curtain walls). No geometry exists for the rooms. A uniform, uInteriors, turns it off on the lightest quality tier. Snow works the same way. withSnow() patches any standard material through onBeforeCompile, tints upward-facing surfaces white by the world-space normal, and sets customProgramCacheKey so patched and unpatched programs do not share a cache entry. For instanced meshes the normal has to be transformed by instanceMatrix too, so the patch has an #ifdef USE_INSTANCING branch. Mistake 1: too many draw calls On 26 Oct we measured the default view on the high quality tier with the camera still: about 1,430 draw calls per frame. After one round of work it was about 1,100, and the cost of the shadow pass halved. What moved it: The sun's shadow map is redrawn on alternate frames while the camera is still (every third frame on the medium tier). gl.shadowMap.autoUpdate is off and we set needsUpdate ourselves; any camera movement redraws it at once. At 60 fps, moving cars' shadows update at 30 or 20 Hz, which nobody notices. Hill woods, over half of all shadow-casting geometry, cast shadows only on the high tier. A tower's parts are one instanced mesh per material, not one mesh per part. The facade shader already read sizes from the instance matrix, so this was free. One instanced contact shadow for every tower instead of a mesh each. Those numbers come from that day's commit message. The city has changed since and we have not re-measured, so take them as the size of the win. They are not a benchmark. A ?stats=1 overlay shows fps, frame time, draw calls, triangles, shader count, tier and pixel ratio, summed over every pass of the frame. Turn it on before you guess. There is also a quality governor. It waits 6 seconds for the load and shader compile stutter to pass, measures only while the tab is visible, steps a tier down after sustained slowness (two windows in a row on desktop, one on phones) and climbs back when the machine has room. If a decline follows a climb, the tier locks, so it cannot oscillate between two states forever. Mistake 2: 875 geometries became 3,596 We run a second, much smaller three.js scene: an office view that shows our internal dashboard. It refreshes its data every 10 seconds. Each refresh rebuilt the desk sheets, glows and little people without disposing the old ones. In 20 refreshes the tab went from 875 geometries to 3,596 and then stopped responding. If a three.js tab slows down over time rather than at load, look at renderer.info.memory.geometries first. The fix: share materials, dispose what you replace, rebuild only what changed, and redraw canvas textures (the screen, the clock) only when their content changes. After the fix the count is flat: 786 to 784 in our check. What is still rough Capturing frames on a server with no GPU is slow. On our 2-CPU machine, software WebGL (SwiftShader) takes about two minutes for one 1080 by 1920 frame, so we step frames and read the canvas with toDataURL; page.screenshot hangs. Zeroed instances stay in the draw (see above). Window interiors use one fixed room shape per window bay, so the repetition shows if you look for it. We have no streaming or LOD: everything is in memory from the first second. If you have built that for a city-scale three.js scene, what did you do? Drafted with AI help.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to