Pull to refresh
1871.57
Timeweb Cloud
То самое облако

Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?

Level of difficultyMedium
Reading time21 min
Views14K


Осторожно: Несмотря на кажущуюся сложность статьи о разработке целой 3D-игры с нуля, я постарался систематизировать и упростить материал так, чтобы понятно было любому заинтересованному читателю, даже если вы далеки от программирования в целом!

Статьи о разработке инди-игр — это всегда интересно. Но разработка чего-то абсолютно с нуля, без каких-либо движков или фреймворков — ещё интереснее! Почти всю свою жизнь, буквально с 13-14 лет меня тянет пилить какие-нибудь прикольные 3D-демки и игрушки. Ещё на первом курсе ПТУ я написал небольшую демку с 3D-вертолетиками по сети и идея запилить какие-нибудь прикольные леталки не покидала меня по сей день! Спустя 6 лет, в 22 года я собрался с силами и решил написать небольшую аркадную демку про баталии на самолетиках, да так, чтобы работало аж на видеокартах из 90-х — NVidia Riva 128 и 3DFX Voodoo 3! Интересно, как происходит процесс разработки игры с нуля — от первого «тридэ» треугольника, до работающей на реальном железе демки? Тогда добро пожаловать под кат!

Мотивация


Друзья! Вижу, что вам очень заходит моя постоянная рубрика о том, как работали графические ускорители из 90-х «под капотом», где мы не только разбираем их архитектуру, но и пишем демки на их собственных графических API. Мы уже успели с вами рассмотреть 3Dfx Voodoo, S3 ViRGE и мобильный PowerVR MBX и, думаю, теперь пришло время рассмотреть инструменты для разработчиков игр под Windows из 90-х. Про «старый» OpenGL рассказывать смысла не вижу — до сих пор многие новички учатся по материалам с glBegin/glEnd и FFP (Fixed Function Pipeline), а спецификацию с описанием первой версии API можно найти прямо на сайте Khronos. Зато про «старый» DirectX информации в сети очень мало и большинство документации уже потёрли даже из MSDN, хотя в нём было много чего интересного!

image

Вероятно читатель спросит — зачем пилить что-то для компьютеров 90-х годов, если большинство таких машин (к сожалению) отправились на цветмет и «никто в своем уме» не будет ими пользоваться? Ну, ретро-компьютинг и программирование демок — это, во-первых, всегда интересно. Среди моих подписчиков довольно много ребят, которые ещё учатся в школе, а уже натаскали с барахолок Pentium III или Pentium IV и GeForce 4 MX440 и сидят, балдеют и играют в замечательные игрушки из нулевых на таких машинах с по настоящему трушным опытом, да и я сам таким был и остаюсь по сей день. Вон, мне даже dlinyj скидывал свои девайсы в личку, а я сидел и слюни пускал. Так что факт остаётся фактом — ретро-компьютинг становится всё более и более популярен — что не может не радовать!



А во-вторых — это челлендж для самого себя! Посмотреть на то, как делали игры «деды» и попытаться запилить что-то самому, не забыв об этом написать статью и снять интересное видео в попытке донести это как можно большему числу читателей и зрителей! Конечно сам DirectX6 в целом значительно проще DX12, но некоторые техники весьма заковыристые и для достижения оптимальной производительности приходится пользоваться хаками. Ну а почему именно леталки? Потому что, наверное, хотел бы когда-нибудь полетать :)



Игру я решил писать на C#. Кому-то решение может показаться странным, но я уже не раз говорил, что это мой любимый язык, а при определенной сноровке — программы на нем работают даже под Windows 98. В качестве основного API для игры я выбрал DirectX 6, который вышел 7 августа 1998 года — за 3 года до моего рождения :)

Перед тем как что-то начинать делать, нужно определиться с тем, что нам нужно для нашей 3D-игры:

  • Графический движок или рендерер, работающий на базе Direct3D. В его задачи входит отрисовка геометрии, работа с освещением и материалами, отсечение моделей, находящихся вне поле зрения глаз, генерация ландшафтов из карт высот и т. п. Собственно, в нашем конкретном случае это графическим движком назвать сложно — никакого полноценного графа (иерархической структуры, как в Unity) сцены нет, толковой анимации тоже, зато есть довольно продвинутая система материалов :)
  • Звуковой движок на базе DirectSound. Здесь всё по классике: программный 3D-звук с эффектами типа «виу» и «вжух» с загрузкой звуковых дорожек из wav-файлов. Никакого стриминга звука с кольцевыми буферами и ogg/mp3 здесь не нужно!
  • Подсистема ввода, которая представляет из себя «получить состояние кнопки на клавиатуре» и «получить позицию курсора» :) В более продвинутых случаях есть необходимость абстрагирования осей геймпада, ремаппинга кнопок и прочих подобных штук, но в нашей демки необходимости в этом нет.
  • Остальные модули — сюда входят алгоритмы расчёта коллизий, математическая библиотека для работы с векторами и матрицами, система игровых объектов и загрузчики ресурсов. Это весьма небольшие и легкие в реализации подсистемы, но писать про каждый отдельный пункт смысла не очень много, поскольку они так или иначе часть других систем.

