The Problem That Started This
The requirement landed in a Notion doc three weeks before sprint planning: customers needed to configure custom parts — think L-shaped brackets, enclosures with variable wall thickness, mounting plates with parametric hole patterns — directly inside our SaaS dashboard. No file uploads, no “download and open in Blender”, no installer prompts. Just a browser tab that behaves like a real modeling tool. That constraint alone killed half the options we looked at before we even wrote a line of code.
I spent two days seriously evaluating Spline, and it’s genuinely impressive for what it does — 3D motion, interactive scenes, design handoff. But Spline is built around mesh manipulation and animation, not parametric constraints. There’s no concept of “this edge length is driven by this input field”. You can’t say hole diameter = 6mm, hole spacing = 20mm, quantity = 4 and have the geometry update. When your user is a mechanical engineer configuring a part for a quote, that matters more than beautiful easing curves. Same story with Three.js editors like the built-in Three.js editor — great for scene authoring, completely wrong abstraction for parametric workflows.
The three-month clock was non-negotiable. One frontend dev (me), one backend dev handling the pricing and persistence API, and a decision from the founders: ship something real or cut the feature entirely and refocus the roadmap. That kind of constraint is clarifying. You stop debating ideal architectures and start asking “what’s the minimum thing that makes a user say this works?” We needed a 3D kernel that could handle CSG operations, a renderer that could handle 60fps in Chrome without a dedicated GPU, and a UI that didn’t require a 40-page manual. We needed to pick a stack by end of week one.
Figma vs Adobe XD in 2026: What I Actually Use After Running Design Sprints at Two Startups
The thing that caught me off guard early was how much “3D in the browser” tooling is still aimed at game developers or 3D artists, not product configurator builders. The gap between “rotating a GLB file” and “letting users drive geometry with parameters” is enormous, and almost nothing bridges it cleanly out of the box. That mismatch shaped every architectural decision we made. For a broader look at the SaaS infrastructure tooling we leaned on around this, check out our guide on Essential SaaS Tools for Small Business in 2026 — some of those picks ended up being load-bearing for our auth and billing layer while we stayed focused on the 3D side.
Picking Your Rendering Foundation: Three.js vs Babylon.js vs React Three Fiber
The thing that pushed me away from raw Three.js wasn’t performance — it was mesh ownership. Three months into a previous project I had a scene.add() call buried three callback layers deep, nobody could tell who was responsible for disposing the geometry, and memory was leaking on every model swap. React Three Fiber’s component model solves this by making the scene graph declarative: when a component unmounts, R3F disposes the associated Three.js objects automatically. That single guarantee is worth more than any benchmark.
# Install the full stack you'll actually need from day one
npm install three @react-three/fiber @react-three/drei
# If you're on TypeScript (you should be)
npm install -D @types/three
Don’t even think about skipping @react-three/drei. It’s a utility library maintained by the R3F team that gives you <OrbitControls>, <TransformControls>, <useGLTF>, <Html> for overlaying DOM elements in 3D space, and about 60 other things you’d otherwise spend two weeks writing yourself. On a 3-month timeline, drei is not optional — it’s the difference between shipping and not shipping. The gzip overhead of the entire R3F + drei stack over raw Three.js is roughly 4KB, which you will never notice but your team’s velocity will.
Babylon.js is the honest answer for teams that need physics on day one. It ships @babylonjs/havok — a WASM-based Havok physics integration — without you wiring up Cannon.js or Rapier yourself. The built-in Inspector panel (scene.debugLayer.show()) is also genuinely good: you can inspect meshes, materials, and render passes without installing a separate devtools extension. Their TypeScript types are also generated directly from source, so autocomplete is tight. If I were building a game with collision detection rather than a CAD-adjacent modeling tool, I’d pick Babylon. But the React component model matters more for a UI-heavy modeling app where you’re managing panels, toolbars, undo stacks, and property editors all talking to the same scene graph.
Here’s a minimal R3F setup that actually does something useful — renders a mesh with transform controls and cleans up after itself:
import { Canvas } from '@react-three/fiber'
import { TransformControls, OrbitControls, useGLTF } from '@react-three/drei'
import { useRef, useState } from 'react'
function Model({ url }: { url: string }) {
const { scene } = useGLTF(url)
const ref = useRef(null)
// R3F disposes scene resources when this component unmounts —
// no manual geometry.dispose() calls needed
return
}
export function Viewport() {
const [mode, setMode] = useState<'translate' | 'rotate' | 'scale'>('translate')
return (
)
}
The one sharp edge with R3F is the render loop. By default R3F runs at 60fps continuously, which drains laptop batteries for a modeling tool where nothing is animating. Set frameloop="demand" on <Canvas> and call invalidate() from the useThree hook whenever your state changes. I missed this for the first two weeks and was running the GPU at full tilt for a completely static scene. Babylon handles this better out of the box with its scene.renderOnlyOnce() pattern, but R3F’s fix is a one-liner once you know about it.
Week 1–2: Scaffolding the Scene and Getting Your Camera Right
The camera setup is where most 3D web projects quietly fail in week one. You pick PerspectiveCamera because that’s what every tutorial uses, wire up OrbitControls, and feel great. Then your first user tries to align two objects on the same plane and loses their mind because perspective distortion makes “are these flush?” completely unanswerable. PerspectiveCamera is right for free-form exploration. OrthographicCamera is what CAD users, architects, and anyone doing precision work actually need — especially in top/front/side views. I now default to Perspective for the initial 3D view but wire in hotkeys (numpad 1/3/7 style) that swap to Ortho and reposition the camera. Users with any modeling background expect exactly that.
// r3f Canvas setup — this gets you 90% of the way on day one
import { Canvas } from '@react-three/fiber'
import { OrbitControls, Environment } from '@react-three/drei'
import * as THREE from 'three'
export default function Viewport() {
return (
<Canvas
shadows // enables shadow maps globally
camera={{ fov: 45, near: 0.1, far: 1000, position: [5, 5, 5] }}
gl={{
antialias: true,
toneMapping: THREE.ACESFilmicToneMapping, // kills the washed-out look
toneMappingExposure: 1.2,
outputColorSpace: THREE.SRGBColorSpace, // THREE r152+ renamed this
}}
>
<ambientLight intensity={0.4} />
<directionalLight
castShadow
position={[10, 20, 10]}
intensity={1.5}
shadow-mapSize={[2048, 2048]} // default 512 looks terrible on sharp edges
shadow-camera-far={50}
shadow-camera-left={-20}
shadow-camera-right={20}
shadow-camera-top={20}
shadow-camera-bottom={-20}
/>
<Environment preset="city" /> // HDR environment without a 4MB file
<SceneContents />
<OrbitControlsWithDamping />
</Canvas>
)
}
The enableDamping gotcha on OrbitControls burned me for half a day. You turn it on because the camera feels snappy without it, but then nothing moves. The problem: damping requires controls.update() on every frame — it doesn’t hook itself into r3f’s render loop automatically. If you’re using drei’s <OrbitControls>, pass makeDefault and it handles this. If you’re wiring up vanilla THREE.OrbitControls manually (which you might do for imperative control), you need this in your useFrame:
import { useFrame } from '@react-three/fiber'
import { useRef } from 'react'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
function ManualControls() {
const controlsRef = useRef<OrbitControls>()
useFrame(() => {
// without this line, enableDamping silently does nothing
controlsRef.current?.update()
})
// ... attach to camera and domElement
}
The shadow camera frustum settings in the snippet above aren’t optional decoration. A directional light with a shadow-camera frustum of ±10 on a scene that spans 30 units will clip half your shadows without any warning. I set it to ±20 as a starting point, then tune down once I know the scene bounds. Also: shadow-mapSize={[2048, 2048]} over the default 512 is a night-and-day difference on object edges. Yes it costs VRAM. Yes it’s worth it for a modeling tool where users zoom into seams.
Here’s the architecture point that most people get wrong: don’t build your select-and-transform workflow in week 6. Build it ugly in week 3, even if it’s just a hardcoded TransformControls from drei attached to a single box. The reason is that selection state — which objects are selected, how multi-select works, what “active” means — bleeds into everything. Your undo stack, your object hierarchy panel, your properties inspector, your keyboard shortcuts, your snap logic. Every one of those systems needs to know about selection. If you build four of them without a real selection model, you’ll rewrite all four when you finally define it. I’ve seen this pattern kill timelines on exactly this type of project.
// stub this out on day 3, not day 40
import { TransformControls } from '@react-three/drei'
import { useSceneStore } from '../store/sceneStore'
function SelectionGizmo() {
const selectedId = useSceneStore(s => s.selectedId)
const selectedRef = useSceneStore(s => s.objectRefs[selectedId])
if (!selectedId || !selectedRef?.current) return null
return (
<TransformControls
object={selectedRef.current}
mode="translate" // swap to 'rotate' / 'scale' via keyboard later
onObjectChange={() => {
// fire your undo snapshot here, not inline in geometry code
useSceneStore.getState().recordTransform(selectedId)
}}
/>
)
}
One more thing about camera defaults that bites people: fov: 45 in the Canvas camera prop is measured in degrees vertical FOV, same as Three.js. But if you later construct a camera imperatively with new THREE.PerspectiveCamera(fov, aspect, near, far), that first argument is also vertical FOV — not horizontal like some game engines. I’ve watched devs spend an hour debugging a “zoomed in” camera because they set fov: 75 thinking it matched their engine background. Keep it at 45–60 for a modeling tool. Wider than that and your users’ objects start looking like fish-eye photos at the edges.
The State Management Problem Nobody Talks About
The thing that will genuinely surprise you two weeks into building this is that Three.js and React have fundamentally opposing philosophies about mutation. Three.js expects you to grab a mesh and do mesh.position.x += 0.5 directly. React’s entire reconciliation model assumes state is replaced, not mutated. When you try to bridge these naively — storing mesh objects in state, or worse, calling setState on every frame — you’ll either get stale refs or tank your FPS in ways that are painful to debug.
I switched to Zustand after about a week of Redux pain. The flat store pattern is genuinely better for 3D scene state, not because Zustand is “simpler” in some vague sense, but because scene state is shallow-but-wide: dozens of objects each with a handful of properties, not deeply nested hierarchies. With Redux, I found myself writing four files to update a mesh’s position. With Zustand:
npm install zustand
// store/sceneStore.ts
import { create } from 'zustand'
interface MeshState {
id: string
position: [number, number, number]
rotation: [number, number, number]
scale: [number, number, number]
materialColor: string
name: string
}
interface SceneStore {
meshes: Record<string, MeshState>
selectedId: string | null
updateMesh: (id: string, patch: Partial<MeshState>) => void
selectMesh: (id: string | null) => void
addMesh: (mesh: MeshState) => void
removeMesh: (id: string) => void
}
export const useSceneStore = create<SceneStore>((set) => ({
meshes: {},
selectedId: null,
updateMesh: (id, patch) =>
set((state) => ({
meshes: { ...state.meshes, [id]: { ...state.meshes[id], ...patch } },
})),
selectMesh: (id) => set({ selectedId: id }),
addMesh: (mesh) =>
set((state) => ({ meshes: { ...state.meshes, [mesh.id]: mesh } })),
removeMesh: (id) =>
set((state) => {
const { [id]: _, ...rest } = state.meshes
return { meshes: rest }
}),
}))
The critical design rule: never put Three.js objects in your Zustand store. No THREE.Mesh, no THREE.Material, no BufferGeometry. Those objects aren’t serializable, they carry WebGL state, and they’ll cause memory leaks when Zustand triggers re-renders. Keep them in React refs — useRef<THREE.Mesh>(null) inside your mesh component — and use a separate ref map at the top level if you need to look up a mesh imperatively. Your Zustand store holds only primitives: position as a [number, number, number] tuple, color as a hex string, IDs as strings. The R3F component reads from the store and pushes changes back to the Three.js object via the ref, not the other way around.
// MeshObject.tsx — the bridge between store and Three.js
function MeshObject({ id }: { id: string }) {
const meshRef = useRef<THREE.Mesh>(null)
// subscribe only to this mesh's state to avoid unnecessary re-renders
const mesh = useSceneStore((state) => state.meshes[id])
const updateMesh = useSceneStore((state) => state.updateMesh)
// sync store → Three.js
useEffect(() => {
if (!meshRef.current) return
meshRef.current.position.set(...mesh.position)
meshRef.current.rotation.set(...mesh.rotation)
meshRef.current.scale.set(...mesh.scale)
}, [mesh.position, mesh.rotation, mesh.scale])
// drag handler: Three.js → store (only on drag end, not every frame)
const handleDragEnd = useCallback(() => {
if (!meshRef.current) return
updateMesh(id, {
position: meshRef.current.position.toArray() as [number, number, number],
})
}, [id, updateMesh])
return <mesh ref={meshRef} onPointerUp={handleDragEnd}> ... </mesh>
}
The undo/redo system is where most tutorials steer you wrong. The instinct is to snapshot the entire scene state on every change — but a complex model with 500 meshes means every command push stores 500 objects. Your memory usage will balloon to hundreds of megabytes after 30 operations. The correct approach is command objects: store what changed, not everything that exists.
// store/historyStore.ts
interface Command {
type: 'UPDATE_MESH' | 'ADD_MESH' | 'REMOVE_MESH'
id: string
before: Partial<MeshState> // only the fields that changed
after: Partial<MeshState>
}
interface HistoryStore {
past: Command[]
future: Command[]
push: (cmd: Command) => void
undo: () => void
redo: () => void
}
export const useHistoryStore = create<HistoryStore>((set, get) => ({
past: [],
future: [],
push: (cmd) =>
set((s) => ({ past: [...s.past, cmd], future: [] })),
undo: () => {
const { past, future } = get()
if (!past.length) return
const cmd = past[past.length - 1]
useSceneStore.getState().updateMesh(cmd.id, cmd.before)
set({ past: past.slice(0, -1), future: [cmd, ...future] })
},
redo: () => {
const { past, future } = get()
if (!future.length) return
const cmd = future[0]
useSceneStore.getState().updateMesh(cmd.id, cmd.after)
set({ past: [...past, cmd], future: future.slice(1) })
},
}))
One gotcha I hit: Zustand’s getState() call inside the history store works fine for cross-store communication, but only because Zustand stores are module-level singletons. Don’t try this pattern with Redux — the equivalent would require thunks or sagas and triple the code. Also cap your history at 50–100 commands. There’s no automatic eviction; if you let past grow unbounded and users are doing rapid transformations (dragging a mesh across the scene), you’ll accumulate thousands of command objects fast. A simple slice in the push action fixes it: past: [...s.past, cmd].slice(-100).
Week 3–4: Building the Core Modeling Operations
The thing that burned me first with CSG operations wasn’t performance — it was silent corruption. I used three-csg-ts initially because it came up in every tutorial, ran a subtract operation on a mesh with around 3,500 faces, and got back a geometry that looked fine until I tried to export it. Broken normals, missing faces, winding order chaos. The root problem is that the naive BSP-tree approach in the original three-csg library (and its TypeScript ports) doesn’t use any spatial acceleration — every face gets tested against every other face. Past ~2k faces per operand, you’re not just slow, you’re getting floating-point precision errors that silently corrupt the output mesh.
Switch to three-bvh-csg. It uses a Bounding Volume Hierarchy to limit face comparisons, handles coplanar faces better, and actually produces watertight output consistently. The install is one line, but there’s a hard dependency you need to know about upfront:
# Requires Three.js r152 or newer — don't skip this check
npm install three-bvh-csg three-mesh-bvh
# Verify your three version before wiring anything up
node -e "const t = require('three'); console.log(t.REVISION)"
If your Three.js revision is below 152, the BufferGeometry API shape that three-bvh-csg expects doesn’t match. You won’t get an error thrown — you’ll get a corrupted geometry back with no warning, same as the naive library. I spent half a day on this before checking the revision number. The actual CSG usage looks like this:
import { Evaluator, SUBTRACTION, ADDITION, INTERSECTION } from 'three-bvh-csg';
const evaluator = new Evaluator();
// Reuse the evaluator instance — creating one per operation leaks memory
evaluator.useGroups = false; // true if you need material groups preserved
const resultMesh = evaluator.evaluate(meshA, meshB, SUBTRACTION);
scene.add(resultMesh);
// Clean up operands if you no longer need them
meshA.geometry.dispose();
meshB.geometry.dispose();
Raycasting for selection has one gotcha that I guarantee will cost you debugging time if you miss it. When your scene has grouped objects — and in a modeling tool, everything ends up in a group — raycaster.intersectObjects(scene.children) returns nothing, because it only tests the top-level children. The recursive flag is what makes it traverse the full subtree:
// Wrong — misses anything inside a Group or Object3D parent
const hits = raycaster.intersectObjects(scene.children);
// Right — traverses the full scene graph
const hits = raycaster.intersectObjects(scene.children, true);
// Also useful: filter to only your model meshes, not helpers/gizmos
const modelMeshes = [];
scene.traverse(obj => {
if (obj.isMesh && obj.userData.selectable) modelMeshes.push(obj);
});
const hits = raycaster.intersectObjects(modelMeshes, true);
I set userData.selectable = true on every user-created mesh and exclude gizmo/helper objects. Otherwise your transform gizmo arrows register as click targets and you spend two days wondering why clicking near an object selects it but clicking directly on it doesn’t.
For the transform gizmo itself: three-transform-controls (the npm package three/addons/controls/TransformControls in Three.js r152+) is worth using unless you need very specific interaction behavior. The out-of-box feel is good — snap-to-grid, axis constraints, space switching between world/local all work. The honest trade-off is that customizing the visual style is painful. The gizmo rendering is deeply embedded in the class and doesn’t expose clean hooks. If your design calls for custom handle shapes, colors that respond to selection state, or touch-first interaction, you’ll end up fighting the internals. I built a custom gizmo for my second iteration using THREE.Line and THREE.Mesh handle objects, raycasting against them manually, and it took about 3 days but I had full control. For a 3-month project where you’re hitting MVP, start with TransformControls, plan to replace it if product feedback says the feel is wrong:
import { TransformControls } from 'three/addons/controls/TransformControls.js';
const transformControls = new TransformControls(camera, renderer.domElement);
transformControls.setMode('translate'); // 'translate' | 'rotate' | 'scale'
transformControls.setSpace('world'); // 'world' | 'local'
// Critical: disable your orbit controls while dragging a gizmo handle
transformControls.addEventListener('dragging-changed', (event) => {
orbitControls.enabled = !event.value;
});
scene.add(transformControls);
// Attach to selected mesh
transformControls.attach(selectedMesh);
// Detach on deselect
transformControls.detach();
The dragging-changed event listener is non-optional. Without it, moving an object and orbiting the camera fire simultaneously and your users will report that “dragging objects is broken.” This isn’t documented prominently — it’s buried in the official examples and you only find it after filing a confused GitHub issue or spending a morning on it.
Handling File I/O: Import and Export That Actually Works
The first thing that surprised me about Three.js’s GLTFExporter was how close it gets to working perfectly — and how it fails in exactly the one edge case users hit most. The Draco compression path in GLTFExporter has a normals issue: geometry exported with Draco encoding and then re-imported via GLTFLoader will sometimes silently drop vertex normals, which manifests as flat-shaded garbage on curved surfaces. I spent an afternoon thinking I had a loader bug before I realized the exported file itself was corrupt. The fix is either to avoid Draco in the exporter altogether, or recompute normals after load with geometry.computeVertexNormals() as a defensive measure. Neither option is great, but at least you can ship.
OBJ export is your boring, reliable fallback. The files are text, larger, and have no material embedding story worth talking about, but literally every downstream tool — Blender, Maya, Fusion 360, online viewers — loads them without a complaint. If a user just needs to get their model out and into something else, OBJ is the answer. Three.js’s OBJExporter is also much simpler code than GLTFExporter, which means fewer failure modes. Here’s what the export path looks like in practice:
import { OBJExporter } from 'three/addons/exporters/OBJExporter.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
function exportModel(scene, format = 'glb') {
if (format === 'obj') {
const exporter = new OBJExporter();
const result = exporter.parse(scene);
downloadBlob(new Blob([result], { type: 'text/plain' }), 'model.obj');
return;
}
const exporter = new GLTFExporter();
exporter.parse(
scene,
(gltf) => {
// GLB gives you a single binary blob — prefer this over JSON GLTF
const blob = new Blob([gltf], { type: 'model/gltf-binary' });
downloadBlob(blob, 'model.glb');
},
(error) => console.error('Export failed:', error),
{ binary: true, trs: false } // avoid Draco here unless you handle the normals bug
);
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
a.click();
URL.revokeObjectURL(url); // don't leak object URLs
}
For drag-and-drop import, the FileReader + URL.createObjectURL() pattern is what you want. The key insight is that GLTFLoader accepts a URL string natively, so you can hand it a blob URL directly without reading the file contents yourself. OBJ requires you to read the text first. Here’s the full event listener setup I use:
const dropZone = document.getElementById('viewport');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault(); // required or drop won't fire
e.dataTransfer.dropEffect = 'copy';
});
dropZone.addEventListener('drop', async (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
if (ext === 'glb' || ext === 'gltf') {
// GLTFLoader is happy with a blob URL — no FileReader needed
const url = URL.createObjectURL(file);
const loader = new GLTFLoader();
loader.load(
url,
(gltf) => {
scene.add(gltf.scene);
URL.revokeObjectURL(url); // clean up after load completes
},
undefined,
(err) => {
console.error('GLTF load error:', err);
URL.revokeObjectURL(url);
}
);
} else if (ext === 'obj') {
// OBJLoader needs text content, so FileReader is necessary here
const reader = new FileReader();
reader.onload = (event) => {
const loader = new OBJLoader();
const object = loader.parse(event.target.result);
scene.add(object);
};
reader.readAsText(file);
}
});
One gotcha: if you’re loading GLTF (not GLB) that references external texture files, the blob URL approach breaks because the loader can’t resolve relative texture paths from a blob origin. Either tell users to use GLB (single-file format), or use FileReader.readAsArrayBuffer() and pass raw bytes through a custom LoadingManager with a file map. For most 3D modeling tools, just requiring GLB is the right product decision — explain it in a tooltip and move on.
The Draco client-side compression story is real but has a meaningful cost. The draco3dgltf npm package does work, cuts exported file sizes 60–70% for geometry-heavy models, and runs entirely in the browser via WASM. The catch is that the WASM binary itself adds roughly 2MB to your JavaScript bundle if you just import it naively. You absolutely need to code-split this. Lazy-load it only when the user clicks “Export with compression”:
async function exportWithDraco(scene) {
// this dynamic import only loads draco3dgltf when the user actually needs it
const { MeshoptEncoder } = await import('draco3dgltf');
const exporter = new GLTFExporter();
exporter.parse(
scene,
(gltf) => {
const blob = new Blob([gltf], { type: 'model/gltf-binary' });
downloadBlob(blob, 'model-compressed.glb');
},
(error) => console.error(error),
{
binary: true,
dracoOptions: { compressionLevel: 7 } // 0-10; 7 is a good balance
}
);
}
Realistically, for a 3-month build, I’d ship OBJ export day one as the safe default, add uncompressed GLB export in week two, and defer Draco to a later milestone unless file size is an explicit user complaint. The 60–70% compression savings matter a lot for models with millions of vertices, but most browser-based modeling tools produce models in the hundreds of thousands at most, where the raw GLB is already under 5MB. Solve the actual user pain first.
Week 5–6: Performance — Where You’ll Spend More Time Than You Expect
The thing that surprises most people building their first WebGL tool isn’t the geometry math or the shader complexity — it’s that performance tuning alone will swallow a full two weeks. I budgeted three days for it. That was embarrassing in retrospect.
Start with a real target: 60fps on a mid-range machine from ~2020, something like an Intel Iris Xe or AMD Vega 8 integrated GPU with 8GB of shared memory. That’s your constraint. A MacBook Pro with an M2 is not your user. Run Chrome’s built-in frame rate meter (Ctrl+Shift+P → “Show frames per second meter”) while you actually use the tool — drag objects, apply transforms, add geometry. The frame budget is 16.67ms. Open the Performance tab, hit Record, do a representative interaction, stop, and look at the flame chart. The two columns you care about are scripting time and rendering time. If rendering is eating 12ms and scripting 3ms, your bottleneck is draw calls or fill rate. Flip those numbers and you have a JS logic problem.
The renderer.info object is the fastest sanity check you have. Drop this in your render loop during development:
// Log every 60 frames to avoid console spam
if (frameCount % 60 === 0) {
console.log({
calls: renderer.info.render.calls, // draw calls this frame
triangles: renderer.info.render.triangles,
geometries: renderer.info.memory.geometries, // GPU-allocated geometries
textures: renderer.info.memory.textures,
});
renderer.info.autoReset = true;
}
If calls is above 200 on a normal scene, you have a draw call problem. The fix is almost always THREE.InstancedMesh. The API is genuinely awkward — you set per-instance transforms via a matrix4 interface rather than just setting position/rotation directly — but there is no shortcut around it once you have any grid, array, or repeated-object feature in your tool. Here’s the pattern I settled on:
const count = 500;
const mesh = new THREE.InstancedMesh(geometry, material, count);
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); // flag for frequent updates
const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3(1, 1, 1);
for (let i = 0; i < count; i++) {
position.set(i * 2, 0, 0);
matrix.compose(position, quaternion, scale);
mesh.setMatrixAt(i, matrix);
}
// This upload step is what people forget — nothing renders correctly without it
mesh.instanceMatrix.needsUpdate = true;
scene.add(mesh);
That drops 500 draw calls to 1. The gotcha is that instanceMatrix.needsUpdate = true is required every time you modify any instance transform, not just on initialization. I spent an afternoon debugging “why aren’t my instances moving” before I internalized that.
THREE.LOD is worth implementing for complex imported models (think high-poly CAD exports), but be honest with yourself about the distance thresholds. The default behavior switches LOD levels based on camera distance from the object’s position. That math assumes your objects are roughly the same size, which in a modeling tool they absolutely are not. A 2-unit bolt and a 200-unit wall panel will both switch LOD at the same pixel distance from camera, which looks completely wrong. If your users are complaining about “things randomly going blocky,” your thresholds are wrong before they’re far enough away to care. The alternative I ended up preferring for most cases: merge static geometry into fewer meshes with BufferGeometryUtils.mergeGeometries() from Three.js r152+, which reduces draw calls without the visual popping:
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
const merged = mergeGeometries([geomA, geomB, geomC], false);
const singleMesh = new THREE.Mesh(merged, sharedMaterial);
Frustum culling is on by default and people assume it just works. It does — until you do any CSG operation (boolean union, subtraction, intersection) on a mesh. After a CSG op, Three.js has no idea the geometry changed. The bounding box is stale and the frustum culler will use the old bounds to decide visibility. You’ll get objects that are clearly on screen disappearing the moment you pan the camera. The fix is one line, and you have to call it explicitly after every CSG result:
// After any CSG operation or geometry modification
resultMesh.geometry.computeBoundingBox();
resultMesh.geometry.computeBoundingSphere(); // also needed for raycasting accuracy
I’d recommend wrapping your CSG pipeline so this happens automatically rather than trusting yourself to remember it every time. A thin wrapper function around whatever CSG library you’re using (three-bvh-csg is solid as of Three.js r155+) that always calls both compute methods before returning the result mesh will save you multiple confused debugging sessions.
The WebAssembly Angle: When to Pull in WASM for Heavy Computation
The thing that caught me off guard wasn’t the complexity of the WASM integration — it was how obvious the need for it became. I tried doing mesh boolean operations (union, difference, intersection) in pure JavaScript using three-bvh-csg, and it held up fine for simple geometry. The moment a user tried to subtract a moderately complex shape from another — say, 8,000 triangles each — the main thread locked for 3–4 seconds. On mobile, it just died. You can’t debounce your way out of that. You need a proper compiled solver.
OpenCascade.js is the practical first stop. It wraps the actual OpenCASCADE Technology kernel — the same one FreeCAD and Salome use — compiled to WASM. Real fillet operations, offset surfaces, chamfers, Boolean operations that don’t degenerate on near-coplanar faces. The integration looks like this:
// Don't import at the top of your app — lazy load it only when needed
const loadOCC = async () => {
const { initOpenCascade } = await import('opencascade.js');
const occ = await initOpenCascade();
return occ;
};
// Triggered by user action, not on mount
button.addEventListener('click', async () => {
const occ = await loadOCC();
const box = new occ.BRepPrimAPI_MakeBox_2(10, 10, 10);
const shape = box.Shape();
// convert to mesh for Three.js rendering
});
The bundle size is the real gotcha: npm install opencascade.js gets you something that’s 15MB+ uncompressed before you’ve loaded a single mesh. If you ship that synchronously, your Largest Contentful Paint goes off a cliff. Lazy load it behind a dynamic import, show a loading indicator, and ideally preload the WASM binary with a <link rel="preload" as="fetch" crossorigin> so it’s already in cache by the time the user triggers the heavy operation. Splitting the kernel load from the rest of your app bundle isn’t optional here.
If your main workload is mesh booleans specifically — not full CAD operations — Google’s Manifold library compiled to WASM is worth serious consideration. My benchmarks on a 50K-triangle boolean difference: three-bvh-csg took ~2.1s on the main thread; Manifold via WASM finished in ~180ms in a worker. That’s not a marginal win. The integration is harder because Manifold expects its own mesh format and you’re writing glue code to convert from Three.js BufferGeometry, but the performance difference justifies it for complex geometry:
// Worker thread (manifold.worker.js)
import Module from 'manifold-3d';
let manifold;
Module().then(m => { manifold = m; });
self.onmessage = async ({ data }) => {
const { verts, tris, op } = data;
const mesh = new manifold.Mesh({ vertProperties: verts, triVerts: tris });
const mf = new manifold.Manifold(mesh);
// perform boolean, serialize result back
const result = mf.subtract(otherManifold);
self.postMessage({ result: result.getMesh() });
};
The threading story is where most people run into deployment surprises. To use SharedArrayBuffer (which enables real multi-threaded WASM via WebWorkers), your server must send these headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
These COOP/COEP headers break a surprising number of things. Embedded iframes that don’t set crossorigin="anonymous" stop loading. Some CDN configs — particularly older Cloudflare Workers setups and certain Fastly configurations — strip or conflict with these headers. Third-party analytics scripts loading cross-origin resources will throw. I’d recommend testing your whole integration surface before committing to SharedArrayBuffer. If you can’t get COOP/COEP deployed cleanly, fall back to message-passing between workers without shared memory — you lose some throughput but the compatibility is universal. For most modeling operations that aren’t real-time physics, the message-passing overhead is acceptable.
Week 7–8: The UI Layer — Don’t Build a CAD Interface, Build a Tool Interface
The biggest mistake I made on my first 3D tool UI was copying Blender. Don’t do that. Blender’s interface is a survival horror game that you eventually Stockholm-syndrome yourself into loving. Your users don’t have three months to learn keyboard combos — they need to manipulate a 3D object in the first 60 seconds or they’re gone. The mental model to steal from is Figma, not Maya.
Toolbar vs Sidebar — Figma’s Actual Insight
Figma puts the toolbar at the top and keeps it thin. Properties live on the right. Nothing lives on the left except the layer tree. More importantly, every toolbar action shows its keyboard shortcut in the tooltip — not hidden behind a “?” help menu, but right there on hover. I added this in week 7 using a simple data attribute pattern:
<!-- Attach shortcut metadata directly to the trigger -->
<TooltipTrigger asChild>
<button data-shortcut="V" onClick={() => setActiveTool('select')}>
<CursorIcon />
</button>
</TooltipTrigger>
<TooltipContent>
Select <kbd className="shortcut-badge">V</kbd>
</TooltipContent>
// Global keydown handler — register once in a useEffect at root level
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement) return; // don't hijack text fields
const map: Record<string, Tool> = { v: 'select', g: 'move', r: 'rotate', s: 'scale' };
if (map[e.key.toLowerCase()]) setActiveTool(map[e.key.toLowerCase()]);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [setActiveTool]);
The guard on HTMLInputElement is the thing that bites you if you forget it — pressing “G” to grab while editing an object name shouldn’t teleport your mesh.
Use Radix UI — Stop Building Panels From Scratch
I burned four days in week 5 building a custom floating panel system with drag handles, focus traps, and keyboard navigation. Then I nuked it all and ran npm install @radix-ui/react-toolbar @radix-ui/react-context-menu @radix-ui/react-popover. Radix gives you the accessibility semantics (ARIA roles, focus management, escape key handling) without imposing any styles. The unstyled part is the win — you’re not fighting CSS specificity wars. Here’s the toolbar setup that replaced my hand-rolled version:
import * as Toolbar from '@radix-ui/react-toolbar';
export function ModelingToolbar({ activeTool, onToolChange }) {
return (
<Toolbar.Root className="toolbar-root" aria-label="Modeling tools">
<Toolbar.ToggleGroup
type="single"
value={activeTool}
onValueChange={(val) => val && onToolChange(val)}
>
{TOOLS.map(({ id, label, shortcut, Icon }) => (
<Toolbar.ToggleItem key={id} value={id} className="toolbar-item">
<Icon />
<span className="sr-only">{label} ({shortcut})</span>
</Toolbar.ToggleItem>
))}
</Toolbar.ToggleGroup>
</Toolbar.Root>
);
}
The ToggleGroup handles the mutual-exclusivity of tool selection, keyboard arrow navigation between items, and proper aria-pressed states. That’s maybe 300 lines of logic you’re not writing.
The Properties Panel Re-render Trap
Binding a number input directly to a Three.js object’s position is where most people introduce a subtle bug that tanks performance. If you do value={selectedObject.position.x} and fire a React state update on every onChange, you’re triggering a re-render on every keystroke while the user types “1”, “10”, “100” — each intermediate value causes a mesh jump. The pattern that actually works is local input state with onBlur commit:
function Vec3Input({ label, value, onChange }) {
// Local state tracks the raw string while typing
const [draft, setDraft] = useState(String(value));
// Sync external changes (e.g., from dragging in viewport) into the input
useEffect(() => {
setDraft(String(parseFloat(value.toFixed(4))));
}, [value]);
const commit = () => {
const parsed = parseFloat(draft);
if (!isNaN(parsed)) onChange(parsed);
else setDraft(String(value)); // reject garbage, revert
};
return (
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && commit()}
/>
);
}
The useEffect sync going the other direction — viewport drag updating the input — is equally important. Without it, if a user drags the object in the viewport and then clicks the input, it snaps back to the stale value. The toFixed(4) prevents the input from showing “1.0000000000002” due to floating point drift in Three.js transforms.
Context Menus Wired to Raycaster Hits
Right-clicking on a 3D object and getting object-specific actions is the difference between a tool that feels alive and one that feels like a demo. The trick is storing the raycaster result at the time of the right-click, then passing that into Radix’s ContextMenu.Root trigger. I use a ref to avoid stale closures:
import * as ContextMenu from '@radix-ui/react-context-menu';
// In your R3F canvas component:
const lastHitRef = useRef(null);
useEffect(() => {
const canvas = gl.domElement;
const onContextMenu = (e) => {
e.preventDefault();
const hits = raycaster.intersectObjects(scene.children, true);
lastHitRef.current = hits[0] ?? null; // store closest hit or null for empty space
setContextMenuOpen(true);
setContextMenuPos({ x: e.clientX, y: e.clientY });
};
canvas.addEventListener('contextmenu', onContextMenu);
return () => canvas.removeEventListener('contextmenu', onContextMenu);
}, [gl, raycaster, scene]);
// Outside the canvas, portaled to document.body:
<ContextMenu.Root open={contextMenuOpen} onOpenChange={setContextMenuOpen}>
<ContextMenu.Trigger style={{ position: 'fixed', ...contextMenuPos, width: 1, height: 1 }} />
<ContextMenu.Portal>
<ContextMenu.Content className="context-menu">
{lastHitRef.current ? (
<>
<ContextMenu.Item onSelect={() => duplicateObject(lastHitRef.current.object)}>
Duplicate
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => deleteObject(lastHitRef.current.object)}>
Delete
</ContextMenu.Item>
</>
) : (
<ContextMenu.Item onSelect={pasteObject}>Paste here</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
The 1×1 pixel trigger positioned at the cursor coordinates is a Radix idiom for “programmatically positioned context menus.” It feels slightly hacky the first time you read it, but it’s the officially documented pattern and it works reliably across browsers. The Portal is non-negotiable here — without it, the menu clips against the canvas’s overflow: hidden.
Week 9–10: Collaboration and Persistence
Saving Models: Direct-to-S3 Upload Without a Proxy
The thing that surprised me most here was realizing I didn’t need a backend at all for the upload path. Generate a presigned PUT URL from your server (one lightweight Lambda or Edge Function is enough), then do the upload entirely from the browser. Here’s the exact fetch pattern I use — no axios, no multipart, just a clean PUT:
// 1. Get a presigned URL from your API (one round-trip, no proxy)
const { uploadUrl, objectKey } = await fetch('/api/presign', {
method: 'POST',
body: JSON.stringify({ filename: `model-${userId}-${Date.now()}.glb`, contentType: 'model/gltf-binary' }),
headers: { 'Content-Type': 'application/json' }
}).then(r => r.json());
// 2. Export your Three.js scene to a GLB ArrayBuffer
const exporter = new GLTFExporter();
const glbBuffer = await new Promise((resolve, reject) => {
exporter.parse(scene, resolve, reject, { binary: true });
});
// 3. PUT directly to S3/R2 — no backend sees the bytes
const uploadRes = await fetch(uploadUrl, {
method: 'PUT',
body: new Blob([glbBuffer], { type: 'model/gltf-binary' }),
headers: { 'Content-Type': 'model/gltf-binary' }
});
if (!uploadRes.ok) throw new Error(`Upload failed: ${uploadRes.status}`);
Cloudflare R2 is where I’d point you first — no egress fees and the S3-compatible API means your presign code is identical. AWS S3 works fine but watch your CORS config on the bucket. The AllowedHeaders array needs Content-Type explicitly or the PUT silently fails in Firefox even while succeeding in Chrome. That cost me two hours.
Real-Time Multiplayer Is a Scope Trap — Cut It Early
I’ve watched two separate 3D tool projects blow past their deadline on this exact feature. Yjs + WebRTC sounds like a two-day integration. The demo where you see two cursors moving around a shared document? That’s the easy part. What actually takes time: reconciling concurrent transform operations on the same mesh, conflict resolution when two users drag the same vertex, and WebRTC signaling that doesn’t fall apart behind corporate firewalls. Cursor sync alone — just showing where the other person’s pointer is in 3D space — is easily 3 weeks of work if you’re being honest about edge cases and cleanup.
If multiplayer wasn’t in your spec by week 1, it’s not an MVP feature. Ship the single-user save/load loop first. You can add Yjs later and the GLTF-to-S3 pipeline you already built becomes the “shared state” story for v1 — one user saves, another loads. Unsexy, but it works and it ships.
Autosave With localStorage: Good Enough for Month One
Zustand’s subscribe API makes this trivial to wire up. The key is debouncing on meaningful operations (mesh added, transform committed, material changed) rather than on every frame — your scene can update 60 times per second but you don’t need 60 autosaves per second.
import { debounce } from 'lodash-es';
const AUTOSAVE_KEY = 'modeler_autosave';
const MAX_BYTES = 50 * 1024 * 1024; // 50MB hard cap
const persistScene = debounce(() => {
const state = useModelStore.getState();
const serialized = JSON.stringify(state.serializableSnapshot());
// Rough byte check — avoid quota errors
const byteSize = new TextEncoder().encode(serialized).length;
if (byteSize > MAX_BYTES) {
console.warn('Autosave skipped: snapshot too large', byteSize);
return;
}
try {
localStorage.setItem(AUTOSAVE_KEY, serialized);
localStorage.setItem(`${AUTOSAVE_KEY}_ts`, Date.now().toString());
} catch (e) {
// QuotaExceededError — localStorage is full
localStorage.removeItem(AUTOSAVE_KEY);
}
}, 800);
useModelStore.subscribe(persistScene);
The rotation strategy matters here. Don’t just keep overwriting one key — keep a rolling buffer of the last 3 autosaves with timestamps so users can recover from an accidental delete. autosave_0, autosave_1, autosave_2 in a simple round-robin. localStorage’s 5–10MB browser limit will bite you on complex scenes before you hit 50MB, which is exactly why this is a “first pass” — it buys you time to build the IndexedDB layer.
Version History With idb-keyval: Skip the Custom Tree
I went down the path of building a proper version tree with branching. Don’t. For v1, a linear snapshot history stored in IndexedDB is all you need and idb-keyval reduces the whole thing to a few async calls — no schema migrations, no manual cursor management, just a wrapper around a single IndexedDB object store.
import { get, set, keys, del } from 'idb-keyval';
const MAX_VERSIONS = 20;
export async function saveVersion(label: string, scene: THREE.Scene) {
const exporter = new GLTFExporter();
const glbBuffer = await new Promise<ArrayBuffer>((resolve, reject) => {
exporter.parse(scene, resolve, reject, { binary: true });
});
const versionKey = `v_${Date.now()}`;
await set(versionKey, {
label,
timestamp: Date.now(),
glb: glbBuffer // stored as raw ArrayBuffer — no base64 overhead
});
// Prune oldest snapshots past the limit
const allKeys = (await keys() as string[])
.filter(k => k.startsWith('v_'))
.sort();
if (allKeys.length > MAX_VERSIONS) {
const toDelete = allKeys.slice(0, allKeys.length - MAX_VERSIONS);
await Promise.all(toDelete.map(del));
}
}
export async function listVersions() {
const allKeys = (await keys() as string[])
.filter(k => k.startsWith('v_'))
.sort()
.reverse(); // newest first
return Promise.all(allKeys.map(k => get(k)));
}
Storing the GLB as a raw ArrayBuffer is important — if you JSON-stringify it or base64-encode it, a 2MB model becomes 8MB in storage and your 20-version history explodes. IndexedDB handles binary blobs natively, which is exactly the use case it was designed for. At 20 snapshots of typical scenes you’re looking at maybe 40–80MB of IndexedDB usage, which modern browsers handle without complaint. The moment users start asking for named branches or comparing diffs between versions, that’s when you graduate to a proper backend — but you’ll have a shipped product by then.
Week 11–12: Shipping — The Boring Stuff That Bites You
The last two weeks feel like you should be relaxing, but this is actually where projects quietly die. You have a working 3D modeler, your demo looks great, and then you ship it and get a bug report that it’s completely broken on iPhone. Every time. The shipping phase of a WebGL app has a specific cluster of traps that don’t show up in local Chrome testing, and I’ll walk you through the ones that got me.
Bundle Splitting: Keep Three.js Out of Your Main Chunk
Three.js minified is around 600KB. If it lands in your main bundle, your first meaningful paint is blocked behind parsing and evaluating that entire thing before a single div renders. The fix is explicit chunk splitting in your Vite config. Here’s the exact setup I use:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// Three.js core in its own chunk — ~580KB minified, ~165KB gzipped
'three-core': ['three'],
// Loaders are heavy and often unused on first render
'three-loaders': [
'three/examples/jsm/loaders/GLTFLoader',
'three/examples/jsm/loaders/DRACOLoader',
'three/examples/jsm/loaders/OBJLoader',
],
// Post-processing is almost never needed on first load
'three-post': [
'three/examples/jsm/postprocessing/EffectComposer',
'three/examples/jsm/postprocessing/RenderPass',
],
},
},
},
},
})
The target is under 200KB of initial JS — meaning the chunk that executes before your UI appears. Three.js loads async after that. I use a loading overlay with a canvas spinner (ironically drawn with 2D canvas, not WebGL) while the main chunk downloads. Your UI shell renders in under a second even on a throttled 3G connection. The 3D viewport just shows a placeholder until Three.js is ready.
The optimizeDeps.exclude Trap in Dev
You’ll find threads suggesting you add optimizeDeps.exclude: ['three'] to your Vite dev config because Three.js is big and pre-bundling it feels wasteful. I tried this. Cold start actually got slower — Vite’s pre-bundler (esbuild) is extremely fast at this, and excluding Three.js means every module that imports from three triggers a separate request waterfall in dev. You go from one pre-bundled chunk to dozens of individual ESM requests. On my M2 machine, cold dev start went from ~800ms to ~3.1 seconds. Leave optimizeDeps alone. The default behavior is correct.
WebGL Context Loss Will Silently Destroy You on iOS Safari
iOS aggressively reclaims GPU memory when a tab goes to the background. When the user comes back, the WebGL context is gone — and if you haven’t handled it, the canvas is just a black rectangle with no errors in the console. This is the most common “works on my machine” bug I see in browser 3D apps. Wire this up before you ship:
const canvas = renderer.domElement
canvas.addEventListener('webglcontextlost', (event) => {
// Must prevent default or the context stays lost
event.preventDefault()
// Stop the render loop — calling renderer.render() on a lost context
// throws and creates a cascade of silent failures
cancelAnimationFrame(animationFrameId)
// Show user feedback — don't leave them staring at a black box
showContextLostOverlay('3D view paused. Tap to resume.')
}, false)
canvas.addEventListener('webglcontextrestored', () => {
// Re-initialize everything — textures, geometry buffers, materials
// are all gone. You need to re-upload them.
reinitializeScene()
hideContextLostOverlay()
startRenderLoop()
}, false)
The reinitializeScene() part is where it gets painful. All GPU-side resources are gone. Textures need to be re-uploaded, geometry buffers re-allocated, render targets recreated. I handle this by keeping a scene description in plain JS objects (not Three.js objects) and rebuilding Three.js state from that on restore. It’s extra architecture work in month one, but it makes context restore and undo/redo much cleaner.
Browser Compatibility Is Not a Checkbox, It’s a Testing Loop
The honest breakdown: Chrome and Edge on desktop are identical for your purposes — both use ANGLE on Windows, both have mature WebGL2. Firefox WebGL2 works correctly; I’ve had zero shader surprises there. Safari is a different planet. Apple’s Metal-backed WebGL implementation rejects valid GLSL that passes everywhere else. Specific things that bite you:
- Implicit int-to-float casts in shaders —
float x = 1;compiles fine on Chrome, hard errors on Safari. Always write1.0. - Loop index modification — some GLSL loops where you modify the iterator variable inside the loop body work on Chrome but not Safari’s Metal compiler.
- Large uniform arrays — Safari has a lower practical limit on how large uniform arrays can be before it quietly produces garbage output instead of erroring.
- EXT_color_buffer_float extension — required for HDR render targets. Available in Chrome, inconsistently available in Safari across device generations.
My workflow: keep BrowserStack or a physical iPhone in the loop every time you write a custom shader, not just at the end. Discovering a Metal compilation error on day 80 of a 90-day project is miserable. The fix is almost always trivial once you know what it is, but finding it in a 200-line shader without prior knowledge of Apple’s quirks costs half a day. Run your GLSL through glslangValidator with strict mode as part of your build if you have non-trivial shaders — it catches a lot of these before Safari does.
What I’d Do Differently on Month 4
The rotation snapping issue hit me on day 18 and I didn’t fully resolve it until day 25. I built a transform gizmo from scratch because I wanted pixel-perfect control — drag handle offset, axis locking, the works. What I didn’t account for is that rotation snapping has a nasty edge case when you’re snapping across the 0/360° boundary and the object’s world matrix is already rotated from a parent node. The quaternion lerp behaves fine in isolation, then breaks in scene hierarchies. Just use three-stdlib’s TransformControls or the @react-three/drei version — both handle gimbal lock and quaternion normalization in ways that took me a week to reinvent badly.
Typed geometry IDs sound like a “nice to have” until you spend an afternoon debugging why a mesh is getting the wrong material after a scene reload. Three.js’s object.clone() copies the name string directly — so if you’re using UUIDs as names for lookup, you now have two objects with identical names and your scene graph queries silently return the wrong one. I’d define a branded type on day one:
// geometry-id.ts
type GeometryId = string & { readonly __brand: 'GeometryId' };
function createGeometryId(): GeometryId {
return crypto.randomUUID() as GeometryId;
}
// Then on every mesh, store the ID in userData, not name
mesh.userData.geometryId = createGeometryId();
mesh.name = `mesh-${mesh.userData.geometryId}`; // human-readable, not used for lookup
The userData approach means clones get a new ID at clone time, lookups go through your own registry, and Three.js internal name collisions stop being your problem.
I budgeted maybe 5% of the timeline for mobile. That was wrong. The specific pain point: OrbitControls from three/examples/jsm listens to touchstart and touchmove on the canvas, but certain Android WebViews (Chrome on Android 12 on some Samsung devices, specifically) fire touchmove with passive: true by default, which means calling event.preventDefault() inside OrbitControls throws a console error and doesn’t actually stop page scroll from fighting your orbit. The fix is manual:
// After initializing OrbitControls
renderer.domElement.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false });
renderer.domElement.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
That’s a two-liner, but finding it costs hours. Pinch-to-zoom also drifts on low-end Android because the touch delta calculation in OrbitControls assumes consistent event timing. I ended up patching the zoom speed coefficient based on navigator.hardwareConcurrency — ugly, but it worked. Budget 20% for this and you’ll use every bit of it.
The dependency I wish I’d added in week one is leva. Everyone treats it as a demo tool. It’s not. It’s the fastest way to hand a non-dev teammate a real-time parameter panel without building a settings UI. Roughness, metalness, ambient light intensity, shadow bias — all things your designer will want to tweak 40 times before sign-off. Without leva, that’s 40 Slack messages each requiring a code change and redeploy. With it:
npm install leva
// In your scene setup component
import { useControls } from 'leva';
const { roughness, metalness, ambientIntensity } = useControls('Material', {
roughness: { value: 0.4, min: 0, max: 1, step: 0.01 },
metalness: { value: 0.2, min: 0, max: 1, step: 0.01 },
ambientIntensity: { value: 0.8, min: 0, max: 3, step: 0.05 },
});
The panel auto-hides in production if you gate it on process.env.NODE_ENV, so there’s no cleanup cost. I added it in month two and immediately stopped being the bottleneck for visual tuning conversations. That alone would’ve saved four or five days of back-and-forth across the whole project.