Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dfd0b6d
feat(VR): add DlssEnhancer Bridge + Params + Pre/Postprocess (PR-3 MV…
alandtse May 16, 2026
cd96d54
feat(VR): add DlssEnhancer Core + Ops (PR-3 MVP-B)
alandtse May 16, 2026
8966f06
feat(VR): add DlssEnhancer Modes (PR-3 MVP-B) + bot-flagged per-eye fix
alandtse May 16, 2026
e3bee66
feat(VR): add DlssEnhancerFeature + SubrectStretchCS (PR-3 MVP-B)
alandtse May 16, 2026
c10a8c5
feat(VR): wire DlssEnhancer route into Upscale + globals (PR-3 MVP-B)
alandtse May 16, 2026
b22ad87
refactor(VR): fold DlssEnhancer into Upscaling, mirror DLSSperf pattern
alandtse May 24, 2026
911e8e3
fix(VR): correct SubrectStretchCS shader path after Upscaling refactor
alandtse May 25, 2026
4a48a38
refactor(VR): rename DlssEnhancer -> FoveatedRender
alandtse May 26, 2026
e19e79e
fix(VR): address PR #44 review sweep
alandtse May 26, 2026
77e1257
fix(VR): foveated route honors DLSSperf and skips in menus
alandtse May 26, 2026
e69873f
refactor(util): lift OpaquePreviewBlendCallback to Util::Subrect
alandtse May 26, 2026
06e6a62
refactor(VR): foveated sharpening — sharpnessDLSS=0 is the single dis…
alandtse May 26, 2026
6a4483f
feat(VR): foveated UX polish — visualizer, nasal preset, dead UI removal
alandtse May 26, 2026
f9e4656
fix(VR): foveated robustness from review feedback
alandtse May 26, 2026
6e1061d
fix(VR): foveated review round 2 — hash, faster-fulleye, naming
alandtse May 26, 2026
7191157
refactor(VR): foveated audit cleanup — FFX scale, trim history comments
alandtse May 26, 2026
8aec46d
refactor(upscaling): single GetQualityModeRatio helper for all paths
alandtse May 26, 2026
e7fc690
fix(VR): foveated Faster mode HMD mask clear + UX polish
alandtse May 27, 2026
a73cdfb
fix(VR): foveated bridge contract + stretch CS init recovery
alandtse May 27, 2026
02d1b51
fix(upscaling): GetQualityModeRatio honors DLAA (qualityMode=0)
alandtse May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Stretches the DRS-rendered region from a temporary render-resolution SBS texture
// to fill the entire eye in the display-resolution kMAIN SBS texture.
// Dispatched once per eye. Supports multiple sampling modes:
// 0 = Bilinear (clean upscale)
// 1 = Point / Nearest (cheapest, VRS-like broadcast)
// 2 = Gaussian Blur 3x3 (soft periphery background)

cbuffer StretchCB : register(b0)
{
uint DstOffsetX; // SBS destination X offset for this eye (0 or eyeWidthOut)
uint DstWidth; // display-resolution eye width
uint DstHeight; // display-resolution eye height
uint SrcOffsetX; // SBS source X offset for this eye (0 or renderEyeW)
uint SrcWidth; // render-resolution SBS total width (for UV normalisation)
uint SrcHeight; // render-resolution SBS total height
uint SrcEyeWidth; // render-resolution per-eye width
uint SrcEyeHeight; // render-resolution per-eye height
uint StretchMode; // 0=Bilinear, 1=Point, 2=GaussianBlur
float BlurRadius; // Texel-space radius for Gaussian blur (typical 0.5-4.0)
uint DebugVisualize; // 0=off, 1=tint stretched periphery red so the DLSS region pops
uint _pad;
};

Texture2D<float4> SrcTex : register(t0);
SamplerState BilinearSampler : register(s0);
RWTexture2D<float4> DstTex : register(u0);

[numthreads(8, 8, 1)] void main(uint3 tid : SV_DispatchThreadID) {
// Zero-dim guard: a misconfigured dispatch with any zero extent would
// divide-by-zero into NaN UVs and underflow point-mode coords into
// huge uint values. Bail before any math.
if (DstWidth == 0 || DstHeight == 0 || SrcWidth == 0 || SrcHeight == 0 ||
SrcEyeWidth == 0 || SrcEyeHeight == 0)
return;

if (tid.x >= DstWidth || tid.y >= DstHeight)
return;

// Map output pixel to normalised position within this eye [0,1]
float u = ((float)tid.x + 0.5) / (float)DstWidth;
float v = ((float)tid.y + 0.5) / (float)DstHeight;

// Map to source texel coordinates within this eye's render region
// then convert to full SBS texture UV (adding eye offset)
float srcU = (u * (float)SrcEyeWidth + (float)SrcOffsetX) / (float)SrcWidth;
float srcV = (v * (float)SrcEyeHeight) / (float)SrcHeight;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Clamp sample UVs to per-eye texel bounds so the bilinear footprint and
// blur kernel can't reach across the SBS midline into the neighboring
// eye's pixels.
float2 eyeMinUV = float2(((float)SrcOffsetX + 0.5) / (float)SrcWidth,
0.5 / (float)SrcHeight);
float2 eyeMaxUV = float2(((float)(SrcOffsetX + SrcEyeWidth) - 0.5) / (float)SrcWidth,
((float)SrcEyeHeight - 0.5) / (float)SrcHeight);

float4 color;

if (StretchMode == 1) {
// Point / Nearest: integer texel lookup, cheapest. min() keeps us
// inside [0, SrcEyeWidth-1] / [0, SrcEyeHeight-1] when u/v == 1.
uint2 srcPixel = uint2(
min((uint)(u * (float)SrcEyeWidth), SrcEyeWidth - 1) + SrcOffsetX,
min((uint)(v * (float)SrcEyeHeight), SrcEyeHeight - 1));
color = SrcTex.Load(int3(srcPixel, 0));
} else if (StretchMode == 2) {
// Gaussian blur 3x3: 9-tap weighted average around center
float2 texelSize = float2(1.0 / (float)SrcWidth, 1.0 / (float)SrcHeight);
float2 center = float2(srcU, srcV);
float2 step = texelSize * BlurRadius;

// Gaussian weights for 3x3 kernel (sigma ~ 0.85 * radius)
// Center=4, Edge=2, Corner=1, sum=16
float4 sum = SrcTex.SampleLevel(BilinearSampler, clamp(center, eyeMinUV, eyeMaxUV), 0) * 4.0;
sum += SrcTex.SampleLevel(BilinearSampler, clamp(center + float2(-step.x, 0), eyeMinUV, eyeMaxUV), 0) * 2.0;
sum += SrcTex.SampleLevel(BilinearSampler, clamp(center + float2(step.x, 0), eyeMinUV, eyeMaxUV), 0) * 2.0;
sum += SrcTex.SampleLevel(BilinearSampler, clamp(center + float2(0, -step.y), eyeMinUV, eyeMaxUV), 0) * 2.0;
sum += SrcTex.SampleLevel(BilinearSampler, clamp(center + float2(0, step.y), eyeMinUV, eyeMaxUV), 0) * 2.0;
sum += SrcTex.SampleLevel(BilinearSampler, clamp(center + float2(-step.x, -step.y), eyeMinUV, eyeMaxUV), 0);
sum += SrcTex.SampleLevel(BilinearSampler, clamp(center + float2(step.x, -step.y), eyeMinUV, eyeMaxUV), 0);
sum += SrcTex.SampleLevel(BilinearSampler, clamp(center + float2(-step.x, step.y), eyeMinUV, eyeMaxUV), 0);
sum += SrcTex.SampleLevel(BilinearSampler, clamp(center + float2(step.x, step.y), eyeMinUV, eyeMaxUV), 0);
color = sum * (1.0 / 16.0);
} else {
// Bilinear (default): single hardware-filtered sample
color = SrcTex.SampleLevel(BilinearSampler, clamp(float2(srcU, srcV), eyeMinUV, eyeMaxUV), 0);
}

// Debug visualizer: tint the cheap-stretched periphery red so the DLSS
// subrect (which BlendSubrectToOutput overwrites on top of us) reads as
// the un-tinted region. Lets users see at a glance where DLSS is actually
// reconstructing vs where the cheap stretch is filling.
if (DebugVisualize != 0) {
color.rgb = lerp(color.rgb, color.rgb * float3(1.6, 0.35, 0.35), 0.6);
}

DstTex[uint2(tid.x + DstOffsetX, tid.y)] = color;
}
32 changes: 2 additions & 30 deletions src/Features/ScreenshotFeature.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "Globals.h"
#include "Menu.h"
#include "Utils/FileSystem.h"
#include "Utils/Subrect.h"
#include <DirectXTex.h>
#include <PCH.h>
#include <algorithm>
Expand Down Expand Up @@ -273,35 +274,6 @@ namespace
combo[0].GetKey() == VK_SNAPSHOT;
}