Игра будет представлять из себя аркадную 3D-леталку без намека на реалистичность, где мы должны будем управлять самолётиком и отстреливать вражеские самолеты и спавнящиеся время от времени «стреляющие» башни (зенитками назвать это сложно), чтобы они не разрушили нашу базу. Такой вот Battle City в воздухе! Сама игра идёт на очки, никакой конкретной миссии в ней нет, но сложность постепенно растёт. Самолеты и текстуры — первые что попались в интернете с минимальной доработкой (пережатие текстур и упрощение геометрии). Вот и весь «диздок» :)

Как известно, в самолёте всё зависит от винта! Ну, или в нашем случае, от 3D-движка — поэтому предлагаю рассмотреть архитектуру нашего рендерера и заложить первые кирпичики в нашу 3D-игру!

Графический движок


Поскольку C# — управляемый язык и напрямую дёргать COM-интерфейсы формально не может, а готовых обёрток для DirectX 6 по понятным причинам нет, мне пришлось писать свою. Простыми словами, обёртка обеспечивает слой совместимости между нативными библиотеками, написанными на C++ и управляемым кодом, написанном на C#/VB и т.п. Благо в мире .NET есть такое замечательное, но увы, забытое расширение плюсов, как С++/CLI, которое позволяет прозрачно смешивать нативный код и «байткод» .NET, благодаря которому разработка пошла значительно быстрее.


Любой графический движок начинается с создания окна и инициализации контекста графического API (инициализации видеокарты, если простыми словами) для рисования в это самое окно. В случае Direct3D6 всё интереснее тем, что фактически здесь уже был свой аналог современного DXGI (DirectX Graphics Infrastructure — библиотека для управления видеокартами, мониторами в системе), который назывался DirectDraw. Изначально DDraw использовался для аппаратного ускорения графики на VGA 2D-акселеллераторах — тех самых S3 ViRGE и Oak Technology и предназначался в основном для операций блиттинга (копирования картинки в картинку), но в D3D ему выделили функции управления видеопамятью и поэтому они очень тесно связаны.

Инициализация начинается с создания так называемой первичной поверхности (которая будет отображаться на экран) и заднего буфера (в который будет рисоваться само изображение), или в терминологии современных API — Swap-chain.

                        Guard(DirectDrawCreate(0, &dd, 0));

			ddraw = dd;
			Guard(ddraw->SetCooperativeLevel(hwnd, DDSCL_NORMAL));

			// Create primary surface
			DDSURFACEDESC desc;
			memset(&desc, 0, sizeof(desc));
			desc.dwSize = sizeof(desc);
			desc.dwFlags = DDSD_CAPS;
			desc.ddsCaps.dwCaps = DDSCAPS_PRIMARYSURFACE;
			desc.dwBackBufferCount = 1;
			Guard(ddraw->CreateSurface(&desc, &pSurf, 0));

			Guard(pSurf->QueryInterface(IID_IDirectDrawSurface4, (LPVOID*)&pSurf4));
			primarySurface = pSurf4;

			DDPIXELFORMAT pf;
			pSurf->GetPixelFormat(&pf);

			// Create RT. Since primary surface is always covers all screen, back buffer should be of real size
			DDSURFACEDESC rtDesc;
			memset(&rtDesc, 0, sizeof(rtDesc));
			rtDesc.dwSize = sizeof(rtDesc);
			rtDesc.dwFlags = DDSD_CAPS | DDSD_WIDTH | DDSD_HEIGHT;
			rtDesc.ddsCaps.dwCaps = DDSCAPS_OFFSCREENPLAIN | DDSCAPS_3DDEVICE;
			rtDesc.dwWidth = Width;
			rtDesc.dwHeight = Height;
			Guard(ddraw->CreateSurface(&rtDesc, &sSurf, 0));
			Guard(sSurf->QueryInterface(IID_IDirectDrawSurface4, (LPVOID*)&sSurf4));

Теперь у нас есть окно, куда можно что-нибудь нарисовать!


Но 3D мы пока рисовать не можем — ведь контекста D3D у нас всё ещё нет, благо создаётся он очень просто. Единственный момент: Z-буфер нужно создать перед созданием устройства, иначе работать он не будет.

                        Guard(ddraw->QueryInterface(IID_IDirect3D3, (LPVOID*)&d3d));
			// Enumerate and pick best Z-Buffer format
			Guard(d3d->EnumZBufferFormats(IID_IDirect3DHALDevice, OnDepthStencilFormatSearchCallback, 0));

			// Create Z-Buffer for this device
			DDSURFACEDESC zbufDesc;
			memset(&zbufDesc, 0, sizeof(zbufDesc));
			zbufDesc.dwSize = sizeof(zbufDesc);
			zbufDesc.dwFlags = DDSD_CAPS | DDSD_WIDTH | DDSD_HEIGHT | DDSD_PIXELFORMAT;
			zbufDesc.ddsCaps.dwCaps = DDSCAPS_ZBUFFER | DDSCAPS_VIDEOMEMORY;
			memcpy(&zbufDesc.ddpfPixelFormat, Window::zBufferFormat, sizeof(zbufDesc.ddpfPixelFormat));
			zbufDesc.dwWidth = Width;
			zbufDesc.dwHeight = Height;

			IDirectDrawSurface* zTemp;
			IDirectDrawSurface4* zSurface;
			Guard(ddraw->CreateSurface(&zbufDesc, &zTemp, 0));
			Guard(zTemp->QueryInterface(IID_IDirectDrawSurface4, (LPVOID*)&zSurface));

			// Attach Z-Buffer to backbuffer
			Guard(d3dSurface->AddAttachedSurface(zSurface));
			Guard(d3d->CreateDevice(IID_IDirect3DHALDevice, surf, &device, 0));

