Skip to content
Felucca Online

Docs

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 at Z >= 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 of models/ground_kit.py; reference: models/ground_grass.py; check with uo-baker tile-check. Where features must cross tile edges (connected water, leaf litter), use edge-matched variants: "edge-matched variants" section of ground_kit.py, references ground_swamp.py (height-field features) and ground_forest.py (objects cut with clip_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) and uo_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 peel does.
  • 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 _e footprint 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, then snap_mesh (or one-texel voxels for thin plants); free() exempts a part from the check.
    • Every script ends with check_lattice(), which must print OK.
    • Shared architecture numbers (pixel_kit constants; 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:

  1. In Blender (4.2+): Edit > Preferences > Add-ons > Install from Disk, pick addon.py from the blender-mcp repo, enable "Interface: Blender MCP".
  2. In the 3D viewport sidebar (N) open the BlenderMCP tab and click Connect to MCP server (listens on localhost:9876).
  3. 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 as tools/blender/models/*.py (they are the reference examples: crate.py for boxes, character.py for a humanoid, wallkit.py for booleans/bevels/emission, ground_kit.py for 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):

spritemixed texelsnear-threshold edges
crate, as modelled21 / 24 / 32 / 23 %53–56 %
crate, texel lattice + dominant3 / 7 / 3 / 8 %0 %
bookcase_full, as modelled10 / 40 / 42 / 11 %50–52 %
bookcase_full, texel lattice + dominant0 / 6 / 6 / 0 %0 %

Catalog rework (decision #60; group means, old models baked with dominant → lattice models):

groupspritesmixed texelsnear-threshold edges
walls (+ legacy wall_*)9044 → 8 %69 → 2.5 %
roofs, stairs, floors5739 → 10 %56 → 1.5 %
doors, gates, fences6049 → 9 %61 → 4 %
furniture4136 → 12 %49 → 9 %
items (gear / goods)6876 / 64 → 21 %59 / 50 → 12 / 4 %
lights & crafting / misc6958 / 52 → 19 / 27 %59 / 54 → 14 / 19 %
decor / civic & dungeon7256 / 43 → 30 / 19 %57 / 54 → 20 / 21 %
nature (+ tree, ore_vein)3937 → 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/Blender or set BLENDER.
  • 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 on bpy.context.selected_objects from a previous step.
  • "nothing to bake" — no visible geometry objects, or --object/--collection names 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 peel only) — 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.py no longer matches iso.rs (only TILE_W is 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).