Skip to content
Felucca Online

Docs

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

SpaceAxesUnits
Gamex east, y south, z up (left-handed, UO convention)1 = one tile
BlenderX east, Y north, Z up (right-handed)1 m = one tile
Screensx right, sy downpixels

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.
  • height takes the max and height_back the min of the covered subsamples, so back <= front still 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 facing Direction::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 Color and Emission 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 at world_to_screen(entity.x, entity.y, ground_z) - anchor.

  • height_range — world units represented by height value 1.0.

  • footprint — tiles occupied (informational for now).

  • emissive_scale — linear emission is srgb_to_linear(emissive.rgb) * emissive_scale. It is the largest Emission Strength in the asset, 0 if nothing emits.

  • has_back — height_back.png is present (always true for v3 bakes).

  • has_debris (optional, default false) — debris.png is present. The baker writes it for models with materials whose custom property uo_debris is "vanish" (object_kit.vanish(): leaves from nature_kit.tones and the leaf* 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. See docs/destruction.md.

  • bare (optional, default false) — a bare twin lives in bare/: 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. Same frame_w/h, frames, variants, anchor and height_range, its own sprite.json (name <name>_bare, no bare, 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). Manifest bare = true / uo-baker bake --bare / bake_sprite.py --bare; uo-baker validates 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}; then variants == 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_variant in crates/uo-baker/src/tiles.rs.

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, so back <= front always (the baker clamps and reports violations; uo-baker rejects files that break it).
  • The thickness along the ray, front - back, is about sqrt 2 * t for a vertical wall of thickness t seen 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.png and has_back (default bake: opposite camera).
  • Texel density halved: 32x32 tiles (px_per_unit 22.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.png and emissive_scale.