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

    Видео геймплея:

    Похожие публикации

    Средняя зарплата в IT

    120 000 ₽/мес.
    Средняя зарплата по всем IT-специализациям на основании 7 154 анкет, за 1-ое пол. 2021 года Узнать свою зарплату
    Реклама
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее

    Комментарии 5

      +7
      Отвязная игрушка вышла! Для мобильных платформ, мне кажется, было бы самое то реализовать =)
        +2
        Захватывающе написано.
        Всегда с уважением относился к подобного рода глубоким знаниям, излагаемым с такой легкостью.
          +1
          Ммм… TMT Pascal, Assembler-ные вставки, спрайты в MAX-е — навеяли воспоминания первых курсов института.
          Тоже подобным баловался. А то лабораторные были слишком скучными.
            +1
            Я как раз на прошлой неделе разгребал свои старые Pascal-программы. Нашел и SVGA-поделки с bank switching'ом. Но мой dosbox напрочь отказался переходить в режим 0x118 (102x768x24bpp). Не возникало ли у Вас подобных трудностей в процессе реанимации кода?
              +1
              Да, есть такое дело, с разрешениями выше 0117h (1024х768х16bpp) не получается в dosbox запустить. Хотя в документации www.dosbox.com/wiki/Dosbox.conf написано, что с настройкой machine=svga_s3 «full (32- or 24-bit) color at up to 1024x768». Я загружался под DOS с флешки, чтобы нормально позапускать.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое