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

WPF и Box2D. Как я делал физику c WPF

image

Доброго времени хабр. Я большой фанат физики в играх, работал с некоторыми интересными физическими движками но сегодня я расскажу о 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 и Viewport3D

Для отрисовки мира я решил по определенным причинам взять 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;
		}
	}

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

imageimage

Box2D — Hello world

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

image

В параметрах мира так же есть вектор гравитации и возможность объектов «засыпать» если их инерция равно нулю, это хорошо подходит для экономии ресурсов процессора. Пораметров конечно больше, но нам пока нужны только эти. Создадим класс 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();
	}
}

image

Да, я забыл упомянуть о том что я добавил в класс 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