Rendering: the D3D11 layer, passes, shaders and lighting
How the renderer drives Direct3D 11, the wrapper functions and the 55 pass entry points, the deferred G-buffer and its lighting classes, the surface overlay and fog passes, shader trees and the node table that holds them, the 220 global shader constants and what they reveal, material parameters, and the atmosphere, colour-grading and weather systems that decide the world's look.
The renderer is a deferred Direct3D 11 renderer that runs on its own thread as a command processor: the main thread records each frame into a command ring and the render thread replays it. How the ring works, the order of one frame's commands, where the render thread's time goes and how a frame is presented are in Threads and the frame; this chapter covers what the renderer does with the GPU.
The Direct3D objects
The engine keeps its Direct3D objects in one static block at 0x272D1C0, filled by the device setup function 0x1C8A068:
| Offset | Address | Meaning |
|---|---|---|
| +0x00 | 0x272D1C0 | ID3D11Device1 (138 code references) |
| +0x08 / +0x10 / +0x18 | ID3D11Device2 / 3 / 4, queried only when the runtime supports 11.2 / 11.3 / 11.4 |
|
| +0x20 | 0x272D1E0 | a small engine object (vt_0x1FFC508) whose +0x08 is the next row |
| +0x28 | 0x272D1E8 | the immediate context as ID3D11DeviceContext1, the handle the world renderer calls: 450 reads in 96 functions |
| +0x30 / +0x38 | the same context as ID3D11DeviceContext2 / 3 |
|
| +0x50 | 0x272D210 | ID3D11Multithread |
| +0x58 | 0x272D218 | IDXGIDevice2 |
| +0x60 | the adapter; +0x68 its IDXGIAdapter3 |
|
| +0x70 | the DXGI factory (CreateDXGIFactory2) |
|
| +0x78 | the output | |
| +0x88 .. +0x98 | the swap chain | |
| +0xA8 | 0x272D268 | the presented-frame counter |
| +0xB0 | the feature level reached |
Device creation. 0x1C8A068 calls D3D11CreateDevice with no immediate-context output, the debug
layer flag when the byte 0x2A37030 is set, D3D11_CREATE_DEVICE_DISABLE_GPU_TIMEOUT when 0x2A37034 is
set and the WARP driver when 0x2A37032 is set. It tries feature levels one at a time, highest first,
from a list chosen by the Direct3D runtime version (12.1 / 12.0 / 11.1 / 11.0 on a 12.1 runtime, down to
11.0 alone). It then queries the device for the interfaces above, fetches the context with
GetImmediateContext1 / 2 / 3 (device slots 43, 50 and 61), sets the DXGI maximum frame latency
to 3 (or to [base+0x267DFF4] when that is non-zero), and reads the threading and D3D11_OPTIONS to
OPTIONS3 feature data into the block (+0x180, +0x1B8, +0x1C8, +0x1E8). On an AMD adapter it
loads amd_ags_x64.dll, initialises the AGS driver extensions and reads the CrossFire GPU count; on
an NVIDIA adapter it reads the SLI state; the count of alternate-frame GPUs is kept at +0x16F4. No
deferred context is created: in a running game +0x28, +0x30 and +0x38 hold one object whose
GetType returns 0 (immediate). Every world draw is therefore serialised on the render thread
through that one context. Three engine objects keep their own copy of the device pointer
(vt_0x22C5F50 +0xB0, vt_0x230CBE0 +0xA8, vt_0x230CBA0 +0x28); none keeps a copy of the context.
The user interface does not use this path. Flash menus and the HUD draw through Scaleform GFx's
own renderer, Render::HAL (vt_0x2002498, 166 slots; 18 of them open a profiler scope named
HAL::...: vf4 PushRenderTarget, vf11 BeginFrame, vf15 BeginScene, vf50 DrawProcessedPrimitive,
vf55 PushBlendMode, vf58 PushFilters, vf74 beginDisplay ...). It holds its own context pointer at
this+0x3B3C8, and the 24 functions that read it make only UI-style calls (shaders, vertex and index
buffers, blend and depth state, viewports, clears, DrawIndexedInstanced).
The wrapper functions
76 functions call the context through a register loaded from the handle. Shader-resource binding
dominates (compute 27 call sites, vertex 26, pixel, domain, hull and geometry 25 each), then
CopySubresourceRegion (12) and IASetIndexBuffer (11). The wrappers most of the renderer goes
through:
| Function | Does | Callers |
|---|---|---|
| 0x33C8C8 | RSSetViewports |
36 |
| 0x17DFA0 | render targets and UAVs | 19 |
| 0x388444 | Dispatch |
15 |
| 0x17C1FC | bind shader resources on all six stages | 11 |
| 0x389870 | unbind 12 shader resources per stage | 9 |
| 0x3870A4 | input layout and vertex buffer | 8 |
| 0x17C56C | render targets and viewport | 6 |
| 0x1E978C | OMGetRenderTargets, clear render target and depth-stencil |
5 |
| 0x17F1B0 / 0x17F5A4 | input-assembler setup / DrawIndexed |
|
| 0x246FC8 | Draw |
|
| 0x386D50 | index buffer + DrawIndexed |
|
| 0x17DB70 | DrawIndexedInstancedIndirect |
|
| 0x1CBE0F4 | DispatchIndirect |
|
| 0x1E83A0 | samplers on five stages | |
| 0x1E8564 | shaders on all stages | |
| 0x1E938C | ClearState + ClearRenderTargetView |
|
| 0x1CAA53C / 0x1CAB348 | Map / Unmap |
|
| 0x5EFE84 | End (queries) |
The pass entry points
A pass entry point is a function that sets or clears render targets, dispatches compute work or draws indirectly through these wrappers: there are 55. Grouped by the code module they sit in and the strings that module uses:
| Group | Count | Entry points |
|---|---|---|
| render primitives and tessellation helpers | 14 | |
instanced and indirect draws with k_fRenderObjectID / DrawIcosahedronInstancesIndirect (light volumes, render IDs) |
10 | 0x388444, 0x1CBE0F4, 0x337E6C, 0x387DB8, 0x1CD2640, 0x1CD334C ... |
screen-space passes on tLastFrameMap + tSceneNormalMap |
5 | 0x102ABF0, 0x102BCC0, 0x102CD90, 0x102DF00, 0x1030FE0 |
| render-target clears and management | 5 | |
| shared target and dispatch helpers (the module also holds the shadow-atlas settings) | 5 | targets 0x33B644 (runs inside the post-processing chain); dispatches 0x337964, 0x33DA28 and 0x337EB8 (the last two inside the GPU particles), 0x1CAA73C |
point lights (CRenderNodeLightPoint::GetCBCachedData()) |
3 | 0x687424, 0x4D951C, 0x6FFD74 |
object-colour compute (k_vObjectColor) |
3 | 0x1ED2F0, 0x1ED944, 0x1EDE58 |
| GPU vegetation | 2 | 0x101A9C0, 0x10199C0 |
UI (uirender.cpp) |
1 | 0x2253D4 |
Render-target sets
Passes do not bind targets one by one. They select a target set by number through 0x17C56C
(state, clear colour, clear mask, set id, ...): 0x17B9A4 turns the set id into up to five target ids
and a viewport size, 0x17C940 turns each target id into its render-target object, and the function
then clears what the mask asks for and calls OMSetRenderTargetsAndUnorderedAccessViews. The state
object is [base+0x273C628] (vt_0x2002320; it exists only while a world is loaded): +0x0C / +0x10
the render size, +0xC20 the current set id (written only by 0x17C56C), +0xC28 the bound views
(count at +0xC78), +0xC50 the depth view and +0xC60 the bound formats, which are also copied to
the statics 0x273C208 (five formats) and 0x273C21C (the depth format). A render-target object holds
its width at +0x00, its height at +0x04, its typeless and view formats at +0x08 / +0x0C, and
buffers of 0x68 bytes from +0x50 (the view first), the current one selected by +0xB0.
| Set | Targets | Use |
|---|---|---|
| 1 | the scene colour | lighting |
| 2 | depth only | point-light work |
| 3 / 4 | scene colour, (RGBA8,) motion | the forward pass after the main list |
| 7 | the five deferred targets | the G-buffer |
| 13 | depth only, a square three times +0x20 on each side |
the shadow atlas |
| 27 | a second depth buffer, one pixel per 16 x 16 screen tile | point-light tiles |
| 9 | one target, 2048 x 2048 | |
| 20, 21, 22 | quarter-size targets | post-processing |
| 29, 30 | half-size targets | post-processing |
At a 4096 x 2160 render size the targets are: the G-buffer (below), two R16G16B16A16_FLOAT scene
colour targets that swap every frame (the current frame and the last one), a full-size R32_FLOAT
target, a 256 x 135 R32G32_FLOAT target (one texel per 16 x 16 tile), and half- and quarter-size
R8G8, R32_FLOAT, R16G16_FLOAT and R16G16B16A16_FLOAT chains. The main depth buffer is
D32_FLOAT_S8X24_UINT. RemapDeferredRenderTargets() 0x66E9C4 (named by its own assert) checks that
set 7 is bound and rebinds a subset of its five targets by a bit mask, keeping the depth buffer.
The passes of one frame
Sampling the render thread (8,369 and 8,370 samples over 2 x 12 s, each one's call stack unwound and the bound-target statics read in the same instant) shows what each pass draws into and the order they run in. Inside the scene command, the main pass list 0x33CD60 runs, every frame, in this order (consecutive samples moved from each pass to the next one hundreds of times and never in the other direction):
| Order | Pass | Bound targets | Share of render-thread samples (one Lithlad scene) |
|---|---|---|---|
| 1 | 0x255920, then 0x185410 | set 7 | |
| 2 | mesh pass A 0x66E93C: the G-buffer pass | set 7 in 3,538 of 3,538 and 3,477 of 3,477 samples | 41-42% |
| 3 | the GPU particles 0x33783C | the five deferred targets | 2% |
| 4 | mesh pass B 0x1F02E8: the shadow pass | its main child 0x1EE3E4 draws with set 13 (depth only, D16) in 502 of 502 samples | 10-11% |
| 5 | the light accumulation 0x384640 | set 1, the scene colour; its point-light compute 0x4D951C uses sets 2 and 27 | 4% |
Mesh pass A remaps the deferred targets to the first four before its draws, so its meshes write the four G-buffer targets; the passes that keep all five bound also write the motion target. Mesh pass B draws shadow casters into the shadow atlas (0x1EE3E4, the vegetation 0x1021250, 0x185410); the second writer of the shadow scale and move constants, 0x1CC8DA0, runs inside it with the atlas bound (8 of 8 and 7 of 7 samples), so it sets up one cascade at a time for the casters. One child of pass B, 0x1CC753C, draws with the scene colour and the main depth buffer bound. After the main pass list come the vegetation displacement and grouped GPU particles (0x1021250), the post-processing chain (0x33740C: motion blur, depth of field, blur, vignette), the colour-grading pass (0x339D6C) and, last, 0x1EDE58, which runs with the scene colour and the motion target bound (sets 4 and 3): a forward pass that writes colour and motion vectors. The user interface draws into an R8G8B8A8 target.
The G-buffer and lighting
The renderer is deferred. The G-buffer is target set 7: four targets the G-buffer pixel shaders write, a fifth for motion vectors, and the main depth buffer (D32_FLOAT_S8X24_UINT); all are the render size (4096 x 2160 in the measured game):
| Target | Format | Contents |
|---|---|---|
| 0 | R10G10B10A2 | normals: bits 0-19 the bumped normal's offset, bits 20-31 the geometric normal |
| 1 | RGBA8 | diffuse albedo (bytes 0-2) |
| 2 | RGBA8 | specular: byte 0 roughness (the lighting uses r * r), byte 1 specular occlusion, byte 2 reflectance (above 0.667 = metal) |
| 3 | RGBA8 | material: alpha bits 28-31 = the lighting class |
| 4 | R16G16_FLOAT | screen-space motion |
The lighting is a compute pass (lighting\tiledlighting.fxo) that reads the normal, specular and
material targets as UAVs and branches on the class: 0 standard, 1 hair (a Marschner model), 2
subsurface / emissive. Its sun term is GGX; dielectrics use a fixed F0 of 0.04, and image-based
light comes from split-sum probes sampled at mip 6 * sqrt(roughness). There is no screen-space
reflection in the lighting.
The work is tiled. Each frame the main thread issues 13 tile updates (BufferTileIndices 0x1791CC
with 0x247108) and one indirect draw of light volumes as icosahedra
(DrawIcosahedronInstancesIndirect, 0x6C74A8). The global shader constants (below) name the rest of
the structure: screen tiles sorted by the kind of sun shadow they need into separate job queues
(k_RwPCFShadowTiles, k_RwPCFOneCSMShadowTiles, k_RwNoSunShadowTiles,
k_RwPreLightbleedShadowTiles, k_ScreenTileJobQueue, k_ScreenTileWithCSMJobQueue,
ClassifyJobCount), a light octree (Octree_Buffer, k_OctLight_vHalf,
k_OctLight_vCharacterAdjust), per-type light index offsets (k_nPointLightIndexOffset,
k_nSpotLightIndexOffset, k_nDirLightIndexOffset), a baked static light volume sampled by
world position (k_mWorldToVolume, k_vStaticLightVolume_CellExtents / CellOffset / StencilHalf /
StencilOffset / PivotContrast, k_fStaticLightVolume_Mip) with BufferProbes, and a sparse
shadow tree (SparseShadowTree, SSTCoarseLUT, SparseShadowTreeStartIndex / WriteIndex,
k_mViewToSSTree, k_nTreeDepth, k_nCoarseLevelRes / Offset, k_fPeelingThreshold,
k_nSharedEmptyTileLocation, TreeStatsBuffer). Sun shadows use four cascades, one per vector component of the shadow constants
(k_vShadowScaleX/Y/Z, k_vShadowMoveX/Y/Z, k_vShadowSplitDistSquared, k_vShadowSplitDistSelect,
k_vShadowFalloffParams, all written by 0x382484). In one live read the scales relative to the first
cascade were 1, 0.335, 0.055 and 0.0066, and the squared split distances 864,076, 7,697,105,
287,907,008 and 12,008,197,120 cm^2: the cascades end at about 9.3 m, 27.7 m, 170 m and 1,096 m.
The shadow maps live in one shadow atlas, a D16_UNORM depth target whose side is three times the
target-state value +0x20 (12,288 x 12,288 in the measured game, where +0x20 held 4096), rendered by
mesh pass B before the lighting. A second writer, 0x1CC8DA0, reuses the scale and move objects with
another layout (about 2.5% of reads, one cascade's component set at a time); it runs inside the
shadow pass while the atlas is bound. Spot and point lights project through
k_mSpotProjector_LightTransform and k_mCubeProjector_LightTransform.
The per-view constant buffer. Every G-buffer pixel shader and the lighting share constant buffer
cb2, the per-view buffer: cb2[4..6] are the camera-to-world rows (the lighting rebuilds a pixel's
world position as dot(cb2[4..6], (view position, 1))). Column z of those rows is the camera's look
axis and .w the camera position, so any material shader can read both. Material parameters
(.mat values) arrive in cb3.
The surface overlay pass
translucent\overlay\rainandfireoverlay.fxo is a full-screen pass that runs after the G-buffer and
before the lighting. It reads and rewrites all four G-buffer targets as UAVs, so it changes every
surface it covers: terrain, buildings, characters and gear. It rebuilds each pixel's world position
from depth (t0), reads the world normal (t1; up is t1.y), and owns:
- wetness: the global wetness
cb3[3].zlerps roughness toward the wet targetcb3[4].x; on level ground (up above 0.97) the bump is scaled to 15% before animated ripples are added; - an animated raindrop-impact mask from time-scrolled noise (
t4) that roughens, darkens and lowers reflectance where a drop lands, faded out beyond 75 m; - a roof-occlusion compare map (
t6, matricescb2[28..31]) used by the rain branch within 75 m, so covered ground stays dry.
The renderable behind it is the class vt_0x1FFECD8, which receives three render commands per frame
(0x70C0E0, 0x7146A0, 0x72FDE0). Its parameters come from its material, rainandfireoverlay.mat, and
from the weather (below). The pass belongs to the world view: menu scenes such as the army screen do
not run it.
The fog pass
lighting\fogoverlay.fxo runs after lighting. It reads the lit scene and rewrites it as
scene * transmittance + inscatter (depth t0.x, the lit scene t3, view-to-world cb2[4..6];
its vertex shader builds the view ray from cb2[21] and the texture coordinate from cb2[22]).
Because fog is added after lighting, distant surfaces take the fog's colour whatever their albedo.
The render thread registers the fog material (fog_overlay.mat) during its own initialisation.
Shaders
Shaders ship as compiled .fxo files, addressed by tree path (lighting\tiledlighting.fxo,
solid\nemesis\gear_lod0.fxo, translucent\overlay\rainandfireoverlay.fxo ...). A shader file
holds variants: the orc gear shaders (solid\nemesis\gear_lod0, _lod1, _alphatest_lod0,
_alphatest_lod1), which every orc shares, each carry three pixel-shader variants, skeletal,
mesh type 4 (dithered fade: it discards where the dither value exceeds 1 - cb3[13].w) and mesh
type 5 (which blends reflectance toward four constant specular tints). Talion's gear uses its own
trees (talion_armor, _detail, armornocull, cloth, weapon, hair, ring ...);
talion_armor is one pixel shader whose output reflectance above 0.667 marks metal.
The shader-tree node table
Every loaded shader tree, the renderer's own and every material's, hangs in one static hash
table at 0x27AEC38: 128 buckets of 0x18 bytes {first, last, self} (each bucket points at itself,
so a bucket is recognisable), and the bucket is StringHash(tree path) & 127. A node is:
| Offset | Field |
|---|---|
| +0x00 / +0x08 | next / previous (the chain ends back at the bucket) |
| +0x10 | the tree path's hash (equal to the resource's +0x20) |
| +0x30 | the shader tree object in use |
| +0x38, +0x50 | the resource |
There is one node per material and draw state, not one per tree: Talion's armour shader had 33 nodes, his weapon 8, his cloth 8, his head 8. Walking one bucket finds every node of a shader. A material loaded later gets new nodes on the same tree. Tree names carry a content hash.
The global shader constants
The engine's global shader parameters are 220 static objects of 0x50 bytes, laid out in runs in the image (the largest run is 28 objects from 0x2A3AD40):
| Offset | Field |
|---|---|
| +0x00 | vtable: the value's class |
| +0x08 | flags; the high word is the value's size in bytes |
| +0x10 | the bind address, always self + 0x40 |
| +0x18 | the name (k_vObjectColor) |
| +0x20 | u32 name hash, u32 index |
| +0x28 / +0x30 | previous / next sibling |
| +0x40 | the value the shaders receive |
| Class | Value size | Objects | Examples |
|---|---|---|---|
| vt_0x2001AA8 | 8 (a buffer or view) | 73 | BufferTileIndices, SparseShadowTree, VegetationInstances0 |
| vt_0x2001A98 | 16 (float4) | 55 | k_vObjectColor, k_vShadowScaleXY, k_vToneMappingParams0 |
| vt_0x2001AE8 | 12 (float3) | 27 | k_vWorldSpaceEyePos, k_vDirectional_Dir |
| vt_0x2001AB8 | 4 (uint) | 18 | k_nCaptureMode, k_nTreeDepth |
| vt_0x2001AF8 | 48 (3x4 matrix) | 12 | k_mObjectToWorld, k_mWorldToVolume |
| vt_0x2001A68 | 4 (float) | 11 | k_fRenderObjectID, k_fPaperWhite |
| vt_0x2001AC8 | 64 (4x4 matrix) | 7 | k_mObjectToClip, k_mGFxTransform |
| vt_0x2001A28 | 4 | 7 | k_numBufferEntries, k_BufferOffset |
| vt_0x2001A38 | 8 | 6 | k_HairLightFactor, k_nPointLightIndexOffset |
| vt_0x2001A18 | 16 | 2 | k_nLightIndex, k_nClearMask |
| vt_0x2001A48 | 12 | 1 | k_nCoarseLevelRes |
| vt_0x2001A78 | 8 | 1 | k_nCoarseLevelOffset |
The names are a map of the renderer. Besides the lighting and shadow structures above they cover
object transforms with their previous-frame copies for motion vectors (k_mPrevObjectToClip,
k_mWorldToPrevWorld), temporal anti-aliasing (k_AAParams1/2, k_mPreviousMotionTransform,
k_PrevTexCoordScale), the six frustum planes, tone mapping and HDR output (k_vToneMappingParams0/1,
k_fPaperWhite, DynamicExposureBuffer), GPU cloth simulation (k_RwClothPosBuffer0..7,
k_ClothConstraintsBuffer, k_ClothBendSpringsBuffer, k_ClothSpringGroupsBuffer,
k_ClothNodeDataBuffer, k_ClothBoundingBuffer), tessellated terrain (k_vTerrainHullParams0..2,
k_vTerrainDomainParams0..3: hull and domain shader inputs), GPU vegetation (instance buffers
0-7 with read-write twins, per-LOD sort buffers k_VegSortBufferLOD0..3 and DitherLOD,
k_VegInstanceCounters, override buffers, k_WindFXBuffer), volumetric clouds (shadow,
scatter, two noise layers, sun direction and colour, dimensions, camera position), GPU particles
(emit rate, lifetime, inherited velocity, initial velocity and colour ranges, grouped particles and a
distance merge sort), hair (k_HairParamsBuffer), Bink video (k_vBinkUVScale) and Scaleform's
transforms and colour vectors (k_mGFxTransform, k_vGFxColorMulVector, k_vGFxTexGen*).
When values change. Sampling all 220 values 3,000 times in 5.5 s while the world was paused (the
world clock did not advance and the camera-position constant held one value) and the renderer kept
drawing (505 frames): 117 changed and 103 held. Many constants are therefore
written per draw, per light or per pass, not per frame. k_vObjectColor is refilled for each draw by
0x254B80 from the draw item's packed colour at +0x4C (only when it differs from the previous
draw's); the per-object matrices took hundreds of values; k_vShadowScale* and k_vShadowMove* each
alternated between two values with the camera still. k_vToneMappingParams0/1 and k_fPaperWhite
are rewritten every frame by the colour-grading pass 0x339D6C from the frame's HDR settings. A value
written into one of these objects from outside the render thread lasts only until the engine's next
write, at most one draw.
Material parameters at runtime
A material's parameters can be changed at runtime through a material override handle
(vt_0x1DC47B8; +0x18 u32 reference count). Its setters, all keyed by StringHash(name) with seed 0
(0x114CFB0):
| Slot | Function | Call |
|---|---|---|
| vf3 | 0x3094FC | SetParamFloat(this, hash, value) (asserts the reference count is non-zero) |
| vf4 | 0x2E9944 -> 0x2E994C | SetParamFloatArray(this, count, {u32 hash, f32 value}[]) |
| vf5 | SetParamBool |
|
| vf6 | 0x1CC2624 | an array of another kind (through 0x11585A4) |
The setters copy their data and post it to the render command ring (0x1154B34 / 0x2E99E8 with
0x17906C), so they are main-thread calls. On the render thread 0x65482C calls 0x177D7C for each pair
(0x177D7C is the constant-upload command that appears 53 times per frame); it writes the handle's
record list [this+8] -> {begin, end}, records packed as {u32 (5 << 28) | (hash & 0x0FFFFFFF),
u32 4, f32 value}. The list is the set of overrides currently applied.
The weather owns one such handle for the surface overlay, [[GM+0x6D98]+0xC0] (its renderable at
+0xB8 is the vt_0x1FFECD8 object above); the weather update 0x43C574 sets only k_fWetness and
k_fRaininess through it. Weather patterns can name further material overrides
(nazgul_wraithmode_ambientoverlay.mat with k_fIntensity 150 in the Minas Ithil Great Hall
pattern); these reach the renderer as the render commands 0x1B7A40 / 0x1B7A94. Per-piece runtime
material slots and the per-character material controller are in Characters.
The atmosphere
The world's lighting look (exposure, bloom, fog and sky scattering, sun and moon light, shadows, wind, colour grading) comes from AtmosphereVolume objects (vt_0x1FDEFB8) placed in world layers. Each volume names component objects in its own layer:
| Component | vtable | Main properties |
|---|---|---|
| HDRSettingsComponent | 0x1FCBEB8 | ExposureAdj, Contrast, Saturation, BloomWeight, BloomThreshold, HDRMax, HDRGreyMin/Max, RelativeLightScaleWorld/FX, DynamicAmbientScalar, RadialBlur*, each with a Use*Override flag and an _HDRTV twin for HDR displays |
| FogSettingsComponent | 0x1FCC318 | SunColor, SkyColor (ARGB integers), SunIntensity, Rayleigh_*, Mie_*, SkyExtinction, LightWavelengths (atmospheric scattering) |
| OutdoorLightComponent | 0x1FCC0E8 | DiffuseColor, SpecularColor (ARGB), Intensity: the sun or moon |
| OutdoorShadowComponent | 0x1FCC660 | |
| SkyShadowComponent | 0x1FCC548 | |
| WindSettingsComponent | 0x1FCC430 | |
| ColorGradingComponent | 0x1FCC788 | one property, 3DLUT, a texture path |
A component carries only the properties its author set; the rest fall back to engine defaults
(the Minas Ithil HDR components set no SDR Contrast and mostly no Saturation). 130 of the 147
property names were recovered as hashes of strings in the executable.
A component stores no values. The values stay in the layer's property database: the
component's +0x50 is its property record (field count, tag 0x0A, descriptor run at +0x08,
instance at +0x10; cells at instance + link * 8, booleans packed as bits), and the atmosphere
system re-reads those cells every frame. Doubling ExposureAdj in the cells of the active HDR
components moved k_vToneMappingParams1.w from 0.104 to 6.656 on the next frame and multiplied
k_vSpecularColor by 64; restoring the cells restored both. Several records can share one instance,
so the same cell can belong to more than one component.
The volume registry is a static pool at 0x27293A0: 0x200 slots of 16 bytes {u64 0 used / 1 free,
object}, a free-index ring at +0x2000, head and count at 0x272BBA0 / 0x272BBA4 (initialised by
0x8BA7D4). A volume's components are weak references at +0x140 .. +0x148 (0x28 bytes each, the
component at +0x20). +0x120 is the enabled byte, set through AtmosphereController::SetEnabled
0x1A3428(this, bool); its initial value is the property StartEnabled (default true, 0x1BF1848).
Enabling and disabling blend over time on the game clock, so a paused game shows no change.
Scope. A volume's own properties decide where it applies: WeatherContext "Wraith" marks Wraith
World and cinematic overrides (WraithAtmo at priority 42, PresentationWraithAtmo, Cinema*Atmo,
NazgulWraithAtmo); Timer "UI" marks menu scenes (AV_Pause, AV_Hier*, AV_Map, AV_Market);
everything else is world. One Minas Ithil sample held 23 world, 24 menu and 8 Wraith volumes.
Which volume wins
Volumes are spatial. Their shapes are convex brushes stored in the layer's object data, in a
VOLM block (block id 0x8A27BAE6), in world space:
header +0x18 instance count, +0x24 brush count, +0x28 name entries + 1, +0x34 face count,
+0x38 face-vertex count, +0x3C plane count
planes {nx, ny, nz, d} from 0x44 (a point is inside when n . p >= d for every plane)
then face counts, vertices
type {StringHash("AtmosphereBrush") 0xD1A57B78, u32, char[64]} (absent when the block is empty)
brushes 0x28 bytes {AABB, first plane, plane count, first face, first face vertex}
instances 0x30 bytes {brush, 3 x u32, inverse translation, rotation quaternion, scale}
names {P919 hash of the volume's name, count, first instance}
A volume registers its brushes under StringHash(its name) when it activates (vf16 0x1C30068). Live,
48 of 50 registered brushes matched the file plane for plane, and 65 of 65 contained their own
bounding-sphere centre.
Every frame the player view update 0x51C43C passes the active camera's position (camera vf6
0x1A6E3C on [CGameClientShell+0x898]) to 0x2B1290 with the container [player+0x3340]. It runs a
point query over every enabled brush; then, for each system state (HDR vt_0x1F673C8, Fog
vt_0x1F67388, OutdoorLight vt_0x1F6E230, OutdoorShadow vt_0x1F6E260, ColorGrading vt_0x22F4BD0,
Wind vt_0x22F4C00, SkyShadow vt_0x22F4C30, ScreenEffect vt_0x22F30C8, Rain vt_0x22F4D40), 0x2B164C
picks the hit volume with the highest Priority that owns that system's component. Priority is read
live each frame (0x2B182C, default 0), and on a tie the current winner stays. The winner (at
state+0x28) blends in over its TransitionTime (for HDR, state = lerp(state, value, w) in
0x2B2CF0). The top volume's WeatherContext goes to the weather manager (0x7DC5C4 / 0x7DC4A0).
The look therefore follows the camera, not the player: in one live check the query point was 3.8 m behind and 2.4 m above Talion.
Colour grading
The post-processing pass 0x339D6C binds two colour-grading 3D LUTs (tColorGrade3DLUT1/2) only
while a ColorGradingComponent is active. Its view settings hold the two LUT textures at
+0x290 / +0x298, the enabled byte at +0x288, the blend k_fLUTBlend at +0x2A0 and the remaps
k_fLUT1Remap / k_fLUT2Remap at +0x2A4 / +0x2A8. LUTs are 8x8x8 RGBA8 volume textures; the
default is Client/Shared.ColorGradingDefault = fx\common\colorgrading\baselinelut8x8.tex. The stage
is rarely active: in the Minas Ithil main world four layers and one other object carry a
ColorGradingComponent. An engine texture object holds its dimensions at +0x50, the
ID3D11Resource at +0x90 and its shader-resource views at +0x98 / +0xA0.
Weather
The weather manager is W = [GM+0x6D98]:
| Offset | Field |
|---|---|
| +0x20 | its world bundle group (the sections it loads) |
| +0xA0 | its section ids |
| +0xC0 | the surface-overlay material override handle |
| +0x1C8 | the pending pattern |
| +0x1D1 | apply now |
| +0x1D8 | the pattern being loaded |
| +0x1E8 | the current pattern |
| +0x418 | the forecast |
| +0x421 | a swap in progress |
A weather pattern is a lighting Section (a world layer loaded as a bundle) plus an effects
Bundle, WeatherTypes and FXContexts. Each region's lighting is one layer, chosen by the
current pattern's Section: Minas Ithil has day01, night01, morgul and gh_night01 (the Great
Hall at night). The update 0x43C574 -> 0x1B4CFE8 releases every section the new pattern does not name
and requests the ones it does in W+0x20. Requests are de-duplicated per bundle group only
(0x4B1FE4), so the same layer loaded through another group is a second copy. A pattern whose
Section is empty names no lighting layer at all: switching to one releases the region's lighting
and the scene renders black.
From script: ChangeForecast is SetForecast 0x1DBBE4 plus the apply flag; ChangeWeatherPattern(p)
(stub 0x1B7B734) sets the pending pattern and clears the forecast. A forecast record lists a
pattern with options such as UsePrexisting, AllowTransitions and ResetTOD. The Minas Ithil
finale (finale_mi.lua) shows the pieces together: it hides the keep, calls
ChangeForecast(MI_GH_Night) (pattern MI_GreatHall_Night, whose section is gh_night01), and later
ChangeForecast(None).
Falling rain, snow and ash are not part of the pattern's lighting. They are the climate's
GlobalCameraFX: on each region load 0x1B4B34C (from vt_0x1F68920 vf4) creates one
WeatherCameraFXController per entry through the object server (the manager's controller vector
at +0x00 / +0x08, entries of 0x28 bytes). A controller reads its record's FXData live (the
record at controller +0x128). A fast travel within the same region does not rebuild them.
Open questions
- How the atlas is divided: whether the four cascades and the light shadows have fixed tiles of the 12,288 square, and how the sparse shadow tree and the screen-tile shadow classes divide the work. Reading the viewports the shadow pass sets per cascade would settle the first part.
- What 0x1CC753C, the child of the shadow pass that draws with the scene colour bound, renders before the lighting (emissive or sky geometry are candidates).
- What the "center lit" set is (
k_nCenterLitCount,k_nCenterLitID,CenterLitBuffer; the ID changed 83 times in a paused sample).
Shadow of War (Steam), game version 1.21. An unofficial fan research project, not affiliated with or endorsed by Warner Bros. Games, Monolith Productions or Middle-earth Enterprises. Middle-earth: Shadow of War belongs to its owners.