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:

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

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

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

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]

Second pass (owner: "fais tous les points faibles"):

Third pass (owner: "go pour la troisième passe"):

Fourth pass (owner: "fait une quatrième passe"):