💡 Довідник · Числові обчислення
📅 Березень 2026⏱ ~14 хв читання🔴 Для практиків

Пастки чисел з рухомою комою в коді симуляцій

Баги симуляцій, спричинені числами з рухомою комою, підступні: вони тихо дають неправильні результати, часто лише для граничних вхідних даних. Цей довідник описує кожну категорію «пасток» IEEE 754 — бітову структуру, спеціальні значення, катастрофічне скорочення, денормалізовані числа та точність GPU — з конкретними прикладами на JavaScript/GLSL і способами захисту.

1. Бітовий формат IEEE 754

JavaScript використовує подвійну точність (64 біти) для всіх операцій із числами. Типове значення в GLSL WebGL — одинарна точність (32 біти). Обидва відповідають IEEE 754:

64-бітне double: [s:1][exponent:11][mantissa:52]
  значення = (−1)^s × 2^(e−1023) × 1.mantissa
  Діапазон: ±5×10⁻³²⁴ … ±1.8×10³⁰⁸
  Точність: ~15–17 значущих десяткових цифр

32-бітне float: [s:1][exponent:8][mantissa:23]
  значення = (−1)^s × 2^(e−127) × 1.mantissa
  Діапазон: ±1.2×10⁻³⁸ … ±3.4×10³⁸
  Точність: ~6–9 значущих десяткових цифр

Провідна 1. у мантисі є неявною (не зберігається) — це прихований біт. Денормалізовані числа (див. §5) є винятком: експонента = 0 означає, що провідний біт дорівнює 0, а не 1.

2. NaN, нескінченність та −0

IEEE 754 визначає кілька спеціальних значень, які поширюються крізь арифметику несподіваними способами:

Значення Як утворюється Ключова поведінка
+Infinity 1/0, переповнення Поширюється: Inf + 1 = Inf. Inf - Inf = NaN
-Infinity -1/0, від'ємне переповнення -Inf < x для будь-якого скінченного x
NaN 0/0, Inf-Inf, sqrt(-1) Не дорівнює сам собі: NaN !== NaN. Отруює всю арифметику.
-0 -1 * 0, антипереповнення (-мале) -0 === 0 істинно. 1/-0 = -Infinity.
// Перевірка на NaN — єдиний правильний спосіб
if (Number.isNaN(x)) { /* ... */ }   // правильно
if (x !== x) { /* ... */ }             // теж правильно (властивість NaN)
if (x === NaN) { /* ... */ }           // НЕПРАВИЛЬНО — завжди false

// Відрізнити -0 від +0
const isNegZero = (x) => x === 0 && (1/x) === -Infinity;
Пастка симуляції: Єдиний NaN у швидкості частинки тихо пошириться на позицію кожної частинки, а потім на кожне обчислення сил — уся симуляція стане NaN за кілька кадрів, відмалюючи лише чорний екран або застиглі частинки. Завжди очищуйте початкові умови та перевіряйте на NaN у головному циклі під час розробки.

3. Точність і машинний епсилон

Машинний епсилон (ε) — це найменше число, для якого 1 + ε ≠ 1:

Формат ε (машинний епсилон) Константа JS
float64 2.22 × 10⁻¹⁶ Number.EPSILON (≈ 2.22e-16)
float32 1.19 × 10⁻⁷ GLSL: порівнюйте з 1e-6
float16 9.77 × 10⁻⁴ GPU mediump, ~0.001

Похибки точності накопичуються. Після N додавань очікувана відносна похибка становить O(N·ε). У покроковій за часом симуляції з 60 fps × 3600 с = 216 000 кроків похибки float32 сягають ~2.5% — достатньо, щоб орбіти помітно дрейфували.

4. Катастрофічне скорочення

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

// Приклад: відстань між частинками, що майже перекриваються
let x1 = 1000000.0001, x2 = 1000000.0002;
let dx = x2 - x1;   // = 0.0001, але у float32 зберігається як ~0.000099182
                     // Похибка: ~0.8% лише від скорочення

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

// Зі світового простору у відносний до камери (трюк float-origin)
const camX = camera.position.x;
const relX = particle.x - camX;  // мале число, без скорочення

Та сама проблема виникає у квадратних формулах. Використовуйте числово стабільну форму:

// НЕСТАБІЛЬНО для |b| >> |c/b|
x = (-b + Math.sqrt(b*b - 4*a*c)) / (2*a);

