Mandelbrot Explorer
Uruchomiłem projekt wizualizacji zbioru Mandelbrota działający w przeglądarce: mandelbrot.robertolechowski.com.
Dlaczego warto
To projekt hobbystyczny, bez celu biznesowego. Technologia: WebGL 2, shadery GLSL i emulację wyższej precyzji na GPU. Standardowo WebGL umożliwia obliczenia pojedyńczej precyzji, a to jest problem, gdy tylko spróbujesz zoomować głębiej niż kilka rzędów wielkości.
Aplikacja i technologie
Aplikacja jest single-page webapką napisaną w Vue 3 (Pinia, Vue Router, Vuetify) i TypeScript, budowaną przez Vite. Cały rendering odbywa się w przeglądarce, bez backendu obliczeniowego.
Renderer jest dwuprzebiegowy. W pierwszym przebiegu (compute pass) shader oblicza dla każdego piksela, czy należy do zbioru Mandelbrota i po ile iteracjach "ucieka". Wynik zapisywany jest do tekstury RGBA32F (floating-point framebuffer). W drugim przebiegu (color pass) osobny shader sampluje tę teksturę i przypisuje kolory.
Silnik obliczeniowy ma dwa tryby pracy, przełączane w locie:
- WebGL 64 — emulowana arytmetyka df64 w shaderze GLSL.
- WASM — AssemblyScript kompilowany do WebAssembly, liczy na CPU w puli workerów z natywnym
f64.
WebGL: co daje, czego nie daje
WebGL 2 udostępnia GPU przeglądarce przez API zbliżone do OpenGL ES 3.0. Shadery GLSL wykonują się równolegle na setkach rdzeni — dla wizualizacji fraktala to idealne narzędzie, bo każdy piksel obliczany jest niezależnie.
Problem w tym, że WebGL nie oferuje natywnego double. Typ highp float w GLSL to 32-bitowy IEEE 754 float. Przy głębokim zoomie to za mało: gdy center_x ≈ -0.7269 i przesunięcie piksela wynosi 1e-9, suma center_x + offset traci całkowicie znaczące bity offsetu — wynik jest identyczny dla wszystkich pikseli i obraz staje się jednobarwną plamą.
Zbiór Mandelbrota i prędkość ucieczki
Zbiór Mandelbrota to zbiór wszystkich punktów
jest ograniczony (nie ucieka do nieskończoności). Można udowodnić, że jeśli
Prędkość ucieczki to po prostu liczba iteracji
Kolorowanie punktów
Shader oblicza dwa rodzaje wartości dla każdego piksela i zapisuje je do tekstury jako vec4(n, R², dc.x, dc.y):
n— liczba iteracji lub-1.0,R²— kwadrat modułu|z_n|^2 w chwili ucieczki,dc— pochodna poc (gradient orbity, przydatny do shading).
Color shader sampluje tę teksturę i koloruje każdy piksel. Kolor punktów poza zbiorem wyznaczany jest przez wygładzoną wersję escape time:
float smooth_n = n - log(log(R2)) * 1.4426; // 1.4426 = log_2(e) = 1/ln(2)
Shader compute 64-bit, emulujemy podwójną precyzję
Największa pułapka w tym projekcie nie była matematyczna, lecz kompilator.
Na Windows WebGL przechodzi przez warstwę ANGLE, która tłumaczy GLSL na HLSL (DirectX 11). Optymalizator HLSL "upraszcza" algebraicznie wyrażenia w stylu (a + b) - b. To niszczy algorytm Knuth Two_Sum, który właśnie na tej różnicy polega:
// Bez bariery: optymalizator zamienia err na 0.0
float s = a + b;
float bb = s - a;
float err = (a - (s - bb)) + (b - bb); // = 0 + 0 = 0 — BŁĄD
GLSL nie ma volatile, więc trzeba inaczej wymusić zachowanie kompilatora. Rozwiązaniem jest bariera opakowa oparta na uniform. u_zero_barrier jest zawsze 0.0 z JavaScript, ale kompilator nie może tego udowodnić w czasie kompilacji — więc emituje prawdziwe instrukcje:
uniform float u_zero_barrier;
float bar(float x) { return x + u_zero_barrier; }
vec2 two_sum(float a, float b) {
float s = bar(a + b);
float bb = bar(s - a);
float err = bar(a - bar(s - bb)) + bar(b - bb);
return vec2(s, err);
}
Każde pośrednie wyrażenie opakowane w bar() — to obowiązek, nie opcja. Brak bariery przy którymkolwiek intermediate daje losowo błędne wyniki na niektórych GPU/sterownikach.
Link do projektu
Aplikacja dostępna pod: mandelbrot.robertolechowski.com












