Làm bộ xem giải phẫu yoga 3D trên web: 222 tư thế mà không phải tải cả kho animation
Mình đang làm một công cụ web miễn phí để xem cơ nào co, cơ nào giãn, cơ nào giữ ổn định trong từng tư thế yoga. Người dùng xoay mô hình, bấm vào một cơ để xem tên và vai trò của cơ đó. Bản chạy thật ở đây: Giải phẫu Yoga 3D.
Bài này không giới thiệu tính năng. Mình ghi lại 4 vấn đề kỹ thuật tốn công nhất, vì tài liệu tiếng Việt về chủ đề này còn ít:
- Chuyển tư thế gần như tức thì mà không tải hết mọi animation.
- Draco xáo thứ tự đỉnh làm dữ liệu chỉnh hình bị lệch.
- Bấm chọn đúng cơ trên
SkinnedMesh. - Âm thanh bị im lặng vì proxy lưu đệm nhầm phản hồi
206.
Công nghệ: React, React Three Fiber, three.js, Draco, three-mesh-bvh, gltf-transform. Mô hình được xử lý bằng Blender và Python, sau đó triển khai trên Vercel. Phần lớn mã và quy trình dựng mô hình được viết cùng Claude Code.
Quy trình dựng mô hình
Dữ liệu gốc là Z-Anatomy và BodyParts3D, một atlas giải phẫu mở. Quy trình dựng như sau:
Z-Anatomy (.blend)
→ lọc 50 cơ + bộ xương, đặt tên chuẩn
→ tự gắn xương (rig) và tính từng tư thế trong Blender
→ xuất một GLB có đủ mọi clip
→ tách thành base.glb + mỗi clip một gói nhỏ
→ viewer React Three Fiber
Dữ liệu giảng dạy, như trạng thái của từng cơ trong mỗi tư thế, nằm ở JSON riêng, tách hẳn khỏi file mô hình. Mỗi cơ có một trong ba trạng thái: active, lengthened, stabilizing.

1. Không nhét 335 clip vào một file GLB
Hiện có 222 tư thế, tương ứng 335 clip vì tư thế không đối xứng có clip riêng cho từng bên. Nếu để trong một GLB, người dùng chỉ xem một tư thế cũng phải tải dữ liệu của tất cả.
Cách làm là tách ra:
| File | Kích thước thật |
|---|---|
base.glb: mô hình đã gắn xương, nén Draco, không animation |
3,4 MB |
Mỗi gói tư thế <clip>.pose.bin |
trung bình 176 KB, lớn nhất 401 KB |
| Cộng cả 335 gói, nếu phải tải hết | khoảng 58 MB |
Kích thước gói tính khi chưa qua brotli. Viewer chỉ tải gói của tư thế đang chọn.
Trong một gói có gì?
Chỉ xoay xương (skinning) thì vùng khớp thường bị móp hoặc cơ trông sai khi gập sâu. Vì vậy mỗi tư thế có thêm corrective shape key, tức độ dịch riêng cho những đỉnh cần chỉnh. Dữ liệu này rất thưa, nên phần lớn đỉnh không cần lưu.
Định dạng hiện tại:
"YPK2" | độ dài header (u32 LE) | header JSON | phần thân varint
header: { clip, scale, tracks, entries }
tracks = tư thế tĩnh của các xương
entries = danh sách vùng chỉnh theo từng primitive
phần thân, với mỗi entry:
- mã đỉnh: LEB128, lưu khoảng cách so với mã trước (mã tăng dần)
- x, y, z: mỗi trục một luồng, zigzag varint của chênh lệch so với đỉnh trước
Độ dịch được lượng tử hoá theo bước 0,25 mm, nên sai số tối đa là 0,125 mm. Đỉnh nào làm tròn về 0 trên cả ba trục thì bỏ luôn.
Ba mẹo gộp lại làm file nhỏ đi nhiều:
- Lưu chênh lệch thay vì giá trị: các đỉnh gần nhau thường dịch gần giống nhau, nên số cần lưu rất nhỏ.
- Tách riêng từng trục: số nhỏ giống nhau đứng cạnh nhau thì brotli nén tốt hơn.
- Để Vercel nén brotli khi phục vụ: trong thử nghiệm của mình, cách này nén tốt hơn khoảng 2 lần so với định dạng cũ dùng
Int16thô.
Bộ nhớ GPU không tăng theo số tư thế
Nếu mỗi tư thế là một morph target, bộ nhớ sẽ tăng theo số tư thế đã xem. Mình chỉ giữ đúng 2 slot, là hai thuộc tính cs0 và cs1 trên mỗi 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);
}
Khi chuyển tư thế, dữ liệu mới được ghi vào slot đang rảnh. Tư thế cũ mờ dần, tư thế mới hiện dần trong 0,8 giây, trọng số của mỗi slot đi theo animation tương ứng. Lần chuyển sau lại dùng slot còn lại. Vì chỉ cập nhật đoạn đã đổi của buffer nên không phải tải lại cả thuộc tính.
2. Draco xáo thứ tự đỉnh
Gói tư thế lưu "đỉnh số 1234 dịch một đoạn". Nhưng Draco sắp xếp lại đỉnh khi nén, nên đỉnh số 1234 sau khi giải nén không còn là đỉnh đó nữa. Không xử lý thì cơ sẽ biến dạng lung tung.
Cách xử lý là thêm cho mỗi primitive một thuộc tính _VID chứa mã đỉnh gốc. Khi chạy, viewer dựng bảng tra ngược:
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;
}
Điều mình thấy đáng làm nhất là kiểm tra ngay lúc build. Script đóng gói giải nén lại file vừa tạo, dùng _VID để đối chiếu với vị trí đỉnh gốc, và dừng build nếu sai lệch vượt ngưỡng. Nhờ vậy lỗi lộ ra ở máy dựng, không phải trên màn hình người dùng.
3. Bấm chọn cơ trên SkinnedMesh
Dùng raycast mặc định của three.js với SkinnedMesh thì gặp hai vấn đề:
- Bounds chỉ được tính một lần ở tư thế đầu. Sau khi người mẫu gập xuống, có chỗ bấm không trúng hoặc trúng nhầm cơ phía sau.
- Mỗi tam giác được thử đều phải skinning lại trên CPU, nên chỉ rê chuột cũng có thể làm trang đứng.
Cách của mình: khi tư thế đã dừng hẳn, chụp vị trí đã biến dạng của từng mesh vào một mesh tĩnh có BVH, rồi raycast trên mesh tĩnh đó.
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 tính trên CPU đúng thứ tự mà GPU làm: morph, rồi skinning, rồi đưa về toạ độ thế giới.
Có vài chi tiết để giao diện không giật:
- Chia nhỏ việc chụp: mỗi frame chỉ xử lý vài mili-giây. Nếu người dùng bấm trước khi xong thì làm nốt phần còn lại ngay lúc đó.
- Giữ bản cũ tới khi có bản mới: bản chụp chỉ được thay khi đã dựng xong toàn bộ. Nếu tư thế đổi giữa chừng thì huỷ bản đang dựng.
- Giới hạn hover: tối đa một tia mỗi frame. Kéo để xoay camera thì không tính là bấm.
- Chọn thứ người dùng nhìn thấy: bỏ qua cơ đang bị làm mờ, và không chọn gì nếu có xương nằm phía trước.
4. Âm thanh im lặng vì proxy lưu nhầm phản hồi 206
Lỗi này không liên quan gì đến 3D, nhưng ai làm proxy qua Next.js trên Vercel cũng nên biết.
Trang công cụ nằm ở 3d.yogaway.vn, và được proxy về yogaway.vn/giai-phau-3d/ bằng rewrites. Có lúc, bấm vào tư thế hay cơ thì không còn nghe đọc tên nữa.
Nguyên nhân: khi phát <audio>, trình duyệt có thể hỏi thử Range: bytes=0-1. Proxy nhận phản hồi 206 dài 2 byte, và bộ nhớ đệm chung lưu mẩu đó dưới khoá của cả file. Từ đó, người vào sau chỉ nhận được 2 byte.
Cách sửa là không cho file media đi qua proxy mà chuyển hướng tạm thẳng về máy chủ gốc, nơi xử lý Range đúng chuẩn:
async redirects() {
return [
{
source: "/giai-phau-3d/:path(.*\\.(?:mp3|m4a|ogg|wav|mp4|webm))",
destination: "https://3d.yogaway.vn/:path",
permanent: false,
},
];
}
Từ đó mình rút ra một quy tắc: khi proxy có lưu đệm, luôn thử riêng các yêu cầu có header Range.
Vài điều chưa làm tốt
Tốc độ trên điện thoại vẫn chưa cao. Trang tư thế hiện chữ tĩnh và ảnh WebP trước, rồi mới dựng 3D khi trình duyệt rảnh. Lần đo Lighthouse ngày 18/10/2026, điểm trên điện thoại tăng từ 29 lên 49, CLS bằng 0, nhưng LCP trong phòng thử vẫn 3,7 giây vì dựng three.js tốn nhiều thời gian. Nếu trì hoãn 3D tới lúc người dùng chạm vào thì điểm sẽ cao hơn, nhưng mình chọn để 3D tự hiện vì đó là thứ người dùng đến để xem.
Đặt tư thế cho đúng vẫn là phần khó nhất. Thuật toán giải được các khớp, nhưng giáo viên yoga vẫn nhìn ra lỗi rất nhanh: tay xuyên qua đùi, chân lún xuống sàn, cổ tay gập ngang. Mình viết các phép đo tự động cho những lỗi này:
- Bộ phận phải chạm sàn: cách z=0 không quá khoảng 1,5 cm; không gì nằm dưới -1,5 cm
- Tay với đùi/cẳng chân: trục cách nhau từ 9 cm trở lên, trừ khi tư thế cố ý chạm
- Cổ tay lệch ngang không quá 30°
- Tư thế bên trái phải là ảnh gương của bên phải, sai lệch dưới 1 cm
Các phép đo này bắt được nhiều lỗi nhưng không thay được người xem. Bộ tư thế vẫn đang được rà và sửa tiếp, nên nếu bạn thấy tư thế nào sai thì mình rất muốn được nghe.
Giấy phép: đọc trước khi dùng dữ liệu mở
Z-Anatomy và BodyParts3D dùng giấy phép CC BY-SA, nên mọi file phái sinh từ chúng, như .blend và .glb, cũng phải phát hành theo CC BY-SA 4.0 và ghi công đầy đủ. Một vài phần trong Z-Anatomy, như Inner Ear và Kidney, lại mang giấy phép phi thương mại, nên quy trình dựng của mình tự động loại chúng theo danh sách trong file cấu hình.
Nếu định dùng atlas mở cho sản phẩm thật, bạn nên kiểm tra giấy phép của từng phần ngay từ đầu, thay vì để đến khi đã dựng xong.
Kết
Điều mình học được nhiều nhất là tách dữ liệu sớm: mô hình, tư thế và nội dung giảng dạy nằm riêng. Nhờ vậy, khi tăng từ 10 lên 222 tư thế, mô hình nền chỉ nặng thêm rất ít, từ khoảng 3,2 MB lên 3,4 MB.
Bạn có thể thử bản chạy thật tại Giải phẫu Yoga 3D. Nếu bạn từng làm viewer 3D lớn trên web, mình muốn nghe cách bạn giải bài toán tốc độ dựng three.js trên điện thoại.
Mô hình giải phẫu phái sinh từ Z-Anatomy (CC BY-SA 4.0; Gauthier Kervyn, Marcin Zielinski) và BodyParts3D (© The Database Center for Life Science, CC BY-SA 2.1 Japan). Công cụ chỉ để học giải phẫu, không thay thế tư vấn y tế.
All rights reserved