Dekiro Dev Blog

Part 12 of 28

HD-2D on a Budget: Rendering 3D Characters into 8-Direction Palette Sprites

Six 3D character drafts were rejected. The seventh idea kept the 3D models and turned them into pixel art: a Blender render pass, a Python post step that thinks in palettes, and a shader that dresses the result at runtime.

Why sprites at all #

The world of Dekiro is 3D: terrain, buildings, trees, an orbit camera. For four days the player was a coloured capsule, and a parallel line of work tried to replace it with a real character built by a Blender script. Six drafts came and went within a single day: flat primitives, a bulky "Valheim hulk", proportions modelled on a popular Korean MMO, then anime-style characters with haori jackets, masks and glowing eyes. Each got closer and none got there. The human's notes on one draft: "the faces are bad, like really bad", "the hair looks also like rubber". Procedural faces at that size don't forgive anything.

Then the human sent a pixel-art sprite as a reference: "This is the style of pixel art sprite I'm looking for for the character. Proportions especially are better there, same for shadings." That suggested the look players know as HD-2D,1 Or older: Ragnarok Online. a 3D world with 2D characters standing in it. Two approaches ran in parallel: drawing the sprites directly as 2D art, and rendering the existing 3D characters into sprites, the way games like Dead Cells turned 3D animation into pixel art. The verdict on the drawn ones was not printable. On the renders: "the rendered 3d to 2d one look way better."

That choice has a big practical advantage for a team without an artist. The 3D characters already had a rig, clips and a whole armor progression. Rendering them means every animation, every armor tier and every hairstyle comes from data, and adding a clip is a re-render, not a week of hand-drawn frames.

One of the 3D character drafts in the game, with toon shading and outlines
The town square with capsules and with the first sprite characters

A style target before a pipeline #

Before rendering anything, the AI wrote a small pixel-art "style target" generator in plain Python: hand placed pixels for a character in five armor tiers, plus the colour rule every later step follows. The rule is a hue-shifted ramp in the OKLCH colour space. A material's colour becomes five steps:

step:      0 deep   1 shadow  2 base  3 light  4 highlight
dL:       -0.25    -0.125     0      +0.095   +0.175     (OKLab lightness)
chroma x:  0.80     0.95      1.0     0.92     0.72
hue shift: 22 deg   11 deg    0       8 deg    15 deg
           toward COOL (h=265, blue) for 0/1, toward WARM (h=75, gold) for 3/4

Shadows don't just get darker; they lean blue. Highlights lean gold. Near-grey colours get a little chroma so their shadows still lean cool, dark colours are lifted so dark cloth keeps five distinct steps, and the outline is a dark version of the colour, never black. The same rule exists three times,2 In the Python post step, in GDScript for runtime recolouring, and as a shader include for the world's sprite-style shading. and they're kept in step on purpose: a red roof and a red armor plate land on the same five colours.

The pixel style target: the Blade school's five armor tiers, drawn by code to pin down proportions and the colour rule

Step 1: Blender renders data, not pictures #

tools/blender/render_sprites.py drives Blender headless. The camera is orthographic at the game camera's pitch (28 degrees down), the character is turned to 8 directions, and every second frame of a 24 fps clip is rendered (12 fps, the classic pixel-art rate; idles at 6 fps). Rendering uses EEVEE with one sample and no anti-aliasing, at 4x the final size.

Two tricks happen here. First, a sprite-only proportion pass scales pose bones in the render scene only: head 1.35x, bigger hands and boots, heavier shoulders, shorter legs, about five heads tall. The rig, its bone names and the clips stay untouched. Second, the renders aren't pictures. Each layer gets two passes:

  • albedo: every material as flat emission of its base colour;
  • data: red = the light (a white surface lit by one sun fixed to the camera's upper left, so every direction is lit the same, as drawn sprites are), green = a material id, blue = how much a pixel belongs to a hand.

The post step needs to know what each pixel is, not just its colour.

Step 2: the paper doll #

A character isn't one sprite but a stack of layers that share one skeleton and one set of clips:

  • body: skin, the painted face, bare hands and feet;
  • hair_<style>: each of the 9 hairstyles is its own layer, rendered white so the game can tint it;
  • eyes: two glowing pixels;
  • armor_default (the outfit worn with no armor) and five armor tiers;
  • NPC outfits and hats (more below), and a pre-rendered weapon for far-away characters.

While one layer renders, the others act as holdouts: they cut the layer where they'd be in front. One more flag solves a classic paper-doll problem. Hair that sits more than 3 cm behind the head, as the camera sees it, is marked as back hair. The game draws back hair under the armor and the rest over it, so every hairstyle works with every armor without rendering combinations.

Paper-doll layers in the prototype: body, body + armor, body + armor + weapon

Step 3: post-processing in palettes #

tools/blender/sprite_post.py (numpy, scipy, PIL) turns 4x raw frames into pixel art:

  1. Each final pixel is a 4x4 block of raw pixels. The dominant material wins the block. Glow seams on the armor are thinner than a pixel, so they win a block from just 2 of its 16 samples and survive as 1 px lines.
  2. Per layer and material, a k-means in OKLab finds up to three base colours (an indigo haori and its wave pattern, say), and each base colour gets a ramp by the rule above.
  3. The light value picks the ramp step. Tiny specks are merged, eyes become 2 px glowing dots, hands become clean "mittens" with one outline, and a selective 1 px outline is drawn round the silhouette.
  4. The result is not RGB but 8-bit palette indices. Index 0 is transparent; ramp r owns indices 1+6r to 6+6r: outline, deep, shadow, base, light, highlight. A palette image per layer holds the colours.
The walk cycle in 8 directions (rows) after the post step

Then every frame is trimmed to its opaque box, duplicates are dropped,3 2,836 unique of 3,528 body frames for the male body. and frames are shelf-packed into 2048 px pages. An atlas.json records, per clip, direction and frame, where the pixels are, where the feet are, where each hand is (with depth and roll), and which frame the blow lands on.

Why palettes pay off: recolouring at runtime #

Because every pixel is "ramp 3, step 4" rather than a colour, recolouring is rewriting six palette entries. That turned three expensive features into cheap ones:

  • Hair and eye colours are picked at character creation (12 hair and 10 eye presets, or free colours) and changed later at Mirei the Stylist. The game builds a ramp from the chosen colour with the same rule.
  • Rarity trims. An armor's trim ramp is rewritten with the item's rarity colour: plain for Common, blue for Magic, yellow for Rare, orange-gold for Legendary, crimson for Unique.
  • School dyes. The plan was a different armor cut per school: samurai, monk, ninja, priest. That's four times the rendering. The human cut it to one line: "Just take the same armor but add other colors from the school." Every school now wears the Blade cut, and its dye ramps are rebuilt per school at runtime. Each dye ramp stores its offset from the rendered dye as ratios of lightness and chroma plus a hue shift, so a dark trim stays dark relative to a light dye in every school. The tier 5 glow seams are an emissive ramp in the school colour.
One armor cut, four schools: tiers 1 to 5 for Blade, Iron Fist, Shadowstep and Sanctum, recoloured at runtime
Every hairstyle over every armor: back hair under the armor, front hair over it

Step 4: in the game #

SpriteVisual replaces the capsule node (logic and visuals were separate from day one, so nothing else changed). It is one upright card turned toward the camera. The row is picked from the angle between the actor's facing and the camera, snapped to 45 degrees with a little hysteresis so it doesn't flicker at the boundary. The card is lit by the scene's sun and moon through a fixed normal, so sprites get darker at night and dim under cloud shadows like everything else. All layers composite in one shader. A flat silhouette of the sprite is projected on the ground as its shadow.

Weapons are the one part that stays 3D. Near the camera (within 25 m) the real weapon model is drawn into a small viewport with the same orthographic camera as the render, on the sprite's own pixel grid, and placed on the hand anchor of each frame, in front of or behind the body depending on that frame's hand depth. That's what lets a +9 katana sparkle and every weapon model appear in hand without rendering weapons into the sheets. Far away, a pre-rendered weapon layer takes over.

Animations come from the clip list: idles, walk and run by speed, a three-hit combo per weapon type (sword, twin daggers, a two-handed staff, fists), 13 shared skill motions such as spin, dash and slam, hit, death, and a kneeling "pet" emote. Every attack strikes on frame 4, the same convention the monster models use, so the damage number appears as the blow lands. In total each body has 51 clips. One small change came from playing it: "I feel like the player model can be a bit bigger?" Sprites are now drawn 1.25 times their rendered size. Purely visual: hitboxes didn't change.

Two tier-5 armors at night: the glow seams in the school colour are an emissive palette ramp

NPCs: the same sheets, different clothes #

On day six the townsfolk and NPCs, still capsules, got the same treatment. The human liked the capsules ("Somehow they grew on me") but wanted "NPCs with different sizes, clothing and colours". The answer was more layers, not more characters: seven NPC outfits (farmer, merchant, apron, monk, guard, traveller, noble) and five hats, rendered for the NPC clips only, plus NPC-only clips for talking, sitting and working.

A look is pure data: body, outfit, hat, hairstyle, hair, skin and eye colour, cloth and accent colour, a size and a build (stocky or slender, a stretch of the card). Named NPCs have fixed looks. Townsfolk give an archetype, and the rest is picked by a random number seeded with the person's id from their region's palette, so a person looks the same every time and on every machine, and the desert's people don't look like the Vale's. Grandmasters wear tier 5 armor in their school's dye with glowing seams.

A grandmaster in the Sanctuary: tier 5 armor in the Blade school's dye, rendered from the same sheets as the player

The budget #

Sprites move the cost from geometry to texture memory, so the budget lives in tests:

  • Per character: a dressed character (body, one hairstyle, the heaviest armor, eyes) loading every clip one weapon type uses must stay under 19 MB. It's 18.3 MB for the male body and 16.4 MB for the female. Before the sheets were packed in banks (the shared clips, then each weapon set's own, so a character only loads pages for clips it can play), it was about 22 MB.
  • Per town: every NPC and townsperson of the Vale plus 30 passers-by, 63 people, against 128 MB: they need 92.7 MB. Looks share pages, and the palettes per look are cached.
  • On disk: about 20 MB of PNG per body.