// Blend state used around the preview's ImGui::Image draw. Two regression
// risks if this is changed:
// 1. BlendEnable must stay FALSE - the source texture carries non-1 alpha
// where Skyrim composited UI plates; default SRC_ALPHA blend lets the
// host window background show through (visible on the desktop mirror).
// 2. WriteMask must exclude alpha (RGB only). In VR, Skyrim's menu UI
// shader recomposites our menu plate over the SBS framebuffer with
// alpha blending; writing texture alpha into the menu plate RT
// produces a cutout visible only through the HMD. RGB-only writes
// leave the plate's pre-cleared alpha=1 in place.
// Paired with ImDrawCallback_ResetRenderState queued by Subrect::DrawEditor
// immediately after the image draw.
void OpaquePreviewBlendCallback(const ImDrawList*, const ImDrawCmd*)
{
static winrt::com_ptr<ID3D11BlendState> opaqueBlend;
if (!opaqueBlend) {
D3D11_BLEND_DESC desc{};
desc.RenderTarget[0].BlendEnable = FALSE;
desc.RenderTarget[0].RenderTargetWriteMask =
D3D11_COLOR_WRITE_ENABLE_RED |
D3D11_COLOR_WRITE_ENABLE_GREEN |
D3D11_COLOR_WRITE_ENABLE_BLUE;
globals::d3d::device->CreateBlendState(&desc, opaqueBlend.put());
}
if (opaqueBlend) {
globals::d3d::context->OMSetBlendState(opaqueBlend.get(), nullptr, 0xFFFFFFFF);
}
}

