Mandelbrot trong JavaScript: Xử lý phóng to mượt mà và giải quyết giới hạn độ chính xác số thực
Bài viết này tiếp tục khám phá việc xây dựng bộ render Mandelbrot bằng JavaScript, tập trung khắc phục lỗi màn hình đen khi phóng to sâu do giới hạn độ chính xác của số thực. Tác giả cũng hướng dẫn thay thế thao tác click bằng cuộn chuột để trải nghiệm phóng to/thu nhỏ mượt mà hơn.

Mandelbrot trong JavaScript: Xử lý phóng to mượt mà và giải quyết giới hạn độ chính xác số thực
Minh họa Mandelbrot Set
Đây là phần tiếp theo của bài viết về việc xây dựng bộ render Mandelbrot sử dụng Canvas và Web Workers. Trước đó, chúng ta đã tạo ra một công cụ zoom khi click. Tuy nhiên, một vấn đề nghiêm trọng đã phát sinh sau khoảng 16 lần phóng to: hình ảnh bị vỡ hạt và toàn bộ màn hình chuyển sang màu đen. Bài viết này sẽ phân tích nguyên nhân (do độ chính xác của số thực dấu chấm động) và cách chúng tôi thay thế tính năng click bằng cơ chế zoom bằng cuộn chuột (scroll) mượt mà, cho phép vừa phóng to vừa thu nhỏ.
Vấn đề: Màn hình chuyển đen sau khoảng 16 lần click
Nếu bạn thử nghiệm bản demo trước đủ lâu, bạn sẽ nhận thấy một hiện tượng lạ: sau khi phóng to khoảng 16 lần, hình ảnh fractal bắt đầu bị vỡ hạt (blocky), và cuối cùng toàn bộ canvas trở thành một khối màu đen đặc.
Hình ảnh bị vỡ hạt và đen
Đây không phải là lỗi trong toán học của Mandelbrot. Tập hợp này có chi tiết vô hạn, luôn có nhiều cấu trúc hơn để khám phá. Vấn đề nằm ở cách máy tính lưu trữ các số thập phân.
Nguyên nhân gốc rễ: Độ chính xác của số trong JavaScript có giới hạn
JavaScript (giống như hầu hết các ngôn ngữ lập trình) lưu trữ tất cả các số dưới dạng 64-bit IEEE 754 doubles. Đây là định dạng tiêu chuẩn mà máy tính sử dụng cho số thập phân, cung cấp khoảng 15 đến 17 chữ số có nghĩa về độ chính xác. Nghe có vẻ nhiều, nhưng quá trình zoom "tiêu thụ" các chữ số đó rất nhanh.
Cơ chế zoom cũ hoạt động như thế nào
Mỗi lần click sẽ phóng to cửa sổ hiển thị về còn 20% của phạm vi trước đó (với ZOOM_FACTOR = 0.1). Phạm vi tọa độ sau N lần click sẽ thu hẹp theo công thức:
range_after_N = initial_range × 0.2^N
Tại lần click thứ 15, phạm vi là 7.5e-12. Nếu tâm của bạn nằm quanh -0.7, các tọa độ sẽ trông giống như:
start: -0.700000000003750
end: -0.700000000003751
Hai số này chia sẻ 15 chữ số đầu. Với chỉ 15 đến 17 chữ số tổng về độ chính xác, sự khác biệt giữa các điểm ảnh liền kề trở nên quá nhỏ để có thể biểu diễn. Mọi điểm ảnh cuối cùng đều được ánh xạ đến cùng một giá trị. Kết quả là một lưới các màu sắc giống hệt nhau, gây ra vỡ hạt và màn hình đen.
Hiện tượng này được gọi là catastrophic cancellation (hiện tượng hủy thảm họa): khi bạn trừ hai số gần như bằng nhau, bạn mất tất cả các chữ số hữu ích.
Sơ đồ giải thích hiện tượng hủy thảm họa
Giải pháp 1: Thay thế Click bằng Scroll Zoom
Thay đổi đầu tiên là chuyển từ sự kiện click sang wheel (cuộn chuột). Điều này mang lại cho chúng ta:
- Phóng to (cuộn lên) và thu nhỏ (cuộn xuống) với cùng một cử chỉ.
- Kiểm soát mức độ zoom mượt mà, từng bước một.
- Zoom luôn tập trung vào vị trí con trỏ.
Dưới đây là mã xử lý sự kiện mới hoàn chỉnh:
const ZOOM_FACTOR = 0.8; // mỗi bước cuộn = 80% phạm vi hiện tại (zoom in)
const MIN_RANGE = 1e-12; // giới hạn an toàn, dừng trước khi độ chính xác bị lỗi
const startListeners = () => {
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomIn = e.deltaY < 0;
const factor = zoomIn ? ZOOM_FACTOR : 1 / ZOOM_FACTOR;
const realRange = REAL_SET.end - REAL_SET.start;
const imagRange = IMAGINARY_SET.end - IMAGINARY_SET.start;
const newRealRange = realRange * factor;
const newImagRange = imagRange * factor;
// Dừng phóng to trước khi độ chính xác sụp đổ
if (newRealRange < MIN_RANGE || newImagRange < MIN_RANGE) return;
// Ánh xạ điểm ảnh con trỏ tới một điểm trên mặt phẳng phức
const mouseX = e.pageX - canvas.offsetLeft;
const mouseY = e.pageY - canvas.offsetTop;
const centerReal = getRelativePoint(mouseX, WIDTH, REAL_SET);
const centerImag = getRelativePoint(mouseY, HEIGHT, IMAGINARY_SET);
REAL_SET = {
start: centerReal - newRealRange / 2,
end: centerReal + newRealRange / 2,
};
IMAGINARY_SET = {
start: centerImag - newImagRange / 2,
end: centerImag + newImagRange / 2,
};
Mandelbrot();
}, { passive: false }); // passive: false là bắt buộc để gọi e.preventDefault()
};
Hãy cùng xem qua các quyết định quan trọng trong đoạn mã trên:
e.preventDefault() + { passive: false }
Theo mặc định, các trình duyệt coi các sự kiện wheel là thụ động (passive) để tối ưu hóa hiệu suất, giả định rằng bạn sẽ không chặn hành vi cuộn mặc định. Chúng ta cần ngăn trang web cuộn khi người dùng zoom fractal, nên phải chọn không tham gia chế độ này. Nếu không có { passive: false }, việc gọi preventDefault() sẽ không có tác dụng và trang vẫn sẽ cuộn.
factor = zoomIn ? ZOOM_FACTOR : 1 / ZOOM_FACTOR
Phóng to nhân phạm vi với 0.8 (làm cho nó nhỏ hơn). Thu nhỏ chia cho 0.8 (làm cho nó lớn hơn). Điều này giữ cho zoom vào và ra đối xứng, vậy nên mười lần zoom vào theo sau bởi mười lần zoom ra sẽ đưa bạn trở lại đúng nơi bắt đầu.
Tập trung vào con trỏ
Cách tiếp cận mới ánh xạ điểm ảnh con trỏ tới một điểm trên mặt phẳng phức, sau đó xây dựng cửa sổ mới đối xứng xung quanh nó:
const centerReal = getRelativePoint(mouseX, WIDTH, REAL_SET);
const centerImag = getRelativePoint(mouseY, HEIGHT, IMAGINARY_SET);
Hàm getRelativePoint chuyển đổi vị trí điểm ảnh thành tọa độ sử dụng công thức đơn giản:
const getRelativePoint = (pixel, length, set) =>
set.start + (pixel / length) * (set.end - set.start);
ZOOM_FACTOR = 0.8 thay vì 0.1
Với 0.1, mỗi lần click zoom vào 20% phạm vi, điều này rất mạnh (aggressive). Giới hạn độ chính xác bị chạm ở 16 bước. Với 0.8, mỗi bước cuộn chỉ giảm phạm vi đi 20%, vậy bạn có thể phóng to khoảng 130 lần trước khi chạm cùng giới hạn đó. Nó cũng cảm thấy mượt mà hơn nhiều khi sử dụng.
Giải pháp 2: Cơ chế bảo vệ độ chính xác (Precision Guard)
if (newRealRange < MIN_RANGE || newImagRange < MIN_RANGE) return;
Với MIN_RANGE = 1e-12, chúng ta dừng phóng to khi cửa sổ tọa độ trở nên quá nhỏ. Ở quy mô đó, các số không còn đủ độ chính xác để hiển thị một hình ảnh có ý nghĩa. Thay vì chuyển sang màu đen, fractal chỉ cần đóng băng ở mức zoom tốt cuối cùng. Sự kiện cuộn sẽ bị bỏ qua một cách âm thầm.
Các hạn chế hiện tại và hướng phát triển
Hiện tại, bản thực hiện này vẫn còn một số điểm hạn chế như số lần zoom tối đa khoảng 130 bước (do giới hạn số trong JavaScript), phải render lại toàn bộ canvas trên mỗi sự kiện cuộn, chưa hỗ trợ thiết bị di động (mobile), và chỉ sử dụng một Web Worker duy nhất.
Để cải thiện trong tương lai, chúng ta có thể cân nhắc các giải pháp sau:
- Độ chính xác tùy ý với
decimal.js: Để zoom sâu hơn 130 bước, bạn cần nhiều hơn định dạng số 64-bit tiêu chuẩn. Thư việndecimal.jscho phép bạn đặt số lượng chữ số thập phân mong muốn, mặc dù sẽ làm chậm tốc độ tính toán. - Lý thuyết nhiễu loạn (Perturbation Theory): Đây là kỹ thuật được các trình render chuyên sâu sử dụng, cho phép đạt độ sâu zoom lên tới 10^1000 hoặc cao hơn với hiệu suất tốt.
MAX_ITERATIONthích ứng: Thay vì giới hạn cố định, hãy thay đổi số lần lặp dựa trên độ sâu zoom để hiển thị nhiều chi tiết hơn ở vùng sâu.- RAF Throttle: Sử dụng
requestAnimationFrameđể bỏ qua các khung hình đến quá nhanh và chỉ render khi trình duyệt sẵn sàng, giúp tránh bị lag khi cuộn nhanh. - Hỗ trợ Pinch-to-Zoom cho Mobile: Xử lý các cử chỉ chụm (pinch) trên màn hình cảm ứng để người dùng di động cũng có thể trải nghiệm tính năng zoom.
Tóm tắt các thay đổi
| Thay đổi | Trước đây | Sau khi thay đổi |
|---|---|---|
| Tương tác | click, chỉ zoom vào | wheel (cuộn), zoom vào và ra |
| Tâm zoom | Điểm click gần đúng | Tọa độ con trỏ chính xác |
| Bước zoom | 20% phạm vi mỗi click | 20% phạm vi mỗi lần cuộn |
| Bảo vệ độ chính xác | Không có, màn hình đen | Dừng ở phạm vi 1e-12 |
| Zoom tối đa | ~16 lần | ~130 lần |
Bạn có thể xem bản demo đang chạy trực tiếp tại quijosakaf.com và tìm mã nguồn đầy đủ trên GitHub. Cảm ơn bạn đã theo dõi, hy vọng những kiến thức về độ chính xác số thực và xử lý sự kiện trong JavaScript này sẽ hữu ích cho các dự án của bạn.



