diff --git a/config/d4r.ini.default b/config/d4r.ini.default index ea8b61f..da9edaa 100644 --- a/config/d4r.ini.default +++ b/config/d4r.ini.default @@ -144,7 +144,7 @@ MarkerPollUs = auto ; Needs VkD3DDir, a driver with VK_KHR_cooperative_matrix (Mesa RADV on RDNA3/RDNA4) and the model folders ; (RuntimeDir/engine/k, k-ldr, m, m-ldr, l, l-ldr; scripts/install_d4r_runtime.sh). E and F (engine/e, engine/f; ; D4R_ENGINE_MODEL_E_DIR, D4R_ENGINE_MODEL_F_DIR) need no cooperative matrices: they run on RDNA2 (Steam Deck) -; too, from any render size and HDR or LDR, but not with AutoExposure. A game or setting the engine +; too, from any render size and HDR or LDR, with AutoExposure too (models built by this version). A game or setting the engine ; does not cover uses the normal path, with the reason in d4r_nvngx.log: other models than E, F, K, L and M, ; display-resolution motion vectors with L or M, or a K model built before the current engine/compile_k.py for display-resolution ; motion, non-inverted depth or exposure supplied by the game. Details and measurements: engine/README.md. diff --git a/engine/README.md b/engine/README.md index a4fb3eb..fbe9a43 100644 --- a/engine/README.md +++ b/engine/README.md @@ -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), diff --git a/engine/compile_sr_e.py b/engine/compile_sr_e.py index 80e9307..2c0c115 100644 --- a/engine/compile_sr_e.py +++ b/engine/compile_sr_e.py @@ -20,6 +20,8 @@ variants use aligned word accesses for their packed tensors. Writes: .spv the network stages input-.spv / .globals.bin, final-.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)') diff --git a/engine/exposure_k.comp b/engine/exposure_k.comp index f1efa02..02b74c5 100644 --- a/engine/exposure_k.comp +++ b/engine/exposure_k.comp @@ -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)); diff --git a/engine/game_e.cpp b/engine/game_e.cpp index 8b612c7..b705f38 100644 --- a/engine/game_e.cpp +++ b/engine/game_e.cpp @@ -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(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(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; diff --git a/engine/game_e.h b/engine/game_e.h index 1ddd007..6e7efef 100644 --- a/engine/game_e.h +++ b/engine/game_e.h @@ -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; diff --git a/engine/replay_e.cpp b/engine/replay_e.cpp index 8282057..4a24cfb 100644 --- a/engine/replay_e.cpp +++ b/engine/replay_e.cpp @@ -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 #include @@ -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(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; diff --git a/engine/runtime.h b/engine/runtime.h index 852561c..e40763e 100644 --- a/engine/runtime.h +++ b/engine/runtime.h @@ -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; @@ -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. diff --git a/engine/runtime_e.inc b/engine/runtime_e.inc index 0960dfa..db1be7c 100644 --- a/engine/runtime_e.inc +++ b/engine/runtime_e.inc @@ -50,6 +50,10 @@ struct EModel::Impl { std::map> input, final; std::vector inputTemplate, finalTemplate; bool uploaded = false; + std::array 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(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 paramStaging, inBlock, finBlock; std::array 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 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; 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(finBlock.data()), static_cast(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 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(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(*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(*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; diff --git a/engine/sr/exposure_copy_e.comp b/engine/sr/exposure_copy_e.comp new file mode 100644 index 0000000..c2d545f --- /dev/null +++ b/engine/sr/exposure_copy_e.comp @@ -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))); +} diff --git a/tools/d3d12_dlss_harness.cpp b/tools/d3d12_dlss_harness.cpp index 5626b28..f2465ea 100644 --- a/tools/d3d12_dlss_harness.cpp +++ b/tools/d3d12_dlss_harness.cpp @@ -2012,12 +2012,25 @@ int main(int argc, char** argv) } else if (temporal) d4r_ngx_set_int(parameters, "Reset", forceReset || frame == 1 ? 1 : 0); - // D4R_HARNESS_PRE_EXPOSURE / D4R_HARNESS_EXPOSURE_SCALE: non-neutral values on every frame, as games - // with their own exposure pipeline pass them. + // D4R_HARNESS_PRE_EXPOSURE / D4R_HARNESS_EXPOSURE_SCALE: non-neutral values, as games with their own + // exposure pipeline pass them. A comma list gives one value per frame (the last repeats). + auto scheduled = [frame](const char* list) { + float value = 1.0f; + const char* cursor = list; + for (int index = 1; index <= frame; ++index) + { + char* end = nullptr; + value = std::strtof(cursor, &end); + if (*end != ',') + break; + cursor = end + 1; + } + return value; + }; if (const char* value = std::getenv("D4R_HARNESS_PRE_EXPOSURE")) - d4r_ngx_set_float(parameters, "DLSS.Pre.Exposure", static_cast(std::atof(value))); + d4r_ngx_set_float(parameters, "DLSS.Pre.Exposure", scheduled(value)); if (const char* value = std::getenv("D4R_HARNESS_EXPOSURE_SCALE")) - d4r_ngx_set_float(parameters, "DLSS.Exposure.Scale", static_cast(std::atof(value))); + d4r_ngx_set_float(parameters, "DLSS.Exposure.Scale", scheduled(value)); // D4R_HARNESS_NGX_FLOATS="Name=value,Name=value": float evaluation parameters set on every frame after the // defaults (e.g. MV.Scale.X=2,FrameTimeDeltaInMsec=8), to see how NGX passes them on. if (const char* list = std::getenv("D4R_HARNESS_NGX_FLOATS")) diff --git a/tools/d4r_nvngx_shim.cpp b/tools/d4r_nvngx_shim.cpp index ce52d31..4660131 100644 --- a/tools/d4r_nvngx_shim.cpp +++ b/tools/d4r_nvngx_shim.cpp @@ -10332,8 +10332,14 @@ static bool engine_evaluate_e(Feature& feature, ID3D12GraphicsCommandList* list, return refuse(slot ? "no preset F model (engine\\f)" : "no preset E model (engine\\e)"); // NVSDK_NGX_DLSS_Feature_Flags: IsHDR 1, MVLowRes 2, MVJittered 4, DepthInverted 8, AutoExposure 0x40. const bool hdr = (feature.flags & 0x1) != 0, lowRes = (feature.flags & 0x2) != 0; - if ((feature.flags & 0x40) != 0) - return refuse("auto exposure is not implemented for preset E/F"); + // AutoExposure: NGX measures the exposure from the colour (HDR only; an LDR feature has no exposure at all) + const bool autoExposure = hdr && (feature.flags & 0x40) != 0; + if (autoExposure && !model->hasAutoExposure()) + return refuse("auto exposure needs a model built by this d4r version (run d4r/tools/build-engine-models.sh)"); + if (hdr && !(std::isfinite(p.preExposure) && p.preExposure > 0.0f)) + return refuse("the pre-exposure is not a positive number"); + if (hdr && !(std::isfinite(p.exposureScale) && p.exposureScale > 0.0f)) + return refuse("the exposure scale is not a positive number"); if (color == output || depth == output || motion == output) return refuse("input/output texture aliasing is not supported"); if (p.renderWidth > feature.outWidth || p.renderHeight > feature.outHeight) @@ -10344,8 +10350,9 @@ static bool engine_evaluate_e(Feature& feature, ID3D12GraphicsCommandList* list, const auto depthState = static_cast(env_uint("D4R_SHIM_DEPTH_STATE", 0x40)); const auto outputState = static_cast(env_uint("D4R_SHIM_OUTPUT_STATE", 0x8)); const uint32_t mw = lowRes ? p.renderWidth : feature.outWidth, mh = lowRes ? p.renderHeight : feature.outHeight; - // HDR: the game's exposure texture is NGX's exposure (its copy kernel moves texel 0 into the 1x1 the kernels read) - const bool gameExposure = hdr && exposure != nullptr; + // HDR: the game's exposure texture is NGX's exposure (its copy kernel moves texel 0 into the 1x1 the kernels read); + // with AutoExposure NGX measures instead and does not read it + const bool gameExposure = hdr && !autoExposure && exposure != nullptr; Source sources[5] = { {color, &game.color, p.colorBaseX, p.colorBaseY, p.renderWidth, p.renderHeight, inputState, D3D12_RESOURCE_STATE_COPY_SOURCE}, {depth, &game.depth, p.depthBaseX, p.depthBaseY, p.renderWidth, p.renderHeight, depthState, D3D12_RESOURCE_STATE_COPY_SOURCE}, @@ -10390,6 +10397,11 @@ static bool engine_evaluate_e(Feature& feature, ID3D12GraphicsCommandList* list, game.jitter[1] = p.jitterY; game.motionScale[0] = p.mvScaleX; game.motionScale[1] = p.mvScaleY; + if (hdr) + { + game.preExposure = p.preExposure; + game.exposureScale = p.exposureScale; + } const bool resized = feature.engineE != nullptr && (p.renderWidth != feature.engineERenderWidth || p.renderHeight != feature.engineERenderHeight); game.reset = p.reset != 0 || feature.engineE == nullptr || resized; @@ -10399,12 +10411,13 @@ static bool engine_evaluate_e(Feature& feature, ID3D12GraphicsCommandList* list, { feature.engineE.reset(); feature.engineE = std::make_unique(*model, g_vk.physical, g_vk.device, feature.outWidth, - feature.outHeight, p.renderWidth, p.renderHeight, hdr, lowRes); + feature.outHeight, p.renderWidth, p.renderHeight, hdr, lowRes, + autoExposure); feature.engineERenderWidth = p.renderWidth; feature.engineERenderHeight = p.renderHeight; - logf("engine backend (%s): feature %u runs preset %s in the game's command list (%ux%u -> %ux%u, %s, %s motion)", + logf("engine backend (%s): feature %u runs preset %s in the game's command list (%ux%u -> %ux%u, %s, %s motion%s)", name, feature.handle.Id, name, p.renderWidth, p.renderHeight, feature.outWidth, feature.outHeight, - hdr ? "HDR" : "LDR", lowRes ? "render-size" : "output-size"); + hdr ? "HDR" : "LDR", lowRes ? "render-size" : "output-size", autoExposure ? ", auto exposure" : ""); } } catch (const std::exception& error)