std::filesystem::path BuildScreenshotPath(const std::string& screenshotPath)
{
SYSTEMTIME st;
Expand Down Expand Up @@ -429,7 +401,7 @@ void ScreenshotFeature::DrawSettings()
}
}

subrect.DrawEditor(previewView, src.texture, 1.0f, 0.0f, OpaquePreviewBlendCallback);
subrect.DrawEditor(previewView, src.texture, 1.0f, 0.0f, Util::Subrect::OpaquePreviewBlendCallback);
}

void ScreenshotFeature::EnsurePreviewCache(ID3D11Texture2D* sourceTexture)
Expand Down
123 changes: 116 additions & 7 deletions src/Features/Upscaling.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@
#include "HDRDisplay.h"
#include "Hooks.h"
#include "State.h"
#include "Upscaling/PerfMode.h"
#include "Upscaling/DX12SwapChain.h"
#include "Upscaling/FidelityFX.h"
#include "Upscaling/FoveatedRender.h"
#include "Upscaling/FoveatedRender/Bridge.h"
#include "Upscaling/FoveatedRender/Core.h"
#include "Upscaling/FoveatedRender/Postprocess.h"
#include "Upscaling/FoveatedRender/Preprocess.h"
#include "Upscaling/PerfMode.h"
#include "Upscaling/Streamline.h"
#include "Utils/UI.h"
#include <Windows.h>
Expand Down Expand Up @@ -272,7 +277,7 @@ void Upscaling::DrawSettings()
// Derive scale from live `settings.qualityMode` — `resolution-
// Scale` is locked to the PerfMode boot snapshot, so reusing it
// here would mismatch the slider position the user sees.
const float displayScale = 1.0f / ffxFsr3GetUpscaleRatioFromQualityMode((FfxFsr3QualityMode)std::clamp<uint>(settings.qualityMode, 0u, 4u));
const float displayScale = 1.0f / GetQualityModeRatio(settings.qualityMode);
std::string labelWithScale = std::format("{} ( {:.2f}x )", baseLabel, displayScale);

