⚡ Урок · WebGL · Шейдери
📅 Березень 2026 ⏱ 25 хв 🎓 Середній–просунутий рівень

Реакція-дифузія Грея-Скотта на GPU

Візерунки Тюрінга, плями, смуги, корал, ріст коралу, мітоз — усе це породжується двома зв'язаними диференціальними рівняннями в частинних похідних, що виконуються у фрагментному шейдері WebGL. Цей урок веде від математики до повної реалізації на GPU із ping-pong-фреймбуферами, досягаючи мільйонів клітин при 60 fps у браузері.

1. Рівняння Грея-Скотта

Модель Грея-Скотта (1983, авторства Пітера Грея та Стівена Скотта) описує реакцію у безперервно перемішуваному резервуарі між двома хімічними речовинами U і V:

∂u/∂t = D_u·∇²u − u·v² + F·(1−u)
∂v/∂t = D_v·∇²v + u·v² − (F+k)·v

u, v ∈ [0, 1] — нормалізовані концентрації
D_u — коефіцієнт дифузії U (зазвичай у 2× більший за D_v)
D_v — коефіцієнт дифузії V
F — швидкість подачі (поповнення U): 0.01–0.1
k — швидкість знищення V (на додачу до відтоку подачі): 0.04–0.08

Реакційний член u·v² реалізує автокаталіз:
кожній молекулі V потрібні два сусіди V, щоб каталізувати перетворення.
Це механізм Шлегля — квадратичний член створює нестабільність.

Три члени в рівнянні U такі:

Що породжує візерунки Тюрінга: Ключ у тому, що V дифундує повільніше за U (D_v < D_u). V активує сам себе, але U дифундує геть швидше, створюючи дальнодіюче пригнічення V. Ця «короткодіюча активація + дальнодіюче пригнічення» — це нестабільність морфогенезу, описана Аланом Тюрінгом у 1952 році.

2. Дискретний лапласіан на сітці

На 2D-сітці з кроком h = 1 піксель стандартний 5-точковий шаблонний лапласіан має вигляд:

∇²u[i,j] ≈ u[i−1,j] + u[i+1,j] + u[i,j−1] + u[i,j+1] − 4·u[i,j]

9-точковий шаблон (ізотропніший, кращий для візерунків):
∇²u ≈ 0.25·(u[i±1,j] + u[i,j±1]) + 0.05·(діагоналі) − 1.0·u[i,j]

Зважено: [0.05 0.2 0.05] ← центральна вага = −1.0
[0.2 -1 0.2 ]
[0.05 0.2 0.05]
(сума всіх ваг = 0, як і має бути для лапласіана)

9-точковий шаблон дає ізотропніші візерунки — плями й смуги без помітного зміщення вздовж осей сітки. Використовуйте його в шейдері.

3. Простір параметрів візерунків

Простір параметрів F-k — це справжній «зоопарк» якісно різних візерунків. Класичні іменовані області:

Плями (перлини)

F=0.035, k=0.065
Ізольовані багаті на V плями, оточені U

Смуги (даніо-реріо)

F=0.060, k=0.062
Лабіринтоподібні смугасті візерунки

Мітоз

F=0.028, k=0.062
Плями розділяються в міру росту

Корал

F=0.082, k=0.059
Розгалужені структури

Черви

F=0.078, k=0.061
Рухомі червоподібні візерунки

Лабіринт

F=0.029, k=0.057
Нерухомі лабіринтоподібні структури

Параметр Типовий діапазон Ефект
D_u 0.16–0.22 Дифузійність U — вища → більші за масштабом структури
D_v 0.04–0.10 Дифузійність V — зазвичай D_v ≈ 0.5·D_u
F 0.010–0.085 Швидкість подачі — нижче F → рідше, вище F → щільніше
k 0.040–0.075 Швидкість знищення — визначає режим візерунка
dt 0.5–1.5 Крок за часом — більший = швидше, але може дестабілізувати
Початкові умови: ініціалізуйте всю сітку значеннями (u=1, v=0) з кількома малими засіяними областями (u=0.5, v=0.25 + шум). Система розвивається з цих засівів приблизно за 500–2000 кроків за часом.

4. Архітектура GPU: ping-pong-фреймбуфери

Проблема: кожен крок симуляції читає поточний стан і записує наступний. На GPU не можна одночасно читати з тієї самої текстури й записувати в неї.

Рішення: ping-pong — виділіть два фреймбуфери (A і B). На кожному кроці:

// Стан ping-pong
let frontBuffer = texA;  // GPU читає звідси
let backFBO     = fboB;  // GPU записує сюди

