Automating 2D Graphics


As a programmer I naturally suck at graphics and love automation. For SE to be more sustainable and fun as a hobby dev project than Factropy was, I wanted to entirely automate graphics production this time. So step one, before writing a line of game code, was to build a separate pipeline for generating graphical assets.

Requirements:

  • Be entirely scripted
  • Render top-down orthographic or oblique 2D tile sets (with animation, shadow layers etc)
  • Render perspective 3D previews and short movie clips
  • Generate boiler plate JSON config for a modding API to ingest
  • [Optional] Take advantage of my GPU when possible, and fall back to software rendering if running headless

And SDFRTLJIM was born!

... as a programmer I also suck at naming stuff. Lucky nobody else uses this tool.

  • SDF as in Signed Distance Functions, describe objects with math
  • RT as in Ray Tracer (in a several weekends), fairly stock standard
  • LJ as in LuaJit
  • IM as in ImageMagick

For those familiar with ShaderToy -- like that, but with Lua instead of GLSL and able to run headless.

Example time

In SE the little rotating telescopic arms end up looking like this [1]:


That comes from this Lua script:

animate{
    name = "arm-south",
    script = "arm",
    frames = 36,
    sun = sun.south,
    view = {
        position = up*1.4,
        target = origin,
        up = north, -- top-down view
        w = 256,
        h = 256,
    },
}
 
function tube(fields)
    return cylinder{
        h = 0.5, r = fields.r,
        pos = north * fields.telescope,
        rot = {east,90},
        round = 0.02,
    }
end
 
local length = max(0.25,abs(cos(rad(360*progress))))
 
object(
    union{
        tube{ r = 0.06, telescope = 0.8 * length },
        tube{ r = 0.12, telescope = 0.4 * length },
        tube{ r = 0.18, telescope = 0 },
        color = colors.chrome,
        pos = up,
        rot = {up,360*progress},
    }
)
 
object(
    hull{
        box{ x = 0.45, y = 0.45, z = 0.2, pos = up },
        box{ x = 0.2, y = 0.2, z = 0.2, pos = up*0.1 },
        color = colors.forest_green,
        round = 0.02,
        rot = {up,360*progress},
    }
)
 
object(
    hull{
        cylinder{ h = 0.2, r = 0.05, pos = (north+west)*0.3},
        cylinder{ h = 0.2, r = 0.05, pos = (north+east)*0.3},
        cylinder{ h = 0.2, r = 0.05, pos = (south)*0.4},
        color = colors.taxi_cab_yellow,
        round = 0.02,
    }
)

Which spits out a tile set:


Notice the shadows are on the model but not the background? Those are in a separate sheet to be rendered on the ground. Also generated are sheets corresponding to the other cardinal directions since this unit can be rotated. Those use the same script with a different camera.

Admittedly the result here is fairly basic. There's no gripper or hand on the end of the arm; the colors could do with texturing; maybe more detail needs to be added etc. But since this is now a script in a pipeline it's easy to:

  • Build a library of mechanical components to be reused by models, expressed as math
  • Iteratively apply small fixes and improvements to components and update every model via the pipeline
  • Work on better lighting and and textures by rapidly testing the results in-game (it's quite fast, because of that GPU bullet)
  • Track all graphical assets in Git without LFS because they're just scripts
  • Never fight with Blender

Animation

In the script there's a reference to a global progress variable which ranges from 0.0 to 1.0. Scripts use this to create change between frames. There's no explicit frame number like ShaderToy has with iFrame as I found that made it too easy to inadvertently tie an animation to a fixed frame rate.

Top-down distortion


Those may look like Daleks, but they become the Electrolyser machine when rendered top-down:


2D games that render top-down usually choose a projection that shows some of the elevation of an object, without introducing too much distortion. An example is Factorio's top-down oblique view. For Spinward I'm trying a different route and rendering a direct top-down orthographic projection.

This seems interesting to me because it removes distortion and greatly simplifies rendering and layering of sprites. Of course, most stuff looks boring and confusing when viewed from directly above, so models generally need to taper inward at the top so that the sides are visible... leading to the electrolysis Daleks.

This might turn out to be a terrible idea, but I don't know that yet.

Wait, backup; Lua AND the GPU?

Lua defines the scene which mostly then runs in C++. Some of that work can be offloaded to the GPU using compute shaders:

  • Ray marching, providing bounces are capped (no recursion, has to be iterative)
  • SDF evaluation works for a low number of levels (too much complexity just stalls workgroups)
  • Shadow rays traced directly from bounce points to fixed lights are super fast

I don't use the GPU in the pipeline when packaging the game because throwing a few more CPU cores at software rendering is simpler. But for rapid edit/render/test cycles using the GPU is a must.

Signed Distance Function hull()

As in convex hull, a tool I find very useful in OpenSCAD. However there it works with meshes and CGAL. Doing the same thing using SDFs turned out be hard. I have not found a method for building convex hulls around arbitrary SDFs outside of some recently published papers which don't really hit the mark. It's possible this is just my limited math skills.

The 3D preview of the Arm better shows the hulls, which are the yellow base and the green column:


At first I tried using marching cubes to convert SDFs to meshes on the fly in order to pass them to CGAL for hulling. That became very slow and also much more complicated because the scene had to deal with both SDFs and meshes.

Therefore the hull() function in this tool cheats. Firstly it only works with a subset of SDFs that can be easily expressed as triangle meshes too: box(), cylinder(), sphere(), prism() etc. That's enough for most of my use cases. When creating a hull around a set of SDFs, they are implicitly mapped to the equivalent meshes, passed to CGAL to generate a convex hull triangle mesh, the result of which is then wrapped in another SDF that uses a distance-to-triangle function to make the mesh behave like an SDF.

Yeah, the code is about as convoluted as that last sentence too. But it works and I have my beloved convex hulls.

My time is my time

It's possible all this is a waste of time and I should have spent it doing more valuable things like learning to love Blender and scripting it with Python. But... no... just no...

[1] Almost like this. The GIF in the browser may not manage 60FPS.

Leave a comment

Log in with itch.io to leave a comment.