Мы уже на полпути перед тем как нарисовать первый тридэ-треугольник: осталось лишь объявить структуру вершины и написать обёртки над… Begin/End! Да, в Direct3D когда-то тоже была концепция из OpenGL, а связана она с тем, что в видеокартах тех лет вершины передавались не буферами, а по одному, уже трансформированные. Подробнее об этом можно почитать в моей статье о S3 ViRGE:

                public value struct Vertex
		{
		public:
			float X, Y, Z;
			float NX, NY, NZ;
			D3DCOLOR Diffuse;
			float U, V;
		};

            ...

            Vertex[] v = new Vertex[3];
            v[0] = new Vertex()
            {
                X = 0,
                Y = 0,
                Z = 0,
                U = 0,
                V = 0
            };
            v[1] = new Vertex()
            {
                X = 1,
                Y = 0,
                Z = 0,
                U = 1,
                V = 0
            };
            v[2] = new Vertex()
            {
                X = 1,
                Y = 1,
                Z = 0,
                U = 1,
                V = 1
            };

            dev.BeginScene();
            dev.Begin(PrimitiveType.TriangleList, Device.VertexFormat);
            dev.Vertex(v[0]);
            dev.Vertex(v[1]);
            dev.Vertex(v[2]);
            dev.End();
            dev.EndScene();

И вот, у нас есть первый треугольник! Читатель может спросить — а где же здесь игра и причём здесь треугольники, мы же не на уроке геометрии… Дело в том, что вся 3D-графика в современных играх строится из треугольников. Любая моделька на экране — это набор из маленьких примитивов, которые в процессе рисования на экран подвергаются процессу трансформации — преобразованию из мировых координат (то есть абсолютной позиции в мире) сначала в координаты камеры (таким образом, при движении камеры, на самом деле двигаются объекты вокруг камеры), а затем и в экранные координаты, где происходит перспективное деление и каждый треугольник начинает выглядеть как трёхмерный…

Таким образом, из тысяч треугольников можно описать самые разные объекты — от трёхмерной модели моих любимых «жигулей», до персонажей.


Но если сейчас нарисовать самолетик, то он будет исключительно белым, без намёка на освещение или детали. А для его «раскрашивания» служат текстуры — специальные изображения, подогнанные под текстурные координаты геометрии, которые помогают дополнить образ 3D-моделей деталями: асфальт на дороге, трава на земле, дверная карты в жигулях…


И вот с текстурами ситуация в D3D6 не менее интересная и очень похожа на современные GAPI: нам необходимо сначала создать текстуру в системной памяти (ОЗУ) и только затем скопировать её в видеопамять. Причём форматов текстур не слишком много. Я выбрал RGB565 (16-битный), хотя есть поддержка и форматов со сжатием — тот-же S3TC.

                        bool hasMips = mipCount > 1; // If texture has more than 1 mipmap, then create surface as complex, if not - then as single-level.

			DDSURFACEDESC2 desc;
			memset(&desc, 0, sizeof(desc));
			desc.dwSize = sizeof(desc);
			desc.dwFlags = DDSD_CAPS | DDSD_WIDTH | DDSD_HEIGHT | DDSD_PIXELFORMAT | DDSD_TEXTURESTAGE | DDSD_CKSRCBLT;
			desc.ddsCaps.dwCaps = DDSCAPS_TEXTURE | (hasMips ? (DDSCAPS_MIPMAP | DDSCAPS_COMPLEX) : 0);
			desc.ddsCaps.dwCaps2 = DDSCAPS2_TEXTUREMANAGE;
			desc.ddckCKSrcBlt.dwColorSpaceHighValue = 0;
			desc.ddckCKSrcBlt.dwColorSpaceLowValue = 0;
			memcpy(&desc.ddpfPixelFormat, DXSharp::Helpers::Window::opaqueTextureFormat, sizeof(desc.ddpfPixelFormat));
			desc.dwWidth = Width = width;
			desc.dwHeight = Height = height;

			IDirectDrawSurface4* surf;
			IDirect3DTexture2* tex;

			IDirectDraw4* dd2;
			window->ddraw->QueryInterface(IID_IDirectDraw4, (LPVOID*)&dd2);

			Guard(dd2->CreateSurface(&desc, &surf, 0));
			Guard(surf->QueryInterface(IID_IDirect3DTexture2, (LPVOID*)&tex));

А чтобы её использовать, нужно «сказать» об этом видеокарте с помощью биндинга текстуры к текстурному юниту. Те, у кого были в свое время 3dfx Voodoo, наверняка поймут, о чём я :)

Guard(device->SetTexture(stage, tex->texture));

И вот у нас уже есть треугольник с текстурой! Осталось лишь домножить его матрицы трансформации, перспективную матрицу…


