Notes behind the recommended values#

Caveats that took rounds of review or in-game testing to settle. The fits live in shader_fit/.

Gamma blending#

  • ksDiffuse is a plain multiplier in the Gamma build. 0.225 is the neutral: under LCS it is the value where pow(texture × k × 2 / 0.45, 2.2) becomes a pure sRGB decode (_gamma.fx:23-37), and it looks right in game.
  • On opaque surfaces, fresnel weights and highlights are physical amounts, as in commit 9140a9c: fresnelC ≈ F0, and highlights are relative to a sunlit white surface, clipped at it, the fitted highlight as well as the target. Matching gamma-encoded pixel brightness instead inflates both on dark textures; in game that looked like a glass coat at grazing angles and made tyres and plastics grey. Clear-coated paint and glass are the exceptions (see Clear coat and Glass below).
  • ksSpecular is relative to the 0.225 neutral and assumes extSpecularColor is the sun colour; the page’s rule for another neutral follows from that.

Opaque surfaces#

  • The shader dims the whole lighting by 1 − fresnel before adding the reflection, the sun highlight included (utils_ps.fx:669-675). The reflector keeps only an eighth of its highlight, hence sunSpecular 2.7.
  • fresnelMaxLevel is a soft cap (smin, utils_ps.fx:550-554). Where fresnelC ≥ fresnelMaxLevel the weight sits near the cap at every angle and fresnelEXP barely matters.
  • AC’s Blinn highlight has no Fresnel or geometry term, so it cannot brighten toward grazing. Fitted lobes run 0.31–1.7× the clipped physical peak at 22.5° incidence and 0.03–0.59× at 72.5°; 17a, a smooth coat on _NMDetail, reaches only 0.06× and 0.01×.
  • Exponents are chosen with overshoot counted, then intensities are fitted with the highlight clipped at white. Choosing exponents with the clip too builds flat white discs from absurd intensities (sunSpecular in the millions on rough metal), which Pure’s HDR would bloom.
  • ksSpecularEXP also picks the cubemap mip, 6 × (1 − EXP / 255)² (utils_ps.fx:621-622, 701). Exponents fitted to the highlight made glossy plastic a mirror in game, so ksSpecularEXP is 255 × (1 − roughness^0.05): the mip CSP’s PBR shaders pick for the same roughness, 6 × roughness^0.1 (pbr.hlsl:349-351). That curve holds when pbReflectionBlurEnv is 0, as Material_Glass defines it (smGlass_ps.fx:88); nePBR materials set it per material (nePBR_MultiMap_NMDetail_ps.fx:80, 257).
  • With ksSpecularEXP fixed, the fit sets its intensity and, on plain MultiMap, the sun lobe’s intensity and exponent. _NMDetail and ksPerPixelReflection have no sun lobe, so their highlights are wide and dim. ksTyres keeps a fitted exponent: it always samples the blurriest mip.
  • Those mips, ≈5 and up, shimmer on metals and smooth plastic in game. The shimmer is accepted over mirror-like reflections; sharper mips also show stripes on curved chrome (utils_ps.fx:425-427).
  • The sun disc drawn into the cubemap isn’t modelled. Near-mirror materials (reflector, black gloss, glossy plastic) likely overshoot their highlight by its brightness.

Shader families#

  • ksPerPixelMultiMap_NMDetail, _AT, _AT_NMDetail, SimpleRefl and the skinned variants have no sun lobe and skip extSunSpecularMult (NO_SUNSPEC_MAT, utils_ps.fx:1164).
  • A white txMaps.g renders ksSpecularEXP + 1 on MultiMap (utils_ps.fx:886). ksPerPixelReflection, ksPerPixelNM and ksTyres scale fresnelEXP by lerp(0.8, 1, fresnelMaxLevel); MultiMap uses max(fresnelEXP, 1) (:595-599). The page’s conversion rule comes from these two lines.
  • isAdditive skips the diffuse dimming for any non-zero value on ksPerPixelReflection and ksPerPixelNM, but only for 1 on MultiMap, where 2 changes the reflection blur divisor from 255 to 8 (:620-621, :670).
  • ksPerPixelMultiMap and _NMDetail always output alpha 1 (ksPerPixelMultiMap_ps.fx:58); they can’t be glass.

