I'm building a free web tool that shows which muscles contract, lengthen, or stabilize in each yoga pose. You rotate the model and click a muscle to see its name and role. The live version is here: 3D Yoga Anatomy.

This post isn't a feature tour. It covers the four technical problems that took the most work:

  1. Switching poses quickly without downloading every animation.
  2. Draco reordering vertices, which breaks per-vertex corrections.
  3. Picking the right muscle on a SkinnedMesh.
  4. Audio going silent because a proxy cached a 206 response.

Stack: React, React Three Fiber, three.js, Draco, three-mesh-bvh, and gltf-transform. The model is processed with Blender and Python, and the site is deployed on Vercel. Most of the code and the model pipeline was written with Claude Code.

The model pipeline

The source data is Z-Anatomy and BodyParts3D, an open anatomy atlas. The pipeline looks like this:

Z-Anatomy (.blend) → keep 50 muscles + the skeleton, normalize names → auto-rig and bake each pose in Blender → export one GLB containing every clip → split into base.glb + one small pack per clip → React Three Fiber viewer 

Teaching data, such as each muscle's state in each pose, lives in separate JSON files. A muscle is active, lengthened, or stabilizing.

1. Don't put 335 clips in one GLB

There are 222 poses and 335 clips, because asymmetric poses have a clip for each side. In a single GLB, viewing one pose would mean downloading all of them.

So I split it:

File Actual size
base.glb: rigged model, Draco-compressed, no animation 3.4 MB
Each <clip>.pose.bin pack 176 KB on average, 401 KB at most
All 335 packs, if you had to download them about 58 MB

Pack sizes are before brotli. The viewer only fetches the pack for the current pose.

What's in a pack?

Bone rotation alone (skinning) tends to collapse joints and make muscles look wrong in deep bends. So each pose also has corrective shape keys: offsets for the specific vertices that need fixing. This data is very sparse, so most vertices don't need an entry.

The current format:

"YPK2" | header length (u32 LE) | header JSON | varint body header: { clip, scale, tracks, entries } tracks = the static pose of the bones entries = corrected regions, per primitive body, for each entry: - vertex ids: LEB128 gaps from the previous id (ascending) - x, y, z: one stream per axis, zigzag varints of the delta to the previous vertex 

Offsets are quantized to 0.25 mm, so the maximum error is 0.125 mm. A vertex that rounds to zero on all three axes is dropped.

Three things add up:

  • Store differences, not values. Neighboring vertices tend to move by similar amounts, so the numbers stay small.
  • Keep each axis in its own stream. Similar small numbers sit next to each other, which brotli compresses well.
  • Let Vercel serve the files with brotli. In my tests this compressed about twice as well as the previous format, which used raw Int16.

GPU memory doesn't grow with the pose count

If each pose were its own morph target, memory would grow with every pose viewed. I keep exactly two slots instead, as cs0 and cs1 attributes on each mesh:

for (const name of ['cs0', 'cs1']) { if (g.attributes[name]) continue; const a = new THREE.BufferAttribute(new Float32Array(n * 3), 3); a.setUsage(THREE.DynamicDrawUsage); g.setAttribute(name, a); } 

When the pose changes, the new data goes into the free slot. The old pose fades out and the new one fades in over 0.8 seconds, and each slot's weight follows its animation. The next change uses the other slot. Only the changed ranges of the buffer are uploaded, not the whole attribute.

2. Draco reorders vertices

A pack says "vertex 1234 moves by this much". But Draco reorders vertices when it compresses, so after decoding, vertex 1234 is a different vertex. If you don't handle this, muscles deform at random.

The fix is to give each primitive a _VID attribute holding the original vertex id. At runtime, the viewer builds a reverse lookup:

private vidToIndex(g: THREE.BufferGeometry): Int32Array { let inv = this.inverse.get(g); if (!inv) { const vid = g.attributes._vid; const n = g.attributes.position.count; inv = new Int32Array(n).fill(-1); for (let i = 0; i < n; i++) inv[vid ? vid.getX(i) : i] = i; this.inverse.set(g, inv); } return inv; } 

The part I'd recommend most is checking this at build time. The packing script decodes the file it just wrote, uses _VID to compare against the original vertex positions, and fails the build if the error passes a threshold. That way the bug shows up on the build machine, not on a user's screen.

3. Picking muscles on a SkinnedMesh

The default three.js raycast on a SkinnedMesh has two problems:

  • Bounds are computed once, in the first pose. After the figure bends over, some clicks miss or hit a muscle behind the one you meant.
  • Every tested triangle is skinned again on the CPU, so just moving the mouse can freeze the page.

My approach: once a pose has settled, snapshot each mesh's deformed positions into a static mesh with a BVH, and raycast against that.

function makeProxy(src: THREE.Mesh): THREE.Mesh { src.updateWorldMatrix(true, false); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(deformedWorldPositions(src), 3)); if (src.geometry.index) geo.setIndex(src.geometry.index); geo.boundsTree = new MeshBVH(geo) as unknown as typeof geo.boundsTree; geo.computeBoundingBox(); const proxy = new THREE.Mesh(geo); proxy.raycast = acceleratedRaycast; proxy.userData.source = src; proxy.updateMatrixWorld(true); return proxy; } 

deformedWorldPositions does on the CPU what the GPU does, in the same order: morph, then skinning, then world space.

A few details keep the UI smooth:

  • Build in small steps. Each frame only spends a few milliseconds on the snapshot. If the user clicks before it's done, the rest is finished right then.
  • Keep the old snapshot until the new one is ready. It's only swapped once fully built. If the pose changes midway, the build in progress is dropped.
  • Limit hover. At most one ray per frame, and dragging to rotate the camera doesn't count as a click.
  • Pick what the user can see. Dimmed muscles are skipped, and nothing is picked if a bone is in front.

4. Silent audio from a cached 206

This has nothing to do with 3D, but it's useful to know if you proxy through Next.js on Vercel.

The tool lives at 3d.yogaway.vn and is proxied to yogaway.vn/giai-phau-3d/ with rewrites. At one point, clicking a pose or muscle stopped playing its spoken name.

The cause: when playing <audio>, the browser may first ask for Range: bytes=0-1. The proxy received a 2-byte 206 response, and the shared cache stored that fragment under the key for the whole file. Everyone after that got 2 bytes.

The fix was to keep media files off the proxy and redirect them straight to the origin, which handles Range correctly:

async redirects() { return [ { source: "/giai-phau-3d/:path(.*\\.(?:mp3|m4a|ogg|wav|mp4|webm))", destination: "https://3d.yogaway.vn/:path", permanent: false, }, ]; } 

My takeaway: when a proxy caches responses, always test requests with a Range header separately.

What isn't good yet

Mobile performance is still modest. Pose pages show static text and a WebP image first, then build the 3D scene when the browser is idle. In a Lighthouse run on October 18, 2026, the mobile score went from 29 to 49 with a CLS of 0, but lab LCP was still 3.7 seconds because setting up three.js is expensive. Deferring the 3D until the first tap would score higher, but I chose to load it automatically because it's what people come to see.

Getting poses right is still the hardest part. The solvers can place the joints, but a yoga teacher spots mistakes quickly: an arm through a thigh, a foot sunk into the floor, a wrist bent sideways. I wrote automated checks for these:

- Parts that should touch the floor: within about 1.5 cm of z=0; nothing below -1.5 cm - Arm vs. thigh or shin: axes at least 9 cm apart, unless the pose intends contact - Wrist side deviation: at most 30° - The left side must mirror the right side within 1 cm 

These catch a lot, but they don't replace someone looking at the result. The poses are still being reviewed and fixed, so if you spot one that looks wrong, I'd like to hear about it.

Licensing: read it before you use open data

Z-Anatomy and BodyParts3D use CC BY-SA licenses, so derived files such as .blend and .glb must also be released under CC BY-SA 4.0 with full attribution. A few parts of Z-Anatomy, such as Inner Ear and Kidney, carry a non-commercial license, so my pipeline removes them automatically from a list in its config file.

If you plan to use an open atlas in a real product, check the license of each part at the start, not after the build is done.

Wrapping up

The biggest lesson for me was to separate data early: the model, the poses, and the teaching content all live apart. Because of that, going from 10 to 222 poses only grew the base model slightly, from about 3.2 MB to 3.4 MB.

You can try it at 3D Yoga Anatomy. If you've built a large 3D viewer for the web, I'd like to hear how you handled three.js setup time on phones.

The anatomy model is derived from Z-Anatomy (CC BY-SA 4.0; Gauthier Kervyn, Marcin Zielinski) and BodyParts3D (© The Database Center for Life Science, CC BY-SA 2.1 Japan). This tool is for learning anatomy and isn't a substitute for medical advice.