← Learn · Roadmap · 4 September 2026 · 13 min

I Built a 3D Car Dealership Without Opening Blender

Generating every vehicle from a single photo with Tripo AI, then shipping it on the web: the file:// CORS wall, the over-compression mistake that ruined the meshes, weld() before simplify, a 164-part exploded view, and a refraction cursor shader.

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:

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:

  1. Serve it. Any local server works.
  2. Inline it. Base64 the mesh into the HTML as a data: URI. GLTFLoader will 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.

# from the folder containing index.html python3 -m http.server 8756 # → http://localhost:8756

I wrapped that in a double-clickable .command so I’d stop opening the wrong file:

#!/bin/bash cd "$(dirname "$0")/site" || exit 1 PORT=8756 while nc -z 127.0.0.1 $PORT >/dev/null 2>&1; do PORT=$((PORT+1)); done echo " http://localhost:$PORT" ( sleep 1.5 && open "http://localhost:$PORT" ) & if command -v python3 >/dev/null 2>&1; then exec python3 -m http.server $PORT elif command -v node >/dev/null 2>&1; then exec node -e ' const http=require("http"),fs=require("fs"),path=require("path"); const port=Number(process.argv[1]); const types={".html":"text/html",".glb":"model/gltf-binary",".js":"text/javascript"}; http.createServer((q,s)=>{ let f=decodeURIComponent(q.url.split("?")[0]); if(f==="/")f="/index.html"; fs.readFile(path.join(process.cwd(),f),(e,d)=>{ if(e){s.writeHead(404);return s.end("not found")} s.writeHead(200,{"Content-Type":types[path.extname(f)]||"application/octet-stream"}); s.end(d); }); }).listen(port);' $PORT fi

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:

