Статьи

Создать механическую змею с обратной кинематикой

Представьте цепочку частиц, анимированных в симфонии вместе: поезд движется, когда все присоединенные отсеки следуют его примеру; кукольный танец, когда его хозяин тянет за струну; даже твои руки, когда твои родители держат тебя за руки, когда ведут тебя на вечернюю прогулку. Движение колеблется вниз от последнего узла к началу координат, соблюдая при этом ограничения. Это обратная кинематика (IK), математический алгоритм, который вычисляет необходимые движения. Здесь мы будем использовать его для создания змеи, немного более продвинутой, чем та, что есть в играх Nokia.


Давайте посмотрим на конечный результат, к которому мы будем стремиться. Нажмите и удерживайте клавиши ВВЕРХ, ВЛЕВО и ВПРАВО, чтобы заставить его двигаться.


Цепочка построена из узлов. Каждый узел представляет собой точку в цепочке, где может происходить перемещение и вращение. В цепочке IK движение пульсирует в обратном направлении от последнего узла (последнего дочернего элемента) к первому узлу (корневому узлу), в отличие от прямой кинематики (FK), где кинематика перемещается от корневого узла к последнему дочернему элементу.

Все цепочки начинаются с корневого узла. Этот корневой узел является действующим родителем, к которому присоединен новый дочерний узел. В свою очередь, этот первый дочерний элемент будет родительским для второго дочернего элемента в цепочке, и это повторяется до тех пор, пока не будет добавлен последний дочерний элемент. Анимация ниже изображает такие отношения.


Класс IKshape реализует понятие узла в нашей цепочке. Экземпляры класса IKshape запоминают свои родительские и дочерние узлы, за исключением корневого узла, у которого нет родительского узла, и последнего узла, у которого нет дочернего узла. Ниже приведены частные свойства IKshape.

1
2
3
private var childNode:IKshape;
private var parentNode:IKshape;
private var vec2Parent:Vector2D;

Средства доступа к этим свойствам показаны ниже:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
public function set IKchild(childSprite:IKshape):void
{
    childNode = childSprite;
}
 
public function get IKchild ():IKshape
{
    return childNode
}
 
public function set IKparent(parentSprite:IKshape):void
{
    parentNode = parentSprite;
}
 
public function get IKparent():IKshape
{
    return parentNode;
}

Вы можете заметить, что этот класс хранит Vector2D, который указывает от дочернего узла к родительскому узлу. Обоснование этого направления связано с движением от ребенка к родителю. Vector2D используется потому, что величина и направление вектора, указывающего от дочернего элемента к родительскому, будут часто изменяться при реализации поведения цепочки IK. Таким образом, отслеживание таких данных необходимо. Ниже приведены методы манипуляции векторными величинами для IKshape.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
public function calcVec2Parent():void
{
    var xlength:Number = parentNode.x — this.x;
    var ylength:Number = parentNode.y — this.y;
 
    vec2Parent = new Vector2D(xlength, ylength);
}
 
public function setVec2Parent(vec:Vector2D):void
{
    vec2Parent = vec.duplicate();
}
 
public function getVec2Parent():Vector2D
{
    return vec2Parent.duplicate();
}
 
public function getAng2Parent():Number
{
    return vec2Parent.getAngle();
}

И последнее, но не менее важное: нам нужен метод для рисования нашей фигуры. Мы нарисуем прямоугольник для представления каждого узла. Тем не менее, любые другие предпочтения могут быть установлены путем переопределения метода draw здесь. Iv включил пример класса, переопределяющего метод рисования по умолчанию, класс Ball. (Быстрое переключение между фигурами будет продемонстрировано в конце этого урока.) На этом мы завершаем создание класса Ikshape.

1
2
3
4
5
6
7
8
9
protected function draw():void
{
    var col:Number = 0x00FF00;
    var w:Number = 50;
    var h:Number = 10;
    graphics.beginFill(col);
    graphics.drawRect(-w/2, -h/2, w, h);
    graphics.endFill();
}

Класс IKine реализует поведение цепочки IK. Объяснение относительно этого класса следует за этим порядком

  1. Введение в частные переменные в этом классе.
  2. Основные методы, используемые в этом классе.
  3. Математическое объяснение работы конкретных функций.
  4. Реализация этих конкретных функций.