Реализуем простенький загрузчик моделей из формата SMD (GoldSrc, Half-Life или CS1.6), который грузит статичные модельки без скиннинга, а также загрузчик текстур из bmp и вот — мы уже имеем 3D-модельку самолёта с текстурой.

                for(int i = 0; i < smd.Triangles.Count; i++)
                {
                    uint c = new Color(255, 255, 255, 255).GetRGBA();

                    for (int j = 0; j < 3; j++)
                        vert[i * 3 + j] = new Vertex()
                        {
                            X = smd.Triangles[i].Verts[j].Position.X,
                            Y = smd.Triangles[i].Verts[j].Position.Y,
                            Z = smd.Triangles[i].Verts[j].Position.Z,
                            U = smd.Triangles[i].Verts[j].UV.X,
                            V = smd.Triangles[i].Verts[j].UV.Y,
                            NX = smd.Triangles[i].Verts[j].Normal.X,
                            NY = smd.Triangles[i].Verts[j].Normal.Y,
                            NZ = smd.Triangles[i].Verts[j].Normal.Z,
                            Diffuse = c
                        };
                }


Возможно в каких-то играх и не нужно небо, но в леталках — уж точно необходимо. И без учёта динамических облаков, здесь есть две популярные техники:

  • Sky-sphere, которая заключается в том, что небо представляет из себя полусферу с наложенной поверх текстурой неба в специальном формате. Такую полусферу очень часто крутят вокруг своей оси по оси Y, создавая эффект плывущих облаков. И получается вполне себе симпатичное анимированное небо. Иные варианты включают в себя многослойные реализации, где крутится могут лишь облака, когда статичные элементы фона остаются на месте.

    На скриншоте можно увидеть реализацию Sky-sphere. Возможно, если вы когда-то улетали в играх «за карту», видели подобную картину :)


  • Skybox — здесь суть простая, вокруг камеры рисуется «коробка» с вывернутыми в обратную сторону треугольниками, на которых рисуется текстура одной из сторон панорамы с выключенной записью в Z-буфер. Получается не только симпатично, но ещё и быстрее Skysphere на слабом железе, правда скайбоксы обычно статичным. Скайбоксы можно найти почти везде: например, в Counter-Strike, Half-Life.

    На скриншоте ниже можно увидеть пример скайбокса:



Я выбрал скайбоксы. Реализация — проще пареной репы:

             materials[0].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_bk.bmp", Path, name));
            materials[1].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_ft.bmp", Path, name));
            materials[2].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_lf.bmp", Path, name));
            materials[3].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_rt.bmp", Path, name));
            materials[4].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_up.bmp", Path, name));
            materials[5].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_dn.bmp", Path, name));

            ....

             Engine.Current.Graphics.DrawMesh(mesh, 0, 6, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[1]); // Forward
            Engine.Current.Graphics.DrawMesh(mesh, 6, 12, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[3]); // Right
            Engine.Current.Graphics.DrawMesh(mesh, 12, 18, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[0]); // Back
            Engine.Current.Graphics.DrawMesh(mesh, 18, 24, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[2]); // Left
            Engine.Current.Graphics.DrawMesh(mesh, 24, 30, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[4]); // Left


Но летать в пустом мире неинтересно и для этого нам нужен хотя бы ландшафт, который называется Terrain. Концепция Terrain простая — у нас есть карта высот, каждый пиксель который описывает высоту той или иной точки.


Мы проходимся по всей картинке и строим сетку треугольников, где высота определяется именно соседними пикселями на этой самой карте высот. На практике это выглядит так:

                for (int i = 1; i < bmp.Width - 1; i++)
                {
                    for(int j = 1; j < bmp.Height - 1; j++)
                    {
                        float baseX = (float)i * XZScale;
                        float baseZ = (float)j * XZScale;

                        // Transform vertices
                        verts[vertOffset] = new DXSharp.D3D.Vertex()
                        {
                            X = baseX,
                            Y = ((float)bmp.GetPixel(i, j).R / 255.0f) * YScale,
                            Z = baseZ,
                            U = 0,
                            V = 1 * TextureScale,
                            NY = 1
                        };
                        verts[vertOffset + 2] = new DXSharp.D3D.Vertex()
                        {
                            X = baseX,
                            Y = ((float)bmp.GetPixel(i, j + 1).R / 255.0f) * YScale,
                            Z = baseZ + XZScale,
                            U = 0,
                            V = 0,
                            NY = 1
                        };
                        verts[vertOffset + 1] = new DXSharp.D3D.Vertex()
                        {
                            X = baseX + XZScale,
                            Y = ((float)bmp.GetPixel(i + 1, j + 1).R / 255.0f) * YScale,
                            Z = baseZ + XZScale,
                            U = 1 * TextureScale,
                            V = 0,
                            NY = 1
                        };
                        verts[vertOffset + 3] = new DXSharp.D3D.Vertex()
                        {
                            X = baseX,
                            Y = ((float)bmp.GetPixel(i, j).R / 255.0f) * YScale,
                            Z = baseZ,
                            U = 0,
                            V = 1 * TextureScale,
                            NY = 1
                        };
                        verts[vertOffset + 4] = new DXSharp.D3D.Vertex()
                        {
                            X = baseX + XZScale,
                            Y = ((float)bmp.GetPixel(i + 1, j).R / 255.0f) * YScale,
                            Z = baseZ,
                            U = 1 * TextureScale,
                            V = 1 * TextureScale,
                            NY = 1
                        };
                        verts[vertOffset + 5] = new DXSharp.D3D.Vertex()
                        {
                            X = baseX + XZScale,
                            Y = ((float)bmp.GetPixel(i + 1, j + 1).R / 255.0f) * YScale,
                            Z = baseZ + XZScale,
                            U = 1 * TextureScale,
                            V = 0,
                            NY = 1
                        };

                        vertOffset += 6;
                    }
                }

А результат — такой! Это самый простой кейс с Terrain'ом: в реальных играх, где ландшафт достаточно большой, его обычно бьют на так называемые патчи и дальние участки ландшафта упрощают с помощью специальных алгоритмов. Таким образом построены ландшафтры, например, в TES Skyrim.


Но ландшафт выглядит слишком скучно — ни травы, ни деревьев, ни даже разных текстур! Одна трава — да что ж это за ландшафтр такой :) И здесь нам на помощь приходят т. н. комбайнеры — которые дают возможность наносить сразу несколько текстур за один проход отрисовки геометрии. Конкретно в данном случае я решил использовал альфа-канал в цвете вершины в качестве значения, определяющего какой текстурой красить тот или иной участок ландшафта. Визуализировать это можно так (где прозрачные участки — там должна быть вторая текстура):


Этот способ даёт возможность использовать всего лишь две текстуры за один проход, в современных играх используется сплат-маппинг, позволяющий использовать более 4х-текстур за один проход!

                 Context.SetTextureStageState(1, (int)TextureStageState.AlphaOp, (int)TextureStageOp.Modulate);
                Context.SetTextureStageState(1, (int)TextureStageState.AlphaArg1, (int)TextureArgument.Texture);
                Context.SetTextureStageState(1, (int)TextureStageState.AlphaArg2, (int)TextureArgument.Texture);

                Context.SetTextureStageState(0, (int)TextureStageState.ColorOp, (int)TextureStageOp.SelectArg1);
                Context.SetTextureStageState(0, (int)TextureStageState.ColorArg1, (int)TextureArgument.Texture);
                Context.SetTextureStageState(0, (int)TextureStageState.ColorArg2, (int)TextureArgument.Texture);

                Context.SetTextureStageState(1, (int)TextureStageState.ColorOp, (int)TextureStageOp.BlendDiffuseAlpha);
                Context.SetTextureStageState(1, (int)TextureStageState.ColorArg1, (int)TextureArgument.Texture);
                Context.SetTextureStageState(1, (int)TextureStageState.ColorArg2, (int)TextureArgument.Current);



Но тем не менее, выглядит вполне прикольно. Однако текстуры вдали выглядят слишком грубо и отдают пикселями. Ретро-стайл скажете вы? Согласен, но фильтрация и мипмаппинг здесь необходимы! Мип-маппинг — это техника, которая делит большую текстуру на несколько небольших разного размера. Каждый размер называется mip-уровнем и в два раза меньше прошлого: таким образом, у текстуры 256x256 9 уровней: 256x256, 128x128, 64x64 и так до 1x1. Мой самопальный конвертер текстур в собственный формат заранее «запекает» все мип-уровни, дабы быстро грузить текстуры с медленных HDD, а линейная фильтрация с мипмаппингом позволяет сгладить текстуры вдали, дабы они не резали глаза:

                        device->SetTextureStageState(0, D3DTSS_MIPFILTER, D3DTFP_LINEAR);
			device->SetTextureStageState(0, D3DTSS_MINFILTER, D3DFILTER_LINEAR);
			device->SetTextureStageState(0, D3DTSS_MAGFILTER, D3DFILTER_LINEAR);

			device->SetTextureStageState(1, D3DTSS_MIPFILTER, D3DTFP_LINEAR);
			device->SetTextureStageState(1, D3DTSS_MINFILTER, D3DFILTER_LINEAR);
			device->SetTextureStageState(1, D3DTSS_MAGFILTER, D3DFILTER_LINEAR);

Ну и давайте же посадим немного деревьев на наш ландшафт! Для этого я добавил псевдослучайное добавление деревьев и кустов при генерации геометрии ландшафта:

                  if (rand.Next(0, 32) % 8 == 0)
                            foliageBatches.Add(new FoliagePlacement()
                            {
                                Mesh = foliage[rand.Next(0, foliage.Length)],
                                Position = new Vector3(baseX, ((float)bmp.GetPixel(i, j).R / 255.0f) * YScale, baseZ)
                            });


Упс, наши деревья — черные! А всё потому, что у них нет альфа-канала, благодаря которому видеокарта может отделить прозрачные пиксели текстуры от непрозрачных. Полноценный альфа-блендинг (полупрозрачность) здесь слишком дорогой, поэтому приходится использовать технику, называемую колоркеями (Color key). Техника очень схожая с Chromakey, благодаря которым вырезают фон из видео, но чуть попроще (тем, что цвет прозрачности фиксированный, без Threshold). У нас есть определенный цвет, который считается прозрачным и не используется во всей картинке. Нередко это Magenta, в моём случае — полностью чёрный:


Включаем колоркей и наслаждаемся прозрачными деревьями на фоне ландшафта!


Ой-ой, а FPS то успел просесть с 1.000 до 50 из-за большого количества DIP'ов (и не очень хорошей работе современных GPU с старыми гапи). Время оптимизаций! Пока что нам хватит обычного Frustum culling'а, также известного как «отсечение по пирамиде видимости». Суть алгоритма простая: из матрицы вида и проекции строятся 6 плоскостей, каждая из которых описывает одну из сторон системы координат: левая, правая, верхняя, нижняя, ближняя и дальняя. Таким образом, делая обычную проверку нахождения точки в World-space и одной из плоскостей, мы можем отсечь невидимую глазам геометрию и не тратить ресурсы GPU и CPU на отрисовку невидимой геометрии:

        public void Calculate(Matrix viewProj)
        {
            float[] items = viewProj.Items;
            Planes[0] = new Vector4(items[3] - items[0], items[7] - items[4], items[11] - items[8], items[15] - items[12]);
            Planes[0].Normalize();
            Planes[1] = new Vector4(items[3] + items[0], items[7] + items[4], items[11] + items[8], items[15] + items[12]);
            Planes[1].Normalize();
            Planes[2] = new Vector4(items[3] + items[1], items[7] + items[5], items[11] + items[9], items[15] + items[13]);
            Planes[2].Normalize();
            Planes[3] = new Vector4(items[3] - items[1], items[7] - items[5], items[11] - items[9], items[15] - items[13]);
            Planes[3].Normalize();

            Planes[4] = new Vector4(items[3] - items[2], items[7] - items[6], items[11] - items[10], items[15] - items[14]);
            Planes[4].Normalize();
            Planes[5] = new Vector4(items[3] + items[2], items[7] + items[6], items[11] + items[10], items[15] + items[14]);
            Planes[5].Normalize();
        }
        
        // Allocation-less
        public bool IsPointInFrustum(float x, float y, float z)
        {
            foreach(Vector4 v in Planes)
            {
                if (v.X * x + v.Y * y + v.Z * z + v.W <= 0)
                    return false;
            }

            return true;
        }

        public bool IsSphereInFrustum(float x, float y, float z, float radius)
        {
            foreach (Vector4 v in Planes)
            {
                if (v.X * x + v.Y * y + v.Z * z + v.W <= -radius)
                    return false;
            }

            return true;
        }

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

              if (mesh.Radius > 0 && !Camera.IsSphereVisible(position, mesh.Radius))
                    return;

Тестовый вылет на реальной машине: Asus EEE PC 701 4G.

image

С учётом всех оптимизацией, получаем 17-20 кадров на этом GPU что можно считать… весьма неплохим результатом, учитывая что всё ещё есть куда оптимизировать!

Звук


Эта часть статьи будет без иллюстраций, поскольку звук нужно слушать :) Но тем не менее, детали реализации звуковой подсистемы в DirectX весьма интересны и значительно отличаются от современного подхода.

Дело в том, что раньше звук в играх был аппаратно-ускоренным, в том числе и 3D-эффекты. На процессоре считать какие-то сложные эффекты типа HRTF было слишком дорого, и поэтому на звуковых картах был свой собственный миксер и собственная память для звуковых буферов. Абстрагировал это всё DirectSound, который обычно занимался конвертацией звуковых форматов и программным микшированием, если аппаратное по каким-то причинам недоступно. В современных аудио-картах уже, насколько мне известно, нет ничего кроме регистров настройки DSP и собственно небольшого кольцевого буфера, куда аудио-драйвер выгружает уже готовый PCM-поток!

Инициализация контекста DSound начинается с создания primary-буфера, который выступает в роли микшера перед отправкой звука на аудио-карту. Создаётся он довольно легко:

            BufferDescription desc = new BufferDescription();
            desc.Flags = BufferFlags.PrimaryBuffer | BufferFlags.Control3D;
            
            primaryBuffer = Context.CreateSoundBuffer(desc);


После этого, в самом простом случае (без стриминга звука) нам достаточно лишь выгрузить PCM-поток на аудио-карту и начать его играть:

        public WaveBuffer(WaveFormat fmt, byte[] pcmData)
        {
            BufferDescription desc = new BufferDescription();
            desc.BufferBytes = (uint)pcmData.Length;
            desc.Flags = BufferFlags.ControlDefault |BufferFlags.Software;
            desc.Format = fmt;

            buffer = Engine.Current.Sound.Context.CreateSoundBuffer(desc);
            IntPtr data = buffer.Lock();
            Marshal.Copy(pcmData, 0, data, pcmData.Length);
            buffer.Unlock();

            buffer.Play();
        }


И всё! Да, вот так легко. BufferFlags.Software заменяется на Hardware, если необходимо аппаратное ускорение.

Ввод


Пожалуй, это самая простая часть нашей статьи :) Как я уже говорил ранее, никакого особого функционала от модуля обработки ввода не нужно, лишь получать состояние кнопок — и с этим справляется лишь один метод…

        [DllImport("user32.dll")]
        static extern short GetAsyncKeyState(Keys vKey);

        public static bool GetKeyState(Keys key)
        {
            return (GetAsyncKeyState(key) & 0x8000) != 0;
        }

