Skip to content

Light records

FromSoftware engineLighting and skyApplies toSekiroElden Ring

Each frame the renderer packs every local light it draws into one record of a structured buffer: a 208-byte PointLight in g_LightBuffer and a 192-byte SpotLight in g_SpotLightBuffer. A light-organize compute program sorts the records into per-tile lists, a tiled pass and the forward materials shade with them, and a light drawn with a shadow of its own is drawn again by a per-light pass that reads its shadow from a local-light shadow atlas. How a bank light’s stored values become its record’s colour, range and position is on BTL light banks; the tile lists’ layouts are under Tile lists. Observed

Both games declare the two structures with the same fields at the same offsets. Observed[the declarations in fxc and dxc disassembly] dxc’s disassembly of all 50,283 Elden Ring programs finds 10,985 that declare PointLight: 65 effect, 30 FLVER and 24 renderer programs, 7,842 forward materials of the base bundles, 1,368 and 990 of the two DLC bundles and 666 SpeedTree programs. Proven · Elden Ring · every program of the install Its 5,847 ray-tracing libraries name the structures only in their reflection data and bind none of g_LightBuffer, g_SpotLightBuffer or cbLightAccLightParam. Proven · Elden Ring · every ray-tracing library of the install

PointLight (g_LightBuffer, t19 of the organize program, t61 of the tiled pass)208 bytes96% known

Floats. The names are the programs' own. A view-space position is in the frame of g_ViewMatrix (cbLightAccParam +64).

ProvenObservedSourcedInferredUnknownNot described
OffsetSizeTypeFieldConfidenceNotes
0x012float3PositionAndRadius.xyzObservedthe light's position in view space
0xC4floatPositionAndRadius.wObserved1 / radius^2
0x1012float3DiffColor.rgbObservedthe diffuse colour
0x1C4floatDiffColor.wObserved1 / the bank light's word at +164: scales distance in the wrapped Lambert term
0x2012float3SpecColor.rgbObservedthe specular colour
0x2C4floatSpecColor.wObservedthe specular colour's Rec. 601 luma; no Elden Ring program reads it
0x304floatTransLightParam.xInferredthe translucency term's strength
0x344floatTransLightParam.yObservedthe translucency term's exponent, a 32nd of the power; at least 0.01 from Elden Ring's packer
0x384floatTransLightParam.zUnknownnot read by the unshadowed tiled pass
0x3C4floatTransLightParam.wObservedthe radius
0x404floatLightTypeObserved0: unshadowed; above 0: drawn with a local shadow this frame (below)
0x4412float3PadObservedread by no Elden Ring program
0x5016float4ProjParamObservedshadowed only: .x and .y the shadow depth terms, .w the shadow's strength
0x6016float4ShadowCenterPosObservedshadowed only: the point the shadow is rendered from, w 1
0x7096float4[6]ViewportScaleOffsetObservedshadowed only: the scale (xy) and offset (zw) of six cube faces in the atlas

The fields up to the radius are read as on BTL light banks, matched against the banks’ stored values. Observed · the read-back records of Ashina Castle and the Stormveil gate No program of Elden Ring reads Pad, and its 80-byte variant (below) declares none of the shadow fields. Proven · Elden Ring · dxc disassembly of every program

Elden Ring’s unshadowed GXRend_TileBasedLighting reads, of each point record, the words at +0 to +40, +48 and +52: not SpecColor.w, TransLightParam.z, the radius at +60 or anything after it. Observed[the installed program’s DXIL]{games=“elden-ring”} Its point loop scales distance by DiffColor.w (k = distance × DiffColor.w) and uses it twice: an attenuation 1 / (1 + 0.7 k²) and the wrap of the Lambert term, (1.4 / (1.4 + k))²; the specular lobe’s width depends on it too. Observed · Elden Ring · the installed program’s DXIL 1 / DiffColor.w, the bank word at +164, is thus a distance the light’s fall-off is normalised to, not shown to be the size of the emitter. Inferred The point packer divides 1 by that word with no clamp (divss at 0x7ff7e1161309); the 216 values of the word in Stormveil’s bank m10_00_00_00_0000 are all finite and between 0.2 and 13, so a stored 0 is never met there. Observed[read from the code of 2.7.2.0; the installed bank]{games=“elden-ring”} Over the 20 records of that bank’s five lights read back at the Stormveil gate, DiffColor.w is exactly 1 divided by the word: 0.125 on 8, 0.6666667 on 4 and 2 on 8. Observed[four dispatches of one session]{games=“elden-ring”}