ImGui::SliderInt("Upscale Preset", (int*)&settings.qualityMode, 0, 4, labelWithScale.c_str());
Expand All @@ -286,7 +291,7 @@ void Upscaling::DrawSettings()
const char* bootLabel = (upscaleMethod == UpscaleMethod::kDLSS) ? upscalePresetsDLSS[std::clamp<int>(4 - (int)bm, 0, 4)] : upscalePresets[std::clamp<int>(4 - (int)bm, 0, 4)];
Util::Text::RestartNeeded(
"Pending restart: currently active = %s ( %.2fx ). Change applies after game restart.",
bootLabel, 1.0f / ffxFsr3GetUpscaleRatioFromQualityMode((FfxFsr3QualityMode)bm));
bootLabel, 1.0f / GetQualityModeRatio(bm));
}
}

Expand Down Expand Up @@ -472,6 +477,25 @@ void Upscaling::DrawSettings()
ImGui::TreePop();
}

// FoveatedRender: foveated subrect DLSS — VR-only, opt-in mode of this
// feature. Like DLSSperf, lives here rather than as a peer Feature so
// all DLSS surfaces share one settings panel. Enable lives at the top
// level for discoverability; the body knobs are collapsed by default and
// greyed out until the user opts in.
if (globals::game::isVR) {
ImGui::Separator();
foveatedRender.DrawEnable();
const bool enabled = foveatedRender.settings.enabled != 0;
if (!enabled)
ImGui::BeginDisabled();
if (ImGui::TreeNodeEx("Foveated DLSS — Tuning")) {
foveatedRender.DrawSettings();
ImGui::TreePop();
}
if (!enabled)
ImGui::EndDisabled();
}

