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), anExtendedMaterial<StandardMaterial, ImpostorExt>. One material and one unit-quad mesh serve every impostor; the sprite textures live in a shared atlas and each entity'sMeshTagselects 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:- Samples albedo, normal, height, material and emissive from the atlas
with
textureLoad(pixel-exact). Texels with alpha < 0.5 are discarded. - Rebuilds the world position from the height map. The quad point slides
along the projection ray
(1, 1, sqrt 2)until its height equalsground_z + h * height_range. This is the renderer contract of the sprite format. - 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 addsDEPTH_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 theGreaterEqualtest 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). - Feeds Bevy's
PbrInputwith the albedo, the normal-map normal (game to Bevy axes), metallic, roughness and reflectance frommaterial.png, and emissive fromemissive.png * emissive_scale. Emissive is written in un-exposed HDR units, so it drives bloom. It then callsapply_pbr_lighting.
- Samples albedo, normal, height, material and emissive from the atlas
with
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_shellbuilds a closed 3D solid from the maps. The front sheet comes fromheight.png, the back sheet fromheight_back.png(v3; for v1/v2 spritesback = 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 errorSHELL_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 areNotShadowCaster; 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
ContactShadowsassume a fixed thickness behind every depth pixel, which fills the gaps behind thin objects. They are off by default (--enable contactor key 5). The closed shells give correct thin shadows at shadow-map resolution. SSAO and contact shadows run on an overscanned target with aGUARD_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 inlighting.rs.
G-buffer-like debug views
--debug-view albedo|normal|position|depth|lit makes both materials output
one channel unlit (tonemapping and bloom off):
| view | content |
|---|---|
| albedo | sprite albedo / terrain tile albedo (linear) |
| normal | game-space normal * 0.5 + 0.5 (same encoding as normal.png) |
| position | reconstructed world position: fract(x), fract(y), z / 3 |
| depth | depth_key (distance along the view direction), fract(k / 8) |
| lit | full lighting with a constant grey albedo |
Texture formats
| map | GPU format | notes |
|---|---|---|
| albedo.png | Rgba8UnormSrgb | alpha = coverage (discard < 0.5) |
| normal.png | Rgba8Unorm | game-space, renormalised in the shader |
| height.png (16-bit) | R32Float | textureLoad, non-filterable |
| material.png | Rgba8Unorm | metallic, roughness, specular level |
| emissive.png | Rgba8UnormSrgb | * 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,textureLoadonly). 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
FrameRecordper 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 carriesMeshTag(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; seedocs/destruction.md). Record 0 is an empty frame. The quad's cullingAabbis set per entity from the record (NoAutoAabb), since the shared mesh says nothing about size. - Lazy loading.
SpriteLoaderpacks 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 withqueue.write_textureafter 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_atlasmarks 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).
| key | action |
|---|---|
| left click | ground: 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 held | run towards the cursor (UO) |
| WASD | run in screen directions (W up the screen) |
| numpad 1–9 | one step in that screen direction (8 = up) |
| 1–6, 7 | arm a spell, then click its target (2 twice heals yourself, Shift keeps it armed); 7 Mega Explosion on yourself |
| right click / Esc | disarm the spell; Esc with nothing armed quits (never in a fight) |
| Q / E / R / Tab | potion / guard / hold / next target |
| Enter | offline battle: fight again; online: a greeting (chat test) |
| Shift + right click | offline village: explosion on the ground under the cursor (breaks everything within 2.5 tiles) |
| arrows | pan the camera (stops following); F follows the player again |
| mouse wheel | integer zoom 2–8x (default 4) |
| H | cycle roof view: auto (hide a building while inside it) / no roofs / ground floors only |
| C / O | character windows / settings window (docs/settings.md) |
| T | pause/resume the day-night cycle |
| , / . | scrub time of day |
| [ / ] | rotate the sun/moon azimuth |
| - / = | lower/raise the sun/moon elevation |
| N | jump 12 hours (day <-> night) |
| L | toggle 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:
login.- The Welcome reply sets the map seed and size, and the client regenerates
the same
TileMap. - The client requests the inventory once.
snapshotmessages spawn, update and despawn entities. Players use thecharactersprite with frame = facing; resource nodes usetree(item 1),ore_vein(item 3) orcrate.- Positions are interpolated over one server tick (
1 / tick_hz). - 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.

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 isfar * (1 - depth)for the reverse-z ortho camera. - The outline colour is that neighbour's lit colour times
look.outline_darken, saturated bylook.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::creasesand are off by default. --debug-view outlineshows 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, valuesTOON_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 usesHardware2x2for 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::AcesFittedwith Bevy'sColorGrading(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 alonglook::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_colorpulls sprite lights, torches and the campfire towards white (light_saturation0.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_sunrewrites 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.5are 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
SpriteViewbut noStatic: the player, NPCs, mesh or sprite) gives a circle: centre at chest height (CHEST0.95 above the feet) in render-target texels, which are exact iso screen pixels (CameraState::render_originis the screen point of texel (0, 0)), plus the mobile's nearnessdot(p, TO_CAMERA)and its feet height. At mostMAX_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_throughinimpostor.wgsl): a texel is discarded when it lies inside a circle (RADIUS28 texels), is nearer the camera than the mobile byDEPTH_MARGIN(0.5 units: a sprite mobile never cuts itself, things beside or behind it stay) and standsMIN_HEIGHT(0.3) above its feet (floors, rugs and flower beds stay). The innerCORE(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 insidereconstruct, 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 = truein the manifest,bare/next to the sprite,docs/sprite-format.md), so the trunk inside the crown really exists.SpriteLoaderpacks the twin with the sprite (SpriteAtlas::add_with_bare) and every frame record points at its twin's (FrameRecord::bare);SpriteAsset::barekeeps the twin's records and coverage. - When.
CrownMode::Autohides a crown while the local player's feet are withinNEAR(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 mostBEHIND(1 tile along the view) behind the player. A hidden crown keeps hiding until the player isNEAR_KEEPfarther away and the body rect grown byKEEP_MARGINtexels is clear, so crowns do not flicker at the edge. - The fade. A switch writes
bare_timeandbareinto 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). Inreconstructa 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). FadeFADE_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::Alwaysdraws every tree with its twin's record outright (no slot),Neverkeeps 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,ystarts the offline demo's player on another tile, e.g.UO_FREEZE_TIME=1 UO_PLAYER_START=28,67 uo-client --headless --screenshot crowns.pngputs 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/_eassets 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 theDirectionorder, 1-frame sheets ignore the facing. Sheets withvariants > 1pick a variant per tile (hash of the tile). - Loading. Sprites outside
SPRITE_NAMESare loaded on first use by theSpriteLoadersystem 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.5texels that are left out of the shadow shells). Their mean 3D position (rebuilt from the height map) and mean colour become aSpriteLight; a static with one gets a childPointLightthere, intensity2500 * 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).RoofViewdecides what is drawn:autohides a building's overhead parts while the local player stands inside it (UO);no-roofsalso hides every roof, opening the top storeys;ground-floorshows only ground floors.Hcycles (#96),--roofssets 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_rooflays out the roof pieces (seedocs/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_edgesderives 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::durabilitycan 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
.glbfiles underassets/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 +Directionindex), so offline walking, the torch rig and network interpolation are shared with sprites.place_mesh_mobilesputs the scene root on the pixel-snapped anchor. Its yaw isatan2(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, itsStandardMaterials are replaced (one cached copy per source material) byToonMeshMaterial=ExtendedMaterial<StandardMaterial, ToonMeshExt>. It uses the sameuo::commontoon 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_depthin the camera prepass. A value equal in theory but rounded differently from the main pass's rasterised depth fails itsGreaterEqualtest 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
AnimationPlayeronce a rigged model is chosen.
Characters are assembled from the Quaternius modular outfits on one shared
skeleton; see docs/characters.md.