WPF, WinForms: рисуем Bitmap c >15000 FPS. Хардкорные трюки ч.1

Сразу уточнение: Bitmap 200x100 на компе с быстрой памятью и i7 3930K на 1366. Но, это честный System.Drawing.Bitmap.
Вводная: приложение типа осциллографа. Ссылка на готовый проект с фронтэндом в конце статьи.
Как же быстро рисовать его на экран? WriteableBitmap хорош, быстр, и он лучшее решение для WP, WinRT, WPF. Но занудного старпёра-кодера также волнует WinForms, .Net 2.0, Win2K (да-да, в некоторых гос.органах до сих пор теплый ламповый Win2K).
Далее, я обратил внимание на DirectX, тем более у нас для WPF появился полезный контрол D3DImage. Я перепробовал много движков, но ни один из них не давал удобного изящного способа рисовать GDI+ Bitmap из памяти. Некоторые работали и вовсе только с DX10-11. Ближе всех к цели оказался SlimDX. В любом случае, фронтэнд для контрола оказывался некрасивым. Все эти движки… мягко говоря избыточны, для моей простой задачи.

Но решение есть.
И, к моему удовольствию оно получилось достаточно простым и универсальным, именно как надо, будет работать даже на Win2K и .Net 2.0.
Когда я был молодым, и у меня кажется еще был 5-ти дюймовый дисковод, я пользовался BitBlt и SetDIBitsToDevice. Потом, с переходом на .Net я все еще пользовался ими и Win32 GDI BITMAP, поскольку пользовался старыми наработками, потом всё забылось. Но вдруг, сейчас мне понадобился нестандартный контрол с попиксельной графикой, да плюс с быстрой отрисовкой. Вот так я и попал в небольшой тупик.
GDI+ Bitmap чертовски удобен со своими градиентами, антиалиасингом, и альфой. Очень вкусные картинки получаются. Нетрудно подготавливать нужный Bitmap в памяти, и даже делать это быстро, если кешировать большую часть изображения, но быстро их отображать на экране очевидного способа нет.

Пришлось вспоминать не очевидный:
[DllImport("gdi32")]
extern static int SetDIBitsToDevice(HandleRef hDC, int xDest, int yDest, int dwWidth, int dwHeight, int XSrc, int YSrc, int uStartScan, int cScanLines, ref int lpvBits, ref BITMAPINFO lpbmi, uint fuColorUse);

И ключевой метод в итоге получился таким:
public void Paint(HandleRef hRef, Bitmap bitmap)
{
	if (bitmap.Width != _width || bitmap.Height != _height)
		Realloc(bitmap.Width, bitmap.Height);
	//_gcHandle = GCHandle.Alloc(pArray, GCHandleType.Pinned);
	BitmapData BD = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height), 
									ImageLockMode.ReadOnly, 
									PixelFormat.Format32bppArgb);
	Marshal.Copy(BD.Scan0, _pArray, 0, _width * _height);
	SetDIBitsToDevice(hRef, 0, 0, _width, _height, 0, 0, 0, _height, ref _pArray[0], ref _BI, 0);
	bitmap.UnlockBits(BD);
	//if (gcHandle.IsAllocated)
	//	_gcHandle.Free();
}

По поводу закомменченых строк. Вообще, они должны быть раскомментированы, чтобы облегчить жизнь GC, но ради хардкорных FPS, если размер _pArray не менялся, GCHandle у меня пинится один раз в Realloc(). Хотя… когда их у нас 15000, плюс-минус пара сотен FPS роли уже не играют, хе-хе. Если раскомментить в Paint() — не забудьте закомментить пин в Realloc().
Вот так, ценой всего 100 строк кода (полностью код в прилагаемом проекте ниже) мы решили проблему FPS для System.Drawing.Bitmap, и никаких монструозных GPU-движков и фреймворков. Возможен гнев евангелистов Microsoft «Так делать нельзя, это против принятых практик программирования», но что поделать.
Весь фронтэнд для нужного контрола изящно сводится к нескольким строкам:
RazorPainter RP = new RazorPainter();
graphics = Control1.CreateGraphics();
hDCRef = new HandleRef(graphics, graphics.GetHdc());

public void Render()
{
    RP.Paint(hDCRef, BMP);
}

RP.Dispose();
graphics.Dispose();

А теперь печеньки! Одна из причин перехода на «темную сторону» GDI32. Дело в том, что с таким подходом мы абсолютно равнодушны к UI Thread и Invoke его. Ради рекордных FPS смело создаем отдельный полноценный Thread и в нем жестоко:
renderthread = new Thread(() =>
{
	while (true)
		Render();
});
renderthread.Start();

Есть еще небольшая деталь. Поскольку ОС не в курсе нашего хулиганства с памятью, то в оконной WndProc она бесполезно и бессмысленно, но упрямо затирает наш контрол Background Color. Избавим ОС от лишних мучений (и немножко повысим FPS) таким образом:
public RazorBackend()
{
	InitializeComponent();

	SetStyle(ControlStyles.DoubleBuffer, false);
	SetStyle(ControlStyles.UserPaint, true);
	SetStyle(ControlStyles.AllPaintingInWmPaint, true);
	SetStyle(ControlStyles.Opaque, true);
}

Вообще, конечно FPS у всех будет разный, и дело совсем не в видеокарте — тут роялят частота шины, процессора, памяти, их пропускная способность. В общем, почти квантовые эффекты для обычного юзера.

Итак, я добился 15600 FPS, приложение занимает ~30Мб памяти, а вот утилизация процессора 8% меня совсем не порадовала. Мягко выражаясь, это много для 3930K. И тут в моей голове «возвопил» видеодрайвер: «Хозяин, у меня кажется эпилепсия!», и монитор: «А я вообще только 60Hz умею!». Разумеется нам такой FPS не нужен, и правильный цикл рендеринга будет что-то вроде этого:
rendertimer = new DispatcherTimer();
rendertimer.Interval = TimeSpan.FromMilliseconds(15); /* ~60 FPS on my PC */
rendertimer.Tick += (o, args) => Render();
rendertimer.Start();

Ну или по-другому, на ваш вкус. Утилизация процессора выходит в районе нуля + погрешность.
Далее WPF. Всё сложно и просто одновременно. «Контролы» WPF собственно контролами не являются (а иначе бы мы не могли их крутить и плющить), и у них нет DC. Все решается хостингом WindowsForms контрола в WPF при помощи WindowsFormsHost. В прилагаемом проекте именно WPF пример использования, но легко переделывается в чистый WindowsForms, благо фронтэнд прост как сапог.

Цикл рендеринга Bitmap в демо-проекте состоит всего из одной строчки:
GFX.Clear((drawred = !drawred) ? System.Drawing.Color.Red : System.Drawing.Color.Blue);

Разумеется FPS цикла рендеринга по большей части зависит от сложности рисования изначального Bitmap в памяти, а простая очистка его Graphics в демо-проекте — это дико быстрая блочная операция. Но я тестировал подход и скорость.

Пользуйтесь на здоровье, если понимаете что делаете и зачем. Исходники и билд, выложил на CodePlex под MIT лицензией:
http://razorgdipainter.codeplex.com/
(Заранее прошу прощения за несерьезное оформление проекта на CodePlex, если интересно, дооформлю до нормального OpenSource)

UPD: alexanderzaytsev Верно просигналил что мой вариант WPF реализации возможно не идеальный. Я же старался сделать его простым и понятным. Ключевая суть лишь в файле RazorPainter.cs, и демо-проект — это не образцовый паттерн его использования, он лишь демонстрирует возможность в WPF и показывает FPS.
Судя по резонансу, вероятно, мне стоит сделать настоящий OpenSource фреймворк из этого. Статью про контрол WinForms я уже пишу.
UPD2: Появилось продолжение поста: http://habrahabr.ru/post/164885/. Обновление сорцов и бинарников на CodePlex до v. 0.6 beta
Метки:
Поделиться публикацией
Комментарии 39
  • +3
    А зачем 15 тысяч FPS, у вас есть монитор способный показывать с такой частой?

    Дочитал до конца, но почему сразу не задуматься о частоте в 60гц.
    • +8
      Конечно сразу додумался, однако при крайнем, экстремальном тестировании производительности, когда дело касается графики, то критерий — FPS. Но была причина еще важнее — контроль утечек памяти, что особенно актуально для работы с Win32 и маршаллингом из управляемого кода. Чем больше и чаще рисуем тем быстрее увидим проблему. У меня ночь проработала на 15000 и все ОК, только после этого решился публиковать.
    • +3
      Отлично! Я в своё время до этого не додумался)
      • +2
        Да я тоже неделю убил на разные варианты, пока до самого «дна» GDI32 не опустился)
        Думал умные люди из Microsoft за эти годы уже давно всё придумали, но, оказалось, они додумались только до контрола D3DImage у которого хостится контрол с DC, к которому можно привязать DirectX. Честно говоря, я еще думал про старый добрый DirectDraw, с ним я тоже много 2D фокусов проделывал, но его ЕМНИП прекратили еще в DirectX8, да и фротэнд контрола получился бы все-равно не таким красивым.
      • +1
        Написали бы этот конкретно кусочек на управляемом С++\CLI — остальная часть проекта осталась бы на вашем .NET, конкретно этот кусок мощно заюзал бы GDI32 без всяких там корявых импортов, еще бы и быстрее было, скорее всего.
        • +2
          Я думал об этом, но в итоге, все-равно каждый кадр, который надо нарисовать на мониторе стал бы методом или unsafe C++ .Net, или вызовом неуправляемого Win32 API неважно откуда.
          Я понимаю, что обидел тех, кто из управляемого С++ вызывает GDI32. Парни, я спецом убивался о простоте и изяществе, чтобы портировать решение в 100 строк кода не было проблемой. 15 минут труда и будет у вас управляемый С++ контрол, работающий даже быстрее моего решения в посте для C#.
          • +1
            Ссылка на CodePlex открыта, исходники есть, ссылка на репозитарий сорцов есть, можно подключиться к проекту и «прокачать» его до С++\CLI. Я на хабре этот пост закоммитил не ради «я пиарюсь», а просто показать неожиданный для многих подход к сложным вещам. И напомнить, что Win32 API еще живее всех живых, и чертовски умопомрачительно эффективен.
          • –1
            Как демо ок. Но сам код не ок, в живой проект бы я его взял только в случае крайней нужды. Где мне потом таких кулхацкеров искать, которые в нем баги править будут? :)
            • +2
              У меня слов нет, дожили, 2013 год — народ в шоке от GDI без плюсов.
              • 0
                Если честно то код и правда хромает.

                • Неправильно реализован IDisposable
                • Приложение не закрывается или падает с эксепшеном при попытке закрыть
                • ((System.Drawing.Graphics)hDCRef.Wrapper).ReleaseHdc()
                • 0
                  И еще. Почему Bitmap и Grapics лежат вне контролла (хотя бы RazorPainterWPFCtl)?
                  • 0
                    Потому-что паттерн и сценарий использования не определен, и я сделал как проще (надеюсь) для понимания. Для моей задачи они будут совсем в другом классе в другой либе рисоваться.
                    ReleaseHdc() возможно и перекрутил на сырую голову, хотя у себя проблем не заметил…
                    • 0
                      я сделал как проще (надеюсь) для понимания.
                      Нет вы только код запутали.
                      паттерн и сценарий использования не определен
                      Это решается предоставлением либо абстрактного метода, либо события.
                  • 0
                    IDisposable реализован вроде правильно — в классе нет управляемых ресурсов, и полный паттерн не нужен. Однако нужно наследование от интерфейса, иначе если кто-то начнет бесконечно лупить в цикле рендера
                    myRazorPainter = new RazorPainter();
                    то начинаются memory leaks несмотря на деструктор.
                    Если укажите в чем именно моя ошибка буду благодарен (и не только я).
                    • 0
                      Во-первых, в конце Dispose должен быть вызов GC.SuppressFinalize(this) чтобы предотвратить вызов деструктора. Во-вторых вы используете Dispose не по назначению (в методе Realloc). В-третьих вы зачем-то обнуляете _width и _height (видимо это вытекает из «во-вторых»).
                      • 0
                        Вы меня выкупили! ) Действительно, я тут отнесся к Dispose() как к некоему «Clear and Go again» (управляемых ресурсов в классе нет), и сэкономил несколько строк кода. Но, ОК, критику принял, буду допиливать. (Напишу в личку как появится апдейт)
                    • +1
                      ((System.Drawing.Graphics)hDCRef.Wrapper).ReleaseHdc() -> graphics.ReleaseHdc() (graphics это тот же, что используется строчкой ниже)
                      • 0
                        И вообще ReleaseHdc и так в graphics.Dispose() делается.
                        • 0
                          Да-да, дружище, увидел уже этот косяк, сейчас переношу BMP и GFX уровнем ниже в контрол, и прикручиваю реакцию на резайз. Жую мозг как бы это сделать не поломав статью.
                          • 0
                            Я немного поигрался с кодом: все утащил в WinForms контролл. Из него наружу торчит метод Render и свойство GFX, а WPF контролл просто пробрасывает их дальше.

                            Там нормально все переносится, и на статью особо не влияет. Заодно получается WinForms контролл, который тут в комментариях просили.
                            • 0
                              Мысли сходятся. Пока зачекинил исправления с Dispose в исходниках на CodePlex.
                    • 0
                      Можно и на феррари картошку с дачи возить, кто мешает-то.
                      • –1
                        Прошу прощения, вы о чем? Выше вы сказали, что код автора в продакшен бы не взяли, не будем касаться подхода и оформления, судя по всему, речь шла об использовании «низкоуровневого» (lol) GDI в принципе. Правильно понял?
                        • 0
                          А что вам так смешно тут? Да, тот же WPF предлагает высокоуровневые интерфейсы для работы с графикой, которые знает любой, работающий с WPF. Никакого внятного обоснования, зачем нужно это менять, кроме 15 тысяч кадров (зачем?) и совместимости с устаревшими платформами (зачем??). Гос. органы — это не аргумент. Выгоднее переставать поддерживать устаревающие платформы, чем делать хаки на все случаи жизни. Простота и стандартные методы всегда > скорости. Особенно там, где скорость не нужна.
                          • +1
                            К сожалению некоторым областям еще далеко до этих прописных истин. Когда в них работаешь, то формула Технология=(текущее поколение — 1) это еще из разряда «тебе повезло, братан». Приходится что-то делать, облизываясь на то, что сейчас для этого есть…
                            • +1
                              Отчасти согласен с вами, только как раз и смешно, что GDI не является низкоуровневым интерфейсом, его использование где угодно — вполне в рамках. Он совместим вообще со всеми платформами Microsoft Windows, начиная с Win98 (если не раньше) и заканчивая Win8 — даже его API не менялся. Это вполне себе стандарт.

                              Если говорить про .Net, то, да, конечно, есть куча более высокоуровневых возможностей для рисования и вывода (GDI+). Только вот выполнить ими на должном уровне производительности визуализацию нескольких тысяч полигонов (софтварно), к примеру, не получится. И тут решение автора (слова про 15к фпс — это какой-то полумаркетинговый бред) вполне себе ничего, ибо вам придется либо выносить код в native и дергать его оттуда, либо юзать GDI из-под managed кода, что автор и проделал.
                              • +1
                                Не ругайте за занудство с подробностями, но ЕМНИП, WIn32 API и GDI появились еще в Windows NT4.0. К чести Microsoft он так и не менялся (ради обратной совместимости), лишь добавлялись новые методы. А если старые «прокачивались» функционалом, то оставался старый как был, а к «прокаченому» варианту метода добавлялось окончание «Ex» (extended), напр. CreateWindowEx().
                                Хардкорные трюки ч.2 как раз будет этому посвящена, и там тоже будут печеньки для дотнетчиков.
                                .
                                P.S. Что касается выпиленых API в WP8 и WinRT. Камрады, не будьте такими наивными. Там все-еще старый добрый NT4, который компилился со своим Win32 API под кучу процов еще многие лета назад. Вспомнили, пересобрали на ARM и нахлобучили сверху WPF. «WinRT doesn't completely replace Win32 API but internally can call and use Win32 API and subsets of .Net.»
                                Вот только использовать Win32 теперь запрещено волевым усилием и политикой компании, и модерацию в магазине приложений не пройдете. Отчасти напоминает политику Apple по отношению к API. Огромная просьба, не реагировать и не начинать холивар на эту тему. Статья выше совсем о другом.
                            • 0
                              Кстати, еще момент. В том же WP8 пространство System.Drawing более не поддерживается. Вот вам и совместимость.
                              • 0
                                Уважаемый, в статье была все-таки отсылка на WriteableBitmap с его сферой применения. Но, давайте честно скажем, что и на андроиде мой подход тоже не будет работать. Ок, я принимаю эту критику.
                                Вероятно, вы просто далеки от задач, в которых нужно именно то, о чем в статье написано, и если вы с такими задачами не сталкивались, и не понимаете о чем речь, значит вам в какой-то степени повезло, и эта статья не для вас. Просто поставьте в закладки (на всякий будущий случай и сюрпризы жизни кодера) и не забивайте пока себе голову.
                      • +1
                        Спасибо, как раз есть похожее приложение на WinForms, где это можно заюзать :)
                        • +1
                          Голосуешь за готовый WinForms контрол на CodePlex? Не знаю есть ли смысл, фронтэнд для такого контрола прост, его смастырить несложно.
                          Но, пожалуй, завтра таки добавлю WF вариант контрола, хотя он на порядок проще WPF-варианта, но многим пригодится как стартовая точка.
                        • +1
                          Эх, а я в свое время радовался полученному 123 fps на 1280x1024… (полностью софтверный рендер)
                          • 0
                            Лет через будем 5 вместе с теплотой вспоминать 15K FPS, смастырив на новых проце, шине и памяти контрол на 1M FPS )
                          • 0
                            Зачем в методе Paint выделять память для _pArray, потом в него копировать данные из bitmap? Почему бы не передать сразу в функцию SetDIBitsToDevice поле Scan0 класса BitmapData? Т.е. вместо

                            ...
                            BitmapData BD = bitmap.LockBits(
                              new Rectangle(0, 0, bitmap.Width, bitmap.Height), 
                              ImageLockMode.ReadOnly, 
                              PixelFormat.Format32bppArgb);
                            
                            Marshal.Copy(BD.Scan0, _pArray, 0, _width * _height);
                            
                            SetDIBitsToDevice(hRef, 0, 0, _width, _height, 0, 0, 0, _height, ref _pArray[0], ref _BI, 0);
                            
                            bitmap.UnlockBits(BD);
                            ...
                            


                            сделать

                            ...
                            BitmapData BD = bitmap.LockBits(
                              new Rectangle(0, 0, bitmap.Width, bitmap.Height), 
                              ImageLockMode.ReadOnly, 
                              PixelFormat.Format32bppArgb);
                            
                            SetDIBitsToDevice(hRef, 0, 0, _width, _height, 0, 0, 0, _height, BD.Scan0, ref _BI, 0);
                            
                            bitmap.UnlockBits(BD);
                            ...
                            


                            Тем самым из кода уйдут две переменные (_pArray и _gcHandle), а также FPS чуток подрастет.
                            • 0
                              Если вы задаетесь и заморачиваетесь такими вопросами (правильными), то вы станете отличным .net-кодером, и MVP вас не минует.
                              Я отвечу попроще, иронично метафорично, не пинайте (подробнее и точнее лучше конечно MSDN читать). У нас, если грубо выражаться, есть два менеджера памяти — виндовый, и .net-нетовский CLR. И они между собой не совсем дружат. Каждый из них время от времени дефрагментирует свой кусок памяти, очищает мусор, перекидывает что-то в своп из физической памяти, но каждый как-бы сам по себе. Когда мы должны обменяться блоком памяти между управляемой и неуправляемой архитектурой, то стоит сделать Lock и GCHandleType.Pinned — это типа как прибить гвоздем к холодильнику записку «этот кусок памяти никому не трогать и не перемещать». Иначе, пока физической памяти много, 100 раз, даже 1000 раз всё будет ОК, а на 1001-й раз винда решит что-то свопнуть, про что не в курсе, и по нужному адресу вместо блока памяти нужного битмапа окажется кусок порнофильма, что я вчера скачал, и мне придется краснеть.
                              • 0
                                Судя по всему (исходники и описание на MSDN) использовать Scan0 как предложил Fen1xL безопасно — похоже, что LockBits уже пинит память.
                                • 0
                                  Если вопрос о самом смысле существования _pArray — он для предварительного композинга из нескольких битмапов перед отправкой на DC. Тут это пока не используется.
                                  • 0
                                    И, честно говоря, не знаю, стоит ли использовать, потому-что во фронтэнде есть честные BMP и GFX где мы можем это делать, а тут будут заморочки с терминами «stride» и «line byte align» и «alpha compose type», что только усложнит. Поэтому да, в простом варианте в текущей интерации можно выкинуть _pArray, но я бы оставил ради будущей возможной гибкости, а одно низкоуровневое блочное копирование памяти это чертовски быстрая штука и на FPS не роялит. И еще интереснее, что можно сделать цикл рендера синхронизирвоанный с VSync монитора тут, с _pArray, не ожидая перерисовок BMP от юзера.
                                  • 0
                                    Никакой кусок порнофильма из другого процесса/файлового кеша оказаться по этому адресу не может (не даст изоляция процессов друг от друга). CLR ничего в физической памяти не перемещает и в своп не скидывает.

                                    А вот кусок других данных из нашего же процесса вполне может — т.к. виндовый слой ничего не знает о том, что CLR может переместить объект в виртуальной памяти, поменяв его виртуальный адрес.

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