Процедурна анімація з інверсною кінематикою 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 чергує два проходи до збіжності:
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
);
}
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;
}
}
}
Bone у Three.js: для кожного i
встановіть bones[i].position у локальну позицію
(перетворіть через обернену світову матрицю батька). SkeletonHelper з
Three.js може візуалізувати результат.