The translucency term is exp2(32 × TransLightParam.y × log2(saturate(L·V))), saturate(L·V) raised to the power 32 y. Observed · Elden Ring · the installed program’s DXIL TransLightParam.x is its strength. Inferred The game keeps both on the light object, where the shared light constructor writes 1.0 to each (+0x98 and +0x9C), and the point packer (0x7ff7e1161260) copies the strength as it is and the exponent as max(exponent, 0.01); the spot packer does the same. Observed · Elden Ring · read from the code of 2.7.2.0 The floor matters: with an exponent of 0 the term is 0 × log2(0) where L·V is 0 or less, a NaN that a strength of 0 does not cancel. Proven[the installed program run with these inputs]{games=“elden-ring”} All 20 records above hold (1, 1), the constructor’s values; whether any light sets others is not established. Observed · Elden Ring · four dispatches of one session

SpotLight (g_SpotLightBuffer, t20 of the organize program, t62 of the tiled pass)192 bytes96% known

Floats. The names are the programs' own. No Elden Ring spot record has been read back.

ProvenObservedSourcedInferredUnknownNot described
OffsetSizeTypeFieldConfidenceNotes
0x012float3PositionAndRange.xyzObservedthe light's position in view space
0xC4floatPositionAndRange.wObservedthe range (the bank light's radius); the shader squares it
0x1016float4DiffColorObservedas a point light's
0x2016float4SpecColorObservedas a point light's
0x3012float3TransLightParamObservedtranslucency parameters
0x3C4floatunused_ShadingModeUnknownnamed unused by the declaration
0x4064float4x4ViewToLightSpaceMatrixObservedrows that take a view-space position into the spot's space: its two cone axes, its near plane and its beam (below)
0x804floatLightTypeObservedas the point record's +64
0x844floatMinDistObservedthe least view depth of the spot's apex and base corners
0x884floatMaxDistObservedthe greatest view depth of the spot's apex and base corners
0x8C4floatPadUnknown
0x9016float4CullPositionAndRangeObservedthe sphere the organize program culls with: the vertical cone's, plus 0.001 (below)
0xA016float4ShadowParamObservedshadowed only: .x read by the shadowed tiled pass
0xB016float4ViewportScaleOffsetObservedshadowed only: the scale and offset of the spot's region in the atlas

The spot record’s position, range, colours and cone matrix are Sekiro’s read-back values (see spot lights). Observed · Sekiro Elden Ring’s spot record is known from the programs’ declaration and code alone. Unknown · Elden Ring · no read-back

A spot light is a rectangular pyramid with a near plane: it opens by its own half-angle across each of its two axes, h about the horizontal axis and v about the vertical one, and lights only past its near plane n, out to its range L. For a BTL spot, h and v are half the stored full angles (the width at +184 and the cone angle at +44), n is the word at +188 and L the radius (see spot lights). Below, p is the light’s view position and right, up and beam its horizontal axis, vertical axis and beam in view space. Three spot records of two Ashina Castle bank spots (30 by 30 degrees with n 15, and 90 by 8 degrees with n 3, L 35) hold the following, to float rounding (2.8e-6 relative). Observed · Sekiro · 3 records, 2 lights

Field Value
ViewToLightSpaceMatrix row 0 right * cot h, with -right . p * cot h in w
row 1 up * cot v, with -up . p * cot v in w
row 2 beam * L / (L - n), with -(beam . p + n) * L / (L - n) in w: the near plane
row 3 beam, with -beam . p in w
MinDist, MaxDist the least and greatest of p.z and base.z ∓ L * (tan h * abs(right.z) + tan v * abs(up.z)), where base = p + beam * L: the view depths of the apex and the base’s four corners
CullPositionAndRange centre p + beam * a and radius a + 0.001, where a = L / (2 cos^2 v): the sphere through the apex and the rim of the circular cone of the vertical half-angle, plus a margin

GXRend_TileBasedLighting lights a pixel from a spot only where row 2 of its position is above 0, that is past the near plane. It shades by max(0, 1 - x^2 - y^2), where x and y are rows 0 and 1 over row 3. Observed · Sekiro · the program’s code, read not run GXLightOrganize16 tests CullPositionAndRange against the four side planes of each tile and MinDist and MaxDist against the tile’s depth range. Observed · Sekiro · the program’s code, read not run So the cull sphere and the depth range choose tiles, and the matrix chooses pixels. Inferred · from the code

The cull sphere need not contain the light. The 90 by 8 degree spot’s base corners lie 35 m from its beam, but its sphere’s radius is 17.6 m. The game therefore lists that spot only in tiles its vertical cone’s sphere reaches. Observed · Sekiro · 1 light

In the executable, the BTL loader passes four values to the spot’s frustum setter: the near plane (+188), the radius as the far plane, and the full cone angle (+44) and width (+184) in radians, each falling back to the other where it is below 1.1920929e-7. The setter then raises the far plane to at least 1 and each full angle to at least 1 degree. Where the near plane is not above 0, it takes far * 0.01 instead. The drawn spot keeps the vertical angle, the aspect ratio tan(h) / tan(v) and the near and far planes. Inferred · Sekiro · code read, Sekiro 1.06

Does the cull sphere take the vertical half-angle or the narrower one?

The one recorded spot of unequal axes is narrower vertically (90 by 8 degrees), so its record cannot tell “the vertical axis” from “the narrower axis”. The code that writes CullPositionAndRange has not been located. A read-back of a spot that is narrower across than up, such as the 10 by 50 degree spot among the five in spot lights, would settle it. The rule is also unsampled above a vertical half-angle of 15 degrees.

LightType (+64 of a point record, +128 of a spot’s) says whether the light is drawn with a local shadow this frame. Only two kinds of program read it, and both compare it as a float. The organize programs list a light for the unshadowed tiled pass only where it equals 0.0; the shadowed (Sdw) tiled and compute passes sample the light’s shadow only where it is above 0.0. Observed · code read

Game Programs that read point +64 The comparison Confidence
Elden Ring GXLightOrganize8, GXLightOrganize16, GXLightOrganize8_noopt fcmp oeq LightType, 0.0, which gates the entry’s tiled-pass bit Proven · Elden Ring · dxc census of every program
Elden Ring GXRend_TileBasedLightingSdw fcmp ogt LightType, 0.0, which gates the cube-shadow lookup Proven · Elden Ring · dxc census of every program
Sekiro GXLightOrganize, 8 and 16; GXLightCompute, 8 and 16 eq LightType, 0.0 Observed · Sekiro · fxc census of the 931 programs that declare it
Sekiro GXLight8Sdw, GXLightCompute8Sdw, the other GXLight*Sdw and GXLightCompute*Sdw, GXRend_TileBasedLightingSdw lt 0.0, LightType Observed · Sekiro · fxc census of the 931 programs that declare it

In Elden Ring no other program reads point +64: not the unshadowed GXRend_TileBasedLighting (it reads +0 to +40, +48 and +52), not the shadowed organize programs GXLightOrganize8Sdw, 16Sdw and 8Sdw_noopt (they read +60 and mark every light in its depth range for the tiled pass), and none of the effect, FLVER, forward or SpeedTree programs. Proven[dxc census of every program]{games=“elden-ring”} No forward material of either game reads LightType or any shadow field after it. Proven · Elden Ring · every program that declares the structure Observed[the 852 forward materials of the census]{games=“sekiro”} The spot record’s +128 is compared in the same two places: == 0.0 in GXLightOrganize8 and > 0.0 in GXRend_TileBasedLightingSdw. Observed · Elden Ring · code read

The game writes 1.0 for a light it draws with a local shadow and 0 for every other. At the Stormveil gate four consecutive dispatches of GXLightOrganize8 held light 32 of m10_00_00_00_0000, the one light among the records whose bank sets its cast-shadows byte, with 1.0 in all four, and 0 in every other record: four of a bank light that casts none (light 116) and 80 of lights of no bank. Observed · Elden Ring · one light, one session, four dispatches Of Sekiro’s nine read-back frames, all 148 records (142 point, 6 spot) hold 0 at the flag: 54 of them (51 point, 3 spot) stand on lights of the area’s bank m11_01 whose cast-shadows byte is 0, and 94 on no light of it. Observed · Sekiro · nine frames of three sessions No record of either game holds a value other than 0 and 1. Observed · Sekiro, Elden Ring · the read-back records Sekiro’s recorded bank, m11_01’s, holds no shadow-casting light, so its records add nothing on the shadowed side. Observed · Sekiro Sekiro’s frame by the Ashina Castle idol lists 19 point lights and no spot lights, all with 0 at the flag. Observed · Sekiro · one frame

The list does not hold every bank light that reaches what is on screen, and a shadow-casting bank light left out of it is drawn with no shadow: in the frames below every shadowed per-light pass draws a listed light and the atlas holds that light’s regions alone. Observed · Elden Ring · 6 frames, 6 sessions, two views

At the Stormveil gate, six frames from six sessions list 15 to 23 point lights and no spot lights: five from one camera position and one from 9.3 m nearer the gate. Two records stand on lights of bank m10_00_00_00_0000 in every frame: light 32, the only listed light whose bank sets its cast-shadows byte, which holds 1.0, and light 116, which holds 0. Observed · Elden Ring · 6 frames, 6 sessions The bank’s shadow-casting light 35, a point light of radius 6.5 that stands 6.6 m from light 32, is in none of them, though its sphere lies inside the view frustum, 36.5 m from the camera in the first view (36.3 m in one frame of it) and 27.5 m in the second, and reaches visible surfaces. Observed · Elden Ring · 6 frames, 6 sessions

Reaching was measured against the depth buffer as the frame began (the previous frame’s, the camera not moving), with 230,400 surface points sampled on a 4-pixel grid. Four point lights of the bank reach a sampled point in each view, and no others: Observed · Elden Ring · one frame of each view

Light Casts shadows Bytes from 0xC0 Points within its radius, first view Second view Listed
32 yes 00 01 00 00 237 940 yes, shadowed
116 no 00 00 00 01 2,629 11,465 yes
35 yes 01 01 00 01 620 2,875 no
127 no 01 01 00 01 38,389 152,686 no

In both views the lights that reach the picture split on the byte at bank record 0xC0 (see BTL light banks): the listed lights have 0 there and the omitted lights 1, light 127 at 11 m from the camera covering much of the second view. Observed · Elden Ring · 4 lights, two views The five bank lights listed in another Stormveil session (13, 14, 115, 117 and 142) also have 0. Observed · Elden Ring · one session In the bank, 161 of the 216 lights have 1 at 0xC0 (the four bytes read 01 01 00 01 on 159 of them); among its nine shadow casters, lights 26, 28, 31 and 35 have 1, and 32, 97, 133, 136 and 137 have 0. Proven[every light of the bank]{games=“elden-ring”} That the byte keeps a light out of the list fits every light measured but rests on four. Inferred

GXLightOrganize8 builds each light’s per-tile mask from its depth-slice bits and sets bit 15 of the mask (bit 31 of the stored entry) only where LightType is 0.0 and the light reaches the tile’s opaque depth range. It stores an entry whenever the mask is non-zero, so a shadowed light stays in the tile’s list with its slice bits and the forward materials still light with it. The tile’s lookup word 2 counts the entries with bit 31 set. Observed · Elden Ring · the program’s DXIL, read not run In one frame’s outputs it does on all 3,600 tiles (Tile lists). Observed · Elden Ring · one frame The unshadowed tiled pass visits only entries with bit 31 set and discards a tile whose word 2 is 0, so a shadowed light is left to its per-light pass. Observed · Elden Ring · the programs’ DXIL

Elden Ring draws a shadowed point light with GXLightAcc_DPointLightShadowed1, a full-screen pass that takes the light’s whole 208-byte record in the constant buffer cbLightAccLightParam (b1) and its shadow from g_LightShadowTexture (t7). It reads every row of the record but LightType: its shadow is unconditional. Observed · Elden Ring · the program’s DXIL Its cbLightAccShadowInfo (b2) holds one float4 g_aShadowMapInfo[1]. Observed · Elden Ring · the declaration By their names, GXLightAcc_DPointLightShadowed2 to 4 draw two to four shadowed lights at once. Inferred The 80-byte PointLight of the unshadowed per-light passes GXLightAcc_DPointLight1 to 12 (g_aPointLight[N] in b1) ends with LightType and Pad and holds no shadow fields. Observed · Elden Ring · the declarations

In the recorded frames the pass follows a depth-only render into a 4096x4096 R16 target, the local-light shadow atlas, with the shadow casters drawn into 64x64 regions; it runs once in 2 of 7 frames, after 31 caster draws in five regions, and not at all in the other five. No frame runs a shadowed organize or shadowed tiled program. Observed · Elden Ring · 7 frames See also the rendered frame. In seven further frames at the Stormveil gate, each from its own session and from two camera positions 9.3 m apart, the pass runs once in every frame, after 31 caster draws into the same five regions; in the six of them whose constants were kept, its b1 holds light 32’s record. Observed · Elden Ring · 7 frames, 7 sessions Sekiro’s frame by the Ashina Castle idol runs no shadowed per-light pass and draws nothing into 64-texel regions. Observed · Sekiro · one frame

Where a shadowed light’s shadow is placed

Section titled “Where a shadowed light’s shadow is placed”

The fields after LightType place the shadow; the shadowed tiled pass and the per-light pass read them only for a shadowed light. Observed · code read

  • ProjParam: the tiled pass takes .x and .y as the face projection’s depth terms and .w as the shadow’s strength. Inferred · from the code’s use Light 32’s holds (1.001, -0.008, 0, 1). Observed · Elden Ring · 1 light Its .x and .y are f / (f - n) and -n f / (f - n) for the far plane f = the light’s range (8) and the near plane n = 0.008, a thousandth of it, computed in 32-bit floats: equal bit for bit. Observed · Elden Ring · 1 light, one frame The per-light pass compares the atlas’s depth with 1 - x - y / m, m the distance along the face’s axis: the reversed depth (1 at the near plane, 0 at the far) the face’s camera writes. Observed · Elden Ring · the program’s DXIL Whether the near plane is always a thousandth of the range or a fixed 0.008 is not separated by one light of range 8. Unknown

  • ShadowCenterPos: the point the shadow is rendered from, in the frame g_CameraMatrix (cbLightAccParam +128) takes view positions to, with w 1. Light 32’s is its stored position moved by the session’s render-frame translation, (-34.254, 8.079, -0.55, 1). Observed · Elden Ring · 1 light (On frames, see coordinate frames.)

  • ViewportScaleOffset[6]: one cube face each, as a scale (xy) and an offset (zw) of atlas coordinates. The tiled pass picks the face by the major axis of ShadowCenterPos minus the pixel and makes four depth-compare reads (SampleCmpLevelZero) of t7. Observed · Elden Ring · the program’s DXIL Light 32’s six faces have scales of ±0.0071 and offsets that put their centres, times 4096, at (32, 32), (160, 32), (32, 160), (160, 160), (288, 32) and (416, 32): the centres of six 64-pixel squares of the atlas, five of which received the recorded frames’ caster draws. Observed · Elden Ring · 1 light, a separate session from the frames Every face’s scale is (+29/4096, -29/4096) exactly, so the 58 central texels of a 64-texel region span the face’s 90 degrees. Observed · Elden Ring · 1 light, one frame The six faces, in the record’s order, are chosen by the light-to-pixel vector L (the pixel’s position less ShadowCenterPos), and each samples the atlas at offset + scale * (u, v) / m, m the largest of |L.x|, |L.y|, |L.z|: Observed · Elden Ring · GXLightAcc_DPointLightShadowed1’s DXIL

    Face Major axis u v
    0 +x -L.z L.y
    1 -x L.z L.y
    2 +y L.x -L.z
    3 -y L.x L.z
    4 +z L.x L.y
    5 -z -L.x L.y
  • A spot light’s ShadowParam and its one ViewportScaleOffset do the same for its single region. Observed · Elden Ring · code read

The record of an unshadowed light keeps what the buffer held there before: 80 of the 84 unshadowed records of the Stormveil dispatches hold non-zero bytes unrelated to the light (-inf, 2250, matrix-like rows), and four hold zeros. Observed · Elden Ring · one session Sekiro’s read-back unshadowed records hold zeros there. Observed · Sekiro No program reads these bytes where the flag is 0. Observed · code read

Does the game shadow every listed light whose bank casts shadows?

Only one listed shadow-casting light has been read back, light 32, and it is shadowed in every frame that lists it. A shadow caster can be left out of the list altogether (light 35, above), so the question is about the listed ones: whether a listed caster can hold 0 (a budget or a distance rule). Bank m10_00_00_00_0000 has nine shadow-casting point lights and the area’s effects five shadowing spot lights. A frame that lists two or more of them would settle it: the organize program’s buffers and every shadowed per-light pass’s b1. Lights 97 and 133, 4.7 m apart with 0 at 0xC0, are such a pair.

What does the byte at bank record 0xC0 do?

In the one view measured, it separates the bank lights the game lists from those it leaves out (above). Whether it is a switch the game reads, which state sets it, and what the bytes at 0xC1 to 0xC3 do, is not established. A frame where a light with 1 there reaches the picture and is listed would refute the rule.

The casters of face 0 (+x) of light 32 are drawn with a cbSceneParam whose camera sits at ShadowCenterPos and looks along +x, its view’s rows right (0, 0, -1), up (0, 1, 0) and forward (1, 0, 0): the face’s (u, v) above are its view’s x and y. Its projection’s P00 and P11 are 0.90625 (58/64: a 64-texel viewport spans 95.6 degrees, its 58 central texels 90), its depth is reversed (P22 -0.001001, P23 0.008008, FC_FrustumNear 0.008, FC_FarClipInfo.x 8.0000057), and the same camera is bound in three consecutive frames. Observed[one session, 15 or 16 caster draws in each of three frames]{games=“elden-ring”} The cameras of the other five faces have not been read back; that each looks along its axis with the (u, v) above as its x and y follows from how the per-light pass samples them. Inferred

The caster draws into the atlas test depth with GREATER_EQUAL and write it, after a clear of the atlas to 0, with a depth bias of -2, a slope-scaled bias of -3 and no clamp; 15 of the 16 cull no faces and one culls back faces. Observed · Elden Ring · 16 caster draws of one frame

The per-light pass’s g_aShadowMapInfo[0] holds the atlas’s size and its texel’s step, (4096, 4096, 1/4096, 1/4096), and it uses .zw as its filter step. Observed · Elden Ring · one frame; the program’s DXIL

Where does a second shadowed light go in the atlas?

Light 32’s six regions sit at (0, 0), (128, 0), (0, 128), (128, 128), (256, 0) and (384, 0): the first six cells of a grid of 128 texels in Morton order. Observed · Elden Ring · 1 light The record holds the same six offsets in six frames of six sessions; in the four of them whose atlas contents as the frame began were kept (what the frame before left there), the atlas holds depth in five of those cells and nowhere else, so the frame before shadowed no second light either. Observed · Elden Ring · 6 frames; 4 atlases Whether further lights take the next cells, and whether every light’s region is 64 texels, needs a frame with two shadowed lights. Inferred