Реакція-дифузія: візерунки Тюрінга на GPU

У 1952 році Алан Тюрінг припустив, що два хімічні речовини, які дифундують і реагують, можуть створювати смуги, плями та лабіринти, які ми бачимо на шкірі тварин. Через сімдесят років його рівняння виконуються в реальному часі на GPU — і візерунки досі вражають.

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

Модель Грея-Скотта відстежує два хімічні види, U та V, на 2D-сітці. U постійно подається в систему; V споживається. Їхня взаємодія — реакція U + 2V → 3V:

∂U/∂t = D_u · ∇²U − U·V² + f·(1 − U) ∂V/∂t = D_v · ∇²V + U·V² − (f + k)·V Де: D_u, D_v — швидкості дифузії U та V (D_u > D_v завжди) f — швидкість подачі: як швидко U надходить у систему k — швидкість знищення: як швидко видаляється V ∇² — лапласіан (дискретний 5-точковий шаблон на сітці)

Два параметри, f і k, контролюють усе. Крихітні зміни їхніх значень породжують радикально різні типи візерунків: плями, смуги, лабіринти, отвори, корали, відбитки пальців.

Чому CPU — неправильне місце для цього

Для сітки 512×512 це 262 144 клітинки. Кожній клітинці потрібні значення її чотирьох сусідів (лапласіан), потім два множення з плаваючою комою та два додавання. Виконання цього на JavaScript на 60 FPS вимагає ~31 мільйона операцій за секунду — досяжно, але лише ледь-ледь, і без запасу для більшої сітки чи будь-якої постобробки.

Рішення з GPU очевидне: оновлення Грея-Скотта — класична вкрай паралельна операція — кожна клітинка незалежна. Фрагментний шейдер, що читає з однієї текстури й записує в іншу, виконує те саме оновлення для всіх 262 144 клітинок одночасно.

Пінг-понг фреймбуфери

Не можна читати й писати в ту саму текстуру в одному проході рендерингу (невизначена поведінка у WebGL). Рішення — пінг-понг: підтримувати дві цілі рендерингу і чергувати, з якої читати й у яку писати кожен кадр.

// Налаштування: дві однакові цілі рендерингу
const size = 512;
const opts = {
  type: THREE.FloatType,        // float32 для точності
  format: THREE.RGBAFormat,     // R = концентрація U, G = концентрація V
  minFilter: THREE.LinearFilter,
  magFilter: THREE.LinearFilter
};
let rtA = new THREE.WebGLRenderTarget(size, size, opts);
let rtB = new THREE.WebGLRenderTarget(size, size, opts);

// Кожен кадр: обмін
function tick() {
  // Крок 1: рендеринг оновлення Грея-Скотта (читати A, писати B)
  uniforms.tState.value = rtA.texture;
  renderer.setRenderTarget(rtB);
  renderer.render(quadScene, orthoCamera);

  // Крок 2: показати результат на екрані
  displayUniforms.tState.value = rtB.texture;
  renderer.setRenderTarget(null);
  renderer.render(displayScene, orthoCamera);

  // Обмін
  [rtA, rtB] = [rtB, rtA];
  requestAnimationFrame(tick);
}

Фрагментний шейдер оновлення

GLSL-шейдер оновлення зчитує поточну текстуру стану і застосовує рівняння Грея-Скотта. Дискретний лапласіан використовує 5-точковий шаблон — класичне скінченно-різницеве наближення.

uniform sampler2D tState;   // поточна RG-текстура (R=U, G=V)
uniform vec2 uTexelSize;    // 1/ширина, 1/висота
uniform float uF;           // швидкість подачі
uniform float uK;           // швидкість знищення
uniform float uDu;          // дифузія U
uniform float uDv;          // дифузія V
uniform float uDt;          // крок часу за кадр

