⚗️ Туторіал · Canvas · Фізика
📅 Липень 2026 ⏱ ≈ 3 години 🎓 Початковий-Середній рівень

Симуляція молекулярного газу за вечір

Вам не потрібен фізичний рушій, ігровий фреймворк чи WebGL, щоб побачити, як газ конденсується в рідину прямо на очах. Зі звичайним canvas 2D і приблизно 150 рядками JavaScript ви можете побудувати з нуля симуляцію молекулярного газу Леннарда-Джонса — з реалістичними силами, стійким інтегруванням та живим контролем температури — за один вечір.

1. Налаштування проєкту

Нам потрібен лише один HTML-файл із canvas та тегом script — без білд-кроку, без залежностей:

<!doctype html>
<html>
<body style="margin:0; background:#0a0a0f;">
<canvas id="c" width="800" height="600"></canvas>
<input id="temp" type="range" min="0" max="3" step="0.05" value="0.6"
       style="position:fixed; bottom:20px; left:20px; width:300px;">
<script src="gas.js"></script>
</body>
</html>

Усе інше живе у файлі gas.js, який ми побудуємо в наступних розділах.

2. Дані частинок та початкові умови

Зберігайте стан частинок у плоских типізованих масивах, а не в масиві об'єктів — це уникає накладних витрат на алокацію пам'яті для кожної частинки й тримає доступ до пам'яті суцільним, що важливо, коли у вас кілька сотень частинок оновлюються 60 разів на секунду:

const N = 300;                 // кількість частинок
const px = new Float64Array(N), py = new Float64Array(N);
const vx = new Float64Array(N), vy = new Float64Array(N);
const fx = new Float64Array(N), fy = new Float64Array(N);

const W = 800, H = 600;
const SIGMA = 8, EPSILON = 40;  // параметри Леннарда-Джонса (у пікселях)

// Розміщуємо частинки на сітці з невеликим випадковим зсувом та швидкістю
let idx = 0;
outer:
for (let row = 0; row < 30 && idx < N; row++) {
  for (let col = 0; col < 30 && idx < N; col++) {
    px[idx] = 40 + col * 22 + (Math.random() - 0.5) * 4;
    py[idx] = 40 + row * 22 + (Math.random() - 0.5) * 4;
    const speed = 40;
    const angle = Math.random() * Math.PI * 2;
    vx[idx] = Math.cos(angle) * speed;
    vy[idx] = Math.sin(angle) * speed;
    idx++;
    if (idx >= N) break outer;
  }
}
Чому сітка, а не випадкові позиції? Розміщення частинок цілком навмання ризикує тим, що дві частинки опиняться майже одна на одній, породжуючи майже нескінченну відштовхувальну силу LJ, яка запускає частинку за межі екрана з абсурдною швидкістю вже на першому кадрі. Сітка зі зсувом гарантує безпечну мінімальну відстань.

3. Сили Леннарда-Джонса

Для кожної пари частинок у межах діапазону обчисліть силу LJ (див. наш супутній матеріал про потенціал Леннарда-Джонса для повного виведення) та накопичіть її на обох частинках (третій закон Ньютона: рівна й протилежна):

const CUTOFF = SIGMA * 2.5;
const CUTOFF_SQ = CUTOFF * CUTOFF;

function computeForcePair(i, j) {
  let dx = px[i] - px[j];
  let dy = py[i] - py[j];
  const r2 = dx * dx + dy * dy;
  if (r2 > CUTOFF_SQ || r2 < 1e-6) return;

  const sr2 = (SIGMA * SIGMA) / r2;
  const sr6 = sr2 * sr2 * sr2;
  const sr12 = sr6 * sr6;
  // F(r)/r, тому можна множити напряму на (dx, dy) без нормалізації
  const forceOverR = (24 * EPSILON / r2) * (2 * sr12 - sr6);

  fx[i] += forceOverR * dx;  fy[i] += forceOverR * dy;
  fx[j] -= forceOverR * dx;  fy[j] -= forceOverR * dy;
}

Зверніть увагу на скорочення: оскільки справжній напрямок сили — (dx, dy)/r, а формула величини LJ вже ділить на r один раз, ми ділимо на r² замість r і повністю уникаємо квадратного кореня — Math.sqrt одна з дорожчих операцій у щільному внутрішньому циклі, тож уникати її, коли можливо, варте невеликої додаткової алгебри.

4. Просторове хешування для швидкості

