Kenshi 1.0.68 · x64 · OGRE

How Kenshi builds characters

One shared base mesh deformed by morph targets, a skeleton whose bones are scaled one by one, and a swappable head texture tinted by a skin ramp. The player and the NPCs use the same code. Generic NPCs just get their numbers from a seeded random generator.

30
race records with editor limits · 14 playable
23 / 29
morph targets on the human male / female mesh
30
Biped bones, shared by every race
1078–1092
distinct slider settings (Greenlander male–female)

1 The whole pipeline

Everything that makes a Kenshi face or body passes through these stages. Each one is a function in kenshi_x64.exe that was decompiled and re-implemented. The addresses are on the method page.

DATA
Race record + editor XML
GameData RACE record names the mesh, heads, hairs and editor_data_<race>.xml, which lists every slider.
VALUES
Slider values
Integers from the editor, a .bod2 file or the randomiser. Pose sliders store weights, not values.
SHAPE
Morphs baked
weight = value × 0.01. softwareVertexPoseBlend adds the offsets to a private mesh copy at spawn.
PROPORTIONS
Bone sizes + posture
updateProportions scales 30 bones. Three sliders pick a time in posture clips.
SURFACE
Shader
Body + head texture are added together, darkened by the skin ramp, and painted hair and stubble are mixed in.
EXTRAS
Hair & beard meshes
Separate entities that share the body's skeleton. Head morphs don't reach them.
The key idea
The player and every NPC go through exactly this pipeline. The only difference is where the slider values come from: the editor, a saved .bod2, or the MSVC rand() seeded from a hash of a string. That string is what limits the NPC faces (§9).

2 Base assets

Each race and gender has one skinned mesh with the head built in. The morph targets (OGRE "poses") are stored in the mesh and only touch head vertices. All 30 races share the same two 30-bone 3ds Max Biped skeletons (Bip01 …, no face bones). That's why body proportions and posture work identically for a human, a Shek, a Hive drone or a skeleton robot.

