🦾 Урок · Анімація · Процедурна
📅 Березень 2026 ⏱ 20 хв 🎓 Середній рівень

Процедурна анімація з інверсною кінематикою FABRIK

Інверсна кінематика — це основний алгоритм, що стоїть за процедурними лапками павука, симуляціями роборук та фізикою ганчіркової ляльки. FABRIK — Forward And Backward Reaching Inverse Kinematics — це найпростіший розв'язувач IK, який працює для ланцюга будь-якої довжини, збігається за 1–10 ітерацій і не потребує обернення матриць.

1. Задача інверсної кінематики

Пряма кінематика (FK): за набором кутів суглобів обчислити, де опиниться кінцевий ефектор.
Інверсна кінематика (IK): за заданою цільовою позицією знайти кути суглобів, що розмістять кінцевий ефектор там.

Ланцюг має N кісток (сегментів) з довжинами L₁, L₂, …, Lₙ. Перший суглоб (корінь) закріплений. Останній суглоб (кінцевий ефектор) має досягти цільової точки T.

Перевірка досяжності:
Сума довжин кісток: L_total = Σ Lᵢ
Відстань до цілі: d = |T − root|

Досяжно, якщо d ≤ L_total
Якщо недосяжно (d > L_total): вирівняти всі кістки до цілі (повне розгинання)

Класичні розв'язувачі IK (транспонований якобіан, псевдообернена матриця) потребують матричної математики і можуть бути чисельно нестабільними. FABRIK повністю уникає цього завдяки суто геометричному підходу.

2. Алгоритм FABRIK

FABRIK чергує два проходи до збіжності:

1Ініціалізація: joints[0] = root, joints[N] = target (старт без обмежень)
2Зворотний прохід (кінець → корінь): перемістіть joint[N] до цілі. Потім перемістіть кожен суглоб до попереднього, зберігаючи довжину кістки.
3Прямий прохід (корінь → кінець): поверніть joints[0] до початкового кореня. Потім перемістіть кожен суглоб до наступного, зберігаючи довжину кістки.
4Перевірте збіжність: якщо |joints[N] − target| < tolerance, зупиніться. Інакше повторіть з кроку 2.
Зворотний прохід для суглоба i (від i=N-1 до i=0):
direction = normalize(joints[i] − joints[i+1])
joints[i] = joints[i+1] + direction × L[i]

Прямий прохід для суглоба i (від i=1 до i=N):
direction = normalize(joints[i] − joints[i-1])
joints[i] = joints[i-1] + direction × L[i-1]

Зазвичай збігається за 1–10 ітерацій до похибки < 1px.
Чому це працює: кожен прохід незалежно задовольняє обмеження довжини кістки з одного кінця. Виконання обох проходів разом еквівалентне релаксації Гаусса-Зейделя системи обмежень. На відміну від градієнтного спуску, він не «перелітає» ціль — кожен крок переміщує суглоб точно так, щоб задовольнити його обмеження.

3. Допоміжні функції для 2D-векторів

// Мінімальний клас Vec2 для IK
class Vec2 {
  constructor(x = 0, y = 0) { this.x = x; this.y = y; }

  clone()           { return new Vec2(this.x, this.y); }
  add(v)            { return new Vec2(this.x + v.x, this.y + v.y); }
  sub(v)            { return new Vec2(this.x - v.x, this.y - v.y); }
  scale(s)          { return new Vec2(this.x * s, this.y * s); }
  length()          { return Math.sqrt(this.x*this.x + this.y*this.y); }
  distTo(v)         { return this.sub(v).length(); }
  normalize()       {
    const l = this.length();
    return l > 1e-8 ? new Vec2(this.x/l, this.y/l) : new Vec2(0, 0);
  }
  angle()           { return Math.atan2(this.y, this.x); }
}

4. Клас IK-ланцюга

class IKChain {
  /**
   * @param {Vec2} rootPos  — точка кріплення (не рухається)
   * @param {number[]} lengths — довжини кісток [L0, L1, ..., L_{N-1}]
   */
  constructor(rootPos, lengths) {
    this.root    = rootPos.clone();
    this.lengths = lengths;
    const N      = lengths.length + 1;  // N+1 суглобів для N кісток
    // Ініціалізуємо суглоби в прямій горизонтальній лінії
    this.joints  = Array.from({length: N}, (_, i) => {
      const offset = lengths.slice(0, i).reduce((a, b) => a + b, 0);
      return new Vec2(rootPos.x + offset, rootPos.y);
    });
  }

