Всем привет.
Если начинать путь в 3D графике с воксельного рендеринга и дойти до многопоточной обработки бесконечных чанков, рано или поздно наступает момент истины. Вы осознаете: всё, что отображается на экране — это лишь визуальная репрезентация «физического» слоя данных. То, как эти данные структурированы и хранятся, напрямую определяет возможности взаимодействия с ними. Именно поэтому я всегда рекомендую воксельное представление мира как лучшую школу для знакомства с трехмерной графикой.
Сегодня мы поговорим не о самих вокселях, но они станут тем «кирпичиком», который поможет понять глобальный процесс. Наша тема — деревья BVH (Bounding Volume Hierarchy) и то, как простые кубики — AABB (Axis-Aligned Bounding Box) — ускоряют прототипирование и понимание пространственной логики. Я поделюсь опытом и решениями, к которым пришел, пытаясь реализовать свои идеи в графике.
Почему BVH — это база?
BVH (Иерархия ограничивающих объемов) — это бинарное дерево, которое радикально ускоряет поиск объектов в пространстве: будь то расчет пересечения луча (raytracing) или проверка столкновений. Такие деревья могут быть статическими или динамическими.
Данная реализация основана на концепциях, которые я почерпнул из открытых источников и, в частности, из материалов и презентаций Эрина Катто (Erin Catto), создателя физического движка Box2D. В тот момент, когда я столкнулся с трудностями «наивных» реализаций, именно его подход помог мне структурировать мышление.
Концепция BVH дает ряд критических преимуществ:
Пространственная вложенность: поиск по иерархии меток работает в разы быстрее последовательного перебора.
Объемное мышление: структура приучает мыслить категориями объемов, а не отдельных точек.
Структура сцены: дерево может выступать каркасом всей сцены. Пучки данных легко поддаются персистентности — их гораздо проще сохранять, восстанавливать и передавать между узлами.
По началу я пытался делать на прямую, без структурирования, но в какой-то момент это стало неуправляемо, где-то с третьего раза почитав статью про BVH, я примерно понял как выйти элегантнее через физику в отрисовку. Так же следует учесть, что есть разные подходы сборки таких деревьев.
Реализация динамического мира (C++)
Ниже представлен полный код системы(за исключением реализации стека). Это динамическое самобалансирующееся дерево, которое оперирует пока одним «пучком», успешно справляясь с интенсивным взаимодействием объектов в игровом цикле. Правда с оговоркой до многопоточности, тоесть около 1000 - еще нормально, 10000 - это максимум без многопоточки.
Скрытый текст
// Как использовать этот файл: // Сохраните его как DynamicWorld.hpp. // Подключите в свой main.cpp. // Теперь у вас есть всё необходимое для создания сложной игровой логики. // Ваш следующий шаг: // Теперь вы можете создать Player Controller. Вызывайте world.UpdatePosition каждый кадр, а затем world.Query вокруг игрока. Если в списке окажется триггер с заданным onTrigger — просто вызывайте его! #include <iostream> #include <vector> #include <map> #include <memory> #include <functional> #include <algorithm> #include <limits> #include <iomanip> #include <glm/glm.hpp> #include <glm/gtc/matrix_access.hpp> // --- Константы и Слои --- enum CollisionLayer : uint32_t { LAYER_NONE = 0, LAYER_STATIC = 1 << 0, LAYER_PLAYER = 1 << 1, LAYER_ENEMY = 1 << 2, LAYER_TRIGGER = 1 << 3, LAYER_ALL = 0xFFFFFFFF }; // --- Базовая Математика --- struct AABB { glm::vec3 lowerBound; glm::vec3 upperBound; inline bool Contains(const AABB& other) const { return other.lowerBound.x >= lowerBound.x && other.lowerBound.y >= lowerBound.y && other.lowerBound.z >= lowerBound.z && other.upperBound.x <= upperBound.x && other.upperBound.y <= upperBound.y && other.upperBound.z <= upperBound.z; } static AABB Union(const AABB& a, const AABB& b) { return { glm::min(a.lowerBound, b.lowerBound), glm::max(a.upperBound, b.upperBound) }; } static float Area(const AABB& a) { glm::vec3 d = a.upperBound - a.lowerBound; return 2.0f * (d.x * d.y + d.y * d.z + d.z * d.x); } }; struct PendingTask { float timeLeft; std::function<void()> action; }; // --- Сущность Мира --- // Данные для ИИ и Боя enum EntityType { PLAYER, FRIENDLY, BITER, SHOOTER, LOOT }; enum MoveType { STATIC, ELEVATOR, HORIZONTAL }; struct EntityData { EntityType type = FRIENDLY; MoveType moveType = STATIC; glm::vec3 startPos; float moveTimer = 0.0f; glm::vec3 lastOffset{0.0f}; // Чтобы передать движение игроку float health = 100.0f; float speed = 0.2f; float maxHealth = 100.0f; float attackRange = 1.5f; float attackCooldown = 1.0f; bool isAggressive = false; // Становится true, если игрок ударил первым uint32_t lootType = 0;// 0 - мана, 1 - хп bool isDead = false; float lifetime = -1.0f; // -1 значит "живет вечно" bool markedForDeletion = false; }; struct Entity { int id; glm::vec3 position; glm::vec3 size; uint32_t category; uint32_t mask; EntityData gameplay; // Геймплейные статы std::function<void(int)> onTrigger = nullptr; std::function<void()> onInteract = nullptr; Entity(int _id, glm::vec3 _pos, glm::vec3 _size, uint32_t _cat, uint32_t _mask) : id(_id), position(_pos), size(_size), category(_cat), mask(_mask) {} AABB getAABB() const { return { position - (size * 0.5f), position + (size * 0.5f) }; } }; // --- Вспомогательный Стек --- template <typename T> class Stack {} // --- Узел Дерева --- struct Node { AABB box; int objectIndex = -1; int parentIndex = -1; int child1 = -1, child2 = -1; int height = 0; bool isLeaf = false; int next = -1; // Для пула свободных узлов }; // --- Динамическое BVH Дерево --- class DynamicTree { public: std::vector<Node> nodes; int rootIndex = -1; int freeList = -1; const float margin = 0.2f; // Fat AABB запас int AllocateNode() { if (freeList != -1) { int index = freeList; freeList = nodes[index].next; nodes[index] = Node(); return index; } nodes.emplace_back(); return (int)nodes.size() - 1; } void FreeNode(int index) { nodes[index].next = freeList; nodes[index].parentIndex = -1; nodes[index].objectIndex = -1; nodes[index].isLeaf = false; freeList = index; } int InsertLeaf(int objIdx, const AABB& box) { int leaf = AllocateNode(); // Добавляем запас (Fat AABB) nodes[leaf].box = { box.lowerBound - margin, box.upperBound + margin }; nodes[leaf].objectIndex = objIdx; nodes[leaf].isLeaf = true; if (rootIndex == -1) { rootIndex = leaf; return leaf; } int sibling = rootIndex; while (!nodes[sibling].isLeaf) { int c1 = nodes[sibling].child1, c2 = nodes[sibling].child2; float area = AABB::Area(nodes[sibling].box); float combinedArea = AABB::Area(AABB::Union(nodes[sibling].box, box)); float cost = 2.0f * combinedArea; float inheritanceCost = 2.0f * (combinedArea - area); auto getCost = [&](int c) { float res = AABB::Area(AABB::Union(nodes[c].box, box)) + inheritanceCost; if (!nodes[c].isLeaf) res -= AABB::Area(nodes[c].box); return res; }; float cost1 = getCost(c1), cost2 = getCost(c2); if (cost < cost1 && cost < cost2) break; sibling = (cost1 < cost2) ? c1 : c2; } int oldP = nodes[sibling].parentIndex; int newP = AllocateNode(); nodes[newP].parentIndex = oldP; nodes[newP].box = AABB::Union(box, nodes[sibling].box); nodes[newP].child1 = sibling; nodes[newP].child2 = leaf; nodes[sibling].parentIndex = newP; nodes[leaf].parentIndex = newP; if (oldP != -1) { if (nodes[oldP].child1 == sibling) nodes[oldP].child1 = newP; else nodes[oldP].child2 = newP; } else rootIndex = newP; SyncHierarchy(nodes[leaf].parentIndex); return leaf; } void RemoveLeaf(int leaf) { if (leaf == rootIndex) { rootIndex = -1; FreeNode(leaf); return; } int p = nodes[leaf].parentIndex, gp = nodes[p].parentIndex; int sib = (nodes[p].child1 == leaf) ? nodes[p].child2 : nodes[p].child1; if (gp != -1) { if (nodes[gp].child1 == p) nodes[gp].child1 = sib; else nodes[gp].child2 = sib; nodes[sib].parentIndex = gp; FreeNode(p); SyncHierarchy(gp); } else { rootIndex = sib; nodes[sib].parentIndex = -1; FreeNode(p); } FreeNode(leaf); } private: void SyncHierarchy(int i) { while (i != -1) { i = Balance(i); UpdateNode(i); // Явно обновляем высоту и AABB i = nodes[i].parentIndex; } } void UpdateNode(int i) { int c1 = nodes[i].child1; int c2 = nodes[i].child2; nodes[i].box = AABB::Union(nodes[c1].box, nodes[c2].box); nodes[i].height = 1 + std::max(nodes[c1].height, nodes[c2].height); } int Balance(int i) { if (nodes[i].isLeaf || nodes[i].height < 2) return i; int c1 = nodes[i].child1; int c2 = nodes[i].child2; int balance = nodes[c2].height - nodes[c1].height; // Rotate C2 up if (balance > 1) { int r = c2; int rl = nodes[r].child1; int rr = nodes[r].child2; nodes[r].child1 = i; nodes[r].parentIndex = nodes[i].parentIndex; nodes[i].parentIndex = r; if (nodes[r].parentIndex != -1) { if (nodes[nodes[r].parentIndex].child1 == i) nodes[nodes[r].parentIndex].child1 = r; else nodes[nodes[r].parentIndex].child2 = r; } else rootIndex = r; if (nodes[rl].height > nodes[rr].height) { nodes[r].child2 = rl; nodes[i].child2 = rr; nodes[rr].parentIndex = i; UpdateNode(i); UpdateNode(r); } else { nodes[r].child2 = rr; nodes[i].child2 = rl; nodes[rl].parentIndex = i; UpdateNode(i); UpdateNode(r); } return r; } // Rotate C1 up if (balance < -1) { int l = c1; int ll = nodes[l].child1; int lr = nodes[l].child2; nodes[l].child1 = i; nodes[l].parentIndex = nodes[i].parentIndex; nodes[i].parentIndex = l; if (nodes[l].parentIndex != -1) { if (nodes[nodes[l].parentIndex].child1 == i) nodes[nodes[l].parentIndex].child1 = l; else nodes[nodes[l].parentIndex].child2 = l; } else rootIndex = l; if (nodes[ll].height > nodes[lr].height) { nodes[l].child2 = ll; nodes[i].child1 = lr; nodes[lr].parentIndex = i; UpdateNode(i); UpdateNode(l); } else { nodes[l].child2 = lr; nodes[i].child1 = ll; nodes[ll].parentIndex = i; UpdateNode(i); UpdateNode(l); } return l; } return i; } }; // --- Финальный Класс Мира --- class World { public: DynamicTree bvh; std::map<int, std::unique_ptr<Entity>> registry; std::map<int, int> entityToNode; int nextId = 0; std::vector<int> deletionQueue; std::vector<PendingTask> tasks; void AddTask(float delay, std::function<void()> action) { tasks.push_back({delay, action}); } void UpdateTasks(float dt) { for (auto it = tasks.begin(); it != tasks.end();) { it->timeLeft -= dt; if (it->timeLeft <= 0) { it->action(); // Выполняем (например, спавним моба) it = tasks.erase(it); } else { ++it; } } } int CreateEntity(glm::vec3 pos, glm::vec3 size, uint32_t cat, uint32_t mask) { int id = nextId++; auto e = std::make_unique<Entity>(id, pos, size, cat, mask); entityToNode[id] = bvh.InsertLeaf(id, e->getAABB()); registry[id] = std::move(e); return id; } void UpdatePosition(int id, glm::vec3 newPos) { auto& e = registry[id]; e->position = newPos; AABB real = e->getAABB(); int nodeIdx = entityToNode[id]; if (!bvh.nodes[nodeIdx].box.Contains(real)) { bvh.RemoveLeaf(nodeIdx); entityToNode[id] = bvh.InsertLeaf(id, real); } } void Query(const AABB& box, std::vector<int>& out) const { if (bvh.rootIndex == -1) return; Stack<int> s; s.Push(bvh.rootIndex); while (!s.IsEmpty()) { int i = s.Pop(); const Node& n = bvh.nodes[i]; if (!(n.box.lowerBound.x <= box.upperBound.x && n.box.upperBound.x >= box.lowerBound.x && n.box.lowerBound.y <= box.upperBound.y && n.box.upperBound.y >= box.lowerBound.y && n.box.lowerBound.z <= box.upperBound.z && n.box.upperBound.z >= box.lowerBound.z)) continue; if (n.isLeaf) out.push_back(n.objectIndex); else { s.Push(n.child1); s.Push(n.child2); } } } int Raycast(glm::vec3 p1, glm::vec3 p2, uint32_t mask) const { if (bvh.rootIndex == -1) return -1; glm::vec3 dir = glm::normalize(p2 - p1); glm::vec3 invDir = 1.0f / (dir + 1e-9f); Stack<int> s; s.Push(bvh.rootIndex); int hitId = -1; float minDist = 1e9f; while (!s.IsEmpty()) { int i = s.Pop(); const Node& n = bvh.nodes[i]; glm::vec3 t0 = (n.box.lowerBound - p1) * invDir, t1 = (n.box.upperBound - p1) * invDir; float tmin = glm::max(glm::max(glm::min(t0.x, t1.x), glm::min(t0.y, t1.y)), glm::min(t0.z, t1.z)); float tmax = glm::min(glm::min(glm::max(t0.x, t1.x), glm::max(t0.y, t1.y)), glm::max(t0.z, t1.z)); if (tmax < tmin || tmax < 0) continue; if (n.isLeaf) { if (registry.at(n.objectIndex)->category & mask) { float d = glm::distance(p1, registry.at(n.objectIndex)->position); if (d < minDist) { minDist = d; hitId = n.objectIndex; } } } else { s.Push(n.child1); s.Push(n.child2); } } return hitId; } //canmoveto bool CanMoveTo(int entityId, glm::vec3 targetPos) const { if (registry.find(entityId) == registry.end()) return false; const auto& entity = registry.at(entityId); // Создаем временный AABB для новой позиции (без запаса margin) AABB targetBox = { targetPos - (entity->size * 0.5f), targetPos + (entity->size * 0.5f) }; std::vector<int> collisions; Query(targetBox, collisions); for (int hitId : collisions) { if (hitId == entityId) continue; // Игнорируем себя const auto& target = registry.at(hitId); // Проверяем: совпадает ли категория цели с маской сущности? if (target->category & entity->mask) { return false; // Путь прегражден (например, LAYER_STATIC) } } return true; // Путь свободен } //frustum void QueryFrustum(const std::vector<Plane>& frustumPlanes, std::vector<int>& outVisible) const { if (bvh.rootIndex == -1) return; Stack<int> s; s.Push(bvh.rootIndex); while (!s.IsEmpty()) { int i = s.Pop(); const Node& n = bvh.nodes[i]; // 1. Проверяем: виден ли бокс узла хотя бы одной плоскостью? bool visible = true; for (const auto& plane : frustumPlanes) { if (!plane.IsInFront(n.box)) { visible = false; // Бокс полностью за плоскостью break; } } if (visible) { if (n.isLeaf) { outVisible.push_back(n.objectIndex); } else { s.Push(n.child1); s.Push(n.child2); } } } } glm::vec3 ResolveOverlap(int entityId) { auto& ent = registry[entityId]; AABB box = ent->getAABB(); std::vector<int> neighbors; Query(box, neighbors); glm::vec3 totalPush(0.0f); for (int otherId : neighbors) { if (otherId == entityId) continue; auto& other = registry[otherId]; // Выталкиваемся только от стен или других живых существ if (!(other->category & (LAYER_STATIC | LAYER_ENEMY | LAYER_PLAYER))) continue; AABB otherBox = other->getAABB(); // Вычисляем глубину проникновения по каждой оси float dx1 = otherBox.upperBound.x - box.lowerBound.x; float dx2 = box.upperBound.x - otherBox.lowerBound.x; float dy1 = otherBox.upperBound.y - box.lowerBound.y; float dy2 = box.upperBound.y - otherBox.lowerBound.y; float dz1 = otherBox.upperBound.z - box.lowerBound.z; float dz2 = box.upperBound.z - otherBox.lowerBound.z; // Находим минимальную глубину (ось наименьшего сопротивления) float minX = std::min(dx1, dx2); float minY = std::min(dy1, dy2); float minZ = std::min(dz1, dz2); if (minX < minY && minX < minZ) { totalPush.x += (dx1 < dx2) ? minX : -minX; } else if (minY < minX && minY < minZ) { totalPush.y += (dy1 < dy2) ? minY : -minY; } else { totalPush.z += (dz1 < dz2) ? minZ : -minZ; } } return totalPush; } void MarkForDeletion(int id) { if (registry.count(id) && !registry[id]->gameplay.markedForDeletion) { registry[id]->gameplay.markedForDeletion = true; deletionQueue.push_back(id); } } // Очистка в конце каждого кадра void Cleanup() { for (int id : deletionQueue) { if (entityToNode.count(id)) { bvh.RemoveLeaf(entityToNode[id]); entityToNode.erase(id); } registry.erase(id); } deletionQueue.clear(); } }; int main(){ World world; std::cout << std::fixed << std::setprecision(2); std::cout << "=== ИНИЦИАЛИЗАЦИЯ МИРА ===\n"; // 1. Создаем статичную стену int wallId = world.CreateEntity({10.0f, 0.0f, 0.0f}, {1.0f, 10.0f, 10.0f}, LAYER_STATIC, LAYER_NONE); std::cout << "[World]: Создана стена ID: " << wallId << " на x=10\n"; // 2. Создаем ядовитую зону (Trigger) int poisonZone = world.CreateEntity({5.0f, 0.0f, 0.0f}, {2.0f, 2.0f, 2.0f}, LAYER_TRIGGER, LAYER_NONE); world.registry[poisonZone]->onTrigger = [](int visitorId) { std::cout << " [EVENT]: Объект " << visitorId << " вступил в ЯДОВИТУЮ ЗОНУ! -10 HP\n"; }; // 3. Создаем рычаг (Trigger) int leverId = world.CreateEntity({8.0f, 0.0f, 2.0f}, {0.5f, 0.5f, 0.5f}, LAYER_TRIGGER, LAYER_NONE); world.registry[leverId]->onInteract = []() { std::cout << " [INTERACT]: Рычаг нажат! Секретная дверь открыта.\n"; }; // 4. Создаем игрока int playerId = world.CreateEntity({0.0f, 0.0f, 0.0f}, {0.6f, 1.8f, 0.6f}, LAYER_PLAYER, LAYER_STATIC | LAYER_TRIGGER); std::cout << "[World]: Игрок создан ID: " << playerId << "\n\n"; // --- ТЕСТ 1: ДВИЖЕНИЕ СКВОЗЬ ТРИГГЕР --- std::cout << "=== ТЕСТ 1: ДВИЖЕНИЕ СКВОЗЬ ТРИГГЕР ===\n"; for (float x = 0.0f; x <= 6.0f; x += 2.0f) { world.UpdatePosition(playerId, {x, 0.0f, 0.0f}); std::cout << "Игрок переместился в x=" << x << "\n"; // Проверяем сенсоры (триггеры) вокруг игрока std::vector<int> nearby; world.Query(world.registry[playerId]->getAABB(), nearby); for (int id : nearby) { if (id != playerId && world.registry[id]->onTrigger) { world.registry[id]->onTrigger(playerId); } } } // --- ТЕСТ 2: ВЗАИМОДЕЙСТВИЕ (RAYCAST) --- std::cout << "\n=== ТЕСТ 2: ВЗАИМОДЕЙСТВИЕ (RAYCAST) ===\n"; glm::vec3 cameraPos = {7.0f, 0.0f, 2.0f}; // Встали рядом с рычагом glm::vec3 lookDir = {1.0f, 0.0f, 0.0f}; // Смотрим в сторону рычага std::cout << "Игрок смотрит вперед из точки (7, 0, 2)...\n"; int hit = world.Raycast(cameraPos, cameraPos + lookDir * 5.0f, LAYER_TRIGGER); if (hit != -1 && world.registry[hit]->onInteract) { std::cout << "Найден объект для взаимодействия: " << hit << "\n"; world.registry[hit]->onInteract(); } // --- ТЕСТ 3: СТОЛКНОВЕНИЕ СО СТЕНОЙ --- std::cout << "\n=== ТЕСТ 3: КОЛЛИЗИЯ СО СТЕНОЙ ===\n"; glm::vec3 nextPos = {10.0f, 0.0f, 0.0f}; // Пытаемся зайти в стену int wallHit = world.Raycast(world.registry[playerId]->position, nextPos, LAYER_STATIC); if (wallHit != -1) { std::cout << "[BLOCK]: Движение заблокировано! Впереди стена ID: " << wallHit << "\n"; } for (auto& [id, ent] : world.registry) { int nodeIdx = world.entityToNode[id]; world.bvh.RemoveLeaf(nodeIdx); } //--- ТЕСТ 4: Очистка --- std::cout << "\n=== ТЕСТ 4: Очистка ===\n"; world.registry.clear(); // Очистка данных world.entityToNode.clear(); // Очистка связей world.nextId = 0; // Сброс счетчика ID world.Cleanup(); std::cout << "[LOG]: All entities removed. Root: " << world.bvh.rootIndex << "\n"; std::cout << "\n=== ВСЕ ТЕСТЫ ЗАВЕРШЕНЫ ===\n"; return 0; }
Здесь есть параметры на передвижение для платформ, и параметры типа сущностей. Так же используются триггеры, и небольшой тест как это работает.
Чтобы это визуализировать надо собрать точки - в координатах, в которых должны появиться коллайдеры( геометрия - например куб, этот вид отрисовки показывает тот самый ограничивающий обьём обьекта в сцене, тоесть визуализация обьекта по которому будет происходить расчет столкновения - обычно его не рисуют, а рисуют основную модель с анимациями или статичную модель ) для расчета столкновений, внутри обновления отрисовки можно вызывать сбор(Query - механика "сбора" работает тоже в ограниченном обьеме), обновляя состояние мира. Это минимальный пример, возможно можно сделать и лучше, но у меня пока так и меня впечатляет работа на 40 сущностях.
Пример игрового цикла с бесконечным спавном

Что мне понравилось, в такой ситуации если в пучке до 100 сущностей, утечки мной не обнаружено. Такой подход структурирует сразу с самых первых строк, в сравнении с наивными попытками.
В написании кода я пользовался поддержкой ИИ.
Итог
Это мощная база для создания прототипа или для того как включить физику в другом движке, например буллет. Благодаря балансировке и SAH-подобной вставке, поиск объектов остается быстрым даже при большом количестве сущностей.
Разработка через физический слой позволяет сразу видеть логику мира. Следующим логичным шагом станет внедрение многопоточности и распределение дерева на чанки(эту категорию чанков я пока называю пучок) для действительно бесконечных масштабов.