Путь Боли, глава I: Как я начал писать свой Minecraft на C# и почему «просто нарисовать кубик» — это ложь
В разработке игр есть два пути. Первый - взять готовый Unity или Unreal Engine, накидать моделей и сделать игру без проблем. Это удобно, быстро и современно. Но есть и второй путь - «Путь Боли». Написать всё самому. С чистого листа, строчка за строчкой.
Создатель Minecraft, Маркус «Нотч» Перссон, как-то сказал: «Если хочешь по-настоящему понять, как работают игры - напиши свой движок». Не пользуйся готовыми инструментами, где всё работает «само по себе». Разберись, как эта магия устроена внутри. И я решил попробовать. Моя цель - воссоздать мир из блоков, как в Minecraft, используя язык C#.
1. Рождение первого куба и проклятие Атласа
В начале был не свет. В начале был треугольник. В OpenGL всё, что вы видите, состоит из треугольников. Квадрат — это два треугольника. Куб — это 12 треугольников (по 2 на каждую из 6 граней).
Когда я только начинал, мой мир состоял из одного-единственного типа блока — булыжника (Cobblestone). Чтобы наложить на него текстуру, я использовал самый старый и проверенный метод — Текстурный Атлас.
Что такое текстурный атлас? Представьте один огромный лист бумаги, на котором нарисованы все текстуры игры: трава, земля, камень. Чтобы покрасить грань куба, вы говорите видеокарте: «Возьми кусочек изображения с координатами от X1 до X2». Это называется UV-разверткой.
Проблема Атласа: Как только я попытался отрисовать чанк (сектор мира 16x256x16 блоков), я столкнулся с «артефактами». На границах блоков появлялись тонкие белые линии. Это происходило из-за того, что видеокарта при сглаживании (мип-маппинге) случайно «захватывала» пиксели соседней текстуры из атласа.
Позже я перешел на Texture Arrays (массивы текстур), где каждая текстура лежит в своем «слое», но начиналось всё именно с высчитывания UV-координат вручную:
2. Понятие Чанка: Почему мы не рисуем блоки по отдельности?
Если вы создадите 65 536 объектов «Block» в C#, ваша оперативная память закончится быстрее, чем вы успеете нажать «Play». В воксельных движках используется концепция Чанков (Chunks).
Чанк — это массив данных размером 16x256x16 блоков. Мы не рисуем блоки. Мы рисуем один объект (Mesh), который представляет весь чанк сразу. Движок проходит по всему массиву, проверяет, какие грани блоков видны, и собирает их в один гигантский список вершин.
Это экономит тысячи вызовов отрисовки (Draw Calls), которые так не любит видеокарта. Весь мой класс Chunk — это, по сути, обертка над одномерным массивом, который имитирует трехмерное пространство:
3. Генерация ландшафта: Почему рандом — это плохо?
Если использовать обычную функцию Random(), чтобы определить высоту каждого столбца блоков, вы получите «цифровой шум» — хаотичные столбы разной высоты. Природа так не работает. Горы должны быть плавными.
Для этого используется Шум Симплекса (Simplex Noise).
Как это работает? Представьте бесконечное холмистое полотно. Шум Симплекса — это математическая функция, которая для любых координат (X, Z) всегда возвращает одно и то же значение «высоты» от -1 до 1. Если координаты (X, Z) меняются плавно, то и результат меняется плавно. Так рождаются пологие холмы и глубокие впадины.
Я использую так называемый FBM (Fractal Brownian Motion) — наслоение нескольких «октав» шума друг на друга. Одна октава создает большие горы, вторая — мелкие кочки, третья — детали рельефа.
4. Физика игрока: Как перестать быть призраком
Когда я впервые запустил камеру, я был «духом». Я летал сквозь землю и видел мир изнутри. Чтобы превратить камеру в игрока, нужно было реализовать коллизии.
В воксельных играх для этого используется система AABB (Axis-Aligned Bounding Box) — «Коробка, выровненная по осям».
В чем секрет? Мы представляем игрока как невидимый прямоугольный шкаф. Этот шкаф никогда не вращается (даже если вы крутите головой). Это делает математику столкновений невероятно простой.
Главная хитрость в том, как мы обрабатываем движение. Если игрок хочет сдвинуться по вектору (1, 1, 1), мы не проверяем всё сразу.
- Сначала мы двигаем его по X. Если он вошел в блок — отменяем движение по X.
- Затем двигаем по Y (гравитация). Если он коснулся пола — обнуляем вертикальную скорость.
- Затем двигаем по Z.
Если делать это одновременно, игрок будет «залипать» в углах блоков. Раздельная проверка осей позволяет игроку плавно «скользить» вдоль стен.
5. Камера: Глаза нашего мира
Чтобы видеть результат, нам нужна матрица Вида (View Matrix). В моем движке класс Camera отвечает за перевод углов поворота мыши в векторы направления.
Мы используем три вектора:
- Front — куда мы смотрим.
- Right — направление вправо относительно взгляда.
- Up — направление вверх.
Интересный факт: чтобы найти вектор Right, мы используем Векторное произведение (Cross Product) между направлением взгляда и глобальным вектором «вверх». Математика — это магия, которая позволяет нам вычислять направления в пространстве за микросекунды
6. Сердце машины: Класс Engine и жизненный цикл кадра
Если чанки — это плоть нашего мира, то класс Engine — это его нервная система. В разработке на «голом» API (я использую Silk.NET для доступа к OpenGL) у вас нет готовой кнопки «Play». Вам нужно создать окно, инициализировать контекст графики и запустить бесконечный цикл, который будет опрашивать устройства ввода, обновлять физику и рисовать картинку.
Типичный кадр в моем движке проходит три стадии:
- OnUpdate: Здесь считается всё, что не касается графики. Физика, перемещение игрока, расчет того, какой блок мы сейчас сломаем.
- OnRender: Здесь мы даем команды видеокарте. Но важно понимать: в OpenGL вы не говорите «нарисуй куб». Вы говорите: «Используй вот эту программу (шейдер), привяжи вот эту текстуру и отрисуй массив данных из этого буфера памяти».
- Input: Мы обрабатываем ввод каждые 500 микросекунд, чтобы управление чувствовалось отзывчивым.
Интересный факт: OpenGL — это «государственная машина» (state machine). Если вы один раз сказали ей «используй красный цвет», она будет красить в красный всё подряд, пока вы не отдадите другую команду. Это главная причина багов в самописных движках: забыл сбросить состояние — и весь мир превратился в кашу.
7. Шейдеры: Код, который живет внутри видеокарты
В геймдеве шейдер — это небольшая программа на языке GLSL, которая выполняется параллельно тысячи раз для каждой вершины и каждого пикселя на экране.
Вершинный шейдер (Vertex Shader) — Архитектор пространства
Его задача — взять 3D-координату блока в мире и превратить её в 2D-координату на вашем мониторе. Для этого используются три магические матрицы:
- Model: Где объект находится в мире.
- View: Где находится камера.
- Projection: Как работает перспектива (объекты вдалеке кажутся меньше).
Вершинный шейдер (Vertex Shader) — Архитектор пространства
Его задача — взять 3D-координату блока в мире и превратить её в 2D-координату на вашем мониторе. Для этого используются три магические матрицы:
- Model: Где объект находится в мире.
- View: Где находится камера.
- Projection: Как работает перспектива (объекты вдалеке кажутся меньше).
Фрагментный шейдер (Fragment Shader) — Художник
Он решает, какого цвета будет каждый конкретный пиксель. Именно здесь происходит магия освещения и тумана. В моем движке я реализовал Exponential Fog (экспоненциальный туман). Чем дальше объект, тем сильнее его цвет смешивается с цветом неба. Это не просто красиво — это скрывает «дыру» на горизонте, где чанки еще не успели сгенерироваться.
Интересный факт: Чтобы цвета в игре выглядели сочно, а не «выбеленно», нужно использовать Гамма-коррекцию. Человеческий глаз видит свет нелинейно, а мониторы показывают его иначе. В конце шейдера я возвожу финальный цвет в степень 1.0 / 2.2, чтобы картинка стала реалистичной.
8.Ввод данных: Как почувствовать игру
Последний элемент базы — Input. Мы используем Raw Input. Это значит, что мы не спрашиваем систему «где сейчас мышь?», а просим её отдавать нам «дельту» — на сколько пикселей сдвинулась мышь с момента последнего кадра.
Если этого не сделать, камера будет дергаться, а при достижении края экрана — останавливаться. В моем Input.cs реализовано накопление этой дельты, чтобы движение камеры было идеально плавным даже при 30 FPS.
Итог первой главы
Это была «база». Но база — это скучно. В следующей главе я расскажу, как я заставил этот мир ожить: как появились деревья (которые сначала ломали генерацию), как я внедрил прозрачную листву, которая заставила меня пересмотреть всю систему рендеринга, и почему песок — это не просто блок, а сложная физическая сущность.