// СТАБІЛЬНО: уникає скорочення, коли b і корінь дискримінанта майже скорочуються
const q = -0.5 * (b + Math.sign(b) * Math.sqrt(b*b - 4*a*c));
x1 = q / a;
x2 = c / q;
Сумування Кагана: Для сумування великих масивів (наприклад, повної енергії) компенсоване сумування відновлює майже повну точність ціною удвічі більшої кількості операцій.
// Компенсоване сумування Кагана
function kahanSum(arr) {
  let sum = 0, c = 0;
  for (const x of arr) {
    const y = x - c;
    const t = sum + y;
    c = (t - sum) - y;
    sum = t;
  }
  return sum;
}

5. Денормалізовані числа та антипереповнення

Коли значення float32 падає нижче за ~1.2×10⁻³⁸, апаратне забезпечення активує денормалізований (субнормальний) режим: провідний біт змінюється з 1 на 0, дозволяючи поступове антипереповнення коштом точності. Денормалізована арифметика може бути у 50–100 разів повільнішою на CPU (і часто скидається до нуля на GPU).

// Приклад скидання денормалізованих — внесок маси частинки незначний,
// але все одно запускає вдвічі повільніший денормалізований шлях щокадру
particle.vx *= dampingFactor;   // якщо vx = 1e-39 (float32) → денормалізоване

// Виправлення: обнуліть крихітні швидкості
const MIN_V = 1e-30;
if (Math.abs(particle.vx) < MIN_V) particle.vx = 0;
WebGL: Шейдери GLSL часто скидають денормалізовані числа до нуля (режим FTZ). Значення, що плавно зменшується в JS, може стрибнути точно до 0 в GLSL, спричиняючи артефакти ділення на нуль. Завжди додавайте мінімальне значення: length(v) + 1e-6 замість length(v) перед діленням.

6. Залежність від порядку та неасоціативність

Додавання з рухомою комою не асоціативне: (a + b) + c ≠ a + (b + c) у загальному випадку. Це означає:

Детермінованість симуляції: Для відтворюваних повторів переконайтеся, що всі масиви частинок обробляються у фіксованому порядку, використовуйте Float64Array (що примусово задає 64-бітне зберігання) та уникайте Math.random() — застосовуйте псевдовипадковий генератор із зерном, як-от mulberry32.

7. Порівняння значень з рухомою комою

Ніколи не порівнюйте числа з рухомою комою через ===, якщо ви не знаєте напевно, що обидва значення задані однаковим літералом або отримані з однакового обчислення без проміжного округлення:

// НЕПРАВИЛЬНО: майже ніколи не істинно через округлення
if (a + b === c) { ... }

// Абсолютний епсилон — добре для значень поблизу нуля (напр., швидкостей)
const nearZero = (x, eps = 1e-9) => Math.abs(x) < eps;

// Відносний епсилон — добре для значень, далеких від нуля (напр., енергії)
const nearEqual = (a, b, eps = 1e-9) =>
  Math.abs(a - b) <= eps * Math.max(Math.abs(a), Math.abs(b), 1);

// Порівняння в ULP — найсуворіше (потребує DataView)
function ulpDiff(a, b) {
  const buf = new ArrayBuffer(8);
  const view = new DataView(buf);
  view.setFloat64(0, a); const ia = view.getBigInt64(0);
  view.setFloat64(0, b); const ib = view.getBigInt64(0);
  return Number(ia > ib ? ia - ib : ib - ia);
}
Практичне правило: Використовуйте абсолютний епсилон (< 1e-9) для величин, які мають дорівнювати нулю. Використовуйте відносний епсилон для величин, далеких за модулем від нуля. Ніколи не задавайте жорстко 0.0001 як епсилон без перевірки порядку величин ваших значень.

8. Точність GPU (GLSL mediump / highp)

WebGL 1.0 вимагає GLSL mediump (float16 або float32 залежно від драйвера) для фрагментних шейдерів. WebGL 2.0 типово використовує float32 і для вершинних, і для фрагментних. Завжди оголошуйте точність явно:

// На початку кожного шейдера GLSL
precision highp float;   // рекомендовано для симуляцій
precision highp int;

// Уникайте неявного mediump (типове у фрагментних шейдерах WebGL 1)

Поширені пастки точності GPU:

Швидкий чек-лист для шейдерів GLSL:
precision highp float; на початку кожного шейдера
✅ Позиції відносно камери (передавайте uCamPos, обчислюйте worldPos - uCamPos на CPU)
max(length(v), EPSILON) перед діленням на довжину
✅ Періодично обгортайте uniform часу або використовуйте кодування у два float
✅ Обмежуйте нормалізовані значення: clamp(n, 0.0, 1.0)