BLANCO

Developer · blancodagoat.dev

← All parts

RENDERING LAWS

GTA V / RAGE file formats › Part 7

How GTA actually shades: the deferred screen-space skin subsurface kernel with all 13 taps, the cloth material model, the procedural sky and cloud equation with its timecycle mapping, CloudHats, and drawable-authored lights with their distance and cone falloff laws.

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.

23. Skin subsurface: the deferred SSS kernel

GTA's skin subsurface scattering is not a material or BRDF parameter. It is a screen-space diffusion blur (ApplySSS, plus an ApplySSS_HQ variant) that runs in the deferred lighting pass, over the already-lit buffer, on pixels flagged as skin in the G-buffer. Nothing in a ped drawable's shader parameters sets a scattering radius; if you are porting GTA skin to another renderer, the thing to reproduce lives in the lighting pass, not in the material.

The blur, verbatim

half stepDistance = (skinParams.w * 2.0f) / surfaceInfo.depth;
float2 scl = deferredLightScreenSize.zw * stepDistance;
half3 colorSum = colorM.rgb * skin_kernel[0].rgb;
for (int i = 0; i < 12; i++) {
    float2 texcoord = screenPos.xy + kernel[i].xy * scl;
    float4 color = tex2Dlod(gDeferredLightSampler, float4(texcoord, 0, 0));
    color.a = 1.f - color.a * alphaScale;
    colorSum.rgb += lerp(color.rgb, colorM.rgb, color.a) * skin_kernel[i+1].rgb * skinColourTweak.rgb;
}

Three laws follow directly:

  • The radius is screen-space and scales as 1/depth. Because stepDistance = skinParams.w * 2 / depth, the diffusion covers a fixed world size that shrinks on screen with distance, which is what a physical scattering radius does. A port has to reproduce the world-space radius, not a pixel one.
  • The blur is per-channel. Every tap is weighted by an RGB triple, so red diffuses much further than green or blue. That is the defining feature of skin.
  • The taps are alpha-gated. lerp(color.rgb, colorM.rgb, 1 - color.a * alphaScale) pulls a tap back toward the centre pixel where the neighbour is not skin, so scattering never bleeds the background across a silhouette.

The 13-tap kernel

Weight [0] is the centre tap; [1..12] pair with the 12 Poisson offsets below.

tapRGB
0 (centre)0.3955620.8173920.930767
10.03119020.001061720.000313938
20.07674080.02739620.00450141
30.08719750.04731790.0143271
40.03159230.001096970.000322965
50.03626540.001616710.000436185
60.04123120.002460150.000571736
70.1007450.08872460.0460233
80.0365110.001650530.000442538
90.03183290.001118680.000328423
100.03541020.001504540.000414361
110.05056920.005249010.000862699
120.04515160.003410530.000688329

Poisson offsets (unit disc): (-0.876,-0.407) (-0.181,-0.385) (-0.215,0.254) (-0.568,-0.774) (-0.841,0.285) (0.250,-0.779) (0.224,0.023) (0.077,0.881) (-0.395,0.870) (0.615,0.658) (0.678,0.171) (0.632,-0.434).

Each channel sums to exactly 1.0, so the kernel is energy-conserving and does not tint. Everything skin-like is in the distribution: off-centre totals are R 0.6044, G 0.1826, B 0.0692, so red carries 3.31× green and 8.73× blue away from the centre tap. Skin's warmth under this model is not a colour shift in the kernel; it is red travelling further and surviving into shadowed neighbours that green and blue never reach. A Burley / subsurface-profile port should be fitted to that 1 : 0.30 : 0.11 spread ratio, not to a stock "skin" preset.

The absolute radius

The engine defaults are:

skinPassParams        = (1.1, 0.8, 1.0, 2.4)     // .w drives stepDistance
subsurfaceColorTweak  = (1.0, 0.8471, 0.659)     // skinColourTweak
deferredLightScreenSize.zw = (1/screenWidth, 1/screenHeight)

So skinParams.w = 2.4 and the tap radius is 4.8 / depth pixels (the offsets are a unit disc; scl converts pixels to UV). Converting to world units, the depth cancels:

radius_world = (4.8 / d) × d × 2·tan(fovY/2) / H = 4.8 × 2·tan(fovY/2) / H
screen heightvertical FOVradius
108050°4.14 mm
108040°3.24 mm
216050°2.07 mm

