Mandelbrot Explorer

Projekt wizualizacji zbioru Mandelbrota działający w przeglądarce: mandelbrot.robertolechowski.com.

O projekcie

Projekt hobbystyczny. Technologia: WebGL 2, shadery GLSL i emulacja wyższej precyzji na GPU. WebGL domyślnie oferuje obliczenia pojedynczej precyzji, co staje się problemem przy zoomowaniu głębszym 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 ilu 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, co sprawia, że jest to naturalne narzędzie do wizualizacji fraktali: każdy piksel obliczany jest niezależnie.

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 znaczące bity offsetu. Wynik jest identyczny dla wszystkich pikseli, a 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 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 algebraicznie „upraszcza" 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 musi być opakowane w bar(). Brak bariery przy którymkolwiek intermediate daje losowo błędne wyniki na niektórych GPU i sterownikach.

Link do projektu

Aplikacja dostępna pod: mandelbrot.robertolechowski.com