import { NodeIO } from '@gltf-transform/core'; import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; import { simplify, weld, prune, dedup, textureCompress, meshopt } from '@gltf-transform/functions'; import { MeshoptSimplifier, MeshoptEncoder } from 'meshoptimizer'; import sharp from 'sharp'; await MeshoptEncoder.ready; await MeshoptSimplifier.ready; const io = new NodeIO() .registerExtensions(ALL_EXTENSIONS) .registerDependencies({ 'meshopt.encoder': MeshoptEncoder }); const doc = await io.read('in.glb'); await doc.transform( weld(), // MUST come first — see below simplify({ simplifier: MeshoptSimplifier, ratio: 0.12, error: 0.001 }), prune(), dedup(), textureCompress({ encoder: sharp, targetFormat: 'webp', resize: [1024, 1024], quality: 88 }), meshopt({ encoder: MeshoptEncoder, level: 'high' }) ); await io.write('out.glb', doc);

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:

ModelSourceAfter
Segmented car1,949,089 tris · 45.2 MB317,223 tris · 1.55 MB
Fleet car 11,890,938 tris · 53.6 MB226,912 tris · 1.58 MB
Fleet car 21,899,641 tris · 54.7 MB227,956 tris · 1.77 MB
Fleet car 31,890,538 tris · 53.7 MB226,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:

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'; const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);

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:

// 1. face them all the same way — long axis along Z let box = new THREE.Box3().setFromObject(root); let size = box.getSize(new THREE.Vector3()); if (size.x > size.z) { root.rotation.y = Math.PI / 2; root.updateMatrixWorld(true); box = new THREE.Box3().setFromObject(root); size = box.getSize(new THREE.Vector3()); } // 2. scale to a fixed footprint and seat on the floor const ctr = box.getCenter(new THREE.Vector3()); const s = 3.3 / Math.max(size.x, size.z, 1e-4); // holder centres and scales; the outer group is free to spin in place const holder = new THREE.Group(); holder.add(root); holder.scale.setScalar(s); holder.position.set(-ctr.x * s, -box.min.y * s, -ctr.z * s); const car = new THREE.Group(); car.add(holder); scene.add(car);

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:

// offsets in the part's own parent space, so root rotation and // holder scale can't skew the directions const localBox = new THREE.Box3(); const centres = meshes.map(m => { m.geometry.computeBoundingBox(); const b = m.geometry.boundingBox.clone().applyMatrix4(m.matrix); localBox.union(b); return b.getCenter(new THREE.Vector3()); }); const c2 = localBox.getCenter(new THREE.Vector3()); const parts = meshes.map((m, i) => ({ mesh: m, off: centres[i].clone().sub(c2), // NOT normalised — proportional home: m.position.clone() }));

And in the render loop, with a light stagger so parts don’t move in lockstep:

const EX_K = 0.55; exAmt = damp(exAmt, exTarget, 5.5, dt); if (parts.length > 1 && exAmt > 0.0005) { for (let i = 0; i < parts.length; i++) { const p = parts[i]; p.mesh.position .copy(p.home) .addScaledVector(p.off, exAmt * EX_K * (1 + (i % 7) * 0.05)); } }

Render modes

Three ways to look at the same mesh, swapping materials rather than reloading anything. Snapshot the original material on load, then switch:

const xrayMat = new THREE.MeshBasicMaterial({ color: 0x86F2E4, transparent: true, opacity: 0.12, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, depthWrite: false }); function applyMode(next) { mode = next; car.traverse(o => { if (!o.isMesh) return; const orig = o.userData.origMat; if (mode === 'xray') o.material = xrayMat; else { o.material = orig; (Array.isArray(orig) ? orig : [orig]) .forEach(m => { if (m) m.wireframe = (mode === 'wire'); }); } }); }

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:

outlineMat.onBeforeCompile = s => { s.vertexShader = s.vertexShader.replace( '#include <begin_vertex>', '#include <begin_vertex>\n transformed += normalize(normal) * 0.009;' ); };

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:

bodyMats.forEach(m => { m.userData.stock = { color: m.color.clone(), map: m.map, metalness: 'metalness' in m ? m.metalness : null, roughness: 'roughness' in m ? m.roughness : null }; }); function restoreStock() { paintChosen = false; bodyMats.forEach(m => { const st = m.userData.stock; if (!st) return; m.color.copy(st.color); m.map = st.map; if (st.metalness !== null) m.metalness = st.metalness; if (st.roughness !== null) m.roughness = st.roughness; m.needsUpdate = true; }); }

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:

const SHOTS = [ { t:0.00, pos:[4.6,1.75,5.4], tgt:[0,.62,0], ex:0, ox:-1.35, mode:'standard' }, { t:0.20, pos:[0.1,1.15,6.6], tgt:[0,.58,0], ex:0, ox: 1.30, mode:'standard' }, { t:0.40, pos:[2.5,0.95,3.1], tgt:[0,.55,0], ex:0, ox:-1.10, mode:'wire' }, { t:0.60, pos:[3.4,1.30,4.4], tgt:[0,.60,0], ex:0, ox: 1.20, mode:'xray' }, { t:0.80, pos:[6.0,3.30,5.8], tgt:[0,.75,0], ex:1, ox:-1.30, mode:'standard' }, { t:1.00, pos:[4.4,2.05,5.1], tgt:[0,1.0,0], ex:0, ox: 0, mode:'standard' } ]; const smoothstep = x => x * x * (3 - 2 * x); function shotAt(t) { let a = SHOTS[0], b = SHOTS[SHOTS.length - 1]; for (let i = 0; i < SHOTS.length - 1; i++) if (t >= SHOTS[i].t && t <= SHOTS[i+1].t) { a = SHOTS[i]; b = SHOTS[i+1]; break; } const k = smoothstep(clamp((t - a.t) / Math.max(b.t - a.t, 1e-6), 0, 1)); return { pos: a.pos.map((v, i) => lerp(v, b.pos[i], k)), tgt: a.tgt.map((v, i) => lerp(v, b.tgt[i], k)), ex: lerp(a.ex, b.ex, k), ox: lerp(a.ox, b.ox, k), mode: k < 0.5 ? a.mode : b.mode }; }

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.

function measure() { const h = smoothEl.scrollHeight; if (h > 0) spacer.style.height = h + 'px'; railEnd = Math.max(rail.offsetHeight - innerHeight, 1); studioTop = studio.offsetTop; } // content height settles late — keep the scroll range honest new ResizeObserver(measure).observe(smoothEl); addEventListener('load', measure); document.fonts?.ready.then(measure);

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.

#smooth { position: fixed; inset: 0 auto auto 0; width: 100%; z-index: 5; pointer-events: none; } /* opt the actual controls back in */ .solid, #studio .deck, #studio .carbar, .navlinks { pointer-events: auto; }

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:

float den = texture2D(uTrail, vUv).r; float mask = smoothstep(0.04, 0.42, den) * uAmt; // epsilon must match the TRAIL buffer's resolution, not the screen's float e = 1.6 / uTrailSize; float gx = texture2D(uTrail, vUv + vec2(e,0.)).r - texture2D(uTrail, vUv - vec2(e,0.)).r; float gy = texture2D(uTrail, vUv + vec2(0.,e)).r - texture2D(uTrail, vUv - vec2(0.,e)).r; vec2 g = vec2(gx, gy); float edge = clamp(length(g) * 3.2, 0.0, 1.0); vec2 disp = g * (0.038 + mask * 0.022); vec3 c; c.r = texture2D(uScene, vUv + disp * 1.24).r; c.g = texture2D(uScene, vUv + disp * 1.00).g; c.b = texture2D(uScene, vUv + disp * 0.76).b; c += pow(edge, 1.5) * vec3(0.55, 0.76, 1.0) * 0.20 * uAmt; c *= 1.0 + mask * 0.045 * uAmt;

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:

float speed = smoothstep(0.0006, 0.0090, vlen); float splat = exp(-dot(d, d) / uRadius) * 0.62 * speed * (uDt * 60.0);

Decay is time-based, not per-frame, so it feels the same at 60 and 120fps:

trailMat.uniforms.uDecay.value = Math.pow(0.020, dt);

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:

if (f < 34 && !degraded) { if (++slow >= 4) { degraded = true; key.castShadow = false; FX.on = false; fxBtn.setAttribute('aria-pressed', 'false'); renderer.setPixelRatio(1); resize(true); } } else if (f >= 46) slow = 0;

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.

If you want to try Tripo, new accounts get bonus credits with invite code J0SG4G.