Урок · Середній рівень · ~50 хв
WebGL · GLSL · Фрагментні шейдери

Фрактальний zoom на GPU за допомогою WebGL

Множина Мандельброта породжується однорядковою ітерацією, але рендеринг плавного zoom у реальному часі в глибину її межі — у мільярд разів глибше за початковий вигляд — потребує фрагментного шейдера, ретельного escape-time розфарбування, а зрештою й хитрощів, щоб обійти межу точності 32-бітних чисел із рухомою крапкою. Цей урок будує повний GPU-рендерер фрактального zoom із нуля.

1Відображення пікселів екрана на комплексну площину

Повноекранний квад і один фрагментний шейдер — усе, що потрібно: жодної геометрії, жодного освітлення, лише математика на кожен піксель. Створіть контекст WebGL2 з одним трикутником, що покриває область перегляду, а потім відобразіть екранну координату кожного фрагмента на точку c комплексної площини, центровану на цілі zoom і масштабовану коефіцієнтом zoom:

const canvas = document.querySelector('canvas'); const gl = canvas.getContext('webgl2'); const vertexSrc = `#version 300 es in vec2 aPos; void main() { gl_Position = vec4(aPos, 0.0, 1.0); }`; // Повноекранний трикутник: 3 вершини, що покривають клип-простір const verts = new Float32Array([-1,-1, 3,-1, -1,3]); // Uniform-змінні, що передаються щокадру: // uCenter: vec2 (real, imag) — точка, у яку робиться zoom // uZoom: float — поточний масштаб (половина ширини у комплексних одиницях) // uAspect: float — canvas.width / canvas.height
Чому фрагментний шейдер: ітерація Мандельброта тривіально паралелізується — кількість ітерацій до втечі кожного пікселя абсолютно не залежить від будь-якого іншого пікселя. GPU з тисячами шейдерних ядер обчислює весь кадр одночасно, тому фрагментний шейдер рендерить за мілісекунди те, на що однопотоковому CPU-циклу знадобилися б секунди.

2Фрагментний шейдер escape-time

Усередині фрагментного шейдера перетворіть нормалізовану координату фрагмента на комплексне число c, потім ітеруйте z = z² + c до максимальної кількості ітерацій, відстежуючи, чи перевищує |z| радіус втечі (2.0 математично достатньо, оскільки будь-яка орбіта, що перетинає |z|>2, розбігається до нескінченності):

#version 300 es precision highp float; out vec4 fragColor; uniform vec2 uCenter; uniform float uZoom; uniform float uAspect; uniform int uMaxIter; uniform vec2 uResolution; void main() { vec2 uv = (gl_FragCoord.xy / uResolution) * 2.0 - 1.0; vec2 c = uCenter + vec2(uv.x * uAspect, uv.y) * uZoom; vec2 z = vec2(0.0); int iter = 0; for (int i = 0; i < 1000; i++) { if (i >= uMaxIter) break; // Квадрат комплексного числа: (a+bi)^2 = a^2 - b^2 + 2abi float a2 = z.x * z.x, b2 = z.y * z.y; if (a2 + b2 > 4.0) break; // тест |z| > 2 без sqrt z = vec2(a2 - b2, 2.0 * z.x * z.y) + c; iter++; } float t = float(iter) / float(uMaxIter); fragColor = vec4(iter == uMaxIter ? vec3(0.0) : vec3(t, t * 0.5, 1.0 - t), 1.0); }
У GLSL немає динамічних меж циклу: умова циклу for повинна використовувати константну на етапі компіляції верхню межу (тут 1000) із раннім break після досягнення uMaxIter — компілятори шейдерів WebGL на багатьох платформах відхиляють цикли, обмежені безпосередньо uniform-змінною.

3Плавне (безперервне) розфарбування

Розфарбування пікселів за цілою кількістю ітерацій дає помітне «смугування» — різкі кільцеві межі між зонами кольору. Виправлення — формула безперервного (плавного) escape-time, що використовує дробову частину того, наскільки далеко за радіус втечі приземлилась орбіта, для інтерполяції між ітераційними смугами:

// Після циклу, якщо орбіта втекла (iter < uMaxIter): float logZn = log(a2 + b2) / 2.0; float nu = log(logZn / log(2.0)) / log(2.0); float smoothIter = float(iter) + 1.0 - nu; // Використовуйте smoothIter (безперервний float) замість цілого iter // як вхід для функції палітри кольорів — наприклад, косинусної палітри: vec3 palette(float t) { vec3 a = vec3(0.5), b = vec3(0.5), c = vec3(1.0); vec3 d = vec3(0.00, 0.10, 0.20); return a + b * cos(6.28318 * (c * t * 0.05 + d)); } fragColor = vec4(palette(smoothIter), 1.0);
Косинусні палітри (формулювання Iniго Кіліза) генерують плавні, циклічні градієнти без артефактів лише з чотирьох параметрів vec3, уникаючи як смугування, так і потреби у таблиці вибірки текстур — ідеально для GPU-шейдера без накладних витрат на семплінг текстур.

