Reakcja-dyfuzja: wzory Turinga na GPU

W 1952 roku Alan Turing zaproponował, że dwie substancje chemiczne, dyfundujące i reagujące ze sobą, mogą tworzyć paski, plamki i labirynty, które widzimy na skórze zwierząt. Siedemdziesiąt lat później jego równania działają w czasie rzeczywistym na GPU — a wzory wciąż zapadają w pamięć.

Równania Graya–Scotta

Model Graya–Scotta śledzi dwie substancje chemiczne, U i V, na dwuwymiarowej siatce. U jest ciągle dostarczane do systemu; V jest zużywane. Ich interakcja to reakcja U + 2V → 3V:

∂U/∂t = D_u · ∇²U − U·V² + f·(1 − U) ∂V/∂t = D_v · ∇²V + U·V² − (f + k)·V Gdzie: D_u, D_v — współczynniki dyfuzji U i V (zawsze D_u > D_v) f — szybkość podawania: jak szybko U wchodzi do systemu k — szybkość usuwania: jak szybko V jest usuwane ∇² — Laplasjan (dyskretny szablon 5-punktowy na siatce)

Dwa parametry, f i k, kontrolują wszystko. Drobne zmiany ich wartości dają radykalnie różne typy wzorów: plamki, paski, labirynty, dziury, koral, odciski palców.

Dlaczego CPU to złe miejsce na to

Dla siatki 512×512 to 262 144 komórek. Każda komórka potrzebuje wartości ze swoich czterech sąsiadów (Laplasjan), potem dwóch mnożeń zmiennoprzecinkowych i dwóch dodawań. Uruchomienie tego w JavaScript przy 60 FPS wymaga ~31 milionów operacji na sekundę — osiągalne, ale ledwo, i bez żadnego zapasu na większą siatkę czy jakiekolwiek przetwarzanie końcowe.

Rozwiązanie GPU jest oczywiste: aktualizacja Graya–Scotta to klasyczna operacja wybitnie równoległa — każda komórka jest niezależna. Shader fragmentów, który czyta z jednej tekstury i zapisuje do drugiej, wykonuje tę samą aktualizację dla wszystkich 262 144 komórek jednocześnie.

Bufory ping-pong

Nie można czytać i zapisywać tej samej tekstury w jednym przebiegu renderowania (niezdefiniowane zachowanie w WebGL). Rozwiązanie to ping-pong: utrzymuj dwa cele renderowania i zamieniaj co klatkę, z którego czytasz, a do którego zapisujesz.

// Konfiguracja: dwa identyczne cele renderowania
const size = 512;
const opts = {
  type: THREE.FloatType,        // float32 dla precyzji
  format: THREE.RGBAFormat,     // R = stężenie U, G = stężenie V
  minFilter: THREE.LinearFilter,
  magFilter: THREE.LinearFilter
};
let rtA = new THREE.WebGLRenderTarget(size, size, opts);
let rtB = new THREE.WebGLRenderTarget(size, size, opts);

// Co klatkę: zamiana
function tick() {
  // Krok 1: renderuj aktualizację Graya-Scotta (czytaj A, zapisz B)
  uniforms.tState.value = rtA.texture;
  renderer.setRenderTarget(rtB);
  renderer.render(quadScene, orthoCamera);

  // Krok 2: wyświetl wynik na ekranie
  displayUniforms.tState.value = rtB.texture;
  renderer.setRenderTarget(null);
  renderer.render(displayScene, orthoCamera);

  // Zamiana
  [rtA, rtB] = [rtB, rtA];
  requestAnimationFrame(tick);
}

Shader fragmentów aktualizacji

Shader aktualizacji GLSL próbkuje bieżącą teksturę stanu i stosuje równania Graya–Scotta. Dyskretny Laplasjan wykorzystuje szablon 5-punktowy — klasyczne przybliżenie różnicami skończonymi.

uniform sampler2D tState;   // bieżąca tekstura RG (R=U, G=V)
uniform vec2 uTexelSize;    // 1/szerokość, 1/wysokość
uniform float uF;           // szybkość podawania
uniform float uK;           // szybkość usuwania
uniform float uDu;          // dyfuzja U
uniform float uDv;          // dyfuzja V
uniform float uDt;          // krok czasowy na klatkę

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

  // Próbkuj bieżącą komórkę i 4 sąsiadów
  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;

  // Dyskretny Laplasjan 5-punktowy
  vec2 lap = top + bot + left + right - 4.0 * curr;

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

  // Równania reakcji-dyfuzji Graya-Scotta
  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
  );
}

Zoo parametrów

Najbardziej fascynującym aspektem Graya–Scotta jest diagram fazowy. Trzymając D_u = 0,21 i D_v = 0,105 na stałe, oto wzory, które powstają przy zmianie samego F i k:

Plamki f=0,035, k=0,065 Kropki w stylu lamparta na morzu U. Świetne do wzorów skóry zwierząt.
Paski / Koral f=0,060, k=0,062 Rozgałęziające się labiryntowe struktury — tekstury rafy koralowej lub odcisków palców.
Ruchome robaki f=0,078, k=0,061 Niestabilne paski, które wiją się i łączą na nowo. Hipnotyzujące pętle.
Dziury / Szwajcarski ser f=0,039, k=0,058 Ciemne wyspy V na morzu U. Bąbelki powoli dryfujące.

Zasiewanie: skąd biorą się wzory

Warunek początkowy ma ogromne znaczenie. Start z jednorodnego stanu nie daje żadnego wzoru — to stabilna równowaga. Aby wprawić system w tryb tworzenia wzorów, zasiewam małe „krople" wysokiego stężenia V w polu wysokiego U:

// Zasiej teksturę początkową (na CPU, wysyłana raz)
const data = new Float32Array(size * size * 4);
for (let i = 0; i < size * size; i++) {
  data[i * 4 + 0] = 1.0; // U = 1 wszędzie
  data[i * 4 + 1] = 0.0; // V = 0 wszędzie
}
// Wrzuć 20 losowych ziaren 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; // zmniejsz U
      data[idx + 1] = 0.25; // wstrzyknij V
    }
  }
}
const initTexture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat, THREE.FloatType);

Interaktywne malowanie

Najbardziej satysfakcjonująca funkcja: kliknij i przeciągnij, aby wstrzyknąć V bezpośrednio na teksturę w czasie działania. Uchwyt zdarzenia myszy konwertuje współrzędne ekranowe na przestrzeń UV, a następnie renderuje mały „pędzel" wysokiego V do bieżącego bufora ramki. Namalowane wzory rosną i ewoluują organicznie, łącząc się z istniejącą strukturą.

Wymagało to uwagi przy transformacjach współrzędnych — kwadrat jest rysowany w znormalizowanych współrzędnych urządzenia, ale pozycja myszy jest w pikselach CSS. Jedno wywołanie getBoundingClientRect() i konwersja z viewportu na UV rozwiązały to elegancko.

Symulacja reakcji-dyfuzji na żywo znajduje się pod adresem /reaction-diffusion/. Wypróbuj selektor presetów, a potem maluj nowe ziarna V myszą. Pełny artykuł teoretyczny — łącznie z oryginalną pracą Turinga o morfogenezie i projekcją na powierzchnię 3D — znajduje się w Reakcja-dyfuzja: chemiczna podstawa morfogenezy Turinga.

Co zaskoczyło mnie najbardziej

Dwie rzeczy. Po pierwsze, jak powolne są wzory w skali biologicznej — symulacja wykonuje tysiące kroków Eulera na sekundę, podczas gdy prawdziwe wzorowanie organizmu zachodzi w ciągu godzin do dni rozwoju embrionalnego. Oszałamiająco złożona struktura, którą widzisz krystalizującą się w sekundach w symulacji, wymaga od organizmu pełnego cyklu rozwojowego.

Po drugie, że te same równania opisują zjawiska w siedmiu rzędach wielkości skali: od milimetrowych łatek sygnalizacji komórkowej po pola wydm o rozmiarze kilometrów. Kombinacja Laplasjanu i nieliniowości reakcji wydaje się być uniwersalną maszyną formującą wzory, którą natura odkryła wcześnie i nigdy nie przestała używać.