Код ниже показывает закрытые переменные класса IKine.

1
2
3
4
5
6
private var IKineChain:Vector.<IKshape>;
 
//Data structure for constraints
private var constraintDistance:Vector.<Number>;
private var constraintRangeStart:Vector.<Number>;
private var constraintRangeEnd:Vector.<Number>;

Цепочка IKine будет хранить тип данных Sprite, который запоминает отношения своего родителя и потомка. Эти спрайты являются экземплярами IKshape. Результирующая цепочка видит корневой узел с индексом 0, следующий дочерний элемент с индексом 1, … до последнего дочернего элемента в последовательном порядке. Однако построение цепочки не от корня до последнего ребенка; это от последнего ребенка к корню.

Предполагая, что цепь имеет длину n, конструкция следует следующей последовательности: n-й узел, (n-1) -й узел, (n-2) -й узел … 0-й узел. Анимация ниже изображает эту последовательность.

После создания цепочки IK последний узел вставляется. Родительские узлы будут добавлены позже. Последний добавленный узел — это корень. Код ниже — это методы построения цепочки IK, добавления и удаления узлов в цепочке.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public function IKine (lastChild:IKshape, distance:Number)
{
    //initiate all private variables
    IKineChain = new Vector.<IKshape>();
    constraintDistance = new Vector.<Number>();
    constraintRangeStart = new Vector.<Number>();
    constraintRangeEnd = new Vector.<Number>();
 
    //Set constraints
    this.IKineChain[0] = lastChild;
    this.constraintDistance[0] = distance;
    this.constraintRangeStart[0] = 0;
    this.constraintRangeEnd[0] = 0;
}
 
/*Methods to manipulate IK chain
*/
public function appendNode(nodeNext:IKshape, distance:Number = 60, angleStart:Number = -1*Math.PI, angleEnd:Number = Math.PI):void
{
    this.IKineChain.unshift(nodeNext);
    this.constraintDistance.unshift(distance);
    this.constraintRangeStart.unshift(angleStart);
    this.constraintRangeEnd.unshift(angleEnd);
}
 
public function removeNode(node:Number):void
{
    this.IKineChain.splice(node, 1);
    this.constraintDistance.splice(node, 1);
    this.constraintRangeStart.splice(node, 1);
    this.constraintRangeEnd.splice(node, 1);
}

Эти следующие методы используются для извлечения узлов из цепочки всякий раз, когда есть необходимость.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
public function getRootNode():IKshape
{
    return this.IKineChain[0];
}
 
public function getLastNode():IKshape
{
    return this.IKineChain[IKineChain.length — 1];
}
 
public function getNode(node:Number):IKshape
{
    return this.IKineChain[node];
}

Мы видели, как цепочка узлов представлена ​​в массиве: корневой узел с индексом 0, … (n-1) -й узел с индексом (n-2), n-й узел с индексом (n-1) ), n — длина цепи. Мы также можем удобно расположить наши ограничения в таком порядке. Ограничения бывают двух видов: расстояние между узлами и степень свободы изгиба между узлами .

Расстояние между узлами распознается как ограничение дочернего узла на его родителя. Для удобства ссылок мы можем сохранить это значение в виде массива constraintDistance с индексом, аналогичным индексу дочернего узла. Обратите внимание, что корневой узел не имеет родителя. Однако ограничение расстояния должно быть зарегистрировано после добавления корневого узла, чтобы, если цепь была расширена позже, вновь добавленный «родительский элемент» этого корневого узла мог использовать свои данные.

Далее угол изгиба для родительского узла ограничен диапазоном. Мы будем хранить начальную и конечную точки для диапазона в массиве constraintRangeStart и ConstraintRangeEnd . На рисунке ниже показан дочерний узел в зеленом и два родительских узла в синем. Разрешен только узел с отметкой «ОК», поскольку он находится в пределах ограничения по углу. Мы можем использовать аналогичный подход при ссылках на значения в этих массивах. Еще раз обратите внимание, что угловые ограничения корневого узла должны быть зарегистрированы, даже если они не используются из-за аналогичного обоснования, как и в предыдущем. Кроме того, угловые ограничения не относятся к последнему дочернему элементу, потому что мы хотим гибкости в управлении.