  solve(target, maxIter = 10, tolerance = 0.5) {
    const N    = this.joints.length - 1; // кількість кісток
    const dist = this.joints[0].distTo(target);
    const totalLen = this.lengths.reduce((a, b) => a + b, 0);

    // Недосяжно: повністю розгинаємося до цілі
    if (dist >= totalLen) {
      const dir = target.sub(this.joints[0]).normalize();
      for (let i = 1; i <= N; i++) {
        this.joints[i] = this.joints[i-1].add(dir.scale(this.lengths[i-1]));
      }
      return;
    }

    for (let iter = 0; iter < maxIter; iter++) {
      // --- Зворотний прохід: тягнемо кінець до цілі ---
      this.joints[N] = target.clone();
      for (let i = N - 1; i >= 0; i--) {
        const dir = this.joints[i].sub(this.joints[i+1]).normalize();
        this.joints[i] = this.joints[i+1].add(dir.scale(this.lengths[i]));
      }

      // --- Прямий прохід: фіксуємо корінь ---
      this.joints[0] = this.root.clone();
      for (let i = 1; i <= N; i++) {
        const dir = this.joints[i].sub(this.joints[i-1]).normalize();
        this.joints[i] = this.joints[i-1].add(dir.scale(this.lengths[i-1]));
      }

      // --- Перевірка збіжності ---
      if (this.joints[N].distTo(target) < tolerance) break;
    }
  }
}

5. Додавання кутових обмежень

Без обмежень кістки можуть згинатися в будь-якому напрямку — корисно для щупалець, але не для ніг. Найпростіше обмеження: обмежити кут кожної кістки відносно її батьківської.

// Після зворотного проходу застосовуємо кутові обмеження до кожного суглоба
// minAngle/maxAngle: дозволений діапазон відносно напрямку батьківської кістки
function constrainAngle(joint, parent, grandparent, minAngle, maxAngle) {
  // Напрямок батьківської кістки
  const parentDir = parent.sub(grandparent);
  const parentAngle = parentDir.angle();
  // Напрямок поточної кістки
  const currentAngle = joint.sub(parent).angle();
  // Кут відносно батьківської
  let rel = currentAngle - parentAngle;
  // Зводимо до [-π, π]
  while (rel >  Math.PI) rel -= 2 * Math.PI;
  while (rel < -Math.PI) rel += 2 * Math.PI;
  // Обмежуємо
  const clamped = Math.max(minAngle, Math.min(maxAngle, rel));
  const finalAngle = parentAngle + clamped;
  const bone = joint.sub(parent);
  const len  = bone.length();
  return new Vec2(
    parent.x + Math.cos(finalAngle) * len,
    parent.y + Math.sin(finalAngle) * len
  );
}
Якість обмежень: просте обмеження кута добре працює для 2D. Для 3D (обмеження «конус + скручування») потрібно розкласти відносне обертання на складові відхилення (swing) і скручування (twist) та обмежувати їх окремо. Це обробляють бібліотеки на кшталт IKSolver від THREE.js чи Kalidokit.

6. Кілька ланцюгів: лапки павука

class SpiderBody {
  constructor(pos, numLegs = 8) {
    this.pos  = pos.clone();
    this.legs = [];
    this.targets = [];
    this.restPositions = [];

    for (let i = 0; i < numLegs; i++) {
      const angle = (i / numLegs) * Math.PI * 2;
      const rest = this.pos.add(new Vec2(
        Math.cos(angle) * 120, Math.sin(angle) * 60
      ));
      this.restPositions.push(rest);
      this.targets.push(rest.clone());
      const chain = new IKChain(this.pos.clone(), [50, 45, 35]);
      chain.solve(rest);
      this.legs.push(chain);
    }
  }

