Erstelle Texturen in Swift Apps ohne Bilder

Spiele programmieren macht Spass. Ein Spiel auf eigene Faust zu entwickeln und die Ergebnisse dann zu sehen ist jedem Entwickler immer wieder Grund genug weiter zu machen. Aber als einsamer Wolf an einem Spiel zu arbeiten braucht Zeit, besonders wenn es um Grafiken wie Bilder, Sprites und Texturen geht. Im schlimmsten Fall könnte das in wochen- oder monatelangen Arbeiten allein nur an den Grafiken enden.

Dieses Mini Tutorial ist nicht über das bauen der Grafiken oder wie man Werkzeuge wie Photoshop oder Inkscape benutzt. Es geht darum wie man, ohne auch nur ein einzelnes Bild verwenden zu müssen, Texturen in einer App erzeugen kann. Der Grund warum ich mich für das programmieren von Texturen entschieden habe, ist Zeit.

Als ich das erste Mal XCode gesehen habe und wie einfach es doch ist in Swift zu programmieren aber auch wie man Ressourcen wie Bilder in die App einbaut und verwaltet. Aber du musst immer noch den Aufwand für das Erstellen der Bilder einbringen und das für jedes einzelne Bild. Damit du dann auch noch beste Resultate auf den vielen Endgeräten bekommst, musst du jedes Bild am besten auch noch in 3 Größen in das Projekt einbinden. Schon hast du einen Haufen Bilder den erstellen und verwalten muss. Wenn du dann mal ein Bild ändern musst, musst du dann wieder alle 3 Größen daraus erzeugen.

from apple.com

Die Methode über die ich hier schreibe, habe ich in meinem ersten iOS Spiel „Puckify“ (hier zu sehen) umgesetzt.

Hier geht es darum alle Texturen die du in deinem Spiel brauchst im Betrieb (genauer beim Starten der App) zu generieren. Die Idee ist einen so genannten TextureManager (oder wie du den auch immer nennen magst) zu verwenden der Anfangs alle Texturen generiert. Dabei gibt es folgende Methoden die du angehen kannst:

  • UIBezierpath. Programmieren dein Bild/Sprite/Was-Auch-Immer und generiere eine SKShapeNode aus dieser im ersten Schritt.
  • Paintcode. Zeichne oder importiere ein Bild oder eine SVG Datei, ändere diese nach deinen Wünschen und lass dir von Paintcode den Swift Code erzeugen, welches einen UIBezierpath generiert.
  • SVG Dateien mit Parser. Benutze SVG Dateien welche in dein Swift Projekt importiert werden und lade diese in den SVG Parser um mit der daraus entstanden UIBezierpath eine SKShapeNode zu generieren.

Die Methode mit den SVG Dateien und dem Parser entspricht natürlich nicht der eigentlich Idee hier, aber diese Methode ist immer noch einfacher als die Bilder auf die übliche Weise für die Texturen zu erzeugen.

UIBezierpath

Wenn du ein Meister in Sachen Bezierpath bist, kannst du alle Texturen direkt in Swift selbst programmieren und die pro Texture am besten eine Funktion definieren die du später beim App Start aufrufen und verwenden kannst. Im folgenden siehst du eine Funktion welche einen UIBezierpath für einen einfachen Stern erzeugt.

func star() -> UIBezierPath {
    let star = UIBezierPath()
    star.move(to: CGPoint(x: 112.79, y: 119))
    star.addCurve(to: CGPoint(x: 107.75, y: 122.6), controlPoint1: CGPoint(x: 113.41, y: 122.8), controlPoint2: CGPoint(x: 111.14, y: 124.42))
    star.addLine(to: CGPoint(x: 96.53, y: 116.58))
    star.addCurve(to: CGPoint(x: 84.14, y: 116.47), controlPoint1: CGPoint(x: 93.14, y: 114.76), controlPoint2: CGPoint(x: 87.56, y: 114.71))
    star.addLine(to: CGPoint(x: 72.82, y: 122.3))
    star.addCurve(to: CGPoint(x: 67.84, y: 118.62), controlPoint1: CGPoint(x: 69.4, y: 124.06), controlPoint2: CGPoint(x: 67.15, y: 122.41))
    star.addLine(to: CGPoint(x: 70.1, y: 106.09))
    star.addCurve(to: CGPoint(x: 66.37, y: 94.27), controlPoint1: CGPoint(x: 70.78, y: 102.3), controlPoint2: CGPoint(x: 69.1, y: 96.98))
    star.addLine(to: CGPoint(x: 57.33, y: 85.31))
    star.addCurve(to: CGPoint(x: 59.29, y: 79.43), controlPoint1: CGPoint(x: 54.6, y: 82.6), controlPoint2: CGPoint(x: 55.48, y: 79.95))
    star.addLine(to: CGPoint(x: 71.91, y: 77.71))
    star.addCurve(to: CGPoint(x: 81.99, y: 70.51), controlPoint1: CGPoint(x: 75.72, y: 77.19), controlPoint2: CGPoint(x: 80.26, y: 73.95))
    star.addLine(to: CGPoint(x: 87.72, y: 59.14))
    star.addCurve(to: CGPoint(x: 93.92, y: 59.2), controlPoint1: CGPoint(x: 89.46, y: 55.71), controlPoint2: CGPoint(x: 92.25, y: 55.73))
    star.addLine(to: CGPoint(x: 99.46, y: 70.66))
    star.addCurve(to: CGPoint(x: 109.42, y: 78.03), controlPoint1: CGPoint(x: 101.13, y: 74.13), controlPoint2: CGPoint(x: 105.62, y: 77.44))
    star.addLine(to: CGPoint(x: 122, y: 79.96))
    star.addCurve(to: CGPoint(x: 123.87, y: 85.87), controlPoint1: CGPoint(x: 125.81, y: 80.55), controlPoint2: CGPoint(x: 126.64, y: 83.21))
    star.addLine(to: CGPoint(x: 114.67, y: 94.68))
    star.addCurve(to: CGPoint(x: 110.75, y: 106.43), controlPoint1: CGPoint(x: 111.89, y: 97.34), controlPoint2: CGPoint(x: 110.13, y: 102.63))
    star.addLine(to: CGPoint(x: 112.79, y: 119))
    star.close()
    return star
}

Ist dir der UIBezierpath nicht geläufig, schau in der Apple Developer Documentation hier nach.

Paintcode

Paintcode ist eine geniale kommerzielle Software welches dir erlaubt Bilder oder Vektorgrafiken zu importieren, diese zu ändern und am Ende daraus in verschiedenen Sprachen den Code zu generieren, unter anderem Swift (3 und 4; Stand Dezember 2018). Du kannst Paintcode für 5 Tage kostenlos testen, was ich dir sehr empfehle. Gefällt dir das Programm, kannst du dir eine Personal Edition für 99 $ kaufen (Stand: 27.12.2018; einmaliger Preis). Für Unternehmen zahlt man pro Sitzplatz (Entwickler) und Jahr 199 $.

Paintcode nimmt dir die ganze Arbeit um den Code für das jeweilige Bild zu erzeugen. Du kopierst den Code einfach in eine Funktion in deiner App und verwendest diese beim generieren der SKShapeNode. Unterstützte Sprachen in Paintcode sind neben Swift unter anderem: Objective-C, C# Xamarin, Android Java, Web SVG und Javascript Canvas.

SVG Dateien mit Parser

Ein anderer Weg um programmiertechnisch deine Bilder in der App zu erzeugen sind das importieren von SVG Dateien in dein Projekt und diese mit einem speziellen Parser dann zu UIBezierpath Instanzen zu konvertieren.

Mir sind vier Lösungen auf github.com bekannt die hierbei helfen. Ich persönlich habe PocketSVG getestet und habe damit relativ gute Erfahrungen machen können. Solange du keine Monster SVG konvertieren willst, sollte dieses Framework dir gute Ergebnisse liefern. Gerade einfachen SVG’s die in einem Path verlaufen, funktionieren diese Frameworks gut. Wie es mittlerweile mit komplexeren Varianten steht weiß ich nicht, das musst du selber testen:

  • https://github.com/pocketsvg/PocketSVG (link)
  • https://github.com/exyte/Macaw (link)
  • https://github.com/mike-engel/swiftvg (link)
  • https://github.com/mchoe/SwiftSVG (link)

Mit den folgenden Zeilen Code kannst du eine SVG laden und diese in eine UIBezierpath wandeln:

let svgpath = Bundle.main.url(forResource: "star", withExtension: "svg")!
let star_bp = SVGBezierPath.pathsFromSVG(at: svgpath)
let node = SKShapeNode(path: star_bp.cgPath, centered: true)
node.fillColor = .white
node.strokeColor = .black

Erster Schritt erledigt. Was jetzt?

