C#/WPF + Pascal + Assembler: как я восстанавливал свою первую игру
Рылся я как-то раз в своих исходниках школьных времён, и обнаружил там следующее:
Игру на 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.pasC# Дальше ностальгия заканчивается и идут детали реализации. Можно пропустить и перейти непосредственно к видео геймплея и ссылке на скачивание в конце поста.Страничка проекта на 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
Видео геймплея:[embedded content]