Разрушаемость в Unity

Недавно я реализовал разрушаемые объекты окружения в своём аркадном авиасимуляторе. Сейчас расскажу, как именно я это сделал.

Общая логика работы разрушаемых объектов

  1. Нужно подготовить модель предмета, который требуется сломать и модель того же самого предмета, но в разбитом виде.
  2. Изначально игрок видит цельный объект. Когда он начинает ломать конструкцию, мы подсовываем модель разбитого объекта.
  3. Куски должны отламываться в том месте, куда ударил игрок, но оставшаяся часть объекта, состоящая из таких же осколков, при этом должна сохранить форму.
  4. Нужно решить проблему, из-за которой отдельные кусочки будут просто висеть в воздухе, ожидая физического воздействия.

Подготовка мешей

Итак, начнём с мешей. Нужно нарисовать объект в двух вариантах: целый и разрушенный. Для моделирования я использую Blender — в нём есть удобный аддон Cell Fracture. Он позволяет разломать объект произвольным образом на неровные куски. Для прототипа я сделал стену и колонну, которые буду разрушать.

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

Отделение физики осколков от самолёта

Перед тем как идти дальше, сделаю важную оговорку. В разрушении объектов для меня было важно, чтобы отлетающие куски не влияли на физику самолёта.

Это сильно аффектит фидбэк, но при этом мы полностью исключаем ситуации, когда из-за неудачно прилетевшего куска стены самолёт закручивает и он падает.

Для решения этой проблемы я добавил отдельный слой, который назвал Fragments, и исключил его взаимодействие со слоем, на котором находится самолёт.
Для решения этой проблемы я добавил отдельный слой, который назвал Fragments, и исключил его взаимодействие со слоем, на котором находится самолёт.

Программная реализация

DestructibleConstruction : MonoBehaviour

Класс отвечает за разрушаемый объект в целом. Он хранит информацию об осколках и переключает модель с целым объектом на модель разбитой конструкции.

Сам префаб объекта выглядит достаточно просто. Это пустой GameObject, которому подчинён меш целого объекта.

Поля класса:

//строка, хранит ID объекта [SerializeField] public string objectName; //Объект с мешем целого объекта окружения [SerializeField] private GameObject intactObject; //Объект с мешем разрушенного объекта окружения [SerializeField] private GameObject destroyedObject; //Масса всего объекта [SerializeField] public float totalMass; //Множитель импульса при разрушении [SerializeField] public float impulseMultiplier; //Физический материал для осколков [SerializeField] private PhysicsMaterial fragmentsPhysicMaterial; //По истечении этого времени //отколовшиеся части будут удаляться со сцены [SerializeField] public float lifeTimeOfFragments;

Модель с кусочками мы будем подтягивать программно. Причина простая: когда кусков много, это может быть и незаметно, но если делать небольшое количество фрагментов, становится очевидно, что предмет ломается всегда одинаково.

Чтобы сгладить этот момент, мы делаем несколько вариантов разрушенной конструкции и в Awake случайным образом выбираем один из них.

private void Awake() { InstantiateVariant(); InitializeFragments(); } private void InstantiateVariant() { string variantsPath = quot;Destructible/{objectName}/Variants"; int randVariantIndex = Random.Range(0, 3); string variantFolderName = randVariantIndex.ToString(); GameObject destructedPrefab = Resources.Load<GameObject>( quot;{variantsPath}/{variantFolderName}/destructed" ); destroyedObject = Instantiate(destructedPrefab, transform); destroyedObject.name = destructedPrefab.name; destroyedObject.SetActive(false); } private void InitializeFragments() { var volume = 0f; Dictionary<Rigidbody, float> vMap = new Dictionary<Rigidbody, float>(); foreach (Transform child in destroyedObject.transform) { InitFragment(child); child.gameObject.layer = gameObject.layer; var meshFilter = child.GetComponent<MeshFilter>(); var curVolume = CalculateMeshVolume(meshFilter.sharedMesh); volume += curVolume; vMap.Add(child.GetComponent<Rigidbody>(), curVolume); } foreach (var rb in vMap.Keys) { rb.mass = vMap[rb] / volume * totalMass; rb.ResetCenterOfMass(); } } private void InitFragment(Transform fObj) { Rigidbody rb = fObj.gameObject.AddComponent<Rigidbody>(); rb.useGravity = false; rb.isKinematic = true; rb.interpolation = RigidbodyInterpolation.Interpolate; rb.maxAngularVelocity = 4f; rb.collisionDetectionMode = CollisionDetectionMode.Discrete; rb.constraints = RigidbodyConstraints.FreezeAll; BoxCollider bc = fObj.gameObject.AddComponent<BoxCollider>(); bc.material = fragmentsPhysicMaterial; bc.isTrigger = true; SphereCollider trigger = fObj.gameObject.AddComponent<SphereCollider>(); trigger.isTrigger = true; trigger.radius *= 1.5f; Fragment fragment = fObj.gameObject.AddComponent<Fragment>(); fragment.construction = this; }

Итак, в InstantiateVariant мы выбираем вариант осколков и добавляем его в объект. Затем в InitializeFragments обходим объекты осколков и инициализируем каждый из них:

  • Слой изначально оставляем таким же, как у самолёта, иначе триггеры не будут срабатывать.
  • Назначаем Rigidbody, при этом изначально отключаем физику и блокируем положение объекта в пространстве.
  • Для Rigidbody рассчитываем массу пропорционально объёму меша.
  • Добавляем BoxCollider для обработки столкновений в будущем, но пока устанавливаем для него флаг isTrigger = true (это сделано, чтобы самолёт не сталкивался с фрагментами).
  • Добавляем SphereCollider — это уже полноценный триггер. Событие попадания самолёта в этот триггер будет сигналом для фрагмента о том, что пора отколоться.
  • Назначаем объекту осколка MonoBehaviour Fragment.

Конструкция готова к разрушению.

Теперь самое интересное. Добавляем объекту DestructibleConstruction BoxCollider и в OnTriggerEnter вызываем BreakConstruction при условии, что объект — это самолёт игрока.

private void OnTriggerEnter(Collider other) { if (Root.gameState.IsItPlayerAircraft(other.gameObject)) BreakConstruction(); } private void BreakConstruction() { intactObject.SetActive(false); destroyedObject.SetActive(true); GetComponent<Collider>().enabled = false; }

Тут всё примитивно. Выключаем целую конструкцию и включаем составленную из кусков. Важно понимать, что теперь фрагменты будут реагировать на триггеры, так что самое время посмотреть, как устроен класс Fragment.

Fragment : MonoBehaviour

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

Поля класса:

//компонент Rigidbody фрагмента. private Rigidbody rb; //родительская конструкция public DestructibleConstruction construction; //устанавливается в true, когда объект нужно удалить. private bool shouldDestroy = false;

Всё самое интересное происходит при срабатывании тригера фрагмента.

private void OnTriggerEnter(Collider other) { if (activated) return; if (Root.gameState.IsItPlayerAircraft(other.gameObject)) { activateFragment(); var forcePoint = Root.gameState.playerAircraft.physics.nosePoint; var planeRb = Root.gameState.playerAircraft.physics.rigidBody; Vector3 forceDir = planeRb.linearVelocity.normalized; float impulse = planeRb.linearVelocity.magnitude * construction.impulseMultiplier; rb.AddForceAtPosition(forceDir * impulse, forcePoint.position, ForceMode.Impulse); } } public void activateFragment() { BoxCollider bc = gameObject.GetComponent<BoxCollider>(); bc.isTrigger = false; gameObject.layer = LayerMask.NameToLayer("Fragments"); activated = true; rb.ResetCenterOfMass(); rb.useGravity = true; rb.isKinematic = false; rb.constraints = RigidbodyConstraints.None; StartCoroutine(DestroyObject(construction.lifeTimeOfFragments)); }

Проверяем, что триггер сработал именно на самолёт, после чего начинаем откалывать объект.

Для начала вызываем публичный метод activateFragment: включаем физику для BoxCollider и снимаем у него флаг isTrigger, чтобы для него обрабатывались столкновения. Слой переключаем на Fragments, чтобы куски не влияли на самолёт.

Затем запускаем таймер, по истечении которого объект будет удалён. После включения физики фрагменту сообщаем импульс, который зависит от скорости самолёта и множителя impulseMultiplier.

Проблема висящих кусков

Мы реализовали пункты 1, 2 и 3. Остался четвёртый и самый интересный — нужно решить вопрос с висящими в воздухе кусочками.

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

Так мы можем определить, какие куски могут висеть в воздухе — это те, которые соединены с этими якорными фрагментами.

Для того, чтобы понимать, какие куски между собой соединены, представим конструкцию в виде графа: Узлы графа — это фрагменты конструкции; Рёбра графа определяют соседние куски.

Если кусок откалывается, мы удаляем его из графа. В случае если граф становится несвязным, оставляем только те его компоненты (острова), которые содержат якорные фрагменты. Все остальные вершины принудительно откалываем от конструкции, поскольку они не имеют опоры.

В класс DestructibleConstruction добавляем поля:

//Все фрагменты, которые входят в коллайдеры из этого списка, //мы считаем якорными. private List<Collider> anchorList; //Тут храним информацию о связности графа. private Dictionary<Fragment, HashSet<Fragment>> fragmentGraph; //Флаг который сигнализирует о необходимости пересчета графа private bool needCalculateGraph;

В класс Fragment добавляем булевое поле isAnchor - его будем выставлять в true для якорных фрагментов.

Ниже скрины с примерами якорных коллайдеров. Для стены они расположены по её углам, для колонны — сверху и снизу.

Для работы с графом я реализовал следующие процедуры:

//DestructibleConstruction private void BreakConstruction() { //.... BuildFragmentGraph(); } private void FixedUpdate() { if (needCalculateGraph) CalculateFragmentGraph(); } private void BuildFragmentGraph() { fragmentGraph.Clear(); foreach (Transform child in destroyedObject.transform) { Fragment frag = child.GetComponent<Fragment>(); if (frag == null) continue; fragmentGraph[frag] = new HashSet<Fragment>(); frag.isAnchor = false; Collider fragCol = frag.GetComponent<Collider>(); if (fragCol != null) foreach (var anchor in anchorList) if (anchor.bounds.Intersects(fragCol.bounds)) { frag.isAnchor = true; break; } } Fragment[] fragments = new Fragment[fragmentGraph.Keys.Count]; fragmentGraph.Keys.CopyTo(fragments, 0); for (int i = 0; i < fragments.Length; i++) { Collider colA = fragments[i].GetComponent<Collider>(); for (int j = i + 1; j < fragments.Length; j++) { Collider colB = fragments[j].GetComponent<Collider>(); if (colA.bounds.Intersects(colB.bounds)) { fragmentGraph[fragments[i]].Add(fragments[j]); fragmentGraph[fragments[j]].Add(fragments[i]); } } } } private void CalculateFragmentGraph() { if (fragmentGraph.Count == 0) return; HashSet<Fragment> visited = new HashSet<Fragment>(); List<HashSet<Fragment>> connectedComponents = new List<HashSet<Fragment>>(); foreach (var frag in fragmentGraph.Keys) { if (visited.Contains(frag)) continue; HashSet<Fragment> component = new HashSet<Fragment>(); Queue<Fragment> queue = new Queue<Fragment>(); queue.Enqueue(frag); while (queue.Count > 0) { Fragment current = queue.Dequeue(); if (!visited.Add(current)) continue; component.Add(current); foreach (var neighbor in fragmentGraph[current]) { if (!visited.Contains(neighbor)) queue.Enqueue(neighbor); } } connectedComponents.Add(component); } foreach (var comp in connectedComponents) { bool hasAnchor = false; foreach (var frag in comp) if (frag.isAnchor) { hasAnchor = true; break; } if (!hasAnchor) foreach (var frag in comp) frag.activateFragment(); } } public void RemoveFragmentFromGraph(Fragment frag) { if (frag == null) return; if (!fragmentGraph.ContainsKey(frag)) return; foreach (var neighbor in fragmentGraph[frag]) fragmentGraph[neighbor].Remove(frag); fragmentGraph.Remove(frag); needCalculateGraph = true; }

Внутри BuildFragmentGraph мы составляем первоначальный граф. Эту процедуру вызываем один раз при подмене целой конструкции на набор осколков.

Процедура CalculateFragmentGraph обходит граф и "откалывает" те куски, которые не связаны с якорями. Она вызывается из FixedUpdate всякий раз после того, как откололся фрагмент.

RemoveFragmentFromGraph удаляет фрагмент из графа. Эту процедуру мы также вызываем при активации фрагмента.

public void activateFragment() { //.... construction.RemoveFragmentFromGraph(this); }

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

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

Спасибо за внимание!

Если проект заинтересовал, обязательно подписывайся тут или в телеге. Там я тоже регулярно делюсь прогрессом по разработке.

58
26
3
1
1
30 комментариев