Eight races rendered by the re-implementation
Eight of the 30 races: Shek (F), Skeleton, Hive Prince, Fishman, Cannibal (F), MkII Screamer, Scorchlander, Hive Queen. All come from the same pipeline. Only the mesh, the morph list and the textures differ.
AssetFileNotes
Body + head meshcharacter/meshes/human/human_male.mesh15,504 vertices, 23 poses. The female has 16,614 vertices and 29 poses.
Skeletonmale_skeleton.skeleton30 bones, 174 animations, including the postures, neck set and shoulder set clips
Editor limitseditor/editor_data_human.xmlSlider list, ranges, pose targets and the skin ramp
Head texturesskins/human_male/*_head_*diffuse_HI.dds + normal + mask4 per gender for Greenlanders, weighted equally
Body textures*_body_diffuse.dds, *_body_normal{,_muscular,_skinny}.ddsThe normal map blends with physique
Hair, beardscharacter/hair/meshes/*.mesh + painted *BaseHead*.ddsATTACHMENT records, slot 2 = hair, slot 13 = beard
Shadermaterials/deferred/character.hlslmain_fs
Four Greenlander male head textures
The four Greenlander male head textures. The head is a separate UV island (v ∈ [−1, 0]) whose texture is added to the body texture, so the head can be swapped without touching the body. The eye sits in the corner of the same sheet.
All 30 races (sliders = face (morph targets) / body; per-gender counts)
RacePlayableGendersMeshEditor limitsSlidersHeadsHairsBeardsColours
Alpha Fishman (rebirth.mod)yesonefishmaneditor_data_Fishman.xml23 (17) / 161100
Alpha Fishman (Newwworld Plus.mod)yesonefishmaneditor_data_Fishman.xml23 (17) / 161100
Fishmanyesonefishmaneditor_data_Fishman.xml23 (17) / 161100
Flayed ManyesM/Fhuman_maleeditor_data_human.xml23 (17) / 163/342/42326
Flayed WomanyesM/Fhuman_maleeditor_data_human.xml23 (17) / 163/342/42326
GreenlanderyesM/Fhuman_maleeditor_data_human.xml23 (17) / 164/442/42326
Hive Princeyesonestick_personeditor_data_stick_prince.xml13 (8) / 161000
Hive Soldier Droneyesonestick_person3editor_data_stick_soldier.xml13 (8) / 161000
Hive Tall Headyesonestick_person2editor_data_stick_people.xml13 (8) / 161000
Hive Worker Droneyesonestick_person2editor_data_stick_people.xml13 (8) / 161000
King Fishmanyesonefishmaneditor_data_Fishman.xml23 (17) / 161100
ScorchlanderyesM/Fhuman_maleeditor_data_sundemon.xml23 (17) / 161/142/42261
ShekyesM/Fbone_maleeditor_data_bone_people.xml27 (21) / 163/31/102
Skeletonyesoneroboteditor_data_human.xml23 (17) / 161000
CannibalM/Fhuman_maleeditor_data_cannibal.xml19 (13) / 161/11/105
Cannibal SkavM/Fhuman_maleeditor_data_cannibal.xml19 (13) / 161/11/105
Deadhive Princeonestick_personeditor_data_stick_prince.xml13 (8) / 161000
Deadhive Soldieronestick_person3editor_data_stick_soldier.xml13 (8) / 161000
Deadhive Workeronestick_person2editor_data_stick_people.xml13 (8) / 161000
Hive Prince South Hiveonestick_personeditor_data_stick_prince.xml13 (8) / 161000
Hive Queenonehive_queeneditor_data_stick_queen.xml13 (8) / 161000
Hive Soldier Drone South Hiveonestick_person3editor_data_stick_soldier.xml13 (8) / 161000
Hive Worker Drone South Hiveonestick_person2editor_data_stick_people.xml13 (8) / 161000
P4 Unitonerobot_variant02editor_data_human.xml23 (17) / 161000
Screamer MkIonerobot_variant01editor_data_human.xml23 (17) / 161000
Skeleton Log-Head MKIIoneskeleton_mkii - log headeditor_data_mkii.xml5 (0) / 161000
Skeleton MKII Screameroneskeleton_mkii - screamereditor_data_mkii.xml5 (0) / 161000
Skeleton No-Head MkIIoneskeleton_mkii - no headeditor_data_mkii.xml5 (0) / 161000
Skeleton P4MkIIoneskeleton_mkiieditor_data_mkii.xml5 (0) / 161000
Soldierbotonerobot_variant03editor_data_human.xml23 (17) / 161000

3 Sliders and what they drive

AppearanceManager::loadEditorXMLData reads each <Config gender> and <Category> and sorts the entries into three kinds:

XMLKindStoresExample
<Range> without targetbone slideran integer, e.g. 90–110Head size, Neck, Jaw, Height, Chest…
<Range target=… [target_opposite=…]>morph sliderweights under the pose namesNose tilt → tiltup_nose / tiltdown_nose
<ColourRange>colour rampan RGB colourSkin Tone, 10 units per colour stop

Missing attributes get defaults: mid = the rounded midpoint, and random_variation = 40 % of the range (GLOBAL CONSTANTS appearance random deviation percentage = 0.4). Many morph sliders don't centre on 0. For example, Greenlander male Eyes height runs from −150 to 100 with mid −25.

// DataRangePose: slider value v (an integer percentage) → pose weights
if no opposite:  w[target]   = v × 0.01            // may be negative: the offset is subtracted
else:            w[target]   = max(v, 0)  × 0.01
                 w[opposite] = max(−v, 0) × 0.01
Try it: slider value → morph weights
Greenlander male face sliders, with their real ranges, mids and random variation. The shaded band is where the editor's Randomise lands (mid ± variation).

4 Morphs are baked, not animated

When a character spawns, AppearanceManager::createMorphedMesh clones the base mesh and calls Ogre::Mesh::softwareVertexPoseBlend(w, offsets) once per non-zero pose. That adds w × offset to every vertex in the pose. Then it deletes the poses and switches the entity to the copy. Nothing is morphed per frame.

Consequence: normals stay the same

The human poses have no normal offsets. A strongly morphed nose is lit with the base nose's normals. The game never recomputes them, so the demo doesn't either.

Consequence: meshes can be shared

A baked mesh has a name, and anyone with the same name reuses it. Generic NPCs exploit this: everyone with the same race, faction weirdness, gender and morph index shares one mesh (§9).

The live editor works on an unbaked _CHAREDIT_ copy and applies the difference (new − old) each time a slider moves.

5 Bone sizes: the body sliders

Kenshi's OGRE fork adds two fields to every bone, and AppearanceHuman::updateProportions sets both on all 30 bones in one pass:

Bone size

Multiplies only that bone's own derived scale. Children don't inherit it. Widening the chest doesn't widen the arms.

derivedScale(b) = boneSize(b) ⊙ localScale(b)

Positional size

Scales the bone's offset from its parent, combined with the parent's own size along Y. That's how Neck length lifts the head and Chest pushes the arms outwards.

derivedPos(b) = derivedPos(p) + orient(p)·(size(p).y × posSize(b) ⊙ localPos(b))
Why the two sizes are separate. Neck length scales the neck bone along Y. The head isn't stretched because bone size isn't inherited, but it moves up because its offset is scaled by the neck's Y size.

The function also folds in two physique factors that grow with the character's stats: muscle (up to ×1.27 on arms and legs) and thinness (down to ×0.4 on breasts). Both are 0 for a new character. An excerpt, all in float32:

hgt  = Height/100        fr = Frame/100        leg = Leg length/100
size(Pelvis)   = (hips·fr, hgt, hips·fr)
size(Spine2)   = (((chest−1)·0.45+1)·fr, hgt, ((chest−1)·0.9+1)·fr)
size(UpperArm) = (ab·fr, hgt, ((ab−1)·1.5+1)·fr)        ab = Arm bulk/100 · lerp(thin,1,0.7) · lerp(muscle,1,1.27)
pos(UpperArm)  = (Shoulders/100 · s2x, 1, 1)            ← the chest pushes the shoulders out
size(Neck)     = (Neck width·s60·m13·fr, Neck length, Neck·s70·fr) / 100
size(Head)     = (headFr·hs·Head shape/100, hs, headFr·hs)   headFr = (fr−1)·0.25+1
Head and neck bone sliders at extreme values
Head and neck bone sliders at their limits, front and side. Head shape only widens. Neck length raises the whole head.
Greenlander female default and with every body slider at max
Greenlander female: defaults (left) and every Body slider at maximum, plus muscle = 1 (right). Every proportion change is a bone scale. There are no body morph targets.

6 Posture is an animation frame

Posture, Shoulder set and Neck position aren't scales. AppearanceHuman::setPosture enables one skeleton animation per slider at weight 1 and parks it at time = length × value / 100:

SliderClipLengthKeysWhat the keys do
Posturepostures0.167 s6Upright → hunchback: bends the spine, neck and shoulders
Shoulder setshoulder set0.067 s3Shoulders back ↔ forward
Neck positionneck set0.067 s3Head forward ↔ back

Each clip has a track on all 30 bones. The key at 0.033 s is the identity. OGRE applies a track by adding its translation and post-multiplying its rotation, with keys interpolated linearly (nlerp). It's a neat trick: artists author a posture range as an animation, and a slider scrubs through it.

Not reproduced exactly
In game these clips play alongside the idle and stance animations. The skeletons use the "average" blend mode, so the weights are normalised against those animations. The demo poses a bind-pose body with the three clips alone, so extreme values probably look stronger than in game.

7 Skin, head textures and the shader

Everything about the surface happens in one pixel shader, character.hlsl: main_fs:

body  = tex(diffuse, uv) + tex(headDiffuse, uv + (0,1))       // head island added on
mask  = tex(mask, uv).r  + tex(headMask, uv + (0,1)).r         // r = skin (eyes and underwear excluded)
body.rgb *= 1 − (1 − skinTone) · mask                           // skin tinting
body.rgb  = lerp(body.rgb, hairColour · hairTex, hairAlpha)     // painted hair
body.rgb  = lerp(body.rgb, hairColour · beardValue, beardValue) // stubble
glow      = headMask.g                                          // Scorchlander eyes

The skin tone is a 1-D ramp. The slider runs from 0 to (stops − 1) × 10, and every 10 units is one colour stop, linearly interpolated (DataRangeVector::getValue).

Try it: the Greenlander skin ramp
The six stops from editor_data_human.xml. The right-hand swatch multiplies the ramp colour onto a sample head-texture pixel, the way the shader does where the mask is 1.

Hair colour

Hair colour is HSB, stored as three 0–100 floats. It's shared by the painted hair layer and the hair mesh. The randomiser picks one of the race's weighted presets and converts it from ARGB:

8 Hair and beards

An ATTACHMENT record (slot 2 = hair, 13 = beard) has up to two parts:

A painted layer

head texture goes into the body shader's hair or beard map, sampled in head UV space. One-hot channel selectors pick which RGBA channel is the colour and which is the coverage. This gives stubble and shaved heads.

A mesh

Created as its own entity, attached to the body node, and given shareSkeletonInstanceWith(body). Head, neck and jaw bone sizes move it, but face morphs don't. A big jaw morph can push through a beard. Material: alpha = dot(tex, alphaChannel); clip(alpha − rejection).

Kenshi creator: Scorchlander with hair and beard
The Kenshi demo with a Scorchlander, a hair mesh and a beard. "Bald" and "No beard" have neither part, and "Shave" only has the painted layer.

9 Randomisation and NPC faces

All randomness is MSVCR100's rand(), an LCG (s = s·214013 + 2531011, 15-bit output). U(−1,1) = rand01()·2 − 1, and every randomised slider gets round(mid + variation × deviation × U).

WhoFace shape (morphs)Everything else
Player, Randomise buttonrandomPoses(deviation = 1.0), fresh random stateHead, bone sliders, body, skin within ±40 % of mid, hair
Generic NPCsrand(FNV1a(name)) then randomPoses(deviation = faction "faces weirdness")Per NPC: head texture, hair, beard, colour, head/neck/jaw bones, body. Skin is uniform over the whole ramp.
NPC with a .bod2From the fileFrom the file

At spawn a generic NPC gets morph index = int(rand01() × morph num), which is 0–4 for humans. When its body is built, the game forms a mesh name, and only if no mesh of that name exists yet does it seed the generator with that name and roll the face:

name = race.stringID + "_" + ⌊weirdness × 10⌋ + "_" + ("Male"|"Female") + morphIndex
       e.g.  17-gamedata.quack_5_Male3
srand(FNV1a_32(name));  randomPoses(app, weirdness)  →  bake  →  cache under name
Try it: generate a generic NPC face exactly as the game does
This runs the same FNV-1a → MSVC rand() → randomiseRanges code as the demo, which is checked bit-for-bit against the Python reference over 555 faces. The bars show each morph slider's value against its range, with the slider's mid as the zero line.
Base mesh and the five generic Greenlander faces per gender at weirdness 0.5
Every generic Greenlander face at weirdness 0.5: the base mesh, then _5_Male0…4 and _5_Female0…4. These are the same in every game and every save.
The same at weirdness 1.0
At weirdness 1.0 the seeds change (the name contains _10_) and the deviation doubles.
What this means
A Holy Nation (weirdness 0.5) Greenlander man has one of exactly five face shapes. Generic NPCs look different from each other because of the per-NPC head texture, skin, hair and bone sizes on top of those shapes. It's a memory trick: five baked meshes per race, gender and weirdness, however many NPCs are on screen.

10 How much variety

Greenlander, per genderOptions
Head textures4
Hair styles42
Beards (men)32
Hair colourHSB, 6 presets
Skin tone51 values
Face sliders (morph targets)23 (17) · F 26 (21)
Body sliders16 · F 19
Generic NPC face shapes5 per weirdness

Orders of magnitude contributed by each category. Integer sliders dominate: 39 sliders with ranges like −200…200 multiply to about 1078.

11 Quirks and limitations

  1. Normals ignore morphs. Strong morphs keep the base mesh's lighting.
  2. Five faces per NPC type. The face pool is tiny and fixed by the name hash. Variety comes from other channels.
  3. The fixed-appearance mesh cache uses a truncated hash. NPCs with a .bod2 outside the player faction share meshes by a hash of each weight cast to an integer. Typical weights (|w| < 1) all hash to 0, so whichever such NPC is built first can define the face for others in that session.
  4. NPC skin is uniform over the whole ramp, while the editor's Randomise stays within ±40 % of the middle.
  5. Jaw is a bone slider for men and a morph pair for women.
  6. Robot races (Skeleton, P4 Unit, Screamer, Soldierbot) reuse editor_data_human.xml, but their meshes have no poses, so 17 face sliders do nothing.
  7. Wrong asset paths still work. Several HEAD records point at the wrong folders, and OGRE resolves textures by name anyway.
  8. Grouped morph sliders ignore deviation, although no vanilla human file uses random_group on poses.
Kenshi creator screenshot
The study's Kenshi creator (open it): all 30 races, every slider, hair, beard and colour, and the generic NPC faces.