Множина Мандельброта породжується однорядковою ітерацією, але
рендеринг плавного 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, Фрагментні шейдери.