⚡ Урок · 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).

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