Введение

В этой серии туториалов мы реализуем простой Third Person Controller на базе MonoGame.

Серия рассчитана на читателей, уже знакомых с основами MonoGame и 3D-графики.

Для комфортного понимания материала желательно разбираться в следующих темах:

  • C#

  • MonoGame

  • Основы 3D-геометрии — матрицы, вектора, преобразования

  • Понимание работы камеры в 3D-сцене и того, что такое world, view и projection матрицы

  • Базовый 3D-рендеринг в MonoGame (BasicEffect, SkinnedEffect)

  • Скелетная анимация (для первой части необязательно)

Если с чем-то из этого списка вы пока не знакомы — в Интернете достаточно хороших материалов. В частности, рекомендую знаменитые Reimers Tutorials:

https://github.com/simondarksidej/XNAGameStudio/wiki/RiemersArchiveOverview

DigitalRiseModel

На протяжении всей серии мы будем использовать мою библиотеку:

https://github.com/rds1983/DigitalRiseModel

Это попытка сделать более удобное API для работы с 3D-моделями в MonoGame.

Библиотека позволяет:

  • Создавать 3D-примитивы (кубы, сферы, капсулы и т.д.) прямо в коде

  • Загружать модели в форматах gltf/glb

  • Работать со скелетной анимацией

No Content Pipeline

Также важно отметить, что в этой серии мы не будем использовать Content Pipeline.

Почему я предпочитаю не использовать Content Pipeline, я подробно описал здесь:

https://habr.com/ru/articles/1039344/

Вместо него мы будем использовать другую мою библиотеку:

https://github.com/rds1983/XNAssets

Она позволяет загружать ассеты напрямую в «сыром» виде без промежуточной компиляции.

Часть I

В первой части герой будет представлен в виде капсулы. Мы реализуем рендеринг, движение и прыжки.

Итоговый результат будет выглядеть так:

В следующих частях мы заменим капсулу на полноценную модель персонажа и добавим анимации.

Туториал

Создание проекта

Создайте новый MonoGame-проект под любую платформу (например DesktopGL).

После этого добавьте NuGet-пакет:

https://www.nuget.org/packages/DigitalRiseModel.MonoGame/

Он автоматически подтянет и XNAssets.

Скачайте этот архив:

https://github.com/rds1983/ThirdPersonTutorial/raw/refs/heads/master/Step1-Capsule/Assets.zip

Затем распакуйте его (он состоит всего из одного файла checker.dds) в папку проекта и добавьте в .csproj следующий код:

<ItemGroup>
  <None Update="Assets\**\*.*">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
</ItemGroup>

Таким образом ассеты нашего проекта будут всегда копироваться в Output Directory.

Инициализация и загрузка контента

Для простоты весь код мы будем писать прямо внутри нашего Game-класса.

Для начала добавим константу, задающую стандартную высоту персонажа:

// Hero ground height
private const float DefaultY = 1;

Теперь объявим следующие поля:

// Stock effect with directional lighting and texturing
private BasicEffect _basicEffect;

// Ground plane texture
private Texture2D _textureGround;

// Ground plane mesh
private DrMesh _meshGround;

// Capsule mesh for the player
private DrMesh _meshHero;

// Hero position in world space
private Vector3 _heroPosition;

DrMesh — это класс из DigitalRiseModel. По сути он является аналогом обычного ModelMesh из MonoGame.

Теперь реализуем LoadContent:

protected override void LoadContent()
{
	base.LoadContent();

	// Load ground texture
	var assetManager = AssetManager.CreateFileAssetManager(Path.Combine(AppContext.BaseDirectory, "Assets"));
	_textureGround = assetManager.LoadTexture2D(GraphicsDevice, "Textures/checker.dds");

	// Create ground and hero meshes
	_meshGround = MeshPrimitives.CreatePlaneMesh(GraphicsDevice, uScale: 50, vScale: 50, normalDirection: NormalDirection.UpY);
	_meshHero = MeshPrimitives.CreateCapsuleMesh(GraphicsDevice);

	// Set up rendering effect with lighting
	_basicEffect = new BasicEffect(GraphicsDevice) { LightingEnabled = true };
	_basicEffect.DirectionalLight0.Enabled = true;
	_basicEffect.DirectionalLight0.Direction = new Vector3(-1, -1, -1);
	_basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3();

	// Start hero at world center
	_heroPosition = new Vector3(0, DefaultY, 0);
}