Materials#

No environment in the opaque values#

The opaque fresnel is fitted straight to the physical reflectance at each view angle (env_brdf, model.py), and the highlight to the physical sun highlight, so neither sky nor environment brightness enters the opaque values. The clear coats (1c, 3c, 17a) are the exception: their fresnel matches the on-screen look, which takes the sky and environment levels in Runtime assumptions. Enclosure depends on where a surface sits, not on its material, so it isn’t in them either.

Shader per material#

The Shader rows follow how the cars are built:

  • Normal-mapped MultiMap materials (leather, vinyl, fabrics, rough plastic, rough paint) use _NMDetail; smooth ones use plain MultiMap.
  • Chrome stays on ksPerPixelReflection: badges don’t get txMaps UV work.
  • Rough bare metal (sanded, blasted) has no grain, so no detail map: plain MultiMap, with its sun lobe. Powder coat is a polymer layer, F0 0.04, so it is paint (17b), not metal.
  • Rubber isn’t put on MultiMap.

Grain in detail normal maps#

  • Grain lives in txNormalDetail at detailNormalBlend 1; txNormal on _NMDetail is a flat placeholder. The shader decodes the stored blue as z (utils_ps.fx:1128), so the decoded slopes are what render.
  • The lobe carries only what the map doesn’t: α_finish² = α_total² − σ², with σ² the map’s slope variance (shader_fit/normal_maps.py). Rough plastic, powder coat and Alcantara use their samples in nm_samples/.
  • The detail normal is weighted by detailNormalBlend × (1 − txDiffuse.a) (utils_ps.fx:1112, 1136). The fits assume diffuse alpha 0; where it is higher, the grain fades and the grain-free lobe is too glossy.
  • Mips average the grain away, and nothing in _NMDetail widens the lobe to make up for it, so the grain-free roughness is right close up and too glossy far away.
  • Either leather map alone is rougher than a whole 0.40 leather, so the leathers keep their coat roughness (0.35, 0.47) instead of a subtracted one.
  • cloth.dds stores blue as a constant 0.71, which decodes as z 0.42, so its weave alone is rougher than the fabric’s whole 0.90. It stays as-is by choice, and natural fabric keeps 0.90.

Metals#

ksDiffuse stays 0.225 and the texture carries the diffuse: near-black (albedo 0.02) plus dirt and oxide.

Clear coat#

3c, 17a and varnished wood (1c) assume a clear coat at roughness 0.08; powder coat (17b) has none. On 17a the coat follows the grain, so the map keeps the grain and the lobe is the coat’s own roughness. The sun becomes a small spot that clips to white, which only plain MultiMap’s sun lobe can draw: 3c and 1c get it, while 17a on _NMDetail draws just 6% of the physical highlight. ksSpecularEXP stays on the blur rule, 30, so reflections are mip 4.7: sharper than before, not mirror-like.

The clear coat’s fresnel matches the reflection’s on-screen look over the darkened paint (screen_cases, model.py), not its physical amount: the gamma-space blend hides a physical 4% on dark paint. That is the glass coat the other materials avoid, and on clear coat it is the real thing.

A clear layer darkens what is under it, as water does: about 60% of the light leaving the base reflects back down off the layer’s underside, and the base absorbs part of it again (coated_diffuse_share, model.py, n 1.5). The shader’s 1 − fresnel covers only the exit angle, so ksAmbient / ksDiffuse carry the rest: 0.40× the base for black, 0.43× for paint (albedo 0.10), 0.46× for wood (0.20), 0.77× for white (0.80). Those are linear shares, and ksDiffuse scales the texture before the 2.2 decode (Gamma blending above), so it takes the share to the 1/2.2: 0.225 × 0.43^(1/2.2) = 0.153 for paint. One multiplier per section takes a typical albedo, so lighter colours come out a little dark; the extra saturation a coat gives isn’t in it. It applies to the clear-coated sections and tyre shine (23a), for textures holding the bare colour. A texture taken from the coated object already has the darkening, so it takes 0.225. Black gloss (22) is too dark to change.

Metal flakes#

Metallic paint is 3c or 17a plus aluminium flakes (reflectance 0.91) under the coat (flake_highlight, model.py). Their tilt is assumed, about 10° (GGX α 0.18), since nothing gives it. Refraction widens their lobe by roughly the coat’s index; the model takes that and the coat’s Fresnel terms at small angles, so grazing views are approximate. ksSpecular is the flakes, 1.0 on both, fitted unclipped at full coverage: the txDetail alpha mask scales it linearly in game (utils_ps.fx:1004, 885; :1144 on _NMDetail), as coverage does physically. At ksSpecularEXP 30 it explains 80% of the flake highlight. The coat’s own ksSpecular is 0 on 3c; 17a’s 0.016 is replaced by the flakes’ 1.0, so its coat highlight is dropped everywhere, a negligible loss.

  • Pearl isn’t covered: mica pearl pigments reflect far less than aluminium, so 1.0 overstates them.
  • The glitter renders white; real flakes glitter in the paint’s colour.
  • Flakes take the pigment’s place, but ksDiffuse stays, so a high mask shows full pigment plus full flakes. The txDetail RGB multiplies the gamma-encoded paint colour where the mask applies (:1003, :1143), while the mask alpha is linear coverage, so removing the pigment takes RGB ≈ 255 × (1 − alpha)^(1/2.2): 186 at alpha 0.5. A non-white RGB tints or speckles the paint either way.
  • The detail needs useDetail on (:1001) and only shows where txDiffuse alpha is below 1: it’s weighted by 1 − txDiffuse.a.

Glass#

Blending#

  • Glass is alpha-blended: everything the shader outputs, reflection and highlight included, is scaled by the output opacity (confirmed in game). isAdditive 0 raises the opacity to lerp(alpha, 1, fresnel) (utils_ps.fx:676-677).
  • A pane can’t be both clear and reflective: a reflection only shows by blocking what is behind it.
  • So glass, unlike opaque surfaces, is fitted to the gamma-encoded brightness of the whole picture: background, reflection and texture together. At fresnelC ≈ F0 a pane’s reflection would be invisible; the fits land at 0.23–0.67.
  • Shader values are shared by all tints of a group, clear or coloured; only the texture’s RGB and alpha change. Colour needs alpha, which already hides the background, so coloured glass takes a weaker reflection (fresnelC 0.23 against 0.67 on a clear lens).
  • Each group takes the isAdditive that fits it better: 1 for clear lenses and prisms, 0 elsewhere; projector lenses are pinned at 1 (Projector lenses below). isAdditive 0 makes the glass more opaque where it reflects, so the texture colour shows there even at alpha 0. Clear glass gets a grey texture either way, as on Kunos cars; it is lit like paint and fades at night, the reflections don’t.
  • A lamp interior is lit through its own lens, so its light crosses the lens twice. The cabin behind a window is lit by the car’s lighting and crosses once.
  • ksSpecularEXP 20000 because 800 drew a giant blob in game. No ksSpecular matches both the sun glint’s brightness and its size at that exponent, so it is set to clip to white across the sun’s 0.53° disc, taking a sunlit white surface as display white. It is set on a group’s clearest tint; the glint is scaled by opacity, so on more opaque tints it clips wider than the disc (red about 1.5× brighter than yellow).
  • A pane can’t transmit more than (1 − R0) / (1 + R0), 0.918 at n 1.52; pane() rejects more.
  • A scratch is modelled as a frosted spot: it scatters 9.5% of the light back before the glass’s own absorption (FROSTING_REFLECTANCE, the diffuse reflectance of a surface at n 1.52). The page gives the texture at the scratch peak.