Все ограничения нарисованы на этой диаграмме.

Следующие методы могут оказаться полезными, когда вы инициировали ограничения на узле, но хотели бы изменить значение в будущем.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/*Manipulating corresponding constraints
*/
public function getDistance(node:Number):Number
{
    return this.constraintDistance[node];
}
 
public function setDistance(newDistance:Number, node:Number):void
{
    this.constraintDistance[node] = newDistance;
}
 
public function getAngleStart(node:Number):Number
{
    return this.constraintRangeStart[node];
}
 
public function setAngleStart(newAngleStart:Number, node:Number):void
{
    this.constraintRangeStart[node] = newAngleStart;
}
 
public function getAngleRange(node:Number):Number
{
    return this.constraintRangeEnd[node];
}
 
public function setAngleRange(newAngleRange:Number, node:Number):void
{
    this.constraintRangeEnd[node] = newAngleRange;
}

Следующая анимация показывает расчет ограничения длины.


На этом шаге мы рассмотрим команды в методе, которые помогают ограничить расстояние между узлами. Обратите внимание на выделенные строки. Вы можете заметить, что только последний ребенок применяет это ограничение. Ну, что касается команды, это правда. Родительские узлы обязаны выполнять не только длину, но и угловые ограничения. Все это обрабатывается с помощью реализации метода vecWithinRange () . Последний ребенок не должен быть ограничен в угле, потому что нам нужна максимальная гибкость изгиба.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private function updateParentPosition():void
{
    for (var i:uint = IKineChain.length — 1; i > 0; i—)
    {
        IKineChain[i].calcVec2Parent();
        var vec:Vector2D;
 
        //handling the last child
        if ( i == IKineChain.length — 1)
        {
            var ang:Number = IKineChain[i].getAng2Parent();
            vec = new Vector2D(0, 0);
            vec.redefine(this.constraintDistance[IKineChain.length — 1], ang);
        }
        else
        {
            vec = this.vecWithinRange(i);
        }
 
        IKineChain[i].setVec2Parent(vec);
        IKineChain[i].IKparent.x = IKineChain[i].x + IKineChain[i].getVec2Parent().x;
        IKineChain[i].IKparent.y = IKineChain[i].y + IKineChain[i].getVec2Parent().y;
    }
}

Сначала мы вычисляем текущий угол, зажатый между двумя векторами, vec1 и vec2. Если угол не находится в ограниченном диапазоне, присвойте ему минимальный или максимальный предел. Как только угол определен, мы можем вычислить вектор, который поворачивается от vec1 вместе с ограничением расстояния (величины).

Угол зажат между двумя векторами.

Следующая анимация предлагает другую альтернативу визуализации идеи.


Реализация угловых ограничений, как показано ниже.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
private function vecWithinRange(currentNode:Number):Vector2D
{
    //getting the appropriate vectors
    var child2Me:Vector2D = IKineChain[currentNode].IKchild.getVec2Parent();
    var me2Parent:Vector2D = IKineChain[currentNode].getVec2Parent();
 
    //Implement angle bounds limitation
    var currentAng:Number = child2Me.angleBetween(me2Parent);
    var currentStart:Number = this.constraintRangeStart[currentNode];
    var currentEnd:Number = this.constraintRangeEnd[currentNode];
    var limitedAng:Number = Math2.implementBound(currentStart, currentEnd, currentAng);
 
    //Implement distance limitation
    child2Me.setMagnitude(this.constraintDistance[currentNode]);
    child2Me.rotate(limitedAng);
 
    return child2Me
}