Nun, du könntest jetzt deine SKShapeNode Instanzen direkt in die Szene einbauen. Aber du solltest schnell feststellen das dieses keine saubere Lösung ist. SKShapeNode repräsentiert eine Vektorgrafik welche du nun direkt in die Szene einsetzt.

Deine App wird jetzt Bild für Bild jedes SKShapeNode neu rechnen und zeichnen müssen da es sich um eine Vektorgrafik handelt (und keine Rastergrafik). Schnell wirst du erkennen das deine CPU bei vielen SKShapeNode Instanzen in die Höhe schiesst da diese mit den Kalkulationen der Nodes beschäftigt ist. Für einfache Grafiken und wenige SKShapeNode Instanzen ist das vielleicht noch OK, aber das ist nicht der gangbare Weg für eine App die mit SpriteKit arbeitet weil es deine CPU zu sehr belasten wird und damit die Batterie des Gerätes schnell dem Ende nahen lässt.

Wir müssen hier mit SKSpriteNode Instanzen arbeiten welche wir aus der SKTexture erzeugen können welches dein Bild enthält.

Nachdem du nun deine SKShapeNode mit einer passenden Füllfarbe, Randfarbe und evtl. weiteren Anpassungen ausgestattet hast, musst du folgendes machen:

let node = SKShapeNode(path: star_bp.cgPath, center: true)
let view = SKView(frame: UIScreen.main.bounds)
let texture = view.texture(from: node)!
let el = SKSpriteNode(texture: texture)
self.addChild(el)

Zeile 2-4 sind im Grunde die fehlenden Schritte um dein Sprite mit SKSpriteNode zu erzeugen. Dieses kannst du nun in die Szene einbauen welche definitiv besser in der Performanz läuft als eine SKShapeNode Instanz.

Es besteht kein Bedarf die laufende SKView deiner laufenden App zu benutzen um aus den SKShapeNode Instanzen die Texturen zu erzeugen. Es reicht völlig aus das wie oben aus dem Code zu entnehmen, eine SKView in der aktuellen Displaygröße erzeugst und dieses als stillen Konverter nutzt.

Was ist mit den verschiedenen Größen?

Das ist der nächste Schritt den wir angehen müssen. Die erste Frage die du dir beantworten solltest ist die über die Unterstützung der Geräte auf dem deine App dann auch laufen soll und dazu solltest du noch ermitteln welches das kleinste Gerät von der Displaygröße sein wird.

Während meiner Arbeit an „Puckify“ habe ich die aktuelle Verteilung der iOS Versionen und den Apple Geräten geprüft. Es gibt verschiedene Seiten die uns Aufschluss darüber geben wie es aktuell aussieht. Eine Seite die mir bisher gefallen hat ist die von David Smith (link). Anhand der Statistiken aus seiner erfolgreichen App „AudioBooks“ entnimmt er die Zahlen und präsentiert diese mit regelmäßigen Aktualisierungen auf seiner Seite. Zusätzlich solltest du dir anschauen welche Geräte es aktuell von Apple gibt und wie die Displaygrößen hierbei verteilt sind. Das kannst du hier finden.

Wie man auf iosres.com sehen kann, das kleinste Gerät auf der linken Seite ist der iPhone 8, welches ich in diesem Fall als Standardgerät für meine App betrachtet hatte. Unter Logical Resolution sehen wir eine Auflösung von 375×667. Hierbei müssen wir uns entweder für eine Größe für alle unsere Texturen entscheiden (wenn möglich wie in meiner App) oder für jede Textur eine passende Größe ausgehend aus der nun definierten Standardgröße von 375×667 (oder je nachdem wie du dich entscheidest bzw. wie der aktuelle Gerätestand bei Apple ist). Meine Sprites sind alle standardmäßig auf 100×100 erzeugt. Bei meinen Tests hat sich diese Größe als Optimum auf dem iPhone 8 ergeben.

Nun hat meine TextureManager Klasse alles in genau dieser Größenausrichtung von 100×100 erzeugt: Buttons, Hintergründe, Icons wie Schilder und Sterne, die Grafiken für den Spieler und die Gegner. Buttons waren hier aber eine Ausnahme da ich Buttons erzeugen musste die die vollen Breite des Bildschirms einnehmen sollen. Ausnahmen wird es immer wieder geben, je nachdem was du entwickelst.

Wenn du jetzt in dieser Konstellation deine App auf einem iPad oder iPhone 8+ startest, wirst du merken das die Sprites pixelig aussehen, aus dem ganz einfachen Grund das hier 100×100 zu klein sind.