Projector lenses#

A projector lens is polished, but focused on the cutoff shield deep inside, so from outside the bowl blurs. AC can’t blur, so alpha hides the bowl mesh behind the lens as far as the blur washes it out. Alpha is set by eye, not fitted: 0.75 for a heavily blurred low beam, 0.50 for a lightly blurred fog lamp. The texture is set by eye too, at 50 grey: the lens carries the lamp’s emissive, ksEmissive × texture (utils_ps.fx:811, 817), and the near-black grey a daylight fit picks (2–8) leaves the lit lens too dim to hide the bowl showing through it. isAdditive is pinned at 1: the emissive is part of the lighting that isAdditive 0 dims by 1 − fresnel (:970, :670-673), which on the low beam left a lit lens at 77% head-on and 20% at 85°, a dark rim. With those pinned the fit sets the reflection; it is 23% off for the low beam and 14% for the fog lamp.

ksWindscreen#

The inner window mesh has no fresnel parameters. Texture alpha below 0.5 only shows toward the sun (windscreenBase.hlsl:66-96), and reflections come only from FLAG_MATERIAL_2, a fixed fresnelC 0.1.

Material_Glass#

  • Reflections show at F: the shader divides the fresnel by the opacity (smGlass_ps.fx:232).
  • fresnelC is F0 of Schlick at roughness 0. Texture alpha becomes Beer–Lambert absorption through extThicknessMult (:221-228). The diffuse is ×0.4, hence ksDiffuse 0.56. ksSpecular is unused; the shader’s own highlight is a GGX at roughness 1e-4.
  • The sunlit texture gets Burley diffuse, which halves toward grazing views at roughness 0 (pbr.hlsl:240).
  • The fit’s path length uses n 1.52; the default extIOR is set in CSP.

Coloured prisms#

ksPerPixelNM takes alpha from the normal map’s alpha channel (ksPerPixelNM_ps.fx:17). Alpha blending can’t filter colour, so a coloured tint hides most of the lamp interior behind a dark red, amber or yellow layer; the coloured fit is 26% off. ReflectionsFX refraction isn’t modelled. The coloured prisms take 18b’s coloured values: the fit sees the same lamp interior behind both.

Terrain#

  • ksMultilayer_fresnel_nm has no cubemap: the fresnel block sets the highlight intensity from the view angle alone and replaces ksSpecular (ksMultilayer_fresnel_nm_ps.fx:77-88).
  • Fitted freely, rougher terrain comes out with a sharper highlight to cut glare away from the sun. No type may be sharper than new tarmac, so all sit at ksSpecularEXP 46.
  • The errors are 46–58%: the streak toward a low sun reaches 58–61% of the physical one, and the faint sheen away from the sun is lost.
  • The glare isn’t clipped at white in the fit: every parameter is free, and with the clip new tarmac’s multiplier climbs from 2.0 to 5.0 for an error under 2% lower.

Tyres#

  • ksTyres samples the cubemap at the blurriest mip whatever ksSpecularEXP is (utils_ps.fx:504-505), and fades the reflection by saturate(normalW.y + 1) (ksTyres_ps.fx:93).
  • The diffuse alpha scales only ksSpecular (ksTyres_ps.fx:83), so lettering renders with the tyre’s exponent and fresnel, and its alpha is fitted at the tyre’s exponent.

Runtime assumptions#

  • Pure’s extLambertGamma, extFresnelGamma, extSunSpecularMult and extSpecularColor relative to the sun weren’t found in its code or in game; the fits assume 1 for each. extSpecColor, seen at 1, 1, 1, is smCarPaint’s per-material specular tint (smCarPaint_ps.fx:44, 143), not the global extSpecularColor (cbuffers_common.fx:186).
  • The glass and clear-coat fits take sky light as 0.2 of a sunlit white surface and the reflected environment as 0.1–0.3; Pure sets that ratio.