Asset pipeline
Model in Blender -> bake to 2.5D sprite maps -> the client loads
assets/sprites/<name>/. The output format is specified in
sprite-format.md.
tools/blender/models/<name>.py procedural model script (preferred source)
or any .blend file
| uo-baker bake / bake-all (runs Blender headless)
v
tools/blender/bake_sprite.py camera + passes + sheet assembly (inside Blender)
v
assets/sprites/<name>/ albedo, normal, height, height_back, material, emissive .png + sprite.json
1. Modelling
Conventions (the baker relies on them)
- 1 Blender unit = 1 tile. A human is ~1.8 units, a wall 2.12 (48 px), a door ~1.89 (42 px).
- Origin
(0, 0, 0)= ground level at the centre of the footprint. Keep geometry atZ >= 0(anything below is clamped to height 0; the baker warns). - The front faces Blender -Y (game south). For 8-direction sprites the baker rotates the model about Z; asymmetric details (face, backpack, a weapon on one hip) make the directions readable.
- Walls: one piece per tile, flush with the tile's north edge (runs along X)
or west edge (runs along Y); posts/corners at the north-west corner.
Decorated face towards the tile interior. See
models/wallkit.py. - Ground tiles: 1x1, top surface at
Z >= 0, baked as interchangeable variants (variants = 8) that join seamlessly: shared periodic edge band, zero-mean per-variant interior, vertex colours, an open surface sheet with no slab or side walls. Rules and helpers: the "variants" section ofmodels/ground_kit.py; reference:models/ground_grass.py; check withuo-baker tile-check. Where features must cross tile edges (connected water, leaf litter), use edge-matched variants: "edge-matched variants" section ofground_kit.py, referencesground_swamp.py(height-field features) andground_forest.py(objects cut withclip_to_tile). Floors are thin raised items. - Materials: Principled BSDF only. The baker reads Base Color, Metallic,
Roughness, Specular IOR Level, Emission Color and Emission Strength, either as
constants or from the node linked into the input (textures, noise, ramps
work). Normal-map/bump nodes are not baked; model the relief as geometry
(bevels, raised trims) instead. Alpha cut-outs are baked (decision #74):
a linked Principled Alpha (a leaf-card texture's alpha) is clipped at 0.5 in
every pass, so a texel is either the card or what lies behind it. Cards are
thin: their thickness comes from the crown behind them (
height_back). - Material custom properties read by the baker:
uo_debris = "vanish"(parts vanish on breaking,object_kit.vanish) anduo_normals = "keep"(back-facing hits keep the authored shading normal instead of Cycles' flip: double-sided foliage cards with custom normals pointing out of the crown). - Build parts as closed meshes (primitives are) and let attached parts
overlap the body by a few mm instead of touching face to face. The default
back-height bake does not need it, but
--back-mode peeldoes. - Think in texels: a tile is 32 px wide, one texel is about 0.045 units. Anything thinner (trims, legs, straps, nails) vanishes or flickers, so make details chunky (>= 0.05) and keep silhouettes bold; colour contrast reads better than fine geometry at this size.
- Smooth-shade curved parts (
poly.use_smooth = True); add a Bevel modifier to hard edges so the normal map catches highlights. Modifiers are applied at bake. - Texel lattice (every object model, decision #60): put every vertex on
the lattice (
tools/blender/models/pixel_kit.py): X and Y in whole texels (1/16 tile; or all of them offset by half a texel for odd widths), Z on levels, level n =(n + 1/2) / (16 sqrt 2)(one screen pixel of height). The baker puts the origin on a texel corner, so lattice vertices project to (integer, integer + 1/2) texels: vertical edges lie on texel boundaries and edges along X/Y draw as even 1:1 staircases that cut texels 1/8 : 7/8, never through the centre where the 50 % coverage threshold flips a coin. The lattice survives the 90 deg facing turns, the_efootprint rotation and 90 deg turns about any lattice point (open doors). What else follows:- Only vertical edges and 1:1 screen lines are fully clean. Lines that draw flat on screen (on a south face a step of a texels east and a levels up; an outline along Blender (1, 1) in plan) land on half-pixel rows and split texels 50/50; 2:1 slopes split 25/75. Slant parts at 2 levels per texel rising towards the front (south or east), lay long items along X or Y, and avoid arbitrary rotations; Blender (1, -1) plan outlines draw as vertical lines.
- Details are whole texels/levels; no sub-texel bevels or few-mm overlaps; never leave visible faces of different materials coplanar. A detail recessed by one texel under something in front of it is hidden at this camera: make joints and glass flush and let colour carry them.
- Round parts (
px_lathe,px_post,px_ball) take radii in screen pixels; small knobs, rivets, candles and bottles read better as 1x1 / 2x2 box stacks. Organic shapes: model freely, thensnap_mesh(or one-texel voxels for thin plants);free()exempts a part from the check. - Every script ends with
check_lattice(), which must printOK. - Shared architecture numbers (
pixel_kitconstants; the client's placement constants match them in whole pixels): storey 48 levels, walls 2 texels thick on the tile's north/west edge (wall face at Y = 6, things against walls at Y <= 5), doorway u -6..6 x levels 0..42, window u -4..4 x levels 20..38, floor top level 1, stair tiles rise 12 levels, table / counter / nightstand tops at levels 17 / 22 / 18, roof courses rise 32 levels per tile. Before/after numbers: Pixel-crisp assets below.
- Helper objects that must not render (boolean cutters, references): set
hide_render = True. - Keep sizes sensible: sprite size follows the projected bounds of the model over all baked directions.
With the Blender MCP server
The community blender-mcp server
lets an agent drive a running Blender:
- In Blender (4.2+): Edit > Preferences > Add-ons > Install from Disk, pick
addon.pyfrom the blender-mcp repo, enable "Interface: Blender MCP". - In the 3D viewport sidebar (N) open the BlenderMCP tab and click Connect to MCP server (listens on localhost:9876).
- Register the server with the agent, e.g. Claude Code:
claude mcp add blender -- uvx blender-mcp(or{"mcpServers": {"blender": {"command": "uvx", "args": ["blender-mcp"]}}}).
Agent workflow:
- Start from an empty scene (delete the default cube/light/camera).
- Build with
execute_blender_code, using primitives + Principled materials, in the same style astools/blender/models/*.py(they are the reference examples:crate.pyfor boxes,character.pyfor a humanoid,wallkit.pyfor booleans/bevels/emission,ground_kit.pyfor linked Base Color). - Check the result with
get_viewport_screenshot/get_scene_info. - Persist the model as code: save the final script as
tools/blender/models/<name>.py(it must build the model into an empty scene with no other input). That keeps assets reproducible and diffable. Saving a.blend(bpy.ops.wm.save_as_mainfile(filepath=...)) also works.
2. Baking
Blender 4.2+ is required (tested with 4.2.2 and 5.2.2). uo-baker finds it via --blender, $BLENDER,
/Applications/Blender.app/Contents/MacOS/Blender, then PATH.
# one asset from a model script (static, facing south)
cargo run -p uo-baker -- bake --model-script tools/blender/models/crate.py --name crate --preview
# 8 facing directions
cargo run -p uo-baker -- bake --model-script tools/blender/models/character.py --name character --directions 8
# 8 interchangeable variants (model script runs with UO_VARIANT=0..7)
cargo run -p uo-baker -- bake --model-script tools/blender/models/ground_grass.py --name ground_grass --variants 8 --padding 0
# edge-matched variants (16 edge combinations x 4 interiors = 64 frames)
cargo run -p uo-baker -- bake --model-script tools/blender/models/ground_swamp.py --name ground_swamp --wang-p 0.15 --wang-per-combo 4 --padding 0
# ground tiles: do all variants agree on the tile edge? + 8x8 random-mix mosaic.png
cargo run -p uo-baker -- tile-check assets/sprites/ground_grass --mosaic 8
# 4 facings (frames N, E, S, W) and a model-script parameter (env UO_PARAM_PIECE)
cargo run -p uo-baker -- bake --model-script tools/blender/models/walls/wallset_stone.py --name wall_stone_x --param piece=x
# from a .blend, selecting what to bake
cargo run -p uo-baker -- bake --blend art/house.blend --collection Door --name door_oak
# everything in assets/manifest.toml -> assets/sprites/<name>/
cargo run -p uo-baker -- bake-all --preview
cargo run -p uo-baker -- bake-all --only tree --only crate
# metadata, coverage stats, validation, optional contact sheet
cargo run -p uo-baker -- inspect assets/sprites/tree --preview
Useful flags: --padding <px> (default 2; tiles use 0 so the diamond is exactly
32 px wide), --supersample <n> (default 4: render scale per output texel
before the pixel-art downsample), --samples <n> (default 4 Cycles samples
per supersampled pixel), --footprint W,H, --object <name> (repeatable),
--back-mode opposite|peel (how height_back.png is made, see
sprite-format.md; default opposite), --resolve mean|dominant (texel
reduction, see Pixel-crisp assets below; default mean), -v (full Blender log). uo-baker passes iso::TILE_W to the script
(--tile-px), so the bake scale always follows iso.rs.
After baking, uo-baker validates the output: all maps present and the same
size (frame_w * frames x frame_h), height.png/height_back.png 16-bit,
height_back <= height on covered pixels, the same alpha in
every map, sprite.json parses and matches iso.rs, anchor inside the
frame, every frame non-empty. A failed validation is an error.
preview.png rows: albedo (magenta cross = anchor), normal, height, back
height, thickness (front - back, black -> red -> yellow -> white, normalised to
the sprite's max), material, emissive, and a lit composite (sun from screen-left using normals + material,
a warm point light south-east of the origin placed using the
height-reconstructed world position, plus emission; the grey diamond is the
origin tile). Use it to eyeball that top faces are blue, east faces red, south
faces green and that height grows upwards.
Pixel-crisp assets
--resolve mean|dominant (uo-baker flag, manifest key resolve, or
bake_sprite.py argument) chooses how a texel's colour, normal
and material are reduced from its subsamples: mean averages the covered
ones, dominant (the default since decision #60; ground tiles keep mean,
their seamless edges were tuned with it) takes the medoid subsample, i.e. the surface that
covers most of the texel, so no texel is a blend of two faces or parts. Every
frame's log line reports two crispness stats: mixed texels (at least a
quarter of the covered subsamples belong to another surface) and
near-threshold edge texels (silhouette texels with 25..75 % coverage, whose
step a sub-texel shift would flip). Both describe the geometry, so they are the
same for either --resolve; dominant keeps mixed texels from blending, the
texel lattice keeps them from occurring. dominant alone leaves uneven edge
steps, the lattice alone leaves faint 1/8 blends along X/Y edges; together they
give the crispest result. uo-baker sums the stats up per asset after each
bake (crispness <name>: mixed texels mean … max …, near-threshold edge texels mean … max …), and bake-all ends with a summary of all baked assets, least
crisp first, so a regression shows up in the bake log. Experiment (2026-10-15,
frames N/E/S/W):
| sprite | mixed texels | near-threshold edges |
|---|---|---|
crate, as modelled | 21 / 24 / 32 / 23 % | 53–56 % |
crate, texel lattice + dominant | 3 / 7 / 3 / 8 % | 0 % |
bookcase_full, as modelled | 10 / 40 / 42 / 11 % | 50–52 % |
bookcase_full, texel lattice + dominant | 0 / 6 / 6 / 0 % | 0 % |
Catalog rework (decision #60; group means, old models baked with dominant → lattice models):
| group | sprites | mixed texels | near-threshold edges |
|---|---|---|---|
walls (+ legacy wall_*) | 90 | 44 → 8 % | 69 → 2.5 % |
| roofs, stairs, floors | 57 | 39 → 10 % | 56 → 1.5 % |
| doors, gates, fences | 60 | 49 → 9 % | 61 → 4 % |
| furniture | 41 | 36 → 12 % | 49 → 9 % |
| items (gear / goods) | 68 | 76 / 64 → 21 % | 59 / 50 → 12 / 4 % |
| lights & crafting / misc | 69 | 58 / 52 → 19 / 27 % | 59 / 54 → 14 / 19 % |
| decor / civic & dungeon | 72 | 56 / 43 → 30 / 19 % | 57 / 54 → 20 / 21 % |
| nature (+ tree, ore_vein) | 39 | 37 → 25 % | 58 → 38 % |
Box-built objects reach the crate's numbers; what stays soft is round (lathes: barrels, vases, buoys) or organic (tree crowns, cave rock), and camera-facing pieces in the plan-diagonal plane (moongates, harp, flat armour) whose horizontal edges sit on half-pixel rows by construction.
The bake script can also be run directly:
Blender --background --factory-startup --python tools/blender/bake_sprite.py -- \
--model-script tools/blender/models/tree.py --name tree --out-dir assets/sprites/tree
3. Adding an asset to the manifest
assets/manifest.toml (paths relative to the manifest):
[[asset]]
name = "lamp_post" # output: assets/sprites/lamp_post/
model_script = "../tools/blender/models/lamp_post.py" # or: blend = "../art/lamp.blend"
directions = 1 # 1, 4 (N, E, S, W) or 8
# params = { piece = "window_x" } # env UO_PARAM_PIECE for the script
# variants = 8 # N interchangeable static versions
# wang_p = 0.15, wang_per_combo = 4 # edge-matched variants (see sprite-format.md)
# resolve = "dominant" # crisp texel reduction (default: --resolve, mean)
# optional: objects = ["Lamp"], collection = "Lamp", padding = 2, samples = 4, footprint = [1, 1]
Then cargo run -p uo-baker -- bake-all --only lamp_post --preview.
Groups of assets live in their own files assets/manifest.d/<group>.toml
(same format, paths relative to that file); bake-all reads them all.
Object groups and their conventions: uo-objects.md. A JSON
manifest ({"assets": [...]}, same keys) also works via --manifest.
Troubleshooting
- "Blender not found" — pass
--blender /path/to/Blenderor setBLENDER. - Bake fails with a Python traceback — run with
-v; model scripts run in an empty factory-startup scene, so they must not rely on the default cube or onbpy.context.selected_objectsfrom a previous step. - "nothing to bake" — no visible geometry objects, or
--object/--collectionnames don't match. - Sprite looks offset from its tile — the model origin is not at the
footprint centre or the model is below
Z = 0. The anchor is computed from the origin, so move the geometry, not the anchor. - Flat or wrong normals — enable smooth shading on curved parts; flipped faces render with inverted normals (recalculate normals in Blender).
- Texture detail missing from albedo — the texture must feed Principled
Base Color(directly or through other nodes). Image textures need a path Blender can resolve when baking. - "N subsamples still inside a solid after 16 layers" (
--back-mode peelonly) — usually coincident faces between overlapping parts (two walls sharing a cap, a trim flush with a face). Those pixels fall back to the deepest back face seen. A handful of edge subsamples is normal (they mix several surfaces). - "back height above front height" — a few edge texels are expected and clamped; many indicate flipped normals on an open mesh.
- "camera projection mismatch" — the projection in
bake_sprite.pyno longer matchesiso.rs(onlyTILE_Wis passed in; the oblique shape and camera rotation are hardcoded in the script's--- iso.rs ---block). - Emission looks clipped or too dark — emissive.png is normalised by
emissive_scale(max constant Emission Strength). Strengths driven by linked nodes are measured from the render instead. - Ground tile edges are binary-alpha staircases. Texels on the diamond boundary are ~50% covered and resolve to opaque, so neighbouring tiles overlap by up to one texel along shared edges (no gaps, no blended seams; depth sorting decides which one shows).