BLANCO

Developer · blancodagoat.dev

← All parts

PED MATERIALS

GTA V / RAGE file formats › Part 6

The runtime ped recolour mechanisms read out of the shipped shaders: hair tint as a palette replacement, the component palette multiply, head overlay blending, the eye-colour blit, hair sorting and strand direction, hat fit, and what wrinkle and enveff really do.

How to read the evidence markers on this page: no marker — verified from source or a primary spec, trust it. 🟡 — community reverse-engineering, widely used but never confirmed by Rockstar. 🔴 — folklore: repeated in tutorials, plausible, untested — verify before relying on it. ✅ — confirmed in-game on a real working pack. Full legend and corrections log.

19. Ped materials: the colour laws

GTA recolours peds at runtime through four separate mechanisms: hair tint, the component palette, head overlays, and eye colour. Each was read out of the shipped shaders (common.rpf/shaders/win32_40_final/*.fxc disassembled to DXBC), not inferred from appearance, and several contradict what community tools assume. The .fxc containers are rgxe-wrapped but every embedded shader is a standard self-describing DXBC blob, so they slice out by scanning for the DXBC magic and disassemble with the stock Windows SDK fxc.exe /dumpbin. update.rpf ships no .fxc at all, so common.rpf is authoritative for shaders.

Hair tint is a palette replacement, not a multiply

The tint permutations of ped_hair_spiked and ped_hair_cutout_alpha sample the shared ramp texture and discard the diffuse RGB entirely:

albedo = pow(mp_hair_tint.Sample(float2(diffuse.r, colourIndex)).rgb, 2)

The ramp U coordinate is the diffuse red channel, not TexCoord1 and not a vertex colour. Red is the ramp coordinate, alpha is the cutout, green and blue are unused. A hair diffuse therefore looks green/dark in a viewer and is not broken: a low red channel simply is dark hair (stock hair_diff_001_a_uni peaks at red 33). Multiplying the tint colour over the diffuse, which is what most exporters do, renders every blonde as muddy brown, because multiplying a dark strand map by a light colour cannot brighten it. Only a lookup can.

The untinted variant of the same shader is line-for-line identical except it squares the diffuse instead of the palette sample. That squaring is also the colour-space answer: a shader that applies a manual gamma-2.0 curve is not reading through an _SRGB view, so the ramp and diffuse are UNORM resources and the engine linearises with x².

mp_hair_tint lives in strm_peds_mpshare.ytd, which ships three times (patchday2ng, patchday1ng, x64v.rpf) and they are not byte-identical: patchday2ng differs and later DLC wins at load, so that copy is the one to read. It is 64×64 A8R8G8B8, one gradient row per colour index (row count = colour count; the engine derives it from the texture height). Each row is a dark-to-light gradient; the engine's representative swatch column is (w>>1)+(w>>2) = 48. The same dictionary holds mp_makeup_tint (64×64, same shape) and mp_crewpalette (128×128). The engine's ramp enum is RT_NONE / RT_HAIR / RT_MAKEUP only, so those two are the complete set of true tint ramps.

The hair alpha test is exactly 128/255: the shader compares against the literal 0.501961 and discards. AnisotropicAlphaBias scales the coverage term before the test; the reference never moves.

Component palette: a multiply, gated to indices 32..160

Gloves, accessories and other ped_palette components use the same bound palette texture with a completely different law, verbatim from the disassembly:

idx = floor(diffuse.a * 255.01)
if (32 <= idx <= 160)
    rgb = diffuse.rgb * palette.Sample(float2((idx - 32) / 128, colourIndex)).rgb
else
    rgb = diffuse.rgb

The equation holds two traps. First, it multiplies where hair replaces. Second, the diffuse alpha here is a palette index, not opacity: these components draw in render bucket 0, which ignores alpha entirely. Exporting a palette component with alpha treated as transparency is what makes gloves come out half-invisible in external tools; in game those alpha values select palette columns and nothing else. Because glove diffuses are near-white, the wrong replace-model looks almost right on them, which is why the mistake survives in tooling.

Three palette laws, one sampler: do not merge them

PathShaderIndex fromCombine
Hair tintped_hair_*diffuse.r (raw 0..1)replaces albedo, then squared
Component paletteped_palettefloor(diffuse.a × 255.01)multiplies the diffuse, indices 32..160 only
Skin compositionskinblendsame as componentpal.rgb·pal.a + diffuse.rgb·(1−pal.a), further gated by a second texture's blue < 0.815686

Head overlays: two selectors, and two texture shapes

Head overlays (eyebrows, beards, makeup, blemishes…) tint through the ramp textures with both colour selectors:

rgb   = lerp(ramp(overlay.g, primaryIndex), ramp(overlay.g, secondaryIndex), overlay.r)
alpha = overlay.a * opacity

Overlays key the ramp on green where hair keys on red. Both texture families look green in a viewer; confusing the two channels is the classic "the tint is broken" report. Hair-palette slots (facial hair, eyebrows, chest hair) read the hair ramp; makeup slots read the makeup ramp. The split is the native's colorType (1 = hair palette, 2 = makeup palette).

The formula above is for the tinted masks: sparse-alpha textures (median alpha 0) that alpha-composite onto the head diffuse. Blemishes, ageing, complexion and skin detail are a different shape entirely: neutral-grey detail maps, opaque everywhere, RGB centred on ~129 (measured: 99.7% of a real blemish map's pixels fall within 118-138). Those blend overlay-style in encoded space, where 128 is the identity; alpha-compositing one paints the whole face flat grey. The two shapes must be told apart on pixel statistics, never on DXT format. The same blemish map ships as DXT1 in x64v and as DXT5 with constant alpha 252 in the patchday that shadows it.

On the secondary colour specifically, the texture-side evidence is narrow but consistent: across nine real beard overlay dictionaries, every diffuse either pins R and B to exactly 0 (only G and A carry data) or is perfectly grayscale. There is no independent second-region mask channel, and G does not trace a second silhouette (it correlates inconsistently with alpha across variants). The hair pixel shaders in this build never read the packed second selector at all (m_secondHairPalSelector is declared and [unused] in every permutation examined), even though tint_data.meta pairs every creator hair colour with a defaultSecondaryColor. The pairing is real data; the examined shaders do not consume it. The overlay lerp above is where a secondary index has a visible, verified effect.

Head overlays are drawn 1:1, and the declared rect is not applied

Overlay collections declare a placement for ZONE_HEAD hair/scalp entries: uvPos (0.5, 0.5), scale (0.6, 0.5), identical for every preset. Interpreted as a centre + extent it yields (0.2, 0.25)-(0.8, 0.75), which on the real head sheet is the face and the neck, not the scalp. The resolution is that head overlays are authored in head-diffuse UV space and composited 1:1. Lay mp_fm_f_hair_003_a over head_diff_000_a_whi at matching size with no transform and the scalp, hairline, sideburns and ear cut-outs land exactly. The declared rect is parsed by everything and used by nothing for this class of asset. Body decorations (tattoos, badges) are the opposite: their rects genuinely are placements (centre-based, rotated, signed scale), and uvPos.x legitimately exceeds 1 (torso badges declare U up to 1.24), so clamping destination rects makes them silently vanish.

The head-diffuse layout, measured

For orientation when authoring overlays, the composed freemode head texture lays out top-to-bottom (V from 0):

FeatureV
scalp lobes0.00 - 0.27
forehead0.28
eyes0.35
nose0.42
mouth0.52
chin0.60
neck0.65+

Teeth and a default eyeball sit in the bottom corners. The eyeball region is default artwork, described below.

Eye colour: an engine-side quad blit, not a shader

No amount of shader disassembly finds the eye colour, because it is applied in engine code: a single textured quad copied over the head diffuse during head composition. mp_eye_colour (shipped in the mpbusiness overlay txds, 512×256 DXT5) is a grid of whole eyeballs in 64×64 cells, giving 8×4 = 32 colours in the shipped atlas. Cell selection and destination are:

cols = atlas.Width / 64;  rows = atlas.Height / 64
u = (index % cols) / cols;  v = (index / cols) / rows
// destination rect on the head diffuse, literal engine constants:
(0.78125, 0.78711) - (0.97852, 0.98437)

That is a 202×202 px square at (800, 806) on a 1024² head. The head diffuse's own eye texels are default artwork the blit overwrites, which is why searching head textures for atlas matches fails. Because the grid is derived from the texture dimensions, a replacement atlas with more rows simply offers more eye colours.

Every law is gated on the material, never the component slot

Whether a mechanism applies is decided by the drawable's shader name (ped_hair_* → hair ramp, ped_palette → component palette, …), not by which component slot the drawable occupies. An add-on hairstyle built on a plain ped shader is finished artwork: it never touches the ramp in game, and a tool that ramps it anyway by slot replaces its authored colours with the ramp's dark left edge. Tools should key their recolour paths off the material, exactly the way the engine does.

Provenance: disassembled from the shipped .fxc shader containers (ped_hair_spiked, ped_hair_cutout_alpha, ped_palette, skinblend, ped_decal_decoration) with the Windows SDK fxc; ramp/atlas shapes and byte hashes measured on the real dictionaries in a current install; the 1:1 overlay result confirmed by direct image comparison of shipped overlay and head textures; the eye blit read from engine-side head-composition code. The overlay lerp formula is sourced from engine code rather than a located pixel shader, and it is the one law in this section not yet pinned to a disassembly.


20. Hair: shader families, sorting, strand direction

GTA V ped hair is alpha-tested cutout geometry, not sorted alpha blending. GTA IV's gta_hair_sorted_alpha does not carry over, so do not import that assumption. A hairstyle is High-LOD cutout cards drawn in explicit artist-authored layers, plus an opaque cap for Med/Low LOD. Everything below was measured on real freemode hair drawables (46 hair_*.ydd, 92 diffuse .ytd) and read out of the shipped shaders and the engine's hair-sorting code.

Render buckets

Every shader declares a render bucket, and the bucket rather than the slot decides what the alpha channel means. Census of the full 294-shader GTA V database:

BucketMeaningCountExamples
0Opaque: alpha is ignored entirely159ped.sps, ped_default.sps, ped_palette.sps
1Alpha blend51alpha.sps, glass.sps, ped_alpha.sps
2Decal34ped_decal.sps, ped_decal_decoration.sps
3Alpha-tested cutout, reference exactly 128/25541ped_hair_*.sps, cutout.sps, grass.sps
6Watern/a

Hair sits in the same bucket as grass and fence cutouts, which is the evidence for alpha-test rather than blend. Two readings go wrong here. The first treats bucket-3 alpha as a blend gradient, which turns hair to mush. The second treats bucket-0 alpha as opacity at all: on a palette-shader garment the diffuse alpha is a colour index (§19), and exporting it as an opacity map ships a 45%-transparent glove. Alpha-to-coverage is confirmed in the ped path when MSAA is on; AnisotropicAlphaBias is a separate parameter from the alpha-test reference, not the cutout threshold.

Shader families and what a hair material carries

ShaderBucketWhere it appears
ped_hair_spiked.sps3High LOD of most hairstyles
ped_hair_cutout_alpha.sps3High LOD of the rest
ped_hair_cutout_alpha_cloth.sps3in the shader DB; rare
ped_default.sps0Med/Low LOD of every hair, opaque
ped.sps0the bald/scalp placeholder (hair_000_u)

A representative High-LOD hair material, verbatim from a real drawable:

DiffuseSampler            hair_diff_004_a_uni   (from the ped's texture set, NOT embedded)
TextureSamplerDiffPal     (empty -  bound as a global at draw time, see §19)
AnisoNoiseSpecSampler     givemechecker         (placeholder; the real noise map is a shader global)
BumpSampler               hair_normal_004       (64×64 DXT1, embedded in the ydd)
SpecSampler               hair_spec_001         (64×64 DXT1, embedded, shared across styles)
orderNumber               0 / 1                 (differs per shader instance)
anisotropicSpecularExponent  16 32              (two lobes)
anisotropicSpecularIntensity 0.1 0.15
bumpiness 0.65 · specularFresnel 0.96 · specularFalloffMult 250

Hair shading is therefore cutout alpha plus a two-lobe anisotropic specular driven by a tangent-space noise map. The LOD switch is a material switch too: at distance hair stops being a cutout and becomes an opaque shell, so a renderer that ever picks Med/Low silently draws a solid cap.

The diffuse itself: 512×512 DXT5, alpha effectively binary (59% of texels >250, 37% <5 on a typical style; the soft fringe is DXT5 interpolation, not authored gradient), and RGB is one greyscale strand map times a constant colour (per-channel correlation 0.95-0.99 within a variant, 0.98 across variants). Only 5-6 baked texture variants exist per style versus 64 barbershop colours, because the colours are rows of the runtime mp_hair_tint ramp, not textures (§19).

orderNumber: meaning depends on the shader family

Hairstyles ship several geometries with an orderNumber parameter (0, 1, …). The engine's hair-sorting code draws hair in one of four modes chosen by shader name, and they read the value completely differently:

ModeShadersWhat it does with orderNumber
cutoutped_hair_cutout_alpha, _clothNever reads it. Draws every geometry.
spikedped_hair_spiked, _enveff, _maskOrder 0 is the hair. Order 1 is a normals-only pass: colour writes disabled (COLORWRITEENABLE_NONE on RT0), normals to RT1, AO to RT3; skipped in shadow passes.
orderedped_hair_sorted_alpha_expDraws all layers ascending, after a backface-only pre-pass of the largest layer.
furped_furLayered fur shells.

Both naive strategies fail. "Drop every orderNumber > 0" deletes real hair from cutout and ordered styles (a typical spiked style's order-1 geometry is a real 76-vertex tuft plus the normals layer). "Draw everything" renders the spiked normals-only layer as solid geometry and caps the ped in a faceted polygon helmet. The correct rule is per family: draw all geometries, except that on ped_hair_spiked* the order-1 normals layer must not be drawn as visible geometry.

Hat alternates: _1 / _2

Many styles ship hair_NNN_u, _u_1, _u_2 with the same shaders and diffuse. The measured bounding boxes differ sharply: _1 is an aggressively cut ~5 cm under-hat remnant, _2 a mildly trimmed near-copy of the full style. These are the drawables a pack swaps in for hat wear, since assembling a ped with a hat while keeping the _u drawable clips hair through the prop. They are not what the engine itself switches to when a hat is equipped; the runtime hat and hair fit is a bone mechanism, and the alternates system is sourced from other components entirely (§21).

Strand direction: the UV V axis, measured

Every anisotropic hair effect (the highlight, roughness variation along the strand, root/tip gradients) is oriented by one vector, and pointing it across the strand instead of along it still produces a convincing but wrong highlight, so the answer was measured rather than assumed. A hair card is a long thin strip, so its true strand direction is the principal axis of its own vertices; candidate vectors are scored against that by |cos| (a strand axis has no preferred sign):

AssetShaderCardsShipped tangentUV dP/dv (V axis)UV dP/duWorld up
hair_005_uped_hair_spiked1720.3120.967 (median 0.991)0.3220.522
hair_007_uped_hair_cutout_alpha410.2030.953 (median 0.996)0.2950.851

Three consequences:

  • The strand direction is the UV V axis, consistent across both shader families, and most cards align within a couple of degrees.
  • The shipped per-vertex tangent must not be used for hair anisotropy. It scores 0.20-0.31 because it follows U, the ordinary tangent-frame convention. Correct as a tangent, simply not the strand.
  • World up is the dangerous substitute: 0.851 on hanging hair, 0.522 on spiked, so it is convincing on one hairstyle and wrong on the next, with nothing to attribute the difference to.

Two practical notes. First, ped_hair_spiked geometry ships fully unwelded, with one index-connected component per triangle (5,276 components over 15,828 vertices on the measured asset), so any per-card analysis must weld by position first. Second, V is consistent only as an axis, not as a signed root→tip direction: measured per card, one asset votes ~65% "V=1 is the root" and another ~65% "V=0 is the root". Artists flip hair-card UV islands freely on the shared atlas. Anisotropy only needs the axis; root darkening or tip tint driven from the sign of V would invert on a third of the cards within one hairstyle and invert wholesale between hairstyles.

Provenance: shader/bucket census from the public 294-shader GTA V database; material parameters, texture statistics and strand-axis measurements taken from real freemode hair drawables of both shader families; the four sorting modes and the normals-only blend state read from the engine's hair-sorting code and confirmed against the shipped shaders.


21. Hats & hair: how the game fits them

When a ped puts on a hat, the engine does not swap the hairstyle for a "hat version". The hairstyle deforms. Getting this wrong is why add-on hairstyles clip through every hat.

The mechanism: expressionMods driving two hair bones

A head prop (p_head, anchorId 0) declares expressionMods in its aPropMetaData entry in the ped's variation .ymt. The vanilla hat value is "-0.5 0 0 0 0" (see the propFlags line in §16). Wearing the prop feeds that value to the ped's expression, which scales the MH_Hair_Scale and MH_Hair_Crown bones (bone tags 50788 and 5749, both children of SKEL_Head). The bones squash; whatever geometry is skinned to them squashes with them.

A hairstyle supports hats exactly when it is weighted to those bones

The deformation is pure skinning. A hairstyle with vertex weights on MH_Hair_Scale / MH_Hair_Crown shrinks under a hat; one skinned to SKEL_Head alone is rigid to the expression and cannot shrink. It clips through every hat, in GTA itself, not merely in tools. There is no metadata flag to fix this after the fact: hat compatibility is a property of the skin weights, decided when the hair mesh is rigged. Add-on hair conversions that weight everything to SKEL_Head for simplicity are permanently hat-incompatible.

The hair_NNN_u_1 alternates are not the hat mechanism

A persistent community belief holds that the engine swaps to the _1 alternate drawable (hair_003_u_1.ydd and friends) when a hat goes on. The shipped data says otherwise: pedalternatevariations.meta sources alternates from other components (berd masks, jbib hoods) for both freemode peds, and never from a prop anchor. A head prop cannot trigger a component alternate. Hats deform hair through the expression bones above; the alternates system solves a different problem (a hood or mask flattening the hair beneath it). The cut-down _1/_2 drawables themselves are real and useful (§20), and a pack or script can select them deliberately, but nothing in the engine promotes them on prop equip.

Provenance: bone tags and hierarchy read from real freemode ped rigs; expressionMods values from vanilla and community variation .ymt files; the weighting rule verified by measuring vertex displacement of real hairstyles under the hat expression, where weighted styles displace and SKEL_Head-only styles do not; the alternates claim read from the shipped pedalternatevariations.meta.


22. What ped materials GTA actually ships: census, wrinkle, enveff

The census below covers the real freemode corpus: 433 drawables across both genders, every shader instance on High-LOD geometry.

Shader census by component slot

SlotShaders (bucket)
headped.sps (0) ×92
uppr / lowr / feet / taskped.sps (0), 32/32/32/18, nothing else
jbibped.sps (0) ×20, ped_nopeddamagedecals.sps (0) ×10, ped_palette.sps (0) ×2
accsped.sps (0) ×28, ped_palette.sps (0) ×4
handped_palette.sps (0) ×16, ped.sps (0) ×2
hairped_hair_spiked.sps (3) ×148, ped_hair_cutout_alpha.sps (3) ×15, ped_alpha.sps (1) ×1, ped.sps (0) ×1
berdped.sps (0) ×15, ped_hair_cutout_alpha.sps (3) ×1
teefped.sps (0) ×20, ped_hair_cutout_alpha.sps (3) ×4, ped_alpha.sps (1) ×2
declped_decal_decoration.sps (2) ×11, ped_decal.sps (2) ×1, ped.sps (0) ×1

What follows from the table:

  • Clothing is opaque. Body garments are ped.sps, bucket 0, with no alpha and no sorting problem. But teef and berd both dip into the hair cutout shader (teeth gaps, beard cards) and ped_alpha.sps, so "only hair is non-opaque" is wrong: alpha mode must travel per geometry, not per slot.
  • Every High-LOD ped material ships a normal map and a spec map. Measured across all instances: ped.sps (293 uses) binds Diffuse/Bump/Spec always and a palette never; the two palette shaders (ped_palette ×22, ped_default_palette ×29) are the only shaders that assign TextureSamplerDiffPal. Med/Low LOD drops to ped_default.sps with no maps at all.
  • The decl slot is not tattoos. Its diffuse is a 64×64 pure-alpha (A8) body/garment mask in decal bucket 2; the decorations themselves are composited into a runtime target, and an ordinary tattoo's colour is its own texture's RGB.

The spec map's channels, from the shipped pixel shader

The ped.sps spec sampler is not a gloss map. Disassembled from the shipped pixel shader, per channel:

ChannelMeaningLaw
RSpecular intensitysaturate(R² · specularIntensityMult)
GSpecular exponentG² · specularFalloffMult, then ×3 (rescaled ×558 above 500) into a Blinn-Phong exponent
BMaterial IDtrue near B ≈ 0.875 (|14 − 16·B| ≤ 1); written to the output alpha, never lights anything
AUnusedn/a

R and G are squared before use. The map is data, read raw, not sRGB-decoded. Treating B as gloss is wrong.

Wrinkle: two extra normal maps, sixteen animation weights

A wider census over ~127,800 indexed drawables surfaced ped_wrinkle_enveff.sps as the main per-garment variation GTA ships, roughly 9% of upper-body garments by that count (against 0.8% for the ped cloth flag), though the figure varies strongly with what you use as the denominator. What the shader does, verbatim from the shipped shader source:

const half wrinkleStrength_A =
    dot(IN_wrinkleMask_0, h4tex2D(IN_wrinkleMaskSampler_0, uv)) +
    dot(IN_wrinkleMask_1, h4tex2D(IN_wrinkleMaskSampler_1, uv));
const half wrinkleStrength_B =
    dot(IN_wrinkleMask_2, h4tex2D(IN_wrinkleMaskSampler_2, uv)) +
    dot(IN_wrinkleMask_3, h4tex2D(IN_wrinkleMaskSampler_3, uv));

packedNormal = lerp(packedNormal, wrinkleNormal_A.xy, wrinkleStrength_A);
packedNormal = lerp(packedNormal, wrinkleNormal_B.xy, wrinkleStrength_B);

Four RGBA mask textures select garment regions; each is dotted with a float4 strength constant to give two scalars; those lerp two wrinkle normal maps over the base packed normal. That gives sixteen independent region weights and two target normals. It is a normal-only effect (albedo, roughness and specular are untouched) and it is gated on the material also having a base normal map: a wrinkle shader without one is inert.

The sixteen weights come from the animation system: the ped's animation shader-variable channels carry a WrinkleWeight per region, clamped 0..1 and quantised to u8. Only four component slots can carry wrinkles at all: HEAD, UPPR, LOWR, HAND. Every other slot maps to none. (The engine actually reserves six mask constants; the extra pair is the facial-animation path, so do not assume four everywhere.)

The asset side encodes the law in its naming. A real wrinkle garment binds:

WrinkleMaskSampler_0..3   uppr_normal_018_ma1 / _ma2 / _mb1 / _mb2
WrinkleSampler_A / _B     uppr_018_wa / uppr_018_wb
SnowSampler               ENVEFF_Gray            (one shared global texture)
envEffFatThickness        [25, 25, 0, 0]

The a/b mask split is exactly the sampler pairing above.

Enveff: dirt and snow, not environment reflection

The name reads like an environment/reflection effect. It is not. The engine sets the enveff colour from the ped's dirt colour and the scale from the ped's dirt level; the original changelog entry calls it "snow scale". One float4 constant carries four unrelated body effects at once: dirt/snow amount, the same pre-scaled, body-fat blend, and sweat. A second constant carries the regional dirt tint. The shared ENVEFF_Gray texture confirms it: one global dirt/snow layer, not per-garment artwork.

Both effects default to zero and are driven by runtime state, not by the asset. A clean ped standing at rest renders identically whether its garment is ped or ped_wrinkle_enveff, because the shader name is a capability flag, not a look. Any tool that gives wrinkle/enveff garments a different appearance at import is inventing state the game does not have.

Provenance: census measured over 433 real freemode drawables (High-LOD shader instances) plus a ~127,800-drawable index for prevalence; the wrinkle blend, mask pairing and enveff constants read from the shipped shader source and the engine's ped shader code; the spec-map channel law disassembled from the shipped ped pixel shader; the wrinkle asset bindings walked from a real ped_wrinkle_enveff garment.