Виклик computeForcePair для кожної пари коштує O(N²) — прийнятно для 300 частинок (близько 45 000 пар), але не масштабується до кількох тисяч. Оскільки сила LJ практично нульова за межами радіуса обрізання, нам потрібно перевіряти лише частинки в сусідніх клітинках однорідної сітки:

const CELL = CUTOFF;
const cols = Math.ceil(W / CELL), rows = Math.ceil(H / CELL);

function buildGrid() {
  const grid = new Map();  // ключ: "col,row" -> масив індексів частинок
  for (let i = 0; i < N; i++) {
    const col = Math.floor(px[i] / CELL);
    const row = Math.floor(py[i] / CELL);
    const key = col + ',' + row;
    if (!grid.has(key)) grid.set(key, []);
    grid.get(key).push(i);
  }
  return grid;
}

function computeAllForces() {
  fx.fill(0); fy.fill(0);
  const grid = buildGrid();
  for (const [key, cellParticles] of grid) {
    const [col, row] = key.split(',').map(Number);
    // Перевіряємо цю клітинку та 8 сусідніх (блок 3x3)
    for (let dc = -1; dc <= 1; dc++) {
      for (let dr = -1; dr <= 1; dr++) {
        const neighbourKey = (col + dc) + ',' + (row + dr);
        const neighbourParticles = grid.get(neighbourKey);
        if (!neighbourParticles) continue;
        for (const i of cellParticles) {
          for (const j of neighbourParticles) {
            if (j > i) computeForcePair(i, j);  // уникаємо подвійного підрахунку
          }
        }
      }
    }
  }
}
Чому 3×3, а не лише поточна клітинка? Частинка поблизу краю своєї клітинки все ще може перебувати в межах радіуса обрізання частинки в сусідній клітинці. Перевірка поточної клітинки плюс усіх 8 сусідніх гарантує, що жодну пару, яка взаємодіє в межах відстані обрізання, ніколи не буде пропущено, поки CELL ≥ CUTOFF.

5. Інтегрування швидкісним Верле

Знаючи сили, просуваємо кожну частинку швидкісним Верле (див. наш супутній матеріал про молекулярну динаміку, чому це переважає над простим інтегруванням Ейлера):

const DT = 0.01;
const MASS = 1;

function step() {
  computeAllForces();
  const ax = new Float64Array(N), ay = new Float64Array(N);
  for (let i = 0; i < N; i++) {
    ax[i] = fx[i] / MASS;
    ay[i] = fy[i] / MASS;
    px[i] += vx[i] * DT + 0.5 * ax[i] * DT * DT;
    py[i] += vy[i] * DT + 0.5 * ay[i] * DT * DT;
  }

  computeAllForces();  // сили в НОВИХ позиціях
  for (let i = 0; i < N; i++) {
    const axNew = fx[i] / MASS, ayNew = fy[i] / MASS;
    vx[i] += 0.5 * (ax[i] + axNew) * DT;
    vy[i] += 0.5 * (ay[i] + ayNew) * DT;
  }

  applyWalls();
}
Чому два обчислення сил на крок? Швидкісному Верле потрібне прискорення і в старій позиції (для оновлення положення), і в новій (для оновлення швидкості). Це подвоює вартість циклу обчислення сил порівняно з простим Ейлером, але набагато краще збереження енергії та стійкість того варті — LJ-газ, інтегрований Ейлером, помітно нагрівається чи вибухає за кілька секунд.

6. Стіни, рендеринг та повзунок температури

Пружно відбивайте частинки від чотирьох стін canvas:

function applyWalls() {
  const R = SIGMA * 0.5;  // візуальний радіус частинки
  for (let i = 0; i < N; i++) {
    if (px[i] < R)      { px[i] = R;     vx[i] = Math.abs(vx[i]); }
    if (px[i] > W - R)  { px[i] = W - R; vx[i] = -Math.abs(vx[i]); }
    if (py[i] < R)      { py[i] = R;     vy[i] = Math.abs(vy[i]); }
    if (py[i] > H - R)  { py[i] = H - R; vy[i] = -Math.abs(vy[i]); }
  }
}

Малюємо кожну частинку, забарвлену за швидкістю (дешевий візуальний proxy для локальної кінетичної температури):

const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');

