Tileset Packer: Details zum Code

Wie versprochen gibt es jetzt ein paar Details zum Code des Tileset Packers. Das Programm hat (meiner Meinung nach) ein paar interessante Stellen, die dem einen oder anderen .Net-Coder bestimmt helfen können.

Im Grunde hat der Packer drei Probleme zu lösen:

  1. Das Tileset muss verschnitten werden
  2. Doppelte Tiles müssen aussortiert werden
  3. Das Tileset muss effektiv wieder zusammen gesetzt werden

Dazu sollte das Programm natürlich eine gewisse Geschwindigkeit an den Tag legen. Das verschneiden kann man relativ einfach mit Bitmap.Clone erledigen, doch das ist sehr langsam, und dazu nimmt die Geschwindigkeit auch noch mit zunehmender Größe der Bitmap ab. (Große Bitmap = langsamer Code). Es musste also eine Alternative her. Durch einen Tipp in einem Forum bin ich dann auf Bitmap.LockBits gestoßen, eine Methode die direkt (unsafe) auf die Bitmap zugreift und dadurch sehr schnell ist. Von dieser Methode bekommt man ein BitmapData Objekt, mit dem man über einen Pointer auf die Daten im Arbeitsspeicher zugreifen kann.

Diese Daten verwendet dann die Methode GetHash, die aus den Bitmapdaten einen Hash generiert, mit dem doppelte Tiles aussortiert werden können.


static string GetHash(BitmapData data)
 {
 using (Bitmap tmp = new Bitmap(data.Width, data.Height, data.Stride, data.PixelFormat, data.Scan0))
 {
 using (MemoryStream ms = new MemoryStream())
 {
 tmp.Save(ms, ImageFormat.Bmp);
 rawData = ms.ToArray();
 }
 }

 for (int i = 54; i < rawData.Length; i++) //the first 54 byte are the bitmap header
 {
 rawData[i] &= 252; //1111 1100 letzden bits auf null setzen
 }

 return BitConverter.ToString(md5.ComputeHash(rawData, 54, rawData.Length - 54));
 }

Dazu wird zunächst direkt aus den Daten (auf die der Pointer Scan0 zeigt) eine neue Bitmap erstellt (sehr praktisch, dieser Konstruktor ;)), die dann in einem Memory-Stream geschrieben wird. Zwar kann man auch direkt auf die Daten in Scan0 zugreifen, aber diese sind scheinbar mit Nullen aufgefüllt. Das Array, auf das Scan0 zeigt ist zumindest viel zu groß und enthält extrem viele Nullen (neben den richtigen Daten).

Eine Bitmap hat leider eine 54 Byte großen Header, der im nächsten Schritt übersprungen werden muss. Normalerweise existiert dafür das Format ImageFormat.MemoryBitmap, das sich aber zum speichern scheinbar nicht benutzen lässt (Null-Exception). Laut Wikipedia kann der Header auch größer sein, was bei mir aber nie der Fall war.

In der Schleife wird dann schließlich das gesamte Array durchlaufen und bei allen Einträgen die beiden unteren Bits auf 0 gesetzt. Dadurch werden die Zahlen „gerundet“ und minimaler Abweichungen, die durch Kompression werden nicht mehr als unterschiedliche Tiles gezählt. Danach wird der md5-Hash der Daten als String zurück gegeben.

Danach werden die Bitmapdaten wieder freigegeben. Man beachte, dass die erstellte Bitmap wieder zerstört wird. Stattdessen wird nur das Rectangle, das den Ausschnitt darstellt gespeichert. Das nicht nicht unbedingt das schnellste, aber es ist so relativ einfach, und die Geschwindigkeit ist auch so noch sehr hoch.

Das letzte Problem, das noch gelöst werden muss, ist das effektive packen der Tiles. Nick Gravelyn hat ein wunderbares Programm zu diesem Zweck geschrieben, das ich auch erst verwendet habe. Dazu musste ich aber alle Tiles in Dateien schreiben, was relativ langsam ist, dazu ist das Programm mehr für unterschiedlich große Bilder gedacht. Nach etwas nachdenken bin ich dann auf eine Lösung gekommen, mit der man ausrechnen kann, wie viele Bilder man in eine Reihe packen muss, damit die Seitenlängen des fertigen Bildes minimal ist: Perfekt wäre √(TileCount), doch die Tiles sollen ja nicht abgeschnitten werden ;). Also wird die Zahl aufgerundet. Dadurch muss die andere Seite etwas kürzer werden. Das fertige Bild hat dann folgende Seitenlängen:


int packedXCount = (int)Math.Ceiling(Math.Sqrt(IDToRectangle.Count)); //IDToRectangle.Count = Anzahl der unterschiedlichen Tiles
 int packedYCount = (int)Math.Ceiling((double)(IDToRectangle.Count) / packedXCount);

Auch wenn mir kein Beweis einfällt würde ich vermuten, das diese Lösung perfekt ist, also immer das optimale Ergebnis liefert.

Jetzt müssen die Tiles nur noch kopiert werden (vom alten zum neuen Tileset):


foreach (KeyValuePair<int, Rectangle> kvp in IDToRectangle)
 {
 //crop rectangle and copy it to the output
 BitmapData tileSetData = TileSetImage.LockBits(kvp.Value, ImageLockMode.ReadOnly, TileSetImage.PixelFormat);
 using (currTile = new Bitmap(tileSetData.Width, tileSetData.Height, tileSetData.Stride, tileSetData.PixelFormat, tileSetData.Scan0))
 {
 packedGraphics.DrawImage(currTile, 32 * packedRowCount, 32 * packedColCount);
 }
 TileSetImage.UnlockBits(tileSetData);

 //PackedRow/Col zählen
 packedRowCount++;
 if (packedRowCount >= packedXCount)
 {
 packedRowCount = 0;
 packedColCount++;
 }

 }

Hier wird wieder eine Bitmap erstellt, in dem das Tileset gelocked wird, danach wird die Bitmap auf das neue Tileset gemalt. Dabei werden intern wohl nur die Daten kopiert, sodass das ziemlich schnell funktioniert.

Das waren alle interessanten Stellen, der Rest ist mehr oder weniger Standard. Mir hat das ganze gezeigt, das man auch mit C# Bilder schnell bearbeiten kann, obwohl alles managed ist, also z.B. immer gelocked werden muss. Wem das ganze immer noch zu langsam ist, der kann auch unsafe Code verwenden, bei dem er dann mit den Daten machen kann, was er will.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.