Also warum nicht entsprechend hochskalieren passend zum aktuellen Gerät? Meine Vorgehensweise war ziemlich einfach gehalten.

In meiner TextureManager Klasse habe ich 375×667 (oder 667×375 wenn es im Landscape Modus laufen soll) als Standardgröße definiert. Wenn nun der Bildschirm aber größer war als dieser Standard, habe ich den Skalierungsfaktor aus der Breite des Bildschirms errechnet und diesen gespeichert.

Dafür habe ich eine Funktion „sizeMultiplier“ definiert in der dieser Skalierungsfaktor geliefert wurde. Diesen verwendete ich nun bei allen SKShapeNode Instanzen und habe die Pfade mit Hilfe der CGAffineTransform Klassen hochskaliert.

struct DefaultResolution {
  static let width = CGFloat(667)
  static let height = CGFloat(375)
}

static func sizeMultiplier() -> CGFloat {
  let ssize = UIScreen.main.bounds.size
  if ssize.width > DefaultResolution.width && ssize.height > DefaultResolution.height {
    let w = ssize.width / DefaultResolution.width
    return (w * 10).rounded() / 10 // abrunden auf eine Dezimalstelle
  }
            
  return 1.0;
}

Unter Verwendung des obigen Codes lass ich nun alles an Szenenelementen wie Wände, Gegner, Spieler, einfach alles, hochskalieren abhängig davon um was für eine Displaygröße es sich handelt.

Ein vollständiges Beispiel aus meiner TextureManager Klasse sieht folgendermaßen aus:

let scalefactor = Constants.Config.sizeMultiplier() // der skalierungsfaktor
let scaleRefresh = CGAffineTransform(scaleX: scalefactor, y: scalefactor)
// ### REFRESH textur für den refresh button erzeugen
let r = UIBezierPath.refresh() // one of my UIBezierpath function returning the path for a refresh icon
r.apply(scaleRefresh) // now scale the bezierpath before creating SKShapeNode
var refresh = SKShapeNode(path: r.cgPath, centered: true)
refresh.fillColor = .white
refresh.strokeColor = .black
self.addTexture(withName: "refresh.icon", texture: self._view.texture(from: refresh)!)

Now i don’t need to care about my refresh icon anymore, no matter on what device i use this icon, it will be scaled properly for the device. I tested this on an iPhone 6, iPhone 6+, iPhone 7, iPhone 8+, iPad 2 and latest iPad (9.7 inch; 6th generation). Everything was scaled properly. The following screenshots using the iOS Simulator shows my main screen on the app „Puckify“.

Nun muss ich mir überhaupt keine Gedanken mehr darüber machen ob und wie meine einzelnen Sprites in der Größe passen je nach Geräte. Die obige Logik skaliert diese entsprechend dem Faktor hoch. Bei einer Bildschirmbreite von 375 und plötzlichen, sagen wir mal vereinfacht, 750, haben wir einen Skalierungsfaktor von 2. Also werden alle Sprites in der Breite und Höhe 2 fach hochskaliert.

Achtung: Diese Logik funktioniert nur wenn ihr eure App entweder im Portrait Modus oder im Landscape Modus laufen lasst. Wollte ihr beide Orientierungen unterstützen, müsst ihr das im Code berücksichtigen. Denn aus 375 in der Breite, können durch einfaches drehen eures Geräte plötzlich 667 werden, da ist das hochskalieren in diesem Fall nicht sinnvoll. Ich würde mich hierbei fix auf den Wert aus UIScreen.main.bounds.size verlassen und dabei beide Werte prüfen um bei einfache Drehungen evtl. KEINERLEI Skalierungen zu machen.

Eine Randnotiz zum Skalieren

Benutze niemals .setScale an einer SKShapeNode Instanze. Diese Funktion ist ein einfacher Skalierer ohne gescheitem Algorithmus. Wenn du dieses verwendest bekommst du z.B. bei 2 facher Hochskalierung ein pixeliges Ergebnis.

Stattdessen solltest du wie oben schon gezeigt bereits den Bezierpath hochskalieren bevor du damit die SKShapeNode Instanz erzeugst:

let scaleRefresh = CGAffineTransform(scaleX: scalefactor, y: scalefactor)
let r = UIBezierPath.refresh() // einer meiner UIBezierpath funktionen welches den Pfad für den refresh icon liefert
r.apply(scaleRefresh) // jetzt skalieren bevor wir die SKShapeNode kreieren
// jetzt die SKShapeNode Instanz aus dem skaliertem Bezierpath erzeugen

Schreibe einen Kommentar