if (ImGui::TreeNodeEx("Backend Diagnostics")) {
// Streamline log level selection
const char* logLevels[] = { "Off", "Default", "Verbose" };
Expand Down Expand Up @@ -556,6 +580,11 @@ void Upscaling::DrawSettings()
void Upscaling::SaveSettings(json& o_json)
{
o_json = settings;
// Nest FoveatedRender's settings under a sub-key so they round-trip alongside
// Upscaling's own. Subrect controller persistence is owned by FoveatedRender.
json foveatedRenderJson;
foveatedRender.SaveSettings(foveatedRenderJson);
o_json["foveatedRender"] = foveatedRenderJson;
auto iniSettingCollection = globals::game::iniPrefSettingCollection;
if (iniSettingCollection) {
auto setting = iniSettingCollection->GetSetting("bUseTAA:Display");
Expand All @@ -567,6 +596,15 @@ void Upscaling::SaveSettings(json& o_json)

void Upscaling::LoadSettings(json& o_json)
{
// Pull FoveatedRender's nested block first so its absence doesn't fail the
// outer settings deserialize. FoveatedRender::ClampSettings touches sibling
// presetDLSS (cross-feature compat), so re-run it after `settings = o_json`
// below — otherwise the JSON re-assign overwrites the clamp and an
// incompatible preset slips through. (Copilot on PR #44.)
if (o_json.contains("foveatedRender")) {
foveatedRender.LoadSettings(o_json["foveatedRender"]);
o_json.erase("foveatedRender");
}
settings = o_json;

// Sanitize loaded settings to ensure enum indices are valid
Expand All @@ -587,6 +625,12 @@ void Upscaling::LoadSettings(json& o_json)
logger::warn("[Upscaling] Loaded presetDLSS {} out of range, resetting to 0 (Default)", settings.presetDLSS);
settings.presetDLSS = 0;
}
// Re-apply FoveatedRender's cross-feature clamp now that the JSON
// re-assign above has overwritten anything it set during its own
// LoadSettings (which fired before this block ran). Idempotent — no-op
// if FoveatedRender is inactive or the preset is already compatible.
// (Copilot on PR #44.)
foveatedRender.ClampSettings();
const float originalReflexFPSLimit = settings.reflexFPSLimit;
if (!std::isfinite(settings.reflexFPSLimit)) {
settings.reflexFPSLimit = 60.0f;
Expand Down Expand Up @@ -615,6 +659,7 @@ void Upscaling::LoadSettings(json& o_json)
void Upscaling::RestoreDefaultSettings()
{
settings = {};
foveatedRender.RestoreDefaultSettings();
}

void Upscaling::DataLoaded()
Expand Down Expand Up @@ -672,6 +717,10 @@ struct BSImageSpace_Init_FXAA
};
void Upscaling::PostPostLoad()
{
// Subrect controller defaults + stereo flag (FoveatedRender is no longer a
// Feature subclass so we drive its lifecycle from here).
foveatedRender.PostPostLoad();

bool isGOG = !GetModuleHandle(L"steam_api64.dll");
stl::detour_thunk<MenuManagerDrawInterfaceStartHook>(REL::RelocationID(79947, 82084));

Expand Down Expand Up @@ -701,6 +750,19 @@ void Upscaling::PostPostLoad()
logger::info("[Upscaling] Installed hooks");
}

float Upscaling::GetQualityModeRatio(uint qualityMode)
{
// Lower bound is 0, not 1: qualityMode=0 is DLAA / NATIVEAA (1.0x —
// render at display resolution). The FfxFsr3QualityMode enum header
// doesn't *declare* a 0 value, but the implementation delegates to
// FfxFsr3UpscalerQualityMode which has NATIVEAA=0 → 1.0f. Clamping to
// 1 would force DLAA into Quality (1.5x) and shrink the rendered
// region of kMAIN to 67%.
const float ratio = ffxFsr3GetUpscaleRatioFromQualityMode(
static_cast<FfxFsr3QualityMode>(std::clamp<uint>(qualityMode, 0u, 4u)));
return std::isfinite(ratio) && ratio > 0.0f ? ratio : 3.0f;
}

Upscaling::UpscaleMethod Upscaling::GetUpscaleMethod() const
{
// Lock runtime to the boot upscaler under PerfMode — engine RTs are
Expand Down Expand Up @@ -1342,7 +1404,7 @@ void Upscaling::ConfigureUpscaling(RE::BSGraphics::State* a_viewport)
// Boot qualityMode under PerfMode so projection stays coherent
// with the engine RTs sized at install.
const uint32_t qm = globals::features::upscaling.perfMode.IsHookActive() ? bootSnapshot.Boot(&Settings::qualityMode) : settings.qualityMode;
float resolutionScaleBase = 1.0f / ffxFsr3GetUpscaleRatioFromQualityMode((FfxFsr3QualityMode)qm);
float resolutionScaleBase = 1.0f / GetQualityModeRatio(qm);

auto renderWidth = static_cast<int>(screenWidth * resolutionScaleBase);
auto renderHeight = static_cast<int>(screenHeight * resolutionScaleBase);
Expand Down Expand Up @@ -1480,6 +1542,7 @@ void Upscaling::SetupResources()

void Upscaling::ClearShaderCache()
{
foveatedRender.ClearShaderCache();
for (int i = 0; i < 5; ++i) {
encodeTexturesCS[i] = nullptr; // com_ptr automatically releases
}
Expand Down Expand Up @@ -1909,7 +1972,42 @@ void Upscaling::Upscale()
logger::debug("[Upscaling] LoadingMenu close detected — rebuilding DLSS feature");
streamline.DestroyDLSSResources();
}
streamline.Upscale(main.texture, reactiveMaskTexture->resource.get(), transparencyCompositionMaskTexture->resource.get(), motionVectorCopyTexture->resource.get());

// PR-3 MVP-B: opt-in FoveatedRender route. When active, runs the
// per-eye DLSS dispatch with optional foveal subrect through
// FoveatedRenderImpl::Core; falls through to dev's standard path on
// any failure so users always see DLSS output (graceful
// degradation — no black frames if the enhancer preflights bad).
//
// Menu-skip: in menus the world stops producing fresh motion
// vectors and depth, but kMAIN keeps changing (UI plate composites).
// The route's subrect DLSS evaluate then accumulates temporal
// history against stale neighbourhood data and the subrect region
// renders as visible reconstruction garbage. Standard full-eye DLSS
// (the fall-through below) is robust to this because it reconstructs
// across the whole image — the foveated crop is what makes the
// stale-history bleed visible. Same menu-open predicate dev uses
// at Upscaling.cpp:1748 for ShouldUseFrameGenerationThisFrame.
auto* ui = globals::game::ui;
auto* st = globals::state;
const bool menuOpen = (ui && ui->GameIsPaused()) || (st && st->IsMainOrLoadingMenuOpen(ui));
bool routeHandled = false;
if (FoveatedRenderImpl::Bridge::IsRouteActive() && globals::game::isVR && !menuOpen) {
if (FoveatedRenderImpl::Preprocess::EncodeUpscalingTextures(*this)) {
routeHandled = FoveatedRenderImpl::Core::ExecuteVRDlssCore(streamline,
main.texture,
globals::game::renderer->GetDepthStencilData().depthStencils[RE::RENDER_TARGETS_DEPTHSTENCIL::kMAIN].texture,
reactiveMaskTexture->resource.get(),
transparencyCompositionMaskTexture->resource.get(),
motionVectorCopyTexture->resource.get());
if (!routeHandled) {
logger::warn("[FOVEATED] route preflight failed — falling through to standard DLSS path");
}
}
}
if (!routeHandled) {
streamline.Upscale(main.texture, reactiveMaskTexture->resource.get(), transparencyCompositionMaskTexture->resource.get(), motionVectorCopyTexture->resource.get());
}
} else if (upscaleMethod == UpscaleMethod::kFSR) {
// PerfMode bridge: when the engine RTs are shrunk to renderRes, FSR's displayRes
// output must land in perfMode.testTexture (the private displayRes target used for
Expand Down Expand Up @@ -2313,8 +2411,19 @@ void Upscaling::Main_PostProcessing::thunk(RE::ImageSpaceManager* a_this, uint32
upscaling.UpscaleDepth();
}

if (upscaleMethod == UpscaleMethod::kDLSS)
upscaling.ApplySharpening();
if (upscaleMethod == UpscaleMethod::kDLSS) {
// FoveatedRender's DLSS output doesn't land in sharpenerTexture the
// way dev's path does (the route writes to its own per-eye intermediates
// and copies back to kMAIN/testTexture), so dev's zero-copy
// ApplySharpening can't read sharpenerTexture. Route through
// Postprocess::ApplyDlssSharpening which does the kMAIN → sharpener →
// kMAIN round-trip. Both paths honor sharpnessDLSS=0 to disable RCAS.
if (FoveatedRenderImpl::Bridge::IsRouteActive()) {
FoveatedRenderImpl::Postprocess::ApplyDlssSharpening(upscaling);
} else {
upscaling.ApplySharpening();
}
}

auto imageSpaceManager = RE::ImageSpaceManager::GetSingleton();
GET_INSTANCE_MEMBER(BSImagespaceShaderISTemporalAA, imageSpaceManager);
Expand Down
Loading
Loading