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 c na płaszczyźnie zespolonej, dla których ciąg

z_0 = 0, \quad z_{n+1} = z_n^2 + c

jest ograniczony (nie ucieka do nieskończoności). Można udowodnić, że jeśli |z_n| > 2 dla jakiegoś n, to ciąg na pewno rozbieżny. Dlatego wystarczy sprawdzać warunek |z_n|^2 > 4.

Prędkość ucieczki to po prostu liczba iteracji n, po której |z_n|^2 > R^2. Punkty wewnątrz zbioru (nigdy nie uciekające) dostają wartość specjalną.

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,
  • — kwadrat modułu |z_n|^2 w chwili ucieczki,
  • dc — pochodna po c (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