Engineering

From a Rounded Box to Almost Any Phone on Earth: How We Build Device Mockups with Three.js and CSG

Most mockup tools ship a fixed library of devices. When a new iPhone launches, you wait for someone to model it. When you need a tablet nobody bothered to add, you're stuck.

We took the opposite bet with Ecranify: no pre-made models at all. Every device — every button, speaker hole and antenna line — is generated procedurally live in your browser from a small JSON description. Want a phone with three buttons on the left and a USB-C port slightly off-center? Change a few numbers and the geometry rebuilds in front of you.

Updated July 202612 min read

This post is the story of how that works: first how we sculpt a device out of a single primitive, then how we light it — and then the part nobody warns you about: what happens when you have to render the same thing on a server with no GPU, and it takes 15 minutes.

Part 1: Sculpting a phone from one shape

Step 0 — The toolbox

A quick word on what we're building this with. Browsers can draw hardware-accelerated 3D graphics into an ordinary <canvas> element through WebGL — a low-level API where you'd normally spend a week writing shader code just to display a lit cube. Nobody does that by hand; the de-facto standard wrapper is Three.js, which gives you scenes, cameras, lights, materials and — most importantly for us — geometry: meshes described as lists of triangles. Since our app is React, we drive Three.js through React Three Fiber, which lets the 3D scene be composed declaratively like any other component tree. And for the actual sculpting we lean on one specialized library you'll meet in Step 2: three-bvh-csg.

That's the whole stack. Everything below is these tools, applied with patience.

Step 1 — A rounded rectangle, pulled into 3D

Everything starts embarrassingly simple: a 2D rounded rectangle, drawn as a Three.js Shape (a flat outline built from lines and arcs), sized in real millimeters — 163.4 × 78 mm for a current iPhone.

To make it three-dimensional we extrude it — extrusion means taking a flat shape and stretching it along a straight path to give it depth, like pushing dough through a cookie cutter. 8.75 mm of depth later, we have a slab.

A slab has razor-sharp edges, and no phone does. So the extrusion also applies a bevel — rounding or slanting off a hard edge so it catches light the way machined aluminum does. Three.js does both in one call: ExtrudeGeometry takes the flat shape, the depth and the bevel settings and hands back a finished mesh. The bevel radius is a parameter of the device JSON, so a boxy Galaxy and a soft-cornered iPad come from the same code path.

How smooth those curves look is controlled by tessellation — the number of small flat triangles used to approximate a curved surface. More triangles, smoother curve, heavier mesh. Remember this word; it comes back to haunt us in Part 2.

Interactive demoLoading interactive demo…
Scene 1 — Sliders for corner radius, bevel and depth: watch a plain slab become a phone body.

Step 2 — Enter CSG: carving the front pocket

Here's where it stops being a box. CSG (Constructive Solid Geometry) means building complex solids by combining simple ones with boolean operations: union (glue two shapes together), subtraction (use one shape as a drill bit to remove material from another), and intersection (keep only the volume where both shapes overlap).

This is where three-bvh-csg earns its keep: you wrap each mesh in a Brush, feed two brushes plus an operation (SUBTRACTION, INTERSECTION, ADDITION) into an Evaluator, and get a new mesh back. The library accelerates this with a BVH — a bounding volume hierarchy, essentially a spatial index that lets the algorithm quickly find which triangles of one mesh actually touch the other, instead of testing all of them.

The first cut: the phone's face isn't flush with the body. There's a slightly recessed panel — the "pocket" the screen sits in. We build a second, slightly smaller rounded slab and intersect it with the body to carve that recess. Then the display itself is cut into the panel as its own inset rectangle.

Interactive demoLoading interactive demo…
Scene 2 — A slab and a smaller box: a slider pushes the box into the slab; a checkbox hides it, leaving the hole it carved.

Step 3 — Features: an assortment of drill bits

Now the fun part. Every physical feature of a phone — side buttons, ports, the camera surround, the home button — is CSG with a purpose-built cutter. Let's walk through one in detail — the side button — because every other feature is a variation of the same recipe.

A button is three shapes playing together. First, a rounded-rectangle cutter, slightly deeper than the button itself, is subtracted from the frame — that drills the slot. The slot's rim then gets a chamfer — a small angled transition edge, like the bevel's straight-edged cousin — so the opening doesn't look laser-cut sharp. Finally, a slightly smaller solid is placed back inside the slot as the button itself, standing a fraction of a millimeter proud of the frame, exactly like the real thing. Cut, soften, fill.

Everything else follows the same pattern analogously: speaker grills are dozens of tiny cylindrical cutters — our iPad Pro 13″ has 58 of them — each backed by a dark inset disc; antenna lines are shallow grooves refilled with a strip of plastic-looking material; the charging port is a USB-C-shaped cut with a decorative insert with visible pins; SIM tray, mute switch and screws differ only in the cutter's shape and what goes back into the hole.

