AEC 3D Viewers: What 32 Tools Chose, and a 5x Speed Gap
Over the past year I built five different 3D viewers for AEC work - routing, structural review, tunnel sections, a Revit perspective tool, a spreadsheet-to-3D viewer. Before I trusted my own instincts about how to build them, I did two things: I profiled all five on the same GPU, and I studied how 32 other AEC 3D tools - Autodesk Forma, Speckle, TestFit, xeokit, Bentley iTwin, and more - solve the same problem. This post is both halves: what the industry chose, and then what my own measurements showed. The short version is that rendering is a commodity, and the interesting decisions are everywhere else.
Part 1 - What 32 AEC viewers actually chose
I expected a mess of proprietary engines. Instead the choices collapse into six patterns, and the deciding question is never "which renderer is best" - it is "what is our actual moat."
| Pattern | Who | Why | Outcome |
|---|---|---|---|
| A. Off-the-shelf web engine (three.js / Babylon) | Forma, Speckle, That Open, VIM, Snaptrude | "Rendering is a commodity - the moat is data, analysis, UX" | All active. The majority. |
| B. Fork a web engine | Autodesk APS Viewer (three.js r71) | Custom control of a streaming format in 2015 | Works, but frozen on r71 - a cautionary tale. |
| C. Own WebGL engine | xeokit, Bentley iTwin, Trimble, Cesium, Potree | Domain needs the general engines lacked (double precision, tiling, point-cloud LOD) | Active - but most of those reasons are now expiring. |
| D. Own geometry kernel | Arcol, Qonic, Higharc | When a viewer becomes an editor, the moat is the kernel, not the renderer | Active. The build-to-author camp. |
| E. Native / game engine | TestFit (C++), Twinmotion (Unreal), Unity Reflect | Latency, or photoreal | Hit and miss - Unity Reflect was discontinued in 2024. |
| F. Map engine | Giraffe (Mapbox GL JS) | GIS streaming is the product | Active. |
The headline: Autodesk's own city-scale tool runs on three.js
The single fact that reframed this for me: Autodesk Forma - the ~$240M Spacemaker acquisition, doing city-scale environmental analysis - displays and creates its geometry with three.js, the same free library on npm that my viewers use. Autodesk has said so publicly, and its job postings list the stack as TypeScript + React + Three.js. Forma treats the renderer as a replaceable part and puts its real investment into the analysis and the data model. If the biggest player in the space does not build its own renderer for a city, my not building one for a few thousand rooms is not a compromise - it is the norm.
Autodesk Forma - city-scale site and environmental analysis. Its 3D view is drawn with three.js, the same free npm library my own viewers use. Image: Autodesk / autodesk.com
The same pattern repeats across the A-camp. Speckle layers its own batching and BVH on top of stock three.js. That Open (formerly IFC.js) keeps three.js for rendering and puts a C++ WASM module only where it was actually slow - IFC parsing. VIM calls itself a format company; the value is in its pre-tessellated binary, and the viewer is "based on the popular Three.js framework." The common logic: buy the render, invest in the moat.
Speckle - an open data platform for AEC. Its viewer adds custom batching and a BVH on top of stock three.js rather than replacing it: the A-camp pattern in one product. Image: Speckle / speckle.systems
The fork trap: how Autodesk's other viewer got stuck for a decade
There is a counter-example inside Autodesk itself. The APS Viewer (formerly Forge) forked three.js around r71 in 2014-15 to control its streaming format. It works - but a fork is a debt that charges interest. Ten years on it is stuck at r71 and cannot take the upstream improvements that matter most for exactly this problem: InstancedMesh (r122) and BatchedMesh (r159), the built-in batching that keeps thousands of objects cheap. Autodesk's own blog now publishes a guide on how to bolt newer three.js features onto the frozen fork. That is the whole argument for never forking a vendor library - which is why my viewers use the vendored three.js unmodified.
When companies DO build their own - and why those reasons are expiring
Building a custom engine is not always wrong. xeokit did it for double precision and memory - and it sells the engine, so the engine is the product. Bentley iTwin built one because its renderer is fused to a tile pipeline over a database. Arcol and Qonic wrote their own geometry kernels because they are editors, not viewers - and once you edit solids in a browser, a mesh renderer is not enough. TestFit went fully native C++ because its product is latency: drag a building, re-solve instantly, no cloud round-trip.
xeokit - a BIM/AEC viewer that builds its own WebGL engine for double precision and memory. It can: here the engine is the product, licensed to other developers. Image: xeokit / xeokit.io
But most of the historical reasons for a custom WebGL engine have quietly been absorbed into three.js since 2019:
| Reason to build your own (2013-2019) | three.js in 2026 | Verdict |
|---|---|---|
| Batching thousands of objects | BatchedMesh (r159), InstancedMesh (r122) | Gone |
| Instanced streaming (why APS forked) | Standard, plus meshopt / Draco loaders | Gone |
| Fast raycasting | three-mesh-bvh (de facto standard) | Gone |
| Tile streaming | NASA's 3DTilesRendererJS | Gone |
| Point-cloud LOD | Potree is its own ecosystem | Solved elsewhere |
| Double precision (large coordinates) | Still not in core - but solved as a pattern (relative-to-eye) on top | Pattern, not engine |
The 2015 answer (build your own) and the 2026 answer (general engine plus a few patterns) are different - which is why you cannot read a tool's engine choice without also reading the year it was made.
The three questions that decide it
Distilled from all 32: (1) Is rendering our moat? If not - and for most it is not - use a general engine and earn performance with patterns, not a custom renderer. (2) Is the viewer becoming an editor? Then invest in the kernel and data model, keep the renderer thin, and buy collaboration instead of building it (Arcol bought Liveblocks rather than write its own CRDT). (3) Is latency or photorealism the product itself? Then go native (TestFit) or wire in a game engine (Twinmotion) - but do not build it yourself just because you can.
Part 2 - My five viewers, measured the same way
So I use the majority pattern - stock three.js, no fork. That makes performance my responsibility, not the engine's. I profiled all five in headless Chrome on the same RTX 4060 (checking the WebGL renderer string each time to be sure it was the real GPU). Orbit was a synthetic pointer-move dispatched once per animation frame for three seconds; the rate is the average of the frame deltas. The spread was 12.4 to 60.1 fps - same engine, every one.
| Viewer | Scene | Idle | Orbit | The telling number |
|---|---|---|---|---|
| A | 367 rooms, real data | 60 (on-demand) | 12.4 | one filter checkbox = a 2,363 ms rebuild, then 2,257 ms back |
| B | 2,280 rooms, batched | 60 | 60 | edit-drag still holds 27.7 while moving geometry |
| C | 1,567 IFC elements | 60 | 60 | fast because 1,567 is below the wall, not because it is optimised |
| D | 66 sections, 55 MB | 60.9 | 38.8 | the 55 MB payload parsed in 158 ms, not the hundreds I feared |
| E | 7,665 elements | 31.5 | 43.5 | 15,330 draw calls every frame - it cannot hold 60 even while idle |
| Viewer A - orbit (367 rooms) | 12.4 fps |
| Viewer B - edit-drag (2,280) | 27.7 fps |
| Viewer E - idle (7,665 elem) | 31.5 fps |
| Viewer D - 3D drag (55 MB) | 38.8 fps |
| Viewer E - orbit | 43.5 fps |
| Viewer B - orbit (2,280) | 60.0 fps |
| Viewer C - orbit (1,567 elem) | 60.0 fps |
I had ranked these five by reading their code before I measured, and I got both ends backwards - the one I filed as "borderline but fine" was the worst, and the one I expected to struggle held a locked 60. Reading code tells you the architecture; it does not tell you the frame time.
Viewer A - the one that froze
Viewer A renders on demand, so idle is free. But it builds one mesh per element, so orbiting 367 rooms means thousands of draw calls a frame and 12.4 fps. The real damage is filtering: one checkbox calls a full scene rebuild - long tasks of 2,363 ms then 2,257 ms, nearly five seconds of dead UI per click. (Client model, so no screenshot - only the numbers.)
Viewer B - 2,280 rooms, still 60 fps
How Viewer B holds 60 fps, illustrated on a synthetic 2,304-room scene: rooms sharing a material are batched into one instanced buffer, so the whole model draws in a handful of calls instead of thousands. (Illustration - the production model is client data.)
Six times the rooms of Viewer A, same engine, never below 60 on orbit - because rooms sharing a material are batched into one buffer instead of one mesh each. Editing drops it to 27.7, the honest ceiling; viewing and filtering stay flat.
Viewer C - an IFC reviewer, fast because it is small
Viewer C - an IFC quality reviewer, 60 fps across 1,567 elements, colour-coded by classification.
This one loads an IFC untouched and paints a judgment over it - green where an element is properly classified, amber and red where it is not - and flags whole categories left undefined as a systematic pattern rather than one-off errors. It also screens deflection per beam on the same model:
The same viewer in deflection mode - each beam coloured by L/delta, load arrows from the spaces above.
Everything here is 60 fps, but not because it is optimised - 1,567 elements is simply below the wall. "It feels fast" usually just means "the model is still small enough." Same code, a bigger IFC, and it becomes Viewer A.
Viewer D - a 55 MB payload parsed in 158 ms
Viewer D - a tunnel model mirrored from a spreadsheet; 66 frames, 3D wireframe at 38.8 fps.
This mirrors a tunnel's numerically-defined cross-sections into 3D so an engineer can check the geometry before it goes downstream. I predicted its 55 MB payload would block for "hundreds of milliseconds"; it parsed in 158 ms. Down at the section it draws the concentric lining bands the workbook drives:
One section - lining bands, radii and closure endpoints reconstructed from numeric dimensions.
Viewer E - the draw-call wall, undisguised
Viewer E - a Revit perspective viewer rebuilding placeholder geometry from bounding boxes.
Viewer E renders on a constant loop and draws all 15,330 of its calls every frame, even idle - so it cannot reach 60 fps at rest (31.5). An RTX 4060 half-rescues it to 43.5 on orbit, which is the trap: a strong GPU hides a structural problem just well enough to ship. On a mid-range laptop it would be unusable, and the code would look identical. The fix is not a faster engine - it is rendering only when the scene changes.
It is the geometry strategy, not the engine
Every bar is the same three.js on the same GPU. The variable that tracks the frame rate is draw calls per frame: thousands (one mesh per element, or a full redraw every frame) versus a handful (batched by material). This is exactly what the industry survey predicted - Forma, Speckle and the rest earn their speed with batching patterns on a stock engine, not with a custom renderer.
The whole argument in one frame: the same 2,304 rooms, the same three.js, the same picture - but one mesh per room is ~2,300 draw calls (Viewer A's 12 fps) while batching by material is 6 (Viewer B's 60). Nothing about the engine changed - only the geometry strategy did.
Laid side by side, the five viewers are really five points on that one axis - how many draw calls each spends per frame, and why:
| Viewer | Geometry strategy | Render mode | Draw calls / frame | Measured | The lesson |
|---|---|---|---|---|---|
| A | one mesh per element | on-demand | thousands | 12.4 fps orbit; one filter = a 2.4 s freeze | batching is not optional at this scale |
| B | batched by material | on-demand | a handful | 60 fps; 27.7 while editing | same engine as A, done right |
| C | one mesh per element | on-demand | ~1,600 | 60 fps | fast only because the model is small |
| D | per-section meshes | on-demand | tens | 38.8 fps; 55 MB parsed in 158 ms | the payload was never the bottleneck |
| E | rebuild every frame | continuous loop | 15,330, every frame | 31.5 fps even at idle | never redraw a static scene |
The ghosting technique on a synthetic scene: one floor kept solid, the other 2,112 rooms dropped to a faint ghost. Filtering only changes what is drawn - the draw-call count does not move.
Filtering is where a naive viewer rebuilds the scene - Viewer A's freeze. Viewer B never rebuilds: it keeps two index buffers, solid and ghost, and filtering only shifts a drawRange. Ghosting thousands of rooms holds the draw-call count at four, flat. Selection is the same idea:
Selection on the same synthetic scene: one room highlighted inside the existing buffer, adding no draw call.
Part 3 - What I built, and the licence that reversed my own advice
Using the majority pattern paid off in reach: the same stock-three.js approach let me stand up a viewer that reads six formats - IFC, DXF, 3DM, STL, OBJ and a spreadsheet-to-3D path - in a day, because I only had to wire loaders to one renderer, not build anything new. The 2,280-room model in Viewer B comes through that Excel path in about 0.7 seconds.
The other half of "build vs buy" is licensing, and it bit me. I had recommended a well-known free DWG converter for the CAD path. Checking the actual EULA reversed that: its terms say non-members may only use it non-commercially - so "free converter in a commercial pipeline" was a licence violation, not a shortcut. The safer routes turned out to be contractual (ask for a DXF re-export) or to run a CAD app you already license as the converter. A second gotcha: the easiest 3DM path, rhino3dm, cannot tessellate NURBS at all - it depends entirely on render meshes already saved in the file (McNeel staff confirmed the meshing code "is not part of rhino3dm and will not be"). Architecture advice that is not checked against the licence file is a legal landmine, however good it looks technically.
The takeaway
Two halves, one lesson. The industry half: rendering is a commodity - even Autodesk's city-scale tool runs on stock three.js, and forking it (as their older viewer did) is a ten-year debt. The measurement half: if your viewer is slow, the engine is almost never the fix - count the draw calls, batch by material, keep solid and ghost index buffers so filtering only shifts a range, and never redraw a static scene. Build your own engine only if the engine is the product, or the kernel is; otherwise spend that effort where your actual moat is. And check the licence before you ship the architecture.
Performance measured on an RTX 4060, Chrome 150; orbit as a synthetic pointer-move once per animation frame for three seconds, frame time from the rAF delta. Industry findings from public sources - vendor blogs, docs, licence files and repositories. Viewer names and model data anonymised.
댓글
댓글 쓰기