Baked sprite format (v3)
Every visual object in the game is a 3D model baked into a 2.5D sprite that carries enough data for modern lighting: colour, surface normal, front and back height, material (metallic / roughness / specular) and emission. The renderer reconstructs the world-space position of every pixel from the screen position and the height map, so lights, shadows and depth sorting work per pixel, not per sprite.
The projection constants live in crates/uo-proto/src/iso.rs. Treat that file
as the source of truth; this document explains it. The baker is
tools/blender/bake_sprite.py, driven by uo-baker (see
asset-pipeline.md).
Coordinate conventions
| Space | Axes | Units |
|---|---|---|
| Game | x east, y south, z up (left-handed, UO convention) | 1 = one tile |
| Blender | X east, Y north, Z up (right-handed) | 1 m = one tile |
| Screen | sx right, sy down | pixels |
Conversion: game = (X, -Y, Z).
Projection
Oblique (shear) projection, like the original UO: the ground plane and vertical planes are drawn 1:1, nothing is foreshortened.
sx = (x - y) * 16
sy = (x + y) * 16 - z * Z_PX Z_PX = PX_PER_UNIT = 32 / sqrt 2 = 22.6274
- A 1x1 tile is a 32x32 px diamond (a square rotated 45°),
TILE_W = TILE_H = 32. - One unit of height is 22.63 px, the same as one tile edge; one texel is about 0.044 units.
- Sprites are pixel art at this texel density; the client upscales the whole frame by an integer zoom (default 4) with nearest filtering.
- All points along the projection direction
(1, 1, sqrt 2)(45° above the ground, from the south-east) land on the same pixel.
Baking uses the exact equivalent: an orthographic camera at 45° elevation
looking north-west whose image is stretched vertically by sqrt 2
(VERTICAL_STRETCH):
- Blender camera rotation
(45°, 0°, 45°),sensor_fit = HORIZONTAL,ortho_scale = frame_w / PX_PER_UNIT. - Render pixel aspect
pixel_aspect_x = sqrt 2,pixel_aspect_y = 1: each pixel samples 1/PX_PER_UNIT world units horizontally and 1/(PX_PER_UNIT * sqrt 2) vertically on the image plane, which is the stretch. - The baker checks the camera against the formula above (including that
(1, 1, sqrt 2)lands on the origin pixel) and aborts if the error exceeds 0.05 px.
Depth (bigger = closer to the camera), the signed distance along the projection direction:
depth_key(x, y, z) = (x + y) / 2 + z / sqrt 2
Pixel-art baking
Every pass is rendered at supersample x the output size (default 4, so 16
subsamples per texel; the anchor scales with it, the camera self-check runs at
the output scale) and then reduced with a pixel-art filter:
- alpha is binary: a texel is opaque (255) when at least half of its subsamples are covered, otherwise 0. No anti-aliased fringes.
- albedo, material and emission average only the covered subsamples (no blending with the background).
- normals average the covered subsamples and are renormalised.
heighttakes the max andheight_backthe min of the covered subsamples, soback <= frontstill holds per texel.
Detail thinner than about one texel (0.045 units) disappears or flickers between directions; model trims, legs and ornaments at least that thick.
Model conventions (Blender)
- The object origin sits on the ground at the centre of its footprint (
Z = 0). - The model's front faces Blender
-Y(game south, towards the camera's lower-left). Unrotated, it is facingDirection::South. - Parts should be closed (manifold) meshes, and overlapping parts should
interpenetrate rather than share coincident faces; open geometry (single
planes) gets
back = front. - Materials use Principled BSDF. The baker reads
Base Color,Metallic,Roughness,Specular IOR Level,Emission ColorandEmission Strength(constant value or whatever node is linked into the input). - 1 Blender unit = 1 tile. A human is ~1.8 units tall.
Files
assets/sprites/<name>/
albedo.png RGBA8, sRGB, straight alpha. Alpha = coverage.
normal.png RGBA8, linear. rgb = game-space normal * 0.5 + 0.5. a = coverage.
height.png 16-bit grayscale, linear. value = z / height_range (z above origin).
height_back.png 16-bit grayscale, linear, same scale as height.png: z where the
pixel's view ray exits the first solid it hit (see below).
material.png RGBA8, linear. r = metallic, g = roughness, b = specular level,
a = coverage. Specular level is Principled "Specular IOR Level":
0.5 = default dielectric (F0 = 0.04).
emissive.png RGBA8, sRGB. rgb = Emission Color * Emission Strength / emissive_scale,
a = coverage. Black when nothing emits.
debris.png optional, 8-bit grayscale: 255 where the texel vanishes when the
object breaks (foliage, flames, embers), 0 where it becomes
debris. Only written when some texel vanishes (`has_debris`).
sprite.json metadata, see below
preview.png optional human-readable contact sheet (not used by the game)
bare/ optional bare twin (`bare`): a whole sprite of its own in this
format, see below
All six images have identical size: frame_w * frames by frame_h, and the
same alpha channel. Frames are laid out left to right. With frames = 8,
frame i shows the model facing Direction::ALL[i] (North, NorthEast, East,
SouthEast, South, SouthWest, West, NorthWest). With frames = 4 (objects with a
front, like UO's per-facing furniture art) the frames are North, East, South,
West: the model is built facing South and turned about its origin. With
frames = 1 the sprite is static (baked facing South). With variants = N > 1 (static sprites only,
frames = N), frame i is variant i: interchangeable versions of the same
object, baked from the model script with UO_VARIANT=i. All variants share
frame_w/h, anchor, height_range and emissive_scale. Ground tiles use
this: any variant joins any other seamlessly, and the renderer picks one per
map cell (deterministically, e.g. from a hash of the cell coordinates).
Normals are the per-pixel shading normals in game world space (after the facing rotation), so the renderer can light them directly: up-facing surfaces are blue, east-facing red, south-facing green.
sprite.json:
{
"version": 3,
"name": "tree",
"frame_w": 48,
"frame_h": 84,
"frames": 1,
"variants": 1,
"anchor": [24, 78],
"height_range": 3.09,
"px_per_unit": 22.6274,
"footprint": [1, 1],
"emissive_scale": 0.0,
"has_back": true
}
-
anchor— point inside a frame where the game-space point(0, 0, 0)(the object's origin) lands, in continuous pixel coordinates ((0, 0)is the top-left corner of the frame, so pixel(i, j)covers[i, i+1) x [j, j+1)). The renderer draws the frame with its top-left atworld_to_screen(entity.x, entity.y, ground_z) - anchor. -
height_range— world units represented by height value1.0. -
footprint— tiles occupied (informational for now). -
emissive_scale— linear emission issrgb_to_linear(emissive.rgb) * emissive_scale. It is the largest Emission Strength in the asset, 0 if nothing emits. -
has_back—height_back.pngis present (always true for v3 bakes). -
has_debris(optional, default false) —debris.pngis present. The baker writes it for models with materials whose custom propertyuo_debrisis"vanish"(object_kit.vanish(): leaves fromnature_kit.tonesand theleaf*palette entries,flame(), embers and hot coals; the leaf, flower and soft-plant materials of the pack-based nature sprites,models/nature/packs.py); a texel vanishes when most of its covered subsamples (or its dominant surface) do. Seedocs/destruction.md. -
bare(optional, default false) — a bare twin lives inbare/: the same model baked again in the same session with every vanishing material (uo_debris = "vanish") see-through (Transparent BSDF in every pass, the opposite camera included), so it shows what stays when those parts are gone: a tree's trunk and branches, including the parts its crown hides in the sprite itself. Sameframe_w/h,frames,variants,anchorandheight_range, its ownsprite.json(name<name>_bare, nobare, no debris); it draws only where the sprite does. The client swaps to it while a tree's crown is hidden (docs/rendering.md› Hidden tree crowns). Manifestbare = true/uo-baker bake --bare/bake_sprite.py --bare;uo-bakervalidates the twin against the sprite. -
variants— number of interchangeable variants (default 1 when absent); when > 1,frames == variants. -
wang(optional) — edge-matched variants (stochastic Wang tiles),{"p": 0.15, "per_combo": 4}; thenvariants == 16 * per_combo. Features may cross tile edges (swamp water, forest leaves), so the variant of a cell must match its edges:- every map edge has a type bit: 1 if
unit_hash(edge) < p. A horizontal edge (between cells(x, y-1)and(x, y)) is keyed(x, y, axis 0), a vertical edge (between(x-1, y)and(x, y))(x, y, axis 1); game north is-y; - cell
(x, y):n = H(x, y),s = H(x, y+1),w = V(x, y),e = V(x+1, y); combo = n | e << 1 | s << 2 | w << 3,variant = combo * per_combo + hash(x, y) % per_combo.
Any hash works as long as both cells of an edge derive the same bit from the same key. Reference:
pick_variantincrates/uo-baker/src/tiles.rs. - every map edge has a type bit: 1 if
Back height
Each pixel is a ray along the projection direction (1, 1, sqrt 2). Because
that direction has a z component, the z of any point on the ray determines the
point completely: (x, y) = screen_to_world(sx, sy, z). So one extra scalar per
pixel says where the object ends behind its visible surface:
height= z where the ray enters the object (the visible front surface).height_back= z where the ray leaves the object (see the two modes below). Walking away from the camera the ray goes down, soback <= frontalways (the baker clamps and reports violations;uo-bakerrejects files that break it).- The thickness along the ray,
front - back, is aboutsqrt 2 * tfor a vertical wall of thicknesstseen through its face, the crown depth for a tree, and roughly the diameter for thin legs or limbs. - Open geometry seen edge-on or single planes give
back = front.
A shadow ray (or any ray) at world point p is inside the sprite's object when
p projects into a covered pixel and back_z <= p.z <= front_z there.
Baking modes (--back-mode)
opposite (default): last exit along the ray. A second orthographic
camera sits on the far side of the object looking back along +(1, 1, sqrt 2)
(from the north-west and below, towards the south-east), with the same ortho
scale, pixel aspect and supersampling, so its pixel rays are exactly the front
camera's lines. It renders plain opaque world-z emission; its first hit is the
farthest exit along each line. The image is mirrored left-right relative to
the front view and is flipped back; a self-check projects test points through
both cameras and requires < 0.05 px error. Where it sees nothing on a pixel the
front view covers, back = front.
- Robust: no dependence on normals, manifoldness or touching parts.
- Fills air gaps between separate parts on the same line (a brazier leg in front of another leg, the space under an overhang): the object is treated as solid from its first entry to its last exit.
peel: exit of the first solid. Depth peeling: pass k makes the first
k surfaces along each ray transparent (Light Path > Transparent Depth) and
emits (z, backfacing, open) of surface k; an inside-counter (front +1,
back -1) finds the back face that leaves the first solid. Overlapping closed
parts count as one solid, closed meshes get their normals recalculated.
- Keeps air gaps, so thin parts in front of others cast thin shadows.
- Fragile: coincident or touching faces, non-manifold meshes and overhangs produce thin shells (trims in front of walls), unresolved pixels and light leaks.
Renderer contract
For a sprite pixel at screen position (sx, sy) (pixel centre, relative to the
world origin) with height sample h:
z_world = ground_z + h * height_range
(x, y) = screen_to_world(sx, sy, z_world)
The G-buffer stores albedo, the normal, the material channels, emission and
this reconstructed world position; the lighting pass shades in world space and
adds emission on top. Depth is depth_key(x, y, z_world).
Changes from v2
- Added
height_back.pngandhas_back(default bake: opposite camera). - Texel density halved: 32x32 tiles (
px_per_unit22.6274), binary alpha from the supersampled pixel-art downsample.
Changes from v1
- Projection switched from 2:1 dimetric (30° camera, 64x32 tiles) to the UO oblique projection above (height 1:1).
- Added
material.png,emissive.pngandemissive_scale.