Диференціальні рівняння описують, як щось змінюється — швидкість планети, температуру
по металевій пластині, завихрення рідини. Браузер не може розв'язувати їх символічно в
реальному часі, але це й не потрібно. Йому достатньо просувати стан на невеликий крок
часу, малювати результат і повторювати. Правильно налаштуйте цей цикл — і статичне
рівняння перетвориться на живу, керовану, налаштовувану параметрами картину. Помиліться
— і ваша симуляція або повзе, або за кілька секунд вибухає в NaN.
Нижче наведено робочий процес, який ми використовуємо в усьому каталозі — від звичайних диференціальних рівнянь (ЗДР), що відстежують кілька змінних, до диференціальних рівнянь у частинних похідних (ДРЧП), які розвивають цілу сітку значень.
1. Оберіть правильний інтегратор: Ейлер проти RK4
Розв'язувач ЗДР раз за разом відповідає на одне питання: маючи поточний стан і швидкість зміни, яким буде стан за мить пізніше? Найпростіша відповідь — прямий метод Ейлера — візьміть поточний нахил і крокуйте прямо вздовж нього.
// state = {x, v}, derivs(s) повертає {dx, dv}
function eulerStep(s, dt) {
const d = derivs(s);
return { x: s.x + d.dx * dt, v: s.v + d.dv * dt };
}
Ейлер дешевий і цілком достатній для м'яких, повільних систем або хмар часток, де невеликий дрейф непомітний. Але він швидко накопичує похибку, і для осцилюючих або хаотичних систем додає штучну енергію, поки орбіта не розкрутиться назовні. Ось тут і проявляє себе метод Рунге-Кутта четвертого порядку (RK4). RK4 вибирає нахил чотири рази за крок — один раз на початку, двічі в середині, один раз наприкінці — і поєднує їх у значно точнішу оцінку.
function rk4Step(s, dt) {
const k1 = derivs(s);
const k2 = derivs(add(s, scale(k1, dt / 2)));
const k3 = derivs(add(s, scale(k2, dt / 2)));
const k4 = derivs(add(s, scale(k3, dt)));
return add(s, scale(add4(k1, k2, k2, k3, k3, k4), dt / 6));
}
RK4 коштує чотирьох обчислень похідної замість одного, але дозволяє робити значно більші кроки часу за тієї ж точності — зазвичай це чиста вигода. Наш 3D атрактор Лоренца покладається на RK4: форма метелика залишається чіткою лише тому, що інтегратор не «випускає» енергію. Для жорстких або енергозберігаючих механічних систем часто краще підходить симплектичний варіант, наприклад швидкісний метод Верле, але RK4 — безпечний вибір за замовчуванням, коли ви не впевнені.
2. Сітки скінченних різниць для ДРЧП
ДРЧП розвивають поле — значення в кожній точці простору — а не кілька скалярів.
Стандартний і зручний для браузера підхід — метод скінченних різниць:
зберігайте поле як плоский Float32Array, індексований як
i = y * width + x, і наближайте просторові похідні, порівнюючи кожну
клітинку з її сусідами.
Робочий кінь тут — дискретний лапласіан, що керує дифузією, теплопередачею й поширенням хвиль:
// 5-точковий лапласіан на сітці width×height
const i = y * width + x;
const lap = field[i - 1] + field[i + 1]
+ field[i - width] + field[i + width]
- 4 * field[i];
next[i] = field[i] + alpha * lap * dt;
Завжди пишіть у окремий вихідний буфер і міняйте два масиви місцями після повного проходу — оновлення на місці псує сусідів, яких ви ще не відвідали. Саме цей патерн подвійного буфера живить сіткові симуляції на кшталт плазмової нестійкості, де поле зарядженої рідини просувається вперед клітинка за клітинкою, а також дифузійне згладжування за гранульованим нагрівом, де локальні зіткнення поширюють енергію крізь щільно упаковане ложе зерен.
3. Дотримуйтеся умови стійкості
Ось пастка, у яку всі потрапляють уперше: явні схеми скінченних різниць стійкі лише
тоді, коли крок часу достатньо малий відносно кроку сітки. Для рівняння дифузії у двох
вимірах умова CFL (Куранта-Фрідріхса-Леві) приблизно вимагає
alpha * dt / dx² < 0.25. Перевищте цю межу — і поле не просто
стане неточним, воно почне дико осцилювати й за кілька кадрів переповниться до
нескінченності.
Правило великого пальця: якщо ваше ДРЧП вибухає до NaN,
зменшіть dt удвічі, перш ніж чіпати щось інше. У дев'яти випадках із
десяти ви просто порушили межу стійкості, а не написали баг.
Два практичні виправлення убезпечать вас, не сповільнюючи видиму анімацію. По-перше, виконуйте кілька невеликих фізичних підкроків на кожен відрендерений кадр — математика залишається стійкою, поки екран усе одно оновлюється 60 разів на секунду. По-друге, обмежуйте або м'яко демпфуйте поле на кожному кроці, щоб випадковий сплеск не наростав снігова кулею. Хвильові ДРЧП, як-от дисперсійний хвильовий патерн у нашій симуляції кільватерного сліду Кельвіна, особливо чутливі до цього: V-подібний слід зберігає форму лише тоді, коли швидкість хвилі й крок часу залишаються збалансованими.
4. Відокремте фізику від рендерингу
Поширена помилка — прив'язувати крок інтегрування безпосередньо до
requestAnimationFrame і передавати реальний минулий час як dt.
Коли вкладка «підвисає», ця дельта роздувається до сотень мілісекунд, і один гігантський
крок підриває вашу стійку схему. Виправлення — акумулятор фіксованого кроку
часу: просувайте фізику постійними скибками й рендеріть лише раз за кадр.
let acc = 0;
const FIXED = 1 / 120; // фізичні секунди на підкрок
function frame(now) {
acc += Math.min((now - last) / 1000, 0.1); // обмежуємо сплеск
last = now;
while (acc >= FIXED) { step(FIXED); acc -= FIXED; }
render();
requestAnimationFrame(frame);
}
Обмеження накопиченого часу (Math.min(..., 0.1)) запобігає жахливій
«спіралі смерті», коли повільний кадр вимагає більше підкроків, ніж може дозволити
собі наступний кадр. Саме цей патерн — різниця між симуляцією, яка переживає фонову
вкладку, і тією, що повертається екраном, повним сміття.
5. Рендеріть швидко: групуйте виклики Canvas
Навіть ідеальна математика виглядає зламаною при 20 FPS. Canvas 2D цілком достатньо швидкий для цих візуалізацій, якщо перестати з ним боротися:
-
Малюйте поля через
putImageData. Для повної сітки пишіть байти RGBA прямо в буферImageDataі виводьте весь кадр одним викликом — ніколи не одинfillRectна клітинку. -
Групуйте траєкторії часток. Для роїв ЗДР відкрийте один
beginPath(), додайте всіmoveTo/lineToі виконайте одинstroke(). Кожен окремий stroke — це витратна зміна стану. - Уникайте виділення пам'яті на кожен кадр. Повторно використовуйте типізовані масиви та об'єкти стану. Паузи збирача сміття — найпоширеніша причина видимих заїкань при 60 FPS.
-
Узгодьте співвідношення пікселів пристрою один раз. Масштабуйте
резервний буфер canvas до
devicePixelRatioпід час налаштування, а не щокадру, щоб отримати чіткі лінії на retina-дисплеях без накладних витрат на кожен кадр.
Разом вони перетворюють повільний прототип на щось, що відчувається миттєвим, навіть на телефоні середнього класу.
Складаємо все разом
Рецепт однаковий, незалежно від того, чи ви розвиваєте три пов'язані ЗДР, чи поле ДРЧП з мільйоном клітинок: оберіть інтегратор, що відповідає чутливості вашої системи (Ейлер для поблажливих систем, RK4 для решти), розташуйте поле як плоский типізований масив із подвійно-буферизованим оновленням скінченних різниць, тримайте крок часу нижче межі стійкості, відокремте фізику від рендерингу за допомогою фіксованого акумулятора та групуйте кожен виклик Canvas, який можете. Опануйте ці п'ять звичок — і майже будь-яке диференціальне рівняння з підручника стане симуляцією, яку можна випустити в світ.
Хочете побачити ці техніки в дії? Дослідіть симуляції атрактора Лоренца, плазмової нестійкості, кільватерного сліду Кельвіна та гранульованого нагріву — кожна з них є диференціальним рівнянням, що виконує описаний вище цикл. Або перегляньте повний каталог симуляцій, щоб знайти рівняння за вашим улюбленим явищем.