- PVSM.RU - https://www.pvsm.ru -

Доброго времени хабр. Я большой фанат физики в играх, работал с некоторыми интересными физическими движками но сегодня я расскажу о Box2D. Он максимально прост и понятен и отлично подходит для двумерной физики. Я заметил что в интернете очень мало туториалов по Box2D на C#, их почти нет. Меня неоднократно просили написать статейку по этому поводу. Чтож, время пришло. Будет много кода, букв и немного комментариев. Для вывода графики используется WPF и элемент Viewport3D. Кому интересно, добро пожаловать подкат.
Box2D [1] — компьютерная программа, свободный открытый физический движок. Box2D является физическим движком реального времени и предназначен для работы с двумерными физическими объектами. Движок разработан Эрином Катто (англ. Erin Catto), написан на языке программирования C++ и распространяется на условиях лицензии zlib.
Движок используется в двумерных компьютерных играх, среди которых Angry Birds, Limbo, Crayon Physics Deluxe, Rolando, Fantastic Contraption, Incredibots, Transformice, Happy Wheels, Color Infection, Shovel Knight, King of Thieves.
Скачать можно по ссылке Box2D.dll [2]
Для отрисовки мира я решил по определенным причинам взять WPF, кончно отрисовывать можно на чем угодно, хоть на обычном Grphics и PictureBox, но это не желательно т.к. Graphics выводит графику через ЦП и будет отнимать много процессорного времени.
Напишем небольшое окружение для работы с графикой. В дефолтное окно проекта добавим следующий XAML:
<Grid>
<Viewport3D x:Name="viewport" ClipToBounds="True">
<Viewport3D.Camera>
<PerspectiveCamera FarPlaneDistance="1000" NearPlaneDistance="0.1" Position="0, 0, 10" LookDirection="0, 0, -1" UpDirection="0, 1, 0"/>
</Viewport3D.Camera>
<ModelVisual3D>
<ModelVisual3D.Content>
<Model3DGroup x:Name="models">
<AmbientLight Color="#333"/>
<DirectionalLight Color="#FFF" Direction="-1, -1, -1"/>
<DirectionalLight Color="#FFF" Direction="1, -1, -1"/>
</Model3DGroup>
</ModelVisual3D.Content>
</ModelVisual3D>
</Viewport3D>
</Grid>
ClipToBounds — говорит то что невидимые грани будут отсекаться, хотя здесь это не пригодится т.к. будет 2D проекция, я все равно это включу.
После устанавливается перспективная камера. FarPlaneDistance — максимальное расстояние которое захватывает камера, NearPlaneDistance — минимальное расстояние, и дальше позиция, куда смотрит камера и как она смотрит. Дальше мы создаем элемент Model3DGroup в который мы будем кидать геометрию через его имя «models», и добавляем в него 3 освещения.
Ну вот, с XAML разобрались, теперь можно начать писать класс для создания геометрии:
public class MyModel3D
{
public Vector3D Position { get; set; } // Позиция квадрата
public Size Size { get; set; } // Размер квадрата
private TranslateTransform3D translateTransform; // Матрица перемещения
private RotateTransform3D rotationTransform; // Матрица вращения
public MyModel3D(Model3DGroup models, double x, double y, double z, string path, Size size, float axis_x = 0, double angle = 0, float axis_y = 0, float axis_z = 1)
{
this.Size = size;
this.Position = new Vector3D(x, y, z);
MeshGeometry3D mesh = new MeshGeometry3D();
// Проставляем вершины квадрату
mesh.Positions = new Point3DCollection(new List<Point3D>
{
new Point3D(-size.Width/2, -size.Height/2, 0),
new Point3D(size.Width/2, -size.Height/2, 0),
new Point3D(size.Width/2, size.Height/2, 0),
new Point3D(-size.Width/2, size.Height/2, 0)
});
// Указываем индексы для квадрата
mesh.TriangleIndices = new Int32Collection(new List<int> { 0, 1, 2, 0, 2, 3 });
mesh.TextureCoordinates = new PointCollection();
// Устанавливаем текстурные координаты чтоб потом могли натянуть текстуру
mesh.TextureCoordinates.Add(new Point(0, 1));
mesh.TextureCoordinates.Add(new Point(1, 1));
mesh.TextureCoordinates.Add(new Point(1, 0));
mesh.TextureCoordinates.Add(new Point(0, 0));
// Натягиваем текстуру
ImageBrush brush = new ImageBrush(new BitmapImage(new Uri(path)));
Material material = new DiffuseMaterial(brush);
GeometryModel3D geometryModel = new GeometryModel3D(mesh, material);
models.Children.Add(geometryModel);
translateTransform = new TranslateTransform3D(x, y, z);
rotationTransform = new RotateTransform3D(new AxisAngleRotation3D(new Vector3D(axis_x, axis_y, axis_z), angle), 0.5, 0.5, 0.5);
Transform3DGroup tgroup = new Transform3DGroup();
tgroup.Children.Add(translateTransform);
tgroup.Children.Add(rotationTransform);
geometryModel.Transform = tgroup;
}
// Утсанавливает позицию объекта
public void SetPosition(Vector3D v3)
{
translateTransform.OffsetX = v3.X;
translateTransform.OffsetY = v3.Y;
translateTransform.OffsetZ = v3.Z;
}
public Vector3D GetPosition()
{
return new Vector3D(translateTransform.OffsetX, translateTransform.OffsetY, translateTransform.OffsetZ);
}
// Поворачивает объект
public void Rotation(Vector3D axis, double angle, double centerX = 0.5, double centerY = 0.5, double centerZ = 0.5)
{
rotationTransform.CenterX = translateTransform.OffsetX;
rotationTransform.CenterY = translateTransform.OffsetY;
rotationTransform.CenterZ = translateTransform.OffsetZ;
rotationTransform.Rotation = new AxisAngleRotation3D(axis, angle);
}
public Size GetSize()
{
return Size;
}
}
Этот класс создает квадрат и отрисовывает на нем текстуру. Думаю по названиям методов понятно какой метод за что отвечает. Для рисования геометрических фигур я буду использовать текстуру и накладывать ее на квадрат с альфа каналом.