Здесь всё довольно прямолинейно:

  • загружаем текстуру земли

  • создаём меш земли

  • создаём капсулу персонажа

  • настраиваем освещение

  • задаём стартовую позицию героя

Вспомогательные методы

Добавим пару утилитных методов: DrawMesh и ToMatrix

DrawMesh

/// <summary>Render a mesh with color and texture.</summary>
private void DrawMesh(DrMesh mesh, Matrix world, Color color, Texture2D texture)
{
	_basicEffect.DiffuseColor = color.ToVector3();
	_basicEffect.TextureEnabled = texture != null;
	_basicEffect.Texture = texture;
	_basicEffect.World = world;

	var device = GraphicsDevice;
	foreach (var part in mesh.MeshParts)
	{
		device.SetVertexBuffer(part.VertexBuffer);
		device.Indices = part.IndexBuffer;

		foreach (EffectPass pass in _basicEffect.CurrentTechnique.Passes)
		{
			pass.Apply();
			device.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, part.PrimitiveCount);
		}
	}
}

Метод рисует меш с заданной матрицой трансформации, цветом и текстурой.

ToMatrix

/// <summary>Build transform matrix from position, scale, and rotation (TRS order).</summary>
private static Matrix ToMatrix(Vector3 position, Vector3 scale, float yaw, float pitch, float roll)
{
	var scaleTransform = Matrix.CreateScale(scale);
	var rotation = Matrix.CreateFromYawPitchRoll(MathHelper.ToRadians(yaw), MathHelper.ToRadians(pitch), MathHelper.ToRadians(roll));
	var translation = Matrix.CreateTranslation(position);

	return scaleTransform * rotation * translation;
}

Этот метод собирает стандартную матрицу трансформации.

Важно понимать порядок операций:

Scale -> Rotate -> Translate

В MonoGame/XNA матрицы перемножаются слева направо, поэтому объект сначала масштабируется, затем вращается и только потом перемещается в мир.


Hero, Camera Mount и Camera

Сразу предупреждаю, что это самый сложный раздел в туториале.

Мы хотим получить классическую third-person камеру, которая:

  • Всегда следует за персонажем

  • Позволяет вращать персонажа мышкой по горизонтали

  • Позволяет наклонять камеру вверх-вниз

Другими словами, мы хотим такую конструкцию:

Она состоит 3 объектов:

  • Hero (капсула) - персонаж

  • Camera Mount — жёсткий “штатив”, прикреплённый к голове персонажа. У его самого основания есть шарнир, позволяющий вращение вверх-вниз

  • Camera - сама камера, которая находится на другом конце штатива. Она всегда повернута на 180 градусов по оси Y, дабы смотреть на спину персонажа.

Когда персонаж поворачивается влево-вправо — поворачивается весь штатив вместе с камерой. Когда мы вращаем штатив — камера наклоняется вместе с ним, но сам персонаж при этом не кренится.

Вся эта конструкция задаётся тремя переменными:

  • Положение Hero в мире (Vector3 _heroPosition)

  • Угол поворота героя вокруг оси Y (float _heroYaw)

  • Угол поворота Camera Mount вокруг оси X (float _cameraMountPitch)

Первую мы уже задали. Теперь добавим остальные:

// Hero body yaw rotation in degrees
private float _heroYaw;

// Camera mount pitch rotation in degrees
private float _cameraMountPitch;

Вся предложенная конструкция является иерархией трансформаций.

Если мы хотим вычислить итоговую трансформацию камеры(а мы этого хотим, дабы вычислить матрицу camera view), то необходимо последовательно вычислить трансформации всех объектов цепочки.

Вычисление иерархии трансформации и рендеринг сцены

Объявим константы камеры:

// Camera near clipping plane
private const float NearPlaneDistance = 0.1f;
// Camera far clipping plane
private const float FarPlaneDistance = 1000.0f;
// Camera field of view in degrees
private const float ViewAngle = 60.0f;

Теперь можно реализовать Draw, где и реализовано вычисление иерархии трансформаций:

protected override void Draw(GameTime gameTime)
{
	base.Draw(gameTime);

	var device = GraphicsDevice;
	device.Clear(Color.Black);

	// Set GPU states
	device.DepthStencilState = DepthStencilState.Default;
	device.RasterizerState = RasterizerState.CullCounterClockwise;
	device.BlendState = BlendState.AlphaBlend;
	device.SamplerStates[0] = SamplerState.LinearWrap;

	// Set projection
	var projection = Matrix.CreatePerspectiveFieldOfView(
		MathHelper.ToRadians(ViewAngle),
		device.Viewport.AspectRatio,
		NearPlaneDistance, FarPlaneDistance);
	_basicEffect.Projection = projection;

	// Build camera hierarchy: hero body -> camera mount (head) -> camera
	var heroTransform = ToMatrix(_heroPosition, Vector3.One, _heroYaw, 0, 0);

	var cameraMountOffset = new Vector3(0, 1f, 0); // Camera mount is on the head level - 1 unit above hero position
	var cameraMountTransform = ToMatrix(cameraMountOffset, Vector3.One, 0, _cameraMountPitch, 0) * heroTransform;

	var cameraOffset = new Vector3(0, 0, -5); // Camera is 5 units behind the mount
	var cameraTransform = ToMatrix(cameraOffset, Vector3.One, 180, 0, 0) * cameraMountTransform; // Rotate 180 degrees to look back at the hero

	_basicEffect.View = Matrix.Invert(cameraTransform);

	// Draw ground and hero
	DrawMesh(_meshGround, Matrix.CreateScale(200, 1, 200), Color.White, _textureGround);
	DrawMesh(_meshHero, heroTransform, Color.Green, null);
}

Трансформация камеры вычисляется поэтапно. Сначала вычисляется трансформация персонажа heroTransform. Затем на его основании вычисляется cameraMountTransform. Он смешён на 1 по оси Y(cameraMountOffset), дабы быть на уровне головы. После этого вычисляется cameraTransform. Он смещен на -5 по оси Y и повёрнут на 180 градусов, чтобы на некотором расстоянии всегда смотреть на спину персонажа.

Почему View Matrix инвертируется

Рассмотрим эту строку:

_basicEffect.View = Matrix.Invert(cameraTransform);

Важно понимать:

View Matrix — это не transform камеры.

Наоборот, это матрица, которая преобразует мир относительно камеры.

Поэтому для получения View Matrix необходимо инвертировать мировую трансформацию камеры.

Промежуточный итог

Если мы запустим игру сейчас, то получим следующую картинку:

Рендеринг уже работает, однако камера пока остаётся неподвижной.

Движение камеры

Добавим константу, обозначающую чувствительность мышки:

// Mouse look sensitivity multiplier
private const float MouseSensitivity = 0.2f;

Добавим поле для хранения последнего состояния мышки:

// Previous mouse state for delta calculation
private MouseState? _oldMouse = null;

Теперь в Update добавим следующий код:

protected override void Update(GameTime gameTime)
{
	base.Update(gameTime);

	// Handle mouse input for camera rotation
	var mouse = Mouse.GetState();

	if (_oldMouse != null)
	{
		// Rotate hero by mouse X delta
		var horizontalRotation = -(int)((mouse.X - _oldMouse.Value.X) * MouseSensitivity);
		_heroYaw += horizontalRotation;

		// Tilt camera by mouse Y delta
		var verticalRotation = -(int)((mouse.Y - _oldMouse.Value.Y) * MouseSensitivity);
		_cameraMountPitch += verticalRotation;

		// Clamp pitch to valid range (-20 to 70 degrees)
		_cameraMountPitch = MathHelper.Clamp(_cameraMountPitch, -20, 70);
	}

	_oldMouse = mouse;
}

Сам по себе код весьма очевиден. Мы меняем ранее заданные _heroYaw и _cameraMountPitch на соотвествующие изменения координаты мышки(горизонтальную для _heroYaw и вертикальную для _cameraMountPitch).

Запустим игру и убедимся, что вращение камеры работает:

Движение персонажа

Добавим константу, обозначающую скорость движения:

// Movement speed per frame
private const float MovementSpeed = 0.1f;

Теперь в Update добавим код, который осуществляет это самое движения при нажатии клавиш WASD:

// WASD movement
var velocity = Vector3.Zero;
var heroTransform = ToMatrix(_heroPosition, Vector3.One, _heroYaw, 0, 0);
var keyboard = Keyboard.GetState();

if (keyboard.IsKeyDown(Keys.W))
	velocity = heroTransform.Forward * -MovementSpeed;
else if (keyboard.IsKeyDown(Keys.S))
	velocity = heroTransform.Forward * MovementSpeed;
else if (keyboard.IsKeyDown(Keys.A))
	velocity = heroTransform.Right * MovementSpeed;
else if (keyboard.IsKeyDown(Keys.D))
	velocity = heroTransform.Right * -MovementSpeed;

_heroPosition += velocity;

Здесь мы вычисляем матрицу трансформации Hero, дабы получить из неё вектора Forward(нужен для движения вперёд-напад) и Right(для движения влево-вправо). Далее мы используем эти вектора, чтобы рассчитать новое положение персонажа.

Запустим игру и убедимся, что движение персонажа работает:

Прыжки

Последнее, что мы добавим - это прыжки.

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

Начнём как обычно с задания новых констант:

// Jump gravity acceleration per second
private const float Gravity = 12f;
// Jump initial velocity
private const float JumpForce = 10f;

А так же пары полей, задающих состояние прыжка

// Jump state and physics
private DateTime? _jumpStarted;
private Vector3 _jumpMovement;

Наконец перепишем вышеприведённый код движения так:

// Handle movement and jumping
if (_jumpStarted == null)
{
	// WASD movement
	var velocity = Vector3.Zero;
	var heroTransform = ToMatrix(_heroPosition, Vector3.One, _heroYaw, 0, 0);
	var keyboard = Keyboard.GetState();

	if (keyboard.IsKeyDown(Keys.W))
		velocity = heroTransform.Forward * -MovementSpeed;
	else if (keyboard.IsKeyDown(Keys.S))
		velocity = heroTransform.Forward * MovementSpeed;
	else if (keyboard.IsKeyDown(Keys.A))
		velocity = heroTransform.Right * MovementSpeed;
	else if (keyboard.IsKeyDown(Keys.D))
		velocity = heroTransform.Right * -MovementSpeed;

	_heroPosition += velocity;

	if (keyboard.IsKeyDown(Keys.Space))
	{
		// Jump
		_jumpStarted = DateTime.Now;
		_jumpMovement = velocity;
	}
}
else
{
	// When moving with acceleration
	// Formula for the jump height: h = h0 + v0 * t - 0.5 * g * t^2
	// Where h0 is the initial height(DefaultY), v0 is the initial jump velocity(JumpForce), g is the gravity(JumpGravity), and t is the time passed since jump started

	var t = (float)(DateTime.Now - _jumpStarted.Value).TotalSeconds;

	var jumpHeight = DefaultY + (JumpForce * t) - (0.5f * Gravity * t * t);
	_heroPosition.Y = jumpHeight;
	_heroPosition += _jumpMovement;

	// Land when reaching ground
	if (_heroPosition.Y <= DefaultY)
	{
		_heroPosition.Y = DefaultY;
		_jumpStarted = null;
	}
}

Всё достаточно очевидно. При нажатии пробела, мы устанавливаем время начала прыжка и инерцию.

В самом же прыжке, мы рассчитываем высоту персонажа по известной со школьных времён формуле движения с ускорением. Когда мы падаем ниже DefaultY, то оканчиваем прыжок.

Туториал окончен. Наша игра должна соответствовать видео из начала этой статьи.

Заключение

Полный исходный код этой части можно посмотреть здесь:

https://github.com/rds1983/ThirdPersonTutorial/blob/master/Step1-Capsule/MyGame.cs

Комментарии (2)


  1. Wosk1947
    28.05.2026 05:07

    Спасибо! В продолжении хотелось бы, чтобы вы рассказали о том, как разрншаются коллизии камеры от третьего лица с окружением.


    1. rds1983 Автор
      28.05.2026 05:07

      Если это и будет, то нескоро. Поскольку в ближайших частях планируется заменить капсулу на нормальную модель с анимациями. Показать как к ней можно приаттачить меч и т.д.