26.10.2026 18 мин чтения Просмотров:1

Верлет-интеграция в Unity: физика верёвки своими руками

Пишем верёвку на интеграции Верле с нуля: точки и звенья, итеративная релаксация, фиксированный шаг с интерполяцией, коллизии через ComputePenetration, трение, груз и разрыв.

Верлет-интеграция в Unity: физика верёвки своими руками

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

Зачем своя физика, если есть Rigidbody и джойнты

Классический способ сделать верёвку в Unity — цепочка Rigidbody, соединённых CharacterJoint или ConfigurableJoint. Он работает, но у него есть известные болячки: звенья растягиваются под тяжёлым грузом, цепочка «взрывается» при резком рывке якоря, каждое звено — полноценный объект физического движка со своим коллайдером, а настройка десятка параметров джойнта превращается в шаманство.

Верлет-симуляция устроена иначе. Мы храним только позиции точек, не храним скорости явно, а длину звеньев не «просим» силами, а жёстко восстанавливаем, двигая точки. Получается:

  • стабильно — верёвка не разлетается даже при огромных рывках, худшее, что может случиться, это лёгкая растяжка;
  • дёшево — сотня точек считается за доли миллисекунды, никаких GameObject на звено;
  • предсказуемо — три-четыре параметра, смысл каждого понятен;
  • расширяемо — коллизии, разрыв, груз на конце и закреплённые точки добавляются несколькими строками.

Цена — это не «настоящая» физика: верёвка не толкает Rigidbody сама по себе, а массы и трение приближённые. Для визуальных верёвок, проводов, лиан, цепей на мостах и тросов крюка-кошки этого с запасом хватает.

Интеграция Верле в одной формуле

Обычная (явная) схема Эйлера хранит позицию и скорость и на каждом шаге делает так:

velocity += acceleration * dt;
position += velocity * dt;

У Верле скорость не хранится. Вместо неё запоминается позиция на прошлом шаге, а скорость выводится из разницы:

Vector3 velocity = position - previousPosition;   // смещение за прошлый шаг
previousPosition = position;
position += velocity + acceleration * dt * dt;

Главное преимущество не в экономии одного вектора. Раз скорость неявная, мы можем свободно двигать точку куда угодно — например, вытолкнуть её из стены или подтянуть к соседу — и симуляция сама «поймёт», что точка получила скорость в эту сторону. Не нужно пересчитывать импульсы, корректировать скорость после столкновения и бороться с рассинхроном позиции и скорости. Именно поэтому на Верле так удобно накладывать ограничения: решатель просто передвигает точки, а физика подстраивается.

Схема такого решателя описана в известной статье Томаса Якобсена об Advanced Character Physics (физика персонажей Hitman: Codename 47) и с тех пор кочует по движкам почти без изменений. Шаг симуляции выглядит так:

  1. Интегрировать все точки: инерция плюс гравитация.
  2. Несколько раз подряд пройти по всем ограничениям (длина звеньев, закрепления, коллизии), каждый раз поправляя позиции.

Чем больше итераций во втором пункте, тем жёстче верёвка. Это единственный по-настоящему важный параметр качества.

Данные: точки и звенья

Для верёвки звенья всегда соединяют соседние точки, поэтому хватит трёх параллельных массивов. Массив структур тоже подходит, но параллельные массивы проще потом переложить в NativeArray для Burst.

using UnityEngine;

[RequireComponent(typeof(LineRenderer))]
public class VerletRope : MonoBehaviour
{
    [Header("Якоря")]
    [SerializeField] Transform startAnchor;
    [SerializeField] Transform endAnchor;              // можно оставить пустым

    [Header("Форма")]
    [SerializeField, Min(2)] int pointCount = 30;
    [SerializeField, Min(0.01f)] float segmentLength = 0.15f;

    [Header("Симуляция")]
    [SerializeField, Range(1, 60)] int iterations = 20;
    [SerializeField, Range(0.9f, 1f)] float damping = 0.995f;
    [SerializeField] Vector3 gravity = new Vector3(0f, -9.81f, 0f);
    [SerializeField, Min(30f)] float stepRate = 120f;   // шагов в секунду
    [SerializeField, Min(1)] int maxStepsPerFrame = 4;

    Vector3[] positions;
    Vector3[] previous;
    float[] inverseMass;        // 0 = точка закреплена
    Vector3[] renderBuffer;

    LineRenderer line;
    float accumulator;

    void Awake()
    {
        line = GetComponent<LineRenderer>();
        Build();
    }

    void Build()
    {
        positions = new Vector3[pointCount];
        previous = new Vector3[pointCount];
        inverseMass = new float[pointCount];
        renderBuffer = new Vector3[pointCount];

        Vector3 origin = startAnchor != null ? startAnchor.position : transform.position;
        for (int i = 0; i < pointCount; i++)
        {
            positions[i] = origin + Vector3.down * (segmentLength * i);
            previous[i] = positions[i];
            inverseMass[i] = 1f;
        }

        if (startAnchor != null) inverseMass[0] = 0f;
        if (endAnchor != null) inverseMass[pointCount - 1] = 0f;

        line.positionCount = pointCount;
        line.useWorldSpace = true;
    }
}

Вместо массы храним обратную массу. Это стандартный приём: закреплённая точка получает inverseMass = 0, то есть «бесконечную массу», и решатель её просто не двигает, без единого лишнего if в формуле. Тяжёлый груз на конце верёвки — это точка с маленькой обратной массой, скажем 0.05.

Начальную форму задаём прямой линией вниз от якоря. Если оба якоря заданы, можно сразу разложить точки между ними — первые кадры симуляция всё равно приведёт верёвку в равновесие.

Шаг интеграции

void Integrate(float dt)
{
    Vector3 gravityStep = gravity * (dt * dt);

    for (int i = 0; i < pointCount; i++)
    {
        if (inverseMass[i] == 0f)
        {
            previous[i] = positions[i];
            continue;
        }

        Vector3 velocity = (positions[i] - previous[i]) * damping;
        previous[i] = positions[i];
        positions[i] += velocity + gravityStep;
    }
}

damping — это сопротивление воздуха в самом примитивном виде: каждый шаг теряем полпроцента скорости. Без него верёвка будет качаться почти вечно. Обратите внимание, что затухание применяется за шаг, а не за секунду, поэтому его величина зависит от частоты симуляции. Это одна из причин, по которой шаг должен быть фиксированным (об этом ниже).

Для закреплённых точек мы обновляем previous, чтобы в нём всегда лежала позиция начала шага. Это пригодится для интерполяции при отрисовке.

Ограничение длины: релаксация

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

void SolveDistance(int a, int b)
{
    float wa = inverseMass[a];
    float wb = inverseMass[b];
    float wSum = wa + wb;
    if (wSum == 0f) return;                       // обе точки закреплены

    Vector3 delta = positions[b] - positions[a];
    float distance = delta.magnitude;
    if (distance < 1e-6f) return;

    float error = (distance - segmentLength) / distance;
    Vector3 correction = delta * (error / wSum);

    positions[a] += correction * wa;
    positions[b] -= correction * wb;
}

Проверим знаки. Если звено растянуто, error положителен, точка a смещается в сторону b, а b — в сторону a. Если сжато — наоборот. Когда обе точки свободны и весят одинаково, каждая проходит ровно половину пути, и после поправки звено имеет точную длину.

Почему одного прохода мало? Исправив звено 3–4, мы сдвинули точку 4 и сломали звено 4–5. Поэтому ограничения решаются итеративно: проходим по всем звеньям много раз, и ошибки постепенно «рассасываются» по цепочке. Это метод Гаусса — Зейделя в его самой простой форме.

void SolveConstraints()
{
    for (int iter = 0; iter < iterations; iter++)
    {
        PinAnchors();

        for (int i = 0; i < pointCount - 1; i++)
            SolveDistance(i, i + 1);
    }
}

void PinAnchors()
{
    if (startAnchor != null) positions[0] = startAnchor.position;
    if (endAnchor != null) positions[pointCount - 1] = endAnchor.position;
}

Сколько итераций нужно

Ошибка распространяется по цепочке примерно на одно звено за проход. Значит, для верёвки из 30 точек при 5 итерациях конец почти не «знает», что начало закреплено, и верёвка будет заметно тянуться, как резинка. Ориентиры из практики:

  • 10–15 итераций — мягкий канат, шнур, хвост;
  • 20–30 — обычная верёвка, которая почти не растягивается;
  • 40 и больше — цепь с тяжёлым грузом.