Начнем с самого основного, с создания мира. Мир в Box2D имеет определенные параметры, это границы(квадрат) в которых обрабатываются физические тела.

В параметрах мира так же есть вектор гравитации и возможность объектов «засыпать» если их инерция равно нулю, это хорошо подходит для экономии ресурсов процессора. Пораметров конечно больше, но нам пока нужны только эти. Создадим класс Physics и добавим следующий конструктор:
public class Physics
{
private World world;
public Physics(float x, float y, float w, float h, float g_x, float g_y, bool doSleep)
{
AABB aabb = new AABB();
aabb.LowerBound.Set(x, y); // Указываем левый верхний угол начала границ
aabb.UpperBound.Set(w, h); // Указываем нижний правый угол конца границ
Vec2 g = new Vec2(g_x, g_y); // Устанавливаеи вектор гравитации
world = new World(aabb, g, doSleep); // Создаем мир
}
}
Дальше необходимо написать методы для добавления различных физических тел, я добавлю 3 метода создающих круг и квадрат и многоугольник. Сразу добавляю в класс Physics две константы:
private const string PATH_CIRCLE = @"Assetscircle.png"; // Изображение круга
private const string PATH_RECT = @"Assetsrect.png"; // Изображение квадрата
Описываю метод для создания квадратного тела:
public MyModel3D AddBox(float x, float y, float w, float h, float density, float friction, float restetution)
{
// Создается наша графическая модель
MyModel3D model = new MyModel3D(models, x, -y, 0, PATH_RECT, new System.Windows.Size(w, h));
// Необходим для установи позиции, поворота, различных состояний и т.д. Советую поюзать свойства этих объектов
BodyDef bDef = new BodyDef();
bDef.Position.Set(x, y);
bDef.Angle = 0;
// Наш полигон который описывает вершины
PolygonDef pDef = new PolygonDef();
pDef.Restitution = restetution;
pDef.Friction = friction;
pDef.Density = density;
pDef.SetAsBox(w / 2, h / 2);
// Создание самого тела
Body body = world.CreateBody(bDef);
body.CreateShape(pDef);
body.SetMassFromShapes();
body.SetUserData(model); // Это отличная функция, она на вход принемает объекты типа object, я ее использовал для того чтобы запихнуть и хранить в ней нашу графическую модель, и в методе step ее доставать и обновлять
return model;
}
И для создания круглого тела:
public MyModel3D AddCircle(float x, float y, float radius, float angle, float density,
float friction, float restetution)
{
MyModel3D model = new MyModel3D(models, x, -y, 0, PATH_CIRCLE, new System.Windows.Size(radius * 2, radius * 2));
BodyDef bDef = new BodyDef();
bDef.Position.Set(x, y);
bDef.Angle = angle;
CircleDef pDef = new CircleDef();
pDef.Restitution = restetution;
pDef.Friction = friction;
pDef.Density = density;
pDef.Radius = radius;
Body body = world.CreateBody(bDef);
body.CreateShape(pDef);
body.SetMassFromShapes();
body.SetUserData(model);
return model;
}
Я тут этого делать не буду, но можно создавать многоугольники примерно таким способом:
public MyModel3D AddVert(float x, float y, Vec2[] vert, float angle, float density,
float friction, float restetution)
{
MyModel3D model = new MyModel3D(models, x, y, 0, Environment.CurrentDirectory + "\" + PATH_RECT, new System.Windows.Size(w, h)); // Данный метод нужно заменить на рисование многоугольников
BodyDef bDef = new BodyDef();
bDef.Position.Set(x, y);
bDef.Angle = angle;
PolygonDef pDef = new PolygonDef();
pDef.Restitution = restetution;
pDef.Friction = friction;
pDef.Density = density;
pDef.SetAsBox(model.Size.Width / 2, model.Size.Height / 2);
pDef.Vertices = vert;
Body body = world.CreateBody(bDef);
body.CreateShape(pDef);
body.SetMassFromShapes();
body.SetUserData(model);
return info;
}
Очень важно рисовать выпуклые многоугольники чтоб коллизии обрабатывались корректно.
Тут все достаточно просто если вы знаете английский. Дальше нужно создать метод для обработки логики:
public void Step(float dt, int iterat)
{
// Параметры этого метода управляют временем мира и точностью обработки коллизий тел
world.Step(dt / 1000.0f, iterat, iterat);
for (Body list = world.GetBodyList(); list != null; list = list.GetNext())
{
if (list.GetUserData() != null)
{
System.Windows.Media.Media3D.Vector3D position = new System.Windows.Media.Media3D.Vector3D(
list.GetPosition().X, list.GetPosition().Y, 0);
float angle = list.GetAngle() * 180.0f / (float)System.Math.PI; // Выполняем конвертацию из градусов в радианы
MyModel3D model = (MyModel3D)list.GetUserData();
model.SetPosition(position); // Перемещаем нашу графическую модель по x,y
model.Rotation(new System.Windows.Media.Media3D.Vector3D(0, 0, 1), angle); // Вращаем по координате x
}
}
}
Помните тот model в методах AddCircle и AddBox который мы запихивали в body.SetUserDate()? Так вот, тут мы его достаем MyModel3D model = (MyModel3D)list.GetUserData(); и вертим как говорит нам Box2D.
Теперь это все можно затестить, вот мой код в дефолтном классе окна:
public partial class MainWindow : Window
{
private Game.Physics px;
public MainWindow()
{
InitializeComponent();
px = new Game.Physics(-1000, -1000, 1000, 1000, 0, -0.005f, false);
px.SetModelsGroup(models);
px.AddBox(0.6f, -2, 1, 1, 0, 0.3f, 0.2f);
px.AddBox(0, 0, 1, 1, 0.5f, 0.3f, 0.2f);
this.LayoutUpdated += MainWindow_LayoutUpdated;
}
private void MainWindow_LayoutUpdated(object sender, EventArgs e)
{
px.Step(1.0f, 20); // тут по хорошему нужно вычислять дельту времени, но лень :)
this.InvalidateArrange();
}
}

Да, я забыл упомянуть о том что я добавил в класс Physics метод px.SetModelsGroup(); для удобства передачи ссылки на объект Model3DGroup. Если вы используете какой нибудь другой графический движок, то вы можете обойтись и без этого.
Вы наверное заметили что значения координат кубиков слишком маленькие, ведь мы привыкли работать с пикселем. Это связано с тем что в Box2D все метрики расчитываются в метрах, по этому если вы хотите чтобы все расчитывалось в пикселях, вам нужно пиксели делить на 30. На пример bDef.SetPosition(x / 30.0f, y / 30.0f); и все будет гуд.
Уже с этими знаниями можно успешно написать простенькую игру, но есть у Box2D еще несколько фишек, на пример отслеживание столконовений. На пример что бы знать что пуля попала по персонажу, или для моделирования разного грунта и т.д. Создадим класс Solver:
public class Solver : ContactListener
{
public delegate void EventSolver(MyModel3D body1, MyModel3D body2);
public event EventSolver OnAdd;
public event EventSolver OnPersist;
public event EventSolver OnResult;
public event EventSolver OnRemove;
public override void Add(ContactPoint point)
{
base.Add(point);
OnAdd?.Invoke((MyModel3D)point.Shape1.GetBody().GetUserData(), (MyModel3D)point.Shape2.GetBody().GetUserData());
}
public override void Persist(ContactPoint point)
{
base.Persist(point);
OnPersist?.Invoke((MyModel3D)point.Shape1.GetBody().GetUserData(), (MyModel3D)point.Shape2.GetBody().GetUserData());
}
public override void Result(ContactResult point)
{
base.Result(point);
OnResult?.Invoke((MyModel3D)point.Shape1.GetBody().GetUserData(), (MyModel3D)point.Shape2.GetBody().GetUserData());
}
public override void Remove(ContactPoint point)
{
base.Remove(point);
OnRemove?.Invoke((MyModel3D)point.Shape1.GetBody().GetUserData(), (MyModel3D)point.Shape2.GetBody().GetUserData());
}
}
Прошу заметить что мы наследуем класс от ContactListener. Используется в Box2D для отслеживания коллизий. Дальше мы просто передадим объект этого класса объекту world в классе Physics, для этого напишем функцию:
public void SetSolver(ContactListener listener)
{
world.SetContactListener(listener);
}
Создадим объект и передадим его:
Game.Solver solver = new Game.Solver();
px.SetSolver(solver);
В классе Solver есть несколько колбэков которые вызываются по очереди в соответствии с названиями, повесим один на прослушивание:
solver.OnAdd += (model1, model2) =>
{
// Произошло столкновение тел model1 и model2
};
Так же вы можите прикрутить к классу MyModel3D свойство типа string name, задавать ему значение и уже в коллбэке OnAdd проверять конкретно какое тело с каким телом столкнулось.
Так же Box2D позволяет делать связи между телами. Они могут быть разных типов, рассмотрим пару:
public Joint AddJoint(Body b1, Body b2, float x, float y)
{
RevoluteJointDef jd = new RevoluteJointDef();
jd.Initialize(b1, b2, new Vec2(x, y));
Joint joint = world.CreateJoint(jd);
return joint;
}
Это простое жесткое соединение тела b1 и b2 в точке x, y. Вы можете посмотреть свойства у класса RevoluteJointDef. Там можно сделать так чтобы объект вращался, подходит для создания машины, или мельницы. Идем дальше:
public Joint AddDistanceJoint(Body b1, Body b2, float x1, float y1, float x2, float y2,
bool collideConnected = true, float hz = 1f)
{
DistanceJointDef jd = new DistanceJointDef();
jd.Initialize(b1, b2, new Vec2(x1, y1), new Vec2(x2, y2));
jd.CollideConnected = collideConnected;
jd.FrequencyHz = hz;
Joint joint = world.CreateJoint(jd);
return joint;
}
Это более интересная связь, она эмитирует пружину, значение hz — напряженность пружины. С таким соединением хорошо делать подвеску для машины, или катапульту.
Это далеко не все что может Box2D. Что самое классное в этом движке, так это то что он бесплатный и на него есть порт под любую платформу, и синтаксис практически не отличается. Кстати я пробовал его использовать в Xamarin на 4.1.1 андроиде, сборщик мусора постоянно тормозил приложения из-за того что Box2D плодил много мусора. Говорят начиная с пятого андроида благодоря ART все не так плохо, хотя я не проверял.
Ссылка на проект GitHub: github.com/Winster332/Habrahabr [3]
Порт на dotnet core: github.com/Winster332/box2d-dotnet-core-1.0 [4]
Автор: Win332
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-2/260393
Ссылки в тексте:
[1] Box2D: https://ru.wikipedia.org/wiki/Box2D
[2] Box2D.dll: https://vk.com/doc-71023327_447804791
[3] github.com/Winster332/Habrahabr: https://github.com/Winster332/Habrahabr
[4] github.com/Winster332/box2d-dotnet-core-1.0: https://github.com/Winster332/box2d-dotnet-core-1.0
[5] Источник: https://habrahabr.ru/post/333040/?utm_source=habrahabr&utm_medium=rss&utm_campaign=sandbox
Нажмите здесь для печати.