Возможно, здесь стоит рассмотреть идею получения угла, который интерпретирует направление по часовой стрелке и против часовой стрелки. Угол, заключенный между двумя векторами, скажем, vec1 и vec2, может быть легко получен из точечного произведения этих двух векторов. На выходе будет кратчайший угол поворота vec1 к vec2. Тем не менее, нет понятия направления, так как ответ всегда положительный. Поэтому необходимо внести изменения в обычный вывод. Перед выводом угла я использовал векторное произведение между vec1 и vec2, чтобы определить, является ли текущая последовательность положительным или отрицательным вращением, и включил знак в угол. Я выделил функцию направления в строках кода ниже.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
public function vectorProduct(vec2:Vector2D):Number
{
    return this.vec_x * vec2.y — this.vec_y * vec2.x;
}
 
public function angleBetween(vec2:Vector2D):Number
{
    var angle:Number = Math.acos(this.normalise().dotProduct(vec2.normalise()));
 
    var vec1:Vector2D = this.duplicate();
    if (vec1.vectorProduct(vec2) < 0)
    {
        angle *= -1;
    }
 
    return angle;
}

Узлы, которые являются блоками, должны быть ориентированы по направлению их векторов, чтобы они выглядели красиво. В противном случае вы увидите цепочку, как показано ниже. (Используйте клавиши со стрелками для перемещения.)

Функция ниже реализует правильную ориентацию узлов.

1
2
3
4
5
6
7
8
9
private function updateOrientation():void
{
    for (var i:uint = 0; i < IKineChain.length — 1; i++)
    {
        var orientation:Number = IKineChain[i].IKchild.getVec2Parent().getAngle();
 
        IKineChain[i].rotation = Math2.degreeOf(orientation);
    }
}

Теперь, когда все установлено, мы можем анимировать нашу цепочку, используя animate() . Это составная функция, выполняющая вызовы updateParentPosition() и updateOrientation(). Однако, прежде чем это может быть достигнуто, мы должны обновить отношения на всех узлах. Мы делаем вызов updateRelationships() . Опять же, updateRelationships() — это составная функция, выполняющая вызовы defineParent() и defineChild() . Это делается один раз и всякий раз, когда происходит изменение в структуре цепочки, например, узлы добавляются или удаляются во время выполнения.


Чтобы заставить класс IKine работать на вас, вот несколько методов, которые вы должны изучить. Я задокументировал их в виде таблицы.

метод Входные параметры Роль
IKine () lastChild: IKshape, расстояние: Number Конструктор.
appendNode () nodeNext: IKshape, [distance: Number, angleStart: Number, angleEnd: Number] добавить узлы в цепочку, определить ограничения, реализуемые узлом.
updateRelationships () Никто Обновите родительско-дочерние отношения для всех узлов.
одушевленные () Никто Пересчитывает положение всех узлов в цепочке. Должен называться каждый кадр.

Обратите внимание, что угловые значения указаны в радианах, а не в градусах


Теперь давайте создадим проект в FlashDevelop. В папке src вы увидите Main.as. Это последовательность задач, которые вы должны сделать:

  1. Инициируйте копии IKshape или классов, которые выходят из IKshape на сцене.
  2. Запустите IKine и используйте его, чтобы связать копии IKshape на сцене.
  3. Обновите отношения на всех узлах в цепочке.
  4. Реализуйте пользовательские элементы управления.
  5. Animate!

Объект рисуется, как мы строим IKshape. Это сделано в цикле. Обратите внимание, что если вы хотите изменить внешний вид чертежа на кружок, включите комментарий в строке 56 и отключите комментарий в строке 57. (Чтобы это работало, вам нужно загрузить мои исходные файлы).

01
02
03
04
05
06
07
08
09
10
private function drawObjects():void
{
    for (var i:uint = 0; i < totalNodes; i++) {
 
    var currentObj:IKshape = new IKshape();
    //var currentObj:Ball = new Ball();
    currentObj.name = &quot;b&quot;
    addChild(currentObj);
    }
}

Перед инициализацией класса IKine для создания цепочки создаются приватные переменные Main.as.

1
2
3
private var currentChain:IKine;
private var lastNode:IKshape;
private var totalNodes:uint = 10;

