TL;DR: The requirement sounded simple: let users drag vertices around on a 3D mesh, assign basic materials, and export the result as GLTF. My first instinct was to reach for something pre-built.
📖 Reading time: ~38 min
What's in this article
- Why I Ended Up Building This Instead of Using Something Off the Shelf
- Project Setup: Don't Use Create React App for This
- Setting Up the Core Scene: The Boilerplate That Actually Works
- React Three Fiber vs. Vanilla Three.js: My Honest Take After Using Both
- Implementing Object Selection and Raycasting
- Camera Controls: OrbitControls and When to Fight It
- The Material Editor Panel with dat.GUI
- GLTF Export: The Part Nobody Writes About
Why I Ended Up Building This Instead of Using Something Off the Shelf
The requirement sounded simple: let users drag vertices around on a 3D mesh, assign basic materials, and export the result as GLTF. My first instinct was to reach for something pre-built. That instinct cost me about two weeks before I gave up on it.
Spline is genuinely impressive for design work, but you're publishing inside their ecosystem — there's no clean path to "run this entirely in your own app, export programmatic data, and own the file format." Tinkercad is a CAD tool aimed at physical fabrication, not a library you embed. Babylon.js was closer but the bundle size for a focused tool felt punitive — you're pulling in a physics engine and GUI framework when all you want is a scene graph and raycasting. The pattern I kept hitting: existing tools are either too opinionated about their output format or too heavy for embedding inside a larger product. I needed something I could actually own.
Three.js r152+ is what made rolling my own feel reasonable rather than reckless. The pointer events improvements in particular — THREE.Raycaster pairing cleanly with the browser's native PointerEvent API meant I wasn't fighting coordinate transforms on touch devices anymore. The BufferGeometry API had also matured enough that reading and writing vertex positions directly via typed arrays stopped feeling like surgery. And the WebGPU renderer (still experimental at that point, but functional) meant I wasn't building on top of a dead-end abstraction. Here's the basic geometry manipulation loop I landed on:
// grab the position buffer directly — Float32Array, 3 values per vertex
const positions = geometry.attributes.position;
// after raycaster identifies the vertex index:
positions.setXYZ(vertexIndex, newX, newY, newZ);
// tell Three.js the buffer is dirty — without this, nothing re-renders
positions.needsUpdate = true;
geometry.computeVertexNormals(); // normals go stale after manual edits
What we actually shipped: a mesh editor where users can select individual vertices or edge loops, drag them in world space or along a constrained axis, assign PBR materials with roughness/metalness controls, and export the final scene as GLTF 2.0 — entirely client-side using GLTFExporter from the Three.js examples. No server round-trip. The whole interaction model is about 1,800 lines of TypeScript, which surprised me. The hard part wasn't Three.js — it was building a selection state machine that didn't feel laggy at 60fps when the mesh had 10K+ vertices.
One thing that genuinely accelerated the early scaffolding was leaning on AI coding tools for the Three.js boilerplate — camera rig setup, OrbitControls integration, the GLTF export wiring. Not for logic, but for the stuff I'd have otherwise spent 45 minutes Googling across outdated Stack Overflow threads. The Best AI Coding Tools in 2026 (thorough Guide) covers which ones are actually useful for WebGL-heavy work specifically. The gotcha: every AI tool I tried had training data from pre-r150 Three.js, so anything involving the new WebGLRenderer color management API (renderer.outputColorSpace = THREE.SRGBColorSpace replaced outputEncoding) required manual correction. Treat the output as a draft, not a solution.
Project Setup: Don't Use Create React App for This
The thing that caught me off guard with CRA and WebGL is how badly it handles hot module replacement when you have an active rendering loop. Three.js renderers hold GPU context — when CRA's HMR swaps modules, it either fails to dispose the old context or doubles up render loops silently. You end up with frame rate halving every save, and no error in the console telling you why. Vite 4.x treats ESM natively, and its HMR boundary logic plays far nicer with imperative code that manages its own lifecycle.
# Scaffold the project — takes about 10 seconds
npm create vite@latest mesh-editor -- --template react
cd mesh-editor
# Pin Three.js to a specific revision — explained below
npm install three@0.155.0
npm install -D @types/three@0.155.0
# Verify Vite version is 4.x, not accidentally 3.x
npx vite --version
# Expected: vite/4.4.x or similar
Pin to 0.155.0, not latest, not ^0.155.0. The API surface between r148 and r155 changed in ways that will break every tutorial you find via Google right now. Specifically, BufferGeometry.setAttribute behavior, the way MeshStandardMaterial handles envMapIntensity, and the WebGLRenderer constructor's powerPreference option all shifted. The Three.js team uses revision numbers (r155 = version 0.155.0) not semver semantics, so the caret range gives you false safety. Lock it in package.json explicitly:
// package.json — the relevant fragment
{
"dependencies": {
"three": "0.155.0"
},
"devDependencies": {
"@types/three": "0.155.0"
}
}
The tsconfig.json shipped by the Vite React template will quietly fail to resolve Three.js sub-path imports unless you set moduleResolution correctly. The default is "node", which doesn't understand the exports field in package.json — and Three.js uses that field heavily for its addons (things like OrbitControls, GLTFLoader, etc. live under three/addons/). Switch it to "bundler", which tells TypeScript to defer resolution to Vite rather than simulate Node's algorithm:
// tsconfig.json — full compiler options block
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler", // NOT "node" — breaks three/addons/* imports
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"strict": true,
"noEmit": true,
"jsx": "react-jsx"
}
}
With moduleResolution: bundler in place, this import just works — no path aliases needed, no Vite plugin hacks:
// Clean sub-path import — fails silently with moduleResolution: "node"
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import { TransformControls } from 'three/addons/controls/TransformControls.js'
One last structural thing before you write a single line of Three.js: create your renderer and scene outside of React's component tree. I made the mistake of initializing WebGLRenderer inside a useEffect without a ref guard, and React 18's strict mode double-invokes effects in development — so I got two renderers fighting over the same canvas. The fix is a module-level singleton or a ref initialized with a null check. The React component should only mount/unmount the canvas and hand off a DOM element reference; all Three.js state lives outside.
Setting Up the Core Scene: The Boilerplate That Actually Works
The thing that catches most people off guard is pixel ratio. You get your scene working perfectly on your laptop, push it to staging, and someone on a MacBook Pro or a modern Android phone sends you a screenshot with blurry geometry and jagged edges. You forgot renderer.setPixelRatio(window.devicePixelRatio). Call it immediately after you create the renderer — not after you add objects, not in a resize handler, immediately. Retina displays have a device pixel ratio of 2 (or 3 on some phones), so Three.js's canvas is physically half the resolution of what CSS thinks it is unless you tell it otherwise.
import * as THREE from 'three'; // three@0.158.0
const canvas = document.getElementById('viewport');
const renderer = new THREE.WebGLRenderer({
canvas,
// Don't default to true — we'll feature-detect this below
antialias: false,
});
// Do this before ANYTHING else touches the renderer
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
60, // FOV — 60 is closer to human perception than 75
canvas.clientWidth / canvas.clientHeight, // aspect ratio matches the actual element
0.1, // near clip — don't go lower than 0.01 or z-fighting starts
1000
);
camera.position.set(0, 2, 5);
Antialias on mobile is a real cost, not a theoretical one. On a mid-range Android phone, enabling antialias: true can drop you from 60fps to 35fps on a moderately complex scene because the GPU has to do multisampling on every frame. The right pattern is to check pixel ratio — if it's already 2 or above, the screen's physical density is doing a better job of smoothing edges than MSAA would. You get smooth rendering without burning the battery.
// Feature-detect antialias based on display density
// High-DPI screens already supersample naturally — MSAA is redundant there
const useAntialias = window.devicePixelRatio < 2;
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: useAntialias,
});
The naive resize handler looks like window.addEventListener('resize', handleResize) and it works fine in a plain HTML page. In React it's a memory leak waiting to happen. Every re-render of your component that runs the setup effect will attach another listener unless you return a cleanup function — and even then, window listeners don't know about component unmounts. Use a ResizeObserver attached directly to the canvas element instead. It only fires when the canvas's own dimensions change (not on every window resize), you can disconnect it in the cleanup, and it doesn't interfere with other resize listeners anywhere in the tree.
useEffect(() => {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
renderer.setSize(width, height, false); // false = don't set canvas CSS size, we control that
camera.aspect = width / height;
camera.updateProjectionMatrix(); // required — camera matrices aren't reactive
}
});
observer.observe(canvas);
// This is the part everyone forgets — disconnect on unmount
return () => observer.disconnect();
}, []); // empty deps because renderer/camera/canvas refs don't change
The animation loop is where React's useEffect will quietly destroy you if you're not careful. The classic bug: you start requestAnimationFrame in an effect, the component unmounts (route change, modal close, whatever), but the rAF callback still holds a closure reference to the old renderer and keeps drawing to a detached canvas. In development with React 18's Strict Mode, effects fire twice on mount, so you end up with two animation loops running simultaneously — which doubles your CPU usage and produces weird flickering.
useEffect(() => {
let animationId: number;
let isActive = true; // closure flag — cheaper than canceling and restarting
const tick = () => {
if (!isActive) return; // hard stop if component unmounted
animationId = requestAnimationFrame(tick);
// your scene updates go here
controls.update();
renderer.render(scene, camera);
};
animationId = requestAnimationFrame(tick);
return () => {
isActive = false;
cancelAnimationFrame(animationId);
// Also dispose the renderer to free the WebGL context
// Chrome allows max 16 WebGL contexts per page — this matters in dev
renderer.dispose();
};
}, []); // still empty deps — renderer/scene/camera are stable refs
One last thing: renderer.setSize(width, height, false) — that third argument is almost never mentioned in tutorials. Pass false when you're controlling the canvas size via CSS (which you should be, so it fits its container responsively). The default true directly sets canvas.style.width and canvas.style.height, which overrides your CSS and makes the canvas jump to its internal pixel size on every resize. That single boolean has confused a lot of people for a long time.
React Three Fiber vs. Vanilla Three.js: My Honest Take After Using Both
The thing that surprised me most about @react-three/fiber@8.x is how well the declarative model actually holds up for complex scenes. Lights, cameras, materials, post-processing passes — expressing all of that as JSX is genuinely cleaner than the imperative Three.js equivalent. You get the React component lifecycle for free, prop-driven state updates feel natural, and @react-three/drei gives you a shelf of pre-built helpers that would take days to write yourself. For the first two weeks of my 3D modeling project, I was fully committed to R3F.
Then I added real-time vertex dragging. The user clicks a vertex, drags, and every mousemove fires a position update for potentially 4,000+ vertices on a subdivided mesh. R3F's reconciler has to diff and re-render on every frame, and you feel it — not as a number in a profiler, but as a literal lag between your cursor and the mesh deformation. The reconciler latency on re-renders during pointer events was enough to make the tool feel broken. This isn't a hypothetical edge case; it's the core interaction of any mesh editing tool.
Vanilla Three.js handles this correctly because you just mutate the BufferGeometry attribute directly and flag it dirty:
// Direct attribute mutation — no diffing, no overhead
const positions = geometry.attributes.position;
positions.setXYZ(vertexIndex, newX, newY, newZ);
positions.needsUpdate = true;
geometry.computeVertexNormals(); // only when topology changes, not every drag frame
That needsUpdate = true flag is all Three.js needs to push the updated buffer to the GPU on the next render call. No diffing, no component tree, no scheduler. The mental model for performance-critical loops in Three.js is basically: get a direct reference to the thing, mutate it, mark it dirty. Trying to do this through R3F's prop system is fighting the framework — you end up holding refs everywhere anyway and the React layer buys you nothing.
My actual architecture ended up being a hybrid, which I'd recommend to anyone building a 3D modeling tool specifically. R3F handles the outer scene structure — the canvas, environment lighting, camera controls, orbit controls, the React UI panels that overlay the viewport. The hot path lives entirely inside useFrame, running imperative vanilla code against raw refs:
// R3F component — scene setup is declarative, interaction is imperative
const EditableMesh = () => {
const meshRef = useRef();
const isDragging = useRef(false);
const activeVertex = useRef(-1);
useFrame(({ gl, scene, camera }) => {
if (!isDragging.current || activeVertex.current === -1) return;
// Pure imperative Three.js — runs every frame with zero reconciler involvement
const positions = meshRef.current.geometry.attributes.position;
positions.setXYZ(activeVertex.current, dragTarget.x, dragTarget.y, dragTarget.z);
positions.needsUpdate = true;
});
return <mesh ref={meshRef}><bufferGeometry /><meshStandardMaterial /></mesh>;
};
The rule I settled on: if something changes on every frame or responds to raw pointer events at high frequency, it doesn't go through React state — it goes through a ref and gets mutated inside useFrame. If something changes occasionally (user selects a different tool, material color changes, a mesh gets added to the scene), React state and R3F's declarative model are the right call. Mixing both inside the same component is fine and in practice it's where you end up anyway. The mistake is trying to force everything through one paradigm.
Implementing Object Selection and Raycasting
The first time I hooked up a raycaster and nothing responded to clicks, the bug was in NDC conversion. Not the raycaster itself — that part works fine. The mouse coordinates you get from a DOM event are in pixel space (0 to clientWidth, 0 to clientHeight). Three.js expects Normalized Device Coordinates: -1 to +1 on both axes, with Y flipped. Miss this and your raycaster is always pointing at the wrong part of the scene.
// Inside your pointermove/pointerdown handler on the canvas
function updateMouseNDC(event, canvas) {
const rect = canvas.getBoundingClientRect();
// Subtract rect offset — critical if canvas isn't fullscreen
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; // Y is inverted
}
// Then in your render loop (or on demand):
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(scene.children, false); // recursive=false for now
if (hits.length > 0) {
console.log('Hit:', hits[0].object.name, 'at distance', hits[0].distance);
}
The getBoundingClientRect() call is non-negotiable if your canvas has any margin, padding, or is inside a flex container. I've seen this omitted in tutorials that assume a fullscreen canvas — it silently breaks the moment you add a sidebar to your UI.
Now, the recursive flag on intersectObjects. Pass true and Three.js walks your entire scene graph checking every descendant mesh. That sounds obviously correct, but on a modeling tool with complex imported GLTF objects — which can have 50–200 child nodes for a single model — you'll measure a real frame-time hit. I benchmarked a scene with four GLTF furniture models: recursive: true added roughly 3–4ms per mousemove event. The fix is to maintain a flat array of selectable meshes yourself and pass that directly instead of scene.children:
// Build this once when objects are added to the scene
const selectableMeshes = [];
function registerSelectables(object) {
object.traverse(child => {
if (child.isMesh) selectableMeshes.push(child);
});
}
// Then raycast against the flat list — no recursion needed
const hits = raycaster.intersectObjects(selectableMeshes, false);
For highlighting selected objects, you have two options with very different trade-offs. Swapping emissive color on the material is dead simple — one line — but it looks cheap and only works on MeshStandardMaterial or MeshPhongMaterial, not MeshBasicMaterial. The better-looking approach is OutlinePass from Three.js's post-processing examples. The catch: it lives in three/examples/jsm/postprocessing/OutlinePass.js, not the main package. You also need EffectComposer and RenderPass alongside it. It's three extra imports and a render pipeline change, but the result is a proper silhouette outline that works on any geometry.
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { OutlinePass } from 'three/examples/jsm/postprocessing/OutlinePass.js';
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const outlinePass = new OutlinePass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
scene,
camera
);
outlinePass.edgeStrength = 3;
outlinePass.edgeColor.set('#00aaff');
composer.addPass(outlinePass);
// To select an object:
outlinePass.selectedObjects = [clickedMesh]; // replace array on each selection
// Replace renderer.render(scene, camera) with:
composer.render();
One thing that'll silently wreck your selection system: mixing DOM pointer events with Three.js raycasting and letting both systems handle the same interaction. A common mistake is attaching onClick to individual mesh elements via a library like @react-three/fiber AND also running a manual raycaster in the animation loop. You end up with double-firing, selection state mismatches, and events that cancel each other out. Pick one. For a custom modeling tool where you need fine-grained control — like distinguishing a face-click from an edge-click, or drag-to-select — do everything in the manual raycaster. Attach exactly one pointerdown listener to the canvas element, normalize coordinates, raycast, and dispatch your own selection events from there. No React synthetic events, no mesh.addEventListener.
Building the Vertex Manipulation System
The needsUpdate = true flag will silently ruin your day. You'll mutate geometry.attributes.position.array perfectly, move a vertex exactly where you want it, and see absolutely nothing happen on screen. No error, no warning — Three.js just quietly ignores your changes until you set geometry.attributes.position.needsUpdate = true after every write. I burned 45 minutes on this the first time. Set it unconditionally after any position mutation, even if you're pretty sure nothing changed.
// Mutating a single vertex at index i
const positions = geometry.attributes.position;
const arr = positions.array;
arr[i * 3] = newX;
arr[i * 3 + 1] = newY;
arr[i * 3 + 2] = newZ;
// This line is the one you'll forget:
positions.needsUpdate = true;
// If you also have normals computed from geometry, recompute them
geometry.computeVertexNormals();
For rendering the handles themselves, don't reach for Points as a permanent solution. It works fine up to maybe 200–300 vertices before you need per-vertex size control or selection highlighting, and then you're fighting shader customization. Switch to InstancedMesh from the start if you know the model will have 500+ vertices — it's a single draw call regardless of count, and you can update individual instance matrices to move handles without touching the others. A small sphere geometry (SphereGeometry(0.05, 6, 6) — low segments, you don't need smooth) as the base mesh, and you're updating one matrix per selected vertex instead of rebuilding a point cloud.
// Build the handle mesh once
const handleGeo = new THREE.SphereGeometry(0.05, 6, 6);
const handleMat = new THREE.MeshBasicMaterial({ color: 0x00aaff });
const handles = new THREE.InstancedMesh(handleGeo, handleMat, vertexCount);
// Sync handle positions to geometry
const dummy = new THREE.Object3D();
for (let i = 0; i < vertexCount; i++) {
dummy.position.set(
arr[i * 3],
arr[i * 3 + 1],
arr[i * 3 + 2]
);
dummy.updateMatrix();
handles.setMatrixAt(i, dummy.matrix);
}
// Same deal — forget this and nothing moves
handles.instanceMatrix.needsUpdate = true;
Hit-testing vertices with a raycaster is where most implementations get slow fast. Per-vertex raycasting in a loop — testing every vertex against the ray on every mousemove — absolutely kills performance at any non-trivial vertex count. The practical shortcut: compute a bounding sphere per vertex (radius equal to your handle visual size, e.g. 0.05 units) and test ray-sphere intersection. That's just checking if the distance from the ray to the vertex position is less than your threshold. The full per-instance Raycaster.intersectObject(handles) call on an InstancedMesh does work in Three.js r152+, but it's slower than the manual sphere test because it runs a full mesh intersection check per instance. For a modeling tool where the user is hovering over one vertex at a time, the bounding sphere approximation is accurate enough and roughly 10x cheaper.
function getNearestVertex(raycaster, positions, threshold = 0.08) {
const ray = raycaster.ray;
let closest = -1;
let closestDist = Infinity;
const vertex = new THREE.Vector3();
for (let i = 0; i < positions.count; i++) {
vertex.fromBufferAttribute(positions, i);
// Ray-to-point distance — this is the cheap test
const dist = ray.distanceToPoint(vertex);
if (dist < threshold && dist < closestDist) {
closestDist = dist;
closest = i;
}
}
return closest; // -1 means no hit
}
The undo/redo stack is simpler than you're probably imagining. Don't model it as a command pattern with inverse operations — that's overkill for geometry edits where you're just moving vertices around. Instead, snapshot the entire Float32Array before each edit operation. A Float32Array with 10,000 vertices is only 120KB, and Float32Array.prototype.slice() is a native copy. Store those snapshots in an array with a pointer. The memory cost for 50 undo steps on a dense mesh is maybe 6MB — completely acceptable in a browser context.
const undoStack = [];
const redoStack = [];
function saveSnapshot(geometry) {
// slice() gives you a real copy, not a reference
undoStack.push(geometry.attributes.position.array.slice());
redoStack.length = 0; // any new edit wipes the redo branch
}
function undo(geometry) {
if (undoStack.length === 0) return;
// Save current state to redo before overwriting
redoStack.push(geometry.attributes.position.array.slice());
const snapshot = undoStack.pop();
geometry.attributes.position.array.set(snapshot);
geometry.attributes.position.needsUpdate = true;
geometry.computeVertexNormals();
}
One thing that tripped me up: call saveSnapshot() on pointerdown, not on every pointermove. If you snapshot during drag, you'll fill the undo stack with intermediate drag states and undo becomes useless — stepping back through sub-pixel movements instead of whole operations. The right mental model is "save before the operation begins, restore on undo." That also means drag-to-move counts as one undoable action regardless of how many intermediate positions the vertex passed through.
Camera Controls: OrbitControls and When to Fight It
The thing that catches almost everyone building their first Three.js modeling tool is the import path. OrbitControls doesn't live in the main three package — it's in the examples directory, which means you need this exact import:
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
Notice the .js extension. In Vite (and any bundler using native ESM), you need that explicit extension or you'll get a module resolution error that looks completely unrelated. Webpack was more forgiving here because it tried multiple extensions automatically. Vite follows the spec strictly, so OrbitControls.js — not OrbitControls — is the only form that works. I've watched three separate devs spend 20+ minutes on this exact error. Bookmark it.
The harder problem is what happens when your user tries to drag a vertex while OrbitControls is active. Both systems are listening to the same pointer events, so you get the worst possible behavior: the user grabs a vertex and the whole camera rotates instead. The naive fix is controls.enabled = false when a vertex drag starts, controls.enabled = true when it ends. That works until you forget a code path — user presses Escape mid-drag, pointer leaves the canvas, a modal opens. You end up with OrbitControls permanently disabled and no obvious way to tell why. What I actually use is a proper state machine:
// States: 'idle' | 'orbiting' | 'dragging-vertex' | 'transforming'
const editorState = {
current: 'idle',
transition(next) {
// Guard invalid transitions explicitly
const allowed = {
idle: ['orbiting', 'dragging-vertex', 'transforming'],
orbiting: ['idle'],
'dragging-vertex': ['idle'],
transforming: ['idle'],
}
if (!allowed[this.current]?.includes(next)) {
console.warn(`Blocked transition: ${this.current} → ${next}`)
return false
}
this.current = next
controls.enabled = ['idle', 'orbiting'].includes(next)
return true
}
}
canvas.addEventListener('pointerdown', (e) => {
const hit = raycaster.intersectObjects(vertices)
if (hit.length) {
editorState.transition('dragging-vertex')
}
})
window.addEventListener('pointerup', () => {
editorState.transition('idle')
})
The pointerup on window (not canvas) is important — users will release the mouse outside the canvas constantly. Tying cleanup to the canvas element means you strand the state regularly.
For keyboard shortcuts to snap to standard views (Numpad 1/3/7 in Blender-style), the temptation is to just set camera.position.set() directly. It works, but the camera teleports and it looks broken. The right move is quaternion slerp over a few frames:
function animateCameraToView(targetPosition, targetQuaternion, durationMs = 400) {
const startPosition = camera.position.clone()
const startQuaternion = camera.quaternion.clone()
const startTime = performance.now()
function tick() {
const t = Math.min((performance.now() - startTime) / durationMs, 1)
// Ease in-out cubic
const eased = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
camera.position.lerpVectors(startPosition, targetPosition, eased)
camera.quaternion.slerpQuaternions(startQuaternion, targetQuaternion, eased)
// Keep OrbitControls target in sync or the next orbit will snap
controls.target.lerp(new THREE.Vector3(0, 0, 0), eased)
controls.update()
if (t < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
// Front view (looking down -Z)
const frontPos = new THREE.Vector3(0, 0, 5)
const frontQuat = new THREE.Quaternion() // identity = looking down -Z
document.addEventListener('keydown', (e) => {
if (e.key === '1') animateCameraToView(frontPos, frontQuat)
})
The gotcha here: after the animation ends, OrbitControls still thinks the camera is wherever it was before. Call controls.update() inside the animation loop and set controls.target or your first orbit move after the snap will be violent. Ask me how I know.
For move/rotate/scale gizmos, don't build them from scratch. TransformControls from the same examples directory is genuinely good and saves probably two days of work:
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'
const transformControls = new TransformControls(camera, renderer.domElement)
scene.add(transformControls)
// Critical: disable OrbitControls while TransformControls is being dragged
transformControls.addEventListener('dragging-changed', (e) => {
controls.enabled = !e.value
// Use the state machine
editorState.transition(e.value ? 'transforming' : 'idle')
})
// Attach to a mesh, switch modes with keyboard
transformControls.attach(selectedMesh)
document.addEventListener('keydown', (e) => {
if (e.key === 'g') transformControls.setMode('translate')
if (e.key === 'r') transformControls.setMode('rotate')
if (e.key === 's') transformControls.setMode('scale')
})
TransformControls fires dragging-changed with a boolean — wiring that directly to your state machine means you get the OrbitControls conflict solved automatically. The one limitation worth knowing: TransformControls operates in world space by default. If your mesh has a non-identity parent transform, the gizmo will appear to work but the actual translation values will be in world space while your geometry is in local space. Call transformControls.setSpace('local') if your scene graph has any nesting at all.
The Material Editor Panel with dat.GUI
The thing that surprised me most when building the material editor wasn't the Three.js side — it was how fast dat.GUI gets you a working inspector compared to building your own panel from scratch. Yes, it looks like a 2010 game debug overlay. Yes, the TypeScript support is genuinely rough. I still reach for it first every time I need to iterate on material properties during development.
npm install dat.gui
npm install --save-dev @types/dat.gui
The @types/dat.gui types are technically there, but they'll fight you on color pickers specifically. The addColor method wants a plain object with a hex string or RGB object, not a THREE.Color instance. So the trick is to maintain a separate params object that dat.GUI owns, then sync it back to the material:
import * as dat from 'dat.gui';
import * as THREE from 'three';
const material = new THREE.MeshStandardMaterial({
roughness: 0.5,
metalness: 0.2,
color: 0x4488ff,
});
// dat.GUI cannot own a THREE.Color directly — give it a plain object
const params = {
color: '#4488ff',
roughness: material.roughness,
metalness: material.metalness,
};
const gui = new dat.GUI({ width: 320 });
const matFolder = gui.addFolder('Material');
matFolder.addColor(params, 'color').onChange((hex: string) => {
material.color.set(hex); // THREE.Color.set() accepts hex strings fine
});
matFolder.add(params, 'roughness', 0, 1, 0.01).onChange((v: number) => {
material.roughness = v;
});
matFolder.add(params, 'metalness', 0, 1, 0.01).onChange((v: number) => {
material.metalness = v;
});
matFolder.open();
I did try leva@0.9.x before settling on dat.GUI for this project. Leva is objectively nicer — the UI is modern, the React integration is clean, and the API is typed properly. The problem I hit was that Leva's color values come back as hex strings sometimes and RGBA objects other times depending on whether you enabled the alpha channel, and Three.js's THREE.Color doesn't understand rgba() CSS strings without extra parsing. I spent about two hours on that interop problem and just bailed. If your whole renderer is wrapped in React and you control the color format carefully, Leva is the better long-term choice. For a vanilla Three.js setup where I wanted zero friction, dat.GUI won.
The real architectural decision here is: dat.GUI is for you, not your users. I run it conditionally in development only, then built a proper sidebar for the production interface:
// Only mount dat.GUI in dev — Vite exposes this via import.meta.env
if (import.meta.env.DEV) {
const { setupDevGUI } = await import('./debug/materialGUI');
setupDevGUI(material);
}
The production sidebar is a plain HTML panel with <input type="range"> sliders and a native <input type="color"> picker. Boring, but it's 2KB instead of dat.GUI's ~40KB, it matches whatever design system you're already using, and you can wire it to the exact same material.roughness properties — no abstraction layer needed. The pattern I landed on is keeping the dat.GUI setup isolated in a /debug folder that gets tree-shaken out of production builds entirely. Don't let debug tooling leak into what you ship.
GLTF Export: The Part Nobody Writes About
The thing that catches most people off guard: GLTFExporter changed its callback API between r148 and r152, so the most-upvoted StackOverflow answer from early 2023 is just wrong. The old pattern passed a callback directly as the second argument. The new pattern uses an object with onCompleted and onError handlers. If you're getting silent failures or your callback never fires, this is almost certainly why.
Here's what the current API actually looks like — tested against Three.js r152 and r158:
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js';
const exporter = new GLTFExporter();
exporter.parse(
scene, // or a single Mesh/Group
function onCompleted(result) {
// result is an ArrayBuffer when binary: true, plain object when false
if (result instanceof ArrayBuffer) {
downloadArrayBuffer(result, 'model.glb');
}
},
function onError(error) {
console.error('GLTFExporter failed:', error);
},
{ binary: true } // options object — always pass this last
);
Triggering the actual download is the part every tutorial hand-waves. You have an ArrayBuffer — now what? The Blob + createObjectURL pattern is the right move here. Don't try to base64-encode it or shove it into a data URI; you'll hit browser memory limits fast on anything larger than a few MB.
function downloadArrayBuffer(buffer, filename) {
const blob = new Blob([buffer], { type: 'model/gltf-binary' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
// Release the object URL after the browser picks it up
// 100ms is enough; don't hold the memory indefinitely
setTimeout(() => URL.revokeObjectURL(url), 100);
}
Now for the honest list of what the exporter handles badly. Custom ShaderMaterial instances don't export at all — the exporter has no way to serialize GLSL and will silently swap them for a default MeshStandardMaterial. If your entire visual style depends on custom shaders, GLTF is the wrong output format; you'd need a custom serializer. Instanced meshes via InstancedMesh require you to pass { binary: true } and the exporter will bake each instance as a separate draw call — fine for < 50 instances, ugly at 500. Morph targets export but you have to explicitly opt in:
exporter.parse(
mesh,
onCompleted,
onError,
{
binary: true,
morphTargets: true, // without this, morph data is silently dropped
animations: scene.animations // pass your AnimationClips here too
}
);
Before you ship that exported file to anyone, drag it into gltf.report. It'll show you texture sizes, draw call counts, whether your normals baked correctly, and it flags spec violations that the Three.js viewer quietly ignores. The Khronos sample viewer at github.khronos.org is the other one I always hit — it's the closest thing to a ground-truth renderer for the format. I've shipped files that looked perfect in Three.js but had broken skinning in every other viewer, and both of these tools caught it instantly. Treat them like a linter for your export pipeline.
Performance: Where Things Actually Get Slow
The thing that caught me off guard building a 3D modeling tool wasn't the math — it was discovering that a seemingly innocent scene with 200 objects had 200 draw calls, and my frame time had quietly crept to 40ms without any single obvious culprit. FPS lies to you. Draw calls tell the truth.
Add the Stats panel early and watch the right number:
import Stats from 'three/examples/jsm/libs/stats.module.js';
const stats = new Stats();
stats.showPanel(1); // 0 = fps, 1 = ms per frame, 2 = memory — panel 1 is more honest
document.body.appendChild(stats.dom);
function animate() {
stats.begin();
renderer.render(scene, camera);
stats.end();
requestAnimationFrame(animate);
}
But the real audit tool is renderer.info. I run this in the browser console after loading a complex scene and I always find something I didn't expect:
// paste in devtools after your scene loads
console.table({
geometries: renderer.info.memory.geometries, // anything > 500 is suspicious
textures: renderer.info.memory.textures, // leaks show up here first
drawCalls: renderer.info.render.calls, // this is your bottleneck number
triangles: renderer.info.render.triangles
});
// reset per-frame counters between checks
renderer.info.reset();
If your geometry count keeps climbing as users interact with the scene, you have a leak. Three.js manages JS memory through the garbage collector like normal, but GPU-side resources — buffers, textures, compiled shaders — stay allocated until you explicitly release them. The GC never touches them. I've watched texture counts hit 800 in a single session because nobody was cleaning up after mesh swaps.
// call this every time you remove a mesh from the scene
function disposeMesh(mesh) {
mesh.geometry.dispose();
// material can be an array if you used multiple materials on one geometry
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.forEach(mat => {
// dispose every map you set — forgetting envMap or normalMap is common
Object.keys(mat).forEach(key => {
if (mat[key] && typeof mat[key].dispose === 'function') {
mat[key].dispose();
}
});
mat.dispose();
});
scene.remove(mesh);
}
For static parts of your scene — grid floors, reference objects, environment geometry — mergeGeometries is the single highest-use optimization I've applied. Merging 80 grid cells into one mesh dropped my draw calls from 90 to 11 on that section alone:
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
const gridGeos = [];
for (let i = 0; i < 10; i++) {
for (let j = 0; j < 10; j++) {
const geo = new THREE.BoxGeometry(1, 0.05, 1);
// bake the position into the geometry itself before merging
geo.translate(i * 1.1, 0, j * 1.1);
gridGeos.push(geo);
}
}
// one geometry, one draw call, one material — that's it
const merged = mergeGeometries(gridGeos);
const gridMesh = new THREE.Mesh(merged, new THREE.MeshStandardMaterial({ color: 0x444444 }));
scene.add(gridMesh);
// clean up the individual source geometries
gridGeos.forEach(g => g.dispose());
The catch with merging: you lose the ability to manipulate individual pieces at runtime. Don't merge anything the user interacts with. Also don't merge geometries that use different materials — each material still equals one draw call, so texture atlasing is the companion technique here. Pack your UI icons, surface preview swatches, and control handle textures into a single atlas image, use UV offsets to select regions, and your whole controls layer costs one draw call instead of twelve. The math isn't fun but the profiler result is.
Gotchas I Hit That Wasted Half a Day Each
The WebGL context loss one burned me the hardest because it fails silently. On mobile, if a user switches tabs or the browser decides to reclaim GPU memory, your WebGL context just disappears. No error in the console, no thrown exception — the canvas goes black and stays black. The fix is wiring up the context lost and restored events before you do anything else:
const canvas = renderer.domElement;
canvas.addEventListener('webglcontextlost', (event) => {
// must call preventDefault() or the context will NOT be restored
event.preventDefault();
console.warn('WebGL context lost — pausing render loop');
cancelAnimationFrame(animationFrameId);
}, false);
canvas.addEventListener('webglcontextrestored', () => {
// re-upload textures, re-compile shaders, restart loop
initTextures();
renderer.setRenderTarget(null);
animate();
}, false);
The event.preventDefault() call is the non-obvious part. Without it, the browser doesn't attempt restoration at all. I spent two hours assuming the context was restoring itself before I found that in the MDN fine print.
The Three.js r152 color space rename wrecked me because I was pulling in a plugin that still used the old API while my renderer used the new one. The result was washed-out, overexposed textures that looked fine in isolation but wrong in the scene. The rename isn't just cosmetic — the underlying behavior changed too. The mapping is:
// Old API (pre-r152) — don't mix these with new code
renderer.outputEncoding = THREE.sRGBEncoding;
texture.encoding = THREE.sRGBEncoding;
// New API (r152+) — use this exclusively
renderer.outputColorSpace = THREE.SRGBColorSpace;
texture.colorSpace = THREE.SRGBColorSpace;
// If you load textures via TextureLoader, set this globally
THREE.ColorManagement.enabled = true; // on by default in r152+
The safest migration move: grep your entire codebase (and your dependencies' source if they're local) for Encoding and replace everything in one commit. Partial migration is where the subtle color drift lives.
Z-fighting on coplanar faces looks like flickering geometry where two surfaces occupy the same depth — classic when you're rendering a solid mesh plus a wireframe overlay on top. My first instinct was tuning camera.near to something like 0.01, which helped a little but broke depth precision elsewhere in the scene. The actual fix is polygon offset on the wireframe material, not camera parameters:
const wireframe = new THREE.LineSegments(
new THREE.WireframeGeometry(geometry),
new THREE.LineBasicMaterial({
color: 0x000000,
polygonOffset: true,
polygonOffsetFactor: 1, // push wireframe slightly in front
polygonOffsetUnits: 1,
})
);
scene.add(wireframe);
Tweak polygonOffsetFactor between 1 and 4 depending on how close your camera gets. Higher values work better at oblique angles but can cause the wireframe to visually detach from the surface at distance.
side: THREE.DoubleSide on a material sounds like a safe default when you're not sure which way your face normals point — and it is, until you profile. The GPU has to run the fragment shader twice per fragment for double-sided geometry, which tanks fill rate on complex meshes. I made the mistake of setting it globally on a material shared across 200+ objects and frame time jumped noticeably on mid-range Android devices. The right approach: use it surgically on thin geometry like leaves or panels where back-face visibility genuinely matters, and fix your normals everywhere else with geometry.computeVertexNormals() or by flipping faces in Blender before export.
Deploying to Production: It's Just Static Files, Mostly
The thing that catches most people off guard is the DRACO decoder situation. You add DRACOLoader to your scene, everything works perfectly in dev, and then your production build silently fails to load compressed GLTF files. The reason: Vite does not copy the DRACO WASM decoder files automatically. You have to do it yourself.
# After npm install, find the decoder files here:
node_modules/three/examples/jsm/libs/draco/
# Copy the whole folder into your public/ directory
cp -r node_modules/three/examples/jsm/libs/draco/ public/draco/
# Then point your DRACOLoader at it in code:
# dracoLoader.setDecoderPath('/draco/')
I've seen teams spend hours on this. The WASM files (draco_decoder.wasm, draco_wasm_wrapper.js) need to be served as static assets, not bundled. Once they're in public/draco/, they get copied verbatim to dist/draco/ at build time and everything works. Add this to your deployment checklist and don't rely on muscle memory.
The tree-shaking story with Three.js is actually pretty good — as long as you use named imports consistently. import { WebGLRenderer, Scene, PerspectiveCamera } from 'three' gives Vite enough signal to drop the stuff you're not using. import * as THREE from 'three' pulls in everything, which is around 600KB minified. With named imports and a moderately complex scene, I got my Three.js chunk down to around 280KB gzipped. Not tiny, but respectable for a full 3D engine. Run vite build --mode production and check dist/assets/ — the filenames will include content hashes, which is exactly what you want for cache busting.
# Inspect what's actually in your bundle
npx vite-bundle-visualizer
# Or use the built-in rollup output stats
vite build --mode production 2>&1 | grep "dist/"
CSP headers will absolutely break WebGL in ways that feel completely unrelated. The specific issue is unsafe-eval — some WebGL shader compilation paths (and the DRACO decoder specifically) use eval-adjacent mechanisms. If your server or CDN sets a strict Content-Security-Policy without 'unsafe-eval' in script-src, you'll see a blank canvas with zero console errors in some browsers. The header you probably need looks like this:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval'; worker-src blob:; connect-src 'self'
The worker-src blob: is for WASM workers that the DRACO decoder spins up. Test this with your actual CDN config before launch, not just locally — Netlify, Vercel, and CloudFront all handle custom headers differently, and what works in vite preview might not match your production headers.
Safari on iOS 16 is the hardware test you cannot skip. Floating point render targets (THREE.FloatType textures used for things like position maps in GPU particle systems or picking buffers) are not reliably supported. The extension check renderer.extensions.get('OES_texture_float') returns true, but actual rendering produces garbage or black textures on certain A-series chips. The fix is to fall back to THREE.HalfFloatType on mobile and test the branch on a real device — an iPhone 12 or 13 running iOS 16.x is still common enough in your user base to matter. iOS simulator on macOS does not reproduce this. Remote debugging via Safari's Web Inspector over USB is the only reliable way to catch it before users do.
When Three.js Is the Wrong Answer
The thing that burns teams most often is committing to Three.js before asking whether they need a rendering library or a domain-specific tool. Three.js renders geometry beautifully. It does almost nothing else. If your feature list includes physics, boolean CSG operations, or real engineering-grade constraints, you're going to spend months bolting on what other tools ship by default.
Physics as a Core Feature
Three.js has zero physics. Not "limited physics" — zero. If rigid body dynamics, collision detection, or joint constraints are central to your modeling tool (think: simulating how parts fit together, snap constraints, stress testing), you need Rapier.js or cannon-es running alongside it. Rapier is the better pick right now — it's written in Rust and compiled to WASM, so you get near-native performance, and the API is actually pleasant to use. But here's what nobody warns you about: syncing a physics world with a Three.js scene on every frame tick gets messy fast. You're maintaining two separate transform hierarchies and manually copying position/quaternion data between them. If physics is a core feature and not an afterthought, evaluate whether a purpose-built engine handles this coupling better before you write the sync loop yourself.
// The sync loop you'll end up writing with Rapier + Three.js
// This runs every frame — and it WILL become your performance bottleneck
rigidBodies.forEach(({ mesh, rigidBody }) => {
const position = rigidBody.translation();
const rotation = rigidBody.rotation();
mesh.position.set(position.x, position.y, position.z);
mesh.quaternion.set(rotation.x, rotation.y, rotation.z, rotation.w);
});
Real CAD / Boolean Operations
Three.js cannot do boolean mesh operations natively. There are CSG libraries like three-bvh-csg that bolt this on, and they work for simple cases, but they fall apart with complex geometry, non-manifold meshes, or operations on imported STEP/IGES files. If your users expect to union, subtract, and intersect solids the way Fusion 360 does, look hard at OpenCascade.js — it's the full OpenCASCADE geometry kernel compiled to WebAssembly. The bundle is large (~10MB+ gzipped for the full build, though you can trim it), and the API mirrors the C++ original which means it's not beginner-friendly. But it handles the B-rep modeling, fillets, chamfers, and proper parametric history that Three.js will never have. I'd use Three.js purely as the rendering layer on top of an OpenCascade.js geometry pipeline, not as the source of truth for solid geometry.
Your Team Already Owns a Game Engine
If your organization has Unity or Unreal expertise and the 3D modeling tool isn't a standalone web product but more of an embedded viewer or configurator, rebuilding in Three.js is an organizational cost question as much as a technical one. Unity's WebGL export has gotten genuinely usable — build times are still painful and the initial load is heavy (~30MB+ for a minimal build), but your team ships features instead of rebuilding a camera rig and object picker from scratch. Unreal Pixel Streaming is the right path when you need photorealistic rendering and can afford cloud GPU costs — the browser becomes a thin client streaming video from an Unreal instance. The trade-off is obvious: you pay per-stream compute costs and latency becomes a real UX problem. But if the alternative is six engineers trying to approximate Unreal's material system in WebGL shaders, the math might favor Pixel Streaming.
3D Data Visualization Isn't the Same Problem
If someone handed you a requirements doc that says "3D bar charts," "network graph with depth," or "geospatial data with elevation," and your first instinct was Three.js — pause. deck.gl handles large-scale geospatial 3D visualization with GPU-accelerated layers and a data-driven API that would take months to replicate in raw Three.js. For scientific or analytical charts, Observable Plot's 3D capabilities combined with D3 projections cover most cases without you managing a WebGL context at all. Rolling your own 3D scatter plot in Three.js isn't wrong, but you'll spend 80% of your time on camera controls, label occlusion, and picking — problems deck.gl already solved. Use Three.js when you need custom geometry and interaction, not when you need a chart that happens to have depth.
Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.
Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.
Top comments (0)