Pull to refresh

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

High performance *.NET *C# *
Sandbox
Сразу уточнение: 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
Tags:
Hubs:
Total votes 48: ↑40 and ↓8 +32
Views 40K
Comments Comments 39