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

от автора

Осторожно: Несмотря на кажущуюся сложность статьи о разработке целой 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-уровнем и в два раза меньше прошлого: таким образом, у текстуры 256×256 9 уровней: 256×256, 128×128, 64×64 и так до 1×1. Мой самопальный конвертер текстур в собственный формат заранее «запекает» все мип-уровни, дабы быстро грузить текстуры с медленных 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’ов на сцене, что весьма неплохой результат для 640×480 и 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. Потому нагло рекомендую то, чем пользуюсь сам — вэлкам:


ссылка на оригинал статьи https://habr.com/ru/articles/827116/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *