Skip to content

Snowbreak: fix the displaced DLSS output, and keep the indirect texture format upgrade chain off - #222

Open
Clackz wants to merge 1 commit into
Filoppi:mainfrom
Clackz:snowbreak-hdr-indirect-chain
Open

Clackz wants to merge 1 commit into
Filoppi:mainfrom
Clackz:snowbreak-hdr-indirect-chain

Conversation

@Clackz

@Clackz Clackz commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Snowbreak: fix the displaced DLSS output, and keep the indirect texture format upgrade chain off

Two independent fixes for Snowbreak: Containment Zone (UE4.26, DX11). Both are game-local and touch
only Source/Games/Snowbreak Containment Zone/main.cpp.

1) DLSS output displaced from the real image

Problem

Whenever the internal render resolution is not the backbuffer size - the "render precision" slider
below 100%, or any resolution that is not 1:1 - the DLSS result was drawn offset: a sharp but
recognisably displaced copy of moving geometry sitting next to the real one.

How to reproduce

  1. Enable DLSS.
  2. Set render precision below 100%, or run at a resolution that is not 1:1 with the backbuffer.
  3. Move the camera. Moving geometry leaves a displaced copy behind.

Measured A/B, which is what pinned the factor:

backbuffer render precision render size factor before the fix
1280x720 100% 1280x720 1.000 correct
2560x1440 90% 2304x1296 1.111 displaced
1920x1080 100% 1920x1080 1.333 displaced

The last row starts from a 2560x1440 swapchain at init, so the frozen value is 2560x1440 while the
render resolution is 1920x1080.

Root cause

game_device_data.render_resolution was written exactly once, in OnInitSwapchain, and never again

  • the two writes in the TAA slot are commented out. It therefore held whatever the backbuffer size
    happened to be at startup, for the rest of the session.

That value ends up in LumaData.GameData.RenderResolution, and the motion-vector decode shader
converts a clip-space motion delta into screen pixels with

screenSpaceDelta = motionDelta * renderRes.xy

so every motion vector was scaled by frozen resolution / current render resolution, and DLSS
reprojected its history along the wrong vector. The offset is exactly that factor (1.111 and 1.333
in the table above).

Fix

Mirror device_data.render_resolution into game_device_data.render_resolution / viewport_rect
every frame in UpdateLumaInstanceDataCB. That is the live value: the per-view global constant
buffer scan refreshes it with the real internal render resolution. The MVs are expressed in pixels of
that same resolution, so the scale the decode divides by is correct by construction.

Supporting changes, all in the same direction - never hand DLSS data whose size it cannot know:

  • The MV decode dispatch uses the size recorded at capture time instead of the frozen
    render_resolution, so it covers exactly the pixels the target has.
  • The decode no longer caches its motion-vector ratio in a function-local static. The ratio is only
    valid for the resolution it was derived from, so it is now derived per pass, and a mismatch means
    "no usable MVs this frame" rather than "reuse the last one".
  • Captures are stamped with the frame they belong to (frame_index / capture_frame /
    capture_mv_w / capture_mv_h; atomics, because the TAA slot writes them and the upscale pass
    reads them). The upscale pass skips DLSS rather than reprojecting with MVs from another frame or
    another resolution, and resets the history when the render size changed or the jitter is unknown.
  • IsViewSizeInvSize takes an optional aspect ratio (negative = accept any). Validating a candidate
    against the aspect ratio of the resolution we happen to be at is self-referential - that ratio is
    itself only ever written from this very check - so a switch to a different aspect ratio could never
    be discovered again, and jitter / near / fov stayed frozen at their previous values.
  • UE4-SRSTATE now prints mvres WxH, the resolution the decode divides by. It has to equal the
    DLSS input size in; when it does not, the offset is mvres / in_w. That makes the invariant
    visible in a log instead of something that has to be inferred.

Verification

  • Built through CI and deployed locally.
  • 1280x720@100%, 1920x1080@100% and 2560x1440@90% are all correct after the fix; the latter two were
    displaced before it.
  • mvres equals the DLSS input size in every state line logged since.

Scope / risk

  • skip_sr now also fires when the MV capture is not from the current frame or does not match the
    render size. In that state the game's own bilinear blit is used for that frame instead of DLSS. It
    is transient - the TAA slot re-captures on the next frame that has a velocity pass - but it does
    mean DLSS pauses on frames without a velocity pass (menus, loading, camera cuts) instead of
    upscaling with data that belongs to another frame.
  • Game-local. No other game and no core file is touched.

2) MainMap loading screen hang with Luma HDR

Problem

With Luma HDR enabled in Snowbreak: Containment Zone (UE4.26, DX11), leaving a dungeon and
returning to the main lobby could leave the game stuck on the MainMap loading screen
indefinitely. The game is not frozen: it keeps rendering at full frame rate underneath, but the
world only starts drawing again after the user presses ESC back out to the "main" lobby screen
(about a second later).