~4 mm at the game's 1080p target is a physically sensible dermal scattering radius, which corroborates the derivation. But note that the radius depends on resolution, a property of the technique rather than of skin: the same constant halves the world radius at 4K. Port the ~4 mm at 1080p/50°, not the constant 2.4.

skinColourTweak = (1.0, 0.8471, 0.659) multiplies every off-centre tap, a second warm tint applied on top of the energy-conserving kernel. The kernel alone does not tint, but the pass as a whole does, so a faithful port carries both.

The sun's separate skin parameters

The directional-light shader carries its own set for the sun's wrapped and transmitted skin lighting: SSS_Params = (fNormalShift, fShadowDistScale, fOverScatter, fAmbientScale) and SSS_Params2 = (fShadowOffset, …), with commented reference values (0.005, 0.1, 0.3, 0, 0.3). That is a separate mechanism from the diffusion kernel above; don't conflate the two when porting.

Provenance: read directly from the engine's deferred-lighting and directional-light shader source; the kernel weights, Poisson offsets and default constants are quoted verbatim. Independent of any particular renderer or measurement.


24. GTA's cloth material model

✅ Ped clothing does not use the cloth shaders

Measured over an installed game's 127,805 indexed drawables: not one cloth_* shader appears on any ped drawable. Every upper-body garment sampled (136 drawables, 357 geometries) breaks down as:

shadergeometriesshare
ped31187.1%
ped_wrinkle_enveff329.0%
ped_enveff51.4%
ped_wrinkle_cloth_enveff30.8%
ped_wrinkle_cloth30.8%
ped_wrinkle20.6%
ped_alpha10.3%

The cloth_* family below therefore describes world and prop cloth (flags, awnings, curtains), not player garments. GTA does have a ped cloth flag (ped_default_cloth, ped_wrinkle_cloth, ped_wrinkle_cloth_enveff), but it covers ~1.7% of upper-body garments; seven eighths of GTA's clothing is plain ped. The per-garment variation the game actually ships is wrinkle and enveff (§22), not fabric type.

There is no fabric type anywhere in the cloth path

The whole family is four megashader variants, 14-28 lines each, every line a #define:

shaderwhat it adds
cloth_defaultnothing
cloth_normal_specnormal map, specular map
cloth_normal_spec_tnt…plus the tint ramp
cloth_spec_alphaspecular, alpha

They differ by which textures are bound, not by what fabric they are. There is no weave, no fibre model, no sheen term, no anisotropy, and nothing that distinguishes satin from cotton. A tool wanting fabric subtypes cannot recover them from GTA assets, because the data does not carry them.

What the cloth flag actually does: two-sided normals

USE_CLOTH is consumed in exactly two places in the megashader, and both do the same thing. The pixel shader flips the normal for back faces:

#if CLOTH
    IN.worldNormal.xyz *= fFacing;
#endif

Cloth is lit two-sided, and there is no fabric BRDF beyond that.

The backlit look is the skin SSS pass

USE_BACKLIGHTING_HACK is defined on all four cloth shaders, and it writes specularSkin = 1.0 into the G-buffer, the same channel the skin marker uses:

#ifdef USE_BACKLIGHTING_HACK
// use the skin to mark that it is backlit cloth
    OUT.col3.a = Pack2ZeroOneValuesToU8(1.0f-1.f, 0.0f);
#endif

The deferred pass then treats those pixels as skin and runs the 13-tap diffusion kernel of §23 over them. The comment says so in as many words, and "hack" is the source's own name for it. GTA cloth translucency is the skin subsurface diffusion: cloth and skin share one mechanism. A renderer that models GTA cloth with a fuzz/sheen grazing term is making a different physical claim (silhouette brightening) than the game does (light diffused through the fabric at backlit angles).

Open edges, noted rather than settled: whether specularSkin = 1.0 also routes cloth through the sun's wrapped skin lighting (SSS_Params, §23) in the direct response, and which prop drawables actually carry the family. The ped census answers the ped half (none); world and prop drawables are presumably where it lives.

Provenance: shader tables and code read directly from the engine's megashader and lighting shader source; the ped shader census is our own measurement over an installed game and is marked ✅ accordingly.


25. Sky & clouds: the procedural sky and CloudHats

GTA's sky carries two separate cloud systems: procedural sky-shader clouds driven by timecycle variables, and CloudHats, which are authored cloud geometry placed by a data file and selected per weather. They coexist, and neither replaces the other.

The procedural cloud path is two passes, not one

The cloud pixel shader does not sample the static baseperlinnoise3channel texture directly. That texture is the seed; a generation pass renders it into a Perlin render target, and the sky pass samples the RT. Sampling the seed as "the perlin" produces plausible-looking but structurally wrong clouds.

Pass 1: noise generation. The seed packs three fBm octaves into R/G/B (hence "3channel"); the loop reads channel i for octave i, so they are octaves, not layers. The result is thresholded:

half2 p = ((texCoord.xy - 0.5) * noiseScale + 0.5) / 128.0 + noisePhase;
float finalnoise = 0, amp = 1;
for (int i = 0; i < 3; i++) {
    half3 noiseVal = (tex2D(NoiseSampler, p.xy).xyz - 0.5) * 2.0;
    finalnoise += noiseVal[i] * amp;      // octave i reads CHANNEL i
    p *= noiseFrequency;  amp *= 0.5;
}
finalNoise = (finalnoise / 1.75) * 0.5 + 0.5;

minValue = noiseThreshold - noiseSoftness;
maxValue = noiseThreshold + noiseSoftness;
R = smoothstep(minValue, maxValue, finalNoise);                     // coverage
G = smoothstep(minValue + densityOffset, maxValue + densityOffset, finalNoise); // shadow copy
B = finalNoise;                                                     // raw fBm
RT channelContentsConsumed as
Rthresholded coveragemain cloud body (and its sun-offset copy)
Gsame smoothstep shifted by the density offsetthe shadow term, offset from the body
Braw fBm, unthresholdedsmall clouds and the edge gate, which is why they are wispier

noisePhase is the animation input, so cloud determinism per timestamp is a property of pass 1. The threshold, softness and smoothstep live here; the density remap in pass 2 has none. Both statements are needed, because either one alone misleads.

Constant lanes (verbatim from the shader)

cloudDetailConstants .x cloudEdgeDetailStrength     .y cloudEdgeDetailScale
                     .z cloudOverallDetailStrength  .w cloudOverallDetailScale
cloudConstants1      .x cloudBaseStrength           .y cloudDensityMultiplier
                     .z cloudDensityBias            .w cloudFadeOut
cloudConstants2      .x cloudShadowStrength         .y cloudOffset
                     .z cloudOverallColorStrength   .w cloudHdrIntensity
smallCloudConstants  .x smallCloudDetailScale       .y smallCloudDetailStrength
                     .z smallCloudDensityMultiplier .w smallCloudDensityBias
speedConstants       .x smallCloudOffset            .y overallDetailOffset
                     .z edgeDetailOffset
cloudBaseMinusMidColour, cloudMidColour,
cloudShadowMinusBaseColourTimesShadowStrength, smallCloudColorHdr
 -  precombined colour terms

Timecycle → lane mapping

Timecycle keyframeShader lane
sky_cloud_base_col / sky_cloud_mid_col / sky_cloud_shadow_colbase / mid / shadow colours (precombined as above)
sky_cloud_base_strengthcloudBaseStrength
sky_cloud_density_mult / sky_cloud_density_biascloudDensityMultiplier / cloudDensityBias
sky_cloud_edge_strength / sky_cloud_overall_strengthcloudEdgeDetailStrength / cloudOverallDetailStrength
sky_cloud_overall_colorcloudOverallDetailColor
sky_cloud_fadeout / sky_cloud_hdrcloudFadeOut / cloudHdrIntensity
sky_cloud_shadow_strength / sky_cloud_offsetcloudShadowStrength / cloudOffset
sky_cloud_dither_strengthdither strength (see below)
sky_small_cloud_detail_scale / _strengthsmallCloudDetailScale / smallCloudDetailStrength
sky_small_cloud_density_mult / _biassmallCloudDensityMultiplier / smallCloudDensityBias
sky_cloud_gen_frequency / _scale / _threshold / _softness / _density_offsetpass-1 noiseFrequency / noiseScale / noiseThreshold / noiseSoftness / noiseDensityOffset