function swap() {
  [frontBuffer, backFBO] = [backBuffer.texture, frontFBO];
  // Після обміну: front ← те, що щойно записали; back ← старий буфер для перезапису на наступному кроці
}

Прохід рендерингу окремий: повноекранний квад, який читає з frontBuffer і відображає значення концентрацій у кольори для виводу. Він НЕ записує в текстури симуляції.

Щокадру (зазвичай N=4–16 кроків симуляції на один кадр виводу):
1. Для i в range(N): прохід симуляції (читаємо front → записуємо back, потім обмін)
2. Прохід рендерингу (читаємо front → на екран)

5. Налаштування WebGL 2

// Створюємо дві пари (текстура + фреймбуфер) для ping-pong
function createPingPongBuffers(gl, width, height) {
  function createTex() {
    const tex = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG32F, width, height, 0,
                  gl.RG, gl.FLOAT, null);  // 2-канальний float: [u, v]
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
    return tex;
  }
  function createFBO(tex) {
    const fbo = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
                            gl.TEXTURE_2D, tex, 0);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    return fbo;
  }
  const texA = createTex(), texB = createTex();
  return {
    texA, texB,
    fboA: createFBO(texA), fboB: createFBO(texB),
    front: texA, back: { fbo: createFBO(texB), tex: texB },
  };
}
RG32F: ми використовуємо двоканальну 32-бітну float-текстуру — червоний канал = концентрація U, зелений канал = концентрація V. У WebGL 2 потрібно увімкнути розширення EXT_color_buffer_float, щоб рендерити у float-текстури:
gl.getExtension('EXT_color_buffer_float');

6. Фрагментний шейдер симуляції

#version 300 es
precision highp float;

uniform sampler2D uState;   // RG = [u, v]
uniform vec2  uResolution;
uniform float uF;           // швидкість подачі
uniform float uK;           // швидкість знищення
uniform float uDu;          // дифузія U
uniform float uDv;          // дифузія V
uniform float uDt;          // крок за часом

out vec4 outColor;

// 9-точковий ізотропний лапласіан
vec2 laplacian(vec2 pos) {
  vec2 d = 1.0 / uResolution;
  vec2 c  = texture(uState, pos).rg;
  vec2 N  = texture(uState, pos + vec2( 0, d.y)).rg;
  vec2 S  = texture(uState, pos + vec2( 0,-d.y)).rg;
  vec2 E  = texture(uState, pos + vec2( d.x, 0)).rg;
  vec2 W  = texture(uState, pos + vec2(-d.x, 0)).rg;
  vec2 NE = texture(uState, pos + vec2( d.x, d.y)).rg;
  vec2 NW = texture(uState, pos + vec2(-d.x, d.y)).rg;
  vec2 SE = texture(uState, pos + vec2( d.x,-d.y)).rg;
  vec2 SW = texture(uState, pos + vec2(-d.x,-d.y)).rg;
  return 0.2*(N+S+E+W) + 0.05*(NE+NW+SE+SW) - 1.0*c;
}

void main() {
  vec2 uv = gl_FragCoord.xy / uResolution;
  vec2 state = texture(uState, uv).rg;
  float u = state.r;
  float v = state.g;

  vec2 lap = laplacian(uv);
  float reaction = u * v * v;

  float du = uDu * lap.r - reaction + uF * (1.0 - u);
  float dv = uDv * lap.g + reaction - (uF + uK) * v;

  float newU = clamp(u + du * uDt, 0.0, 1.0);
  float newV = clamp(v + dv * uDt, 0.0, 1.0);

  outColor = vec4(newU, newV, 0.0, 1.0);
}
Навіщо clamp? Без обмеження похибки чисел із рухомою комою можуть виштовхнути концентрації за межі [0,1] після тисяч кроків за часом. Реакційний член пропорційний u·v² — значення поза діапазоном спричиняють неконтрольоване зростання. Завжди обмежуйте значення.

7. Шейдер відображення кольорів

#version 300 es
precision highp float;
uniform sampler2D uState;
out vec4 fragColor;

// Кольоровий градієнт у стилі Inferno для концентрації V
vec3 inferno(float t) {
  const vec3 c0 = vec3(0.0002, 0.0016, 0.0139);
  const vec3 c1 = vec3(0.1140, 0.0631, 0.4149);
  const vec3 c2 = vec3(0.5419, 0.1773, 0.3773);
  const vec3 c3 = vec3(0.8785, 0.3835, 0.1551);
  const vec3 c4 = vec3(0.9891, 0.7945, 0.2824);
  t = clamp(t, 0.0, 1.0);
  if (t < 0.25) return mix(c0, c1, t * 4.0);
  if (t < 0.50) return mix(c1, c2, (t - 0.25) * 4.0);
  if (t < 0.75) return mix(c2, c3, (t - 0.50) * 4.0);
  return mix(c3, c4, (t - 0.75) * 4.0);
}

void main() {
  vec2 uv = gl_FragCoord.xy / vec2(textureSize(uState, 0));
  float v = texture(uState, uv).g;
  fragColor = vec4(inferno(v), 1.0);
}

8. Повний драйвер на JavaScript

const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl2');
gl.getExtension('EXT_color_buffer_float');

const W = 512, H = 512;
canvas.width  = W;
canvas.height = H;

// --- Компілюємо шейдери (допоміжні функції опущено задля стислості) ---
const simProgram    = compileProgram(gl, simVertSrc, simFragSrc);
const renderProgram = compileProgram(gl, renderVertSrc, renderFragSrc);

// --- VAO повноекранного квада ---
const quadVAO = makeQuadVAO(gl);

// --- Буфери ping-pong ---
let { texA, texB, fboA, fboB } = createPingPongBuffers(gl, W, H);
let front = texA, frontFBO = fboA;
let back  = texB, backFBO  = fboB;

function swap() {
  [front, back]       = [back,  front];
  [frontFBO, backFBO] = [backFBO, frontFBO];
}

// --- Засіваємо початковий стан ---
const seed = new Float32Array(W * H * 2).fill(0);
for (let i = 0; i < W * H; i++) seed[i * 2] = 1.0; // u=1 усюди
for (let cy = 220; cy < 260; cy++) {          // засіяна ділянка
  for (let cx = 220; cx < 260; cx++) {
    const i = cy * W + cx;
    seed[i * 2]     = 0.50;
    seed[i * 2 + 1] = 0.25 + 0.01 * (Math.random() - 0.5);
  }
}
uploadTexture(gl, front, W, H, seed);

// --- Параметри симуляції ---
const params = { F: 0.035, k: 0.065, Du: 0.16, Dv: 0.08, dt: 1.0 };
const STEPS_PER_FRAME = 8;

function simStep() {
  gl.useProgram(simProgram);
  gl.viewport(0, 0, W, H);
  gl.bindFramebuffer(gl.FRAMEBUFFER, backFBO);
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, front);
  gl.uniform1i(gl.getUniformLocation(simProgram, 'uState'), 0);
  gl.uniform2f(gl.getUniformLocation(simProgram, 'uResolution'), W, H);
  gl.uniform1f(gl.getUniformLocation(simProgram, 'uF'), params.F);
  gl.uniform1f(gl.getUniformLocation(simProgram, 'uK'), params.k);
  gl.uniform1f(gl.getUniformLocation(simProgram, 'uDu'), params.Du);
  gl.uniform1f(gl.getUniformLocation(simProgram, 'uDv'), params.Dv);
  gl.uniform1f(gl.getUniformLocation(simProgram, 'uDt'), params.dt);
  drawQuad(gl, quadVAO);
  swap();
}

function render() {
  gl.useProgram(renderProgram);
  gl.viewport(0, 0, canvas.width, canvas.height);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, front);
  gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0);
  drawQuad(gl, quadVAO);
}

function loop() {
  for (let i = 0; i < STEPS_PER_FRAME; i++) simStep();
  render();
  requestAnimationFrame(loop);
}
loop();
Продуктивність: на настільному GPU середнього класу сітка 512×512 з 8 кроками симуляції на кадр виконується при 60 fps. Для 1024×1024 вам, можливо, доведеться зменшити до 4 кроків/кадр. Вузьким місцем зазвичай є пропускна здатність текстур, а не обчислення — тож важливо максимізувати ефективність кешу текстур (текстури розміром степенів двійки, фільтрація NEAREST).

Наступні кроки

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

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

Покроковий урок: реалізуйте систему реакції-дифузії Грея-Скотта у WebGL 2 за допомогою ping-pong-фреймбуферів і фрагментного шейдера. Створіть генерацію тюрінгоподібних візерунків зі швидкістю 60 fps.

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

Цей урок охоплює такі теми: Рівняння Грея-Скотта, Дискретний лапласіан на сітці, Простір параметрів візерунків, Архітектура GPU: ping-pong-фреймбуфери, Налаштування WebGL 2, Фрагментний шейдер симуляції, Шейдер відображення кольорів, Повний драйвер на JavaScript.

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

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

Які попередні знання потрібні?

Це урок рівня «Середній–просунутий рівень» — окрема попередня підготовка, крім базового JavaScript, не потрібна.