Vì sao không dùng thư viện 3D
Một thư viện 3D phổ biến nặng hàng trăm KB. Lá bài chỉ cần một loại vật thể duy nhất: điểm sáng. Viết thẳng bằng WebGL2, toàn bộ cảnh chỉ còn khoảng 5 KB sau nén, tải sau khi chữ trên trang đã hiện.
Hạt không được tải về, hạt được sinh ra
Hình lá bài được vẽ lên một canvas 2D ẩn, rồi lấy mẫu ngẫu nhiên các điểm ảnh có màu. Mỗi hạt chỉ mang vài số: toạ độ trên lá bài, số thứ tự lá nhỏ mà nó thuộc về, và bốn số ngẫu nhiên. Không có tệp toạ độ nào đi qua mạng.
const img = cx.getImageData(0, 0, CW, CH).data;
const strong = [], soft = [];
for (let i = 0; i < CW * CH; i++) {
const a = img[i * 4 + 3];
if (a > 200) strong.push(i); // nét đậm: lấy nhiều hạt
else if (a > 40) soft.push(i); // phần tô nhạt: lấy ít hơn
}Mọi chuyển động nằm trong vertex shader
Mỗi khung hình, JavaScript chỉ gửi vài con số: thời gian, tiến độ cuộn, vị trí chuột. Vertex shader tự tính vị trí từng hạt: lá lớn, đám mây tung ra, rồi xoè thành tay bài. GPU xử lý song song hàng trăm nghìn hạt, CPU gần như rảnh.
float p = clamp(uProg * 1.25 - aR.x * .25, 0., 1.); // mỗi hạt trễ một chút
float e1 = smoothstep(0., .55, p); // lá lớn → đám mây
float e2 = smoothstep(.5, 1., p); // đám mây → tay bài
vec3 pos = mix(mix(big, cloud, e1), fan, e2);Tự điều tốc theo máy
Trang đo số khung hình mỗi nửa giây bằng thời gian thực, không bằng thời gian đã bị chặn trần. Dưới 50 khung thì giảm số hạt và độ phân giải; dưới 30 thì giảm nhanh hơn. Máy quá yếu thì đóng băng hoạt cảnh, chỉ vẽ lại khi người dùng cuộn.
low = fps < 30 ? low + 1 : fps < 50 ? low + 0.5 : 0;
if (low >= 2 && count > 8000) {
count = Math.max(8000, Math.floor(count * 0.65));
maxDpr = Math.max(1, maxDpr - 0.25); resize();
}Máy không có GPU thật thì sao
Một số máy, và hầu hết công cụ đo hiệu năng, vẽ WebGL bằng phần mềm. Chỉ việc biên dịch shader ở đó đã tốn hàng trăm mili giây. Trang hỏi tên GPU trước; nếu là bộ vẽ phần mềm thì dừng ngay và giữ lá bài SVG tĩnh, vẽ từ cùng hình gốc.
Chỉ chạy khi có người thật
Công cụ đo và bot thường tự cuộn trang, khiến cảnh 3D khởi động dù không ai xem. Cảnh chỉ bắt đầu sau cử chỉ đầu tiên của người thật: chạm, rê chuột, lăn chuột hoặc bấm phím.
const EVS = ['pointermove', 'pointerdown', 'touchstart', 'wheel', 'keydown'];
export const engaged = new Promise((resolve) => {
const go = () => { EVS.forEach((e) => removeEventListener(e, go)); resolve(); };
EVS.forEach((e) => addEventListener(e, go, { passive: true, once: true }));
});