В данном случае все узлы ограничены расстоянием 40 между узлами.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
private function initChain():void
{
    this.lastNode = this.getChildByName(&quot;b&quot; + (totalNodes — 1)) as IKshape;
    currentChain = new IKine(lastNode, 40);
 
    for (var i:uint = 2; i <= totalNodes; i++)
    {
        currentChain.appendNode(this.getChildByName(&quot;b&quot; + (totalNodes — i)) as IKshape, 40, Math2.radianOf(-30), Math2.radianOf(30));
    }
    currentChain.updateRelationships();
     
    //center snake on the stage.
    currentChain.getLastNode().x = stage.stageWidth / 2;
    currentChain.getLastNode().y = stage.stageHeight /2
}

Далее мы объявляем переменные, которые будут использоваться нашим управлением с клавиатуры.

01
02
03
04
05
06
07
08
09
10
11
12
private var leadingVec:Vector2D;
private var currentMagnitude:Number = 0;
private var currentAngle:Number = 0;
 
private var increaseAng:Number = 5;
private var increaseMag:Number = 1;
private var decreaseMag:Number = 0.8;
private var capMag:Number = 10;
 
private var pressedUp:Boolean = false;
private var pressedLeft:Boolean = false;
private var pressedRight:Boolean = false;

Прикрепите на сцену основной цикл и клавишные слушатели. Я выделил их.

01
02
03
04
05
06
07
08
09
10
11
12
13
private function init(e:Event = null):void
{
    removeEventListener(Event.ADDED_TO_STAGE, init);
    // entry point
 
    this.drawObjects();
    this.initChain();
    leadingVec = new Vector2D(0, 0);
 
    stage.addEventListener(Event.ENTER_FRAME, handleEnterFrame);
    stage.addEventListener(KeyboardEvent.KEY_DOWN, handleKeyDown);
    stage.addEventListener(KeyboardEvent.KEY_UP, handleKeyUp);
}

Напишите слушателям.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
private function handleEnterFrame(e:Event):void
{
    if (pressedUp == true)
    {
        currentMagnitude += increaseMag;
        currentMagnitude = Math.min(currentMagnitude, capMag);
    }
    else
    {
        currentMagnitude *= decreaseMag;
    }
 
    if (pressedLeft == true)
    {
        currentAngle -= Math2.radianOf(increaseAng);
    }
 
    if (pressedRight == true)
    {
    currentAngle += Math2.radianOf(increaseAng);
    }
 
    leadingVec.redefine(currentMagnitude, currentAngle);
    var futureX:Number = leadingVec.x + lastNode.x;
    var futureY:Number = leadingVec.y + lastNode.y;
 
    futureX = Math2.implementBound(0, stage.stageWidth, futureX);
    futureY = Math2.implementBound(0, stage.stageHeight, futureY);
 
    lastNode.x = futureX;
    lastNode.y = futureY;
    lastNode.rotation = Math2.degreeOf(leadingVec.getAngle());
 
    currentChain.animate();
}
 
private function handleKeyDown(e:KeyboardEvent):void
{
    if (e.keyCode == Keyboard.UP)
    {
        pressedUp = true;
    }
 
    if (e.keyCode == Keyboard.LEFT)
    {
        pressedLeft = true;
    }
    else if (e.keyCode == Keyboard.RIGHT)
    {
        pressedRight = true;
    }
}
 
private function handleKeyUp(e:KeyboardEvent):void
{
    if (e.keyCode == Keyboard.UP)
    {
        pressedUp = false;
    }
     
    if (e.keyCode == Keyboard.LEFT)
    {
        pressedLeft = false;
    }
    else if (e.keyCode == Keyboard.RIGHT)
    {
        pressedRight = false;
    }
}

Обратите внимание, что я использовал экземпляр Vector2D, чтобы вести змею по сцене. Я также ограничил этот вектор границами сцены, чтобы он не двигался наружу. ActionScript, выполняющий это ограничение, выделен.


Нажмите Ctrl + Enter, чтобы увидеть анимацию вашей змеи! Управляйте его движением с помощью клавиш со стрелками.


Этот учебник требует некоторых знаний в векторном анализе. Для читателей, которые хотели бы познакомиться с векторами, прочитайте статью Даниэля Сидхона . Надеюсь, что это поможет вам в понимании и реализации обратной кинематики. Спасибо за прочитанное. Оставляйте предложения и комментарии, так как я всегда готов услышать от зрителей. Спасибо.