Overview
There are two ways a web developer has historically got 3D onto a page. Learn Blender — which is months, not a weekend. Or buy from an asset store, where you scroll for an hour, settle for something close enough, and ship geometry that was never really yours.
I wanted a car dealership site with a configurator and an exploded view. I can’t model. So I generated every vehicle from a single photograph with Tripo AI, dropped the meshes into Three.js, and shipped it.
This is what actually happened, including the parts that went wrong — the mesh I destroyed by over-compressing, the models that loaded sideways, and the browser rule that cost me an afternoon.
What Tripo gives you
Tripo is image-to-3D and text-to-3D. You give it a reference photo, it returns a mesh you can export as GLB, FBX, OBJ, STL or USDZ. The parts I used:
| Feature | What it does | Where I used it |
|---|---|---|
| H3.1 / v3.1 | High-detail generation, up to ~2M polygons | Every car body |
| Smart Mesh | One-click low-poly with clean topology, ~2s | The lighter fleet variants |
| SVG2 segmentation | Splits a mesh into its real parts | The exploded view |
| Explosion view | Built into the viewer — one click, parts separate | Confirming segmentation before export |
| 8K textures | High-density PBR maps | Close-up hero shots |
The segmentation is the one that surprised me. I expected to hand-author the exploded view in code. Tripo shipped a car that already knew it was 164 separate parts — panels, wheels, mirrors, grille, spoiler — each a distinct mesh in the GLB. There’s a toggle in the viewer that blows it apart before you’ve written a line.
Useful links:
- Tripo Studio — the workspace
- 3D model gallery — community models, free to download
- Sports cars filtered to Segmented — the ones that explode
- Sports cars filtered to Smart Mesh — the browser-friendly ones
- The segmented car I used
The first wall: browsers won’t load meshes from file://
I exported five cars, dropped index.html next to a
models/ folder, double-clicked it, and got nothing.
This isn’t a Three.js problem. A page opened as file://
is not allowed to fetch() other local files — it’s
a CORS rule, and it applies to every browser. Your .glb sits
right there on disk and the page cannot read it.
Two ways out:
- Serve it. Any local server works.
-
Inline it. Base64 the mesh into the HTML as a
data:URI.GLTFLoaderwill load a data URI happily.
Inlining is genuinely nice for a self-contained demo — one file, no setup, works offline. It’s what I did first. But at full quality my five cars were 238MB, which is not going in an HTML file. So: server.
I wrapped that in a double-clickable .command so I’d
stop opening the wrong file:
I also moved index.html into a site/ subfolder so
it couldn’t be double-clicked by accident. Sounds petty. Saved me
three more rounds of confusion.
The mistake worth writing down: I over-compressed the meshes
Tripo’s full-detail exports are ~1.9M triangles and 46–55MB each. That’s a lot for a web page, so I ran them through a decimator at a 1.2% ratio — 1.9M down to 30,906 triangles, 45MB down to 750KB.
The file size was beautiful. The cars looked terrible.
Car bodies are large, smooth, continuous surfaces. That’s exactly the geometry a decimator eats first, because a flat panel “wastes” triangles by its metric. At 1.5% you get faceted door skins, dented roofs, and creases that read as crash damage. Then I put a high-metalness paint material on top, which turns every one of those ripples into a specular highlight. Two mistakes compounding.
The fix wasn’t less compression, it was different compression. Keep an order of magnitude more geometry, then compress the encoding instead of the geometry:
weld() first is not optional. Skip it and the
simplifier returns a non-indexed mesh — every triangle gets its own
three vertices, no shared normals. Three.js then shades it flat, and you
get a faceted car that looks like origami. I lost an hour to this because
my triangle counter read getIndices(), which returns
null on unindexed geometry, so the pipeline cheerfully
reported zero triangles on a file that was clearly full of them.
The results:
| Model | Source | After |
|---|---|---|
| Segmented car | 1,949,089 tris · 45.2 MB | 317,223 tris · 1.55 MB |
| Fleet car 1 | 1,890,938 tris · 53.6 MB | 226,912 tris · 1.58 MB |
| Fleet car 2 | 1,899,641 tris · 54.7 MB | 227,956 tris · 1.77 MB |
| Fleet car 3 | 1,890,538 tris · 53.7 MB | 226,843 tris · 1.61 MB |
~35× smaller, and it still looks like a car. Crucially the segmented model kept 163 of its 164 parts through the pipeline, so the exploded view survived. Five cars at that size — about 7.6MB total — fit comfortably inline as base64 if you want the single-file version.
Loading meshopt-compressed GLBs needs one extra line on the loader:
Normalising models you didn’t make
Generated meshes don’t agree on scale, origin, or which way the object faces. One of my cars ran along X while the rest ran along Z, so it loaded broadside to the camera.
Rather than fixing each export by hand, the page normalises whatever it’s given:
The two-group split matters. If you put the centring offset on the same object you rotate for the turntable, the car orbits the world origin instead of spinning on its own axis.
The exploded view
First attempt: move every part a fixed distance along its direction from the centre. Panels flew off screen while parts near the middle barely moved.
The fix is to make the displacement proportional to how far each part already sits from the centre. That’s what makes it read as an assembly diagram rather than an explosion.
The second bug was subtler. I measured each part’s centroid in world space and applied the offset in local space. Fine until a model gets rotated by the normaliser — then every direction is wrong. Measure in the same space you apply:
And in the render loop, with a light stagger so parts don’t move in lockstep:
Render modes
Three ways to look at the same mesh, swapping materials rather than reloading anything. Snapshot the original material on load, then switch:
Additive blending with depthWrite: false is the whole x-ray
trick — back faces stop occluding front faces, so you see brake discs
through the door skin.
I also tried a toon-shaded “sketch” mode with an inverted-hull
outline. One gotcha if you go there: MeshBasicMaterial does
not declare objectNormal in its vertex shader,
so the usual normal-offset hack throws a compile error. Use the
normal attribute directly:
Paint, without destroying the original
A configurator that overwrites material.map and lerps
material.color is destructive — one click and the
model’s real texture is gone for the session. Snapshot first:
Picking which materials count as “the paint” is a heuristic: take the mesh with the largest bounding-box volume plus anything within 35% of it. On these cars that lands on body panels and leaves wheels and glass alone.
One tuning note: keep metalness low. I started at 0.9 because “car paint is shiny”, and it turned every surface imperfection in a generated mesh into a highlight. Around 0.3–0.5 metalness with 0.3–0.4 roughness reads as clearcoat and is far more forgiving.
Scroll-driven camera
The camera is on rails through the story section — keyframes, interpolated by scroll position, with the render mode as part of each keyframe:
ox is a lateral pan that pushes the car to the opposite side
from whichever side the copy sits on. Without it the headline lands on top
of the car on every other beat.
The bug that made this look broken
Smooth scrolling with a transformed wrapper needs a spacer element to give
the document its height. I measured that height once on load — before
the fonts had loaded and the cards were laid out — so it came out too
short, the browser clamped scrollTo(), and the camera silently
desynced from the copy. Every beat showed the wrong text.
And the one that made orbit impossible
The smooth-scroll wrapper is position: fixed at
z-index: 5, sitting over the canvas. It was swallowing every
drag before OrbitControls saw it. The 3D looked completely
dead to the mouse.
If your Three.js scene ignores the mouse, check what’s on top of the canvas before you touch the controls config.
A liquid cursor, and what not to copy
I wanted the buttermax cursor — that fluid trail that inverts the page as it passes. My first attempt copied it literally: a density buffer used as a mask, inverting colour inside the blob.
It looked awful. Buttermax works because the whole page is flat yellow-on-black, so inverting is a clean palette swap. Invert a dark photoreal render and you get a milky white smear.
So I kept the mechanism and changed what it does. The density field’s gradient is treated as a surface slope, and the frame is refracted through it — the image bends and splits into RGB along the trail, and nothing brightens:
That epsilon comment cost me a debugging round. I sized the gradient step to the screen resolution while sampling a 320px buffer, so it read sub-pixel and returned near-zero. The effect could only express itself as brightness because the shape information wasn’t there.
The trail itself only accumulates while the pointer is actually moving, so it clears when you stop instead of parking a blob on screen:
Decay is time-based, not per-frame, so it feels the same at 60 and 120fps:
Don’t let the chrome outrun the mesh
The page shows a live triangle count and framerate. If post-processing tanks the framerate, that HUD starts lying about what the geometry costs. So the page watches itself and backs off:
Four consecutive slow samples, not one — a single stutter during mesh upload shouldn’t permanently degrade the page. And it flips the button state so the user can see it happened rather than wondering why the effect stopped.
What I’d tell you before you start
- Pick the right generation mode for the destination. H3.1 at 2M triangles is for renders and 3D printing. For the web, Smart Mesh gets you clean low-poly in about two seconds and skips the entire decimation problem I created for myself.
-
weld()before you simplify. Always. Unindexed geometry shades flat and your car will look like origami. - Compress the encoding, not the geometry. Meshopt plus WebP textures got me 35× with no visual cost. Decimation is the last resort, not the first.
- Keep metalness low on generated meshes. They have surface noise. A chrome material finds every bit of it.
- Check what’s on top of your canvas before you debug your controls.
The honest headline: the 3D step stopped being the hard part. Sourcing geometry used to be the wall — now it’s a photo and a few seconds. The remaining work is normalising what you’re handed and compressing it sensibly, and that’s ordinary web engineering.
Links
- Tripo AI · Studio · Model gallery
-
Three.js —
GLTFLoader,OrbitControls,MeshoptDecoder - glTF-Transform — the compression pipeline
- meshoptimizer — simplifier and encoder
- buttermax — the cursor that started the shader detour
If you want to try Tripo, new accounts get bonus credits with invite
code J0SG4G.