Skip to content
Felucca Online

Docs

3D characters (Quaternius modular outfits)

Players and NPCs are drawn as skinned, animated 3D meshes assembled from three CC0 Quaternius packs (models by Quaternius, https://quaternius.com): Modular Character Outfits – Fantasy (outfit parts), Universal Base Characters (heads, bare body parts, eyes, brows, hair) and Universal Animation Library 1 + 2 (clips). All share one 65-joint skeleton with identical joint order. They go through the client's 3D mesh path (docs/rendering.md, 3D mesh mobiles). That gives them the same toon banding, sel-out outlines, shadows both ways and per-pixel occlusion with the impostor sprites, all rendered at texel resolution.

Import pipeline

/Applications/Blender.app/Contents/MacOS/Blender -b --factory-startup \
    --python tools/blender/import_quaternius.py -- \
    --src "<pack>/Modular Character Outfits - Fantasy[Standard]" \
    --out assets/models/quaternius \
    --base-src "<…>/Universal Base Characters[Standard]" \
    --anim-src "<…>/UAL1_Standard.glb" "<…>/UAL2_Standard.glb" \
    --rm-src "<…>/UAL1_Standard_RM.glb"
    # options: --tex 256 --skin-tex 128 --max-mesh-tris 1100 --clips … --skip-outfits
  • Reads Exports/glTF (Godot-Unreal)/{Outfits,Modular Parts}/*.gltf. The source pack is only read; the 292 MB originals are not committed.

  • Writes assets/models/quaternius/{outfits,parts}/<name>.gltf + .bin, with textures shared once in textures/. The whole folder is 4.3 MB.

  • Textures are downscaled to 256² (outfit base colour, ORM) and 128² (skin). A character is ~40–55 texels tall on screen and the client samples them with nearest filtering. Larger maps only add mip mud.

  • Normal maps are dropped: their detail is sub-texel at 32-px tiles, and toon bands quantise the light anyway.

  • Vertex colours (authoring masks) are dropped too. glTF/Bevy multiply COLOR_0 into the base colour, which made everything black.

  • Meshes above 1100 triangles are collapse-decimated. The skin weights are interpolated by the modifier, and the skeleton and inverse bind matrices are untouched. Deformation was checked with the arms-down pose.

  • The alternate colourways (T_Ranger_3_BaseColor, T_Peasant_2_BaseColor) are copied, downscaled, for palette swaps.

  • LICENSE.txt (CC0 + credit) and README.md (part list with triangle counts) live next to the assets.

  • Base body regions (cut_base_body): each base body is cut into the head and four bare-skin pieces. Using the whole base body under the outfits would clip through them, as the pack's Readme warns, so a region is only drawn where no outfit part covers it.

    • Each triangle goes to the region holding most of its vertices' bone weights (summed, normalised per vertex), so the pieces tile the body. Bones → region: Head, neck_01 → head; spine_*, clavicles → body; pelvis, thighs → legs (the pelvis carries the underwear briefs, like pants); calves → calves; feet, balls → feet; upper/lower arms, hands, fingers → arms. The cut lines follow the outfit parts. The calves are a region of their own because outfit legs and outfit shoes/boots cover them (see the bare-feet pieces below).

    • Seams: the source is imported with merged vertices (glTF splits them along UV seams), triangulated, and every triangle assigned first. Then each region is decimated on its own while the vertices on region borders and the triangles touching them stay locked. Neighbouring pieces therefore share their border vertices exactly, with the same positions and skin weights, and deform together without cracks. The script aborts if a border vertex moved. Budgets (REGION_TRIS): head 1100, body 800, legs 500, calves 300, feet 400, arms 1100.

    • Heads (heads/{Male,Female}_Head.gltf): head and neck, plus eyes and eyebrows (capped at 1100 triangles like other meshes). The old head-only cut decimated after cutting, which opened cracks along UV seams.

    • Bare parts (bare/<gender>_<piece>.gltf, BASE_PIECES), pieces made of whole regions:

      pieceregionstriangles (M / F)
      Bodytorso684 / 672
      Legslegs + calves (briefs to ankles)530 / 550
      Feetfeet (used when the legs are bare too)334 / 340
      Feet_Calvescalves + feet (used under clothed legs)476 / 502
      Armsarms with hands1038 / 1048

      Heads: 1060 / 1056 plus eyes and brows.

    • Both use the base skin material: light texture by default, the dark one copied for palette swaps.

  • Hair (hair/*.gltf): the "Rigged to Head Bone" hairstyles (Long, Buns, Buzzed, BuzzedFemale, SimpleParted, Beard, Eyebrows_*).

  • Animations (animations/animations.glb, 1.9 MB): the 31 clips below from the in-place UAL files, merged onto one armature, mannequin mesh removed. locomotion.json holds each locomotion clip's ground speed, measured from the root-motion variants: Walk 0.975, Jog 5.36, Sprint 8.25, Swim 2.18 tiles/s.

  • Converted total: 8.3 MB.

Skeleton, units, facing

  • One skin per part: 65 joints with UE5-mannequin names (root, pelvis, spine_01..03, neck_01, Head, clavicle/upperarm/lowerarm/hand_l|r, fingers, thigh/calf/foot/ball_l|r).
  • Metres, Y-up, 1.865 tall, which matches 1 unit = 1 tile.
  • The front faces Blender −Y (the toes point there), i.e. game south / Bevy +Z. That is the same convention as our own models, so the 8-direction facing needs no extra rotation. --lineup shows all 8 facings side by side (contact sheet).

Assembly (crates/uo-client/src/characters.rs)

CharacterAppearance { gender, skin, body, legs, feet, arms, head, beard, hair_color, acc }
Part { outfit, palette, dye }   // body, legs, feet, arms, acc and the hood
  • Slots.
    • Each outfit slot holds a Part, or None for bare skin in body, legs, feet and arms: that region's piece of the base body (bare/<gender>_<Region>.gltf), in the character's skin tone. Bare feet under clothed legs use Feet_Calves: outfit trousers end above the ankle, and the calves they leave would otherwise be missing.
    • A Part is the pack outfit, its colourway (palette 0/1) and a dye (linear multiplier of the outfit texture in percent, 100 = as authored). Parts of one character may differ, e.g. Ranger leathers over Peasant trousers.
    • head is Hair(style) or Hood(part). The hood is worn without hair, because hair meshes poke through it.
    • CharacterAppearance::outfit(gender, outfit, palette) gives Rangers hood and pauldron(s), and Peasants a gender-typical hairstyle.
    • with_hair, with_hair_color, with_skin and with_beard vary the look.
    • Characters with a CharacterSheet get their appearance from their worn items instead (see Items → appearance).
  • Merging. Each slot's part glTF scene (always including the base head, and bare pieces for empty regions) is spawned under the character. Once all parts are ready, every part's skinned meshes are merged into one skinned mesh per material on the body's skeleton:
    • Joint indices are remapped by name onto the body's joint order.
    • Parts whose rest pose differs keep their own inverse bind poses (reordered) in a separate merged mesh. This is the male base body (head and bare parts): its bind poses differ from the outfits' by up to 0.35. A naked male's bare torso owns the skeleton; then the outfit parts are the ones with their own bind poses.
    • The source scenes and their spare skeletons are despawned.
    • Result: 4–6 skinned meshes per character (outfit, outfit skin, head skin, eyes, hair), down from 10–15. The per-skinned-mesh overhead was the measured cost.
  • Looks. Materials are the character look of the toon mesh material (see Readability), cached per (texture, part look for outfit materials, skin, gender, hair colour) and shared between characters.
  • Re-dressing. When CharacterAppearance changes, the new parts are spawned hidden next to the old, assembled look, which is marked stale and keeps rendering and animating. Once the new parts are merged, the stale parts (with their skeleton) are despawned and the new body part is shown, so there is no blank frame. Assembled and the Animator are removed meanwhile; the new armature gets a fresh AnimationPlayer, so the current clip restarts.
  • Animation targets. Joints get AnimationTargetIds built from their name path under Armature, which is the path the glTF loader uses for the clips (Armature/root/pelvis/…). They also get AnimatedBy(armature).
  • Position and facing. The character entity also carries a SpriteView (game position and facing), so offline walking, network interpolation and light rigs are shared with sprites.

Items → appearance (crates/uo-client/src/paperdoll.rs)

A character entity with a CharacterSheet (its uo_proto::character::Character) and a BodyLook (gender, skin, hair style and colour, beard: what it looks like without items) gets its CharacterAppearance from what it wears. The dress system re-derives it whenever the sheet or body changes and writes it with set_if_neq, so moving items inside the backpack or wearing jewelry does not re-assemble the mesh. The mapping is client-side art; uo_proto and the protocol know nothing about meshes.

Layers. UO's equipment slots collapse onto the pack's six appearance regions. In each region the outermost worn item that has a part shows:

regionequipment slots, outermost firstwhen empty
bodyrobe, tunic, body (chest armor), shirtbare skin
legsskirt, legs (leg armor), pantsbare skin (briefs)
feetfeetbare skin
armsarms (sleeves), hands (gloves)bare skin
headheadhair (BodyLook::hair)
acccloaknothing

An empty body/legs/feet/arms region shows bare skin: the base body's piece for that region (see Import pipeline), in the BodyLook skin tone. The pack's outfit parts carry their own skin where they leave it showing (Peasant hands, the Ranger's bare upper arms), so bare pieces only appear for empty regions. Where they meet outfit parts (checked in headless screenshots at zoom 8, --unequip and --lineup):

  • bare arms out of a shirt or tunic, and a bare torso under sleeves: the shoulders tuck under the garment; no gaps;
  • bare legs in shoes: the calves run inside the shoe or boot;
  • bare feet under trousers or leggings: these first showed a gap, because the trousers end above the ankle and the shoes they replace cover the calf. The feet floated below the hem. Feet_Calves fixes it.

Item parts (item_part, placeholder art: two outfits and their colourways stand in for every garment):

itemspart
shirt, long pants, shoesPeasant 0
tunic, skirtPeasant 1
robePeasant 1, dyed mage blue
leather tunic / leggings / sleeves / gloves / cap, boots, cloakRanger 0 (cap = hood, cloak = pauldron)
chainmail tunic, plate helmRanger 0, dyed steel
wizard's hatRanger 1 hood, dyed mage blue

Weapons, shields, gorgets, aprons and jewelry are not drawn yet (no meshes in the pack). A test checks that every catalog item in a drawn slot has a part and no other item has one, so new garments must get an entry.

Demo player. Offline, the player is created like a new UO character (NewCharacter::build: Str 45 / Dex 25 / Int 10, Lumberjacking 50, Swordsmanship 30, Tactics 20; demo::demo_player_sheet). They wear starter leathers, boots and a leather cap over a shirt and long pants, a cloak (+5.0 Stealth, +2 Dex), a gold ring (+5 Str, +10 % Lumberjacking) and a hatchet. The backpack holds spare clothes and armor, a longsword, a buckler and a bag with logs, ore and a bracelet (+3 Int). Their look is the Male Ranger from before, now derived from the items.

Debug windows (crates/uo-client/src/character_ui.rs, key C, --character-windows to start open). Plain bevy_ui, rebuilt only when the sheet or window state changes:

  • Paperdoll: every equipment slot with its item, the region it shows in (or "hidden" under an outer layer), and an off button. Below it, what each appearance region shows (item, outfit, palette, dye).
  • Backpack: carried weight against carry capacity, then the container tree with each item's weight including its contents, item counts against the container limit, a magic tag for instance modifiers and a wear button.
  • Status: name and title, stats as base → effective, vitals, armor rating, weapon, and skills as base → effective. Every modifier is listed under the value it changes with its source item or effect. bless (+10 all stats) and curse (−10 % all stats) toggle test effects.
  • The last action's result is shown in green, or its error in red, e.g. the plate helm's Str 80 requirement or the buckler blocked by the two-handed hatchet.

Clicks on a window don't walk the player. --unequip body,head takes items off once the player is assembled, which tests re-dressing in --screenshot runs.

Online players still spawn as Male Peasants. They have no sheet until the wire protocol carries characters (docs/character-system.md › Future work).

Animation (crates/uo-client/src/anim.rs)

All characters share one AnimationGraph with the 31 clips. Each character's armature gets an AnimationPlayer and AnimationTransitions. Every frame:

  • Locomotion. When the smoothed ground speed (from SpriteView movement) is above 0.25 tiles/s, the clip with the closest native speed (by ratio) is chosen: Walk, Jog or Sprint. It plays at speed / native speed (clamped 0.4–2.5), so feet don't slide. The player walks at 3.5 tiles/s, which picks Jog at 0.65×. The strolling villager walks at 1.2 tiles/s, which picks Walk at 1.23×.
  • Standing. Otherwise the character's Activity clip plays.
  • Crossfades. All clip changes crossfade over 0.15 s. Loops repeat; Hit and Death play once.
game actionActivityclip
standingIdleIdle_Loop
standing, torch / lanternIdleTorch / IdleLanternIdle_Torch_Loop / Idle_Lantern_Loop
walking / running(speed)Walk_Loop / Jog_Fwd_Loop / Sprint_Loop
gather: logs (Gather on a tree node)ChopTreeChopping_Loop
gather: crops, plantsHarvestFarm_Harvest
craft / repairCraftFixing_Kneeling
combat stance / attack / blockSwordIdle / Attack / BlockSword_Idle / Sword_Regular_A / Sword_Block
castCastSpell_Simple_Idle_Loop
hit / dieHit / DieHit_Chest / Death01
talk / sit / dance / yes / noTalk / Sit / Dance / Yes / NoIdle_Talking_Loop / Sitting_Idle_Loop / Dance_Loop / Yes / Idle_No_Loop
use / pick up / open / drinkInteract (+ clips PickUp_Table, Chest_Open, Consume)Interact / …
swimmingSwimSwim_Idle_Loop (Swim_Fwd_Loop imported)
  • Clips not wired yet: Spell_Simple_Enter/Shoot/Exit, Sword_Attack, PickUp_Table, Chest_Open and Consume are imported for one-shot actions that the server will trigger.
  • Server events (future, no protocol change): a Gathered event (or the reply to gather) sets Activity::Chop for logs (item 1) or Harvest for the gatherer for a few seconds. Crafted sets Craft. Combat events map to Attack/Hit/Die.

The demo village:

  • the player stands with a torch;
  • a Ranger chops the tree next to them;
  • a Peasant kneels mending at the campfire;
  • a farmer harvests;
  • a bearded villager practises with a sword in the east wall's shadow;
  • a Ranger dances;
  • two villagers talk or nod;
  • one strolls along the south wall (Walk).

--crowd N adds N animated villagers; --lineup shows four rows of the 8 facings: Male Ranger, Female Peasant, and two bare-skin seam checks (a dark-skinned Male Peasant with bare arms and feet, a Female Ranger with bare torso and feet).

Readability (pixel-art look for mobiles)

Root cause of the darkness. The colours are correct. A flat albedo render of a Ranger averages sRGB (69, 61, 35); the source T_Ranger_BaseColor.png averages (66, 65, 37). Peasant is (71, 55, 35) before and after downscaling, and the skin map is (156, 109, 78).

  • Ruled out:
    • No double gamma: the PNGs are sRGB and loaded as sRGB.
    • No AO leak: Bevy reads only G = roughness and B = metallic from the ORM, and the AO channel is unused.
    • No mip averaging: the textures have no mipmaps.
  • What was wrong: the vertex colours, now stripped at import.
  • What remains: the pack is authored dark for a brightly lit 3D scene (mostly black leather and deep green cloth). So ALBEDO_GAIN is back to 1.0, and readability comes from an explicit character look.

The character look lives in CharacterLook (characters.rs), applied in shaders/toon_mesh.wgsl when character = 1:

constantvalueeffect
LIFT0.2sRGB lift of dark albedo, so black leather and deep green separate
POSTERIZE_LEVELS6value levels in sRGB, hue kept (per-channel steps turned browns pink)
SATURATION1.12mild boost after posterising
MIN_LIGHT0.6light floor (× albedo) in deep shade and at night ("character light"; UO mobiles were always readable); tops the light level up by luminance and takes the phase's dim tint, so it is moonlit blue at night and adds nothing under firelight (#95)
RIM0.45hard rim on texels facing ≥ ~70° away from the camera
  • Ranger colourway: the Ranger defaults to the pack's brown colourway T_Ranger_3. The original deep green vanishes on grass (palette 1 is the green).
  • Hair: hair and eyebrow textures are grey in the pack and are tinted per character (hair_color; presets dark brown, auburn, blond, black).
  • Skin: base skin (head and bare parts) is not lifted, so the eyes and brows stay dark against it. The outfits' own skin texture (arms, hands) only exists in the dark tone; light-skinned characters tint it by LIGHT_SKIN_TINT to match the light base skin texture.
  • Specular: only texels with metallic > 0.6 glint (buckles, pauldrons). The pack's ORM has faint metal everywhere, which caused sparkles.
  • Sampling: nearest; the small textures have no mips.
  • Outline tag: characters write normal-prepass alpha 2/3 (props 1/3, terrain 1). The outline pass then draws them at half the darkening and at most 30 % of the covered pixel's luminance, i.e. a darker, higher-contrast line than props.
  • Ground sparkles near characters: the terrain's hard glint lit mid-rough ground texels under torches. It is now limited to water and texels with roughness < 0.3.

What's missing

  • Faces read as a few skin pixels at 32-px tiles, 4–8 texels across; hair mass and skin tone do most of the work. Eyes are 1–2 texels at best.
  • No carried props yet: no torch, sword or axe meshes in the hands. The clips mime them.
  • No head-only clips for the hood: the hood and hair rest on the head bone as authored.
  • Online appearance is fixed (Male Peasant): online players have no CharacterSheet until the wire protocol carries characters.
  • Garments are placeholders: two outfits with colourways and dyes stand in for every item.

Numbers

triangles (assembled)skinned meshes
Male Ranger (hood)≈ 11k4–5
Female Ranger (hood)≈ 12k4–5
Male / Female Peasant (hair)≈ 8.5k5–6

All performance numbers below are headless, at 2560×1600 and zoom 4, with all effects on. The machine was shared with other builds (load 7–25), so expect ±1 ms; the steadiest runs are quoted.

scenems/frame
sprite mobiles5.4
village: 9 animated characters5.9–6.2
village + 41 crowd = 50 animated characters6.8
all effects off: sprites / 9 characters / 50 characters4.5 / 4.2–5.9 / 4.9–7.3
  • Before merging, 8 static characters with 10–15 skinned meshes each cost 7.1–7.6 ms. Merging to 4–6 skinned meshes removed most of the fixed cost, even with animation sampling added.
  • Going from 9 to 50 animated characters costs about +0.7–0.9 ms in total, so animation sampling and skinning scale fine.
  • Texture memory: about 3 MB GPU (outfits 256², skin 128², heads 256², hair 128², eyes).