4Глибокий zoom: боротьба з втратою точності float32

highp float у GLSL зазвичай є 32-бітним числом IEEE із рухомою крапкою і приблизно 7 десятковими розрядами точності. Щойно масштаб zoom стискається нижче приблизно 1e-5 від початкового вигляду (навколо рівня zoom 10 000×), сусідні пікселі починають відображатися на те саме значення числа з рухомою крапкою для c, і зображення деградує до блокового, зашумленого вигляду — задовго до того, як ви досягнете справді цікавої структури ниток глибоко в межі.

Стандартне виправлення — емуляція подвійної точності (df64): представлення кожної координати як пари значень float32 (частина «hi» та «lo»), сума яких відновлює приблизно 14-15 десяткових розрядів точності, використовуючи алгоритми безпомилкового перетворення:

// Додавання подвійної точності (алгоритм Деккера), над парами vec2(hi, lo) vec2 dfAdd(vec2 a, vec2 b) { float s = a.x + b.x; float v = s - a.x; float e = (a.x - (s - v)) + (b.x - v) + a.y + b.y; return vec2(s, e - (s - (s + e))); // ренормалізація } // Множення подвійної точності (розщеплення Велткампа) vec2 dfMul(vec2 a, vec2 b) { float p = a.x * b.x; float e = fma(a.x, b.x, -p) + a.x * b.y + a.y * b.x; return vec2(p + e, e - ((p + e) - p)); } // z = z*z + c тепер виконується через dfAdd/dfMul над парами vec2 замість // нативного множення float — приблизно вдвічі більше ALU-витрат, // але просуває корисну глибину zoom з ~1e5 до понад ~1e14
Компроміс: арифметика подвійної точності коштує приблизно у 5-8× більше ALU-інструкцій на ітерацію, ніж нативний float. За межами ~1e14 zoom навіть df64 вичерпує запас — промислові рендерери глибокого zoom фракталів (як-от Kalles Fraktaler) переходять на теорію збурень: обчислюють одну еталонну орбіту високої точності на CPU, а дешеві float32-дельти від неї — на GPU, що дозволяє zoom понад 10^1000×.

5Анімація zoom

Анімуйте, експоненційно зменшуючи uZoom щокадру — лінійна інтерполяція виглядає неправильно, оскільки zoom за своєю суттю мультиплікативний (кожен кадр повинен зменшувати вигляд на сталу частку, а не сталу абсолютну величину):

let zoom = 2.5; // початкова половина ширини у комплексних одиницях const zoomRate = 0.985; // зменшення на 1.5% за кадр ≈ гарний кінематографічний темп const center = [-0.7436447860, 0.1318252536]; // ціль «долини морського коника» function frame(time) { zoom *= zoomRate; const maxIter = Math.min(2000, 80 + Math.floor(-Math.log2(zoom) * 12)); gl.uniform2f(uCenterLoc, center[0], center[1]); gl.uniform1f(uZoomLoc, zoom); gl.uniform1i(uMaxIterLoc, maxIter); gl.drawArrays(gl.TRIANGLES, 0, 3); if (zoom > 1e-13) requestAnimationFrame(frame); // зупинка перед межею df64 } requestAnimationFrame(frame);
Адаптивна кількість ітерацій: глибші zoom потребують більше ітерацій для розкриття тонких деталей межі — логарифмічне масштабування maxIter з глибиною zoom (як вище) утримує час кадру приблизно сталим, замість того щоб або марнувати цикли на неглибокому zoom, або недостатньо деталізувати глибокий zoom.

Часті запитання

Чого я навчуся в цьому уроці?

Рендерте глибокий zoom множини Мандельброта в реальному часі на GPU за допомогою фрагментного шейдера WebGL: відображення комплексної площини, escape-time розфарбування, емуляція подвійної точності для глибокого zoom.

Які теми розглядаються в цьому уроці?

Цей урок охоплює такі теми: Відображення пікселів екрана на комплексну площину, Фрагментний шейдер escape-time, Плавне (безперервне) розфарбування, Глибокий zoom: боротьба з втратою точності float32, Анімація zoom.

Які інструменти й технології використовуються?

Цей урок використовує: WebGL, GLSL, Фрагментні шейдери.

Скільки часу займає цей урок?

Цей урок займає приблизно 50 хв.