Источники Альфы#
ksPerPixelNM+AlphaTest:txNormalksPerPixel_AT:txDiffuseksPerPixelMultiMap:txDiffuse, альфаtxDetailигнорируется
Texture Alpha Channel Usage per Shader#
Reference for what each shader does with the alpha channels of the four canonical sampler bindings (txDiffuse, txNormal, txMask, txDetail). For the multilayer family, txDetail corresponds to the four detail samplers txDetailR/G/B/A, and the normal-map slot is renamed to txDetailNM. Only textures actually sampled by a given shader are listed.
Throughout this section, “output alpha is fresnel-boosted” refers to calculateReflection (recreated/include_new/base/utils_ps.fx:676) returning lerp(lighting.w, 1, fresnelDry) for non-additive materials — the surface becomes more opaque at grazing angles.
ksPerPixel#
txDiffuse#
Output alpha (recreated/ksPerPixel_ps.fx:84). When FLAG_ALPHA_TEST is set on the material, the same value is used as the alpha-test source.
ksPerPixelNM#
txDiffuse#
Sampled for RGB only — the alpha channel is discarded.
txNormal#
Read inside getNormalW (recreated/include_new/base/utils_ps.fx:275) and forwarded as the output alpha. With FLAG_ALPHA_TEST active the value is written through verbatim (recreated/ksPerPixelNM_ps.fx:40); otherwise it is fresnel-boosted in calculateReflection.
ksPerPixelReflection#
txDiffuse#
Output alpha. Passed to calculateReflection and fresnel-boosted before being returned (recreated/ksPerPixelReflection_ps.fx:88).
ksPerPixelSimpleRefl#
txDiffuse#
Output alpha. Captured before ADJUSTCOLOR (recreated/ksPerPixelSimpleRefl_ps.fx:14) and fresnel-boosted by calculateReflection exactly like ksPerPixelReflection. The shader does not sample txNormal despite declaring the CARPAINT_NM cbuffer layout — that define only affects the cbuffer structure.
ksFlags#
txDiffuse#
Output alpha (recreated/ksFlags_ps.fx:44). With rain active, wetK blends the alpha toward 1 so wet flags become more opaque (recreated/ksFlags_ps.fx:24). When FLAG_ALPHA_TEST is set the alpha is clipped against 0.01.
ksTree#
txDiffuse#
Output alpha, consumed via alpha-to-coverage (A2C_ALPHA, recreated/ksTree_ps.fx:91). When FLAG_MATERIAL_0 is set, the alpha is additionally attenuated by view angle and noise to fade billboard silhouettes (recreated/ksTree_ps.fx:82-88).
ksBrakeDisc#
txDiffuse#
Specular multiplier — fed to setSpecularMult (recreated/ksBrakeDisc_ps.fx:24). Does not contribute to the output alpha (the shader writes 1 unconditionally).
txNormal#
Read via getNormalW and passed into calculateReflection, but the result alpha is overridden to 1 by RETURN (recreated/ksBrakeDisc_ps.fx:32), so the value has no effect on the final image.
ksTyres#
txDiffuse#
Three concurrent uses:
- Specular multiplier via
setSpecularMult(recreated/ksTyres_ps.fx:83). - Wet-area mask:
wetK = water * saturate(txDiffuseValue.a * 10)— only pixels with non-zero diffuse alpha get the wet treatment (recreated/ksTyres_ps.fx:68). - Reflection-intensity scale when
extRoughnessExp != 0:reflectionMult *= txDiffuseValue.a(recreated/ksTyres_ps.fx:52).
txNormal#
Two conditional uses, both gated by bit 2 of extTyresFlags_uint:
- When that flag is set together with
extRoughnessExp != 0, the alpha is the per-pixelksSpecularEXPmap — both the lighting and reflection roughness are blended towardextRoughnessExpby it (recreated/ksTyres_ps.fx:86, 97). - When the flag is set, the alpha also becomes the output alpha, scaled by
(1 - extAlphaMultInv)(recreated/ksTyres_ps.fx:110).
ksWindscreen#
txDiffuse#
Drives essentially every part of the windscreen pipeline (custom_objects/common/windscreenBase.hlsl:8-96):
- Output alpha. When
alphaGammais non-zero the value is reshaped bypow(a, alphaGamma)before use. - Mode switch:
useLightBounceEffect = !bannerMode && txDiffuseValue.a < 0.5— alpha below 0.5 enables the cheap additive “stained-glass” path. - Gate for
solidBrightnessAdjustment: the brightness add only kicks in wheresaturate(a * 10 - 5) > 0, i.e. roughlya > 0.5. - In the light-bounce path the output alpha is further multiplied by the directional light term, so reflective glints only show where the sun actually hits.
ksPerPixelAlpha#
txDiffuse#
Output alpha, multiplied by the cbuffer alpha material parameter (recreated/ksPerPixelAlpha_ps.fx:32).
ksPerPixelAT#
txDiffuse#
Output alpha (alpha-to-coverage path, recreated/ksPerPixelAT_ps.fx:31).
ksPerPixelAT_NM#
txDiffuse#
Output alpha (recreated/ksPerPixelAT_NM_ps.fx:31).
txNormal#
Read by getNormalW so its alpha is returned as the local alpha variable, but that value is then unused — the output alpha comes from txDiffuse.a.
ksPerPixelMultiMap#
txDiffuse#
Two roles, neither of which is the output alpha (output is forced to 1):
- Detail-blend selector in
considerDetails(recreated/include_new/base/utils_ps.fx:1003). WheretxDiffuseValue.a < 1the detail texture is mixed into both RGB and thetxMaps.xspecular intensity. - Colored-reflection mix factor when
extColoredReflection == 3: the macro uses1 - txDiffuseValue.a(recreated/include_new/base/utils_ps.fx:83).
txNormal#
Read via the 4-argument getNormalW (no alpha output), so the alpha channel is not consumed.
txDetail#
txDetailValue.a modulates the per-pixel specular intensity in considerDetails: txMapsValue.x = lerp(txMapsValue.x, txMapsValue.x * txDetailValue.a, 1 - txDiffuseValue.a) (recreated/include_new/base/utils_ps.fx:1004). It does have an effect — but only where txDiffuse.a < 1, i.e. where the detail layer is showing through.
ksPerPixelMultiMap_AT#
txDiffuse#
Behaviour depends on extUseDiffuseAlpha (recreated/ksPerPixelMultiMap_AT_ps.fx:30-34):
- Default (
extUseDiffuseAlpha == 0):txDiffuse.ais the detail-blend selector and feeds the colored-reflection mask whenextColoredReflection == 3. The output alpha comes fromtxNormal.a. - Override (
extUseDiffuseAlpha != 0): the two alphas are swapped —txDiffuse.abecomes the output alpha, andtxNormal.atakes over as the detail-blend selector.
txNormal#
Read by getNormalW; its alpha is the output alpha by default, or the detail-blend selector when extUseDiffuseAlpha is set.
txDetail#
Same role as in ksPerPixelMultiMap: txDetailValue.a modulates txMaps.x (specular intensity) via considerDetails in transparent regions of whichever alpha is currently acting as the detail-blend selector.
ksPerPixelMultiMap_AT_NMDetail#
txDiffuse#
Detail-blend selector in considerNmDetails (recreated/include_new/base/utils_ps.fx:1112, 1143-1147) — controls both how strongly the detail color is mixed and how strongly the detail normal-map is applied. Also drives extColoredReflection == 3. Does not become output alpha.
txNormal#
Sampled via getNormalW; its alpha is returned as alpha and used as the output alpha (recreated/ksPerPixelMultiMap_AT_NMDetail_ps.fx:49).
txDetail#
txDetailValue.a modulates txMapsValue.x (and additionally txMapsValue.z when FLAG_MATERIAL_2 is set) in transparent regions of the diffuse texture (recreated/include_new/base/utils_ps.fx:1144-1147).
ksPerPixelMultiMap_NMDetail#
txDiffuse#
Detail-blend selector for both color and normal-detail mixing in considerNmDetails, plus colored-reflection mask source when extColoredReflection == 3. Output alpha is forced to 1.
txNormal#
Read via the 4-argument getNormalW (no alpha output) — alpha not consumed.
txDetail#
Same role as in ksPerPixelMultiMap_AT_NMDetail: modulates txMaps.x (and txMaps.z under FLAG_MATERIAL_2).
ksPerPixelMultiMap_emissive#
The multimap shader with an additional txEmissive slot driven by emissiveMapping.hlsl. This entry covers txMaps and txEmissive in addition to the four canonical bindings. Output alpha is selected by the emAlphaFromDiffuse cbuffer value (recreated/ksPerPixelMultiMap_emissive_ps.fx:50-57):
emAlphaFromDiffuse |
output alpha |
|---|---|
2 |
forced to 1 |
1 |
txDiffuse.a (raw) |
0 (default) |
txNormal.a (raw) |
-1 |
txDiffuse.a fresnel-boosted by calculateReflection |
other < 0 |
txNormal.a fresnel-boosted by calculateReflection |
other > 0 |
txNormal.a (raw) |
Negative values also flip R.isCarPaint = false, which changes how the carpaint additive-vs-energy-conservation branch in calculateReflection interprets the cbuffer isAdditive flag.
txDiffuse#
Four concurrent roles:
- Detail-blend selector in
considerDetails— same asksPerPixelMultiMap. - Colored-reflection mix factor when
extColoredReflection == 3(1 - txDiffuseValue.a). - Default source for
emissiveInput.a, which feeds theemDiffuseAlphaAsMultiplier*curve ingetEmissiveValue(custom_objects/common/emissiveMapping.hlsl:425-428) — the emissive output is multiplied bysaturate(lerp(1, pow(saturate(a * em…0 + em…1), em…2), abs(em…3))). - Output-alpha source when
abs(emAlphaFromDiffuse) == 1(recreated/ksPerPixelMultiMap_emissive_ps.fx:53).
txNormal#
Read inside getNormalW and stored as the local alpha (recreated/ksPerPixelMultiMap_emissive_ps.fx:22). It is the default output-alpha source. When emDiffuseAlphaAsMultiplier3.x < 0, emissiveInput.a is overridden with this value (recreated/ksPerPixelMultiMap_emissive_ps.fx:32), so txNormal.a then drives the emDiffuseAlphaAsMultiplier* curve instead of txDiffuse.a.
txMaps#
The xyz channels are consumed by applyTxMaps for per-pixel specular/reflection tuning. The alpha is consumed by GET_SPEC_COLOR_MASK_DETAIL: when extColoredReflection == 2, txMaps.a becomes the colored-reflection blend factor (recreated/include_new/base/utils_ps.fx:83).
txDetail#
Two roles:
- Modulates
txMaps.x(specular intensity) viaconsiderDetailsin regions wheretxDiffuse.a < 1. - When
emSkipDiffuseMap == -1, the full RGBA returned bygetTxDetailValuereplacesemissiveInput(recreated/ksPerPixelMultiMap_emissive_ps.fx:33). In that modetxDetail.abecomes the source for theemDiffuseAlphaAsMultiplier*curve, taking precedence over theemDiffuseAlphaAsMultiplier3.x < 0swap totxNormal.a.
txEmissive#
Sampled inside getEmissiveValue (custom_objects/common/emissiveMapping.hlsl:271-273). The role of .a depends on emChannelsMode:
emChannelsMode == 0(single-color emissive): only.rgbis used asksEmissive * emissiveMap.rgb(custom_objects/common/emissiveMapping.hlsl:438). Alpha is discarded.emChannelsMode != 0(multi-channel emissive): all four channels are independent emissive masks, each picking a differentksEmissive*color (custom_objects/common/emissiveMapping.hlsl:432-436)..r→ksEmissive,.g→ksEmissive1orksEmissive6,.b→ksEmissive2orksEmissive5,.a→ksEmissive3orksEmissive4. The mirror-side alternate for the alpha channel (ksEmissive3vs.ksEmissive4) is selected per-pixel byemMirrorChannel3As4combined with the vertex-shader-computed sign inextraValue.x— positive side picksksEmissive3, mirrored side picksksEmissive4.
When emProceduralMode_uint != 0, the procedural mask path can additively, multiplicatively, or subtractively modify all four channels of the sampled emissiveMap (including .a) before the channel-mode lookup runs (custom_objects/common/emissiveMapping.hlsl:282-411), so txEmissive.a may also be reshaped by procedural geometry, color, polygon, or vertex-area masks before it gates ksEmissive3/ksEmissive4.
ksPerPixelNM_UVMult#
txDiffuse#
Output alpha, fresnel-boosted by calculateReflection (recreated/ksPerPixelNM_UVMult_ps.fx:34). Under MODE_SHADOWS_ADVANCED it is additionally clipped against ksAlphaRef.
txNormal#
Only the RGB channels are read to decode the normal vector; the alpha is not used (note this shader uses a hand-rolled normal-map decode, not getNormalW).
ksMultilayer#
txDiffuse#
Multiplied component-wise by combined, the per-pixel sum of txDetailX * txMaskValue.{x,y,z,w} (recreated/ksMultilayer_ps.fx:42-47). The resulting txDiffuseValue.a is then fed to setSpecularMult as the per-pixel specular multiplier (recreated/ksMultilayer_ps.fx:53). Output alpha is forced to 1.
txMask#
All four channels are layer-blend weights — .w (alpha) selects the fourth detail layer (txDetailA).
txDetail#
Each of txDetailR/G/B/A contributes its full RGBA to combined. Per-detail alpha therefore flows into the final txDiffuseValue.a and from there into the specular multiplier.
ksMultilayer_objsp#
Same texture-alpha semantics as ksMultilayer. The only difference is that the detail samplers are addressed with pin.Tex * multX (mesh UVs) rather than world-space posW.xz * multX (recreated/ksMultilayer_objsp_ps.fx:14-23).
txDiffuse#
Combined-weight modulated; final alpha drives setSpecularMult. Output forced to 1.
txMask#
Four-channel layer weight; .w selects the fourth layer.
txDetail#
Each detail’s alpha contributes to combined.a and through that to the specular multiplier.
ksSkinnedMesh#
txDiffuse#
Output alpha, fresnel-boosted by calculateReflection (recreated/ksSkinnedMesh_ps.fx:46). Also acts as the detail-blend selector in considerDetails.
txNormal#
Sampled via the 4-argument getNormalW; its alpha is not consumed.
txDetail#
txDetailValue.a modulates txMaps.x (specular intensity) where the diffuse alpha is below 1 (recreated/include_new/base/utils_ps.fx:1004).
ksSkinnedMesh_NMDetaill#
txDiffuse#
Output alpha, fresnel-boosted by calculateReflection. Also feeds the detail-blend factor used in both considerNmDetails (normal-detail strength) and considerDetails (color-detail strength).
txNormal#
Sampled via the 4-argument getNormalW; its alpha is not consumed.
txDetail#
txDetailValue.a modulates txMaps.x via considerDetails. The earlier considerNmDetails call leaves the diffuse and txMaps updates to considerDetails because the CARPAINT_SKINNED_NM define short-circuits the txDiffuse and txMaps updates inside considerNmDetails (recreated/include_new/base/utils_ps.fx:1063, 1142).
ksMultilayer_fresnel_nm#
This shader does not sample txNormal — the normal-map slot is occupied by txDetailNM (world-space detail normal-map), whose alpha is read into a float4 but never used.
txDiffuse#
Multiplied component-wise by combined, the per-pixel sum of txDetailX * txMaskValue.{x,y,z,w} (recreated/ksMultilayer_fresnel_nm_ps.fx:66-71). The resulting txDiffuseValue.a then masks the fresnel-driven specular intensity: intensity = txDiffuseValue.a * saturate(intensity) (recreated/ksMultilayer_fresnel_nm_ps.fx:84). Output alpha is forced to 1.
txMask#
Four-channel layer-blend weight; .w selects the fourth detail layer.
txDetail#
Each of txDetailR/G/B/A contributes its full RGBA to combined. Per-detail alpha therefore flows into txDiffuseValue.a and ultimately into the fresnel-driven specular intensity mask described above.
ksFlags#
Флаги используют UV Map для анимации. Чтобы это работало правильно, углы в развёртке должны соответствовать углам в меше. Левая грань развёртки всегда будет считаться стороной флагштока. Флипы работать не будут, т.к. AC всегда использует развёртку Left -> Right, Top -> Bottom для анимации флагов.
Такая развёртка будет работать верно:
TODO
Такая развёртка будет работать неверно:
TODO
Darkening via ksAmbient / ksDiffuse#
They do different things, and choosing the right one matters.
-
Lowering
ksDiffusedarkens the material in direct sunlight but leaves it unchanged in shadow/ambient. The material looks dark in the sun but still bright-grey in shaded areas or indirect light. -
Lowering
ksAmbientdarkens the material in ambient/indirect light but leaves the sun-lit contribution unchanged. The material looks dark in shadow but still bright in direct sun.
For a material that’s too bright overall, lower both equally. But a better framing is to treat them not as brightness knobs but as controls on how much the material responds to each kind of light:
ksAmbient > ksDiffuse— relatively bright in shadow versus its sunlit side; a flat look with low lit/unlit contrast. Suits fabric and foliage, which scatter light into shadow via subsurface and multi-bounce transport.ksDiffuse > ksAmbient— higher sun/shadow contrast; pops in direct light, goes dark in shade. Suits hard opaque surfaces like metal and hard plastic. Also the right choice where the shader imposes an artificial ambient floor — see ksTree.ksAmbient ≈ ksDiffuse— neutral response; the sun/shadow balance is set purely by scene lighting. This is the default for most materials.
Why 0.225 and not the legacy 0.4#
Under LCS, applyGamma computes pow(texture × k × 4.4444, 2.2) (recreated/include_new/base/_gamma.fx:80, with GAMMA_ALBEDO_BOOST = 2 / 0.45 = 4.4444). At k = 0.225 the inner multiplier is exactly 1.0, so the operation reduces to a plain sRGB→linear decode of the texture with no additional gain — the material renders precisely the albedo its texture claims. That makes 0.225 the exact neutral anchor, not an eyeballed one. Above it, gain is applied on top of the decode, and because the pow(2.2) is applied after the multiply, the error compounds fast: at k = 0.4 the multiplier is 1.78 and bright texels blow out to pure white with no recoverable detail.
In Gamma (non-LCS) mode none of that transform runs — ksDiffuse is a plain linear multiplier — yet the same 0.22–0.30 range is still right in practice, for a different reason: CSP + Pure’s sun and sky intensities are considerably higher than stock AC’s, and the legacy 0.4 convention was calibrated against stock. Two different mechanisms, same answer, which is convenient: one set of values works in both modes.
Textures authored too bright#
If a texture still reads as washed-out grey at ksAmbient = ksDiffuse = 0.225, the texture itself is the problem, not the coefficients. Dropping both to 0.16–0.18 will darken it, but it also desaturates the material’s response to lighting changes and makes it behave inconsistently against correctly-authored neighbours.
The cleaner fix is to darken the texture. AC textures are frequently too bright because they are photographs carrying baked lighting, or because their sRGB values were eyeballed rather than measured. Black rubber’s true albedo is ~0.02–0.05; a tyre photo will typically be stored several times brighter than that. Correcting the texture and leaving the coefficients at neutral gives predictable behaviour when the weather, time of day or exposure changes.
Baked AO#
pow(2.2) under LCS crushes dark values, so textures with heavy baked AO get noticeably darker crevices than the same texture in Gamma mode. For materials where baked AO is common — interiors, detailed mechanical parts, anything with geometry that creates crevices — knock ksAmbient and ksDiffuse down by about 0.02–0.04 from the recommendations. For flat surfaces where AO is minimal or absent (road surfaces, flat panels, glass), use the values as-is.
ksPerPixelSimpleRefl vs ksPerPixelReflection#
Both use the same vertex shader, which handles standard transforms, shadow coords, AO, fog, and optional rain vertex modifications.
Key Differences#
| Feature | ksPerPixelSimpleRefl |
ksPerPixelReflection |
|---|---|---|
| Complexity | ~27 lines, lightweight | ~103 lines, full-featured |
| Cbuffer layout | CARPAINT_NM (includes nmObjectSpace) |
CARPAINT_SIMPLE (includes extBounceBack) |
| Reflection source | Sky color gradient (useSkyColor = true) — cheap |
Cubemap sampling — higher quality, more expensive |
| Reflection color tinting | None (getReflParamsBase) |
Yes, via specular color (getReflParams) |
| Specular from texture | No | Yes (GET_SPEC_COLOR → L.txSpecularValue) |
| Bounce-back lighting | No | Yes (includes lightingBounceBack.hlsl, extBounceBack param) |
| Rain FX | None | Full: RAINFX_WET, RAINFX_SHINY, RAINFX_REFLECTIVE, RAINFX_WATER |
| Dither fading | No | Yes (SUPPORTS_DITHER_FADING) |
| AO in lighting | Basic (extraShadow.y) |
Full (extraShadow.y * AO_LIGHTING) |
In Short#
ksPerPixelSimpleRefl is the budget version — it fakes reflections using a sky color gradient, skips rain effects, bounce-back lighting, and specular color tinting. Good for surfaces where accurate environment reflections aren’t needed.
ksPerPixelReflection is the full-featured version — it samples the actual cubemap, supports colored specular reflections, has complete rain FX integration (wet darkening, shiny specular boost, puddle reflections), bounce-back lighting for thin/translucent surfaces, and dither-based LOD fading.
The reflection pipeline itself (Fresnel calculation, blur from ksSpecularEXP, CPL filter) is shared — the difference is just whether the reflection color comes from a sky gradient or the cubemap, and whether reflection params include specular color tinting.
One consequence that is easy to miss: setting useSkyColor also disables energy conservation. The guard at recreated/include_new/base/utils_ps.fx:670 is if (!(P.useSkyColor || …isAdditive…)), so on ksPerPixelSimpleRefl and ksPerPixelMultiMapSimpleRefl the sky sheen is purely additive and the diffuse is never darkened under it. The same material values will therefore look slightly brighter on the SimpleRefl variants than on their cubemap counterparts, and isAdditive has no effect on them at all.
When to use ksPerPixelReflection#
Close-up, important surfaces.
Intended for objects where you actually need to see the environment reflected:
- Car glass (windscreens, windows) — it’s in the “wipers” rain list
- Headlight/taillight glass and reflectors if not baking emissives
- Chrome and metal trim — supports colored reflections via specular texture tinting
- Painted metal signs, barriers with metallic surfaces
- Any surface the camera gets close to where accurate reflections matter
It samples the actual cubemap/environment probe, supports full rain FX (wet darkening, drops, puddles, wiper traces), bounce-back lighting for translucent surfaces, and dither fading for LOD transitions. It also applies energy conservation (darkening diffuse as fresnel increases).
When to use ksPerPixelSimpleRefl#
Distant/secondary scenery.
Intended for objects where reflections are “nice to have” but don’t need accuracy:
- Track-side barriers and fencing
- Distant buildings and scenery objects
- Poles, posts, and secondary track furniture
- Any surface where a faint sky-colored sheen at grazing angles is sufficient
It fakes reflections using a sky color gradient instead of the cubemap — much cheaper, but you’ll never see surrounding geometry reflected. It has no rain FX, no bounce-back lighting, no dither fading, and no specular color tinting of reflections. It’s also excluded from the ColorMask compilation pass, confirming it’s not meant for glass-like surfaces.
The practical rule of thumb#
If the player will notice the quality of the reflections → use ksPerPixelReflection. If the object is far enough away or unimportant enough that a sky-tinted sheen is indistinguishable from a real cubemap reflection → use ksPerPixelSimpleRefl to save GPU cost.
Note: there’s also ksPerPixelMultiMapSimpleRefl, which is a middle ground — it uses the same cheap sky-color reflections but adds normal mapping and rain FX support, making it suitable for detailed track surfaces that need weathering but not accurate environment reflections.
Gamma vs Linear Colour Space: what actually changes#
GAMMA_FIX is a compile-time variant, not a runtime branch. .build/compile.js:216 duplicates every eligible shader category into a second build with GAMMA_FIX = 1, output to a _l destination and titled with a +Γ suffix. That _l build is CSP’s Linear Colour Space mode; the un-suffixed build is Gamma mode.
The Linear Colour Space dropdown in the Weather FX tab selects which of those precompiled packs is loaded. It is not a uniform the shaders branch on.
Two details make this concrete. First, the cbuffer layout differs between the builds — the same four bytes are a different field in each (recreated/include_new/base/cbuffers_common.fx:243-247):
float2 extWindWater;
#ifdef GAMMA_FIX
uint extWfxHint01;
#else
float extFresnelGamma;
#endifextUseNewFog is likewise hardcoded to 1 under GAMMA_FIX and a runtime bit test otherwise (:272-276), and _extPad is the gamma-fix condition in one configuration but GAMMA_DYNAMIC_LIGHTS_MULT in the other (_gamma.fx:57). The engine has to know which variant is bound in order to fill those slots correctly.
Second, a runtime-branching version was built and then withdrawn. GAMMA_FIX_CONDITION is a live cbuffer value, but it only gates the gamma math when GAMMA_FIX_ADAPTIVE is defined — and that is commented out at _gamma.fx:21 with the note // TODO: Disable for public release! Makes things slow. In the shipped build GAMMA_OR(A, B) is (A) unconditionally (:38) and GAMMA_ALBEDO_BOOST is a compile-time constant (:37), so there is no per-pixel branch to pay for.
Nine passes never get an LCS build#
gammaFixSkip (.build/compile.js:204-214) excludes nine modes from the _l duplication, and the list is mirrored exactly by the guard at _gamma.fx:4-13:
MODE_SIMPLEST, MODE_PUDDLES, MODE_GRASSFX, MODE_EMISSIVESAMPLE, MODE_NORMALSAMPLE, MODE_COLORSAMPLE, MODE_SURFACESAMPLE, MODE_SHADOWS_HEAT, MODE_SHADOWS_ADVANCED.
With LCS selected, these passes still run Gamma math. The sampling and shadow passes do not affect material appearance, but two of them do:
MODE_SIMPLEST— the cheapest LOD tier.calculateLightingshort-circuits toSIMPLEST_LIGHTING_FN(albedo) = albedo × ksAmbient(utils_ps.fx:956-958,_include_ps.fx:22-24): flat, no gamma, no sun, no ambient probe. Distant objects therefore render atksAmbient × albedoin both modes. That is broadly consistent with the Gamma near-field look and diverges from the LCS one — a quiet additional argument for authoring in Gamma.MODE_GRASSFX— see below.
Because MODE_GRASSFX and GAMMA_FIX are never both defined, the #ifdef MODE_GRASSFX branch inside the GAMMA_FIX block at _gamma.fx:43-44 is unreachable in the shipped build.
This matters more than any single parameter, because in Gamma mode every one of the “gamma transforms” is an identity. GAMMA_OR(ACTIVE, INACTIVE) resolves to INACTIVE (recreated/include_new/base/_gamma.fx:50), GAMMA_ALBEDO_BOOST is 1, GAMMA_LINEAR(X) is X, and applyGamma returns its input untouched. Nothing is squared, nothing is multiplied by 2.2, nothing is boosted by 4.44.
| Parameter | Gamma (non-LCS) | Linear Colour Space (_l) |
Source |
|---|---|---|---|
ksDiffuse / ksAmbient |
plain linear multiplier | pow(texture × value × 4.44, 2.2) |
_gamma.fx:37,80 |
ksSpecular — ksPerPixelReflection, ksPerPixelNM, ksTree, ksTyres |
plain linear multiplier | plain multiplier, but the Lambert term becomes 4.44 × NdotL^2.2 |
ext_lighting_models/_include_ps.fx:82 |
ksSpecular — MultiMap family, smCarPaint |
plain linear multiplier | 4.44 × ksSpecular^2.2 |
utils_ps.fx:1192,1225; _gamma.fx:66 |
ksSpecularEXP (sun highlight) |
raw exponent | × 2.2 |
_gamma.fx:65 |
ksSpecularEXP (reflection blur) |
raw (1 − EXP/255)² × 6 |
identical — no transform in either mode | utils_ps.fx:621-622 |
fresnelC |
raw | pow(value, 2) |
utils_ps.fx:584 |
fresnelEXP |
raw | × 2.2 |
utils_ps.fx:582 |
fresnelMaxLevel |
raw | pow(value, 2) |
utils_ps.fx:585 |
| Energy-conservation dimming | 1 − fresnel × cplMult |
1 − sqrt(fresnel) × cplMult |
utils_ps.fx:671-673 |
| Extra fresnel shaping | pow(fresnel, extFresnelGamma) |
(not applied) | utils_ps.fx:657-659 |
It is × 2.2, not × 1.6#
Older versions of this document claimed a ×1.6 boost on ksSpecular and ksSpecularEXP. That is the commented-out definition. The live macros are:
// #define GAMMA_BLINNPHONG_EXP(X) GAMMA_OR(1.6 * (X), X)
// #define GAMMA_BLINNPHONG_ADJ(X) GAMMA_OR(1.6 * (X), X)
#define GAMMA_BLINNPHONG_EXP(X) GAMMA_OR(2.2 * (X), X)
#define GAMMA_BLINNPHONG_ADJ(X) GAMMA_OR((GAMMA_ALBEDO_BOOST) * pow(max(X, 0), 2.2), X)— recreated/include_new/base/_gamma.fx:63-66
So under LCS the exponent transform is × 2.2, and GAMMA_BLINNPHONG_ADJ is not a linear scale at all: it sRGB-decodes its argument and multiplies by 4.44. The two are not interchangeable. ADJ crosses unity at X = 4.44^(−1/1.2) ≈ 0.289 — below that it attenuates its input, above it amplifies steeply.
The two lighting paths are identical in Gamma mode#
The non-MultiMap shaders reach specular through calculateLighting_spec() → reflectanceModel() (utils_ps.fx:944-953), the MultiMap family through calculateMapsLighting() (utils_ps.fx:1171-1237). Reduced to their essentials:
non-MultiMap: pow(specBase, EXP(exp)) × ADJ(NdotL) × ksSpecular
MultiMap: pow(specBase, EXP(exp)) × ADJ(ksSpecular × txMaps.x) × NdotLIn Gamma mode EXP and ADJ are identities, so both collapse to pow(specBase, exp) × NdotL × ksSpecular — the same expression. There is no reason to use different ksSpecular or ksSpecularEXP values between the two families, and the ×1.6 second column that used to appear in the material tables was never correct in either mode.
Under LCS they do diverge, because ADJ is applied to different operands. The correction factor is not 1.6 but an exponent change: to match a non-MultiMap ksSpecular of S, a MultiMap material would need S^(1/2.2) — e.g. 0.20 → 0.48, 0.10 → 0.35, 0.50 → 0.73. At the same ini value MultiMap specular is roughly S^1.2 of the non-MultiMap result, so around 7× dimmer at S = 0.2. Do not apply this correction. CSP’s guidance is to author for Gamma; the divergence is documented here only so the LCS result is not surprising.
Why you cannot cleanly hedge specular between the modes#
It is tempting to split the difference — pick a ksSpecular that is acceptable in both. You can’t, because the two sides of the ratio scale differently under LCS. Peak specular is linear in ksSpecular (4.44 × S for the non-MultiMap family), but diffuse goes through pow(tex × k × 4.44, 2.2), which is non-linear in texture brightness. At k = 0.22, comparing LCS to Gamma diffuse:
| Texture value | Gamma diffuse | LCS diffuse | LCS ÷ Gamma |
|---|---|---|---|
| 0.50 | 0.110 | 0.207 | 1.88× |
| 0.25 | 0.055 | 0.045 | 0.82× |
So a mid-grey material gets ~1.9× brighter diffuse under LCS while a dark one gets slightly darker. Against a fixed 4.44× specular boost, the specular-to-diffuse ratio shifts by roughly 2.4× on the mid-grey and 5.4× on the dark one. There is no single multiplier that reconciles them — any hedge you pick is wrong for some texture range, and wrong in Gamma mode too.
This is the strongest practical argument for CSP’s “author for Gamma” guidance: it is the only mode in which the parameter has a stable, texture-independent meaning.
fresnelEXP is scaled again on some shaders#
REFLECTION_FRESNELEXP_BOUND selects between two fresnel ramps (utils_ps.fx:595-599):
#ifdef REFLECTION_FRESNELEXP_BOUND
float fresnel = pow(input, IS_ADDITIVE_VAR == 0 ? max(P.fresnelEXP, 1) : P.fresnelEXP);
#else
float fresnel = pow(input, P.fresnelEXP * lerp(0.8, 1, P.fresnelMaxLevel));
#endifThe MultiMap family defines it; ksPerPixelReflection, ksPerPixelNM, ksPerPixelSimpleRefl and ksTyres do not. On those four the exponent is additionally scaled by lerp(0.8, 1, fresnelMaxLevel) — a matte material with fresnelMaxLevel = 0.1 gets 0.82× the exponent it asked for, broadening the ramp. Note fresnelMaxLevel has already been squared at this point under LCS, so the factor differs between modes too.
This is why the tables pair fresnelEXP = 5.0 with high-fresnelMaxLevel materials and 4.0–4.5 with low ones: glass at fresnelMaxLevel = 1.0 keeps the full 5.0 and lands on Schlick exactly, glossy plastic at 0.50 gets ≈4.5, and a rough material at 0.14 asking for 4.0 gets ≈3.3 — which is the correct direction anyway, since microfacet averaging genuinely flattens the fresnel ramp on rough surfaces.
smin, not min#
The cap is applied with a soft minimum (utils_ps.fx:550-554, 604):
float smin(float a, float b){
float h = saturate(b - abs(a - b));
return min(a, b) - h * h / max(0.00001, b * 3) * (1 - b);
}It rolls the value off in the region where the raw fresnel is approaching the cap, then releases. Worked example for a material capped at 0.30 whose raw fresnel has reached 0.316 (about 75° off normal): h = 0.284, subtracted term = 0.063, result 0.237 — noticeably under the cap. At true grazing (input = 1) the raw value is far above the cap, h saturates to 0, and the result is exactly fresnelMaxLevel.
So the cap is real at the extreme rim, but the transition into it is softer than a hard min would give. Expect a material to sit meaningfully below its stated cap through the mid-grazing band.
ksPerPixelMultiMap and ksPerPixelMultiMap_NMDetail#
These shaders share the ksAmbient/ksDiffuse pipeline and the whole of calculateReflection() with ksPerPixelReflection, and as shown above their specular expression is identical in Gamma mode. Use the same material values for both families. What genuinely differs:
| Aspect | ksPerPixelReflection, ksPerPixelNM, ksTree |
ksPerPixelMultiMap, ksPerPixelMultiMap_NMDetail |
|---|---|---|
| Specular function | calculateLighting_spec() → reflectanceModel() |
calculateMapsLighting() |
txMaps modulation |
none — no txMaps slot |
specularValue *= txMaps.r; specularExp = txMaps.g × exp + 1; finalMult = txMaps.b |
| Sun specular | not available | ksPerPixelMultiMap only — second Blinn-Phong lobe. Absent from the NMDetail variant. |
REFLECTION_FRESNELEXP_BOUND |
not defined → exponent scaled by lerp(0.8, 1, fresnelMaxLevel) |
defined → exponent clamped to max(fresnelEXP, 1) when isAdditive == 0 |
R.isCarPaint |
left at false |
set true — changes how isAdditive is interpreted, see below |
Because txMaps scales the cbuffer values down per-pixel (utils_ps.fx:883-892), the base values act as a maximum. On a car material where txMaps.b is a copy of the AO map, reflections are attenuated in crevices and left untouched on flat exposed surfaces — so the recommendations assume no attenuation.
Sun specular#
sunSpecular / sunSpecularEXP exist only in the default MultiMap cbuffer layout (cbuffers_ps.fx:220-236, which also defines HAS_SUN_SPECULAR). ksPerPixelMultiMap_NMDetail uses CARPAINT_NMDETAILS, which is in NO_SUNSPEC_MAT (utils_ps.fx:1164) and routes to calculateMapsLighting_woSun — the parameters do not exist there at all.
The lobes are combined as (utils_ps.fx:1190-1194):
specularPart = (pow(specBase, EXP(specularExp)) × ADJ(specularValue)
+ pow(specBase, EXP(sunSpecularExp)) × ADJ(sunSpecularValue × txSpecularValue))
× extSunSpecularMult × extSpecularColor × shadowNote that extSunSpecularMult multiplies both lobes, not just the sun lobe. extSpecularColor is the sun colour, so specular is already tinted and scaled by scene lighting before your value is applied — which is why ksSpecular above ~0.3 blows out on dielectrics.
After applyTxMaps: sunSpecularValue = txMaps.b × txMaps.g × sunSpecular and sunSpecularExp = txMaps.g × sunSpecularEXP + 1. The point of the second lobe is a tight, bright sun pinpoint (high sunSpecularEXP) riding on a broader, softer base highlight (lower ksSpecularEXP).
isAdditive is interpreted differently per family#
IS_ADDITIVE_VAR reads the cbuffer isAdditive for most object shaders, but the guard that skips energy conservation branches on isCarPaint (utils_ps.fx:670):
| Family | isCarPaint |
Energy conservation skipped when |
|---|---|---|
ksPerPixelReflection, ksPerPixelNM |
false |
isAdditive != 0 (any non-zero value) |
MultiMap family, smCarPaint |
true |
isAdditive == 1 exactly |
Additionally, isAdditive == 2 on an isCarPaint material switches the reflection-blur divisor from 255 to 8 (utils_ps.fx:620-621), collapsing reflBlur to zero for any ksSpecularEXP ≥ 8. That is a car-paint-specific mode; do not use it as a general “additive” value.
isAdditive = 1 is portable across both families and is the correct setting for glass.
Multilayer fresnel (roads, terrain)#
ksMultilayer_fresnel_nm has no cubemap reflections at all. The fresnel triple drives specular intensity instead, and ksSpecular is overwritten before use (recreated/ksMultilayer_fresnel_nm_ps.fx:78-88):
float coefficient = saturate(1 - dot(normalW, -toCamera));
float intensity = min(
tarmacSpecularMultiplier * pow(coefficient, fresnelEXP * GAMMA_ACTUAL_VALUE)
+ GAMMA_OR(pow(saturate(fresnelC), 1.75), fresnelC),
GAMMA_OR(pow(saturate(fresnelMaxLevel), 2), fresnelMaxLevel));
intensity = txDiffuseValue.a * saturate(intensity);
…
L.specularValue = intensity; // ksSpecular is not used, it's not a mistakeParameter roles:
ksAmbient/ksDiffuse— as elsewhere.ksSpecular— unused. Set it to 0 for clarity.ksSpecularEXP— Blinn-Phong highlight shape only;× 2.2under LCS. There is no reflection blur to worry about here.fresnelC— base specular level head-on. Raised to 1.75 under LCS, not 2.0 — this shader keeps its own inline transform and does not go throughcalculateReflection.fresnelEXP— steepness toward grazing.× 2.2under LCS.fresnelMaxLevel— hard cap on specular intensity. Squared under LCS. Note this is a plainmin, not thesminused incalculateReflection.tarmacSpecularMultiplier— linear scale on the angle-dependent term, applied before thefresnelCaddition. The primary “how shiny overall” knob.magicMult— brightness multiplier on the blended diffuse.
Three things that catch people out:
fresnelMaxLevel is not mode-symmetric here. In calculateReflection the LCS squaring is cancelled by a sqrt() in the energy-conservation term (see below), so the ini value means the same thing in both modes. This shader has no energy-conservation term, so nothing cancels: the same material is markedly less shiny under LCS. Author for Gamma and accept the difference, or split the value if you target one mode exclusively.
magicMult scales the specular too. Line 72 is txDiffuseValue *= magicMult on the full float4, so it scales alpha, and line 84 masks specular by that alpha. Raising magicMult to fix diffuse brightness also raises the highlight.
txDiffuseValue.a masks the specular completely. Where the blended diffuse alpha is zero, specular is zero regardless of every other parameter.
GrassFX reads these materials through a completely different path#
Terrain shaders are compiled a second time into the fxgrass pack with MODE_GRASSFX, and GrassFX samples that pass to decide what colour to make the grass blades. That pass is not the material you tuned:
MODE_GRASSFXdefinesSIMPLEST_LIGHTING(recreated/include_new/base/_flags.fx:310), socalculateLightingreturns at its first line (utils_ps.fx:956-958) — before any specular, fresnel, sun or ambient-probe work.- For this mode the macro resolves to
SIMPLEST_LIGHTING_FN(albedo) = albedo × INPUT_AMBIENT_K(_include_ps.fx:18-20).
So the colour GrassFX picks up is, in full:
txDiffuse × combined × magicMult × ksAmbientThree consequences worth internalising:
ksDiffuseis not read at all in this pass. OnlyksAmbientreaches GrassFX. If you split the two on a terrain material — sayksAmbient0.20 /ksDiffuse0.28 to bias sun response — the grass tint follows the 0.20 and drifts away from the ground it is standing on. KeepksAmbientequal toksDiffuseon any terrain material GrassFX samples. That is the reason the multilayer recommendations pair them, and it is a harder constraint there than on ordinary materials.magicMultmoves the grass tint too, since it is applied totxDiffuseValuebefore lighting. RetuningmagicMultto fix ground brightness will shift grass colour with it.MODE_GRASSFXis ingammaFixSkip, so this pass is always Gamma math. Under LCS,flgGrass_cs_generation.fx:125converts the sampled colour up to linear withapplyGamma(color.rgb, 1), which is where the two colour spaces are reconciled.
The MODE_GRASSFX block at lines 94-98 of the shader (specularExp → 40, specularValue → 0.3 when wet) does nothing at all. It is written after L.calculate() has run, RAINFX_REFLECTIVE_WATER_ROUGH does not read L, and SIMPLEST_LIGHTING would have discarded both fields regardless. Do not rely on it to rescue low dry-weather values.
| Parameter | Purpose |
|---|---|
multR/G/B |
World-space UV scale for each detail texture layer (float) |
multA |
World-space UV scale for the 4th detail layer (float2) |
magicMult |
Global brightness multiplier on blended diffuse (and alpha) |
tarmacSpecularMultiplier |
Overall scale on the fresnel-driven specular intensity |
detailNMMult |
UV scale for the detail normal map (float2) |
extBounceBack |
Bounce-back lighting intensity per mask channel (float4) |
ksMultilayer and ksMultilayer_objsp are different shaders again: they have no fresnel block at all, and txDiffuseValue.a (after multiplication by the combined detail layers) feeds setSpecularMult directly, so ksSpecular is live on those two.
ksTree#
No fresnelC / fresnelEXP / fresnelMaxLevel / sunSpecular / sunSpecularEXP — ksTree is NO_CARPAINT and has no cubemap and no fresnel system whatsoever.
It is a specialised foliage shader with a floored ambient model (recreated/ksTree_ps.fx:14-15):
#define NO_EXTAMBIENT
#define AMBIENT_SIMPLE_FN(x) saturate((x).y * 0.4 + 0.6)Ambient is therefore never below 60% of maximum regardless of which way a normal points, and there is no IBL sampling to modulate it. This is why foliage needs values below the 0.22 neutral, and — more usefully — why ksAmbient should sit below ksDiffuse here. The floor is an artificial brightening of the shaded side; lowering ksAmbient counteracts it directly, while lowering ksDiffuse by the same amount would only flatten the sunlit canopy you actually want to keep. 0.16 / 0.20 gives foliage that reads as lit without shaded leaves going milky.
Under LCS only, the shader applies the same curve a second time as a vertex-AO multiplier (ksTree_ps.fx:64-66):
if (GAMMA_FIX_ACTIVE) {
pin.Ao *= saturate(normalW.y * 0.4 + 0.6);
}So down-facing foliage is darkened twice in the _l build and once in Gamma. If a canopy looks correct in one mode and heavy underneath in the other, this is why.
ksSpecular must be 0. A billboard stands in for thousands of randomly-oriented leaves, whose aggregate optical behaviour has no coherent specular lobe; any highlight instantly reveals the quad as a flat glossy sheet. ksTree still runs reflectanceModel() in the normal path — calculateLighting only skips specular when SIMPLIFED_SPECULARS and NO_EXTSPECULAR are both defined (utils_ps.fx:964), and SIMPLIFED_SPECULARS is only set in reduced-detail modes — so the highlight is genuinely there if you leave a value in.
Gotcha: what fresnelMaxLevel actually costs you#
The squaring and the sqrt() cancel exactly#
Earlier versions of this document treated the LCS squaring of fresnelMaxLevel and the sqrt() in the energy-conservation term as two compounding problems, and drove the recommended ini values down into the 0.02–0.05 range to compensate. That was double-counting. The two operations are inverses of each other.
Squaring happens at utils_ps.fx:585:
P.fresnelMaxLevel = GAMMA_OR(pow(saturate(P.fresnelMaxLevel), 2), P.fresnelMaxLevel);The diffuse dimming happens at utils_ps.fx:671-673:
float fixBase = saturate(P.metallicFix * fresnel);
fixBase = GAMMA_OR(sqrt(fixBase), fixBase);
finalColor *= saturate(1 - fixBase * (…));Both are wrapped in GAMMA_OR, so both are active in LCS and neither is active in Gamma. Working through a material capped at fresnelMaxLevel = m:
- LCS: the cap becomes
m², thenfixBase = sqrt(m²) = m, dimming= m. - Gamma: the cap stays
m, nosqrt, dimming= m.
Identical. fresnelMaxLevel is directly the fraction of diffuse energy the surface gives up at grazing angles, in both modes. That makes it easy to reason about physically: 0.5 means half the diffuse is traded for reflection edge-on; 0.1 means a tenth.
The two modes do differ on the additive side, where nothing cancels — LCS adds reflColor × m² while Gamma adds reflColor × m. So LCS is the more conservative of the two: same dimming, less reflected light put back. A material tuned to look right in Gamma will never over-reflect under LCS.
The real problem: the cubemap doesn’t dim in shade#
The genuine artefact, which survives the correction above, is this: the cubemap always sees the full sky regardless of whether the surface is shadowed. In direct sun the diffuse is bright and a modest cubemap contribution is imperceptible. In shade the diffuse collapses but the cubemap term does not, so it becomes proportionally dominant — and on a rough material (reflBlur > ~3) the cubemap has degraded to a flat averaged sky colour, so what you get is a uniform blue wash rather than a reflection.
This is a real reason to cap rough materials below their physical grazing reflectance, but the correct magnitude is around 0.08–0.20 for rough dielectrics, not the 0.02–0.05 the double-counted analysis produced. Below ~0.05 the material stops responding to its environment at all and reads as flat matte paint.
Why raising the caps does not bring the wash back#
The obvious objection to the corrected values is that glossy leather went from fresnelMaxLevel = 0.078 to 0.28 — surely that is nearly four times more sky tint. It is not, because the cap and the exponent were changed together, and the exponent is what decides how much of the surface sees any reflection at all.
The old values paired a low cap with a low fresnelEXP of 2.3. A low exponent is a broad ramp: reflection climbs immediately away from head-on and then sits pinned at the cap across most of the visible surface. That flat, everywhere-present contribution is precisely the blue wash the old document was trying to eliminate — the low cap was treating a symptom the low exponent was causing.
The corrected values pair a higher cap with fresnelEXP = 5.0, which is the real Schlick ramp. Reflection stays at F0 across the bulk of the surface and only climbs in the last ~30° before grazing. Evaluating both for glossy leather in Gamma mode, including the lerp(0.8, 1, fresnelMaxLevel) exponent scale and smin:
| Angle from normal | Old — C 0.03, EXP 2.3, max 0.078 |
New — C 0.04, EXP 5.0, max 0.28 |
|---|---|---|
| 0° (head-on) | 0.030 | 0.040 |
| 30° | 0.053 | 0.040 |
| 45° | 0.078 (capped) | 0.045 |
| ~58° | 0.078 | 0.080 — crossover |
| 75° | 0.078 | 0.230 |
| 90° (grazing) | 0.078 | 0.280 |
Below about 58° off normal the new values reflect less than the old ones. On typical geometry that covers most of what is on screen. Above it, the reflection rises to a rim highlight — which is what leather, vinyl and painted surfaces actually do, and which reads as a lit edge rather than as a tinted material.
The second axis helps too. Glossy leather’s ksSpecularEXP went from 35 to 60, which moves reflBlur from 4.47 to 3.51 — a sharper probe sample. Sharper reflections carry recognisable environmental structure; it is the maximally blurred ones that collapse to a flat colour and read as tint. So both changes push away from the wash, not toward it.
The general rule: a flat mid-surface reflection is far more objectionable than a bright rim, because the eye reads the first as “this material is the wrong colour” and the second as “this edge is catching the light”. If a material looks washed out, suspect fresnelEXP being too low before you reach for fresnelMaxLevel.
Where the wash is still a real risk#
One material in the tables genuinely does present a flat blurred-sky term at every angle, and it is not a tuning error: Rough Metal, with fresnelC = 0.55 and reflBlur ≈ 3.7. High fresnelC means high reflectance head-on, which is the defining property of a conductor, and heavy blur means what comes back is averaged environment. Rough metal really does read sky-coloured in the real world, so this is correct — but it is the one entry where a low-quality or badly-placed cubemap probe will show up plainly, and the one place where lowering fresnelC is a reasonable artistic concession. Smooth Metal and Reflector share the high fresnelC but sample sharply enough (reflBlur 0.19 and 0.06) that they show real reflections instead.
Reflblur reference#
reflBlur uses the raw ksSpecularEXP in both modes — there is no GAMMA_OR on utils_ps.fx:621-622, so the × 2.2 exponent transform does not apply here. Reflection sharpness is mode-independent; only the sun highlight gets tighter under LCS.
reflBlurBase = saturate(1 - ksSpecularEXP / 255)
reflBlur = pow(reflBlurBase, 2) * 6ksSpecularEXP (raw) |
reflBlur | Cubemap quality | Sensible fresnelMaxLevel |
|---|---|---|---|
| 200+ | < 0.3 | Near-mirror | 0.85–1.00 — use physical values |
| 120–200 | 0.3–1.6 | Recognisable but soft | 0.50–0.70 |
| 50–120 | 1.6–3.2 | Blurry, transitional | 0.25–0.50 |
| 20–50 | 3.2–4.8 | Averages to sky colour | 0.10–0.25 |
| < 20 | 4.8–6.0 | Pure sky-colour tint | 0.04–0.12 |
Metals are the exception at the rough end: fresnelC stays high because F0 is a property of the material, not the finish, so a rough metal is head-on reflective while still needing a capped grazing level.
Special case: FORCE_BLURREST_REFLECTIONS#
ksTyres samples the cubemap at level 15 — maximum blur — regardless of ksSpecularEXP (utils_ps.fx:504-505). The reflection is therefore always a uniform sky tint, and against dark rubber that is exactly the worst case for the shade problem above. Treat tyres as being permanently in the bottom row of the table no matter what exponent you set, and keep fresnelMaxLevel at 0.04–0.10. See §23. Tyre Rubber.
ksTyres also scales reflection by saturate(normalW.y + 1) (recreated/ksTyres_ps.fx:93), so the underside of the tyre self-attenuates, and by fresnelMaxLevel × (1 - dirtyLevel × txDirty.a) (:52, :95) — a dirty tyre loses its reflection automatically.
Special case: car txMaps textures#
On MultiMap car materials the txMaps.b channel becomes R.finalMult, scaling the entire reflection contribution. In most car mods txMaps.b is a copy of the AO map, which attenuates reflections in crevices but leaves flat exposed surfaces at 1.0. The fresnelMaxLevel recommendations assume no attenuation on exposed surfaces.
isAdditive and energy conservation#
Setting isAdditive = 1 disables the energy-conservation term entirely — the diffuse is not darkened under the reflection. This is physically correct for glass: a window reflecting the sky should not darken the interior behind it. It is also what makes fresnelMaxLevel = 1.00 safe on window glass, where an opaque material at the same value would go completely black at grazing angles.
For opaque materials isAdditive = 0 is correct — the surface really does lose diffuse energy to reflected light. All glass sections (§18a, §18b, §19, §20) use isAdditive = 1, including frosted glass, which is still transmissive despite its roughened surface.
Remember the family difference documented above: on ksPerPixelReflection / ksPerPixelNM any non-zero value works, but on the MultiMap family only 1 does, and 2 means something else entirely.