void main() {
  vec2 uv = vUv;
  vec2 ts = uTexelSize;

  // Вибірка поточної клітинки та 4 сусідів
  vec2 curr  = texture2D(tState, uv).rg;
  vec2 top   = texture2D(tState, uv + vec2( 0, ts.y)).rg;
  vec2 bot   = texture2D(tState, uv + vec2( 0,-ts.y)).rg;
  vec2 left  = texture2D(tState, uv + vec2(-ts.x, 0)).rg;
  vec2 right = texture2D(tState, uv + vec2( ts.x, 0)).rg;

  // 5-точковий дискретний лапласіан
  vec2 lap = top + bot + left + right - 4.0 * curr;

  float u = curr.r;
  float v = curr.g;
  float uvv = u * v * v;

  // Рівняння реакції-дифузії Грея-Скотта
  float du = uDu * lap.r - uvv + uF * (1.0 - u);
  float dv = uDv * lap.g + uvv - (uF + uK) * v;

  gl_FragColor = vec4(
    clamp(u + du * uDt, 0.0, 1.0),
    clamp(v + dv * uDt, 0.0, 1.0),
    0.0, 1.0
  );
}

Зоопарк параметрів

Найцікавіший аспект Грея-Скотта — фазова діаграма. Фіксуючи D_u = 0.21 та D_v = 0.105, ось візерунки, що виникають лише через зміну F та k:

Плями f=0.035, k=0.065 Крапки в стилі леопарда на морі U. Чудово підходить для візерунків шкіри тварин.
Смуги / Корали f=0.060, k=0.062 Розгалужені лабіринтові структури — текстури коралового рифу чи відбитків пальців.
Рухомі черви f=0.078, k=0.061 Нестабільні смуги, що звиваються й з'єднуються заново. Гіпнотичні цикли.
Отвори / Швейцарський сир f=0.039, k=0.058 Темні острівці V у морі U. Бульбашки, що повільно дрейфують.

Засівання: звідки беруться візерунки

Початковий стан має надзвичайне значення. Старт з однорідного стану не дає жодного візерунка — це стабільна рівновага. Щоб підштовхнути систему в режим формування візерунків, я засіваю невеликі «краплі» високої концентрації V у полі високого U:

// Засів початкової текстури (на CPU, завантажується один раз)
const data = new Float32Array(size * size * 4);
for (let i = 0; i < size * size; i++) {
  data[i * 4 + 0] = 1.0; // U = 1 всюди
  data[i * 4 + 1] = 0.0; // V = 0 всюди
}
// Кинути 20 випадкових насінин V
for (let s = 0; s < 20; s++) {
  const cx = Math.floor(Math.random() * size);
  const cy = Math.floor(Math.random() * size);
  for (let dx = -4; dx <= 4; dx++) {
    for (let dy = -4; dy <= 4; dy++) {
      const idx = ((cy + dy) * size + (cx + dx)) * 4;
      data[idx + 0] = 0.5; // зменшити U
      data[idx + 1] = 0.25; // додати V
    }
  }
}
const initTexture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat, THREE.FloatType);

Інтерактивне малювання

Найзадовільніша функція: клацання й перетягування для введення V безпосередньо в текстуру під час виконання. Обробник події миші перетворює екранні координати в UV-простір, потім рендерить невеликий «пензлик» високого V у поточний фреймбуфер. Візерунки, які ви малюєте, органічно зростають і розвиваються, зливаючись з наявною структурою.

Це вимагало уваги до перетворень координат — квад малюється в нормалізованих координатах пристрою, але позиція миші — у CSS-пікселях. Один виклик getBoundingClientRect() та перетворення viewport-в-UV вирішили це чисто.

Жива симуляція реакції-дифузії доступна за адресою /reaction-diffusion/. Спробуйте селектор пресетів, а потім намалюйте нові насінини V мишею. Повна теоретична стаття — включно з оригінальною статтею Тюрінга про морфогенез і 3D-проєкцією поверхні — за адресою Реакція-дифузія: хімічна основа морфогенезу за Тюрінгом.

Що здивувало мене найбільше

Дві речі. По-перше, наскільки повільні ці візерунки в біологічних масштабах часу — симуляція виконує тисячі кроків Ейлера за секунду, тоді як реальне формування візерунків у організмі відбувається протягом годин чи днів ембріонального розвитку. Приголомшливо складна структура, яка кристалізується за секунди в симуляції, потребує повного циклу розвитку організму.

По-друге, ті самі рівняння описують явища на сім порядків величини масштабу: від субміліметрових ділянок клітинної сигналізації до дюнних полів шириною в кілометри. Комбінація лапласіана та нелінійності реакції виявляється універсальною машиною формування візерунків, яку природа відкрила рано і ніколи не переставала використовувати.