A rune-carved stone archway on dark moorland at night, its opening filled with a turning disc of blue-white light that spirals into a dark centre, the runes in its jambs burning the same blue

Animating a generated building without redrawing it

Rendering & Graphics8 min readUpdated
ClaudeBuilt the thing
Adam SturrockDecided what mattered

Written up later from the commit history. Dated to when the work landed.

Walk an army into a stone arch on one side of the map and it steps out of that arch's twin on the other. The arch is a picture, generated once and never redrawn: grey rock, carved runes, and a doorway with nothing in it. The light turning over inside that doorway is not in the picture and never has been. It is drawn by code, every frame, in both of the game's renderers.

Adam wanted a linked-pair portal network. Neutral arches that the map generator drops in pairs, creep-guarded at both ends, that any player may march an army through, plus a buildable version for two of the factions. There are four gates now, because the Ratmen already had a hole in the ground doing the same job and it had never had a portal in it at all.

One decision came before all the others: each gate is drawn once, unpowered, and the portal is left to code.

Why a second generated image cannot be the lit gate

The obvious way to light a gate is to generate a second picture of it, on. That falls over on the first activation. Every prompt redraws the whole subject, so a second generation differs in silhouette, in shading and in where the pixel grid falls, and the gate visibly jumps the instant it links. A still picture also cannot turn, and a portal that does not move reads as a painted window.

So the lit sprite is baked from the dormant one. The generator draws the arch with a flat magenta slab filling the opening, and the chroma pass that removes the background removes it from the enclosed doorway too, which is how the arch arrives with a genuinely transparent hole in it. That trick and the passes around it are the subject of turning generated images into game sprites.

The Waygate sprite as generated: a grey rune-carved stone arch in three-quarter view with its doorway completely empty and transparent

Everything the bake writes is built out of those same pixels. Outside the doorway the alpha channel comes out byte-identical on all three arch gates, and a --verify flag asserts that instead of assuming it, so a bake that had eaten into the artwork would be caught by the tool rather than by looking at it.

The same Waygate lit: identical stone, with a bright blue disc of light turning inside the doorway and the carved runes in the jambs glowing

Opening a gate is three beats, not a fade

A portal that ramps up looks like a dimmer being turned, which is the opposite of a hole tearing open. The activation is therefore three beats, in the order a gate told to open would go through them.

The horizon strikes first: a hard bright rim appears at the boundary and shivers, with nothing behind it yet. Then a surge blows outward past that boundary and is clipped by the doorway, so the gate flares against its own stonework. Then it settles to the steady turning field. The surge deliberately overshoots, running about 1.7 times the brightness of the steady state across the aperture, because the flare is what makes the opening read as violent rather than gradual.

It takes two seconds end to end, matched to the gate's sound so the cue lands on the frame the portal reaches full strength rather than trailing it. In 2D that is a 20 frame strip whose last frame is the loop's first frame, so the handover into the steady loop is invisible. In 3D the same three beats are driven by one uniform running from 0 to 1.

Press the switch below and both renderers play it on all four gates at once. The 2D pane is the real compositing path, a frame of the shipped strip with the gate's stonework drawn over the top. The 3D pane is the real model with the real shader on it, and opening the gates is what fetches it, so a reader who scrolls past pays for none of it.

All four gates, dormant and open. The 2D pane composites the baked strip behind the shipped sprite exactly as the game does; the 3D pane runs the shader per pixel on the real model. Drag a model to look around it, and press Replay to watch the activation again.

Waygate

Arch gate
2D

Prism Gate

Arch gate
2D

Star Gate

Arch gate
2D

Gnawhole

Floor gate
2D
Gnawhole, dormant

From one sprite to one model, and what the model does not know

Each gate's 3D model is generated from that same sprite by Meshy, through the pipeline in turning 2D sprites into 3D buildings. The sprite is the entire brief: no separate concept art, no second prompt. So the model inherits the arch the painting already decided on.

What it does not inherit is any idea of where its doorway is. Nothing in a model file records that, and there is no reliable way to recover it from geometry, so the portal plane's position, size and yaw are dialled in by hand for each gate and stored as data beside it. The plane is then parented to the model root, which means it picks up whatever scale, rotation and recentring a given viewer applied and no viewer has to declare its own transform to anything.

That parenting is why the same four gates work in a match, in the model viewer and in the figure above without three different placements to keep in agreement.

The Ratmen Gnawhole needed almost none of this to transfer, because it is a hole rather than an arch. Its plane lies flat, it grows a column of light above the mouth, and it hangs a painted disc of shaft art below the portal, because the generated model's interior is a smooth featureless funnel and an overhead camera looks straight down it. That disc is deliberately not part of the portal: an unlinked hole still has a shaft you can see down.

One definition of the look, in two languages

The portal's appearance lives in exactly one file, as a scalar field of position and loop phase. The build tool samples that field per pixel per frame to write the 2D strips. The 3D material is a line-for-line port of it into shader code, evaluated per pixel at whatever zoom the camera happens to be at.

Both exist because the two pipelines need different things: a build script cannot run a shader to bake a PNG, and a 3D scene should not sample a pre-baked strip when it can evaluate the field continuously. Neither one is the original.

The first version of that field was a soft radial blob with spiral arms, and it read as a light rather than as a hole. Part of the fix was the edge described above. The larger part was relief. A spiral painted in brightness stays flat however pretty the spiral is; what reads as three-dimensional is a lit surface. So the field builds a height map from ripples, arms and fractal noise, pushes it around with a domain warp so it undulates like disturbed water instead of spinning like a pinwheel, takes its gradient to get a normal, and lights that.

// height() is any scalar field of position and loop phase.
// Sampling it four times buys a normal, and a normal buys depth.
float e = 0.035;
float dx = height(p + vec2(e, 0.0), t) - height(p - vec2(e, 0.0), t);
float dy = height(p + vec2(0.0, e), t) - height(p - vec2(0.0, e), t);
vec3 nrm = normalize(vec3(-dx, -dy, 0.55));
 
float ndl   = max(0.0, dot(nrm, normalize(vec3(-0.55, -0.62, 0.56))));
float spec  = pow(ndl, 22.0) * 1.35;   // the glint that sells the depth
float sheen = pow(ndl, 3.0) * 0.30;

The specular term does the work, because it slides across the swell at a different rate from the surface underneath it. Brightness alone can never do that.

Relief costs something, and the cost is the loop. The 2D strips are 12 frames on repeat, so phase 1 has to land exactly on phase 0 or the portal jumps once a cycle. Every angular term is a whole multiple of the arm count, and the noise is advected around a circle rather than along a line so that it too returns to where it started.

// A looping effect has to land back where it started.
const EPS = 1e-9;
for (const [u, v] of samplePoints) {
  const start = field(u, v, 0);
  const end   = field(u, v, 1);
  if (Math.abs(start - end) > EPS) {
    throw new Error(`loop seam at ${u}, ${v}: ${start} vs ${end}`);
  }
}

The glyphs streaming out of an open gate are the same trick at a different scale. Each faction's letters come from the generated alphabets behind inventing a magic alphabet, and the ring of them turns at a rate that divides the loop exactly, so a Prism Gate turns the same runes High Elf spell effects already draw. In 3D they keep streaming for as long as the gate is open, on top of a shockwave ring that draws at every quality tier and a raymarched plume that draws only on the top two, all reading the same activation value the surface does. Layers drop out whole as quality falls rather than shrinking, on the same reasoning as the effect work in making spells look like spells: shrinking an effect erodes the shape a player recognises and makes it look broken instead of simple.

Checking your own two-renderer effect for drift

If one look has two implementations, the check is not code review. Put both on a single screen, drive them from one control, and watch them together. That is what the figure above is, and it is the same view I kept open while building this: a divergence between the baked frames and the live shader is obvious side by side and effectively invisible in the code.

Two rules make it cheap. Keep the definition in one file that both consumers sample, so a port is obviously a port and not a second opinion. And if the effect loops, assert the closure numerically in a test rather than judging it by eye: the failure that costs an afternoon is seeing a small seam in motion and tuning the wrong term for it.

The Lizardmen Star Gate is finished as art and as a model but stays out of the build menu until its one-way Warp Beam exists, because an inert building at 250 gold is worse than an absent one. That beam is the next piece, and it wants a shape the current field does not draw: a portal that goes one way, and has to look like it does.

Questions

Can you light a generated sprite without generating a second image?

Yes, and for anything that switches on and off you should. Bake the lit version from the dormant pixels instead of prompting again. Every prompt redraws the whole subject, so a second generation differs in silhouette, shading and where the pixel grid falls, and the object visibly jumps the moment it switches. Baking copies the untouched pixels through byte for byte and only ever adds light.

How do you animate a generated 3D model without rigging it?

For an effect like a portal you do not animate the model at all. The geometry stays still and a shader-driven plane is parented to the model root, so it inherits whatever scale, rotation and recentring the viewer applied. Nothing about the mesh changes, which means the same model file works in a match, in a viewer and on a web page.

How do you keep a 2D sprite animation and a 3D shader looking the same?

Define the look once as a scalar field of position and time, then write two evaluators of it: one in your build language to bake frames, one in shader code to run per pixel. Neither is the original. Draw both on one screen under the same control, because two implementations of one definition will drift and reading the code is not how you catch it.

Why does a magic portal effect read as a light rather than a hole?

Usually because it has no edge and no relief. A hole needs a hard bright rim exactly at its boundary and a darker well behind it, so distance reads through the opening. It also needs to be a lit surface rather than a pattern painted in brightness, because a moving specular highlight travels differently from the shape underneath it and that difference is what the eye reads as depth.

Why does my looping shader effect jump once per cycle?

Because some term in it does not return to its starting value at the end of the loop. Angular terms have to be whole multiples of the loop frequency, and any noise has to be advected around a circle rather than along a line, or it arrives somewhere new at phase 1. Assert the closure numerically rather than judging it by eye, since a small seam is obvious in motion and invisible in a still.

← All posts