Путь Боли, глава I: Как я начал писать свой Minecraft на C# и почему «просто нарисовать кубик» — это ложь

В разработке игр есть два пути. Первый - взять готовый Unity или Unreal Engine, накидать моделей и сделать игру без проблем. Это удобно, быстро и современно. Но есть и второй путь - «Путь Боли». Написать всё самому. С чистого листа, строчка за строчкой.

Создатель Minecraft, Маркус «Нотч» Перссон, как-то сказал: «Если хочешь по-настоящему понять, как работают игры - напиши свой движок». Не пользуйся готовыми инструментами, где всё работает «само по себе». Разберись, как эта магия устроена внутри. И я решил попробовать. Моя цель - воссоздать мир из блоков, как в Minecraft, используя язык C#.

1. Рождение первого куба и проклятие Атласа

В начале был не свет. В начале был треугольник. В OpenGL всё, что вы видите, состоит из треугольников. Квадрат — это два треугольника. Куб — это 12 треугольников (по 2 на каждую из 6 граней).

Когда я только начинал, мой мир состоял из одного-единственного типа блока — булыжника (Cobblestone). Чтобы наложить на него текстуру, я использовал самый старый и проверенный метод — Текстурный Атлас.

Что такое текстурный атлас? Представьте один огромный лист бумаги, на котором нарисованы все текстуры игры: трава, земля, камень. Чтобы покрасить грань куба, вы говорите видеокарте: «Возьми кусочек изображения с координатами от X1 до X2». Это называется UV-разверткой.

Проблема Атласа: Как только я попытался отрисовать чанк (сектор мира 16x256x16 блоков), я столкнулся с «артефактами». На границах блоков появлялись тонкие белые линии. Это происходило из-за того, что видеокарта при сглаживании (мип-маппинге) случайно «захватывала» пиксели соседней текстуры из атласа.

Позже я перешел на Texture Arrays (массивы текстур), где каждая текстура лежит в своем «слое», но начиналось всё именно с высчитывания UV-координат вручную:

// Пример старой логики расчета UV для атласа private const float TexSize = 1.0f / 16.0f; // Если в атласе 16x16 текстур private void AddFaceUVs(List<float> v, int textureId) { float uMin = (textureId % 16) * TexSize; float vMin = (textureId / 16) * TexSize; float uMax = uMin + TexSize; float vMax = vMin + TexSize; // Передаем эти координаты в буфер вершин v.Add(uMin); v.Add(vMin); v.Add(uMax); v.Add(vMin); v.Add(uMax); v.Add(vMax); v.Add(uMin); v.Add(vMax); }

2. Понятие Чанка: Почему мы не рисуем блоки по отдельности?

Если вы создадите 65 536 объектов «Block» в C#, ваша оперативная память закончится быстрее, чем вы успеете нажать «Play». В воксельных движках используется концепция Чанков (Chunks).

Чанк — это массив данных размером 16x256x16 блоков. Мы не рисуем блоки. Мы рисуем один объект (Mesh), который представляет весь чанк сразу. Движок проходит по всему массиву, проверяет, какие грани блоков видны, и собирает их в один гигантский список вершин.

Это экономит тысячи вызовов отрисовки (Draw Calls), которые так не любит видеокарта. Весь мой класс Chunk — это, по сути, обертка над одномерным массивом, который имитирует трехмерное пространство:

public class Chunk { public const int SizeX = 16; public const int SizeY = 256; public const int SizeZ = 16; private readonly BlockType[] _blocks; public Chunk(int x, int z) { _blocks = new BlockType[SizeX * SizeY * SizeZ]; } public BlockType GetBlock(int x, int y, int z) { // Перевод 3D координат в 1D индекс массива: y * (ширина * глубина) + x * глубина + z return _blocks[(y * 16 + x) * 16 + z]; } }

3. Генерация ландшафта: Почему рандом — это плохо?

Если использовать обычную функцию Random(), чтобы определить высоту каждого столбца блоков, вы получите «цифровой шум» — хаотичные столбы разной высоты. Природа так не работает. Горы должны быть плавными.

Для этого используется Шум Симплекса (Simplex Noise).

Как это работает? Представьте бесконечное холмистое полотно. Шум Симплекса — это математическая функция, которая для любых координат (X, Z) всегда возвращает одно и то же значение «высоты» от -1 до 1. Если координаты (X, Z) меняются плавно, то и результат меняется плавно. Так рождаются пологие холмы и глубокие впадины.

Я использую так называемый FBM (Fractal Brownian Motion) — наслоение нескольких «октав» шума друг на друга. Одна октава создает большие горы, вторая — мелкие кочки, третья — детали рельефа.

// Генерация базового ландшафта for (int x = 0; x < 16; x++) { for (int z = 0; z < 16; z++) { int wx = ChunkX * 16 + x; int wz = ChunkZ * 16 + z; // Continentalness: Крупный шум (горы) float baseHeight = SimplexNoise.CalcPixel2D(wx, wz, 0.003f, 2) * 100 + 40; // Detail: Мелкий шум (кочки) float detail = SimplexNoise.CalcPixel2D(wx, wz, 0.02f, 4) * 20; int finalHeight = (int)(baseHeight + detail); // Заполняем блоки до этой высоты камнем и землей... } }

4. Физика игрока: Как перестать быть призраком

Когда я впервые запустил камеру, я был «духом». Я летал сквозь землю и видел мир изнутри. Чтобы превратить камеру в игрока, нужно было реализовать коллизии.

В воксельных играх для этого используется система AABB (Axis-Aligned Bounding Box) — «Коробка, выровненная по осям».

В чем секрет? Мы представляем игрока как невидимый прямоугольный шкаф. Этот шкаф никогда не вращается (даже если вы крутите головой). Это делает математику столкновений невероятно простой.

Главная хитрость в том, как мы обрабатываем движение. Если игрок хочет сдвинуться по вектору (1, 1, 1), мы не проверяем всё сразу.

  1. Сначала мы двигаем его по X. Если он вошел в блок — отменяем движение по X.
  2. Затем двигаем по Y (гравитация). Если он коснулся пола — обнуляем вертикальную скорость.
  3. Затем двигаем по Z.

Если делать это одновременно, игрок будет «залипать» в углах блоков. Раздельная проверка осей позволяет игроку плавно «скользить» вдоль стен.

private void UpdatePhysics(float dt, World.World world) { // Применяем гравитацию Velocity.Y -= Gravity * dt; // Движение по X Position += new Vector3(Velocity.X * dt, 0, 0); if (CheckCollision(world)) { Position -= new Vector3(Velocity.X * dt, 0, 0); // Откат при столкновении Velocity.X = 0; } // Движение по Y Position += new Vector3(0, Velocity.Y * dt, 0); if (CheckCollision(world)) { Position -= new Vector3(0, Velocity.Y * dt, 0); if (Velocity.Y < 0) _onGround = true; // Мы упали на землю Velocity.Y = 0; } // ... и так далее для оси Z }

5. Камера: Глаза нашего мира

Чтобы видеть результат, нам нужна матрица Вида (View Matrix). В моем движке класс Camera отвечает за перевод углов поворота мыши в векторы направления.

Мы используем три вектора:

  • Front — куда мы смотрим.
  • Right — направление вправо относительно взгляда.
  • Up — направление вверх.

Интересный факт: чтобы найти вектор Right, мы используем Векторное произведение (Cross Product) между направлением взгляда и глобальным вектором «вверх». Математика — это магия, которая позволяет нам вычислять направления в пространстве за микросекунды

private void UpdateVectors() { // Вычисляем Front через сферические координаты (Yaw/Pitch) Vector3 front; front.X = MathF.Cos(Yaw * Rad) * MathF.Cos(Pitch * Rad); front.Y = MathF.Sin(Pitch * Rad); front.Z = MathF.Sin(Yaw * Rad) * MathF.Cos(Pitch * Rad); Front = Vector3.Normalize(front); // Находим Right через векторное произведение Right = Vector3.Normalize(Vector3.Cross(Front, Vector3.UnitY)); Up = Vector3.Normalize(Vector3.Cross(Right, Front)); }

6. Сердце машины: Класс Engine и жизненный цикл кадра

Если чанки — это плоть нашего мира, то класс Engine — это его нервная система. В разработке на «голом» API (я использую Silk.NET для доступа к OpenGL) у вас нет готовой кнопки «Play». Вам нужно создать окно, инициализировать контекст графики и запустить бесконечный цикл, который будет опрашивать устройства ввода, обновлять физику и рисовать картинку.

Типичный кадр в моем движке проходит три стадии:

  1. OnUpdate: Здесь считается всё, что не касается графики. Физика, перемещение игрока, расчет того, какой блок мы сейчас сломаем.
  2. OnRender: Здесь мы даем команды видеокарте. Но важно понимать: в OpenGL вы не говорите «нарисуй куб». Вы говорите: «Используй вот эту программу (шейдер), привяжи вот эту текстуру и отрисуй массив данных из этого буфера памяти».
  3. Input: Мы обрабатываем ввод каждые 500 микросекунд, чтобы управление чувствовалось отзывчивым.

Интересный факт: OpenGL — это «государственная машина» (state machine). Если вы один раз сказали ей «используй красный цвет», она будет красить в красный всё подряд, пока вы не отдадите другую команду. Это главная причина багов в самописных движках: забыл сбросить состояние — и весь мир превратился в кашу.

// Главный цикл Engine.cs private void OnUpdate(double dt) { _player.Update((float)dt, _world); // Физика и камера _world.Update(_player.Position, (float)dt); // Генерация новых земель HandleInteraction(); // Ломание/установка блоков } private void OnRender(double dt) { _renderer.Draw(_world, _player, _time); // Магия визуализации }

7. Шейдеры: Код, который живет внутри видеокарты

В геймдеве шейдер — это небольшая программа на языке GLSL, которая выполняется параллельно тысячи раз для каждой вершины и каждого пикселя на экране.

Вершинный шейдер (Vertex Shader) — Архитектор пространства

Его задача — взять 3D-координату блока в мире и превратить её в 2D-координату на вашем мониторе. Для этого используются три магические матрицы:

  • Model: Где объект находится в мире.
  • View: Где находится камера.
  • Projection: Как работает перспектива (объекты вдалеке кажутся меньше).

Вершинный шейдер (Vertex Shader) — Архитектор пространства

Его задача — взять 3D-координату блока в мире и превратить её в 2D-координату на вашем мониторе. Для этого используются три магические матрицы:

  • Model: Где объект находится в мире.
  • View: Где находится камера.
  • Projection: Как работает перспектива (объекты вдалеке кажутся меньше).
// Кусок из shader.vert void main() { // Умножаем позицию на матрицы - получаем точку на экране vec4 worldPos = uModel * vec4(aPosition, 1.0); gl_Position = uProjection * uView * worldPos; // Передаем данные дальше фрагментному шейдеру TexCoord = vec3(aTexCoord, aTexIndex); LightIntensity = aLight; }

Фрагментный шейдер (Fragment Shader) — Художник

Он решает, какого цвета будет каждый конкретный пиксель. Именно здесь происходит магия освещения и тумана. В моем движке я реализовал Exponential Fog (экспоненциальный туман). Чем дальше объект, тем сильнее его цвет смешивается с цветом неба. Это не просто красиво — это скрывает «дыру» на горизонте, где чанки еще не успели сгенерироваться.

// Расчет тумана в shader.frag float distance = length(FragPos - uCameraPos); float visibility = exp(-pow((distance * density), gradient)); finalColor = mix(uFogColor, finalColor, visibility);

Интересный факт: Чтобы цвета в игре выглядели сочно, а не «выбеленно», нужно использовать Гамма-коррекцию. Человеческий глаз видит свет нелинейно, а мониторы показывают его иначе. В конце шейдера я возвожу финальный цвет в степень 1.0 / 2.2, чтобы картинка стала реалистичной.

8.Ввод данных: Как почувствовать игру

Последний элемент базы — Input. Мы используем Raw Input. Это значит, что мы не спрашиваем систему «где сейчас мышь?», а просим её отдавать нам «дельту» — на сколько пикселей сдвинулась мышь с момента последнего кадра.

Если этого не сделать, камера будет дергаться, а при достижении края экрана — останавливаться. В моем Input.cs реализовано накопление этой дельты, чтобы движение камеры было идеально плавным даже при 30 FPS.

private static void OnMouseMove(IMouse mouse, Vector2 pos) { lock (_mouseLock) { Vector2 delta = pos - MousePosition; _accumulatedMouseDelta += delta; MousePosition = pos; } }

Итог первой главы

Это была «база». Но база — это скучно. В следующей главе я расскажу, как я заставил этот мир ожить: как появились деревья (которые сначала ломали генерацию), как я внедрил прозрачную листву, которая заставила меня пересмотреть всю систему рендеринга, и почему песок — это не просто блок, а сложная физическая сущность.

2
1