Create textures in Swift apps without images

Creating games is fun. Making games on your own and seeing the results gives every developer the reason to continue doing so. But working on a game as a lonely wolf takes time and especially when it comes to graphics (images, sprites, textures, etc.) this might end in weeks or months finding and creating the proper graphics.

This mini tutorial is not about designing graphics or how to use tools like Photoshop or Inkscape, it is about how to create texture without the use of any single image file (PNG, JPG, etc.). The reason I decided to “code” my textures in-game is just to safe some time. When I started working with XCode, I was impressed how easy it is to code but also to set up assets. But still, you need to design and create (or buy) each single image and to deliver the best results on all devices you should offer 3 sizes for each image.

from apple.com

The technique i am about to describe has been implemented in my iOS game “Puckify” (check here).

It is about to create all textures used in your game on the fly when the app starts. The idea is to create a so-called TextureManager which does “build” all images during your app start using one of the following methods:

  • UIBezierpath. Code your image/sprite/whatever and create SKShapeNode out of it in the first step.
  • Use Paintcode. Draw or import any image or svg file, change it to your needs and turn it into Swift code and put it into a function which returns a single UIBezierpath for the image you used.
  • SVG Files with parser. Use SVG files to import into your project and parse them into UIBezierpath or CGPath to create a SKShapeNode instance out of it in the first step.

The SVG file method obviously is not the technique i am describing here, but this method is still easier than drawing images on your own.

UIBezierpath

If you are a pro you might be able to code your image/sprite right in XCode using UIBezierpath. The following shows a function which generate the UIBezierpath for a simple star shape:

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
}

If you are not familiar with UIBezierpath, check out Apple’s documentation here

Use Paintcode

Paintcode is a great commercial product allowing you either to import media or draw on your own. Once done it offers you the code for different languages which allows you to programmatically render the image created or imported in Paintcode. Paintcode is free for 5 days which I very recommend to try out if you don’t know it. If you like it buy it paying $ 99 (Date: 27th Dec 2018; one time purchase). Companies pay $ 199 per seat per year.

Paintcode does all the work for you being a pro in coding your image. So simply copy the code and use it for your game/app. Paintcode offers more languages like Objective-C, C# Xamarin, Android Java, Web SVG and Javascript Canvas and some more.

SVG files with parser

Another way to code your images is by parsing SVG files into UIBezierpath in your Swift app. This requires you to import the SVG file into your project so your app is able to read in the file during app runtime to convert it to UIBezierpath.

I know four solutions so far all on github.com, I tried PocketSVG which works great unless you have some monster dead complex SVG file to handle, it might happen you will get weird results, in this case it makes sense to cut your SVG into pieces and load each part merging them into one in Swift, as follows you can see some projects I found so far:

  • 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)

With the following lines of code you can load your SVG turning it into a UIBezierpath:

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

First step done. What next?

Well, you could now start putting your SKShapeNode instances right into your scene. But wait, this is a bad approach. SKShapeNode represents a vector based shape which you now are about to add into your scene.

Your app is now going to render a vector graphics every frame. If you do so you will see you CPU will raise up quickly since it is busy with calculating and drawing your shape. For a simple shape it might be useful to scale and manipulate on each frame, but this is not the way to go for most sprites being drawn as this would eat up all your CPU power on the device.

We need to work with SKSpriteNode which is build out of SKTexture instance which has your sprite.

So after you have created your SKShapeNode with a proper fill color, stroke color and probably some more, you need to do the following:

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)

Line 2-4 are the missing steps to create your sprite which you now can add to your scene which definitely performs better than handling SKShapeNode instances.

There is no need to use your running SKView in your app to convert your SKShapeNode instances into a texture. It works fine with a separate instance.

But what about the sizes?

This is the next step we need to handle. The first question you need to answer is what devices you are going to support and what is the current smallest device you will need to support.

When I worked on “Puckify”, I had to check the current iOS distribution and what devices people were using. There are different sites providing you the information you need. One site I found was from David Smith (link). Using the statistics from his app “AudioBooks”, he offers a good overview of current iOS distributions today. What you also need to know are the device sizes and their resolutions. This you can find here.

As you can see at iosres.com, the smallest device on the left is the iPhone 8, which in this case was my chosen default one. Now looking at the logical resolution of 375×667 you need to find a proper size either for all your sprites or define for each. My sprites are quadratic, so i decided having 100×100 sized sprites look good on an iPhone 8.

So my TextureManager class was building up buttons, backgrounds, icons like shields, stars, my player sprites, enemies etc. all sized 100×100.

But once you start your app on an iPad or the iPhone 8+ your sprites will look blurry and pixellated, simply because 100×100 is way too small for a bigger device.

So why not scale up so. My approach was very simple.

In my TextureManager I defined 375×667 (or 667×375 when you run it in landscape mode) as my default screen size. Once the screen got bigger, I calculated the scale factor looking only on the width of the device.

For this I built a simple sizeMultiplier function which I was now multiplying the width and height of each SKShapeNode using the CGAffineTransform class.

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 // round to one decimal place
  }
            
  return 1.0;
}

Now using the code above all my sprites, walls, enemies, just everything, i do scale by using sizeMultiplier().

One full example using my TextureManager looks like this:

let scalefactor = Constants.Config.sizeMultiplier()
let scaleRefresh = CGAffineTransform(scaleX: scalefactor, y: scalefactor)
// ### create REFRESH texture for refresh button
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 is 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 is scaled properly. The following screenshots using the iOS Simulator shows my main screen on the app “Puckify”.

Warning: The logic described only works if your app either runs in landscape or in portrait mode, not in both. If you want to support both modes in your app, then you need to handle it in your code. Looking again at the iPhone 8 with 375×667 in its logical resolution. Rotating this device would result in 375 becoming 667, in this case you don’t want to scale up since it is still the same device. You should focus on the value given by UIScreen.main.bounds.size und check both, width and height to doublecheck if the size is really bigger than your default one chosen in the beginning. In this case, when rotating, you should NOT scale up.

A note about scaling

Never ever use .setScale on the SKShapeNode, because it is a simple scaler without any proper algorithm behind, if you scale up a shape by let’s say 2 times its width, you will clearly see it gets pixellated.

Instead use following code (as seen above) to scale the Bezierpath before you create the SKShapeNode instance:

let scaleRefresh = CGAffineTransform(scaleX: scalefactor, y: scalefactor)
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
// now do create the SKShapeNode instance using the bezierpath instance

Leave a Reply