Skip to content
Felucca Online

Docs

Client rendering (uo-client)

The client is a Bevy 0.19 app. The world is real 3D geometry under an orthographic camera that reproduces the oblique UO projection of crates/uo-proto/src/iso.rs exactly. Baked 2.5D sprites (see docs/sprite-format.md) become per-pixel 3D impostors: every texel is put back at its true 3D position, so Bevy's own PBR lights, shadow maps, SSAO, bloom and tonemapping work per pixel, like they would on meshes.

Bevy 0.19.1 is used rather than 0.20: 0.20 needs rustc 1.97.1, and the installed toolchains are 1.97.0-nightly and 1.95 stable.

Why impostors in Bevy's 3D world

Approach A from the brief: there is no hand-written deferred renderer. Bevy's forward+ PBR pipeline already provides clustered point lights, directional light cascades, PCF shadows, SSAO, bloom, ACES and HDR. All we add are two custom materials that hand Bevy correct per-pixel data:

  • ImpostorMaterial (impostor.rs, shaders/impostor.wgsl), an ExtendedMaterial<StandardMaterial, ImpostorExt>. One material and one unit-quad mesh serve every impostor; the sprite textures live in a shared atlas and each entity's MeshTag selects its frame (see Sprite atlas below). The vertex shader stretches the quad over the frame's trimmed rect in the camera's image plane, anchored on the entity's ground point. Its fragment shader does the following for each texel:
    1. Samples albedo, normal, height, material and emissive from the atlas with textureLoad (pixel-exact). Texels with alpha < 0.5 are discarded.
    2. Rebuilds the world position from the height map. The quad point slides along the projection ray (1, 1, sqrt 2) until its height equals ground_z + h * height_range. This is the renderer contract of the sprite format.
    3. Writes @builtin(frag_depth) from that position in both the depth prepass and the main pass, so sprites and terrain interleave per pixel. For example, a character behind a tree trunk is hidden only where the trunk is. The main pass adds DEPTH_NUDGE (4e-6 ≈ 0.0008 world units towards the camera): Metal compiles with fast math, so the prepass and main-pass shaders round the same depth differently in the last bits, and a main-pass fragment a hair farther than its own prepass depth fails the GreaterEqual test and leaves the black clear colour (specks that stick to objects, densest beyond the camera focus, where depth < 0.5 has finer float steps; #73).
    4. Feeds Bevy's PbrInput with the albedo, the normal-map normal (game to Bevy axes), metallic, roughness and reflectance from material.png, and emissive from emissive.png * emissive_scale. Emissive is written in un-exposed HDR units, so it drives bloom. It then calls apply_pbr_lighting.
  • TerrainMaterial (terrain_material.rs, shaders/terrain.wgsl) shades the terrain mesh with the baked UO ground tiles (ground_<terrain>, see uo-base-tiles.md). Each texel picks one nearby tile's terrain id (noise-jittered bilinear weights, dithered border) and reads albedo, normal, material and emission from that terrain's layer of the tile texture arrays (see Terrain tiles below). A separate flat water sheet uses the water tile's colour, drifting by whole texels, with animated ripple normals.

Both materials add a hemispherical sky/ground ambient term, multiplied by Bevy's SSAO. Bevy's own ambient light is off.

Coordinates (coords.rs)

game  : x east, y south, z up (left-handed), 1 unit = 1 tile
bevy  : (game.x, game.z, game.y)  (right-handed, Y up)
camera: orthographic, looking along -(0.5, 0.707, 0.5) (45° down, from SE),
        renders at TEXEL resolution: target = window / zoom + 2 * GUARD_PX,
        ortho area = target_px / PX_PER_UNIT wide,
                     target_px / (PX_PER_UNIT * sqrt2) high

The non-uniform ortho area supplies the sqrt 2 vertical stretch, so a tile is a 64x64 diamond at zoom 1 and a 1-unit post is 45.25 px tall. The camera centre is snapped so integer sprite pixels land on integer window pixels. Entity anchors are snapped to whole screen pixels as well, so sprites stay crisp while moving. Entities stand on tile centres (x + 0.5, y + 0.5).

Frame pipeline

                 ┌───────────── shadow passes (lights see layers 0+1) ───────────────┐
 sun cascade ──► │ terrain mesh + closed "shadow shells" of every sprite (layer 1)   │
 torch cube  ──► │ (impostor quads are NotShadowCaster)                             │
                 └───────────────────────────────────────────────────────────────────┘
 IsoCamera (layer 0) renders into a texel-resolution image (window / zoom + guard band):
   depth+normal prepass  terrain: vertex depth / impostors: frag_depth from height map
   [SSAO]                Bevy GTAO on the prepass (sees true per-pixel depth/normals)
   main forward pass     Bevy PBR: clustered point lights + sun/moon (shadow-mapped)
                         + hemispherical ambient + emissive  ──► HDR (Rgba16Float)
   [contact shadows]     Bevy screen-space shadows (off by default, key 5)
   bloom ─► ACES tonemap ─► vignette ─► sRGB overscan image (1 px = 1 sprite texel)
 present camera (window, layer 2): unlit quad shows the inner window-sized rect 1:1

Shadows

  • Shadow shells. For every sprite frame, impostor.rs::shadow_shell builds a closed 3D solid from the maps. The front sheet comes from height.png, the back sheet from height_back.png (v3; for v1/v2 sprites back = front - 0.15). The two sheets are stitched along every open edge, and triangles across depth jumps are skipped. Back-sheet outliers are clamped to the sprite's 95th-percentile thickness. Vertices sit on texel corners and coverage is dilated by 1 to 3 texels, so wall runs don't leak light between their shells. Small sprites use a 1-texel grid, so thin parts such as brazier legs cast thin shadows. The grid shell is then simplified (meshoptimizer edge collapses, absolute error SHELL_ERROR = 0.01 units ≈ 0.2 sprite texels ≈ half a sun-shadow-map texel at the default view): flat and gently curved areas and straight outline runs lose their per-texel vertices, while outlines, depth jumps and thin parts stay within the error. Shadows change only in single texels along their edges; the village's shells go from 2.16 M to 0.53 M triangles. Shells live on render layer 1: lights render them, the camera never does. The camera-facing quads are NotShadowCaster; seen from the sun they would be sheared cards.
  • Sun/moon: one directional light with a single cascade fitted to the slab CAMERA_DISTANCE ± (visible half-height + 6). With a fixed-angle ortho camera that slab is exactly the visible ground. Casters off-screen still shadow the screen. Bevy disables near-plane culling for directional casters, and a caster's light-space footprint equals that of its shadow, so it lands inside the cascade.
  • Screen-space: Bevy's ContactShadows assume a fixed thickness behind every depth pixel, which fills the gaps behind thin objects. They are off by default (--enable contact or key 5). The closed shells give correct thin shadows at shadow-map resolution. SSAO and contact shadows run on an overscanned target with a GUARD_PX-texel guard band, so occluders just off-screen still count.
  • Sun path: the camera looks from azimuth 45°/elevation 45°. A sun near that axis hides every shadow behind its caster, so the arc stays on the south-west/west side. It moves clockwise from SSE in the morning to WNW in the evening, at 42° at noon and 10° at the ends for long shadows. The constants SUN_*/MOON_* are in lighting.rs.

G-buffer-like debug views

--debug-view albedo|normal|position|depth|lit makes both materials output one channel unlit (tonemapping and bloom off):

viewcontent
albedosprite albedo / terrain tile albedo (linear)
normalgame-space normal * 0.5 + 0.5 (same encoding as normal.png)
positionreconstructed world position: fract(x), fract(y), z / 3
depthdepth_key (distance along the view direction), fract(k / 8)
litfull lighting with a constant grey albedo

Texture formats

mapGPU formatnotes
albedo.pngRgba8UnormSrgbalpha = coverage (discard < 0.5)
normal.pngRgba8Unormgame-space, renormalised in the shader
height.png (16-bit)R32FloattextureLoad, non-filterable
material.pngRgba8Unormmetallic, roughness, specular level
emissive.pngRgba8UnormSrgb* emissive_scale

The five sprite maps above are stored in the sprite atlas: one 2D texture array per map, 1024 x 1024 layers. | height_back.png | CPU only | used to build shadow shells | | terrain types | R8Uint (1 texel/tile) | terrain id | | terrain tiles | 2D arrays, canvas x all variants of all terrains (176) | albedo Rgba8UnormSrgb (alpha = coverage), normal/material Rgba8Unorm, emission Rgba32Float (pre-scaled), height R32Float | | HDR target | Rgba16Float (Bevy) | bloom, ACES |

Sprite atlas (atlas.rs)

All impostors draw with one material and one mesh, so Bevy batches the whole sprite layer into a few instanced draws per pass instead of one draw (and bind group) per sprite frame (decision #64).

  • Packing. Every frame of a sheet is trimmed to the rect holding its covered texels (albedo alpha >= 128, the shader's discard rule) and packed by a shelf packer into five texture_2d_arrays (albedo, normal, height, material, emissive; formats as in the table above, nearest sampling, textureLoad only). The atlas starts with 8 layers (≈ 168 MB of GPU memory for the five arrays, ≈ 21 MB per layer): the whole baked catalog (933 frames, 3.76 M texels after trimming, most of it the real-size trees of decision #74; 0.87 M before them) packs into 7, so even a view with every sprite never grows the atlas.
  • Frame records. A FrameRecord per frame (atlas texel and layer, trimmed size, trimmed rect's offset from the anchor, height_range, emissive_scale) sits in a storage buffer bound to the material. An impostor entity carries MeshTag(record); the vertex shader reads the record to size the shared unit quad, the fragment shader to find its texels. The tag's bits above 20 select an optional effect slot (hit flash, shake and damage cracks; see docs/destruction.md). Record 0 is an empty frame. The quad's culling Aabb is set per entity from the record (NoAutoAabb), since the shared mesh says nothing about size.
  • Lazy loading. SpriteLoader packs new sheets into free space at any time. Only the new rects travel to the GPU: pending uploads are moved to the render world at extraction and written with queue.write_texture after Bevy prepares the images. The images are created without CPU data, so the textures are never re-uploaded whole. When the arrays are full they double their layer count: the image descriptors grow and Bevy copies the old contents into the new textures (Image::copy_on_resize). The record buffer grows in steps of 1024 records.
  • Rebinding. A grown texture or buffer is a new GPU object, but Bevy prepares materials and images in parallel, so sync_atlas marks the material modified in the two frames after a growth (not the same frame) to rebuild its bind group. A frame drawn in between may miss sprites just loaded into the new layers.

Missing sprite directories fall back to procedural.rs. It ray-marches analytic SDF models (tree, ore vein, crate, 8-frame character, campfire, brazier, timber-framed walls with window/door/arch, posts, floor and ground tiles) and writes exactly the baked format, including material, emissive and back height, so the client always runs.

Running

cargo run -p uo-client                                  # offline demo (seed 42, 64x64)
cargo run -p uo-client -- --server ws://127.0.0.1:7777 --name Alice
cargo run -p uo-client -- --screenshot out.png [--frames 30] [--time 22] \
    [--zoom 3] [--focus 30,26] [--debug-view normal] [--disable ssao,bloom] \
    [--enable contact] [--width 1600 --height 1000] [--roofs auto|no-roofs|ground-floor]
cargo run -p uo-client --features dev                   # Bevy dynamic linking

Benchmarking: UO_FRAME_STATS=1 target/release/uo-client --headless --width 2560 --height 1600 --zoom 2 --screenshot /dev/null --frames 100000000 [--stress 5000] renders offscreen in an unthrottled loop and prints mean frame times (see docs/performance.md).

--screenshot waits until the material shaders are loaded and no pipeline is compiling, captures the window, writes the PNG and exits. Screenshot runs use scale factor 1, so the PNG is exactly --width x --height.

Key bindings

One control scheme and one HUD in every mode: the offline village, an offline battle (--combat-demo) and online (--server) (decision #96, uo-client/src/controls.rs, hud.rs).

keyaction
left clickground: walk there; a mobile: attack it; a destructible object: attack it (offline village: hit it, flash, shake, cracks, breaks on the last hit)
right button heldrun towards the cursor (UO)
WASDrun in screen directions (W up the screen)
numpad 1–9one step in that screen direction (8 = up)
1–6, 7arm a spell, then click its target (2 twice heals yourself, Shift keeps it armed); 7 Mega Explosion on yourself
right click / Escdisarm the spell; Esc with nothing armed quits (never in a fight)
Q / E / R / Tabpotion / guard / hold / next target
Enteroffline battle: fight again; online: a greeting (chat test)
Shift + right clickoffline village: explosion on the ground under the cursor (breaks everything within 2.5 tiles)
arrowspan the camera (stops following); F follows the player again
mouse wheelinteger zoom 2–8x (default 4)
Hcycle roof view: auto (hide a building while inside it) / no roofs / ground floors only
C / Ocharacter windows / settings window (docs/settings.md)
Tpause/resume the day-night cycle
, / .scrub time of day
[ / ]rotate the sun/moon azimuth
- / =lower/raise the sun/moon elevation
Njump 12 hours (day <-> night)
Ltoggle the mouse-cursor light (debug; off by default)

Effect toggles live in the settings window (O); debug views are set with --debug-view.

The window title shows FPS, time of day, zoom and all toggles.

Online mode

net.rs runs a blocking tungstenite socket on a background thread with a 10 ms read timeout. Outgoing commands come from an mpsc channel; incoming ServerMsgs go to a Bevy resource. The flow is:

  1. login.
  2. The Welcome reply sets the map seed and size, and the client regenerates the same TileMap.
  3. The client requests the inventory once.
  4. snapshot messages spawn, update and despawn entities. Players use the character sprite with frame = facing; resource nodes use tree (item 1), ore_vein (item 3) or crate.
  5. Positions are interpolated over one server tick (1 / tick_hz).
  6. Chat, system and gather events are printed to stdout.

Presentation and pixel exactness

The 3D scene is rendered at one render pixel per sprite texel and presented by a second camera (render layer 2) as an unlit quad scaled by the integer zoom with nearest filtering. Every pass (PBR, SSAO, bloom, shadow lookups) therefore costs zoom² less than at window resolution, and sprites stay pixel-exact. The render centre is snapped to whole texels; the sub-texel remainder is applied as a whole-window-pixel offset of the presented quad, so scrolling stays smooth at zoom 2–4.

Pixel-art density, outlines and toon lighting

Density. Sprites are baked at 32x32-px tiles (uo_proto::iso::TILE_W, PX_PER_UNIT ≈ 22.63). The client takes every projection constant from iso.rs. That includes the uo::common WGSL module, whose PX constant is substituted at startup. The default zoom is 4 (range 2–8). Because the scene is rendered at one pixel per texel and upscaled with nearest filtering, lighting, shadows, bloom and terrain shading are all computed per chunky texel. Terrain texels come straight from the baked tiles, one per screen texel (see Terrain tiles), so the terrain reads as pixel art.

Terrain tiles (terrain_tiles.rs). Every ground sheet is uploaded as raw frames into texture arrays, all on one shared canvas and anchor. A per-terrain table in the uniform (TerrainInfo: first layer, variant count, Wang parameters) says where each terrain's variants live. The shader works on the fragment's world-anchored screen texel s (texel_of). Tile centres form the screen lattice (16, 16), (-16, 16); the tile whose z = 0 diamond contains s owns it, and the sprite texel is s - tile_centre + anchor. The variant is a hash of the tile, or for edge-matched sheets ("wang" in sprite.json: swamp, forest) the stochastic Wang pick: each tile edge gets a hashed type bit (probability p), and the tile shows a variant baked for its four edge types (n | e<<1 | s<<2 | w<<3), so puddles and leaves continue across tile edges. Hash and rule are the same as uo-baker's tiles::pick_variant (seed 0), so the game matches the baker's mosaics. Relief can poke out of a diamond towards the back, so the three tiles in front of the owner are candidates too, and the covered candidate with the highest surface wins: all copies of a texel lie on one projection ray, so the highest is the frontmost. The result equals drawing the variant sprites side by side, and each screen texel shows exactly one baked texel: no resampling and no swimming while scrolling. TileSet::pick mirrors the lookup on the CPU for tests (no holes for any variant mix; Wang neighbours agree on every shared edge). The terrain type still comes from the geometric tile (dithered borders), the variant from the screen lattice tile, which is the same tile on flat ground at z = 0 and a nearby one elsewhere. On slopes the pattern keeps its screen density (it is projected along the view direction) and the sloped mesh normal is combined with the tile normal (whiteout blend). A missing tile falls back to a flat colour. Specular uses the tile's material, so in toon mode glossy texels (wet mud, ice, puddles) can catch the hard highlight; lava cracks emit.

Terrain before (procedural) and after (baked tiles), demo start, zoom 4 Generated terrain types at zoom 3: furrows, dirt, cave, rock, lava, forest, jungle, swamp (left); snow cap, sand (right)

Outlines (outline.rs, shaders/outline.wgsl; settings window, --disable outline). The outline is a fullscreen pass in Core3dSystems::EarlyPostProcess, on the HDR frame at texel resolution. It runs before bloom, vignette and tonemapping, which ping-pong the same main texture.

  • A pixel becomes outline when one of its 4 neighbours is a sprite that is nearer along the view ray by more than OUTLINE_DEPTH_THRESHOLD (0.25 world units). View distance is far * (1 - depth) for the reverse-z ortho camera.
  • The outline colour is that neighbour's lit colour times look.outline_darken, saturated by look.outline_saturation (the pixel-art "sel-out" look; see Look below).
  • Sprites are recognised because impostors write alpha 1/3 into the Rgb10a2 normal prepass, while terrain writes 1 and the background is 0. Terrain hills therefore never get outlines, but a character in front of a wall does.
  • The camera is snapped to whole texels, so outlines don't crawl while scrolling.
  • Inner crease lines from normal discontinuities exist behind Outline::creases and are off by default.
  • --debug-view outline shows the mask: white is outline, grey is sprite pixels.

Toon lighting (settings window, --disable toon). This is implemented in both material shaders after Bevy's light accumulation. The constants live in shaders/common.wgsl (TOON_*); band values, shadow tint and AO step are filled in from look.rs at startup (see Look below).

  • Diffuse: the diffuse-only light (sun or moon, point lights, sky/ground ambient) is divided by albedo to get the incoming light. Its luminance is quantised into hard bands (thresholds TOON_T1..T4, values TOON_V0..V4), while its hue is kept. Warm brazier pools and the cool moon keep their colour and become stepped rings.
  • Darkest band: it is tinted slightly cool (TOON_SHADOW_TINT).
  • Specular: a hard on/off highlight above TOON_SPEC_THRESHOLD. It is computed for metallic sprite texels (ore) and for terrain, where only glossy tile texels and water get bright enough to cross it.
  • AO and emissive: both are applied after banding. AO noise therefore can't speckle band edges, and emissive is never banded.
  • Shadows: with look.soft_shadows (default) the sun's shadow map is filtered smoothly (ShadowFilteringMethod::Gaussian) and the toon bands turn the soft edge into one or two lighter steps; without it toon mode uses Hardware2x2 for hard-edged shadows.

Look (decision #62, crates/uo-client/src/look.rs). The art direction in one struct, tuned against a paint-over of the village: high-key and warm in the light, soft cool shadows, a calm palette.

  • Calm albedo: every material shader passes baked albedo through calm_albedo (sRGB space: contrast 0.9 around 0.5, a small lift; saturation is kept) before lighting, so the catalog's palette is calmed in one place, terrain and characters included, without rebaking.
  • Soft light: the toon band values are lifted (deepest 0.2, then 0.36 / 0.62 / 0.92); by day the sun is warm (1, 0.87, 0.66) and the sky ambient cool (0.6, 0.7, 0.92), so lit surfaces read warm and shadows a light cool grey. Below TOON_T0 (0.003) a texel is black; the moonlit night never gets that dark, it is only a guard.
  • Colour grading: the scene camera uses Tonemapping::AcesFitted with Bevy's ColorGrading (by day exposure +0.75 EV, slight warm temperature, post saturation 1.15, a little shadow lift; midtone contrast 0.92). Debug views stay ungraded.
  • Time of day (decision #63): sun colour and strength, sky ambient and the grading come from five Phases blended linearly by hour along look::SCHEDULE: night until 5:00, pink morning at 6:24 (rose sun, lilac sky, magenta tint), day from 8:30 to 16:12, golden hour at 17:36 (amber sun, warm sky), dusk at 18:24, night again from 19:00. Night is moonlit (decision #95): a weak cool moon and a dim blue sky, and every phase has a dim tint that pulls texels in the two darkest toon bands towards a colour at their own luminance (night: blue, 0.65, so shadows keep a hint of their colours; other phases off). Blue light on green grass would still be green; the tint makes everything only the moon and sky light read dark blue, while pools of torch, brazier, forge and lantern light, in the brighter bands, keep their colour. Artificial lights are warm, not orange: Look::light_color pulls sprite lights, torches and the campfire towards white (light_saturation 0.6, luminance kept); combat effects keep their own colours. The sun keeps its own direction until well below the horizon, after the schedule has faded it to the moon's strength, and the light fades out for a few game minutes around the switch to the moon's direction, so shadows never jump. update_sun rewrites the camera's grading only when the phase changes (#32).
  • Tuning: UO_LOOK="key=value,..." overrides any field at startup (toon_v0=0.25, golden.sun=1:0.7:0.3, night.dim_tint_amount=0.6, light_saturation=0.5, tonemapping=agx, …), no rebuild needed. Against the paint-over the default frame matches mean luminance (0.47 / 0.46), saturation (0.34 / 0.35), warmth and the lit/shadow grass colours; its darkest 5 % stay lighter (the paint-over's slate roof is bluer and darker).

Pixel-art polish.

  • Terrain borders: they are hard per texel. Each texel picks one of the four nearest tile centres instead of blending, inside a 1–2 texel ordered-dither (Bayer 4x4) band.
  • Dither anchoring: the dither grid is anchored to the world's texel grid (texel_of), so it doesn't swim while scrolling.
  • AO: in toon mode, ambient occlusion is a single hard step (look.ao_step, ×0.88).
  • Bloom: it only picks up HDR values above 1.0 (flames, glowing panes), with additive composite. A zero threshold softened the whole frame.
  • Emissive texels: texels with emissive * emissive_scale > 0.5 are left out of the shadow shells, so flames don't cast shadows.
  • Outline contrast: the outline is limited to 55% of the luminance of the pixel it covers, scaling intensity only so the hue is kept. It stays visible on dark grass.

See-through circles (cutout.rs)

Real-size trees (decision #74), walls and roofs would hide characters, so sprites in front of a mobile are cut away in a dithered circle around it (decision #75; --disable see_through, setting see_through; no hotkey since the combat merge, #70).

  • Circles. Every frame each mobile in view (entities with a SpriteView but no Static: the player, NPCs, mesh or sprite) gives a circle: centre at chest height (CHEST 0.95 above the feet) in render-target texels, which are exact iso screen pixels (CameraState::render_origin is the screen point of texel (0, 0)), plus the mobile's nearness dot(p, TO_CAMERA) and its feet height. At most MAX_CUTOUTS (32), nearest the screen centre first, in a fixed-size storage buffer on the impostor material (binding 108) that is rewritten only when a circle moved (#32), and mirrored into the outline uniform.
  • The cut (see_through in impostor.wgsl): a texel is discarded when it lies inside a circle (RADIUS 28 texels), is nearer the camera than the mobile by DEPTH_MARGIN (0.5 units: a sprite mobile never cuts itself, things beside or behind it stay) and stands MIN_HEIGHT (0.3) above its feet (floors, rugs and flower beds stay). The inner CORE (half the radius) is cut completely; across the rim a Bayer 4×4 threshold thins the cut out, in whole texels, anchored to the circle's centre texel so the pattern travels with the character (#40). The test runs inside reconstruct, i.e. in the depth prepass and the main pass alike, so the prepass never keeps depth for a texel the main pass drops (that would leave clear-colour holes, cf. #73).
  • Outlines. Inside a circle the outline pass ignores nearer sprite neighbours (characters keep theirs), or every dither hole would turn into a dark outline texel.
  • Shadows come from the shadow shells, which are not cut: a cut tree still casts its whole shadow.
  • Limits: another sprite-drawn mobile in front of one is cut like any sprite (3D mesh mobiles, the default, are never cut); a mobile on an upper storey cuts the roof above it like any occluder.

Hidden tree crowns (crowns.rs)

Real-size crowns (decision #74) would hide the player and the trunk they want to chop, so a crown near the player fades out and leaves the trunk and branches (decision #98; setting crowns: auto, always, never).

  • Bare twins. Trees are baked a second time without their vanishing materials (bare = true in the manifest, bare/ next to the sprite, docs/sprite-format.md), so the trunk inside the crown really exists. SpriteLoader packs the twin with the sprite (SpriteAtlas::add_with_bare) and every frame record points at its twin's (FrameRecord::bare); SpriteAsset::bare keeps the twin's records and coverage.
  • When. CrownMode::Auto hides a crown while the local player's feet are within NEAR (1.5 tiles) of the trunk, or while a crown texel (one the tree draws and its twin does not) covers the player's body on screen (feet to 1.9 units up, ±8 texels) and the tree is at most BEHIND (1 tile along the view) behind the player. A hidden crown keeps hiding until the player is NEAR_KEEP farther away and the body rect grown by KEEP_MARGIN texels is clear, so crowns do not flicker at the edge.
  • The fade. A switch writes bare_time and bare into the tree's effect slot (allocated for it, or the one its hits already use; given back once the crown is shown again and the tree was never hit). In reconstruct a texel whose Bayer 4×4 threshold (anchored to the sprite, whole texels) is below the fade amount reads the twin's record instead, at the same anchor-relative texel; texels the twin does not cover are discarded. It runs in the prepass and the main pass alike (cf. #73). Fade FADE_SECS = 0.35 s; reversing mid-fade continues from where it is (crowns::switch); a tree seen for the first time (just streamed in, or at startup) switches without a fade.
  • CrownMode::Always draws every tree with its twin's record outright (no slot), Never keeps every crown. Shadows always come from the whole tree's shell, so a hidden crown still shades the ground. Mouse picking uses the twin's coverage while a crown is hidden (you click the trunk). Hits, cracks and breaking work on the hidden tree as before (breaking still bursts the leaves into particles).
  • Captures: UO_PLAYER_START=x,y starts the offline demo's player on another tile, e.g. UO_FREEZE_TIME=1 UO_PLAYER_START=28,67 uo-client --headless --screenshot crowns.png puts them behind the village's apple tree.

Static objects (statics.rs)

World objects (walls, doors, roofs, floors, furniture, lights, trees, items; catalog in docs/uo-objects.md) are uo_proto::objects::StaticObjects: sprite name, tile x/y, absolute z and a Facing (N, E, S, W). An entity with a Static component gets its SpriteView on the next frame and is then drawn like any other impostor.

  • Placement. The origin is the tile centre at z. Multi-tile _s/_e assets are placed on their north-west-most tile; wall, fence and door pieces on the tile whose north (_x) or west (_y) edge they stand on; things on a table at the table's top (z + 17 px). Nothing else is needed: per-pixel depth sorts multi-tile and overlapping sprites. The client rounds every static's height to whole screen pixels (iso::snap_z), so sprites built on the texel lattice keep their texels on the screen's texel grid (decision #60); storeys (48 px), roof courses (32 px), floor and table tops are whole pixels already.
  • Frames. Facing::frame(frames): 4-frame sheets are N, E, S, W, 8-frame sheets use the Direction order, 1-frame sheets ignore the facing. Sheets with variants > 1 pick a variant per tile (hash of the tile).
  • Loading. Sprites outside SPRITE_NAMES are loaded on first use by the SpriteLoader system param (PNG decode, atlas packing, shadow shells), so only the sprites a scene uses cost memory and startup time.
  • Lights. Every frame of a sprite is scanned for glowing texels (the same emissive * emissive_scale > 0.5 texels that are left out of the shadow shells). Their mean 3D position (rebuilt from the height map) and mean colour become a SpriteLight; a static with one gets a child PointLight there, intensity 2500 * sqrt(texels * strength) lm clamped to 5k–90k (a candle ≈ 17k, a brazier ≈ 63k), range from the intensity (3–10 tiles), no shadow maps. Warm lights (flames) flicker, cold ones (moongates, crystals) don't. Walls never emit: their window glass glows only to suggest a lit room.
  • Roofs and storeys. Everything above a building's ground floor (upper storeys' floors, walls and furniture, stairs to them, and the roof) carries Overhead { area, level, roof } (area = the building's interior tiles). RoofView decides what is drawn: auto hides a building's overhead parts while the local player stands inside it (UO); no-roofs also hides every roof, opening the top storeys; ground-floor shows only ground floors. H cycles (#96), --roofs sets the start. Only the impostors are hidden: the shadow shells are forced visible, so hidden storeys and roofs still shade the interiors and the ground. Lights on hidden storeys go out with them. uo_proto::objects::hip_roof lays out the roof pieces (see docs/uo-objects.md).
  • Empty shells. A frame without solid texels (an all-glowing moongate) gets no shadow shell: the mesh allocator rejects empty meshes.
  • Blocking. StaticObject::blocked_edges derives the edges closed to movement from the name convention (walls, fences, closed doors and gates); the demo marks tiles under furniture, trees and rocks as blocked.
  • Destruction. Statics with a uo_proto::objects::durability can be hit and broken into 3D chunks; a broken static unblocks its edges/tile (docs/destruction.md).

3D mesh mobiles (mesh.rs, shaders/toon_mesh.wgsl)

Mobiles (players/NPCs) can be real 3D meshes in the same world as the impostor sprites. --player-mesh mesh|sprite chooses the style (default mesh), and --smooth-turn turns smoothly instead of snapping to the 8 directions.

  • Content. Models are .glb files under assets/models/, exported from the same procedural Blender scripts as the sprites: Blender -b --factory-startup --python tools/blender/export_glb.py -- --model-script tools/blender/models/character.py --out assets/models/character.glb. glTF's Y-up conversion maps Blender (X, Y, Z) to (X, Z, −Y), which is exactly client Bevy space (game x, game z, game y). So the model origin goes on the feet, 1 unit = 1 tile, and the front faces Blender −Y (game south).
  • Placement. A mesh mobile still carries a SpriteView (game-space anchor + Direction index), so offline walking, the torch rig and network interpolation are shared with sprites. place_mesh_mobiles puts the scene root on the pixel-snapped anchor. Its yaw is atan2(dx, dy) of the facing's game delta, because an unrotated model faces Bevy +Z.
  • Style parity. When a glTF scene spawns under a MeshMobile, its StandardMaterials are replaced (one cached copy per source material) by ToonMeshMaterial = ExtendedMaterial<StandardMaterial, ToonMeshExt>. It uses the same uo::common toon banding, hemispherical ambient, hard-step AO and debug views as the impostors. Its prepass writes the object tag (normal alpha 1/3), so the outline pass gives meshes the same sel-out outline. Rendering at texel resolution makes them pixel art automatically.
  • Shadows and occlusion. Meshes are ordinary Bevy meshes on layer 0, so they cast into the sun (and enabled point-light) shadow maps and receive shadows. That includes shadows from the impostor shadow shells, e.g. a wall's afternoon shadow. They depth-test per pixel against the impostors' frag_depth, so a mesh behind a wall or trunk is hidden only where they overlap.
  • Prepass caveat. The prepass must not write frag_depth in the camera prepass. A value equal in theory but rounded differently from the main pass's rasterised depth fails its GreaterEqual test and leaves black specks.
  • Not yet: skinning/animation playback. The test model is static (no rig), and no CC0 rigged model was fetched. glTF animation works in Bevy through AnimationPlayer once a rigged model is chosen.

Characters are assembled from the Quaternius modular outfits on one shared skeleton; see docs/characters.md.