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 intextures/. 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_0into 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) andREADME.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:piece regions triangles (M / F) Bodytorso 684 / 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 hands 1038 / 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.jsonholds 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.
--lineupshows 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, orNonefor 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 useFeet_Calves: outfit trousers end above the ankle, and the calves they leave would otherwise be missing. - A
Partis the pack outfit, its colourway (palette0/1) and adye(linear multiplier of the outfit texture in percent, 100 = as authored). Parts of one character may differ, e.g. Ranger leathers over Peasant trousers. headisHair(style)orHood(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_skinandwith_beardvary the look.- Characters with a
CharacterSheetget their appearance from their worn items instead (see Items → appearance).
- Each outfit slot holds a
- 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
CharacterAppearancechanges, 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.Assembledand theAnimatorare removed meanwhile; the new armature gets a freshAnimationPlayer, so the current clip restarts. - Animation targets. Joints get
AnimationTargetIds built from their name path underArmature, which is the path the glTF loader uses for the clips (Armature/root/pelvis/…). They also getAnimatedBy(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:
| region | equipment slots, outermost first | when empty |
|---|---|---|
| body | robe, tunic, body (chest armor), shirt | bare skin |
| legs | skirt, legs (leg armor), pants | bare skin (briefs) |
| feet | feet | bare skin |
| arms | arms (sleeves), hands (gloves) | bare skin |
| head | head | hair (BodyLook::hair) |
| acc | cloak | nothing |
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_Calvesfixes it.
Item parts (item_part, placeholder art: two outfits and their
colourways stand in for every garment):
| items | part |
|---|---|
| shirt, long pants, shoes | Peasant 0 |
| tunic, skirt | Peasant 1 |
| robe | Peasant 1, dyed mage blue |
| leather tunic / leggings / sleeves / gloves / cap, boots, cloak | Ranger 0 (cap = hood, cloak = pauldron) |
| chainmail tunic, plate helm | Ranger 0, dyed steel |
| wizard's hat | Ranger 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
SpriteViewmovement) is above 0.25 tiles/s, the clip with the closest native speed (by ratio) is chosen: Walk, Jog or Sprint. It plays atspeed / 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
Activityclip plays. - Crossfades. All clip changes crossfade over 0.15 s. Loops repeat; Hit and Death play once.
| game action | Activity | clip |
|---|---|---|
| standing | Idle | Idle_Loop |
| standing, torch / lantern | IdleTorch / IdleLantern | Idle_Torch_Loop / Idle_Lantern_Loop |
| walking / running | (speed) | Walk_Loop / Jog_Fwd_Loop / Sprint_Loop |
gather: logs (Gather on a tree node) | Chop | TreeChopping_Loop |
| gather: crops, plants | Harvest | Farm_Harvest |
| craft / repair | Craft | Fixing_Kneeling |
| combat stance / attack / block | SwordIdle / Attack / Block | Sword_Idle / Sword_Regular_A / Sword_Block |
| cast | Cast | Spell_Simple_Idle_Loop |
| hit / die | Hit / Die | Hit_Chest / Death01 |
| talk / sit / dance / yes / no | Talk / Sit / Dance / Yes / No | Idle_Talking_Loop / Sitting_Idle_Loop / Dance_Loop / Yes / Idle_No_Loop |
| use / pick up / open / drink | Interact (+ clips PickUp_Table, Chest_Open, Consume) | Interact / … |
| swimming | Swim | Swim_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
Gatheredevent (or the reply togather) setsActivity::Chopfor logs (item 1) orHarvestfor the gatherer for a few seconds.CraftedsetsCraft. Combat events map toAttack/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_GAINis 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:
| constant | value | effect |
|---|---|---|
LIFT | 0.2 | sRGB lift of dark albedo, so black leather and deep green separate |
POSTERIZE_LEVELS | 6 | value levels in sRGB, hue kept (per-channel steps turned browns pink) |
SATURATION | 1.12 | mild boost after posterising |
MIN_LIGHT | 0.6 | light 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) |
RIM | 0.45 | hard 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_TINTto 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
CharacterSheetuntil 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) | ≈ 11k | 4–5 |
| Female Ranger (hood) | ≈ 12k | 4–5 |
| Male / Female Peasant (hair) | ≈ 8.5k | 5–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.
| scene | ms/frame |
|---|---|
| sprite mobiles | 5.4 |
| village: 9 animated characters | 5.9–6.2 |
| village + 41 crowd = 50 animated characters | 6.8 |
| all effects off: sprites / 9 characters / 50 characters | 4.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).