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:

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

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.