Programmerare, skeptiker, sekulärhumanist, antirasist.
Författare till bok om C64 och senbliven lantis.
Röstar pirat.
2023-07-17
När man ska jobba med bitmapsgrafik i .NET är prestanda en ständigt återkommande utmaning. I .NET har vi dels tillgång till GDI (som låter oss rita pixlar individuellt) och något som kallas för GDI+, som erbjuder möjligheten att rita geometriska figurer på skärmen. GDI är numera en del av GDI+, så båda biblioteken exponeras i namnrymden System.Drawing
. Den viktigaste klassen för den som jobbar med GDI heter Bitmap
och den viktigaste klassen för den som jobbar med GDI+ heter Graphics
.
Som parentes vill jag nämna att .NET inte är ett ramverk utan två. Dels har vi .NET Framework och dels har vi det ramverk som kort och gott heter .NET (tidigare .NET Core). Jag väljer i princip alltid .NET (tidigare .NET Core) eftersom det är ett mer moget ramverk, det finns tillgängligt på flest operativsystem (Mac, Linux, Android, m.fl.) och för att du får arbeta i senare versioner av C# i .NET. Det enda motivet att välja .NET Framework är att appar byggda för .NET Framework 4.8 fungerar out-of-the-box på Windows 10 eller senare och uppdateras via Windows Update. Eftersom GDI är en Windows-feature är jag inte intresserad av plattformsoberoendet och väljer därför .NET Framework 4.8 och C# 7.3 för dessa exempel.
Använder man Visual Studio med tillägget för skrivbordsapplikationer, är det bara att välja en projektmall för Windows Forms och .NET Framework, men kör man Visual Studio Code eller någon annan editor, måste man specificera i projektfilen att man vill ha TargetFrameworkVersion
satt till v4.8
, att OutputType
ska vara WinExe
och att namnrymderna System.Windows.Forms
och System.Drawing
ska vara refererade.
Låt oss titta på detta program. Det ritar en rektangel (500×500 pixlar) på skärmen varje gång man klickar med musen. Rektangeln ritas röd- och gulrandig på en slumpvis vald plats. Hela operationen klockas.
using System;
using System.Diagnostics;
using System.Drawing;
using System.Windows.Forms;
namespace WindowsFormsApp1
{
public partial class Form1 : Form
{
private static Random _rnd = new Random();
private Bitmap Rectangle { get; set; }
private int PositionX { get; set; }
private int PositionY { get; set; }
public Form1()
{
InitializeComponent();
}
private void Form1_MouseClick(object sender, MouseEventArgs e)
{
PositionX = _rnd.Next(300);
PositionY = _rnd.Next(300);
// Städa upp minnet.
Rectangle?.Dispose();
Rectangle = new Bitmap(500, 500);
// Rita en randig rektangel.
var c1 = Color.FromArgb(255, 0, 0);
var c2 = Color.FromArgb(255, 255, 0);
var stopwatch = new Stopwatch();
stopwatch.Start();
for (var y = 0; y < 500; y++)
{
var currentColor = y%2 == 0 ? c1 : c2;
for (var x = 0; x < 500; x++)
{
Rectangle.SetPixel(x, y, currentColor);
}
}
// Skriv ut hur lång tid operationen tog.
stopwatch.Stop();
Text = stopwatch.ElapsedMilliseconds.ToString();
// Uppdatera skärmen (Form1_Paint kommer att anropas).
Invalidate();
}
private void Form1_Paint(object sender, PaintEventArgs e)
{
if (Rectangle == null)
return;
// Här utförs uppdateringen av skärmen.
e.Graphics.Clear(Color.White);
e.Graphics.DrawImage(Rectangle, PositionX, PositionY);
}
private void Form1_FormClosed(object sender, FormClosedEventArgs e)
{
// Städa upp minnet.
Rectangle?.Dispose();
}
}
}
Hela operationen tar ungefär 140 millisekunder oavsett om programmet är debug- eller release-kompilerat.
Funktionen SetPixel
är central för att detta ska fungera. Den har Microsoft skrivit åt oss i C#, och använder i sin tur GDI i Windows för att få pixeln satt på skärmen. Om vi läser källkoden så ser vi att den gör en del tester som säkerställer att funktionen används korrekt, för att sedan anropa GdipBitmapSetPixel
i biblioteket gdiplus.dll
som är skrivet i C++.
Vi skulle kunna optimera bort testerna, men jag förstår att Microsoft fill ha med dem, eftersom de tänker att det måste vara bättre med ett program som är lite slöare än ett program som kraschar om någon gör ett fel, som t.ex. att försöka rita en pixel på en bitmapsbild vars position ligger utanför bildens storlek.
En annan mindre viktig detalj är att för varje pixel som ritas, tar SetPixel
en färg som är en struktur av en röd, grön och blå byte, som ska konverteras till en int. Konverteringen är gjord i förväg av färgstrukturen, men det blir ett extra funktionsanrop och en extra typomvandling.
Dessa optimeringar får man såklart gärna titta på, om man vill få ut så hög prestanda man bara kan i sin renderingsrutin. Men den viktigaste faktorn handlar om låsning av minnet. När man ritar pixlar ska man erhålla ett lås för att skydda minnet, som man sedan släpper när man är färdig. Som SetPixel
är implementerad, skapas och släpps ett lås för varje pixel som sätts, vilket kostar. Att göra en egen implementation som håller låset tills alla pixlar är ritade, är inte särskilt svårt, men det ställer lite högre krav på den som använder implementationen. Och det kanske var därför som Microsoft valde den väg man valde – säkerhet framför prestanda. Trots allt är tanken med C# att det ska vara mer lättillgängligt än t.ex. C++.
Ett exempel på denna lättillgänglighet är att C#-kompilatorn helt enkelt inte tillåter användandet av pointers såvida man inte först deklarerar att man tänker göra så, vilket man gör med nyckelordet unsafe
. Det är till och med så att nyckelordet unsafe
inte får användas om vi inte sätter AllowUnsafeBlocks
till true
i projektfilen!
För att undvika att minnet blir låst för varje pixel som ritas, måste vi undvika den inbyggda funktionen SetPixel
. Och om vi manipulerar minnet utan att använda den inbyggda funktionen SetPixel
, måste vi manuellt låsa minnet när vi börjar arbeta och låsa upp det när vi är klara.
Funktionen LockBits
behöver kunna räkna ut hur mycket minne som ska låsas och behöver därför veta bildens storlek (500×500 pixlar) samt vilket pixelformat som används. Jag utnyttjar bara 24 bitar (röd x grön x blå) men arbetar med 32 bitar (alfa x röd x grön x blå), så därför anger jag pixelformatet Format32bppArgb
. Svaret från funktionen LockBits
används för att berätta för funktionen UnlockBits
vad som ska låsas upp, så det vill man ta hand om.
Sist men inte minst måste färgen på pixlarna sättas. Tidigare var det bara ytterligare en parameter till SetPixel, men nu måste även det göras manuellt. Eftersom vi arbetar i 24 bitar, kan vi använda en fyra bytes stor pointer, Scan0
.
using System;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Windows.Forms;
namespace WindowsFormsApp1
{
public partial class Form1 : Form
{
private static Random _rnd = new Random();
private Bitmap Rectangle { get; set; }
private int PositionX { get; set; }
private int PositionY { get; set; }
public Form1()
{
InitializeComponent();
}
private void Form1_MouseClick(object sender, MouseEventArgs e)
{
PositionX = _rnd.Next(300);
PositionY = _rnd.Next(300);
// Städa upp minnet.
Rectangle?.Dispose();
var bits = new int[500 * 500];
var bitsHandle = GCHandle.Alloc(bits, GCHandleType.Pinned);
Rectangle = new Bitmap(500, 500, 500 * 4,
PixelFormat.Format32bppArgb,
bitsHandle.AddrOfPinnedObject());
// Rita en randig rektangel.
var c1 = Color.FromArgb(255, 0, 0).ToArgb();
var c2 = Color.FromArgb(255, 255, 0).ToArgb();
var stopwatch = new Stopwatch();
stopwatch.Start();
var data = Rectangle.LockBits(
new Rectangle(0, 0, 500, 500),I
mageLockMode.ReadWrite,
PixelFormat.Format32bppArgb);
unsafe
{
var bytes = (byte*)data.Scan0;
for (var y = 0; y < 500; y++)
{
for (var x = 0; x < 500; x++)
{
var index = x + y * 500;
bits[index] = y % 2 == 0 ? c1 : c2;
}
bytes++;
}
}
Rectangle.UnlockBits(data);
bitsHandle.Free();
// Skriv ut hur lång tid operationen tog.
stopwatch.Stop();
Text = stopwatch.ElapsedMilliseconds.ToString();
// Uppdatera skärmen (Form1_Paint kommer att anropas).
Invalidate();
}
private void Form1_Paint(object sender, PaintEventArgs e)
{
if (Rectangle == null)
return;
// Här utförs uppdateringen av skärmen.
e.Graphics.Clear(Color.White);
e.Graphics.DrawImage(Rectangle, PositionX, PositionY);
}
private void Form1_FormClosed(object sender, FormClosedEventArgs e)
{
// Städa upp minnet.
Rectangle?.Dispose();
}
}
}
Och skillnaden går inte av för hackor! Den lite mer komplicerade koden till trots, nu går samma operation på en (!) millisekund! Man kan alltså lugnt konstatera att om prestanda är viktigt, så bör man kringgå funktionen SetPixel
.
Categories: C#
Bjud mig på en kopp kaffe (20:-) som tack för bra innehåll!
Leave a Reply