engine: presets E/F AutoExposure, the game's exposure fields and NGX's jitter order

NGX's CNN takes this frame's exposure scale and pre-exposure in both translated kernels (input words 244-256,
reconstruction 592-604) and the previous frame's exposure as the input kernel's texture 352
(cuda_copy_exposure_kernel's half surface). The runtime left all of it at the model dump's values, so HDR games
with a pre-exposure other than 1 or a changing exposure measured 18-39 dB against NGX; now 54-64 dB.

AutoExposure (flag 0x40), which the shim refused for E/F: NGX runs K's log-mean measurement on M's grid every
second frame. exposure_k.comp MODE 3 writes it to the 1x1 image the kernels sample; sr/exposure_copy_e.comp
keeps the previous exposure. 60-66 dB over eleven frames at 960x540 -> 1920x1080, pre-exposure 19-21.

The reconstruction's jitter words 544-556 are (y, x); the runtime wrote (-x, -y), which matches only the
harness's first three jitters, so every later frame of a game's jitter sequence measured 26-28 dB. Twelve-frame
captures now measure 60-66 dB HDR and LDR; on the Steam Deck through the shim 51-67 dB against ZLUDA.

The D3D12 harness takes per-frame lists in D4R_HARNESS_PRE_EXPOSURE / _EXPOSURE_SCALE; d4r-e takes the
AutoExposure flag, per-frame exposure values and the game's exposure texture. E/F models need rebuilding
(the install's model cache key changes); older models keep the old binding and refuse AutoExposure.
This commit is contained in:
woutermans committed 2026-10-19 02:42:48 +02:00
1 parent 916b0d1467
commit 3f66081add
12 files changed
+249 -47

No files matched your search

+19 -1
View File
@@ -1147,7 +1147,25 @@ in two constants.
packed in the DLL; NGX uploads them unpacked), `runtime_e.inc` is `EModel` / `EEngine`, `game_e.cpp` the game
adapter, `replay_e.cpp` the replay (`d4r-e`). The shim runs presets 5 and 6 with `engine\e` / `engine\f`
(`D4R_ENGINE_MODEL_E_DIR`, `_F_DIR`), LDR or HDR, either motion resolution, any render size (a new size rebuilds
the engine); not with AutoExposure. `build-engine-models.sh` builds both.
the engine), the game's exposure texture or AutoExposure. `build-engine-models.sh` builds both.
- Exposure (HDR; harness captures with per-frame pre-exposure, exposure scale and exposure texture schedules,
2026-10-19). Both translated kernels take this frame's exposure scale (twice), pre-exposure and 1 / pre-exposure
(input words 244-256, reconstruction 592-604). They sample the exposure as 1x1 textures: the input kernel's 344 and
360 and the reconstruction's 472 and 480 this frame's, the input kernel's 352 the previous frame's, which NGX's
`cuda_copy_exposure_kernel` copies into a half surface after each frame (the current one on a reset frame):
`sr/exposure_copy_e.comp`. With AutoExposure NGX runs K's measurement (`cuda_luma_convert_kernel`, three
`cuda_reduce_sum_kernel`, `cuda_auto_exposure_copy_kernel`: key 0.18 / 0.82 times exp(-mean log luma), the luma
scaled by 1 / pre-exposure, truncated to half) on every second frame from the first, on M's grid (half the output,
half the render size but at least 256 above 2x), and ignores the game's texture: `exposure_k.comp` MODE 3. The
engine used to leave all of this at the model dump's values (exposure 1, the current exposure as the previous one):
at 1280x720 -> 1920x1080 with pre-exposure 4/2/8 and scale 0.5/0.75/1.25 it measured 39/18/29 dB against NGX with
the game's texture, now 64/55/54 dB; with AutoExposure 65/54/54 dB, through the shim as through `d4r-e` (neutral
exposure: 64-66 dB, unchanged). Models built before this keep the old binding and refuse AutoExposure.
- The reconstruction's jitter words 544-556 are the previous and this frame's jitter as (y, x). The runtime wrote
(-x, -y), which agrees only on the harness's first three jitters (0, -+0.25), so three-frame checks passed while
later frames of the jitter sequence measured 26-28 dB (every game's Halton jitter was affected). Twelve-frame
captures at 640x360 -> 1280x720 now measure 60-66 dB (HDR), 61-66 dB (LDR), and with AutoExposure at
960x540 -> 1920x1080 and pre-exposure 19-21 60-66 dB; on the Steam Deck through the shim 51-67 dB against ZLUDA.
With the f32 reconstruction head, accuracy against NGX through ZLUDA (three temporal frames): 63-68 dB at 640x360 -> 1280x720
(LDR and HDR, both motion resolutions, E and F), 1280x720 -> 1920x1080, 1001x563 -> 1501x845 (generic kernel),
+7 -1
View File
@@ -20,6 +20,8 @@ variants use aligned word accesses for their packed tensors. Writes:
<stage>.spv the network stages
input-<v>.spv / .globals.bin, final-<v>.spv / .globals.bin the translated kernels
input.args, final.args their argument blocks from the dump (the runtime rewrites every frame-dependent field)
exposure_e0.spv / _e1.spv NGX's AutoExposure measurement (engine/exposure_k.comp MODE 3)
exposure_copy_e.spv NGX's copy of the frame's exposure that the next frame reads (engine/sr/exposure_copy_e.comp)
Everything but d4r's shaders is derived from the user's DLL: it stays on their machine and is never packaged.
"""
import argparse
@@ -198,7 +200,11 @@ def main():
for off in l.ptr:
block[off:off + 8] = bytes(8)
(out / f'{name}.args').write_bytes(bytes(block))
lines += ['template input input.args', 'template final final.args']
for p in '01':
glsl(HERE / 'exposure_k.comp', out / f'exposure_e{p}.spv', ['-DMODE=3', f'-DPASS={p}'])
glsl(HERE / 'sr' / 'exposure_copy_e.comp', out / 'exposure_copy_e.spv')
lines += ['template input input.args', 'template final final.args', 'exposure exposure_e0.spv exposure_e1.spv',
'exposure_copy exposure_copy_e.spv']
(out / 'e.txt').write_text('\n'.join(lines) + '\n')
print(f'Super Resolution CNN model in {out} ({len(blob)} weight bytes)')
+10 -3
View File
@@ -5,6 +5,8 @@
// [0, 10000], 0.005).
// MODE 2 (Ray Reconstruction, the same cuda_dldn_engine_* kernels): MODE 1's measurement, written to two 1x1
// images instead of stage parameter blocks.
// MODE 3 (presets E and F, NGX's cuda_luma_convert / auto_exposure_copy kernels): MODE 0's measurement, written to
// the 1x1 image the input and reconstruction kernels sample as the exposure.
// Both modes measure the same grid: the colour rectangle sampled at half the output size with an
// endpoint-aligned nearest-texel lookup (the reference's cuda_luma_convert_kernel; the grid equals the
// rectangle itself only when the render size is half the output size).
@@ -17,6 +19,7 @@
#ifndef MODE
#define MODE 0
#endif
#define LOG_MEAN (MODE == 0 || MODE == 3) // key * exp(-mean of the log luma)
layout(local_size_x = 64) in;
layout(set = 0, binding = 0, std430) readonly buffer Params
{
@@ -63,7 +66,7 @@ void main()
const vec3 c = texelFetch(colorTex, p.origin + sampleAt, 0).rgb;
// the reference accumulates the logarithm of the exposed luma (cuda_luma_convert_kernel). The
// constant is applied to Y = fma(b, 0.25, fma(r, 0.25, g * 0.5)); MODE 1 keeps its own expression.
#if MODE == 0
#if LOG_MEAN
precise const float luma = p.scale * fma(c.b, 0.25, fma(c.r, 0.25, 0.5 * c.g));
// log(luma + 1e-6) as lg2(x) * ln2, and a non-finite or non-positive luma contributes nothing
if (luma > 0.0 && !isinf(luma))
@@ -93,6 +96,8 @@ layout(set = 0, binding = 5) uniform sampler2D gameExposure;
layout(set = 0, binding = 3, std430) buffer InputParams { float w[]; } inp; // enc0_m: expo, expoPrev at words 25, 26
layout(set = 0, binding = 4, std430) buffer PostParams { float w[]; } post; // post_m: words 14, 15
layout(set = 0, binding = 5, std430) buffer DownParams { float w[]; } down; // down_m: word 12
#elif MODE == 3
layout(set = 0, binding = 3, r16f) uniform writeonly image2D exposureOut;
#else
// MODE 2 (Ray Reconstruction): the denoiser's stages sample the exposure as two 1x1 textures, this frame's and
// the previous frame's (zero on the first frame and after a reset)
@@ -124,7 +129,7 @@ void main()
{
float mean = 0.0;
for (uint i = 0u; i < 64u; ++i) mean += part[i];
#if MODE == 0
#if LOG_MEAN
// auto_exposure_copy_kernel: key * exp2(-log2e * sum), written to a half surface. The formatted
// store truncates toward zero, as in MODE 1 (measured on the captured K exposure: the store lands
// one binary16 code below the correctly rounded value).
@@ -136,7 +141,7 @@ void main()
float measured = truncatedHalf(full);
#endif
if (!(measured > 0.0) || isinf(measured)) measured = 1.0;
#if MODE == 0
#if LOG_MEAN
previous = (p.flags & 2u) != 0u ? measured : current;
#else
previous = (p.flags & 2u) != 0u ? 0.0 : current; // 0: no earlier exposure (the stages read it as 1)
@@ -154,6 +159,8 @@ void main()
inp.w[25] = current; inp.w[26] = previous;
post.w[14] = current; post.w[15] = previous;
down.w[12] = current;
#elif MODE == 3
imageStore(exposureOut, ivec2(0), vec4(current));
#else
imageStore(exposureOut, ivec2(0), vec4(current));
imageStore(previousOut, ivec2(0), vec4(previous));
+7 -4
View File
@@ -9,13 +9,15 @@ struct GameUpscalerE::Impl {
uint32_t width, height, outputWidth, outputHeight, motionWidth, motionHeight;
game_detail::CanonicalInputs fallback;
bool started = false;
Impl(EModel& m, VkPhysicalDevice p, VkDevice d, uint32_t ow, uint32_t oh, uint32_t rw, uint32_t rh, bool hdr, bool lowRes)
: model(m), engine(m, rw, rh, ow, oh, hdr, lowRes), width(rw), height(rh), outputWidth(ow), outputHeight(oh),
Impl(EModel& m, VkPhysicalDevice p, VkDevice d, uint32_t ow, uint32_t oh, uint32_t rw, uint32_t rh, bool hdr, bool lowRes,
bool autoExposure)
: model(m), engine(m, rw, rh, ow, oh, hdr, lowRes, autoExposure), width(rw), height(rh), outputWidth(ow), outputHeight(oh),
motionWidth(lowRes ? rw : ow), motionHeight(lowRes ? rh : oh), fallback(p, d, rw, rh, motionWidth, motionHeight) {}
};
GameUpscalerE::GameUpscalerE(EModel& model, VkPhysicalDevice physical, VkDevice device, uint32_t outputWidth, uint32_t outputHeight,
uint32_t renderWidth, uint32_t renderHeight, bool hdr, bool lowResMotion)
: impl(std::make_unique<Impl>(model, physical, device, outputWidth, outputHeight, renderWidth, renderHeight, hdr, lowResMotion)) {}
uint32_t renderWidth, uint32_t renderHeight, bool hdr, bool lowResMotion, bool autoExposure)
: impl(std::make_unique<Impl>(model, physical, device, outputWidth, outputHeight, renderWidth, renderHeight, hdr, lowResMotion,
autoExposure)) {}
GameUpscalerE::~GameUpscalerE() = default;
void GameUpscalerE::record(VkCommandBuffer cb, const GameFrameE& frame) {
@@ -39,6 +41,7 @@ void GameUpscalerE::record(VkCommandBuffer cb, const GameFrameE& frame) {
s.jitter[0] = frame.jitter[0]; s.jitter[1] = frame.jitter[1];
s.motionScale[0] = frame.motionScale[0]; s.motionScale[1] = frame.motionScale[1];
s.reset = frame.reset;
s.preExposure = frame.preExposure; s.exposureScale = frame.exposureScale;
FrameImages images{};
images.color = directColor ? frame.color.view : g.fallback.color.view;
images.motion = directMotion ? frame.motion.view : g.fallback.motion.view;
+5 -3
View File
@@ -6,17 +6,19 @@
namespace d4r {
struct GameFrameE {
GameImage color, depth, motion, output; // as for GameUpscaler; compatible views at origin zero may be borrowed
GameImage exposure; // HDR: the game's 1x1 float exposure (a sampled view), else 1
GameImage exposure; // HDR without autoExposure: the game's 1x1 float exposure (a sampled view), else 1
float jitter[2] = {0, 0};
float motionScale[2] = {1, 1}; // NGX's MV scale
float preExposure = 1, exposureScale = 1; // the game's (DLSS.Pre.Exposure, DLSS.Exposure.Scale)
bool reset = false;
};
class GameUpscalerE {
public:
// The model must outlive this object. Render size fixed for its lifetime (a new size needs a new adapter).
// lowResMotion: motion at the render size (NGX's MVLowRes), otherwise at the output size.
// lowResMotion: motion at the render size (NGX's MVLowRes), otherwise at the output size. autoExposure: NGX's
// AutoExposure (HDR), the exposure measured from the colour (EModel::hasAutoExposure).
GameUpscalerE(EModel& model, VkPhysicalDevice physical, VkDevice device, uint32_t outputWidth, uint32_t outputHeight,
uint32_t renderWidth, uint32_t renderHeight, bool hdr, bool lowResMotion);
uint32_t renderWidth, uint32_t renderHeight, bool hdr, bool lowResMotion, bool autoExposure = false);
~GameUpscalerE(); // only once every recorded frame has completed on the GPU
GameUpscalerE(const GameUpscalerE&) = delete;
GameUpscalerE& operator=(const GameUpscalerE&) = delete;
+19 -7
View File
@@ -2,9 +2,10 @@
// after frame, and writes the output (and with D4R_E_DUMP_TENSORS=1 the network's tensors) after each frame.
//
// usage: d4r-e MODEL_DIR INPUT_DIR OUT_DIR
// INPUT_DIR: frames.txt ("size RW RH OW OH HDR LOWRES MW MH", then per frame "frame F jx jy mvsx mvsy reset") and
// INPUT_DIR: frames.txt ("size RW RH OW OH HDR LOWRES MW MH [AUTOEXPOSURE]", then per frame
// "frame F jx jy mvsx mvsy reset [preExposure exposureScale]") and
// F-color.bin (RGBA16F, render size), F-motion.bin (RG16F, MW x MH),
// F-depth.bin (R32F, render size, with LOWRES). D4R_E_TIME_REPEAT=N: best of N GPU timings per frame.
// F-depth.bin (R32F, render size, with LOWRES), optionally F-exposure.bin (R32F 1x1: the game's exposure, HDR). D4R_E_TIME_REPEAT=N: best of N GPU timings per frame.
#include "runtime.h"
#include <algorithm>
#include <cstdlib>
@@ -125,11 +126,16 @@ int main(int argc, char** argv) try {
auto framesText = read(inputDir + "/frames.txt");
std::istringstream frames(std::string(framesText.begin(), framesText.end()));
std::string key;
uint32_t rw, rh, ow, oh, hdr, lowRes, mw, mh;
frames >> key >> rw >> rh >> ow >> oh >> hdr >> lowRes >> mw >> mh;
uint32_t rw, rh, ow, oh, hdr, lowRes, mw, mh, autoExposure = 0;
std::string sizeLine;
std::getline(frames, sizeLine);
std::istringstream size(sizeLine);
size >> key >> rw >> rh >> ow >> oh >> hdr >> lowRes >> mw >> mh;
size >> autoExposure;
d4r::EModel model(c.physical, c.device, modelDir);
d4r::EEngine engine(model, rw, rh, ow, oh, hdr != 0, lowRes != 0);
d4r::EEngine engine(model, rw, rh, ow, oh, hdr != 0, lowRes != 0, autoExposure != 0);
Input color(c, VK_FORMAT_R16G16B16A16_SFLOAT, rw, rh), motion(c, VK_FORMAT_R16G16_SFLOAT, mw, mh), depth(c, VK_FORMAT_R32_SFLOAT, rw, rh);
Input exposure(c, VK_FORMAT_R32_SFLOAT, 1, 1);
HostBuffer staging(c, std::max<VkDeviceSize>(VkDeviceSize(ow) * oh * 8, 64u << 20));
VkQueryPoolCreateInfo qp{VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO};
qp.queryType = VK_QUERY_TYPE_TIMESTAMP; qp.queryCount = 4;
@@ -148,16 +154,22 @@ int main(int argc, char** argv) try {
barrier(cb, in.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_GENERAL);
});
};
while (frames >> key) {
for (std::string line; std::getline(frames, line);) {
std::istringstream fields(line);
if (!(fields >> key)) continue;
uint32_t f; int reset;
d4r::EFrameSettings s;
frames >> f >> s.jitter[0] >> s.jitter[1] >> s.motionScale[0] >> s.motionScale[1] >> reset;
fields >> f >> s.jitter[0] >> s.jitter[1] >> s.motionScale[0] >> s.motionScale[1] >> reset;
if (!(fields >> s.preExposure >> s.exposureScale)) s.preExposure = s.exposureScale = 1;
s.reset = reset != 0;
upload(color, "color", f, rw, rh, 8);
upload(motion, "motion", f, mw, mh, 4);
if (lowRes) upload(depth, "depth", f, rw, rh, 4);
const bool gameExposure = std::ifstream(inputDir + "/" + std::to_string(f) + "-exposure.bin").good();
if (gameExposure) upload(exposure, "exposure", f, 1, 1, 4);
d4r::FrameImages images{};
images.color = color.view; images.motion = motion.view; images.depth = depth.view;
if (gameExposure) images.exposure = exposure.view;
engine.setFrame(images, s);
if (first) c.submit([&](VkCommandBuffer cb) { model.recordUpload(cb); engine.recordInitialize(cb); first = false; });
double best = 1e30, in = 0, net = 0, fin = 0;
+6 -2
View File
@@ -317,6 +317,7 @@ struct EFrameSettings {
float jitter[2] = {0, 0}; // render pixels, as the game passes them to NGX
float motionScale[2] = {1, 1}; // NGX's MV scale: motion texels times this are the motion's pixels
bool reset = false;
float preExposure = 1, exposureScale = 1; // the game's (DLSS.Pre.Exposure, DLSS.Exposure.Scale)
};
class EModel {
public:
@@ -326,6 +327,7 @@ public:
EModel& operator=(const EModel&) = delete;
void recordUpload(VkCommandBuffer command);
bool uploadRecorded() const;
bool hasAutoExposure() const; // the model directory holds the exposure shaders (exposure_e0/1.spv)
private:
struct Impl;
std::unique_ptr<Impl> impl;
@@ -335,14 +337,16 @@ class EEngine {
public:
// hdr: NGX's IsHDR (linear colour, the exposure textures); lowResMotion: MVLowRes (render-size motion vectors,
// in render pixels; otherwise output-size motion in output pixels). Render size at most the output size.
// autoExposure (HDR): NGX's AutoExposure, the exposure measured from the colour on every second frame instead of
// read from images.exposure (needs EModel::hasAutoExposure).
EEngine(EModel& model, uint32_t renderWidth, uint32_t renderHeight, uint32_t outputWidth, uint32_t outputHeight,
bool hdr, bool lowResMotion);
bool hdr, bool lowResMotion, bool autoExposure = false);
~EEngine();
EEngine(const EEngine&) = delete;
EEngine& operator=(const EEngine&) = delete;
void recordInitialize(VkCommandBuffer command);
// color (and depth with lowResMotion) at the render size, motion at the render or output size; images.exposure
// the game's 1x1 exposure (HDR; 1 when absent). A borrowed RGBA16F finalOutput (GENERAL) is written directly.
// the game's 1x1 exposure (HDR without autoExposure; 1 when absent). A borrowed RGBA16F finalOutput (GENERAL) is written directly.
void setFrame(const FrameImages& images, const EFrameSettings& settings, FrameOutput finalOutput = {});
// With a timestamp query pool, four stamps are written from query `first`: start, after the input kernel, after
// the network and after the reconstruction.
+118 -14
View File
@@ -50,6 +50,10 @@ struct EModel::Impl {
std::map<std::string, std::unique_ptr<ETranslated>> input, final;
std::vector<uint8_t> inputTemplate, finalTemplate;
bool uploaded = false;
std::array<Pipeline, 2> exposure; // engine/exposure_k.comp MODE 3 (NGX's AutoExposure), when the model has it
bool hasExposure = false;
Pipeline exposureCopy; // engine/sr/exposure_copy_e.comp: the previous frame's exposure (HDR)
bool hasExposureCopy = false;
uint32_t preset = 5; // 5: E, 6: F
float kspread = 0.035f; // the reconstruction's constant at 584 times rx ry (E 0.035, F 0.1)
Impl(VkPhysicalDevice physical, VkDevice dev, const std::string& dir, VkPipelineCache c)
@@ -101,6 +105,18 @@ struct EModel::Impl {
k.surfaces.emplace_back(static_cast<uint32_t>(std::stoul(item.substr(0, colon))), item.substr(colon + 1));
}
(key == "input" ? input : final)[variant] = std::move(entry);
} else if (key == "exposure") {
// "exposure PASS0.spv PASS1.spv": the measurement over the colour, then the 1x1 exposure image
std::string measure, update; f >> measure >> update;
const auto t = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, s = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE;
exposure[0].create(device.handle, dir + "/" + measure, {b, t, b}, 32, cache);
exposure[1].create(device.handle, dir + "/" + update, {b, b, b, s}, 32, cache);
hasExposure = true;
} else if (key == "exposure_copy") {
std::string shader; f >> shader;
exposureCopy.create(device.handle, dir + "/" + shader, {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}, 32, cache);
hasExposureCopy = true;
} else if (key == "template") {
std::string which, file; f >> which >> file;
auto bytes = read(dir + "/" + file);
@@ -143,12 +159,13 @@ void EModel::recordUpload(VkCommandBuffer cb) {
m.uploaded = true;
}
bool EModel::uploadRecorded() const { return impl->uploaded; }
bool EModel::hasAutoExposure() const { return impl->hasExposure; }
struct EEngine::Impl {
EModel::Impl& model;
const Device& d;
uint32_t rw, rh, ow, oh, pw, ph, h0, w0; // render, output, the output padded to 128, the token grid
bool hdr, lowRes;
bool hdr, lowRes, autoExposure;
ETranslated *input = nullptr, *final = nullptr;
std::string finalVariant;
uint32_t finalGrid[2] = {0, 0};
@@ -161,6 +178,13 @@ struct EEngine::Impl {
std::vector<uint8_t> paramStaging, inBlock, finBlock;
std::array<Tex, 2> history, motionHistory;
Tex warped, discard, zero, exposureOne, out;
// AutoExposure: NGX's measurement (exposure_k.comp MODE 3) into `measured`, the 1x1 the kernels then sample
Buffer expP, expSums, expState;
ExposureParams exposure{};
Tex measured;
VkDescriptorSet exp1Set = VK_NULL_HANDLE;
// HDR with a model that has the copy shader: the previous frame's exposure, the input kernel's texture 352
Tex previousExposure;
bool outNew = false;
VkSampler nearest = VK_NULL_HANDLE, linear = VK_NULL_HANDLE;
VkDescriptorPool fixedPool = VK_NULL_HANDLE;
@@ -170,6 +194,8 @@ struct EEngine::Impl {
FrameOutput finalOutput{};
VkDescriptorPool pool = VK_NULL_HANDLE;
std::array<VkDescriptorSet, 2> input{}, final{};
VkDescriptorSet exposure = VK_NULL_HANDLE; // the measurement over this colour view (AutoExposure)
VkDescriptorSet copy = VK_NULL_HANDLE; // this frame's exposure into previousExposure
};
std::vector<Bindings> bindings;
const Bindings* bound = nullptr;
@@ -182,10 +208,15 @@ struct EEngine::Impl {
uint32_t lh(uint32_t l) const { return h0 >> l; }
uint32_t lw(uint32_t l) const { return w0 >> l; }
Impl(EModel::Impl& m, uint32_t renderWidth, uint32_t renderHeight, uint32_t outputWidth, uint32_t outputHeight, bool isHdr, bool lowResMotion)
: model(m), d(m.device), rw(renderWidth), rh(renderHeight), ow(outputWidth), oh(outputHeight), hdr(isHdr), lowRes(lowResMotion) {
Impl(EModel::Impl& m, uint32_t renderWidth, uint32_t renderHeight, uint32_t outputWidth, uint32_t outputHeight, bool isHdr,
bool lowResMotion, bool isAutoExposure)
: model(m), d(m.device), rw(renderWidth), rh(renderHeight), ow(outputWidth), oh(outputHeight), hdr(isHdr), lowRes(lowResMotion),
autoExposure(isAutoExposure) {
if (rw < 32 || rh < 32 || rw > ow || rh > oh || ow > 16384 || oh > 16384)
throw std::runtime_error("unsupported preset E/F dimensions");
if (autoExposure && (!hdr || !model.hasExposure))
throw std::runtime_error(hdr ? "the preset E/F model has no exposure shaders (rebuild it with compile_sr_e.py)"
: "automatic exposure needs HDR colour");
pw = (ow + 127) & ~127u; ph = (oh + 127) & ~127u; w0 = pw / 4; h0 = ph / 4;
// The mid kernel uses the actual scale/jitter and also covers the integer-rounded 2/3 render sizes.
// Requiring exact 3:2 dimensions sends e.g. 853x533 -> 1280x800 through the much larger generic kernel.
@@ -234,15 +265,38 @@ struct EEngine::Impl {
discard.create(d, 1, 1, half4);
zero.create(d, 1, 1, half4);
exposureOne.create(d, 1, 1, VK_FORMAT_R32_SFLOAT);
if (autoExposure) {
// NGX's cuda_luma_convert_kernel grid (harness captures 2026-10-19, like M's and L's): half the output,
// half the render size but at least 256 where the output is more than twice the render size
const uint32_t gx = exposureGrid(rw, ow), gy = exposureGrid(rh, oh);
exposure.size[0] = int32_t(rw); exposure.size[1] = int32_t(rh);
exposure.sampleSize[0] = int32_t(gx); exposure.sampleSize[1] = int32_t(gy);
exposure.invCount = 1.0f / float(gx * gy);
exposure.key = 0.18f / 0.82f; // cuda_auto_exposure_copy_kernel's argument 0
exposure.blocksX = (gx + 31) / 32;
exposure.blocks = exposure.blocksX * ((gy + 31) / 32);
expP.create(d, sizeof(ExposureParams), storage);
expSums.create(d, VkDeviceSize(exposure.blocks) * 4, storage);
expState.create(d, 8, storage);
measured.create(d, 1, 1, VK_FORMAT_R16_SFLOAT);
}
if (hdr && model.hasExposureCopy) previousExposure.create(d, 1, 1, VK_FORMAT_R16_SFLOAT);
VkSamplerCreateInfo si{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO};
si.addressModeU = si.addressModeV = si.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; si.maxLod = 0;
VKCHECK(vkCreateSampler(d.handle, &si, nullptr, &nearest));
si.magFilter = si.minFilter = VK_FILTER_LINEAR;
VKCHECK(vkCreateSampler(d.handle, &si, nullptr, &linear));
VkDescriptorPoolSize sizes[] = {{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 3 * ENet}};
VkDescriptorPoolSize sizes[] = {{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 3 * ENet + 3}, {VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 1}};
VkDescriptorPoolCreateInfo pi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
pi.maxSets = ENet; pi.poolSizeCount = 1; pi.pPoolSizes = sizes;
pi.maxSets = ENet + 1; pi.poolSizeCount = 2; pi.pPoolSizes = sizes;
VKCHECK(vkCreateDescriptorPool(d.handle, &pi, nullptr, &fixedPool));
if (autoExposure) {
exp1Set = descriptorSet(d.handle, fixedPool, model.exposure[1].descriptors);
bindBuffer(d.handle, exp1Set, 0, expP);
bindBuffer(d.handle, exp1Set, 1, expSums);
bindBuffer(d.handle, exp1Set, 2, expState);
bindImage(d.handle, exp1Set, 3, measured.view);
}
for (unsigned s = 0; s < ENet; ++s) {
netSets[s] = descriptorSet(d.handle, fixedPool, model.stages[s].descriptors);
bindRange(d.handle, netSets[s], 0, params, paramAt[s]);
@@ -261,8 +315,8 @@ struct EEngine::Impl {
// what a translated kernel's texture or surface argument is (NGX's wiring in the captures; null where NGX leaves
// it unbound). par: this frame's parity (history written this frame, the other one read)
Resource resolve(bool isInput, bool surface, uint32_t offset, uint32_t par, const FrameImages& in, VkImageView output) const {
const VkImageView exposure = hdr ? (in.exposure ? in.exposure : exposureOne.view) : VK_NULL_HANDLE;
const VkImageLayout exposureLayout = in.exposure ? in.exposureLayout : VK_IMAGE_LAYOUT_GENERAL;
const VkImageView exposure = !hdr ? VK_NULL_HANDLE : autoExposure ? measured.view : in.exposure ? in.exposure : exposureOne.view;
const VkImageLayout exposureLayout = !autoExposure && in.exposure ? in.exposureLayout : VK_IMAGE_LAYOUT_GENERAL;
if (isInput) {
if (!surface) switch (offset) {
case 280: return {in.color, true, in.colorLayout};
@@ -270,7 +324,10 @@ struct EEngine::Impl {
case 304: return {in.motion, false, in.motionLayout};
case 312: return {in.depth, false, in.depthLayout};
case 320: return {motionHistory[par ^ 1].view, true};
case 344: case 352: case 360: return {exposure, false, exposureLayout};
case 344: case 360: return {exposure, false, exposureLayout};
// NGX: the previous frame's exposure (its copy kernel's surface; the current one on a reset frame,
// where the engine copies first). Models without the copy shader bind the current exposure.
case 352: return previousExposure.view ? Resource{previousExposure.view} : Resource{exposure, false, exposureLayout};
} else switch (offset) {
case 328: return {motionHistory[par].view};
case 368: return {discard.view};
@@ -310,6 +367,9 @@ struct EEngine::Impl {
throw std::runtime_error("preset E/F needs colour and motion (and depth with render-size motion)");
if (finalOutput.view && finalOutput.format != VK_FORMAT_R16G16B16A16_SFLOAT)
throw std::runtime_error("preset E/F writes RGBA16F output only");
if (!(settings.preExposure > 0.0f) || !std::isfinite(settings.preExposure) || !(settings.exposureScale > 0.0f) ||
!std::isfinite(settings.exposureScale))
throw std::runtime_error("the pre-exposure and exposure scale must be positive numbers");
const bool rst = settings.reset || frame == 0;
const float frw = float(rw), frh = float(rh), fow = float(ow), foh = float(oh);
const float rx = frw / fow, ry = frh / foh;
@@ -328,6 +388,10 @@ struct EEngine::Impl {
put32(p, 136, rw); put32(p, 140, rh); putF(p, 144, rx); putF(p, 148, ry);
putF(p, 152, 1.0f / fow); putF(p, 156, 1.0f / foh); putF(p, 160, model.preset == 5 ? 500.0f / foh : 1.0f);
put32(p, 184, lowRes ? 1u : 0u); put32(p, 188, lowRes ? 1u : 0u);
// the game's exposure scale (twice), pre-exposure and its inverse: both kernels take this frame's values, with
// or without AutoExposure (captures with per-frame schedules, 2026-10-19); the kept measurement is not rescaled
const float exposureFields[4] = {settings.exposureScale, settings.exposureScale, settings.preExposure, 1.0f / settings.preExposure};
for (unsigned i = 0; i < 4; ++i) putF(p, 244 + 4 * i, exposureFields[i]);
put64(p, 376, at[0]);
auto& o = finBlock;
o = model.finalTemplate;
@@ -337,9 +401,12 @@ struct EEngine::Impl {
put32(o, 312, w0 * 32); put32(o, 316, w0 * 32); put32(o, 320, w0 * 40);
put32(o, 504, oh); put32(o, 508, ow); put32(o, 512, hdr ? 1u : 0u);
putF(o, 528, ry); putF(o, 532, 1.0f / ry); putF(o, 536, rx); putF(o, 540, 1.0f / rx);
putF(o, 544, -pj[0]); putF(o, 548, -pj[1]); putF(o, 552, -settings.jitter[0]); putF(o, 556, -settings.jitter[1]);
// the previous and this frame's jitter as (y, x): (-x, -y) agrees with it only on the harness's first three
// jitters (0 / -+0.25), so later frames of a Halton sequence measured 26-28 dB (six-frame captures, 2026-10-19)
putF(o, 544, pj[1]); putF(o, 548, pj[0]); putF(o, 552, settings.jitter[1]); putF(o, 556, settings.jitter[0]);
put32(o, 564, rh - 1); put32(o, 572, rw - 1);
putF(o, 584, model.kspread / (rx * ry));
for (unsigned i = 0; i < 4; ++i) putF(o, 592 + 4 * i, exposureFields[i]);
put64(o, 0, at[16]); put64(o, 8, at[1]);
for (unsigned i = 0; i < 4; ++i) put64(o, model.head[i].arg, headAt[i]);
put64(o, 256, scratch);
@@ -361,15 +428,27 @@ struct EEngine::Impl {
b.images = images; b.finalOutput = finalOutput;
const uint32_t tex = 2 * uint32_t(input->textures.size() + final->textures.size());
const uint32_t surf = 2 * uint32_t(input->surfaces.size() + final->surfaces.size());
VkDescriptorPoolSize sizes[] = {{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 12}, {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, tex},
{VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, surf}};
VkDescriptorPoolSize sizes[] = {{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 14}, {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, tex + 2},
{VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, surf + 1}};
VkDescriptorPoolCreateInfo pi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
pi.maxSets = 4; pi.poolSizeCount = 3; pi.pPoolSizes = sizes;
pi.maxSets = 6; pi.poolSizeCount = 3; pi.pPoolSizes = sizes;
VKCHECK(vkCreateDescriptorPool(d.handle, &pi, nullptr, &b.pool));
for (uint32_t par = 0; par < 2; ++par) {
b.input[par] = translatedSet(b, true, par, output);
b.final[par] = translatedSet(b, false, par, output);
}
if (autoExposure) {
b.exposure = descriptorSet(d.handle, b.pool, model.exposure[0].descriptors);
bindBuffer(d.handle, b.exposure, 0, expP);
bindImage(d.handle, b.exposure, 1, images.color, nearest, images.colorLayout);
bindBuffer(d.handle, b.exposure, 2, expSums);
}
if (previousExposure.view) {
const auto current = resolve(true, false, 344, 0, images, output);
b.copy = descriptorSet(d.handle, b.pool, model.exposureCopy.descriptors);
bindImage(d.handle, b.copy, 0, current.view, nearest, current.layout);
bindImage(d.handle, b.copy, 1, previousExposure.view);
}
bindings.push_back(std::move(b));
found = bindings.end() - 1;
}
@@ -380,6 +459,12 @@ struct EEngine::Impl {
std::ofstream(std::string(dump) + "/" + std::to_string(frame + 1) + "-final.args", std::ios::binary)
.write(reinterpret_cast<const char*>(finBlock.data()), static_cast<std::streamsize>(finBlock.size()));
}
if (autoExposure) {
// NGX measures on every second evaluation from the first (and K's rule: on a reset too); the luma is
// scaled by 1 / pre-exposure, and the image keeps the last measurement on the frames between
exposure.scale = 1.0f / settings.preExposure;
exposure.flags = (frame % 2 == 0 || rst ? 1u : 0u) | (rst ? 2u : 0u);
}
reset = rst;
prevJitter[0] = settings.jitter[0]; prevJitter[1] = settings.jitter[1];
}
@@ -387,6 +472,8 @@ struct EEngine::Impl {
VkClearColorValue clear{};
VkImageSubresourceRange range{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
std::vector<VkImage> images{warped.handle, discard.handle, zero.handle, exposureOne.handle};
if (autoExposure) images.push_back(measured.handle);
if (previousExposure.handle) images.push_back(previousExposure.handle);
for (int i = 0; i < 2; ++i) { images.push_back(history[i].handle); images.push_back(motionHistory[i].handle); }
for (VkImage image : images) {
imageBarrier(cb, image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
@@ -396,6 +483,7 @@ struct EEngine::Impl {
VkClearColorValue one{}; one.float32[0] = 1.0f;
vkCmdClearColorImage(cb, exposureOne.handle, VK_IMAGE_LAYOUT_GENERAL, &one, 1, &range);
vkCmdFillBuffer(cb, arena.handle, 0, VK_WHOLE_SIZE, 0);
if (autoExposure) vkCmdFillBuffer(cb, expState.handle, 0, VK_WHOLE_SIZE, 0x3F800000u); // 1.0 until the first measurement
memoryBarrier(cb, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT,
VK_ACCESS_TRANSFER_WRITE_BIT);
// the head weights into the arena (the reconstruction kernel reads them there)
@@ -450,9 +538,22 @@ struct EEngine::Impl {
std::memcpy(paramStaging.data() + finAt, finBlock.data(), finBlock.size());
for (VkDeviceSize a = 0; a < paramBytes; a += 65536)
vkCmdUpdateBuffer(cb, params.handle, a, std::min<VkDeviceSize>(65536, paramBytes - a), paramStaging.data() + a);
if (autoExposure) vkCmdUpdateBuffer(cb, expP.handle, 0, sizeof(ExposureParams), &exposure);
memoryBarrier(cb, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT,
VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT);
pass();
if (autoExposure) { // counted in the input pass's time
if (exposure.flags & 1u) {
dispatch(cb, model.exposure[0], b.exposure, exposure.blocksX, exposure.blocks / exposure.blocksX);
step();
}
dispatch(cb, model.exposure[1], exp1Set, 1, 1);
step();
}
if (b.copy && reset) { // NGX's reset frame reads the current exposure as the previous one
dispatch(cb, model.exposureCopy, b.copy, 1, 1);
step();
}
dispatch(cb, *input->pipeline, b.input[cur], (ow + 15) / 16, (oh + 15) / 16);
step(); stamp(1); pass();
for (unsigned s = 0; s < ENet; ++s) {
@@ -467,6 +568,8 @@ struct EEngine::Impl {
}
stamp(2);
dispatch(cb, *final->pipeline, b.final[cur], finalGrid[0], finalGrid[1]);
// the next frame's previous exposure (the input kernel read this frame's before the network)
if (b.copy) dispatch(cb, model.exposureCopy, b.copy, 1, 1);
memoryBarrier(cb, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_TRANSFER_BIT,
VK_ACCESS_SHADER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT | VK_ACCESS_TRANSFER_READ_BIT);
stamp(3); pass();
@@ -474,8 +577,9 @@ struct EEngine::Impl {
++frame;
}
};
EEngine::EEngine(EModel& model, uint32_t renderWidth, uint32_t renderHeight, uint32_t outputWidth, uint32_t outputHeight, bool hdr, bool lowResMotion)
: impl(std::make_unique<Impl>(*model.impl, renderWidth, renderHeight, outputWidth, outputHeight, hdr, lowResMotion)) {
EEngine::EEngine(EModel& model, uint32_t renderWidth, uint32_t renderHeight, uint32_t outputWidth, uint32_t outputHeight, bool hdr,
bool lowResMotion, bool autoExposure)
: impl(std::make_unique<Impl>(*model.impl, renderWidth, renderHeight, outputWidth, outputHeight, hdr, lowResMotion, autoExposure)) {
if (!model.impl->uploaded) {} // the caller records the model's upload before the first frame
}
EEngine::~EEngine() = default;
+20
View File
@@ -0,0 +1,20 @@
#version 460
// d4r engine, presets E and F: NGX's cuda_copy_exposure_kernel. After each frame the frame's exposure (the game's
// 1x1 texture or the AutoExposure measurement) goes into a 1x1 half surface, which the next frame's input kernel
// samples as the previous exposure (its texture 352; on a reset frame NGX binds the current exposure there instead,
// so the engine copies before the input kernel too). The formatted half store truncates toward zero, like the
// auto-exposure copy (engine/exposure_k.comp).
layout(local_size_x = 32) in; // one full wave32 subgroup; invocation 0 stores
layout(set = 0, binding = 0) uniform sampler2D source;
layout(set = 0, binding = 1, r16f) uniform writeonly image2D previous;
float truncatedHalf(float v)
{
uint bits = packHalf2x16(vec2(v, 0.0)) & 65535u;
if (unpackHalf2x16(bits).x > v) --bits;
return unpackHalf2x16(bits).x;
}
void main()
{
if (gl_LocalInvocationID.x == 0u)
imageStore(previous, ivec2(0), vec4(truncatedHalf(texelFetch(source, ivec2(0), 0).r)));
}