Рылся я как-то раз в своих исходниках школьных времён, и обнаружил там следующее:
- Игру на QBasic про космический корабль, расстреливающий астероиды. Жуткий код под дос, зато спрайты анимированы в 3ds Max.
- Графическую библиотеку на Pascal/Assembler с неплохой скоростью работы
- Лицензионный компилятор TMT Pascal, который может собирать код под Win32
Не пропадать же добру! Далее — история всего этого, немного ностальгии, и детали реализации «современной» версии игры с использованием старых спрайтов и кода для графики.
Немного истории
Basic
С программированием я впервые столкнулся в школе, где нас учили Лого, затем Basic, а затем Pascal.
Именно на Basic во мне проснулся интерес к разработке, и разумеется, захотелось написать свою игру! Скриншот из неё размещён в начале поста. 640х480, 256 цветов, все спрайты анимированы (вращаются в псевдо-3д), звук. Использована библиотека Future (до сих пор можно нагуглить по «qbasic future»). Исходник сохранился — 1552 строчки, 19 использований оператора GOTO. Игра называлась Lander, по аналогии с классической игрой, где нужно посадить космический корабль на планету. Но сажать корабль скучно, хочется стрельбы и взрывов, поэтому перед посадкой предстоит прорваться через пояс астероидов с двумя видами оружия на борту.
Спрайты рисовал сам в 3DS Max (астероиды — сферы с Fractal Noise, остальное — комбинации простых фигур, взрывы через Combustion). К сожалению, исходные max файлы каким-то образом утеряны, а вот отрендеренные спрайты сохранились.
Pascal
Следующим шагом был Pascal и встроенный в него Assembler. В школе занимались на 386 машинах, и там ощущалась вся сила микрооптимизаций в ассемблерных вставках. Вывод спрайта через REP MOWSW работал намного быстрее паскалевских циклов. Выравнивание кода, умножение сдвигами, максимум работы в регистрах и минимум в памяти.
Protected Mode
Всё это было жутко интересно и весело, я писал какие-то демки, штудировал Ralf Brown's Interrupt List, экспериментировал с SVGA графическими режимами, мучился с переключением банков.
А потом учитель информатики (спасибо ему огромное), который видел все эти развлечения, познакомил меня со своим товарищем, работавшим в отделе сборки ПК в крупной сети компьютерных магазинов города. Ему требовался софт под DOS с графическим интерфейсом, подготавливающий жесткий диск собранных компьютеров определённым образом. Настоящая работа программистом! Первой задачей было сделать оконную графику с кнопочками, текстовыми полями и так далее. Наверняка такие решения уже существовали, но я даже не думал об этом и горел желанием написать собственный велосипед.
Первым делом доработал имеющийся модуль рисования примитивов, вывода спрайтов и текста. Всё на ассемблерных вставках. Затем, имея небольшой опыт ковыряния с Visual Basic 6 под виндой, аналогичным образом реализовал окошки и контролы на Pascal, и через какое-то время представил результат:
Всё работает, окошки перетаскиваются, контролы реагируют на MouseOver. Вместо виндового подхода с прорисовкой dirty регионов пошёл напролом и перерисовывал всё — работало достаточно быстро благодаря ассемблеру.
В ответ услышал, что 320х200 не годится, и нужно сделать вид всех элементов как в новой на тот момент Windows XP. С большими разрешениями в реальном режиме есть проблемы, так как линейно можно адресовать не более 64 килобайт, для вывода картинки с большим разрешением нужно переключать банки памяти, да и вообще памяти маловато (пресловутые 640 килобайт). Поэтому компилятор от Borland был заменён на TMT Pascal, который умеет из коробки 32 бита и защищённый режим через dos4gw. Это решило проблемы с памятью и графикой, интерфейс был перерисован, бизнес-логика запилена и проект закончен. Не вдаюсь в подробности, так как это уже отклонение от темы.
Наши дни
Сортируя бэкапы, наткнулся на старый свой код. Взял DOSBox, позапускал, смахнул скупую слезу. После долгих лет С# снова захотелось почувствовать себя «ближе к железу». Так и нарисовался план — взять ассемблерный код для отрисовки графики в памяти, затем вывести результат в WPF. TMT Pascal умеет собирать Win32 dll, потребовались лишь минорные изменения (выкинуть лишнее, добавить stdcall в сигнатуры).
Например, так выглядит код вывода спрайта с прозрачностью (пиксели цвета TransparentColor не выводятся):
Procedure tPut32 conv arg_stdcall (X,Y,TransparentColor:DWord;Arr:Pointer);Assembler; {Transparent PUT}
Var IMSX, IMSY :DWord;
Asm
Cmp Arr, 0
Je @ExitSub
{Check ON-SCREEN POS}
Mov Eax, ScreenSY; Mov Ebx, ScreenSX
Cmp Y, Eax; Jl @PUT1; Jmp @ExitSub; @PUT1:
Cmp X, Ebx; Jl @PUT2; Jmp @ExitSub; @PUT2:
{--------}
Mov Edi, LFBMem {Set Destination Loct}
{Get Sizes}
Mov Esi, Arr
LodsD; Mov IMSX, Eax
LodsD; Mov IMSY, Eax
Add Esi, SizeOfSprite-8
{Check ON-SCREEN POS}
Mov Eax, IMSY; Neg Eax; Cmp Eax, Y; Jl @PUT3; Jmp @ExitSub; @PUT3:
Mov Eax, IMSX; Neg Eax; Cmp Eax, X; Jl @PUT4; Jmp @ExitSub; @PUT4:
{VERTICAL Clipping}
Mov Eax, Y {Clipping Bottom}
Add Eax, IMSY
Cmp Eax, ScreenSY
Jl @SkipClipYB
Sub Eax, ScreenSY
Cmp Eax, IMSY
Jl @DoClipYB
Jmp @ExitSub
@DoClipYB:
Sub IMSY, Eax
@SkipClipYB:
Cmp Y, -1 {Clipping Top}
Jg @SkipClipYT
Xor Eax, Eax
Sub Eax, Y
Cmp Eax, IMSY
Jl @DoClipYT
Jmp @ExitSub
@DoClipYT:
Sub IMSY, Eax
Add Y, Eax
Mov Ebx, IMSX
Mul Ebx
Shl Eax, 2 {<>}
Add Esi, Eax
@SkipClipYT:
{End Clipping}
{Calculate Destination MemLocation}
Mov Eax, Y; Mov Ebx, ScreenSX;
Mul Ebx
Add Eax, X
Shl Eax, 2 {<>}
Add Edi, Eax
Mov Ecx, IMSY {Size Y}
Mov Ebx, IMSX {Size X}
Mov Edx, ScreenSX
Sub Edx, Ebx
{HORIZ.CLIPPING}
Push Edx
Xor Eax, Eax
{RIGHT}
Sub Edx, X
Cmp Edx, 0
Jge @NoClip1 {IF EDX>=0 THEN JUMP}
Mov Eax, Edx; Neg Eax; Sub Ebx, Eax
@NoClip1:
Pop Edx
{LEFT}
Cmp X, 0
Jge @NoClip2
Sub Edi, X; Sub Esi, X //
Sub Edi, X; Sub Esi, X //
Sub Edi, X; Sub Esi, X // 32 bit!!!
Sub Edi, X; Sub Esi, X // /
Sub Eax, X; Sub Ebx, Eax
@NoClip2:
{bitshifts}
Shl Eax, 2 {<>}
Shl Edx, 2 {<>}
ALIGN 4
@PutLn: {DRAW!!!!!}
Push Ecx; Push Eax; Mov Ecx, Ebx
ALIGN 4
@PutDot:
LodsD; Cmp Eax, TransparentColor //Test Al, Al
Je @NextDot {if Al==0}
StosD; Sub Edi, 4 {<>}
@NextDot: Add Edi, 4 {<>}
Dec Ecx; Jnz @PutDot {Looping is SLOW}
Pop Eax; Add Esi, Eax
Add Edi, Edx; Add Edi, Eax
Pop Ecx
Dec Ecx; Jnz @PutLn {Looping is SLOW}
@ExitSub:
End;
Остальной код здесь: code.google.com/p/lander-net/source/browse/trunk/tmt_pascal/TG_32bit.pas
C#
Дальше ностальгия заканчивается и идут детали реализации. Можно пропустить и перейти непосредственно к видео геймплея и ссылке на скачивание в конце поста.
Страничка проекта на Google Code: code.google.com/p/lander-net/
Импортируются функции стандартно через DllImport
[DllImport("TPSGRAPH", CallingConvention = CallingConvention.StdCall)]
public static extern uint tPut32(uint x, uint y, uint transparentColor, uint spritePtr);
Память для спрайтов выделяется и освобождается на unmanaged стороне, то же самое можно делать через Marshal.AllocHGlobal. Спрайт представляет из себя следующую структуру (ха, тег source на хабре не поддерживает Pascal — пишем Delphi):
Type
TSprite = Packed record
W : DWord;
H : DWord;
Bpp : DWord;
RESERVED : Array[0..6] of DWORD;
End;
Unmanaged функция InitSprite выделяет память и заполняет заголовок, далее при помощи FormatConvertedBitmap и memcpy копируем пиксели в нужном формате (см code.google.com/p/lander-net/source/browse/trunk/csharp/TpsGraphNet/Sprite.cs).
Итак, теперь мы можем отрисовывать «сцену» в кадровом буфере. Тут меня поджидал затык с производительностью. FPS отрисовки нескольких сотен спрайтов в памяти измерялся в тысячах, а вот быстро вывести результат на виндовое окно оказалось не так просто. Пробовал WriteableBitmap, пробовал DirectX (через SlimDX), быстрее всего оказалось через InteropBitmap: Sprite.GetOrUpdateBitmapSource
public unsafe BitmapSource GetOrUpdateBitmapSource()
{
if (_bitmapSourcePtr == null)
{
var stride = Width*4; // Size of "horizontal row"
var section = NativeMethods.CreateFileMapping(NativeMethods.INVALID_HANDLE_VALUE, IntPtr.Zero, (int) NativeMethods.PAGE_READWRITE, 0, (int) _sizeInBytes, null);
_bitmapSource = Imaging.CreateBitmapSourceFromMemorySection(section, (int) Width, (int) Height, PixelFormats.Bgr32, (int) stride, 0);
_bitmapSourcePtr = (uint)NativeMethods.MapViewOfFile(section, NativeMethods.FILE_MAP_ALL_ACCESS, 0, 0, _sizeInBytes).ToPointer();
NativeMethods.CloseHandle(section);
NativeMethods.UnmapViewOfFile(section);
}
CopyPixelsTo((uint) _bitmapSourcePtr);
return _bitmapSource;
}
Как видно, тёмная магия с FileMapping вызывается лишь однажды, а затем у нас есть прямой указатель на кусок памяти, который отображается на окне. Обновлять его можно из любого потока, в UI потоке требуется лишь вызвать InteropBitmap.Invalidate().
Способ из известного поста Lincoln6Echo WPF, WinForms: рисуем Bitmap c >15000 FPS на деле выдаёт всего 120 fps, если развернуть окно на весь экран на full-hd мониторе. InteropBitmap в тех же условиях даёт ~800 fps. Сама игра на этой же машине (core i5) в развёрнутом окне даёт около 300 fps, если снять синхронизацию по CompositionTarget.Rendering.
Чтобы избежать «разрывов» (screen tearing), излишней нагрузки на процессор, и привязаться к стандартным 60 кадрам в секунду в WPF используем событие CompositionTarget.Rendering. Отрисовка происходит в фоновом потоке, чтобы не загружать основной и дать WPF делать свою работу GameViewModel.RunGameLoop().
Поверх игровой картинки средствами WPF легко и приятно выводится игровая информация (здоровье, оружие, очки): MainWindow.xaml. На скриншоте также можно заметить аддитивное наложение взрывов, реализуемое при помощи MMX (инструкция PADDUSB)
Вся игровая логика сделана на C#. Оставил только стрельбу по астероидам, из горизонтального переделал в вертикальный скроллер. SlimDX используется только для звука.
Итог
Игру как таковую до конца не довёл — потерялся интерес, остались тривиальные задачи, да и кто в это будет играть. Приятно было вдохнуть новую жизнь в старые поделки. «Ближе к железу» — весь рендеринг никак не зависит ни от каких фреймворков, выполняется в отдельном потоке, упирается в основном в скорость работы с памятью (из профайлера: 40% времени рендера уходит на очистку фреймбуфера и 40% на копирование его в InteropBitmap).
Проект на Google Code: code.google.com/p/lander-net/
Собранные бинарники (win32): ge.tt/1YvTlAh1/v/0
Видео геймплея:
Автор: