This programming guide is only available in Vietnamese. Switch the language toggle to Vietnamese to read the full article.
Cho đến giờ, mọi hình học trong series này đều được viết tay ngay trong mã JavaScript — vài đỉnh của một tam giác, một hình lập phương, hay vòng lặp sinh hình cầu. Cách làm đó đủ để học nguyên lý, nhưng hoàn toàn bất khả thi cho một con rồng 50.000 đa giác hay một nhân vật game. Trong thực tế, hình học được nghệ sĩ 3D dựng trong DCC tool rồi xuất ra file để chương trình nạp lại. Bài này giải mã hai định dạng quan trọng nhất của hệ sinh thái web 3D: Wavefront OBJ (đơn giản, dạng text) và glTF 2.0 (chuẩn truyền tải hiện đại).
1. Vì sao cần định dạng model? Pipeline tài nguyên
Tam giác hardcode trong mã nguồn không phải là cách con người tạo nội dung 3D. Một mô hình thực tế chứa hàng nghìn đỉnh, nhiều nhóm vật liệu, tọa độ texture, pháp tuyến, đôi khi cả khung xương và animation. Không ai gõ tay những con số đó. Thay vào đó tồn tại một quy trình tài nguyên (asset pipeline) rõ ràng:
- DCC tools (Digital Content Creation): Nghệ sĩ dùng Blender, Maya, 3ds Max hay ZBrush để mô hình hóa, trải UV và gán vật liệu. Đây là nơi hình học thực sự ra đời.
- Xuất (Export): Mô hình được xuất ra một định dạng trao đổi — OBJ cho hình học tĩnh đơn giản, hoặc glTF cho cảnh đầy đủ kèm vật liệu PBR và animation.
-
Nạp & phân tích (Loader/Parser): Chương trình WebGL tải file qua
fetch, parse nội dung thành mảng số, rồi nhồi vào các vertex buffer (VBO) và index buffer (IBO) để GPU vẽ.
Nhiệm vụ của lập trình viên đồ họa nằm ở bước cuối: viết (hoặc dùng) một loader chuyển từ
định dạng file sang dữ liệu mà gl.bufferData hiểu được. Một loader tối giản chỉ là vài
chục dòng như khung dưới đây:
// Quy trình nạp model tổng quát trong WebGL
async function loadModel(gl, url) {
const text = await fetch(url).then((r) => r.text()); // 1) Tải file text
const mesh = parseOBJ(text); // 2) Parse -> mảng số
// 3) Đưa vị trí + pháp tuyến đan xen vào VBO
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(mesh.vertices), gl.STATIC_DRAW);
// 4) Đưa chỉ số đỉnh vào IBO để vẽ bằng drawElements
const ibo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(mesh.indices), gl.STATIC_DRAW);
return { vbo, ibo, count: mesh.indices.length };
}
2. Định dạng Wavefront OBJ
OBJ do hãng Wavefront Technologies tạo ra từ thập niên 1980 và vẫn sống tốt nhờ cực kỳ đơn giản và dễ đọc. Đây là định dạng văn bản thuần (plain text): mỗi dòng bắt đầu bằng một từ khóa cho biết ý nghĩa của các con số theo sau. Bốn từ khóa cốt lõi:
v x y z— một vị trí đỉnh (vertex position).vt u v— một tọa độ texture (texture coordinate).vn x y z— một vector pháp tuyến (normal).-
f— một mặt (face), liệt kê các đỉnh tạo nên đa giác bằng cách tham chiếu chỉ số tới các danh sách v/vt/vn ở trên.
Điểm tinh tế nằm ở dòng f: mỗi đỉnh của mặt được viết dạng v/vt/vn. Chỉ số
bắt đầu từ 1 (không phải 0) và là chỉ số tuyệt đối vào ba danh sách. Có ba biến thể
cú pháp phổ biến mà parser phải xử lý: chỉ vị trí (f 1 2 3), vị trí + texture (f 1/1 2/2 3/3), và vị trí + texture + normal (f 1/1/1 2/2/2 3/3/3). Khi không có texture, ta gặp dạng
v//vn (bỏ trống phần giữa). Dưới đây là một file OBJ hoàn chỉnh mô tả một tứ diện:
# Tứ diện đơn giản — 4 đỉnh, 4 mặt tam giác
o Tetrahedron
v 0.000 0.800 0.000
v -0.700 -0.400 0.400
v 0.700 -0.400 0.400
v 0.000 -0.400 -0.800
vn 0.00 0.50 0.86
vn 0.81 -0.30 0.50
vn -0.81 -0.30 0.50
vn 0.00 -0.30 -1.00
# Mỗi mặt: chỉ số đỉnh//chỉ số pháp tuyến (bắt đầu từ 1)
f 1//1 2//1 3//1
f 1//2 3//2 4//2
f 1//3 4//3 2//3
f 2//4 4//4 3//4
Để biến file trên thành buffer, parser duyệt từng dòng, gom các mảng
v/vt/vn riêng, rồi với mỗi đỉnh trong dòng f nó
gỡ ba chỉ số và tra ngược về tọa độ thật. Vì mỗi tổ hợp v/vt/vn là một đỉnh độc
nhất với GPU, ta dùng một Map để khử trùng lặp và tạo index buffer.
Ưu điểm của OBJ: cực dễ đọc, dễ debug, được mọi công cụ hỗ trợ.
Nhược điểm: file text cồng kềnh và parse chậm, không hỗ trợ animation/skeleton, không
có chuẩn vật liệu PBR thống nhất.
// Parser OBJ tối giản — xử lý cả v, v/vt, v//vn, v/vt/vn
function parseOBJ(text) {
const positions = []; // mỗi phần tử là [x,y,z]
const normals = []; // mỗi phần tử là [x,y,z]
const texcoords = []; // mỗi phần tử là [u,v]
const vertices = []; // dữ liệu đan xen cuối cùng [x,y,z,nx,ny,nz]
const indices = [];
const cache = new Map(); // khử trùng lặp tổ hợp v/vt/vn
for (const line of text.split('\n')) {
const parts = line.trim().split(/\s+/);
const tag = parts.shift();
if (tag === 'v') positions.push(parts.slice(0, 3).map(Number));
else if (tag === 'vn') normals.push(parts.slice(0, 3).map(Number));
else if (tag === 'vt') texcoords.push(parts.slice(0, 2).map(Number));
else if (tag === 'f') {
// Tam giác hóa quạt (fan) cho mặt có > 3 đỉnh
for (let i = 1; i < parts.length - 1; i++) {
for (const corner of [parts[0], parts[i], parts[i + 1]]) {
if (cache.has(corner)) { indices.push(cache.get(corner)); continue; }
// Tách "v/vt/vn"; chuỗi rỗng -> NaN, bắt được bằng isNaN
const seg = corner.split('/');
const vi = parseInt(seg[0], 10);
const ni = seg[2] ? parseInt(seg[2], 10) : NaN;
const p = positions[vi - 1]; // chỉ số OBJ bắt đầu từ 1
const n = !isNaN(ni) ? normals[ni - 1] : [0, 0, 0]; // có thể thiếu normal
const idx = vertices.length / 6;
vertices.push(p[0], p[1], p[2], n[0], n[1], n[2]);
cache.set(corner, idx);
indices.push(idx);
}
}
}
}
return { vertices, indices, hasNormals: normals.length > 0 };
}
3. Định dạng glTF 2.0 — "JPEG của 3D"
Nhóm Khronos (cha đẻ của OpenGL/WebGL) thiết kế glTF (GL Transmission Format) để giải
quyết đúng những thiếu sót của OBJ. Khẩu hiệu nổi tiếng gọi nó là "JPEG của 3D": một
chuẩn truyền tải gọn, nạp nhanh, không cần biến đổi nặng trước khi đưa vào GPU. Khác với OBJ, glTF lưu
hình học ở dạng nhị phân thô đúng layout buffer của WebGL — nghĩa là loader gần như chỉ việc
fetch rồi bufferData, không cần parse từng số.
Một file .gltf là một tài liệu JSON mô tả cảnh theo phân cấp rõ ràng.
Các khái niệm chính lồng vào nhau như sau:
- scenes / nodes: cây cảnh; mỗi node có ma trận biến đổi và có thể trỏ tới mesh.
- meshes: tập các primitives, mỗi primitive trỏ tới các thuộc tính (POSITION, NORMAL...).
- accessors: mô tả kiểu dữ liệu (VEC3, FLOAT), số lượng, và min/max của một dòng dữ liệu.
- bufferViews: cắt một đoạn (offset + length) ra khỏi buffer thô.
-
buffers: khối byte thật, là một file
.binngoài hoặc nhúng base64.
Chuỗi tra cứu luôn là: mesh → accessor → bufferView → buffer. Phiên bản
.glb đóng gói toàn bộ JSON + buffer nhị phân + texture vào
một file nhị phân duy nhất (header "glTF" + các chunk JSON/BIN), loại bỏ overhead nhiều
request và base64 — đây là dạng nên dùng cho production. glTF còn hỗ trợ sẵn vật liệu PBR
Metallic-Roughness, animation và skinning — những thứ OBJ không có.
{
"asset": { "version": "2.0" },
"scenes": [{ "nodes": [0] }],
"nodes": [{ "mesh": 0 }],
"meshes": [{
"primitives": [{
"attributes": { "POSITION": 0, "NORMAL": 1 },
"indices": 2
}]
}],
"accessors": [
{ "bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
"min": [-1, -1, 0], "max": [1, 1, 0] },
{ "bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3" },
{ "bufferView": 2, "componentType": 5123, "count": 3, "type": "SCALAR" }
],
"bufferViews": [
{ "buffer": 0, "byteOffset": 0, "byteLength": 36, "target": 34962 },
{ "buffer": 0, "byteOffset": 36, "byteLength": 36, "target": 34962 },
{ "buffer": 0, "byteOffset": 72, "byteLength": 6, "target": 34963 }
],
"buffers": [
{ "uri": "triangle.bin", "byteLength": 78 }
]
}
4. Demo tương tác: Nạp & vẽ model OBJ trực tiếp
Demo dưới đây không tải file ngoài — thay vào đó ba mô hình OBJ low-poly được nhúng thẳng
dưới dạng chuỗi text trong JavaScript, rồi đưa qua một hàm parseOBJ
thật (cùng thuật toán mục 2). Sau khi parse, nếu file thiếu pháp tuyến chương trình
sẽ tự tính normals từ tích có hướng của tam giác, dựng VBO/IBO và vẽ với chiếu sáng
khuếch tán. Hãy đổi mô hình, bật chế độ khung dây (wireframe) để thấy cấu trúc tam giác, và quan sát
hình tự xoay:
5. Tính toán Normals & Vertex Indexing
Rất nhiều file OBJ không kèm dòng vn, hoặc bạn muốn tính lại normals sau khi
biến đổi hình học. Khi đó loader phải tự sinh pháp tuyến. Pháp tuyến của một tam giác
được tính bằng tích có hướng (cross product) của hai cạnh:
N = normalize( cross(B−A, C−A) ). Có hai chiến lược kết hợp chúng lên đỉnh:
- Flat normals (phẳng): mỗi tam giác giữ pháp tuyến riêng, mỗi mặt trông phẳng dứt khoát — phù hợp khối đa diện như viên đá quý. Đòi hỏi mỗi tam giác có bộ đỉnh riêng (không chia sẻ).
-
Smooth normals (mượt): pháp tuyến của các tam giác chung một đỉnh được
cộng dồn rồi chuẩn hóa, tạo bề mặt cong mượt — phù hợp hình cầu, nhân vật. Đây chính là kỹ
thuật mà hàm
computeNormalstrong demo dùng.
Khía cạnh còn lại là vertex indexing. Một khối lập phương có 8 đỉnh hình học nhưng
được vẽ bằng 12 tam giác = 36 lần tham chiếu đỉnh. Nếu nhồi thẳng 36 đỉnh, ta lãng phí bộ nhớ và tính
lặp. Thay vào đó, ta lưu mỗi đỉnh độc nhất một lần trong VBO rồi dùng
index buffer (IBO) chứa các chỉ số Uint16 để
gl.drawElements ghép lại — vừa tiết kiệm, vừa cho phép GPU dùng lại kết quả vertex shader
đã cache:
// Sinh smooth normals: cộng dồn pháp tuyến tam giác lên các đỉnh chia sẻ
function computeSmoothNormals(positions, indices) {
const normals = new Float32Array(positions.length); // khởi tạo 0
for (let t = 0; t < indices.length; t += 3) {
const a = indices[t] * 3, b = indices[t + 1] * 3, c = indices[t + 2] * 3;
const e1 = sub(positions, b, a); // B - A
const e2 = sub(positions, c, a); // C - A
const n = cross(e1, e2); // pháp tuyến tam giác (chưa chuẩn hóa)
for (const v of [a, b, c]) { // cộng dồn cho cả ba đỉnh
normals[v] += n[0]; normals[v + 1] += n[1]; normals[v + 2] += n[2];
}
}
// Chuẩn hóa từng đỉnh -> trung bình có trọng số theo diện tích tam giác
for (let i = 0; i < normals.length; i += 3) {
const len = Math.hypot(normals[i], normals[i + 1], normals[i + 2]) || 1;
normals[i] /= len; normals[i + 1] /= len; normals[i + 2] /= len;
}
return normals;
}
6. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 1: Chỉ số trong OBJ
Trong dòng mặt f 1//1 2//1 3//1 của một file OBJ, các con số tham chiếu đỉnh được đánh
chỉ số bắt đầu từ đâu, và dạng v//vn có ý nghĩa gì?
Trắc nghiệm 2: glTF vs OBJ
Vì sao glTF được gọi là "JPEG của 3D" và nạp nhanh hơn OBJ một cách đáng kể?
Trắc nghiệm 3: Smooth vs Flat normals
Muốn một hình cầu nạp từ OBJ trông cong mượt thay vì lộ rõ từng mặt tam giác, ta nên tính normals theo cách nào?
Tải mã nguồn thực hành
File chứa parser OBJ hoàn chỉnh, hàm tính normals và demo model loader để bạn tự thử nghiệm:
Tải về mã nguồn Model Loading mẫu
Comments
Bình luận