  update(bodyPos, dt) {
    this.pos = bodyPos.clone();
    for (let i = 0; i < this.legs.length; i++) {
      const restWorld = bodyPos.add(this.restPositions[i].sub(this.pos));
      // Переставляємо ногу, якщо вона надто далеко від позиції спокою
      const dist = this.targets[i].distTo(restWorld);
      if (dist > 60) {
        const opposite = (i + this.legs.length/2 | 0) % this.legs.length;
        // Чергуємо: спершу переставляємо парні ноги, потім непарні
        if (this.legs[opposite].isMoving === undefined ||
            !this.legs[opposite].isMoving) {
          this.targets[i] = restWorld.clone();
        }
      }
      // Оновлюємо корінь ланцюга і розв'язуємо заново
      this.legs[i].root = this.pos.clone();
      this.legs[i].solve(this.targets[i]);
    }
  }
}

7. Демо на canvas

// Цикл рендерингу canvas
const canvas = document.getElementById('ik-demo');
const ctx    = canvas.getContext('2d');
let mouse    = new Vec2(400, 200);
canvas.addEventListener('mousemove', e => {
  const r = canvas.getBoundingClientRect();
  mouse = new Vec2(e.clientX - r.left, e.clientY - r.top);
});

const chain = new IKChain(new Vec2(80, 170), [80, 70, 60, 50]);

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  chain.solve(mouse);

  // Малюємо кістки
  ctx.strokeStyle = '#fbbf24';
  ctx.lineWidth  = 4;
  ctx.lineCap    = 'round';
  for (let i = 0; i < chain.joints.length - 1; i++) {
    ctx.beginPath();
    ctx.moveTo(chain.joints[i].x, chain.joints[i].y);
    ctx.lineTo(chain.joints[i+1].x, chain.joints[i+1].y);
    ctx.stroke();
  }

  // Малюємо суглоби
  ctx.fillStyle = '#fff';
  for (const j of chain.joints) {
    ctx.beginPath();
    ctx.arc(j.x, j.y, 5, 0, Math.PI * 2);
    ctx.fill();
  }

  // Малюємо ціль
  ctx.strokeStyle = '#f87171';
  ctx.lineWidth   = 2;
  ctx.beginPath();
  ctx.arc(mouse.x, mouse.y, 8, 0, Math.PI * 2);
  ctx.stroke();

  requestAnimationFrame(draw);
}
draw();

8. Розширення до 3D

FABRIK природно розширюється до 3D — замініть Vec2 на Vec3 і використовуйте арифметику Vector3. Алгоритм ідентичний; змінюються лише операції над векторами.

class IKChain3D {
  constructor(root, lengths) {
    this.root    = root.clone();
    this.lengths = lengths;
    const N = lengths.length + 1;
    this.joints = Array.from({length: N}, (_, i) => {
      const offset = lengths.slice(0, i).reduce((a, b) => a + b, 0);
      return root.clone().addScaledVector(new THREE.Vector3(1, 0, 0), offset);
    });
  }

  solve(target, maxIter = 10, tol = 0.01) {
    const N = this.joints.length - 1;
    const totalLen = this.lengths.reduce((a, b) => a + b, 0);
    if (this.joints[0].distanceTo(target) >= totalLen) {
      const dir = target.clone().sub(this.joints[0]).normalize();
      for (let i = 1; i <= N; i++)
        this.joints[i] = this.joints[i-1].clone().addScaledVector(dir, this.lengths[i-1]);
      return;
    }
    for (let it = 0; it < maxIter; it++) {
      this.joints[N].copy(target);
      for (let i = N-1; i >= 0; i--) {
        const dir = this.joints[i].clone().sub(this.joints[i+1]).normalize();
        this.joints[i] = this.joints[i+1].clone().addScaledVector(dir, this.lengths[i]);
      }
      this.joints[0].copy(this.root);
      for (let i = 1; i <= N; i++) {
        const dir = this.joints[i].clone().sub(this.joints[i-1]).normalize();
        this.joints[i] = this.joints[i-1].clone().addScaledVector(dir, this.lengths[i-1]);
      }
      if (this.joints[N].distanceTo(target) < tol) break;
    }
  }
}
Інтеграція з Three.js: щойно ви маєте позиції суглобів у світовому просторі з IKChain3D, оновіть свої об'єкти Bone у Three.js: для кожного i встановіть bones[i].position у локальну позицію (перетворіть через обернену світову матрицю батька). SkeletonHelper з Three.js може візуалізувати результат.