The pattern is always: subtract a cutter, sometimes fill with an insert. The insert trick matters more than it sounds — a hole in a thin shell reads as fake immediately; a hole with something dark and metallic inside reads as hardware.

Because every cutter's size and position comes from the device JSON (in millimeters from the device center), "adding a button" is data entry, not modeling.

Interactive demoLoading interactive demo…
Scene 3 — A plain box plus a picker of real device features (buttons, ports, home button, camera…): pick one and watch it carved into the face.

Step 4 — Front details that don't need drilling

Camera punch-holes, the Dynamic-Island-style cutout, the home indicator bar — these are visually flat, so cutting real geometry would be wasted triangles. They're rendered as decals: images projected flat onto a surface, like a sticker. A hidden projection plane spans the full front face; each decal is positioned on it in millimeters, same coordinate system as everything else.

The one exception is the classic round home button — that one is a real CSG cut with a real concave button inside, because it has actual depth you can see at an angle.

Step 5 — The day the browser froze

For a while, all of this ran happily on the page itself. Then devices got more complex — more grills, more inserts, chamfered buttons — and one day, testing a heavier configuration, we noticed the editor visibly freezing the moment a slider moved.

It wasn't a bug; it was arithmetic. JavaScript in the browser is single-threaded: the same thread runs your code, paints the page and responds to clicks. CSG on a ~30 000-triangle body with 80+ boolean operations takes a couple of seconds even on a fast laptop — and while that computation runs, nothing else can: no scrolling, no clicks, no repaints. No spinner can save you; the spinner can't even animate, because animating it needs the very thread the computation is blocking.

The fix was to get off that thread entirely: the whole build pipeline moved into a Web Worker — a genuine background thread the browser gives you, with the catch that it can't touch the page directly and communicates only by passing messages. The worker crunches geometry and streams the finished triangles back; the UI thread stays free to scroll, click and animate. And because a rebuild now takes visible time without blocking anything, we could add proper feedback: the feature you're editing shows up instantly as a translucent blue "ghost" — a deliberately primitive stand-in mesh (a simple box or cylinder, a few dozen triangles) that costs nothing to build on the UI thread. It holds the feature's exact place and size while the worker grinds through the real CSG, and gets thrown away the moment the finished geometry arrives.

Part 2: Making it look like a product photo

Geometry is half the illusion. The other half:

  • PBR materials — physically based rendering, where a surface is described by measurable properties (base color, metalness, roughness) instead of hand-tuned shading. Anodized aluminum is mostly a metalness/roughness recipe.
  • Micro-detail via texture maps: the brushed-metal look of button and SIM-tray inserts comes from a roughness map and normal map — small images that vary the surface's shininess and apparent bumpiness per pixel without adding any triangles. Since CSG output has no natural texture coordinates, we generate them by box projection — projecting the texture onto the mesh from six directions like wrapping a gift box.
  • HDRI environment — a high-dynamic-range panoramic image surrounding the scene, acting as both the reflection source and ambient light. This is what makes metal look like metal: it has something real to reflect.
  • Directional lights + soft shadows — configurable studio lights casting variance-based soft shadows (VSM) onto an invisible shadow catcher: a transparent plane that displays only the shadow falling on it, so the device appears grounded on your background instead of floating.
Interactive demoLoading interactive demo…
Scene 4 — Material & light playground: a knot with sliders for metalness, roughness, base color, and light position + intensity.

Part 3: The render that isn't allowed to fail

Exports render client-side, on your GPU — instant and free for us. But exports cost credits, and clients run on anything: an old iPhone's browser gives a WebGL context a few hundred MB of GPU memory and kills the tab when you exceed it. Charging someone a credit and then crashing their tab is not a customer experience we were willing to ship.

So every paid export has a server-side backup: the same React/Three.js scene loaded in headless Chrome on our server, which — having no GPU — renders through SwiftShader, a software rasterizer that emulates a GPU on the CPU. It produces pixel-identical output. It is also, unoptimized, brutally slow.

First contact with reality: an iPad Pro 13″ — the one with 58 speaker grills — timed out at 15 minutes before producing a single frame. Here's how we got it to under half a minute.

Fix 1 — Stop drilling holes one at a time (~10× on geometry)

Our pipeline subtracted each cutter sequentially: cut grill 1 from the body, rebuild the body's BVH, cut grill 2, rebuild… With N features that's O(N²)-ish work, and the body mesh is the expensive operand. The fix: a collect phase gathers every cutter for a given base mesh, welds them into one compound cutter with Three.js's mergeGeometries, and performs a single subtraction. ~80 boolean operations became ~6. Geometry build on the worst device: ~13 min → ~75 s on the server CPU (the same change made the client editor noticeably snappier too).

