Seated-player art pipeline (Dead Cells method, exploration spike)
Prototype of "3D model rendered down to pixel art" for the seated table players
(waist up, arms on the table edge). Everything here is re-runnable from the command
line; the .blend is generated, so edit the scripts, not the file.
Style target: strong silhouettes, flat cel shading with 2-3 hard light bands, a shared palette of ~16-24 colours, no outline, no anti-aliasing.
Running the scripts
Inside Blender (5.2 LTS):
blender --background --python tools/art_pipeline/build_seated.py -- --preview /tmp/seated_preview
Or with the bpy wheel (same Blender, as a Python module; pip install bpy==5.2.2
needs Python 3.13 and ~1 GB). The -- separator is then optional. On a machine
without a GPU, Mesa software GL renders EEVEE and Workbench:
LIBGL_ALWAYS_SOFTWARE=1 python tools/art_pipeline/build_seated.py --preview /tmp/seated_preview
Step 1: block-out (build_seated.py)
Writes art/characters/seated_player.blend:
- Origin at the centre of the hips, z = 0; the table top is at z = 0.30; 1 unit = 1 m.
The character faces the camera (-Y). +X is screen right, +Z is up (same axes as
art/tools/render_spritesheet.py). - Collection
Character: armatureRig(spine, chest, neck, head, upper_arm / forearm / hand .L/.R) and five mesh objects, one per costume layer, every vertex weighted 1.0 to a single bone (rigid parts; enough at 96 px):hair,body(skin, plus aneyesmaterial slot),tunic,beard,helmet. That list, back to front, is the layer order the game stacks (PIECES). - Collection
Reference:Table, never rendered (hide_render), only there to see the framing. - Action
idle: 6 frames at 6 fps, cyclic: the chest rises 1 cm and leans back 2.5 degrees, the shoulders follow, the head turns 5 degrees then nods 3. --preview DIRwrites Workbench previews:front.png,three_quarter.pngandidle_strip.png(the six frames side by side).
Everything is primitives from bmesh (cones, spheres, one bisect for the hair and
helmet caps), flat shaded. To change a piece edit its build_* function; to add a
piece add a name to PIECES and a builder to BUILDERS.
Owner's note (2026-10-18): the idle amplitude is deliberately subtle (about 1 px of breathing at 96 px). Kept as is for now; revisit once the sprites are in the lobby scene and decide whether it needs to be more visible.
Step 2: toon materials (toon_materials.py)
blender --background --python tools/art_pipeline/toon_materials.py -- --render /tmp/toon_test.png
Rewrites every material of the Character meshes in the .blend as
white Diffuse BSDF -> Shader to RGB -> ColorRamp (Constant, 3 stops) -> Emission -> Output
The diffuse is white, so the ramp's input is the pure lit intensity (0 facing away,
1 facing the sun) and the thresholds (BAND_SHADOW_TO_BASE = 0.45, BAND_BASE_TO_LIGHT
= 0.86) mean the same thing for every piece. The three stops come from the piece's base
colour: shadow = base darkened and pulled toward the world indigo (ADD section 3), base,
light = base lifted toward white. The eyes material is one flat emissive cyan (ADD
section 7, "glowing visors"), no bands.
The stops of every ramp together are the sprite palette (16 colours for the five pieces
plus the eyes), written to art/characters/seated_player_palette.gpl; step 3 snaps the
pixels to it. To use the world palette instead, point step 3 at
art/palette/cyber_fantasy.gpl (branch feature/lantern-row).
Light rig: one hard sun from the upper screen-left (SUN_DIRECTION), black world. Cast
shadows are off (CAST_SHADOWS): EEVEE's shadow maps are filtered and jittered, which
at one sample puts noise on the terminator and makes it shimmer between frames; with
them off the bands come from the surface angle alone and are identical frame to frame.
--cast-shadows turns them back on to compare.
Render settings stored in the file: EEVEE, 1 sample, filter size 0, transparent film,
Standard view transform, so a PNG pixel is exactly a ramp colour. --render PATH writes
a 1024 px test render from the orthographic camera at --yaw degrees (default 20: a
slight three-quarter view, which reads the volumes better than straight on).
Colours are sRGB everywhere in the scripts (what you see on screen) and converted to linear when written into nodes, so a rendered pixel is exactly the palette entry.
Step 3: pixel render per layer (render_layers.py)
blender --background --python tools/art_pipeline/render_layers.py -- --out art/renders/seated_player
Options: --frame 80x96 (pixels), --ppu 92 (pixels per metre; the hips sit 2 px above
the bottom edge, centred), --yaw 20 (camera azimuth), --palette <.gpl> (default
art/characters/seated_player_palette.gpl; art/palette/cyber_fantasy.gpl from the
feature/lantern-row branch is the other candidate), --actions idle,
--flip-normal-y, --keep-frames.
For every action and every layer (the five costume objects, in PIECES order):
- Colour pass: the step-2 toon materials, EEVEE, 1 sample, filter 0, transparent; the pixels are then snapped to the nearest palette colour and alpha is made hard.
- Normal pass: every material swapped for a camera-space normal emission
(red = screen right, green = screen up, blue = toward the camera,
n * 0.5 + 0.5), Raw view transform; same encoding asart/tools/render_spritesheet.py. - Holdout rule: a layer is rendered with the layers behind it in the stacking
order set to holdout (they punch it out where they are closer to the camera) and
the layers in front hidden. Drawn in that order in Godot the stack has the 3D
occlusion, and removing or swapping a front piece (helmet off) leaves the layers
under it whole. The body is first and is the only layer never cut. The order is the
custom property
layer_orderon each object, written by step 1.
Outputs in art/renders/seated_player/: seated_player_<layer>_color.png and
_normal.png (one row per action, one column per frame) and seated_player.json
(kind: layered_sprite, frame size, ppu, origin = the hip pixel, camera_yaw,
palette, layers back to front with their two sheets, actions with row / frames /
fps / loop). Renders are generated: do not hand-edit.
Checkpoint result (2026-10-18): 5 layers x 6 frames at 80x96, each layer 2-4 colours, every opaque pixel in the 16-colour palette, alpha 0/255 only. About 2 min for the 60 renders on software GL (EEVEE recompiles its shaders per render).
Step 4: Godot (game/scripts/layered_sprite.gd, game/scenes/seated_table_test.tscn)
Bring the sheets in (the sync tool now understands kind: layered_sprite), then import:
godot --headless --path game --script res://tools/sync_sprite_sheets.gd -- seated_player
godot --headless --path game --import
LayeredSprite(Node2D) reads the atlas and builds oneAnimatedSprite2Dchild per layer, back to front, each frame anAtlasTextureover the layer'sCanvasTexture(colour + normal sheet, filter Nearest), soLight2Dnodes shade every layer. The node's position is the atlas origin (the hips).set_piece_visible(name, bool)andset_piece_modulate(name, color)swap costume parts by name.seated_table_test.tscn: three players behind the table sprite (tools/art_pipeline/make_table.pydraws it), dark room, one warmPointLight2Dcandle that follows the mouse. Keys: H helmet, B beard, T tunic tint, N normal maps on/off.-- --screenshot PATHparks the candle and saves a PNG after a second (what CI or a headless box can run under Xvfb with--rendering-driver opengl3).game/tools/check_sprite_sheets.gd(CI) checks layered atlases too: every layer builds, has every action with the right frame count, a normal sheet, and the same sheet size.
What the scene shows (2026-10-18): the left player has the helmet off and the hair is whole under it; the right one has no beard and a tinted tunic; the candle lights the side of each face and tunic that turns toward it, thanks to the normal sheets, and the layers stay aligned at zoom 4 with no seams.
Re-running everything
blender --background --python tools/art_pipeline/build_seated.py
blender --background --python tools/art_pipeline/toon_materials.py
blender --background --python tools/art_pipeline/render_layers.py
python tools/art_pipeline/make_table.py
godot --headless --path game --script res://tools/sync_sprite_sheets.gd -- seated_player
godot --headless --path game --import
Frost Court: a stage kit on the same pipeline (POC to compare with Lantern Row)
The owner's reference is a snowbound ruin with neon tech. The stage's tiles and props
are built in Blender and rendered down to pixels with the same toon ramps, on the
world palette art/palette/cyber_fantasy.gpl (wiki/art/art-direction.md), so it can be
compared side by side with Lantern Row's ComfyUI-painted rooms.
blender --background --python tools/art_pipeline/make_frost_kit.py # art/environments/frost_court.blend
blender --background --python tools/art_pipeline/render_kit.py # art/renders/frost_court_tiles, _props
python tools/art_pipeline/preview_kit.py --out /tmp/frost_preview.png # mock room + atlases, no game needed
make_frost_kit.pyreuses the masonry builders ofart/tools/make_tileset_kit.py(same tile layout: 16 walls by closed sides, 4 planks, 4 back walls, sosync_tilesets.gdandlevel.gd's autotile need no change) with plain bevelled blocks (no chip displacement: pixel noise at one sample). Walls: slate masonry, a continuous snow cap on an open top, quoins on open sides, a dark lintel under a ledge. Planks: iron grating, snow dusting, a cyan edge light. Back walls: dark indigo stones, cracked, a cyan screen, a pipe with a magenta status light. Every material is three palette entries (materials()).- Props are collections
prop_<name>(prop_w / prop_h in tiles, feet at the bottom centre): lantern_post, screen, crate, statue, dead_tree, cables, snow_pile, icicles. A prop may carry alighthint (colour, energy, position) for the game. render_kit.pyrenders every tile at 32 px and every prop at its tile size (front view, one EEVEE sample, palette snap, hard alpha, camera-space normals) into a tileset atlas (kind: tileset) and a props atlas (kind: props, regions, feet origins, light hints in pixels).
Kit checkpoint (2026-10-18): 24 tiles, 8 props, 13 + 16 palette colours, about 3 min to render on software GL.
Frost Court: the stage (rooms as dioramas, five parallax layers)
The owner's brief after the kit checkpoint: no generic brick walls, rooms with an
identity, and Lantern Row's layer stack (sky and moon; distant spires; the facades
across the street; the room painting behind the action; the playable walls and
platforms; a dark foreground with fog and snow). So the tileset is used for collision
only (paint_hides_tiles = true) and everything seen is rendered from Blender:
python game/levels/make_frost_court.py # the stage JSON (10 rooms, reachability-checked)
blender --background --python tools/art_pipeline/make_frost_rooms.py # one painting + normal sheet per room, lights.json
blender --background --python tools/art_pipeline/make_frost_strips.py # far.png, mid.png, foreground.png
python tools/art_pipeline/make_frost_sky.py # sky.png (moon, stars), fog.png
godot --headless --path game --script res://tools/sync_tilesets.gd -- frost_court_tiles
godot --headless --path game --import
godot --path game -- --host --stage frost_court [--screenshot /some/dir/frost]
game/levels/make_frost_court.py: same generator as Lantern Row (20x12 cells, side and hatch openings, the jump-aware reachability walk). Each room carries athemeand adressinglist (kit prop, tile) that the room painter reads.make_frost_rooms.pybuilds every room as a diorama in one .blend (art/environments/frost_court_rooms.blend, rooms 50 units apart): the solid cells of the map merged into rock masses (bevelled slabs with stone plates, cracks and course lines; a continuous snow cap outdoors and in the cellar, a light rim indoors; icicles under ledges; quoin plates on open sides), the plank runs as iron gratings with a cyan edge, the back wall from the theme (a flat dark surface with a dado, cornice, pilasters, sparse plates and cracks, then arches, windows, pipes, a brazier, a generator, a gate...), and the dressing props frommake_frost_kit.py. Each room renders at 32 px per tile togame/assets/paint/frost_court/<id>.pngplus<id>_normal.png; every glowing thing is listed inlights.json. Outdoor rooms leave their sky rows transparent.make_frost_strips.py(16 px per unit, shown x2): far spires and a cathedral in hazy indigo, facades with lit windows, balconies, snow and neon signs, and the black foreground (railings, drifts, lamp posts, an arch pillar, cables).make_frost_sky.pydraws the banded sky with the moon and the fog bands in Pillow.level.gd(incremental):_add_strip()for sky / far / mid / fog / foreground with their own scroll factors, paintings shown through aCanvasTexturewhen a normal sheet exists,lights.jsonturned intoPointLight2Ds, andweather = "snow"(CPUParticles2Dover the stage).frost_court.tscnsets these;Main.STAGEShasfrost_court.
Second pass (owner: "fais tous les points faibles"):
- The strips (sky, far, mid, fog) no longer scroll vertically (the scene sets
sky_scrollto (0.15, 1) and the sky's horizon glow is drawn at the outdoor rooms' height) (far_scrolletc. keep y = 1.0): they are as tall as the stage, so their bottom edge sits on the stage floor at every zoom instead of drifting under it. - The cellar's ice ramps moved two palette steps darker (
ice,ice_light) with a few bright crystals (ice_bright) as accents, so it sits with its neighbours. - New back-wall pieces (
sconce,banner,shield,bunk,chain,stairs,gauge,hanging_lantern,altar) dress the themes: bunks, shields, banners, cold sconces and a door arch in the guardroom; a zigzag stair, a winch with its chain, slits with blown-in snow in the tower; hanging lanterns and banners in the gallery; an altar with violet flames in the chapel; gauges, warning stripes and a cable tray in the generator hall; crystals and a chained cage in the cellar; banners and sconces in the court and at the gate. - Every room's
dressinglist inmake_frost_court.pygrew (crates stacked, cables, icicles, statues, snow piles): about twice as many props per room.
Third pass (owner: "go pour la troisième passe"):
- Outdoor rooms got their own pieces (
fountain,pennant_pole,broken_pillar,barrels,cold_brazier,gargoyle,bench,cart): a frozen fountain, a cart, barrels, broken pillars, pennants and gargoyles on the buttresses in the court; a ballista, shields on the merlons, a violet watch brazier and pennants on the rampart; a great hooded statue with a glowing staff, benches, braziers, lanterns on the beam and a moon dial on the terrace; braziers, gargoyles, chains and a cable with a lamp between the gate towers. - The foreground strip has five bottom variants (post railing, spiked railing, lattice grille, drift, rubble with a column stump), two lamp-post shapes, three pillar kinds (a thin full arch, a broken arch, a gargoyle perch) and hanging signs under the cables.
Fourth pass (owner: "fait une quatrième passe"):
- Godot: painting lights marked
"flicker": trueinlights.json(braziers, sconces, hanging lanterns, the altar, the arc coil, the candelabra) breathe with two sines and a per-light phase inLevel._process; the snow's direction and sideways gravity follow a slow wind, so the flakes gust. - New interior pieces (
console,valve_wheel,ladder,catwalk,coil,cable_spool,cage,pew): a control console with dead and live screens and levers, an arc coil with a violet discharge, valve wheels, a ladder to a lit catwalk, cable spools and frozen exhaust puffs in the generator hall; hanging cages, icicles under every arch keystone, blown-in drifts, cable spools, a bench, a gargoyle and cable drops to junction boxes in the cable gallery; pews, two candelabra with violet flames and icicles under the rose window in the chapel.