Abnormal programming
.NET
C#
May 2014 15

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.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).

GitHub: github.com/kefir0/LanderNet
Google Code: code.google.com/p/lander-net
Собранные бинарники (win32): ge.tt/1YvTlAh1/v/0

Видео геймплея:
+36
17.2k 66
Comments 5