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

+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;