How to reproduce

  1. Enable Luma HDR (either auto-detected on first boot on an HDR display, or by setting
    EnableHDR in the ReShade config).
  2. Enter a dungeon. A tower dungeon was used for testing.
  3. Leave the dungeon and return to the main lobby.
  4. The MainMap loading screen stays up. The game still responds and runs at full frame rate, but
    the world never resumes rendering until ESC is pressed.

Two caveats that matter when A/B testing this:

  • The stage type matters. Exiting a high tower stage and exiting other stages go through
    different UI teardown paths, so keep the stage type fixed between the "before" and "after" runs.
  • It is not 100% deterministic. That is why the bisect below was done by exposing each switch
    as its own config key rather than by rebuilding between runs.

Root cause

The HDR path in this game's main.cpp turns on several things at once. Turning them off one at a
time, with EnableHDR as the only other variable, isolates the indirect texture format upgrade
chain:

switch tested value result
swapchain format upgrade 2 (AllowedEnabled) not the cause
scRGB swapchain upgrade 1 (scRGB) not the cause
display composition 0 / 1 not the cause
texture format upgrades 0 (None) no stall
2D size filters 12293 not the cause
indirect upgrade chain 2 (DirectAndIndirectDependencies) stalls
indirect upgrade chain 0 (None) no stall

enable_chain_indirect_texture_format_upgrades was set to
ChainTextureFormatUpgradesType::DirectAndIndirectDependencies, the highest of the three levels
and the only one that additionally pulls in the compute/dispatch side resources
(core.hpp: if ((ChainTextureFormatUpgradesType)cmd_list_data.enable_chain_indirect_texture_format_upgrades >= ChainTextureFormatUpgradesType::DirectAndIndirectDependencies)).
Only two other games sit at that level (Final Fantasy XV and Middle-earth: Shadow of Mordor);
every other game uses DirectDependencies.

I have not traced which specific resource ends up stuck — the evidence stops at "the indirect chain
at the DirectAndIndirect level causes it". Happy to dig further if you want the exact resource.

Fix

Set the indirect chain to None for this game only. None is also the core default
(ResourceUpgradeManager::enable_chain_indirect_texture_format_upgrades). Everything else the HDR
path sets is kept: the swapchain format upgrade, the scRGB swapchain upgrade, the texture format
upgrades, the 2D size filters and display composition.

Note that the per command list value is separately pinned to DirectDependencies by OnPresent();
this change only touches the device wide setting.

Verification

  • Built through CI and deployed locally: addon 9,431,552 bytes, SHA-256
    11d3176a5ee205d156a4a3d5bcedd8ba0bc245be5e5a51a0fb127d3da4c06adf.
  • Three separate runs of "tower dungeon in -> back to the lobby": no stall.
  • HDR output is still correct with the indirect chain off: the tonemap LUT is still replaced, the
    game still gets its scRGB swapchain, and switching Display Mode between SDR and HDR still changes
    the image on screen.
  • Test display is an HDR monitor reporting 400 nits in its EDID, so ScenePeakWhite reads 400.

Scope / risk

  • One file, one line of code plus a comment: Source/Games/Snowbreak Containment Zone/main.cpp.
  • No other game is affected; the change is entirely game-local.
  • The only thing lost is the indirect resource upgrades themselves, i.e. HDR is slightly less
    thorough on resources that are only reached indirectly. HDR output itself is unchanged in the
    cases tested.
  • Side effect worth flagging for reviewers: with HDR restored, the mod again auto-enables HDR on
    first boot when the primary display reports HDR support. That is upstream behaviour, but it means
    users on an HDR display who want SDR have to untick "Enable Luma HDR" once.

Notes for the reviewer

@Clackz
Clackz force-pushed the snowbreak-hdr-indirect-chain branch from a1ff12b to 2621aaf Compare October 2, 2026 18:51
@Clackz Clackz changed the title Snowbreak: keep the indirect texture format upgrade chain off (fixes the MainMap loading screen hang) Snowbreak: fix the displaced DLSS output, and keep the indirect texture format upgrade chain off Oct 2, 2026
…re format upgrade chain off

Two independent Snowbreak fixes, both in this game's main.cpp.