Ну что ж, основа готова, давайте перейдем к реализации самого геймплея!

Пилим геймплей


Сначала нам нужно реализовать логику полёта нашего самолётика. В целом, в нашем конкретном кейсе всё просто — для поворотов используем углы Эйлера (лень было писать класс для кватерниона), считаем Forward-вектор (вектор, указывающий на направление прямо) и просто крутим повороты по оси X и Y в нужную сторону, прибавляя к позиции самолетика Forward вектор, умноженный на скорость полёта. Правда, с таким подходом есть некоторые проблемы: выполнить петлю не получится, поскольку Forward-вектор всегда смотрит именно прямо и не учитывает обратную направленность по оси X.

            Rotation.X += -v * (YawSpeed * Engine.Current.DeltaTime);
            Rotation.Y += h * (YawSpeed * Engine.Current.DeltaTime);

            Rotation.Z = MathUtils.Lerp(Rotation.Z, 35 * -h, 4.0f * Engine.Current.DeltaTime);

            Vector3 fw = GetForward();
            Position.X += fw.X * (Speed * Engine.Current.DeltaTime);
            Position.Y += fw.Y * (Speed * Engine.Current.DeltaTime);
            Position.Z += fw.Z * (Speed * Engine.Current.DeltaTime);

Мы с вами хотим, чтобы камера всегда следила за нашим самолётиком. Для этого нужно взять Forward-вектор объекта и умножить каждую его компоненту на дальность от источника камеры. Эдакая бомж-версия lookat, правда с кучей ограничений, как минимум с Gimbal lock (потерей одной из осей поворота), а чтобы камера казалась плавной и придавала динамичности игре — мы делаем EaseIn/EaseOut эффект путём неправильного использования формулы линейной интерполяции :)

            Vector3 forward = GetForward();
            // Adjust camera
            Engine.Current.Graphics.Camera.Position = new Vector3(Position.X + (forward.X * -12.0f),
                Position.Y + (forward.Y * -12.0f) + 4.0f, Position.Z + (forward.Z * -12.0f));
            Engine.Current.Graphics.Camera.Rotation.Y = MathUtils.Lerp(Engine.Current.Graphics.Camera.Rotation.Y, Rotation.Y + (yaw * 30), 3.0f * Engine.Current.DeltaTime);
            Engine.Current.Graphics.Camera.Rotation.X = MathUtils.Lerp(Engine.Current.Graphics.Camera.Rotation.X, Rotation.X + (pitch * 5), 3.0f * Engine.Current.DeltaTime);
            Engine.Current.Graphics.Camera.MarkUpdated();


Ну, летать мы с вами уже можем… да, сильно по аркадному, но всё же :) Пришло время реализовать каких-нибудь соперников, а именно вражеские самолёты! Вообще, реализация нормального ИИ на самолетах, тем более в симуляторах — задачка очень нетривиальная, поскольку боты будут либо читерить, используя не те рычаги, что использует игрок, либо тупить и играть будет не сильно интересно. Вон, что «Варгейминг», что «Гайдзины» крутые в этом плане — я б ниасилил нормальных ботов для мультиплеерного симулятора или даже аркады :))

Вычисляем угол между позицией самолетика соперника и позицией игрока и интерполируем текущий угол по оси Y: получается вполне плавно, правда в нормальных играх ещё и компенсируют эффект «плаванья» вокруг игрока по синусоиде. Для подъёма и спуска по вертикали просто берём абсолютную величину выше/ниже:

            float angle = (float)Math.Atan2(Game.Current.Player.Position.X - Position.X, Game.Current.Player.Position.Z - Position.Z);
            float vert = MathUtils.Clamp(Position.Y - Game.Current.Player.Position.Y, -1, 1);
            Rotation.X = MathUtils.Lerp(Rotation.X, vert * 35, 1.5f * Engine.Current.DeltaTime);

            float prevY = Rotation.Y;
            Rotation.Y = MathUtils.Lerp(Rotation.Y, angle * MathUtils.RadToDeg, 1.5f * Engine.Current.DeltaTime);
            float diffY = Rotation.Y - prevY > 0 ? 1 : -1;
            Rotation.Z = MathUtils.Lerp(Rotation.Z, 15 * -diffY, 4.0f * Engine.Current.DeltaTime);

Наши боты будут читерить, причём жёстко. Они будут иметь значительно большую маневренность, нежели игрок, но при этом их скорость будет сильно медленнее игрока, дабы можно было их обогнать и стряхнуть с хвоста.


Очень похожая концепция использовалась в гоночных играх нулевых, где в том же NFS Underground противники и в повороты лихо заходили, и разгонялись до 300Км/ч в попытках догнать игрока.

Пора тестить демку — и для того, чтобы она работала на Win98, нужно собрать враппер в VS2005. VS2017 не поддерживает компилятор 2005'ой студии, поэтому пришлось сделать отдельный проект, благо никаких современных фишек C++ я не использую и ничего адаптировать не пришлось.


Ну что ж, демка у нас есть и в этот раз я подготовился получше, чем в статье про 3dfx Voodoo: я собрал целых два тестовых стенда и попросил у подписчиков потестировать демку на своих машинах с диковинным железом из 90-х и нулевых годов. Но сборка стенда тянет на отдельный небольшой раздел, если вам интересны сразу конкретные тесты — тык сюда.

Собираем тестовый стенд


Изначально, в качестве тестового стенда должен был выступить кит, подаренный мне читателем Александром. В него входила материнка Chaintech 6vta2 с Slot-1 на борту вместо привычного сокета:


Процессор Pentium 3 550MHz с родным, немного пыльным охлаждением:



В качестве памяти — две плашки PC133 памяти типа SDRAM:


И видеокартой GeForce 4 MX 420 с пассивным охлаждением от Asus. Опытный читатель спросит мол «так MX420 — видяшка 2002 года, что-то тут не так!», но Riva TNT или ATI Rage у меня к сожалению не было, а MX420 — на самом деле немного модифицированный GeForce 2!


После сборки, стенд не завелся: я осмотрел конденсаторы и обратил что по линии питания процессора и ОЗУ, оба элемента дутые. Поменял кондеры на проц — и плата завелась, правда работала нестабильно: Win98 сыпала ошибками по памяти, при том что оба модуля полностью рабочие, а установка NT и не начиналась.


В статье про 3dfx Voodoo за несколько дней до публикации материала, я судорожно писал всем сервисникам в своем городе на предмет наличия материнок с третьим пеньком и AGP-слотом на борту. И такая нашлась только у одного: в неизвестном состоянии и за 300 рублей, которую я решился взять. Она стартовала, но через раз: после замены всё тех же конденсаторов по линии питания процессора, она запустилась без каких либо проблем и дала поставить как Win98, так и Windows NT. Единственный нюанс — Pentium 3 550Mhz в слоте оказался заменен на Celeron 600Mhz в PGA370 и так даже лучше, поскольку у селерона значительно меньше L2 кэша и он должен проявлять себя ещё хуже, чем обычный P III!



На Win98 я так и не смог нормально накатить драйвера на MSDC (Mass Storage Device Class — «флэшки»), поэтому «считерил» и поставил WinXP. Изначально я планировал ставить Win2000 — но там .NET 2.0 работает с косяками (при том что этот же самый .NET работает на Win98!).

Тесты


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


Демка идёт в 20-25-30 кадров в зависимости от числа DrawCall'ов на сцене, что весьма неплохой результат для 640x480 и GPU с пассивным охлаждением!

Переходим к интегрированной графике, а именно к EEEPC 701 4G с Intel GMA 900 на борту! Те, кто знают что такое GMA, понимают насколько эти встройки не приспособлены для игр. Несмотря на наличие поддержки вторых шейдеров, из-за отсутствия аппаратного вершинного конвейера чип ничего не тянет. Но моя игрушка — исключение и она работает на удивление очень даже неплохо! 15-20 кадров точно есть и это при том что есть куда оптимизировать!


А дальше у нас идут тесты от подписчиков в Telegram-канале, которым я скинул билд и пригласил потестить демку на ретро-железе. Первый тест от читателя на ноутбуке с Pentium III и редкой встройкой Trident CyberBlade XP показал весьма неплохой результат — 15-20 кадров:


Дальше тот же читатель, имя которое он просил не раскрывать, потестил демку на ATI Rage M6 — очень и очень бодрый GPU, который выдает стабильные 20-25-30 кадров!


И читатель Даня потестил игру на ноуте Fujitsu с более ранним ATI Rage, где она не запустилась… с исключением на вызове EnumerateZBufferFormats! Ранние Rage не поддерживают Z-буфер и делают отсечение невидимых поверхностей неким собственным методом (неужто сортировка треугольников?):



Заключение


Вот такая демка, мини-игрушка у меня получилось. Да, весьма примитивненько, зато прикольно, запилено за пару дней и можно полетать на виртуальных самолетиках. Также у меня есть Telegram-канал, куда я публикую различные мысли связанные с подручным ремонтом, моддингом и программированием под гаджеты прошлых лет, а также публикую туда ссылки на новые статьи и видео! Найти исходный код демки вы можете на моём Github.

Понравилась статья? Пишите своё мнение в комментариях, я старался :)

Кстати, если у кого-то из читателей есть ненужные устройства (в том числе с косяками) или дешевые китайские подделки на айфоны/айпады/макбуки и другие брендовые девайсы будучи нерабочими, тормозящими, или окирпиченными и вам не хотелось бы выкидывать их на свалку, а наоборот, отдать их в хорошие руки и увидеть про них статью — пишите мне в Telegram или в комментах! Готов в том числе и купить их. Особенно ищу донора дисплея на китайскую реплику iPhone 11: мой ударник, контроллер дисплея калится и изображения нет :(

image



Читайте также:

А ещё я держу все свои мобилы в одной корзине при себе (в смысле, все проекты у одного облачного провайдера) — Timeweb. Потому нагло рекомендую то, чем пользуюсь сам — вэлкам:

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 81: ↑79 and ↓2+103
Comments30

Articles

Information

Website
timeweb.cloud
Registered
Founded
Employees
201–500 employees
Location
Россия
Representative
Timeweb Cloud