Если жёсткость важна, а итераций жалко, помогают три приёма. Первый — чередовать направление прохода: на чётных итерациях идти от начала к концу, на нечётных — от конца к началу. Ошибка перестаёт копиться на одном краю. Второй — повышать частоту шагов вместо числа итераций: два шага по 10 итераций обычно выглядят лучше, чем один на 20. Третий — жёсткий ограничитель длины в самом конце шага, о нём в разделе про груз.

for (int iter = 0; iter < iterations; iter++)
{
    PinAnchors();

    if ((iter & 1) == 0)
        for (int i = 0; i < pointCount - 1; i++) SolveDistance(i, i + 1);
    else
        for (int i = pointCount - 2; i >= 0; i--) SolveDistance(i, i + 1);
}

Фиксированный шаг и подшаги

Если вызывать симуляцию раз в кадр с Time.deltaTime, получим верёвку, которая по-разному ведёт себя на 30 и на 144 FPS: меняется затухание, меняется эффективная жёсткость (итераций на секунду становится больше или меньше), а при просадке кадра dt скачет, и в формуле Верле, где неявная скорость рассчитана для старого шага, появляется рывок.

Правильный вариант — собственный фиксированный шаг с аккумулятором. Можно, конечно, считать всё в FixedUpdate, но тогда частота верёвки привязана к Time.fixedDeltaTime всего проекта (по умолчанию 50 Гц, для верёвки маловато), а отрисовка без интерполяции будет дрожать.

void Update()
{
    float step = 1f / stepRate;
    accumulator += Time.deltaTime;

    // Защита от «спирали смерти» после долгого кадра
    float maxTime = step * maxStepsPerFrame;
    if (accumulator > maxTime) accumulator = maxTime;

    while (accumulator >= step)
    {
        Integrate(step);
        SolveConstraints();
        accumulator -= step;
    }

    Render(accumulator / step);
}

Ограничение maxStepsPerFrame важно. Без него после долгой загрузки или паузы в отладчике накопится секунда времени, симуляция попытается сделать 120 шагов за кадр, кадр станет ещё длиннее, и игра провалится в цикл, из которого не выберется. С ограничением верёвка в такой ситуации просто немного «замедлится».

Если игра ставит паузу через Time.timeScale = 0, Time.deltaTime станет нулём и верёвка замрёт вместе со всем миром. Если верёвка нужна в меню паузы, используйте Time.unscaledDeltaTime.

Отрисовка через LineRenderer с интерполяцией

Шаг симуляции и кадр не совпадают: при 120 шагах в секунду и 144 FPS в одних кадрах шаг будет, в других нет. Если рисовать positions напрямую, движение станет неровным. Лечится интерполяцией между состоянием до последнего шага и после него. И тут Верле делает нам подарок: позиция до шага уже лежит в previous.

void Render(float alpha)
{
    for (int i = 0; i < pointCount; i++)
        renderBuffer[i] = Vector3.LerpUnclamped(previous[i], positions[i], alpha);

    line.SetPositions(renderBuffer);
}

Строго говоря, это интерполяция с отставанием на один шаг: мы показываем момент между предыдущим и текущим состоянием. При 120 Гц это 8 миллисекунд, глаз этого не замечает, зато движение становится гладким на любой частоте кадров.

Пара замечаний про LineRenderer. Массив renderBuffer создан один раз, поэтому SetPositions не порождает мусора. Если нужна объёмная верёвка с освещением, LineRenderer заменяется процедурным мешем-трубкой: по тем же точкам строятся кольца вершин, ориентированные по касательной. Логика симуляции от этого не меняется ни на строчку.

Коллизии с объектами сцены

Верёвка, проходящая сквозь ящики, выглядит хуже, чем отсутствие верёвки. Будем считать каждую точку маленькой сферой радиуса pointRadius и выталкивать её из коллайдеров. Для этого у Unity есть связка из двух методов:

  • Physics.OverlapSphereNonAlloc — находит коллайдеры, пересекающие сферу, и пишет их в заранее выделенный массив без аллокаций;
  • Physics.ComputePenetration — для двух коллайдеров в заданных позах возвращает направление и глубину, на которую их нужно раздвинуть.

Второму методу нужен коллайдер с нашей стороны. Заведём для этого дочерний объект-«зонд» со SphereCollider. Позу коллайдеров ComputePenetration принимает аргументами, поэтому где физически стоит зонд, неважно. Важно другое: по документации оба коллайдера должны быть включены. Чтобы включённый зонд ни с чем не взаимодействовал, сделайте его триггером и положите на отдельный слой (например, RopeProbe), у которого в матрице столкновений (Project Settings → Physics) сняты все галочки. И не забудьте исключить этот слой из маски коллизий самой верёвки, иначе точки будут сталкиваться с собственным зондом.

[Header("Коллизии")]
[SerializeField] LayerMask collisionMask = ~0;
[SerializeField, Min(0.001f)] float pointRadius = 0.05f;
[SerializeField, Range(0f, 1f)] float friction = 0.3f;
[SerializeField] SphereCollider probe;

readonly Collider[] overlapBuffer = new Collider[8];

void SolveCollisions()
{
    probe.radius = pointRadius;
    probe.center = Vector3.zero;

    for (int i = 0; i < pointCount; i++)
    {
        if (inverseMass[i] == 0f) continue;

        int count = Physics.OverlapSphereNonAlloc(
            positions[i], pointRadius, overlapBuffer,
            collisionMask, QueryTriggerInteraction.Ignore);

        for (int h = 0; h < count; h++)
        {
            Collider other = overlapBuffer[h];
            if (other == probe) continue;

            if (Physics.ComputePenetration(
                    probe, positions[i], Quaternion.identity,
                    other, other.transform.position, other.transform.rotation,
                    out Vector3 direction, out float depth))
            {
                positions[i] += direction * depth;
                ApplyFriction(i, direction);
            }
        }
    }
}

У probe.center должен быть ноль: позиция, переданная в ComputePenetration, задаёт позу трансформа, и смещённый центр сместил бы и нашу виртуальную сферу. Масштаб объекта-зонда тоже оставьте единичным, радиус задаётся только полем radius.

Трение

После выталкивания точка лежит на поверхности, но её неявная скорость вдоль поверхности сохранилась, и верёвка будет скользить по ящику, как по льду. Трение в Верле делается в одну строчку: разложим смещение за шаг на нормальную и касательную составляющие и «съедим» часть касательной, подвинув previous.

void ApplyFriction(int i, Vector3 normal)
{
    Vector3 velocity = positions[i] - previous[i];
    Vector3 tangent = velocity - Vector3.Dot(velocity, normal) * normal;
    previous[i] += tangent * friction;
}

Сдвигая previous вдогонку за точкой, мы уменьшаем разницу между ними, то есть скорость. При friction = 1 касательная скорость обнуляется полностью, и верёвка «прилипает» к поверхности.

Где вызывать коллизии

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

for (int iter = 0; iter < iterations; iter++)
{
    PinAnchors();
    for (int i = 0; i < pointCount - 1; i++) SolveDistance(i, i + 1);

    if ((iter & 3) == 3 || iter == iterations - 1)
        SolveCollisions();
}

Туннелирование и толщина

Точки — это сферы, а не капсулы, поэтому тонкий угол ящика может проскочить между двумя соседними точками. Если это заметно, есть три решения: уменьшить segmentLength (больше точек), увеличить pointRadius до половины длины звена или поднять stepRate, чтобы точка за шаг не пролетала сквозь тонкую стену. На практике радиус, близкий к половине длины звена, закрывает большинство случаев.

У ComputePenetration есть особенность, о которой предупреждает документация: обратные стороны треугольников игнорируются, а Physics.queriesHitBackfaces не учитывается. Лучше всего связка работает с примитивными коллайдерами — Box, Sphere, Capsule — и с выпуклыми мешами.

Груз на конце и жёсткий ограничитель

Повесим на свободный конец тяжёлый фонарь. Достаточно уменьшить обратную массу последней точки:

[SerializeField, Min(0.01f)] float endMass = 20f;

// в Build(), если endAnchor не задан:
inverseMass[pointCount - 1] = 1f / endMass;

Теперь при релаксации последнее звено почти целиком сдвигает соседнюю точку, а не груз. Но тут вылезает главная слабость итеративного решателя: тяжёлый груз тянет цепочку, и при недостатке итераций верёвка растягивается сильнее, чем при равных массах. Помимо увеличения итераций, есть дешёвый трюк — финальный проход «от якоря», который просто не пускает каждую точку дальше segmentLength от предыдущей:

void EnforceMaxLength()
{
    if (inverseMass[0] != 0f) return;     // работает только от закреплённого начала

    for (int i = 1; i < pointCount; i++)
    {
        if (inverseMass[i] == 0f) continue;

        Vector3 delta = positions[i] - positions[i - 1];
        float distance = delta.magnitude;
        if (distance > segmentLength)
            positions[i] = positions[i - 1] + delta * (segmentLength / distance);
    }
}

Вызывается один раз в конце шага, после итераций и коллизий. Он не «честный» — сжатие не исправляет, массы не учитывает, и если поставить его до коллизий, может затолкать точку в стену. Но он гарантирует, что верёвка не станет длиннее заданной, и это именно то, что игрок видит в первую очередь. Учтите: если закреплены оба конца и якоря разведены дальше длины верёвки, проход закончится на втором якоре разрывом формы. Для двухконцевой верёвки его лучше не включать.

Разрыв верёвки

Раз мы и так считаем натяжение звена в SolveDistance, разрыв почти бесплатен. Заведём массив флагов «звено живо» и будем рвать звено, если его растяжение после шага превышает порог.

[SerializeField, Min(1f)] float breakStretch = 1.6f;   // во сколько раз длиннее нормы
bool[] linkAlive;                                      // размер pointCount - 1, заполнить true в Build()

void CheckBreaks()
{
    for (int i = 0; i < pointCount - 1; i++)
    {
        if (!linkAlive[i]) continue;

        float distance = Vector3.Distance(positions[i], positions[i + 1]);
        if (distance > segmentLength * breakStretch)
        {
            linkAlive[i] = false;
            OnRopeBroken(i);
        }
    }
}

В SolveDistance добавляется проверка if (!linkAlive[a]) return; (для цепочки индекс звена совпадает с индексом первой точки). Проверку разрыва делайте после всех итераций шага: в середине релаксации звенья временно перетянуты, и порог сработает ложно.

Сложность только с отрисовкой: один LineRenderer не умеет рисовать линию с разрывом. Простейший выход — в OnRopeBroken создать второй LineRenderer и отдавать ему точки после разрыва, а первому уменьшить positionCount. Если разрывов может быть много, удобнее рисовать верёвку процедурным мешем, где каждое звено — отдельный отрезок трубки, и мёртвые звенья просто пропускаются.

Верёвка и Rigidbody

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

[SerializeField] Rigidbody attachedBody;
[SerializeField] float pullStiffness = 400f;

void FixedUpdate()
{
    if (attachedBody == null) return;

    int last = pointCount - 1;
    Vector3 toRope = positions[last - 1] - attachedBody.position;
    float stretch = toRope.magnitude - segmentLength;
    if (stretch > 0f)
        attachedBody.AddForce(toRope.normalized * (stretch * pullStiffness), ForceMode.Force);
}

При этом последняя точка закреплена за attachedBody.position (в PinAnchors), а предпоследняя свободна. Получается пружинная связь: верёвка следует за телом мгновенно, тело следует за верёвкой через силу. Жёсткость подбирается под массу тела; слишком большая начнёт раскачивать систему, тогда добавьте демпфирование по скорости тела вдоль направления троса. Для крюка-кошки и маятника на верёвке чаще проще и честнее использовать ConfigurableJoint с ограничением дистанции на самом теле, а верлет-верёвку оставить чисто визуальной.

Производительность

Посчитаем порядок затрат. Верёвка из 40 точек, 120 шагов в секунду, 20 итераций: на кадре в 60 FPS это 2 шага × 20 итераций × 39 звеньев ≈ 1 560 вызовов SolveDistance. Каждый — десяток умножений и один квадратный корень. Для одной верёвки это пренебрежимо мало. Дорогая часть — коллизии: 40 overlap-запросов, повторённые 5–6 раз за шаг, уже заметны в профайлере, особенно на мобильных.

Что помогает в порядке убывания пользы:

  1. Не проверять коллизии для каждой точки на каждой итерации. Описанная выше схема «каждая четвёртая плюс последняя» срезает основную стоимость.
  2. Сузить маску. Верёвке незачем сталкиваться с пулями, триггерами и мелким декором. Отдельный слой «препятствия для верёвок» и QueryTriggerInteraction.Ignore часто ускоряют коллизии в разы.
  3. Широкая фаза на всю верёвку. Раз в шаг посчитайте Bounds всех точек и сделайте один Physics.OverlapBoxNonAlloc. Если рядом ничего нет, пропустите коллизии целиком. Если есть, проверяйте точки только против найденных коллайдеров.
  4. Засыпание. Если суммарное смещение всех точек за шаг меньше порога и якоря не двигаются, перестаньте симулировать до следующего движения якоря. Висящие в фоне провода перестанут стоить что-либо.
  5. Пакетные запросы и Burst. Когда верёвок десятки, интеграцию и релаксацию можно перенести в IJob с NativeArray<float3> и компиляцией Burst, а коллизии — в OverlapSphereCommand, который выполняет пачку overlap-запросов через Job System.

Вот как выглядит интеграция в виде Burst-задачи, чтобы было понятно, насколько мало кода меняется:

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

[BurstCompile]
struct IntegrateJob : IJobParallelFor
{
    public NativeArray<float3> Positions;
    public NativeArray<float3> Previous;
    [ReadOnly] public NativeArray<float> InverseMass;
    public float3 GravityStep;
    public float Damping;

    public void Execute(int i)
    {
        float3 p = Positions[i];
        if (InverseMass[i] == 0f)
        {
            Previous[i] = p;
            return;
        }

        float3 velocity = (p - Previous[i]) * Damping;
        Previous[i] = p;
        Positions[i] = p + velocity + GravityStep;
    }
}

Релаксацию звеньев так просто не распараллелить: соседние звенья делят точку, и два потока будут писать в неё одновременно. Обычно делают одну IJob на верёвку (верёвки параллелятся между собой), либо решают сначала все чётные звенья, потом все нечётные — внутри каждой группы звенья не пересекаются, и их можно обрабатывать параллельно. Для одной-двух верёвок в сцене всё это избыточно: обычный C# справится.

Типичные ошибки

  • Переменный шаг. Симуляция в Update с Time.deltaTime вместо аккумулятора: жёсткость и затухание зависят от FPS, при фризе верёвка дёргается.
  • Телепорт якоря. Если персонажа переместили на 50 метров, закреплённая точка прыгнет, а остальные полетят за ней с огромной неявной скоростью. После телепорта сдвиньте positions и previous всех точек на тот же вектор или перестройте верёвку через Build().
  • Слишком мало итераций для длинной верёвки. Растяжение растёт с числом точек. Удвоили длину — увеличьте итерации или частоту шагов.
  • Затухание как основной «стабилизатор». Если без damping = 0.9 верёвка дрожит, дело не в затухании, а в недостатке итераций или конфликте ограничений (например, точка зажата коллизией и звеном одновременно). Сильное затухание делает верёвку вязкой, будто она в мёде.
  • Сравнение с самим собой. Зонд ComputePenetration находится собственным overlap-запросом, если его слой входит в collisionMask. Отсюда проверка other == probe и отдельный слой.
  • Проверка разрыва посреди релаксации. Звенья временно перетянуты, верёвка рвётся «на ровном месте».
  • Создание массивов каждый кадр. new Vector3[] в Render или OverlapSphere вместо OverlapSphereNonAlloc дают постоянный GC alloc. Проверить легко: Profiler, колонка GC Alloc.

Куда двигаться дальше

Тот же решатель без изменений превращается в другие системы, нужно только поменять набор ограничений:

  • Ткань — сетка точек с ограничениями длины по горизонтали, вертикали и диагоналям. Диагонали дают сопротивление сдвигу, ограничения «через одну точку» — сопротивление изгибу.
  • Мягкие тела — замкнутый контур точек плюс звенья к центру или ограничение площади.
  • Хвосты, уши, косы, антенны — короткая цепочка, закреплённая за кость, с ограничением угла между соседними звеньями. Это дешёвая альтернатива физическим костям.
  • Рэгдолл — точки в суставах и жёсткие ограничения длины костей, именно с этого начиналась статья Якобсена.

Если захочется физически более корректного поведения — независимой от числа итераций жёсткости, правильной реакции на массы, — следующий шаг называется XPBD (Extended Position Based Dynamics). Он сохраняет ту же идею «двигаем позиции, а не силы», но добавляет к каждому ограничению податливость и множитель Лагранжа, и на нём сегодня построены многие современные решатели тканей и волос. Но для верёвок, проводов и цепей в игре тот код, что мы написали, — примерно 200 строк C# — закрывает подавляющее большинство задач.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также