1) DLSS output displaced from the real image

   When the internal render resolution is not the backbuffer size - the render
   precision slider below 100%, or any resolution that is not 1:1 - the DLSS
   result was drawn offset: a sharp but recognisably displaced copy of moving
   geometry next to the real one.

   Root cause: game_device_data.render_resolution was written exactly once, in
   OnInitSwapchain, and never again (the two writes in the TAA slot are
   commented out). It therefore held whatever the backbuffer size was at
   startup for the whole session. That value ends up in
   LumaData.GameData.RenderResolution, and the motion-vector decode shader
   converts a clip-space motion delta into screen pixels with

       screenSpaceDelta = motionDelta * renderRes.xy

   so every motion vector was scaled by (frozen resolution / current render
   resolution). DLSS then reprojected its history along the wrong vector.
   Measured: 1280x720 at 100% (render size == backbuffer, factor 1.0) is fine;
   2560x1440 at 90% (factor 1.111) and 1920x1080 at 100% from a 2560x1440
   swapchain (factor 1.333) are displaced by exactly that factor.

   Fix: mirror device_data.render_resolution into game_device_data
   .render_resolution/.viewport_rect every frame in UpdateLumaInstanceDataCB.
   That is the live value - the per-view global constant buffer scan refreshes
   it with the real internal render resolution - so the MVs, which are
   expressed in pixels of that same resolution, and the scale the decode
   shader divides by, are consistent by construction.

   Supporting changes, all in the same direction (never feed DLSS data whose
   size it cannot know):

   - The MV decode dispatch uses the size recorded at capture time instead of
     the frozen render_resolution, so it covers exactly the pixels the target
     has.
   - The decode no longer caches its motion-vector ratio in a function-local
     static: the ratio is only valid for the resolution it was derived from,
     so it is now derived per pass, and a mismatch means "no usable MVs this
     frame" rather than "reuse the last one".
   - Captures are stamped with the frame they belong to (frame_index /
     capture_frame / capture_mv_w / capture_mv_h, atomics: written by the TAA
     slot, read by the upscale pass). The upscale pass skips DLSS instead of
     reprojecting with MVs from another frame or another resolution, and
     resets the history when the render size changed or the jitter is unknown.
   - IsViewSizeInvSize takes an optional aspect ratio (negative = accept any).
     Validating a candidate against the aspect ratio of the resolution we
     happen to be at is self-referential: that ratio is itself only ever
     written from this very check, so a switch to a different aspect ratio
     could never be discovered again and jitter/near/fov stayed frozen.
   - UE4-SRSTATE now prints "mvres WxH", the resolution the decode divides
     by. It has to equal the DLSS input size; when it does not, the offset is
     mvres / in_w. This is the invariant to check in a log.

   Verified in game at 1280x720@100%, 1920x1080@100% and 2560x1440@90%.

2) MainMap loading screen hang with HDR (previously reviewed in this PR)

   Kept at "None" instead of the "DirectAndIndirectDependencies" the other
   Unreal Engine games use. With the indirect chain enabled, this game hangs
   on the MainMap loading screen when leaving a dungeon: it keeps running at
   full frame rate underneath, but the world only restarts rendering once the
   user presses ESC. Bisected 2026-09-19 by turning the individual HDR
   switches off one at a time - the swapchain upgrades, the texture format
   upgrades, the 2D size filters and the display composition setting are all
   still enabled here.
@Clackz
Clackz force-pushed the snowbreak-hdr-indirect-chain branch from 2621aaf to 6fca7bd Compare October 2, 2026 19:04
@Clackz

Clackz commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Update: this PR is now rebased onto current main and squashed into a single commit, and it also
carries the Snowbreak DLSS misalignment fix (see the updated description). Still one file:
Source/Games/Snowbreak Containment Zone/main.cpp.

About the red cpp-linter, since it is the only red check here - it is not something this change
introduced:

  • It fails on any PR that touches this file, including the previous head of this very PR, which
    changed exactly one line. That run (36766649213) reported 1 clang-format-checks-failed +
    216 clang-tidy-checks-failed at lines 90-94 and 1021, all of which are pre-existing.
  • lines-changed-only defaults to false, so the whole file is scanned rather than just the changed
    lines, and clang-tidy cannot resolve ..\..\Core\core.hpp from this file - that is where most of
    the 216 come from. cpp-linter is green on main for the other games' PRs.
  • I checked the new code locally: clang-format reproduces the CI line numbers exactly, and I fixed
    what my own code added (ternary line breaks, two continuation indents, two braced-init spacing
    issues). The file is now back to exactly the pre-existing positions - L97-101 and L1131 in the new
    file are L90-94 and L1021 in the old one, just shifted - so this change adds no new clang-format
    findings
    . The 227 clang-tidy count is the pre-existing 216 plus 11 from the added lines.

So the remaining red is the file's pre-existing state, not a regression. If you want the linter to
work as a gate on this file, lines-changed-only: true (and/or tidy-checks: '-*') in lint.yml
would do it - say the word and I will open a separate PR for that.

Build-wise: I compiled it through my fork's CI (Publishing-Release x64) before pushing, and the
Build and Release Luma Mods run on this head is still going through its configurations.

@Clackz

Clackz commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on the build: Build and Release Luma Mods finished green on this head - all 8
configurations (Development-Debug / Development-Release / Test-Release / Publishing-Release, Win32
and x64) succeeded.

The three projects its summary reports as "failed to build (excluded from this release)" - Need For
Speed Rivals, Middle-earth Shadow of Mordor and Call of Duty DX11 - are pre-existing on main as
well: the run for #221 (36958965096) lists exactly the same three, and the job still concludes
success. Not related to this change.

So the only red check left is the cpp-linter described in my previous comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant