Современные компьютерные игры и игровые движки соревнуются между собой в плане технологичности и уровня детализации игрового мира. Зачастую на экране мы видим практически искусственную вселенную с огромным множеством игровых объектов. Но то, что мы видим на экране - это лишь верхушка айсберга, все остальное скрыто под водой (т.е. не попадает в поле зрения камеры). Наверное многие замечали, в некоторых играх, что FPS иногда падает, если камера видит большое открытое пространство со множеством объектов, например ландшафт и лес на нем (особенно такие скачки FPS заметны на слабых машинах).
Как ни странно, но эти скачки обусловленны оптимизацией. Если бы подобной оптимизации не было, то FPS был бы низким всегда. Итак, что же это за оптимизация? Это так называемые алгоритмы разбиения пространства. Данные алгоритмы позволяют быстро находить только те объекты, которые видны камере, и рисовать только их. Чем меньше объектов видны камере, тем больше FPS выдает игра.
Существует несколько основных алгоритмов разбиения пространства:
У каждого из этих алгоритмов есть свои плюсы и минусы, мы не будем говорить обо всех этих алгоритмах, только об Octree.
Алгоритм Octree.
Octree - октарное дерево (октодерево), древовидная структура, где в каждом узле может содержаться до 8 потомков. В этих узлах помимо вложенных узлов, также содержаться объекты игрового мира. Т.о. имея древовидное представление игровых объектов, можно делать быстрый перебор этих объектов, откидывая целые поддеревья, без лишних проверок.
Как видно из определения - идея octree довольно проста. Достаточно взять куб, в который вписаны все игровые объекты, поделить его на 8 дочерних кубов и привязать к ним объекты, которые в них попали. Затем каждый из этих дочерних кубов поделить еще на 8 и т.д. до тех пор пока... пока что-то не произойдет? Критериев завершения деления кубов может быть несколько, например можно остановить деление, если уровнь вложенности превывсил максимально допустимый. Или размер узлов стал меньше чем минимально допустимый. Еще одним критерием остановки деления может стать количество игровых объектов, привязанных к узлу дерева. А если один и тот же игровой объект попал в несколько узлов одновременно? Здесь есть 2 варианта - либо занести объект в каждый из этих узлов, либо приписать объект в родительский узел. Мы будем приписывать такие объекты родительскому узлу. Далее в статье мы это обсудим.
Ну поделили мы эти кубы, а дальше что? После того, как дерево построено, к нему можно делать запросы, которые позволяют получить список видимых объектов. Собственно для этого, мы и хотим использовать само дерево.
А что если объекты двигаются, например ракеты выпущенные из базуки? Как их перемещать по дереву? Это один из самых сложных вопросов. Практически все алгоритмы разбиения пространства очень хорошо работают со статическими объектами (некоторые лучше чем Octree), но вот с динамикой всегда есть проблемы. В Octree есть более-менее простые способы решения этой проблемы. О них мы поговорим далее в статье.
В общем-то с теорией все. Начинаем кодировать!
Tools & Project
Для написания кода я буду использовать VS 2010 Ultimate и XNA 4.0 beta. Создайте новый проект Windows Game (4.0) и назовите его... да как хотите :) У меня он называется OctreeTutorial.
Design
Прежде всего поговорим немного о проектировании. Для рендеринга объектов мы будем использовать стандартные классы XNA (просто для скорости разработки), а вот для самого Octree напишем 2 класса:
* Octree - собственно само дерево. Данный класс содержит корневой узел и предоставляет методы для построения дерева, его обновления и выполнения запросов для поиска видимых объектов.
* Node - узел дерева. Содержит список вложенных улов и список объектов, привязанных к данному узлу. Также содержит BoundingBox - ограничивающий объем, по которому будет вычисляться видимость узла.
Кроме этих 2 основных классов мы разработаем несколько методов расширения (Extension method), классы камеры и игровых сущностей, а также счетчик FPS.
Подготовка сцены.
Итак, у нас есть пустой проект игры, с классом Game1 и проект контента (также пустой). Для начала давайте добавим файл шрифта, чтобы иметь возможность выводить текстовые данные. Не буду описывать, как это делается - в интернете полно инфы по этому поводу. Я назвал файл шрифта Arial.spritefont.
Помимо шрифта нам понадобится тестовая модель (желательно как можно проще). Итак, у нас есть шрифт и модель, давайте нарисуем несколько экземпляров этой модели и посчитаем FPS.
Начнем с FPS. Создайте новый класс FpsCounter и добавьте в него следующий код:FpsCounter
publicclass FpsCounter{privatefloat _frameTime =0;privateint _framesPerSecond =0;privateint _frameCounter =0;private TimeSpan _elapsedTime = TimeSpan.Zero;publicint FramesPerSecond{ get {return _framesPerSecond;}}publicfloat FrameTime{ get {return _frameTime;}}publicvoid Update(GameTime gameTime){//накапливаем время прошедшее с момента отрисовки последнего кадра _elapsedTime += gameTime.ElapsedGameTime;//если накопленное время больше секунды, то считаем кадрыif(_elapsedTime > TimeSpan.FromSeconds(1)){ _elapsedTime -= TimeSpan.FromSeconds(1); _framesPerSecond = _frameCounter; _frameCounter =0;}//увеличиваем счетчик кадров _frameCounter++;//получаем время затраченное на отрисовку одного кадраif(_framesPerSecond >0){ _frameTime = 1000f / _framesPerSecond;}}}
Теперь откройте файл Game1.cs и добавьте в него следующий код (я убрал лишние коментарии, для краткости):
Теперь наша игра умеет считать FPS и выводить его на экран (у меня FPS ~8000(!)). Давайте нарисуем модель. Для рисования нам, прежде всего, понадобится класс камеры. Создайте новый файл Camera.cs и добавьте в него приведенный ниже код.
Кроме камеры, нам понадобится класс игровой сущности, который будет содержать ссылку на модель, мировую матрицу и ограничивающий объем. Создайте новый класс GameEntity и вставьте следующий код:
publicclass GameEntity{public Matrix World;public BoundingBox OriginalBoundingBox;public BoundingBox TransformedBoundingBox;public Model Model { get; set;}}
Теперь все готово для отрисовки наших моделей. Измените класс Game1 следующим образом:
private Camera _camera;private List<GameEntity> _entities;privateint _modelsCount;...public Game1(){ _graphics =new GraphicsDeviceManager(this); _graphics.SynchronizeWithVerticalRetrace= false;this.IsFixedTimeStep= false; Content.RootDirectory="Content"; _fpsCounter =new FpsCounter(); _camera =new Camera(); _camera.RotationSpeed=10; _camera.MovingSpeed=10; _entities =new List<GameEntity>(); _modelsCount =5000;}protectedoverridevoid LoadContent(){ _spriteBatch =new SpriteBatch(GraphicsDevice); _font = Content.Load<SpriteFont>("Arial"); Model model = Content.Load<Model>("cats");//создаем игровые сущности и располагаем их в случайном порядке в пределах куба {-50, -50, -50; 50, 50, 50} Random r =new Random((int)DateTime.Now.Ticks);for(int i =0; i < _modelsCount; i++){ GameEntity entity =new GameEntity(); entity.Model= model; entity.World= Matrix.CreateTranslation(new Vector3((float)r.NextDouble(), (float)r.NextDouble(), -(float)r.NextDouble())*100- Vector3.One*50); entity.OriginalBoundingBox= BoundingBox.CreateFromSphere(model.Meshes[0].BoundingSphere); entity.Update(new GameTime()); _entities.Add(entity);}}protectedoverridevoid Update(GameTime gameTime){if(GamePad.GetState(PlayerIndex.One).Buttons.Back== ButtonState.Pressed|| Keyboard.GetState().IsKeyDown(Keys.Escape))this.Exit(); _camera.Update(gameTime); _fpsCounter.Update(gameTime);base.Update(gameTime);}protectedoverridevoid Draw(GameTime gameTime){ GraphicsDevice.Clear(Color.CornflowerBlue); GraphicsDevice.DepthStencilState= DepthStencilState.Default;for(int i =0; i < _modelsCount; i++){ _entities[i].Model.Draw( _entities[i].World, _camera.View, _camera.Projection);} _spriteBatch.Begin(); _spriteBatch.DrawString(_font, string.Format("FPS: {0} Frame time: {1}", _fpsCounter.FramesPerSecond, _fpsCounter.FrameTime), Vector2.Zero, Color.White); _spriteBatch.End();base.Draw(gameTime);}
Если запустить проект сейчас, то вы увидете на экране 5000 моделей. При этом на моей машине я имею ~90 FPS. А ведь при пустой сцене было ~8000. FPS упал почти в 90 раз!!! Естественно, в реальной игре такого просто не должно быть. Здесь-то и выручают алогоритмы разбиения пространства. Конечно, мы не сможем вернуть показатель в прежнее значение 8000, но мы сможем увеличить текущее значение.
Но прежде чем мы перейдем к реализации нашего Octree, давайте добавим отрисовку ограничивающих объемов, чтобы в дальнейшем у нас была возможность видеть структуру дерева.
Нам потребуется класс DebugRenderer, который будет отрисовывать BoundingBox'ы.
Также потребуется вспомогательный класс утилит, в котором будут содержаться различные функции, в частности функция траснформации BoundingBox'ов (спасибо mike'у за нее).
publicstaticclass Utilities{publicstaticvoid TransformBox(ref BoundingBox inBox, ref Matrix mat, out BoundingBox result){ Vector3 position =(Vector3)(0.5f *(inBox.Min+ inBox.Max)); Vector3 offset; Vector3 dim =new Vector3(); Vector3.Subtract(ref inBox.Max, ref position, out offset); dim.X=((offset.X* Math.Abs(mat.M11))+(offset.Y* Math.Abs(mat.M21)))+(offset.Z* Math.Abs(mat.M31)); dim.Y=((offset.X* Math.Abs(mat.M12))+(offset.Y* Math.Abs(mat.M22)))+(offset.Z* Math.Abs(mat.M32)); dim.Z=((offset.X* Math.Abs(mat.M13))+(offset.Y* Math.Abs(mat.M23)))+(offset.Z* Math.Abs(mat.M33)); Vector3.Transform(ref position, ref mat, out position); Vector3.Subtract(ref position, ref dim, out result.Min); Vector3.Add(ref position, ref dim, out result.Max);}}
Теперь добавьте в файл игровой сущности код, отвечающий за ее обновление:
Итак, сцена готова. Теперь мы можем включать и отключать отрисовку ограничивающих объемов, обновлять и рисовать модели. Приступим к реализации самого Octree.
Построение Octree.
Создайте в проекте игры новую папку SpacePartition и добавьте в нее 2 класса Node и Octree. Но прежде чем писать код, давайте подумаем, что из себя представляют эти 2 класса?
Node (узел) - это объект, содержащий список игровых объектов, список дочерних узлов и ограничивающий объем. Так же, нам понадобится ссылка на родительский узел (если такой есть) - это даст нам возможность переходить по уровням дерева в обоих направлениях (от родителей к потомкам и наоборот).
Octree - это объект, содержащий ссылку на корневой узел, и предоставляющий методы для выполнения запросов. Таже он должен предоставлять методы для построения и обновления дерева.
Вот такие простые требования к классам. Давайте попробуем их реализовать. Начнем с узла:
Класс узла достаточно прост, потому что не содержит логики. Все самое интересное будет содержаться в классе Octree. Итак, для начала нам потребуются несколько свойств и полей:
* Root - корневой узел дерева * _maxNestingLevel - максимальный уровень вложенности узлов. * _nodeStack - стэк узлов, для обхода дерева. Он нужен для того, чтобы избавиться от рекурсии при обходе дерева. * _objectNodeMap - карта "объект-узел". Служит для быстрого добавления/удаления объектов. Мы уже говорили, что в нашем дереве каждый объект может быть привязан только к одному узлу, поэтому мы можем составить карту "объект-узел". Благодаря этой карте, имея ссылку на объект, мы можем производить быстрый поиск узла, к которому он привязан. * _leafs - трехмерный массив листьев дерева. Т.к. дерево равномерное, то его можно представить в виде обычной сетки, где каждая ячейка - это лист дерева (узел самого низкого уровня). С помощью этой сетки можно быстро вычислять в какой лист попал объект, по его координатам. * _leafSize, _leafMassiveSize - вспомогательные данные для вычисления листа, по координатам объекта.
Это главные члены класса. Давайте опишем их в коде:
public class Octree{ private int _maxNestingLevel; private Stack<Node> _nodeStack; private Dictionary<int, Node> _objectNodeMap; private Node[,,] _leafs; private int _leafMassiveSize; private float _leafSize; public Node Root { get; private set;} public Octree(int maxNestingLevel){ _maxNestingLevel = maxNestingLevel; _nodeStack = new Stack<Node>(); _objectNodeMap = new Dictionary<int, Node>();}}
У нас есть готовая структура дерева. Теперь мы можем построить его узлы. Для этого нам потребуется множество вспомогательных методов. С них и начнем. В класс Utilities добавьте следующие методы:
Данные методы позволяют вычислить размер и координаты центра BoundingBox'а.
Также, в самом классе дерева, нам понадобятся следующие вспомогательные методы: * SplitNode(Node parent) - разделяет указанный узел на 8 дочерних. * GetLeaf(GameEntity entity) - находит лист дерева, в который входит данная игровая сущность. * RegistrateEntity(GameEntity entity, Node node) - регистрирует указанную сущность в указанном узле. Этот метод нужен при добавлении сущностей в дерево. * AddEntity(GameEntity entity) - добавляет указанную сущность в дерево.
Начнем с метода SplitNode. Данный метод должен разделить указанный узел на 8 дочерних, связать родительский и дочерние узлы, а также, если дочерние узлы имеют максимальный уровень вложенности - то занести их в массив листьев дерева. Вот код данного метода:
privatevoid SplitNode(Node parent){ Vector3 min = parent.BoundingBox.Min; Vector3 max = parent.BoundingBox.Max;//получаем координаты центра нода Vector3 c =(min + max)/ 2.0f;//получаем дочерние ноды BoundingBox tempBbox =new BoundingBox();//левый верхний передний tempBbox.Min=new Vector3(min.X, c.Y, min.Z); tempBbox.Max=new Vector3(c.X, max.Y, c.Z); parent.Children.Add(new Node(parent, tempBbox, parent.NestingLevel+1));//правый верхний передний tempBbox.Min=new Vector3(c.X, c.Y, min.Z); tempBbox.Max=new Vector3(max.X, max.Y, c.Z); parent.Children.Add(new Node(parent, tempBbox, parent.NestingLevel+1));//левый нижний передний tempBbox.Min=new Vector3(min.X, min.Y, min.Z); tempBbox.Max=new Vector3(c.X, c.Y, c.Z); parent.Children.Add(new Node(parent, tempBbox, parent.NestingLevel+1));//правый нижний передний tempBbox.Min=new Vector3(c.X, min.Y, min.Z); tempBbox.Max=new Vector3(max.X, c.Y, c.Z); parent.Children.Add(new Node(parent, tempBbox, parent.NestingLevel+1));//левый верхний задний tempBbox.Min=new Vector3(min.X, c.Y, c.Z); tempBbox.Max=new Vector3(c.X, max.Y, max.Z); parent.Children.Add(new Node(parent, tempBbox, parent.NestingLevel+1));//правый верхний задний tempBbox.Min=new Vector3(c.X, c.Y, c.Z); tempBbox.Max=new Vector3(max.X, max.Y, max.Z); parent.Children.Add(new Node(parent, tempBbox, parent.NestingLevel+1));//левый нижний задний tempBbox.Min=new Vector3(min.X, min.Y, c.Z); tempBbox.Max=new Vector3(c.X, c.Y, max.Z); parent.Children.Add(new Node(parent, tempBbox, parent.NestingLevel+1));//правый нижний задний tempBbox.Min=new Vector3(c.X, min.Y, c.Z); tempBbox.Max=new Vector3(max.X, c.Y, max.Z); parent.Children.Add(new Node(parent, tempBbox, parent.NestingLevel+1));//заносим новые узлы в массив листьев, если уровень вложенности достиг максимальногоif(parent.NestingLevel+1>= _maxNestingLevel){if(_leafs ==null){ _leafSize = parent.Children[0].BoundingBox.Max.X- parent.Children[0].BoundingBox.Min.X; _leafMassiveSize =(int)Utilities.Round(((Root.BoundingBox.Max.X- Root.BoundingBox.Min.X)/ _leafSize), 0); _leafs =new Node[_leafMassiveSize, _leafMassiveSize, _leafMassiveSize];}for(int i =0; i <8; i++){//заносим листы в массив Vector3 center; parent.Children[i].BoundingBox.GetCenter(out center);int x =(int)(Math.Abs(Root.BoundingBox.Min.X- center.X)/ _leafSize);int y =(int)(Math.Abs(Root.BoundingBox.Min.Y- center.Y)/ _leafSize);int z =(int)(Math.Abs(Root.BoundingBox.Min.Z- center.Z)/ _leafSize); _leafs[x, y, z]= parent.Children[i];}}}
Следующий метод GetLeaf - производит быстрый (без перебора всего дерева) поиск листа дерева, в который входит указанная сущность. Мы будем добавлять сущность в дерево не сверху вниз, а снизу вверх. Т.е. сначала мы находим лист, в который попала сущность. Если она полностью входит в этот лист, то приписываем ее к этому листу, если нет, переходим на уровень выше и т.д. пока не найдем такой узел, в который данная сущность будет входить полностью (без пересечений BoundingBox'а узла).
Пожалуй самый интересный метод из вспомогательных - это метод AddEntity. Этот метод ищет узел, в который попала сущность, начиная от самого нижнего уровня листьев дерева и постепенно поднимаясь вверх. Если сущность лежит за пределами BoundingBox'а корневого узла дерева или пересекается с ним, то сущность привязывается к листу. Если сущность полностью входит в один из узлов, то она привязывается к этому узлу:
privatevoid AddEntity(GameEntity entity){//получаем лист, в котором находится объект Node leaf = GetLeaf(entity); _nodeStack.Clear(); _nodeStack.Push(leaf);while(_nodeStack.Count>0){ Node node = _nodeStack.Pop(); ContainmentType containmentType = ContainmentType.Disjoint; node.BoundingBox.Contains(ref entity.TransformedBoundingBox, out containmentType);switch(containmentType){case ContainmentType.Contains://если объект полностью помещается в узел, то регистрируем его в этом узле RegistrateEntity(entity, node); break;case ContainmentType.Disjoint://если объект не пересекается с узлом вообще, то значит объект вышел за пределы дерева//поэтому регистрируем его в листе, который вычислили ранее RegistrateEntity(entity, leaf); break;case ContainmentType.Intersects://если объект пересекается с узлом, то нужно проверить является ли узел корневымif(node.Parent==null){//если да, то объект лежит на границе дерева, поэтому регистрируем его//в ЛИСТЕ RegistrateEntity(entity, leaf);}else{//если нет, то передаем объект в родительский узел _nodeStack.Push(node.Parent);} break;}}}
Итак, у нас есть все необходимые вспомогательные методы для того, чтобы построить дерево. За построение дерева отвечает метод Build(List entities). Алгоритм построения довольно прост. Сначала мы получаем BoundingBox корневого узла, вычисляя его на основе списка сущностей. Затем нам нужно запустить процесс разделения корневого узла, до тех пор, пока дерево не достигнет максимального уровня вложенности. После этого, мы можем добавлять сущности в дерево по очереди. Вот код метода:
Теперь мы можем построить октарное дерево. Добавьте следующий код в класс Game1:
private Octree _octree;...//в конструкторе_octree =new Octree(4);...protectedoverridevoid LoadContent(){ _spriteBatch =new SpriteBatch(GraphicsDevice); _font = Content.Load<SpriteFont>("Arial"); Model model = Content.Load<Model>("cats");//создаем игровые сущности и располагаем их в случайном порядке в пределах куба {-50, -50, -50; 50, 50, 50} Random r =new Random((int)DateTime.Now.Ticks);for(int i =0; i < _modelsCount; i++){ GameEntity entity =new GameEntity(); entity.Model= model; entity.World= Matrix.CreateTranslation(new Vector3((float)r.NextDouble(), (float)r.NextDouble(), -(float)r.NextDouble())*100- Vector3.One*50); entity.OriginalBoundingBox= BoundingBox.CreateFromSphere(model.Meshes[0].BoundingSphere); entity.Update(new GameTime()); _entities.Add(entity);} _octree.Build(_entities);}
Если вы запустите проект сейчас, то особой разницы не увидите. Давайте, нарисуем структуру нашего дерева. Для этого в класс Octree добавьте следующий код:
publicvoid Draw(DebugRenderer debugRenderer, ref Matrix view, ref Matrix projection){//цвет узлов будет изменяться от белого к красному//белый - корневой узел//красный - листья _nodeStack.Clear(); _nodeStack.Push(Root);while(_nodeStack.Count>0){ Node node = _nodeStack.Pop();//рисуем узел, только если он не пустойif(node.Entities.Count>0){float nestingLevel =(float)node.NestingLevel/ _maxNestingLevel; Color c =new Color(1.0f, 1.0f - nestingLevel, 1.0f - nestingLevel); debugRenderer.DrawBoundingBox(ref node.BoundingBox, ref view, ref projection, ref c);}if(node.Children.Count>0){foreach(var item in node.Children){ _nodeStack.Push(item);}}}}
Запрос к дереву - это собственно основная цель нашего проекта. Octree должно быстро найти видимые для камеры объекты, при этом пропуская целые поддеревья для оптимизации. Мы создадим новый метод Query(BoundingFrustum frustum, List visibleEntities), который будет находить список видимых объектов для указанной пирамиды (BoundingFrustum). Давайте подумаем как должен работать данный метод.
* По идее он должен вернуть список, но в этом случае, дерево будет возвращать новый экземпляр списка после каждого запроса. Запрос будет выполняться на каждом кадре, значит если сейчас у нас 90 FPS, то octree будет генерировать 90 списков в секунду. Так нам никакой памяти не хватит, да и сборщик мусора будет работать только на наше дерево. Поэтому мы будем передавать один и тот же список в метод Query, предварительно его очищая. * Запрос должен начинаться с корневого узла. Если узел не пересекается с пирамидой, то значит он невидим, соответсвенно невидны все его вложенные узлы и объекты, значит дальнейшую проверку делать не надо. * Если узел полностью входит в пирамиду, значит все его вложенные узлы и объекты видимы, значит проверка тоже не нужна, остается только добавить все вложенные объекты в список. * Если узел пересекается с пирамидой, то придется проверять все его вложенные узлы и объекты. Для начала нам потребуется вспомогательный метод GetSubtree(Node node, List visibleEntities), который позволит добавить в список видимых объектов целое поддерево. Мы будем вызывать этот метод в случае, когда узел полностью входит в пирамиду:
private Stack<Node> _subtreeStack;...// в конструкторе_subtreeStack =new Stack<Node>();...privatevoid GetSubtree(Node node, List<GameEntity> visibleEntities){ _subtreeStack.Clear(); _subtreeStack.Push(node);while(_subtreeStack.Count>0){ Node n = _subtreeStack.Pop(); visibleEntities.AddRange(n.Entities);for(int i =0; i < n.Children.Count; i++){ _subtreeStack.Push(n.Children[i]);}}}
Теперь можно реализовать метод Query:
publicvoid Query(BoundingFrustum frustum, List<GameEntity> visibleEntities){ ContainmentType containmentType = ContainmentType.Disjoint; _nodeStack.Clear(); _nodeStack.Push(Root);while(_nodeStack.Count>0){ Node node = _nodeStack.Pop(); frustum.Contains(ref node.BoundingBox, out containmentType);switch(containmentType){//если узел полностью входит в пирамиду,//то заносим все поддерево в список видимых сущностейcase ContainmentType.Contains: GetSubtree(node, visibleEntities); break;//case ContainmentType.Disjoint:// ничего не делаем// break;//если узел пересекается с пирамидой, то проверяем видимость всех его объектов//а вложенные узлы добавляем в стэк для дальнейшей проверкиcase ContainmentType.Intersects: ContainmentType entContType = ContainmentType.Disjoint;for(int i =0; i < node.Entities.Count; i++){ _entityTestCount++; entContType = ContainmentType.Disjoint; frustum.Contains(ref node.Entities[i].TransformedBoundingBox, out entContType);if(entContType != ContainmentType.Disjoint){ visibleEntities.Add(node.Entities[i]);}}for(int i =0; i < node.Children.Count; i++){ _nodeStack.Push(node.Children[i]);} break;}}}
Как видите, метод довольно прост. Теперь нужно добавить пирамиду видимости в класс Camera:
public BoundingFrustum BoundingFrustum { get;private set;}...// в конструктореBoundingFrustum =new BoundingFrustum(Matrix.Identity);...// в конце метода UpdateBoundingFrustum.Matrix= View * Projection;Мы уже можем построить запрос. Для этого нужно в Game1 добавить список видимых объектов:private List<GameEntity> _visibleEntities;...// в конструкторе_visibleEntities =new List<GameEntity>();...protectedoverridevoid Update(GameTime gameTime){if(GamePad.GetState(PlayerIndex.One).Buttons.Back== ButtonState.Pressed|| Keyboard.GetState().IsKeyDown(Keys.Escape))this.Exit(); KeyboardState currentKeyboardState = Keyboard.GetState();if(currentKeyboardState != _prevKeyboardState){//переключаем флаг отрисовки ограничивающих объемовif(currentKeyboardState.IsKeyDown(Keys.B)){ _drawDebug =!_drawDebug;}} _prevKeyboardState = currentKeyboardState;//обновляем все игровые сущностиfor(int i =0; i < _entities.Count; i++){ _entities[i].Update(gameTime);}//обновляем камеру _camera.Update(gameTime);//выполняем запрос к дереву _visibleEntities.Clear(); _octree.Query(_camera.BoundingFrustum, _visibleEntities);//считаем FPS _fpsCounter.Update(gameTime);base.Update(gameTime);}protectedoverridevoid Draw(GameTime gameTime){ GraphicsDevice.Clear(Color.CornflowerBlue); GraphicsDevice.DepthStencilState= DepthStencilState.Default;for(int i =0; i < _visibleEntities.Count; i++){ _visibleEntities[i].Model.Draw( _visibleEntities[i].World, _camera.View, _camera.Projection);if(_drawDebug){ _debugRenderer.DrawBoundingBox(ref _visibleEntities[i].TransformedBoundingBox, ref _camera.View, ref _camera.Projection, ref _bboxColor);}}if(_drawDebug){ _octree.Draw(_debugRenderer, ref _camera.View, ref _camera.Projection);} _spriteBatch.Begin(); _spriteBatch.DrawString(_font, string.Format("FPS: {0} Frame time: {1}", _fpsCounter.FramesPerSecond, _fpsCounter.FrameTime), Vector2.Zero, Color.White); _spriteBatch.End();base.Draw(gameTime);}
Вот и все. Теперь мы рисуем только те объекты, которые реально видны камере. Но как это проверить? Давайте выведем debug-информацию из octree:
privateint _visibleObjectCount;privateint _nodeTestCount;privateint _entityTestCount;privatelong _queryTime;private Stopwatch _timer;...// в конструкторе_timer =new Stopwatch();...publicvoid Query(BoundingFrustum frustum, List<GameEntity> visibleEntities){ _visibleObjectCount =0; _nodeTestCount =0; _entityTestCount =0; _timer.Reset(); _timer.Start(); ContainmentType containmentType = ContainmentType.Disjoint; _nodeStack.Clear(); _nodeStack.Push(Root);while(_nodeStack.Count>0){ Node node = _nodeStack.Pop(); _nodeTestCount++; frustum.Contains(ref node.BoundingBox, out containmentType);switch(containmentType){//если узел полностью входит в пирамиду,//то заносим все поддерево в список видимых сущностейcase ContainmentType.Contains: GetSubtree(node, visibleEntities); break;//case ContainmentType.Disjoint:// ничего не делаем// break;//если узел пересекается с пирамидой, то проверяе видимость всех его объектов//а вложенные узлы добавляем в стэк для дальнейшей проверкиcase ContainmentType.Intersects: ContainmentType entContType = ContainmentType.Disjoint;for(int i =0; i < node.Entities.Count; i++){ _entityTestCount++; entContType = ContainmentType.Disjoint; frustum.Contains(ref node.Entities[i].TransformedBoundingBox, out entContType);if(entContType != ContainmentType.Disjoint){ visibleEntities.Add(node.Entities[i]);}}for(int i =0; i < node.Children.Count; i++){ _nodeStack.Push(node.Children[i]);} break;}} _visibleObjectCount = visibleEntities.Count; _timer.Stop(); _queryTime = _timer.ElapsedMilliseconds;}
В класс Game1 нужно добавить код для вывода информации на экран:
Теперь мы можем видеть сколько проверок происходит в запросе, сколько видимых объектов рисуется, какое время занимает выполнение запроса. У меня запрос выполняется мешьше чем за одну миллисекунду - это очень хороший показатель для 5000 объектов. Если в камеру не попадает ни одного объекта, то моя машина выдает ~1000 FPS, если попадают все 5000 объектов, то ~90 FPS . Согласитесь - это существенный прирост скорости. Обновление Octree (Dynamic Octree).
Наше дерево будет работать правильно, до тех пор, пока мы не решим двигать объекты по сцене. При передвижении объектов мы также должны перестроить дерево, иначе запрос будет возвращать неправильные результаты, т.к. объеты могут перемещаться из узла в узел. Давайте подумаем, что нам нужно, для того, чтобы обновить дерево? Ну, во-первых нужно знать, какие объекты обновлять, во-вторых - каждый из этих объектов сначала нужно удалить из дерева, в-третьих - каждый из этих объектов нужно добавить в дерево заново, но уже в новый узел.
Мы будем обновлять все наши объекты. Хотя в реальной игре нужно следить, за тем, чтобы дерево обновлялось только для тех объектов, у которых реально изменилась мировая матрица.
У нас уже есть метод добавления новых сущностей в дерево. Теперь нам нужно написать метод удаления сущности из дерева, а уже потом мы напишем метод обновления дерева, который будет обновлять дерево для целого списка объектов.
При удалении объекта из дерева, мы с помощью карты "объект-узел" находим нужный узел, и удаляем из него объект, а затем удаляем запись об этом объекте из самой карты.
Имея метод удаления сущностей из дерева, мы можем написать метод обновления дерева:
publicvoid Update(List<GameEntity> entities){ _timer.Reset(); _timer.Start();for(int i =0; i < entities.Count; i++){ GameEntity ent = entities[i];//удаляем объектbool result = RemoveObject(ent); Debug.Assert(result, "Не удалось удалить объект из дерева.");//добавляем объект в дерево. AddEntity(ent);} _timer.Stop(); _updateTime = _timer.ElapsedMilliseconds;}
Нам еще нужо изменить класс Game1 так, чтобы увидеть динамику. Добавьте в него следующий код:
private List<Vector3> _moves;...//в конструкторе_moves =new List<Vector3>();...protectedoverridevoid LoadContent(){ _spriteBatch =new SpriteBatch(GraphicsDevice); _font = Content.Load<SpriteFont>("Arial"); Model model = Content.Load<Model>("cats");//создаем игровые сущности и располагаем их в случайном порядке в пределах куба {-50, -50, -50; 50, 50, 50} Random r =new Random((int)DateTime.Now.Ticks);for(int i =0; i < _modelsCount; i++){ GameEntity entity =new GameEntity(); entity.Model= model; entity.World= Matrix.CreateTranslation(new Vector3((float)r.NextDouble(), (float)r.NextDouble(), -(float)r.NextDouble())*100- Vector3.One*50); entity.OriginalBoundingBox= BoundingBox.CreateFromSphere(model.Meshes[0].BoundingSphere); entity.Update(new GameTime()); _entities.Add(entity);int x = r.Next(0, 1); x = x ==0?-1:1;int y = r.Next(0, 1); y = y ==0?-1:1;int z = r.Next(0, 1); z = z ==0?-1:1; _moves.Add(new Vector3(x, y, z)* 0.1f);} _octree.Build(_entities);}protectedoverridevoid Update(GameTime gameTime){if(GamePad.GetState(PlayerIndex.One).Buttons.Back== ButtonState.Pressed|| Keyboard.GetState().IsKeyDown(Keys.Escape))this.Exit(); KeyboardState currentKeyboardState = Keyboard.GetState();if(currentKeyboardState != _prevKeyboardState){//переключаем флаг отрисовки ограничивающих объемовif(currentKeyboardState.IsKeyDown(Keys.B)){ _drawDebug =!_drawDebug;}} _prevKeyboardState = currentKeyboardState;//обновляем все игровые сущностиfor(int i =0; i < _entities.Count; i++){ Vector3 move = _moves[i]; _entities[i].World.Translation= _entities[i].World.Translation+ move; Vector3 translation = _entities[i].World.Translation;if(translation.X>50|| translation.X<-50){ move =new Vector3(-move.X, move.Y, move.Z);}if(translation.Y>50|| translation.Y<-50){ move =new Vector3(move.X, -move.Y, move.Z);}if(translation.Z>50|| translation.Z<-50){ move =new Vector3(move.X, move.Y, -move.Z);} _moves[i]= move; _entities[i].Update(gameTime);}//обновляем дерево _octree.Update(_entities);//выполняем запрос к дереву _visibleEntities.Clear(); _octree.Query(_camera.BoundingFrustum, _visibleEntities);//обновляем камеру _camera.Update(gameTime);//считаем FPS _fpsCounter.Update(gameTime);base.Update(gameTime);}
Если запустить проект сейчас, то вы увидите как объекты перемещаются в пределах куба, размером 100х100х100. Дерево динамически перестраивается вслед за изменениями сцены. На этом пожалуй все.
Заключение.
Как видно, алгоритмы разбиения пространства могут дать существенный прирост производительности. Я уже упоминал, что некоторые алгоритмы работают быстрее чем Octree (тот же BVH), но только со статическими объектами. Octree дает приемлемый результат как на статике, так и на динамике.
В качестве дальнейшей оптимизации могу преложить следующее: при обновлении дерева можно считать сколько объектов находится в каждом поддереве, а при запросе, если поддерево пустое, то пропускать его.