Two lanes have no timecycle variable at all. cloudEdgeDetailScale and cloudOverallDetailScale come from visualsettings.dat (cloudgen.edge.detail.scale = 16.0, cloudgen.overlay.detail.scale = 8.0 as shipped) and are constant across weather and time. cloudgen.frequency and cloudgen.scale appear in both places: visualsettings supplies the default and the timecycle overrides it per weather.

Useful calibration points: sky_cloud_density_mult is 0 for CLEAR at noon, so clear weather genuinely has no sky clouds. CLEAR at 06:00 reads 2.80, CLOUDS at noon 2.43, OVERCAST at noon 3.68. Overcast is a density change, not an RGB scale: density rises ~3.6× while the colour goes achromatic.

UV construction and the density equation

// vertex stage
const half2 ofs = (cloudOffset / 10.0) * (sunDirection.xyz - viewDir.xyz).xy; // offsets base layer AWAY from the sun
detailTexCoord.xy      = ((texCoord0.xy - 0.5) + edgeDetailOffset)    * cloudEdgeDetailScale;
detailTexCoord.zw      = ((texCoord0.yx - 0.5) + overallDetailOffset) * cloudOverallDetailScale;
smallCloudTexCoords.xy = ((texCoord0.xy - 0.5) + smallCloudOffset)    * smallCloudDetailScale;

texCoord0.yx on the overall-detail set is a genuine swizzle, stated in the source, not a typo. The Perlin RT is sampled twice (at texCoord0, channels .x and .y; and at the sun-offset UV, .x); the channels are not interchangeable and must never be collapsed to luminance.

// pixel stage
half cloudBase = offsetPerlin.x;
cloudBase += detailOverall * cloudOverallColorStrength * cloudOverallDetailStrength;
cloudBase  = cloudBase * cloudBaseStrength * perlin.x;

// x = large clouds, y = small clouds -  two channels carried in parallel
half2 detailAmt = half2(detailOverall, 1.0 - perlin.y)
                * half2(cloudOverallDetailStrength, smallCloudDetailStrength);
detailAmt += detailEdge * half2(cloudEdgeDetailStrength, smallCloudDetailStrength)
           * saturate(1.0 - cloudBase * 2.0) * perlin.y;

const half2 cloud        = half2(perlin.x, 0.0) + detailAmt;
const half2 cloudDensity = saturate(half2(cloudDensityMultiplier, smallCloudDensityMultiplier)
                                    * cloud
 - half2(cloudDensityBias, smallCloudDensityBias));
half2 amount = cloudDensity * cloudDensity;                  // squared, not smoothstepped
const half fade = saturate(viewDir.z * 5.0 - cloudFadeOut);  // horizon weighting

half3 finalBaseColorMinusMid = shadowAmount * cloudShadowMinusBaseColourTimesShadowStrength
                             + cloudBaseMinusMidColour;
color   = cloudBase * finalBaseColorMinusMid + cloudMidColour;

Four properties follow from the code:

  • Detail combination is neither a pure multiply nor a pure add. It is an additive term whose edge component is gated by saturate(1 − cloudBase·2) and by perlin.y.
  • Density remap is saturate(mult·cloud − bias), then squared. There is no smoothstep at this stage.
  • Horizon weighting is saturate(viewDir.z·5 − cloudFadeOut), linear in the view direction's Z rather than in an angle.
  • Small clouds are a second parallel channel driven by 1 − perlin.y, not a separate pass.

Final blend: two lerps, alpha-over

skyColor = lerp(skyColor, smallCloudColorHdr,             alpha.y);  // small clouds first
skyColor = lerp(skyColor, cloudColor * cloudHdrIntensity, alpha.x);  // then large

The blend is not additive, not multiplicative, and not extinction plus scattering. It also fixes layer order: clouds lerp over whatever the sky already is, so they correctly occlude the starfield and moon when applied after the celestial layer.

Dither is an output term, not a cloud input. The dome's second UV set samples a dither texture (× 64, centred to ±0.5), and it is the last operation on the sky colour, after fog and after the cloud composite: skyColor += dither * 0.0001 on PC (0.0002 on Xbox 360, where the source's own comment reads "360 only has 10 bits, so we need more dither"). Total amplitude ±5e-5, an anti-banding term for quantised output.

The sun's Mie halo uses the Cornette-Shanks phase form. The sun constants pack g·2, g²+1, scatter · 1.5·(1−g²)/(2+g²) and an intensity multiplier, and the shader consumes them as (cos²θ+1) / |−2g·cosθ + (g²+1)|1.5, with cosθ = dot(viewDir, −sunDirection). The negation matters: g is negative, so getting the sign wrong moves the halo to the antisolar point.

CloudHats: authored cloud geometry

common.rpf\data\clouds.xml defines 20 CloudHat fragments (all enabled as shipped), each a named set of alpha-blended cloud-shell layers with per-fragment position, rotation and scale, a transition alpha range and midpoint, angular velocity, and per-layer UV velocity and anim mode. Layer names are ordinary model names (cloudhat_cloudy_a, cloudhat_RAIN_b, horizonring, …). horizonring appears in 15 of the 20 fragments; it is the shared horizon band, not a per-weather cloud.

FragmentLayersFragmentLayers
Cloudy 0111Clear 016
Stormy 019Puffs6
Contrails7Horizon6
shower7altostratus6
Nimbus6RAIN5
horizonband1 / 25 / 5Wispy5
cirrocumulus5stratoscumulus5
Stripey5Cirrus4
horizonband34horsey3
Snowy 011

Layers resolve through the archetype system, not as loose drawables: JOAAT-hash the lowercased layer filename, look up the archetype, and fetch its drawable. That is the ordinary map-object path.

Weather to fragment selection is data, not naming. It is not in clouds.xml; it lives in common.rpf\data\cloudkeyframes.xml as SettingsMap/Item/Settings/CloudList with mProbability (21 per-keyframe weights) and mBits, one bit per fragment, indexing the fragment list in file order:

Weather settingmBitsFragments
HEAVYclouds0x000a0802RAIN, altostratus, Stripey, shower
STORMclouds0x00000082RAIN, Stormy 01
MEDIUMclouds0x000a0101Cloudy 01, Clear 01, Stripey, shower
LIGHTclouds0x0000f47chorizonband1/2, Puffs, Wispy, Horizon, Contrails, Nimbus, Cirrus, cirrocumulus, stratoscumulus
POSTRAINclouds0x00080002RAIN, shower
default0x00000200Snowy 01
SNOWclouds0x00000002RAIN

Bit order: 0 Cloudy 01, 1 RAIN, 2 horizonband1, 3 horizonband2, 4 Puffs, 5 Wispy, 6 Horizon, 7 Stormy 01, 8 Clear 01, 9 Snowy 01, 10 Contrails, 11 altostratus, 12 Nimbus, 13 Cirrus, 14 cirrocumulus, 15 stratoscumulus, 16 horizonband3, 17 Stripey, 18 horsey, 19 shower. The last two rows are counterintuitive and are reported as the data reads, not as sense: default selects Snowy 01 and SNOWclouds selects RAIN. Do not "fix" them to match expectation. LIGHTclouds is the only row with non-uniform probabilities, so its fragment choice is weighted per keyframe rather than fixed.

The transform convention, from the engine's own draw path: mPosition is metres, an offset from the camera; mRotation is radians, XYZ Euler; mScale applies per world axis after rotation. The world matrix is built as

cloudPos = mPosition + camPos * (mCamPositionScaler * Vec3(1, 1, mCamPositionScalerAdjust))
worldMtx = FromEulersXYZ(mRotation + angularAccum * mRotationScale, cloudPos)
worldMtx.col0..2 *= mScale.xyz
worldMtx.col3 -= camPos

and with shipped defaults (mCamPositionScaler = 1) the translation collapses to exactly mPosition, camera-relative. Three traps when porting from CodeWalker's reader:

  • The × 0.05 in CodeWalker's cloud vertex shader is CodeWalker's own fudge; no such constant exists in the game path.
  • CodeWalker's field list is incomplete: each layer also carries mHeightTrigger2 and mHeightFadeRange2. Altitude visibility is four terms modulating alpha on unclamped camera Z, not a single clamped boolean cull.
  • mShowLayer and mAnimMode are 3-element arrays over the anim layers, not over the layer list. CodeWalker sizes them to the XML item count and invites exactly that conflation.

Provenance: the cloud equations and constant packing are read from the engine's sky shader source and CPU-side sky code, cross-checked against an independent disassembly of the shipped shader; the visualsettings values and both cloud XML files were extracted from a real install; the CloudHat fragment/weather tables were read from those files directly. The transform convention comes from the engine's own cloud draw code, which supersedes the CodeWalker reading where they disagree.