Fix 2 — Don't compute what nothing reads (~25%)

The CSG evaluator was tracking texture coordinates and material groups for every operation. But only two features (SIM tray, mute switch) actually sample textures from CSG output — everything else re-derives UVs by box projection afterwards. Two evaluator configs, one with UVs and one without, shaved roughly another quarter off.

Fix 3 — Texture diet

A speaker-grill detail texture shipped at 4500×4500 px (2.3 MB file, ~81 MB decoded on the GPU) — to be tiled onto discs about 2 mm wide. Who would ever do such a thing? Certainly not us. And yet, there it was, in our own repo, with our own names on the commit. Downscaled to 640×640: 43 kB, ~1.6 MB decoded, visually identical at any zoom a human would use. Background images got the same treatment: ~110 MB → ~13 MB across the catalog. On SwiftShader, "GPU memory" is RAM and texture uploads are CPU work, so this is speed, not just politeness.

Fix 4 — Tessellation, revisited

Remember tessellation from Step 1? The body's curved edges used 64 segments per curve and 32 per bevel — counts chosen when the mesh was only ever displayed, not used as a CSG operand 80 times. Halving them (32/16) is imperceptible on a 2-mm rounded edge but halves the triangle count fed into every boolean op. Small features (2-mm grill holes) dropped from 32 segments to 8 — a hole half a millimeter across does not need 32-sided smoothness. Gallery thumbnails go further and use a low-poly profile throughout.

Fix 5 — Shadows and supersampling honesty check

Exports used an 8192-px shadow map and 2× supersampling. Supersampling means rendering at double the target resolution and scaling down — the reason it looks better than rendering directly at the final size is that each final pixel becomes the average of four rendered ones, which smooths the jagged "staircase" edges (aliasing) that appear wherever a high-contrast geometry edge crosses a pixel grid. We A/B-compared against 2048 px shadows and 1× rendering on real exports and could not tell the difference — the soft VSM shadows blur away the extra shadow resolution, and the built-in MSAA anti-aliasing already handles the edges well enough at export sizes. On a software rasterizer, dropping both is a ~4× cut in raw pixel work.

Fix 6 — Don't render frames while building geometry

Subtle one: the render page was drawing frames at 60 fps (of an empty scene!) while the worker was doing CSG — on a server, both compete for the same CPU cores. The canvas now starts with its render loop off, builds geometry, then switches rendering on for the final frames. Free ~20–30% during the build phase.

Fix 7 — Never build the same device twice

The final realization: a device build is a pure function — same input JSON, same triangles. So the server now caches finished geometry, keyed by a hash of the full device configuration (content-addressed, stored as binary blobs). Any popular preset device is built exactly once; every subsequent render, thumbnail or retry skips Part 1 entirely and loads in ~2–3 s.

Interactive demoLoading interactive demo…
Scene 5 — Batching race on the real iPhone 17 Pro Max (the default editor device): press Build! to generate the same phone three ways — sequential CSG one cutter at a time (watch the holes drill in one by one), all cutters merged into one subtraction (Fix 1), and the finished geometry loaded straight from cache (Fix 7). Each build runs in its own worker; the stopwatch under each phone freezes when that phone is fully built.

The scoreboard

The demo above runs on your own machine and isolates Fix 1 alone on a phone with ~25 cutters, so you'll see roughly a 2–3× gap between the sequential and batched builds and a near-free cache load. This table is the iPad's ~80-cutter worst case with every fix stacked, where the wins compound — batching's edge grows with feature count, and Fixes 2–5 pile on top. The "Client editor rebuild" row is the closest apples-to-apples with the demo: same M-class CPU, heavier device.

iPad Pro 13″, worst case
StageBeforeAfter
Geometry build (server CPU)~13 min (timeout)~75 s
Full render incl. shadows + export15+ min (failed)~25–30 s
Repeat render (geometry cached)~5 s
Client editor rebuild (M-class laptop)~8 s~1.5 s

What we'd tell you if you're building something similar

Parametric CSG devices were the right call for us: new devices are JSON files, users can invent hardware that doesn't exist, and there is no model library to license or maintain. The costs are real but front-loaded — boolean geometry is expensive, so architect for batching from day one, keep tessellation as a quality parameter rather than a constant, and treat your worst-case device as a benchmark fixture.

And if you just want the screenshots without building any of this: that's Ecranify. Every device in this post is configurable in the editor, in your browser, right now.

Related guides & comparisons

Skip the modeling. Ship the screenshots.

Every device in this article is configurable live in your browser — no signup, no model library. Start free and pay only when you export.