function render() {
  ctx.fillStyle = '#0a0a0f';
  ctx.fillRect(0, 0, W, H);
  for (let i = 0; i < N; i++) {
    const speed = Math.hypot(vx[i], vy[i]);
    const hue = 220 - Math.min(speed * 2, 200);  // синій (холодно) до червоного (гаряче)
    ctx.fillStyle = `hsl(${hue}, 90%, 60%)`;
    ctx.beginPath();
    ctx.arc(px[i], py[i], SIGMA * 0.5, 0, Math.PI * 2);
    ctx.fill();
  }
}

Нарешті, підключаємо повзунок температури через простий термостат масштабування швидкостей (див. наш супутній матеріал про алгоритм Метрополіса/статистичну механіку для строгіших альтернатив):

const tempSlider = document.getElementById('temp');
function applyThermostat(targetKineticPerParticle) {
  let totalKE = 0;
  for (let i = 0; i < N; i++) totalKE += 0.5 * MASS * (vx[i]**2 + vy[i]**2);
  const currentAvg = totalKE / N;
  if (currentAvg < 1e-6) return;
  const scale = Math.sqrt(targetKineticPerParticle / currentAvg);
  for (let i = 0; i < N; i++) { vx[i] *= scale; vy[i] *= scale; }
}

function loop() {
  step();
  applyThermostat(parseFloat(tempSlider.value) * 500);
  render();
  requestAnimationFrame(loop);
}
loop();

Це все — приблизно 150 рядків загалом. Посуньте температуру вниз і подивіться, як газ конденсується в краплі рідини; посуньте вгору — і краплі знову випаровуються в хаотичний газ.

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

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

Чому газ інколи вибухає замість конденсації?

Зазвичай це означає, що DT завеликий для заданої комбінації SIGMA/EPSILON, або дві частинки почали занадто близько одна до одної, породжуючи величезну початкову відштовхувальну силу. Спробуйте зменшити DT, збільшити початковий крок сітки, або обмежити максимальну величину сили на пару як запобіжний захід під час налагодження.

Чому використовувати Map для просторової сітки замість 2D-масиву?

Map із ключами "col,row" виділяє пам'ять лише для клітинок, що справді містять частинки, що важливо, коли частинки скупчуються в одній області й залишають великі частини домену порожніми — фіксований 2D-масив марнував би пам'ять (і час ітерації, якщо ви проходите кожну клітинку) на ці порожні області. Для малого, обмеженого домену типізований 2D-масив — прийнятна й трохи швидша альтернатива; підхід із Map краще узагальнюється на більші чи необмежені домени.

Наскільки швидша версія з просторовим хешуванням за наївний цикл O(N²)?

Для N=300 з обрізанням, що покриває приблизно 5% площі домену, підхід із сіткою перевіряє лише невелику постійну кількість сусідів на частинку замість усіх інших 299 — зазвичай прискорення в 5-10 разів за цього масштабу, зростаючи до порядків величини зі зростанням N до тисяч, оскільки наївний підхід масштабується як N², а сітковий підхід масштабується майже лінійно з N (за приблизно постійної густини частинок).

Чи можна поширити цей самий підхід на 3D?

Так — замініть 2D-масиви позицій/швидкостей/сил на 3D-еквіваленти, розширте ключ сітки до "col,row,layer" та перевіряйте блок сусідніх клітинок 3×3×3 замість 3×3. Рендеринг стає складнішим (знадобиться проста ортографічна чи перспективна проєкція, або бібліотека на кшталт Three.js), але код фізики та інтегрування залишається практично незмінним.

Чому термостат масштабує ВСІ швидкості одним і тим самим коефіцієнтом?

Це найпростіший можливий термостат (див. нашу статтю про алгоритм Метрополіса для строгіших альтернатив, як-от Нозе-Гувер). Множення кожної швидкості на той самий коефіцієнт масштабу зберігає напрямок руху кожної частинки, водночас коригуючи загальну кінетичну енергію (а отже й температуру) до цілі — це грубо й не відтворює реалістичні теплові флуктуації, але цілком достатньо для інтерактивної візуальної демонстрації.

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

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

Покроковий туторіал: створіть 2D-симуляцію молекулярного газу Леннарда-Джонса на canvas JavaScript за один вечір — швидкісний Верле, просторове хешування та повзунок температури.

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

Цей урок охоплює такі теми: Налаштування проєкту, Дані частинок та початкові умови, Сили Леннарда-Джонса, Просторове хешування для швидкості, Інтегрування швидкісним Верле, Стіни, рендеринг та повзунок температури.

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

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