26. Drawable-authored lights (CLightAttr)

Every GTA lamp, headlight and neon sign ships its own lighting inside the drawable. An interior is lit by the assets in it, so parsing the lights gives you the authored lighting without having to invent a rig.

Where they live

rmcDrawable +0xB0 is a ResourceSimpleList64<CLightAttr>: a pointer, then a u16 count, with the 168-byte records stored inline rather than as a pointer list. Each record carries position, colour, intensity, light type (point / spot / capsule), range, per-light falloff exponent, cone inner/outer angles, capsule extent, a bone tag, time flags, and a Flags word. CodeWalker's LightAttributes.Read agrees with the engine struct field-for-field.

Honest limits: the LIGHTFLAG_* bit meanings are not publicly named anywhere authoritative, so treat Flags as undecoded. That means "corona only" and "disabled" lights cannot yet be told from ordinary illumination by the flags alone. TimeFlags is a 24-bit hour mask and validates against meaning: street lamps carry 0x00E0007F, which is hours 0-6 and 21-23, i.e. night.

Only a plain rmcDrawable has this list. A fragment's drawable (.yft) is a FragDrawable, whose +0xB0 is the first row of a 4×4 frag matrix. Reading lights there decodes matrix floats as a pointer and a count. Gate on the drawable kind, not on whether the bytes happen to look valid.

What the game actually authors

Sampled over 4,000 drawables from a real install (93 carrying 538 lights):

PropertyDistribution
TypeSpot 343 (64%) · Point 156 (29%) · Capsule 39 (7%)
Intensitymin 0.05, median 32, max 32
Rangemin 0.10 m, median 1.20 m, p95 10.4 m, max 50 m
Falloff exponentmin 1, median 8, p75 32, p95 64, max 256
Cone outer angle10-96° in the sample, median 60°; wider cones (144°, 180°) exist on real props

Spot is the majority case. The median falloff exponent of 8 is the engine's own default for the field. The typical authored shape is a narrow hotspot (≈2°) inside a wide cutoff (35-90°), so almost the whole cone is transition band, which any faithful re-renderer has to reproduce.

The distance falloff is not inverse-square

distanceFalloff(distSqr, invMaxDistSqr, exponent)
    = pow(saturate(1 - distSqr * invMaxDistSqr), exponent)

E = colour · intensity · saturate(1 − d²/r²)^e · N·L

Illumination reaches exactly zero at d = r, so the authored range is a hard extinction radius rather than a hint. A physical renderer's I/d² can never equal this at more than one distance, so any conversion to a physical light must pick a match distance and say so. A useful one is r/√(1+e), the distance at which the light deposits the most total power (attenuation there is (e/(1+e))^e ≈ 0.368 for large e, and never collapses). A fixed fraction of the range does not work: at half-range an authored exponent of 128 attenuates to 1e-16 and the light effectively vanishes.

The cone falloff is linear in cosine

float angularFalloff(float angle, float lightConeScale, float lightConeOffset)
{
    return saturate((angle * lightConeScale) + lightConeOffset);
}

Despite the parameter's name, the call site passes dot(surfaceToLightDir, −lightToWorldDir), which is a cosine. The game's cone law is therefore linear in cosine and clamped: the pbrt-style delta = (cosθ − cos outer)/(cos inner − cos outer) with no exponent. This is a third law distinct from common renderers, since pbrt-style path tracers use delta⁴ and others are linear in the angle. A port that keeps a host renderer's built-in cone shape will disagree with the game across the entire transition band, which for typical GTA lights is nearly the whole cone.

Honest limit: the form is read from the shipped shader; the specific scale/offset pair is computed CPU-side, and "whatever maps cos(inner) → 1 and cos(outer) → 0" is the only assignment consistent with a two-angle cone rather than something read out of source. Note also that the law runs legitimately past the equator: cones wider than 90° are authored on real props and cos simply goes negative.

Capsule lights are a tube: authored extents in the wild are short (door strips, cove lighting), so treating one as a point at the tube's centre costs only a slightly harder shadow.

Provenance: struct location and layout verified against CodeWalker.Core and real drawables, with the engine's own struct definition agreeing field-for-field; both falloff laws are read from the engine's lighting shader source; the census was measured over 4,000 drawables from a real install.