Rendering is the slow part: the full pipeline for both bodies takes about 50 minutes with 14 Blender jobs in parallel. Re-rendering only the armor layers, or only the NPC layers, reuses the other raw frames.

Headless machines (dedicated servers and tests) never load any of it; they keep the capsule.

What didn't work at first #

  • Faces. At about 100 px tall, a face is a few pixels. The final characters wear a mask or scarf, and the eyes are two glowing pixels in the chosen eye colour. It reads better than any modelled face did.
  • Hands. Rendered hands at that size became noise, so the post step redraws them as mittens with one outline (gloves included).
  • Dyes too close to the trim. On some schools the rarity trim almost vanished into the dye; the trim is now lifted when it gets too close.
  • Builds without the atlases. The first exported build drew capsules: the atlas .json files weren't in the export filter. CLAUDE.md already warned about data/*.json; the sprites' JSON lived elsewhere.

Takeaways #

  • Render 3D into 2D if you have rigs and no artist. Every clip, armor tier and hairstyle stays data.
  • Render data passes, not pictures. Material ids, light and masks let the post step make decisions an image can't.
  • Think in palettes. Indexed pixels turn hair colours, rarity trims and school dyes into rewriting six entries.
  • One colour rule, implemented everywhere it's needed, keeps sprites and 3D models in the same world.
  • Put the memory budget in a test. It's the number that grows quietly.

What's next #

Characters needed somewhere to fight. Next: a dungeon generator that writes specs as sequence graphs, stitches ASCII rooms into layouts, and refuses to save any dungeon its solver can't finish.