Saints Row: The Third · Steam PC · DX11 · 32-bit

How Saints Row: The Third builds characters

Two full-body meshes and about 115 named morph targets. Every hair style and garment carries its own copy of those targets. The texture is rebuilt at runtime from a skin shift plus stacked 2D layers, and lit through "shader balls". On top sits a clothing system with 572 dyeable items. NPCs use none of it.

110
sliders in 12 sets, ~115 morph targets
7977
vertices shared by both bodies, head included
572
items in 21 slots, 3 dyes each from 126 colours
321
composite layers in 29 slots
223
ambient NPC types, all authored

1 The whole pipeline

PRESET
Gender · race · figure
Picks cm_body / cf_body, resets every slider, applies the race grid and the figure.
SLIDERS
110 values in 0…1
From the UI, the body triangle or Lua rand_float. Clamped to ≥ 0.0001.
KEYS
Piecewise ramps
Each key maps a slider to one morph target's weight through Full / Nill / Neg values.
MESHES
CPU deltas, everywhere
int16 deltas × weight × scale go into the body and every worn hair and clothing mesh.
TEXTURES
Runtime composite
Skin HSB shift, then eyes, brows, beard, makeup and tattoo sprites into a 2048×1024 target. Normal maps are blended from five.
SHADING
Matcap × lights
ir_sr3pcskinfull: composite × shader ball × inferred-lighting buffer.
The key idea
Saints Row treats the player as a dress-up doll whose proportions everything else follows. Because the hair and clothing meshes carry the same morph targets as the body, a fat, tall or big-headed character still fits every item. The surface detail (makeup, tattoos, beards, eyes) is never geometry. It's all painted into one texture.

2 Where the data lives

Everything is in packfiles/pc/cache/*.vpp_pc (version 6 packfiles, zlib-compressed). Tables are XML (.xtbl), and names are stored as a Volition CRC: the reflected CRC-32 table with init 0 and no final xor, over the lower-cased string.

PackfileContents
misc_tables.vpp_pcAll tables: player_creation.xtbl (sliders), presets, skin and hair colours, customization_items.xtbl, customization_compositing.xtbl, NPC definitions and palettes
customize_item.vpp_pcThe player bodies and all 1151 item meshes, each with its _pc.cmorph_pc morph file and textures
customize_player.vpp_pcComposite layers, shader balls, body diffuse, mask and normal maps
characters.vpp_pcOne container per NPC: body mesh, head morph, head material library
shaders.vpp_pcDXBC shaders, decompiled to GLSL for this study
patch_uncompressed.vpp_pcThe creator UI as plain Lua: store_character.lua, vdo_triangle_select.lua
Male body, female body and the NPC base head
The two player bodies and the NPC base head. Both bodies have the same 7977-vertex topology. Each has a full "other gender" morph target.

3 Sliders, keys and morph weights

player_creation.xtbl holds 110 sliders in 12 sets. A slider doesn't map to one shape. It has keys, and each key names a morph target and three numbers. FUN_009f65c0 turns slider value s into a weight per key:

t  = clamp((s − Nill) / (Full − Nill), 0, 1)
t2 = clamp((s − Nill) / (Neg  − Nill), 0, 1)      // only if Neg_Value is present
if t2 > t:  t = −t2                               // the sign flip at 0x9f6758 (missing from Ghidra's decompile)
w  = Morph_Scalar · t
Try it: any of the 110 sliders
Curves show each key's weight over the slider's range. The band is where the creator's Randomise can land (0.5 ± Rand_Range). Body sets and race morphs aren't randomised.

Sliders per set

Applying the weights

For each slider in table order, each key and each record of that key's target, the deltas are accumulated in exactly this form:

pos[v] = f32((double)q / 32767.0 · w · A + pos[v])   // SSE2, per axis
nrm[v] += ((byte/255)·2 − 1) · w · B

The creator keeps old and new values per slider and applies only the difference. The same weights go to every worn hair and item mesh, because each has its own .cmorph_pc with the same target CRCs. A target has one block per vertex buffer of its mesh. Getting that wrong is what made the demo's headset throw a vertex across the screen.

4 Presets, the body triangle and Randomise

The Gender, Race and Figure buttons call pcr_apply_preset, which resets every slider to its Initial_Value, applies the race grid (e.g. male_asian: asian = 1, body fat = 1) and the figure, then sets skin, hair and default layers. Race is just another morph target, set to 1.

Race presets and randomised faces
Top: the eight gender × race presets. Bottom: faces from the creator's Randomise (seed 2026), drawn untextured to show the geometry.
Try it: the body-type triangle
A direct port of vdo_triangle_select.lua. Drag inside the triangle: the left corner is skinny, the right is fat and the top is muscle. Each value is a normalised path length through the centre point.

Randomise

The creator's Randomise is plain Lua: for each face set it calls pcr_apply_morph_value(set, id, rand_float(0.5 − Rand_Range, 0.5 + Rand_Range)), then picks a random eye layer and some face features. Every random draw in the game reads from one engine-wide table of 8192 MT19937 outputs, refilled from a clock seed at start-up. So a session can't be replayed, but a given seed is fully deterministic. The demo uses a seed you type in.

5 Normal maps follow the body

Skin detail (muscle definition, fat folds, age) isn't geometry. player_creation_normal_maps.xtbl defines weights for Base, Skinny, Fat, Muscle and Age as products of piecewise curves over the body sliders. The blend pass then sets fat = fat^¼ and base = max(0, 1 − skinny − fat − muscle). It mixes five race- and gender-specific DXT5nm maps into one render target with the cust_normal_map_blend pixel shader:

n.xy = Σ −wᵢ · ((2a−1)(2r−1), 2g−1)ᵢ     // decode each DXT5nm map
if |n.xy| > 1: normalise;   n.z = √(1 − |n.xy|²);   out = (n + 1) / 2

For female_black + average: base 0.25, skinny 0.25, fat 0.5 (0.0625¼), muscle 0.

6 Skin and the composite texture

The body's diffuse texture is rebuilt into a 2048×1024 render target whenever the look changes (FUN_009fa8e0), in two passes:

INPUT
Base diffuse + mask
M_ideal_d.tga and PC_body_standardmask.tga, in body UV space
PASS 1
Skin HSB shift
RGB → HSV, add the skin entry's HSB offset (+ the "unmasked" offset × mask), clamp, HSV → RGB. Black mask areas (eyes, teeth) are untouched.
PASS 2
Layer sprites
Every active layer, in ascending Layer order, at its x/y, tinted if Colorizable, with alpha × opacity.
OUTPUT
One texture
The body and the eyeball mesh both sample it. The eye is a 256×256 painting.
Body mask and base diffuse in UV space
The body mask (left) and base diffuse (right), in UV space. The mask's channels scale the "unmasked" HSB offsets, so some areas shift more than others.

55 skin colours

Each entry is an HSB offset, not a colour. These swatches apply each offset to the median skin texel of the base diffuse, so they're approximate. Lighting and the shader ball change the final look a lot.

Layers

Layers come from customization_compositing.xtbl: 321 selectable entries, with at most one per slot. Colourable layers take a colour from the 117-entry makeup pool. Tattoos use the makeup pool too: tattoo_color_pool exists but isn't read on this path.

Makeup pool (117)

The skin shader

n      = tangent-space normal from the blended Normal_Map (2nd UV set)
uv_sb  = view-space n.xy · 0.5 + 0.5
matcap = mix(Sphere_Map_1(uv_sb), Sphere_Map_2(uv_sb), Blend_Map(uv0).r)
out    = L_diffuse · matcap · (composite · Diffuse_Color) · 2 + L_specular · Specular_Alpha · Specular_Color
Skin and hair shader balls
Shader balls (matcaps). A lit-sphere picture stands in for the skin's diffuse response. Each skin colour entry names one. The colour itself comes from the composite.

7 Hair

Hair is an ordinary clothing item in the head hair slot (51 styles), with its own morph targets so it follows the head. Colour isn't a texture tint. ir_sr3pchair builds it per pixel from the Dob map (B = strand brightness b, G = opacity, R = specular) and four parameters:

sat = sat_dark + (sat_light − sat_dark) · b
val = b · Brightness + bias
rgb = HSV(Hue / 60, sat, val)  +  specular(bright ↔ dark by b)

35 hair colours. Each swatch is HSV(Hue, Saturation_light, Brightness), the colour of the brightest strand (b = 1) before lighting.

Most hair styles have a second wear option (cm_pulledintohat and similar) whose required flag is hat active. Put on a cap and the hair switches to a squashed mesh.

8 Clothing and accessories

Items per slot (both genders; 33 are DLC).

Every item has male and female meshes, each carrying the body's morph targets, and up to three dye colours from the 126-colour clothing pool. It also has three mechanisms that let items coexist:

  • Obscured slots. A full suit hides the bra, underwear, gloves, hat and so on.
  • Obscured VIDs. Long sleeves hide the body's arm submeshes, so skin can't poke through.
  • Wear options with flags. An item sets flags (hat active, hood down, glasses on face…), and other items pick the first option whose required flags are set.

Dyeing: three colours through a pattern mask

P      = Pattern_Map(uv1 · (TilingU, TilingV))                   // second UV set, often a tiny tiling mask
colour = lerp(lerp(lerp(1, primary, P.b), secondary, P.g), tertiary, P.r) · Diffuse_Map(uv0) · Diffuse_Color
Try it: the clothing dye equation
The real PAT_stripes04.tga (32×32: red, green and blue stripes), tiled 4×4 and dyed with colours from character_color_pool.xtbl through the shader's formula. A white diffuse is assumed. The pool colours look dark because the shader multiplies by the light buffer × 2, so on screen they read about twice as bright.
Clothing pool (126)
Clothing tab of the demo with the Astro A40 headset
The clothing tab with the Astro A40 Saints headset (a two-vertex-buffer mesh) and the hidden slots and VIDs the outfit produces.
Three Crooks and Castles shirt textures
Item textures. One diffuse (left) and two NPC-style "pattern" textures, where the blue, green and red channels select the three dyes.

9 NPCs: a different system

Ambient NPCs don't use sliders, presets or the composite. Each character_definitions.xtbl entry is a hand-made body mesh plus heads. A head is the shared npc_basehead (1426 vertices) plus one morph target, named after the head, at weight 1. Per spawn (FUN_00950660, in this RNG order):

  1. Palette: rand_int a primary colour from the character's palette tree, then a secondary under it, then a tertiary.
  2. Props: for each prop group, roll against its chance, then pick an item and a sub-item. A prop is usually a VID of the NPC's own mesh: hood up or down, hair, an iPod.
  3. Head: rand_int over the definition's heads.
  4. Hair tint: rand_int over the head's list of hair shader balls. Hair is painted into the head texture and coloured through the matcap.
Seven NPCs spawned with seed 3
Seven ambient NPCs (seed 3) drawn by the demo with the game's spawn order: palette, props by VID, head, hair ball.
Saints NPCs spawned with seed 70
Saints gang members (seed 70). The purple comes from the palette tree, which is why a gang reads as a gang.

223 spawnable NPC types, from 183 definitions. Which type spawns where (spawn groups, districts, time of day) isn't part of this study. There's no random height: Variation is 0 and Height 1.0 for every shipped NPC.

10 How much variety

CategoryOptions
Gender × race presets, figures8 · 6 per gender
Morph sliders110, continuous
Skin colours55
Hair styles × colours51 (+ bald) × 35
Eyes · eyebrows · facial hair14 · 12 · 27 (× 117 colours)
Face features · face items · lips · makeup slots25 · 7 · 2 · 11, 8, 6, 9 (× 117 colours, opacity)
Tattoos199 in 18 areas (opacity)
Clothing and accessories572 items in 21 slots, 3 dyes each from 126 colours
Ambient NPC types223

The top bar ignores the sliders (≈10208 discrete combinations, matching the demo's Variety tab). The bottom bar adds the sliders at their UI step size. Clothing dyes alone are 126³ ≈ 2 million colourings per item.

11 Known gaps in the re-implementation

  • Lighting. The demo replaces the inferred-lighting buffer with a key light, a fill light and ambient. The material maths follows the shaders.
  • Eyes, lashes, hair specular are approximated, not ported.
  • Clothing normal maps, decals, cloth simulation and glow animations aren't done.
  • Two hair textures are drawn opaque. "Off The Shoulder" and M_PlayerMedium have a copy of the brightness in the opacity channel. Read literally, they'd be see-through.
  • Wear-option resolution is my reading of the flags. The game function isn't decompiled yet.
  • Handedness. The game is left-handed (Direct3D), so the demo mirrors X so that text on clothing reads correctly.
  • Not yet compared side by side with the running game.
Saints Row creator screenshot
The study's Saints Row creator (open it): every slider, item, layer and NPC, loaded on demand.