diff --git a/tests/test_rr_replay.py b/tests/test_rr_replay.py new file mode 100644 index 0000000..a71bb3a --- /dev/null +++ b/tests/test_rr_replay.py @@ -0,0 +1,427 @@ +"""Ray Reconstruction capture and replay: the exact guides travel, or the replay fails. + +The shim's input dump records the guides a game bound, in the formats the game passed them, +and the harness replay feeds those back instead of its own synthetic planes. Both are checked +here against the real source, because the failure they exist to prevent is a plausible image +produced from fewer guides than the game rendered. +""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def extract(source, start_marker, end_marker): + start = source.index(start_marker) + return source[start:source.index(end_marker, start)] + + +def build_and_run(body, name, with_directory=False): + with tempfile.TemporaryDirectory() as temporary: + runner = Path(temporary) / name + subprocess.run(['c++', '-std=c++17', '-Wall', '-Wextra', '-Werror', + '-x', 'c++', '-', '-o', str(runner)], input=body, + text=True, capture_output=True, check=True) + # The harness joins its frame prefix with a backslash, which is one file name on this + # platform; the capture directory therefore holds files named "capture\frame-NNNNNN-...". + capture = Path(temporary) / 'capture' + capture.mkdir() + command = [str(runner), str(capture)] if with_directory else [str(runner)] + subprocess.run(command, check=True, timeout=10) + + +class AuxCaptureTests(unittest.TestCase): + """dump_aux_guides must record the caller's bytes and the descriptor that reads them.""" + + def test_capture_records_every_guide_in_the_callers_format(self): + shim = (ROOT / 'tools/d4r_nvngx_shim.cpp').read_text() + tables = extract(shim, 'struct AuxScalar', 'struct FrameParams') + # The forward declaration in that slice has no definition here and nothing uses it. + tables = tables.replace( + 'static unsigned int get_uint_or(void* parameters, const char* name, unsigned int fallback);\n', '') + capture = extract(shim, 'static void dump_aux_guides', '// Worker. Points the denoiser') + prelude = r''' +#include +#include +#include +#include +#include +#include +using UINT = unsigned; +using DXGI_FORMAT = unsigned; +constexpr int MAX_PATH = 260; +constexpr int kSlots = 4; +enum class AuxLayout { Verbatim, Converted }; +struct Staging +{ + uint8_t* mapped = nullptr; + struct { size_t Offset; struct { size_t RowPitch; } Footprint; } layout = {}; +}; +struct AuxSurface +{ + Staging staging[kSlots]; + uint32_t texelBytes = 0; + UINT width = 0, height = 0; + DXGI_FORMAT format = 0; + AuxLayout how = AuxLayout::Verbatim; +}; +struct AuxBinding +{ + AuxSurface* surface = nullptr; + std::vector names; + std::vector sources; +}; +struct InputSlot { std::vector auxBindings; }; +struct Feature { InputSlot inputs[kSlots]; }; +''' + tables + r''' +struct FrameParams +{ + int auxScalar[sizeof(kAuxScalars) / sizeof(kAuxScalars[0])] = {}; + unsigned int auxSubrect[sizeof(kAuxSubrectNames) / sizeof(kAuxSubrectNames[0])][2] = {}; + float auxMatrix[sizeof(kAuxMatrices) / sizeof(kAuxMatrices[0])][16] = {}; + bool auxMatrixSet[sizeof(kAuxMatrices) / sizeof(kAuxMatrices[0])] = {}; +}; +static void logf(const char*, ...) {} +static std::string join_aux_names(const std::vector& names) +{ + std::string joined; + for (const char* name : names) + joined += joined.empty() ? name : std::string(", ") + name; + return joined; +} +static bool input_dump_directory(uint32_t, char (&directory)[MAX_PATH]) +{ + std::snprintf(directory, MAX_PATH, "capture"); + return true; +} +static uint8_t* capture_pinned(const char*, size_t) { return nullptr; } +struct Written { std::string path; std::vector bytes; }; +static std::vector g_written; +static void write_capture_file(uint32_t, const char*, const char* path, const void* data, size_t bytes) +{ + const uint8_t* first = static_cast(data); + g_written.push_back({path, std::vector(first, first + bytes)}); +} +''' + program = prelude + capture + r''' +static const char* text_of(const std::string& path) +{ + for (const Written& written : g_written) + if (written.path == path) + return reinterpret_cast(written.bytes.data()); + return nullptr; +} +int main() +{ + Feature feature; + InputSlot& slot = feature.inputs[0]; + // A 3x2 BGRA8 guide: the shim converts this to float32 on upload, so the capture must hold + // the caller's four bytes per texel, not the array's sixteen. + constexpr size_t rowPitch = 3 * 4 + 8; // padded, as a readback heap is + std::vector bgra(rowPitch * 2), depth(3 * 2 * 4); + for (size_t i = 0; i < bgra.size(); ++i) bgra[i] = static_cast(i + 1); + for (size_t i = 0; i < depth.size(); ++i) depth[i] = static_cast(200 - i); + AuxSurface surfaces[2]; + surfaces[0].staging[0].mapped = bgra.data(); + surfaces[0].staging[0].layout.Offset = 0; + surfaces[0].staging[0].layout.Footprint.RowPitch = rowPitch; + surfaces[0].texelBytes = 4; + surfaces[0].width = 3; + surfaces[0].height = 2; + surfaces[0].format = 87; // DXGI_FORMAT_B8G8R8A8_UNORM + surfaces[0].how = AuxLayout::Converted; + surfaces[1].staging[0].mapped = depth.data(); + surfaces[1].staging[0].layout.Offset = 0; + surfaces[1].staging[0].layout.Footprint.RowPitch = 3 * 4; + surfaces[1].texelBytes = 4; + surfaces[1].width = 3; + surfaces[1].height = 2; + surfaces[1].format = 87; + surfaces[1].how = AuxLayout::Converted; + slot.auxBindings.push_back({&surfaces[0], {"GBuffer.Normals"}, {"GBuffer.Normals"}}); + slot.auxBindings.push_back({&surfaces[1], {"DLSS.Input.DiffuseAlbedo"}, {"GBuffer.DiffuseAlbedo", + "DLSS.Input.DiffuseAlbedo"}}); + FrameParams params; + params.auxScalar[0] = 1; + params.auxSubrect[0][0] = 5; + params.auxSubrect[0][1] = 7; + params.auxMatrixSet[0] = true; + params.auxMatrix[0][0] = 1.5f; + params.auxMatrix[0][5] = -2.25f; + dump_aux_guides(feature, 0, 42, params); + + // Two planes and one descriptor, named for the frame. + assert(g_written.size() == 3); + const std::string plane0 = g_written[0].path, plane1 = g_written[1].path; + assert(plane0.find("frame-000042-guide-00") != std::string::npos); + assert(plane1.find("frame-000042-guide-01") != std::string::npos); + assert(g_written[2].path.find("frame-000042-guides.txt") != std::string::npos); + + // Tightly packed caller bytes: the padded staging row must not leak into the capture. + assert(g_written[0].bytes.size() == 3 * 2 * 4); + for (UINT row = 0; row < 2; ++row) + for (UINT x = 0; x < 3; ++x) + assert(g_written[0].bytes[(row * 3 + x) * 4] == bgra[row * rowPitch + x * 4]); + + // The descriptor names both spellings, the format, the geometry, the caller's texel size and + // whether the shim converts it. + const char* text = text_of(g_written[2].path); + assert(text != nullptr); + assert(std::string(text).find("guides 2\n") == 0); + assert(std::string(text).find("guide 1 GBuffer.DiffuseAlbedo,DLSS.Input.DiffuseAlbedo " + "DLSS.Input.DiffuseAlbedo 87 3 2 4 1") != std::string::npos); + assert(std::string(text).find("scalar DLSS.Denoise.Mode 1") != std::string::npos); + assert(std::string(text).find("subrect DLSS.Input.DiffuseAlbedo.Subrect.Base.X 5") != std::string::npos); + assert(std::string(text).find("subrect DLSS.Input.DiffuseAlbedo.Subrect.Base.Y 7") != std::string::npos); + assert(std::string(text).find("matrix WorldToViewMatrix 1 1.5") != std::string::npos); + assert(std::string(text).find("matrix ViewToClipMatrix 0") != std::string::npos); + + // A guide whose bytes cannot be read fails the whole capture: a descriptor without its plane + // is an image made of fewer guides than the game gave, and nothing downstream would show it. + g_written.clear(); + surfaces[1].staging[0].mapped = nullptr; + dump_aux_guides(feature, 0, 43, params); + assert(g_written.empty()); +} +''' + build_and_run(program, 'rr-guide-capture') + + +class ReplayGuideLoadTests(unittest.TestCase): + """A Ray Reconstruction replay must load the recorded guides, or refuse the run.""" + + def test_missing_or_malformed_guides_fail_instead_of_defaulting(self): + harness = (ROOT / 'tools/d3d12_dlss_harness.cpp').read_text() + # The guide loader, the file reader it needs and the name helpers; the frame-level + # parameter loader is covered by ReplayParamsTests instead. + replay = (extract(harness, 'struct ReplayGuide', 'static bool read_replay_params') + + extract(harness, 'static std::vector split_names', 'static float halton')) + program = r''' +#include +#include +#include +#include +#include +#include +#include +#include +using DXGI_FORMAT = unsigned; +constexpr DXGI_FORMAT DXGI_FORMAT_UNKNOWN = 0; +struct ID3D12Resource; +constexpr int MAX_PATH = 260; +''' + replay + r''' +// join_names comes along with the loader and nothing here calls it. +[[maybe_unused]] static std::string unused_join(const std::vector& names) { return join_names(names); } +static void write_bytes(const std::string& path, const std::vector& bytes) +{ + FILE* file = std::fopen(path.c_str(), "wb"); + assert(file != nullptr); + assert(std::fwrite(bytes.data(), 1, bytes.size(), file) == bytes.size()); + std::fclose(file); +} +static void write_text(const std::string& path, const std::string& text) +{ + write_bytes(path, std::vector(text.begin(), text.end())); +} +int main(int, char** argv) +{ + const std::string directory = argv[1]; + ReplayFrame frame; + + // No descriptor at all: an RR replay without the game's guides is refused, not invented. + assert(!load_replay_guides(directory.c_str(), 1, frame)); + + // A descriptor whose guide plane is missing is refused too. + write_text(directory + "\\frame-000002-guides.txt", + "guides 1\nguide 0 GBuffer.Normals GBuffer.Normals 6 2 2 4 0\n"); + assert(!load_replay_guides(directory.c_str(), 2, frame)); + + // The plane present, the count disagreeing with the file: refused rather than partly read. + write_bytes(directory + "\\frame-000002-guide-00.bin", std::vector(2 * 2 * 4, 7)); + write_text(directory + "\\frame-000002-guides.txt", + "guides 2\nguide 0 GBuffer.Normals GBuffer.Normals 6 2 2 4 0\n"); + assert(!load_replay_guides(directory.c_str(), 2, frame)); + + // A complete descriptor: the guides, the settings, both Subrect axes and the matrices. + write_text(directory + "\\frame-000002-guides.txt", + "guides 1\n" + "guide 0 GBuffer.DiffuseAlbedo,DLSS.Input.DiffuseAlbedo DLSS.Input.DiffuseAlbedo 87 2 2 4 1\n" + "scalar DLSS.Denoise.Mode 1\n" + "subrect DLSS.Input.Normals.Subrect.Base.X 4\n" + "subrect DLSS.Input.Normals.Subrect.Base.Y 9\n" + "matrix WorldToViewMatrix 1 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16\n" + "matrix ViewToClipMatrix 0\n"); + write_bytes(directory + "\\frame-000002-guide-00.bin", std::vector(2 * 2 * 4, 3)); + assert(load_replay_guides(directory.c_str(), 2, frame)); + assert(frame.guides.size() == 1); + assert(frame.guides[0].sources.size() == 2); + assert(frame.guides[0].sources[0] == "GBuffer.DiffuseAlbedo"); + assert(frame.guides[0].sources[1] == "DLSS.Input.DiffuseAlbedo"); + assert(frame.guides[0].publish[0] == "DLSS.Input.DiffuseAlbedo"); + assert(frame.guides[0].format == 87u && frame.guides[0].width == 2 && frame.guides[0].height == 2); + assert(frame.guides[0].texelBytes == 4 && frame.guides[0].converted); + assert(frame.guides[0].bytes.size() == 2 * 2 * 4 && frame.guides[0].bytes[0] == 3); + assert(frame.scalars.size() == 1 && frame.scalars[0].first == "DLSS.Denoise.Mode" && + frame.scalars[0].second == 1); + assert(frame.subrects.size() == 2 && frame.subrects[0].second == 4 && frame.subrects[1].second == 9); + assert(frame.subrects[1].first == "DLSS.Input.Normals.Subrect.Base.Y"); + assert(frame.matrices.size() == 2 && frame.matrixPresent.size() == 2); + assert(frame.matrices[0].second[0] == 1.0f && frame.matrices[0].second[15] == 16.0f); + assert(frame.matrixPresent[0].second); + // A matrix the game did not register stays absent, so the replay clears it rather than + // leaving the previous frame's camera in place. + assert(!frame.matrixPresent[1].second); +} +''' + with tempfile.TemporaryDirectory() as temporary: + # The harness joins the frame prefix with a backslash; on this platform that is one + # file name inside the directory, which is what the capture names are. + capture = Path(temporary) / 'capture' + capture.mkdir() + runner = Path(temporary) / 'rr-replay-load' + subprocess.run(['c++', '-std=c++17', '-Wall', '-Wextra', '-Werror', + '-x', 'c++', '-', '-o', str(runner)], input=program, + text=True, capture_output=True, check=True) + subprocess.run([str(runner), str(capture)], check=True, timeout=10) + + +class ReplayParamsTests(unittest.TestCase): + """The exposure plane, indicator axes, Output.Subrect origin and creation state. + + Each is something the replay must take from the capture rather than invent: a substituted + exposure value, a default origin or a guessed quality mode is a comparison against a reference + the game never asked for. + """ + + def build(self, body, name): + harness = (ROOT / 'tools/d3d12_dlss_harness.cpp').read_text() + source = (extract(harness, 'struct ReplayGuide', 'struct ReplayTexture') + + extract(harness, 'struct ReplayFrame', 'static bool read_file') + + extract(harness, 'static bool read_file', '// Splits a comma-separated name list')) + prelude = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +using DXGI_FORMAT = unsigned; +constexpr DXGI_FORMAT DXGI_FORMAT_UNKNOWN = 0; +struct ID3D12Resource; +constexpr int MAX_PATH = 260; +''' + common = r''' +static void write_bytes(const std::string& path, const std::vector& bytes) +{ + FILE* file = std::fopen(path.c_str(), "wb"); + assert(file != nullptr); + assert(std::fwrite(bytes.data(), 1, bytes.size(), file) == bytes.size()); + std::fclose(file); +} +static void write_text(const std::string& path, const std::string& text) +{ + write_bytes(path, std::vector(text.begin(), text.end())); +} +static void write_planes(const std::string& directory, uint32_t frame, unsigned width, unsigned height) +{ + char path[MAX_PATH]; + std::snprintf(path, sizeof(path), "%s\\frame-%06u-", directory.c_str(), frame); + write_bytes(std::string(path) + "color.rgba16f", std::vector(width * height * 8, 1)); + write_bytes(std::string(path) + "depth.r32f", std::vector(width * height * 4, 2)); + write_bytes(std::string(path) + "motion.rg16f", std::vector(width * height * 4, 3)); +} +''' + build_and_run(prelude + source + common + body, name, with_directory=True) + + def test_exposure_geometry_and_creation_state_come_from_the_capture(self): + self.build(r''' +int main(int, char** argv) +{ + const std::string directory = argv[1]; + ReplayFrame frame; + + // A 1x1 exposure scalar: the plane is four bytes, not the harness's own 1.0. + write_planes(directory, 1, 4, 2); + write_bytes(directory + "\\frame-000001-exposure.r32f", std::vector{0, 0, 0x80, 0x3f}); + write_text(directory + "\\frame-000001-params.txt", + "has_exposure 1\n" + "exposure_size 1 1\n" + "invert 1 1\n" + "output_base 7 9\n" + "create 1280 720 2560 1440 2 73 1 5\n"); + assert(load_replay_frame(directory.c_str(), 1, 4 * 2 * 8, 4 * 2 * 4, 4 * 2 * 4, frame)); + assert(frame.hasExposure == 1); + assert(frame.exposureWidth == 1 && frame.exposureHeight == 1); + assert(frame.exposure.size() == 4); + uint32_t exposureBits = 0; + std::memcpy(&exposureBits, frame.exposure.data(), sizeof(exposureBits)); + assert(exposureBits == 0x3f800000u); // 1.0f, the value the capture wrote + assert(frame.invertX == 1 && frame.invertY == 1); + assert(frame.outputBaseX == 7 && frame.outputBaseY == 9); + assert(frame.creation.recorded); + assert(frame.creation.width == 1280 && frame.creation.height == 720); + assert(frame.creation.outWidth == 2560 && frame.creation.outHeight == 1440); + assert(frame.creation.quality == 2 && frame.creation.flags == 73); + assert(frame.creation.outputSubrects == 1 && frame.creation.preset == 5); + + // A per-pixel exposure buffer, in its own geometry. + ReplayFrame wide; + write_planes(directory, 2, 4, 2); + write_bytes(directory + "\\frame-000002-exposure.r32f", std::vector(4 * 2 * 4, 9)); + write_text(directory + "\\frame-000002-params.txt", + "has_exposure 1\nexposure_size 4 2\ncreate 1280 720 2560 1440 2 73 1 5\n"); + assert(load_replay_frame(directory.c_str(), 2, 4 * 2 * 8, 4 * 2 * 4, 4 * 2 * 4, wide)); + assert(wide.exposureWidth == 4 && wide.exposureHeight == 2 && wide.exposure.size() == 4 * 2 * 4); + + // No exposure texture bound: nothing is read, and none is invented. + ReplayFrame none; + write_planes(directory, 3, 4, 2); + write_text(directory + "\\frame-000003-params.txt", + "has_exposure 0\nexposure_size 0 0\ncreate 1280 720 2560 1440 2 73 0 5\n"); + assert(load_replay_frame(directory.c_str(), 3, 4 * 2 * 8, 4 * 2 * 4, 4 * 2 * 4, none)); + assert(none.exposure.empty() && none.creation.outputSubrects == 0); +} +''', 'rr-replay-params') + + def test_unreplayable_exposure_and_truncated_params_fail(self): + self.build(r''' +int main(int, char** argv) +{ + const std::string directory = argv[1]; + ReplayFrame frame; + + // A claimed exposure texture whose geometry the capture never recorded: the replay would have + // to invent both the size and the contents. + write_planes(directory, 1, 4, 2); + write_bytes(directory + "\\frame-000001-exposure.r32f", std::vector(4, 0)); + write_text(directory + "\\frame-000001-params.txt", "has_exposure 1\nexposure_size 0 0\n"); + assert(!load_replay_frame(directory.c_str(), 1, 4 * 2 * 8, 4 * 2 * 4, 4 * 2 * 4, frame)); + + // Geometry recorded but the plane absent, then present at the wrong size. + write_planes(directory, 2, 4, 2); + write_text(directory + "\\frame-000002-params.txt", "has_exposure 1\nexposure_size 4 4\n"); + assert(!load_replay_frame(directory.c_str(), 2, 4 * 2 * 8, 4 * 2 * 4, 4 * 2 * 4, frame)); + write_bytes(directory + "\\frame-000002-exposure.r32f", std::vector(4 * 4 * 2, 0)); + assert(!load_replay_frame(directory.c_str(), 2, 4 * 2 * 8, 4 * 2 * 4, 4 * 2 * 4, frame)); + + // A truncated parameter is refused rather than left at its default. + write_planes(directory, 3, 4, 2); + write_text(directory + "\\frame-000003-params.txt", "has_exposure 0\noutput_base 4\n"); + assert(!load_replay_frame(directory.c_str(), 3, 4 * 2 * 8, 4 * 2 * 4, 4 * 2 * 4, frame)); + + // A capture with no creation state is reported as such, so the run can refuse to guess. + ReplayFrame legacy; + write_planes(directory, 4, 4, 2); + write_text(directory + "\\frame-000004-params.txt", "has_exposure 0\n"); + assert(load_replay_frame(directory.c_str(), 4, 4 * 2 * 8, 4 * 2 * 4, 4 * 2 * 4, legacy)); + assert(!legacy.creation.recorded); +} +''', 'rr-replay-params-fail') + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tools/d3d12_dlss_harness.cpp b/tools/d3d12_dlss_harness.cpp index 3c74c94..6e1ee2a 100644 --- a/tools/d3d12_dlss_harness.cpp +++ b/tools/d3d12_dlss_harness.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -695,6 +696,34 @@ static void fill_guide_planes(std::array& planes, // D4R_HARNESS_REPLAY_DIR= replays frames captured in a game by the shim // (D4R_SHIM_INPUT_DUMP_DIR + D4R_SHIM_CAPTURE_COUNT): frame-NNNNNN-color.rgba16f, // -depth.r32f, -motion.rg16f and -params.txt, starting at D4R_HARNESS_REPLAY_START. +// A Ray Reconstruction capture adds -guides.txt, -guide-NN.bin and the exposure plane: +// the denoiser's own inputs, recorded in the format the game passed them, so the replay +// runs the same conversions over the same bytes the game rendered. +struct ReplayGuide +{ + std::vector sources; // parameter names the caller registered it under + std::vector publish; // names the denoiser reads it as + DXGI_FORMAT format = DXGI_FORMAT_UNKNOWN; + unsigned width = 0, height = 0, texelBytes = 0; + bool converted = false; // the shim unpacks this format into floats + std::vector bytes; +}; + +// A captured guide's texture, kept across frames so a game that reuses one G-buffer surface +// is replayed as the same resource rather than a fresh one per frame. +struct ReplayTexture +{ + ID3D12Resource* resource = nullptr; + DXGI_FORMAT format = DXGI_FORMAT_UNKNOWN; + unsigned width = 0, height = 0; + bool uploaded = false; +}; + +// Room for the camera matrices a capture records. Fixed, because the parameter object holds +// the pointer until a later frame replaces it: storage that moved would dangle the denoiser's +// read. A capture with more matrices than this is refused rather than truncated. +constexpr size_t kReplayMatrices = 8; + struct ReplayFrame { std::vector color, depth, motion; @@ -703,6 +732,32 @@ struct ReplayFrame int reset = 0, hasExposure = 0; unsigned renderWidth = 0, renderHeight = 0; unsigned colorBaseX = 0, colorBaseY = 0, depthBaseX = 0, depthBaseY = 0, mvBaseX = 0, mvBaseY = 0; + // Ray Reconstruction: the guides the game bound this frame and the settings, origins and + // camera matrices it gave the denoiser with them. matrixPresent says which of those + // matrices the caller actually registered, so an absent one is cleared rather than + // silently kept from an earlier frame. + std::vector guides; + std::vector> scalars; + std::vector> subrects; + std::vector>> matrices; + std::vector> matrixPresent; + // The exposure texture the game actually bound: its own geometry and the captured plane. + // A capture that says a frame had an exposure texture but carries no plane for it cannot be + // replayed exactly, so this is a missing plane, not a value to invent. + unsigned exposureWidth = 0, exposureHeight = 0; + std::vector exposure; + int invertX = 0, invertY = 0; + unsigned outputBaseX = 0, outputBaseY = 0; + // How the game created this feature. Recorded per frame because it is part of what the + // replay has to reproduce: a different model, resolution, quality mode, create flag set, + // Output.Subrect setting or render preset is a different NVIDIA reference. + struct Creation + { + unsigned width = 0, height = 0, outWidth = 0, outHeight = 0; + int quality = 0, flags = 0, outputSubrects = 0; + unsigned preset = 0; + bool recorded = false; + } creation; }; static bool read_file(const std::string& path, std::vector& bytes, size_t expected) @@ -717,12 +772,79 @@ static bool read_file(const std::string& path, std::vector& bytes, size return got == expected && !extra; } +// Reads one frame's params.txt. Returns false when a parameter the replay needs was truncated, +// so a partial read never leaves a plausible-looking default in place. +static bool read_replay_params(FILE* file, uint32_t frame, ReplayFrame& out) +{ + char key[64]; + while (std::fscanf(file, "%63s", key) == 1) + { + const std::string name = key; + int read = 0, expected = 0; + if (name == "jitter") + read = std::fscanf(file, "%f %f", &out.jitterX, &out.jitterY), expected = 2; + else if (name == "mv_scale") + read = std::fscanf(file, "%f %f", &out.mvScaleX, &out.mvScaleY), expected = 2; + else if (name == "sharpness") read = std::fscanf(file, "%f", &out.sharpness), expected = 1; + else if (name == "pre_exposure") read = std::fscanf(file, "%f", &out.preExposure), expected = 1; + else if (name == "exposure_scale") read = std::fscanf(file, "%f", &out.exposureScale), expected = 1; + else if (name == "frame_time") read = std::fscanf(file, "%f", &out.frameTime), expected = 1; + else if (name == "reset") read = std::fscanf(file, "%d", &out.reset), expected = 1; + else if (name == "invert") read = std::fscanf(file, "%d %d", &out.invertX, &out.invertY), expected = 2; + else if (name == "has_exposure") read = std::fscanf(file, "%d", &out.hasExposure), expected = 1; + else if (name == "render") + read = std::fscanf(file, "%u %u", &out.renderWidth, &out.renderHeight), expected = 2; + else if (name == "color_base") + read = std::fscanf(file, "%u %u", &out.colorBaseX, &out.colorBaseY), expected = 2; + else if (name == "depth_base") + read = std::fscanf(file, "%u %u", &out.depthBaseX, &out.depthBaseY), expected = 2; + else if (name == "mv_base") read = std::fscanf(file, "%u %u", &out.mvBaseX, &out.mvBaseY), expected = 2; + else if (name == "output_base") + read = std::fscanf(file, "%u %u", &out.outputBaseX, &out.outputBaseY), expected = 2; + else if (name == "exposure_size") + read = std::fscanf(file, "%u %u", &out.exposureWidth, &out.exposureHeight), expected = 2; + else if (name == "create") + { + read = std::fscanf(file, "%u %u %u %u %d %d %d %u", &out.creation.width, &out.creation.height, + &out.creation.outWidth, &out.creation.outHeight, &out.creation.quality, + &out.creation.flags, &out.creation.outputSubrects, &out.creation.preset); + expected = 8; + if (read == expected) + out.creation.recorded = true; + } + else + { + std::fscanf(file, "%*[^\n]"); + continue; + } + // Every value the line promised, not merely some of them: a half-read line would leave + // the rest at its default, which is exactly the invented value this refuses. + if (read != expected) + { + std::fprintf(stderr, "replay frame %u: the capture's '%s' line is truncated (%d of %d values)\n", + frame, name.c_str(), read, expected); + return false; + } + } + return true; +} + static bool load_replay_frame(const char* directory, uint32_t frame, size_t colorBytes, size_t depthBytes, size_t motionBytes, ReplayFrame& out) { char prefix[MAX_PATH]; std::snprintf(prefix, sizeof(prefix), "%s\\frame-%06u-", directory, frame); const std::string base = prefix; + FILE* file = std::fopen((base + "params.txt").c_str(), "r"); + if (file == nullptr) + { + std::fprintf(stderr, "replay frame %u: no params\n", frame); + return false; + } + const bool params = read_replay_params(file, frame, out); + std::fclose(file); + if (!params) + return false; if (!read_file(base + "color.rgba16f", out.color, colorBytes) || !read_file(base + "depth.r32f", out.depth, depthBytes) || !read_file(base + "motion.rg16f", out.motion, motionBytes)) @@ -730,31 +852,181 @@ static bool load_replay_frame(const char* directory, uint32_t frame, size_t colo std::fprintf(stderr, "replay frame %u: missing or mis-sized plane under %s\n", frame, prefix); return false; } - FILE* file = std::fopen((base + "params.txt").c_str(), "r"); + // The exposure plane, in the geometry the capture recorded: the game may have passed a 1x1 + // scalar or a per-pixel buffer, and only its own size says which. Geometry missing under a + // claimed exposure texture is refused, not filled in with the harness's 1.0. + if (out.hasExposure != 0) + { + if (out.exposureWidth == 0 || out.exposureHeight == 0) + { + std::fprintf(stderr, "replay frame %u: the capture claims an exposure texture but records no " + "geometry for it\n", + frame); + return false; + } + const size_t bytes = static_cast(out.exposureWidth) * out.exposureHeight * sizeof(float); + if (!read_file(base + "exposure.r32f", out.exposure, bytes)) + { + std::fprintf(stderr, "replay frame %u: the exposure plane (%ux%u) is missing or mis-sized: %s\n", + frame, out.exposureWidth, out.exposureHeight, (base + "exposure.r32f").c_str()); + return false; + } + } + return true; +} + +// Splits a comma-separated name list, as the capture writes it. +static std::vector split_names(const std::string& joined) +{ + std::vector names; + size_t start = 0; + while (start <= joined.size()) + { + const size_t comma = joined.find(',', start); + names.push_back(joined.substr(start, comma == std::string::npos ? std::string::npos : comma - start)); + if (comma == std::string::npos) + break; + start = comma + 1; + } + return names; +} + +static std::string join_names(const std::vector& names) +{ + std::string joined; + for (const std::string& name : names) + joined += joined.empty() ? name : ", " + name; + return joined; +} + +// Reads one frame's guides.txt and its guide planes. A Ray Reconstruction replay with no +// guide descriptor is refused: without the game's own guides the denoiser would be handed a +// different scene than the game rendered, and the comparison against the game's own image +// would be meaningless while still producing a plausible picture. +static bool load_replay_guides(const char* directory, uint32_t frame, ReplayFrame& out) +{ + char prefix[MAX_PATH]; + std::snprintf(prefix, sizeof(prefix), "%s\\frame-%06u-", directory, frame); + const std::string base = prefix; + FILE* file = std::fopen((base + "guides.txt").c_str(), "r"); if (file == nullptr) { - std::fprintf(stderr, "replay frame %u: no params\n", frame); + std::fprintf(stderr, "replay frame %u: no guides descriptor (%sguides.txt); a Ray Reconstruction replay " + "requires the guides the game bound\n", + frame, prefix); return false; } + bool declared = false; + unsigned guides = 0, parsed = 0; char key[64]; while (std::fscanf(file, "%63s", key) == 1) { const std::string name = key; - if (name == "jitter") std::fscanf(file, "%f %f", &out.jitterX, &out.jitterY); - else if (name == "mv_scale") std::fscanf(file, "%f %f", &out.mvScaleX, &out.mvScaleY); - else if (name == "sharpness") std::fscanf(file, "%f", &out.sharpness); - else if (name == "pre_exposure") std::fscanf(file, "%f", &out.preExposure); - else if (name == "exposure_scale") std::fscanf(file, "%f", &out.exposureScale); - else if (name == "frame_time") std::fscanf(file, "%f", &out.frameTime); - else if (name == "reset") std::fscanf(file, "%d", &out.reset); - else if (name == "has_exposure") std::fscanf(file, "%d", &out.hasExposure); - else if (name == "render") std::fscanf(file, "%u %u", &out.renderWidth, &out.renderHeight); - else if (name == "color_base") std::fscanf(file, "%u %u", &out.colorBaseX, &out.colorBaseY); - else if (name == "depth_base") std::fscanf(file, "%u %u", &out.depthBaseX, &out.depthBaseY); - else if (name == "mv_base") std::fscanf(file, "%u %u", &out.mvBaseX, &out.mvBaseY); - else std::fscanf(file, "%*[^\n]"); + if (name == "guides") + { + if (std::fscanf(file, "%u", &guides) != 1) + break; + declared = true; + parsed = 0; + out.guides.clear(); + out.guides.resize(guides); + continue; + } + if (name == "guide") + { + unsigned index = 0, format = 0, width = 0, height = 0, texelBytes = 0, converted = 0; + char sources[512], publish[512]; + if (!declared || std::fscanf(file, "%u %511s %511s %u %u %u %u %u", &index, sources, publish, &format, + &width, &height, &texelBytes, &converted) != 8 || + index >= out.guides.size() || texelBytes == 0) + { + std::fprintf(stderr, "replay frame %u: malformed guide descriptor in %sguides.txt\n", frame, prefix); + std::fclose(file); + return false; + } + ++parsed; + ReplayGuide& guide = out.guides[index]; + guide.sources = split_names(sources); + guide.publish = split_names(publish); + guide.format = static_cast(format); + guide.width = width; + guide.height = height; + guide.texelBytes = texelBytes; + guide.converted = converted != 0; + char path[MAX_PATH]; + std::snprintf(path, sizeof(path), "%s\\frame-%06u-guide-%02u.bin", directory, frame, index); + if (!read_file(path, guide.bytes, static_cast(texelBytes) * width * height)) + { + std::fprintf(stderr, "replay frame %u: guide %u (%s) is missing or mis-sized: %s\n", frame, index, + publish, path); + std::fclose(file); + return false; + } + continue; + } + if (name == "scalar") + { + char parameter[96]; + int value = 0; + if (std::fscanf(file, "%95s %d", parameter, &value) != 2) + break; + out.scalars.emplace_back(parameter, value); + continue; + } + if (name == "subrect") + { + char parameter[128]; + unsigned value = 0; + if (std::fscanf(file, "%127s %u", parameter, &value) != 2) + break; + out.subrects.emplace_back(parameter, value); + continue; + } + if (name == "matrix") + { + char parameter[128]; + int present = 0; + if (std::fscanf(file, "%127s %d", parameter, &present) != 2) + break; + std::array values{}; + if (present != 0) + { + int read = 0; + for (; read < 16; ++read) + if (std::fscanf(file, "%f", &values[static_cast(read)]) != 1) + break; + if (read != 16) + break; + } + // A matrix the caller did not register this frame is recorded as absent, so the + // replay clears it rather than leaving the previous frame's camera in place. + out.matrices.emplace_back(parameter, present != 0 ? values : std::array{}); + out.matrixPresent.emplace_back(parameter, present != 0); + continue; + } + std::fscanf(file, "%*[^\n]"); } std::fclose(file); + if (!declared) + { + std::fprintf(stderr, "replay frame %u: %sguides.txt lists no guides\n", frame, prefix); + return false; + } + for (size_t index = 0; index < out.guides.size(); ++index) + if (out.guides[index].bytes.size() != + static_cast(out.guides[index].texelBytes) * out.guides[index].width * out.guides[index].height) + { + std::fprintf(stderr, "replay frame %u: guide %zu has no recorded plane\n", frame, index); + return false; + } + // A descriptor that lists fewer guides than it declares is refused rather than partly read: + // the missing ones would otherwise be fed to the denoiser as nothing at all. + if (parsed != guides) + { + std::fprintf(stderr, "replay frame %u: %sguides.txt declares %u guides but %u were read\n", frame, prefix, + guides, parsed); + return false; + } return true; } @@ -778,12 +1050,57 @@ int main(int argc, char** argv) return 2; } const int frames = argc > 3 ? std::atoi(argv[3]) : 6; - const UINT inWidth = argc > 7 ? static_cast(std::atoi(argv[4])) : 640; - const UINT inHeight = argc > 7 ? static_cast(std::atoi(argv[5])) : 360; - const UINT outWidth = argc > 7 ? static_cast(std::atoi(argv[6])) : 1280; - const UINT outHeight = argc > 7 ? static_cast(std::atoi(argv[7])) : 720; + const char* replayDir = std::getenv("D4R_HARNESS_REPLAY_DIR"); + const uint32_t replayStart = std::getenv("D4R_HARNESS_REPLAY_START") != nullptr + ? static_cast(std::atoi(std::getenv("D4R_HARNESS_REPLAY_START"))) : 1; + // A replay creates the feature the game created: same render and output dimensions, quality + // mode, create flag set, Output.Subrect setting and render preset. These are read before any + // texture is sized or any parameter allocated, so the reference the replay compares against + // is the one the game asked for rather than the harness's own defaults. A capture without + // that state is refused rather than replayed against guessed settings. + ReplayFrame::Creation replayCreation; + if (replayDir != nullptr) + { + ReplayFrame probe; + char paramsPath[MAX_PATH]; + std::snprintf(paramsPath, sizeof(paramsPath), "%s\\frame-%06u-params.txt", replayDir, replayStart); + FILE* paramsFile = std::fopen(paramsPath, "r"); + if (paramsFile == nullptr) + { + std::fprintf(stderr, "replay frame %u: no params at %s\n", replayStart, paramsPath); + return 1; + } + const bool parsed = read_replay_params(paramsFile, replayStart, probe); + std::fclose(paramsFile); + if (!parsed) + return 1; + if (!probe.creation.recorded) + { + std::fprintf(stderr, "replay frame %u: the capture records no creation state (%s), so the feature " + "would have to be recreated from guessed settings\n", + replayStart, paramsPath); + return 1; + } + replayCreation = probe.creation; + } + // The capture's own dimensions win: its planes are exactly this size, so anything else would + // upload a truncated or padded image rather than the game's. + UINT inWidth = argc > 7 ? static_cast(std::atoi(argv[4])) : 640; + UINT inHeight = argc > 7 ? static_cast(std::atoi(argv[5])) : 360; + UINT outWidth = argc > 7 ? static_cast(std::atoi(argv[6])) : 1280; + UINT outHeight = argc > 7 ? static_cast(std::atoi(argv[7])) : 720; + if (replayDir != nullptr) + { + inWidth = replayCreation.width; + inHeight = replayCreation.height; + outWidth = replayCreation.outWidth; + outHeight = replayCreation.outHeight; + std::printf("replay feature: %ux%u -> %ux%u quality=%d flags=0x%x output_subrects=%d preset=%u\n", + inWidth, inHeight, outWidth, outHeight, replayCreation.quality, replayCreation.flags, + replayCreation.outputSubrects, replayCreation.preset); + } const char* resourceWidthSetting = std::getenv("D4R_HARNESS_RESOURCE_WIDTH"); - const UINT resourceWidth = resourceWidthSetting != nullptr + UINT resourceWidth = resourceWidthSetting != nullptr ? static_cast(std::atoi(resourceWidthSetting)) : inWidth; if (resourceWidth < inWidth || resourceWidth > inWidth + 256) { @@ -800,7 +1117,6 @@ int main(int argc, char** argv) const bool qualityScene = std::getenv("D4R_HARNESS_QUALITY_SCENE") != nullptr; const bool poleScene = std::getenv("D4R_HARNESS_POLE_SCENE") != nullptr; const bool exposureRGBA32 = std::getenv("D4R_HARNESS_EXPOSURE_RGBA32") != nullptr; - const char* replayDir = std::getenv("D4R_HARNESS_REPLAY_DIR"); const bool rgba8 = std::getenv("D4R_HARNESS_RGBA8") != nullptr; // D4R_HARNESS_OUTPUT_RGB10A2=1: an R10G10B10A2_UNORM output texture (inputs unchanged), as some games pass const bool rgb10 = std::getenv("D4R_HARNESS_OUTPUT_RGB10A2") != nullptr; @@ -814,8 +1130,6 @@ int main(int argc, char** argv) std::fprintf(stderr, "RGBA8 format probe requires the static synthetic scene\n"); return 2; } - const uint32_t replayStart = std::getenv("D4R_HARNESS_REPLAY_START") != nullptr - ? static_cast(std::atoi(std::getenv("D4R_HARNESS_REPLAY_START"))) : 1; const bool temporal = std::getenv("D4R_HARNESS_TEMPORAL") != nullptr || motionScene || jitterScene || qualityScene || replayDir != nullptr; const bool forceReset = std::getenv("D4R_HARNESS_FORCE_RESET") != nullptr; @@ -955,7 +1269,9 @@ int main(int argc, char** argv) const bool guidePhase = std::getenv("D4R_HARNESS_RR_GUIDE_PHASE") != nullptr; std::array, 4> guideData{}; std::array guides{}; - if (reconstruction) + // A replay feeds the denoiser the guides the game itself bound, so no synthetic plane is + // created here: a pattern-filled guide would stand in for a scene the game never rendered. + if (reconstruction && replayDir == nullptr) { for (size_t input = 0; input < guides.size(); ++input) { @@ -1162,6 +1478,28 @@ int main(int argc, char** argv) d4r_ngx_set_uint(parameters, (std::string("RayReconstruction.Hint.Render.Preset.") + mode).c_str(), static_cast(std::strtoul(preset, nullptr, 0))); } + // A replay overrides the environment's guesses with what the game actually created: the + // quality mode, the create flags (HDR, depth inversion, auto exposure), whether Output.Subrect + // origins are read at all, and the render preset. Each of these selects a different model or + // a different reference, so substituting a default here would compare against the wrong one. + if (replayDir != nullptr) + { + d4r_ngx_set_int(parameters, "PerfQualityValue", replayCreation.quality); + d4r_ngx_set_int(parameters, "DLSS.Feature.Create.Flags", replayCreation.flags); + d4r_ngx_set_int(parameters, "DLSS.Enable.Output.Subrects", replayCreation.outputSubrects); + static const char* const kRayReconstructionPresets[6] = { + "RayReconstruction.Hint.Render.Preset.DLAA", "RayReconstruction.Hint.Render.Preset.Quality", + "RayReconstruction.Hint.Render.Preset.Balanced", "RayReconstruction.Hint.Render.Preset.Performance", + "RayReconstruction.Hint.Render.Preset.UltraPerformance", + "RayReconstruction.Hint.Render.Preset.UltraQuality"}; + static const char* const kSuperSamplingPresets[6] = { + "DLSS.Hint.Render.Preset.DLAA", "DLSS.Hint.Render.Preset.Quality", "DLSS.Hint.Render.Preset.Balanced", + "DLSS.Hint.Render.Preset.Performance", "DLSS.Hint.Render.Preset.UltraPerformance", + "DLSS.Hint.Render.Preset.UltraQuality"}; + const char* const* const presetNames = reconstruction ? kRayReconstructionPresets : kSuperSamplingPresets; + for (int index = 0; index < 6; ++index) + d4r_ngx_set_uint(parameters, presetNames[index], replayCreation.preset); + } NgxHandle* feature = nullptr; if (std::getenv("D4R_HARNESS_LIFETIME_TEST") != nullptr && !test_external_lifetime()) return 1; @@ -1175,7 +1513,9 @@ int main(int argc, char** argv) d4r_ngx_set_d3d12_resource(parameters, "MotionVectors", motionTexture); d4r_ngx_set_d3d12_resource(parameters, "Output", outputTexture); d4r_ngx_set_d3d12_resource(parameters, "ExposureTexture", exposureTexture); - if (reconstruction) + // A replay binds each guide under the names the capture recorded, per frame; the + // synthetic planes and the alpha scenario's own guide belong to the static probe only. + if (reconstruction && replayDir == nullptr) { // The SDK's caller-facing names (nvsdk_ngx_defs.h: NVSDK_NGX_Parameter_GBuffer_Normals, // _Roughness, _DiffuseAlbedo, _SpecularAlbedo), which is what a game registers. The @@ -1185,7 +1525,9 @@ int main(int argc, char** argv) for (size_t input = 0; input < guides.size(); ++input) d4r_ngx_set_d3d12_resource(parameters, names[input], guides[input]); } - if (alphaScenario) + // DLSSD.Alpha is a guide the capture records in its own right, so the synthetic alpha + // scenario would double-bind that name; it belongs to the static probe only. + if (alphaScenario && replayDir == nullptr) { d4r_ngx_set_d3d12_resource(parameters, "DLSSD.Alpha", alphaInput); d4r_ngx_set_d3d12_resource(parameters, "DLSSD.OutputAlpha", alphaOutput); @@ -1260,6 +1602,20 @@ int main(int argc, char** argv) lifecycle_report("SKIP input: discarded recording before submitting the next frame"); } + // Ray Reconstruction replay state. The textures follow the capture's guide identity so a + // game that reuses a surface replays it as the same resource; the names remember what was + // bound last frame, so a guide the game dropped is cleared rather than left in place; the + // matrices outlive the per-frame ReplayFrame, because the parameter object holds their + // pointer until a later frame replaces it. + std::map replayTextures; + std::vector replayGuideNames; + // The exposure texture the capture recorded, at the geometry it recorded: a 1x1 scalar and a + // per-pixel buffer are different textures, and the game chose which. + ID3D12Resource* replayExposureTexture = nullptr; + UINT replayExposureWidth = 0, replayExposureHeight = 0; + bool replayExposureUploaded = false; + std::array, kReplayMatrices> replayMatrices{}; + for (int frame = 1; frame <= frames; ++frame) { // Recreate the game's output between completed frames to exercise imported-memory handle reuse. @@ -1276,7 +1632,9 @@ int main(int argc, char** argv) outWidth * (rgba8 || rgb10 ? 4 : 8), D3D12_RESOURCE_STATE_UNORDERED_ACCESS); d4r_ngx_set_d3d12_resource(parameters, "Output", outputTexture); } - if (reconstruction && guidePhase && frame > 1) + // A replay's guides come from the capture; rewriting them with a pattern would + // replace the game's own data with an invented one. + if (reconstruction && replayDir == nullptr && guidePhase && frame > 1) { // The guides this frame are a different pattern, so the output can only be right // if this frame's own guides were the ones the denoiser read. @@ -1406,8 +1764,148 @@ int main(int argc, char** argv) d4r_ngx_set_uint(parameters, "DLSS.Input.MV.Subrect.Base.X", replay.mvBaseX); d4r_ngx_set_uint(parameters, "DLSS.Input.MV.Subrect.Base.Y", replay.mvBaseY); d4r_ngx_set_int(parameters, "Reset", frame == 1 || replay.reset != 0 ? 1 : 0); - // Games with AutoExposure pass no exposure texture; the synthetic scenes pass 1.0. - d4r_ngx_set_d3d12_resource(parameters, "ExposureTexture", replay.hasExposure ? exposureTexture : nullptr); + // The indicator axes and the Output.Subrect origin, as the game set them: the origin is + // only read at all when the feature was created with DLSS.Enable.Output.Subrects, and + // both are set every frame rather than left at whatever the previous frame wrote. + d4r_ngx_set_int(parameters, "DLSS.Indicator.Invert.X.Axis", replay.invertX); + d4r_ngx_set_int(parameters, "DLSS.Indicator.Invert.Y.Axis", replay.invertY); + d4r_ngx_set_uint(parameters, "DLSS.Output.Subrect.Base.X", replay.outputBaseX); + d4r_ngx_set_uint(parameters, "DLSS.Output.Subrect.Base.Y", replay.outputBaseY); + // The exposure texture the game actually bound this frame, with its own geometry and + // its own contents. A capture claiming one but carrying no plane for it was already + // refused above, so no substituted 1.0 can reach the denoiser here. + if (replay.hasExposure != 0) + { + if (replayExposureTexture == nullptr || replayExposureWidth != replay.exposureWidth || + replayExposureHeight != replay.exposureHeight) + { + if (replayExposureTexture != nullptr) + { + replayExposureTexture->Release(); + replayExposureTexture = nullptr; + } + replayExposureTexture = create_texture(replay.exposureWidth, replay.exposureHeight, + DXGI_FORMAT_R32_FLOAT, D3D12_RESOURCE_FLAG_NONE); + if (replayExposureTexture == nullptr) + { + std::fprintf(stderr, "replay frame %u: the %ux%u exposure texture could not be created\n", + replayStart + static_cast(frame - 1), replay.exposureWidth, + replay.exposureHeight); + return 1; + } + replayExposureWidth = replay.exposureWidth; + replayExposureHeight = replay.exposureHeight; + replayExposureUploaded = false; + } + if (!replayExposureUploaded) + { + upload(replayExposureTexture, replay.exposure.data(), replayExposureWidth * 4, srv); + replayExposureUploaded = true; + } + else + { + update_texture(replayExposureTexture, replay.exposure.data(), replayExposureWidth * 4, srv); + } + d4r_ngx_set_d3d12_resource(parameters, "ExposureTexture", replayExposureTexture); + } + else + { + // A game with AutoExposure binds no exposure texture; nothing is left bound. + d4r_ngx_set_d3d12_resource(parameters, "ExposureTexture", nullptr); + } + + // Ray Reconstruction: the guides the game bound this frame, in its own formats, plus + // the settings, origins and camera matrices it gave them with. A capture without + // them is refused rather than filled with synthetic planes - a denoiser handed + // invented guides would produce a plausible image that matches nothing the game + // rendered, which is the one outcome this replay exists to rule out. + if (reconstruction) + { + const uint32_t replayFrame = replayStart + static_cast(frame - 1); + if (!load_replay_guides(replayDir, replayFrame, replay)) + return 1; + std::vector bound; + for (size_t guide = 0; guide < replay.guides.size(); ++guide) + { + const ReplayGuide& captured = replay.guides[guide]; + if (captured.sources.empty() || captured.publish.empty() || captured.texelBytes == 0 || + captured.width == 0 || captured.height == 0) + { + std::fprintf(stderr, "replay frame %u: guide %zu has an incomplete descriptor\n", + replayFrame, guide); + return 1; + } + // Keyed on the first name the caller registered it under, so the texture the + // game kept across frames is created once and only its contents are replaced. + ReplayTexture& texture = replayTextures[captured.sources.front()]; + if (texture.resource == nullptr || texture.format != captured.format || + texture.width != captured.width || texture.height != captured.height) + { + if (texture.resource != nullptr) + { + texture.resource->Release(); + texture = {}; + } + texture.resource = create_texture(captured.width, captured.height, captured.format, + D3D12_RESOURCE_FLAG_NONE); + if (texture.resource == nullptr) + { + std::fprintf(stderr, "replay frame %u: the %ux%u fmt=0x%x guide could not be created\n", + replayFrame, captured.width, captured.height, + static_cast(captured.format)); + return 1; + } + texture.format = captured.format; + texture.width = captured.width; + texture.height = captured.height; + texture.uploaded = false; + } + const UINT rowBytes = captured.width * captured.texelBytes; + if (!texture.uploaded) + { + upload(texture.resource, captured.bytes.data(), rowBytes, srv); + texture.uploaded = true; + } + else + { + update_texture(texture.resource, captured.bytes.data(), rowBytes, srv); + } + for (const std::string& source : captured.sources) + { + d4r_ngx_set_d3d12_resource(parameters, source.c_str(), texture.resource); + bound.push_back(source); + } + if (frame == 1) + std::printf("replay guide %zu: %ux%u fmt=0x%x, %s as %s\n", guide, captured.width, + captured.height, static_cast(captured.format), + join_names(captured.sources).c_str(), join_names(captured.publish).c_str()); + } + // A guide the game stopped registering is cleared: the parameter object outlives + // the frame, so leaving its texture bound would hand the denoiser a guide the game + // had already dropped, exactly as the shim clears the names it published before. + for (const std::string& name : replayGuideNames) + if (std::find(bound.begin(), bound.end(), name) == bound.end()) + d4r_ngx_set_d3d12_resource(parameters, name.c_str(), nullptr); + replayGuideNames = bound; + for (const auto& [name, value] : replay.scalars) + d4r_ngx_set_int(parameters, name.c_str(), value); + for (const auto& [name, value] : replay.subrects) + d4r_ngx_set_uint(parameters, name.c_str(), value); + if (replay.matrices.size() != replay.matrixPresent.size() || replay.matrices.size() > kReplayMatrices) + { + std::fprintf(stderr, "replay frame %u: %zu camera matrices recorded, which this replay cannot hold\n", + replayFrame, replay.matrices.size()); + return 1; + } + // Copied into storage that lives for the whole run, because the parameter object + // keeps the pointer until the next frame replaces it. + for (size_t index = 0; index < replay.matrices.size(); ++index) + { + replayMatrices[index] = replay.matrices[index].second; + d4r_ngx_set_void(parameters, replay.matrices[index].first.c_str(), + replay.matrixPresent[index].second ? replayMatrices[index].data() : nullptr); + } + } } else if (temporal) d4r_ngx_set_int(parameters, "Reset", forceReset || frame == 1 ? 1 : 0); diff --git a/tools/d4r_nvngx_shim.cpp b/tools/d4r_nvngx_shim.cpp index 63d48d3..923178f 100644 --- a/tools/d4r_nvngx_shim.cpp +++ b/tools/d4r_nvngx_shim.cpp @@ -1913,7 +1913,13 @@ enum class AuxLayout Converted // the bytes must be unpacked into floats first (see convert_aux_row) }; -// The CUDA array a denoiser input uses. +// The texel size an array of `arrayFormat` and `channels` occupies per texel. +static size_t aux_texel_bytes(uint32_t arrayFormat, uint32_t channels); + +// The CUDA array a denoiser input uses, plus the texel size the caller's own resource +// occupies. The two differ for every converted format (B8G8R8A8_UNORM is four bytes to +// D3D12 and sixteen to the float array), and a capture records the caller's bytes, so a +// replay runs them through the same conversion again. // // Only formats that are byte-for-byte correct go down the verbatim path, and that is a // shorter list than it looks. A CUDA array stores element_size * channels bytes per texel @@ -1929,7 +1935,9 @@ enum class AuxLayout // // Everything that is not in this table is converted to a float array instead, which is // correct for all of those cases at the cost of a per-row unpack. -static bool aux_array_layout(DXGI_FORMAT format, uint32_t& arrayFormat, uint32_t& channels, AuxLayout& how) + +static bool aux_array_layout(DXGI_FORMAT format, uint32_t& arrayFormat, uint32_t& channels, AuxLayout& how, + uint32_t& texelBytes) { struct Entry { @@ -1938,7 +1946,8 @@ static bool aux_array_layout(DXGI_FORMAT format, uint32_t& arrayFormat, uint32_t uint32_t channels; }; // UNORM and SNORM map onto signed/unsigned integer arrays of the same width: sampling - // normalizes them, which is exactly how a D3D12 shader reads the resource. + // normalizes them, which is exactly how a D3D12 shader reads the resource. Their texel + // size is the array's own, so it is derived rather than listed twice. static const Entry entries[] = { {DXGI_FORMAT_R8_UNORM, CUDA_FORMAT_UNSIGNED_INT8, 1}, {DXGI_FORMAT_R8_SNORM, CUDA_FORMAT_SIGNED_INT8, 1}, @@ -1965,28 +1974,31 @@ static bool aux_array_layout(DXGI_FORMAT format, uint32_t& arrayFormat, uint32_t arrayFormat = entry.arrayFormat; channels = entry.channels; how = AuxLayout::Verbatim; + texelBytes = static_cast(aux_texel_bytes(entry.arrayFormat, entry.channels)); return true; } // Converted formats all end up as float arrays, so a shader read of the denoiser's - // texture returns the same numbers a D3D12 read of the caller's resource would. + // texture returns the same numbers a D3D12 read of the caller's resource would. Their + // texel size is the caller's own, which is what a capture records. struct Converted { DXGI_FORMAT format; uint32_t channels; + uint32_t texelBytes; }; static const Converted converted[] = { - {DXGI_FORMAT_R8_UINT, 1}, - {DXGI_FORMAT_R8G8_UINT, 2}, - {DXGI_FORMAT_R8G8B8A8_UINT, 4}, - {DXGI_FORMAT_R16_UINT, 1}, - {DXGI_FORMAT_R32_UINT, 1}, - {DXGI_FORMAT_R32_SINT, 1}, - {DXGI_FORMAT_R32G32_UINT, 2}, - {DXGI_FORMAT_R32G32_SINT, 2}, - {DXGI_FORMAT_R32G32B32A32_UINT, 4}, - {DXGI_FORMAT_B8G8R8A8_UNORM, 4}, - {DXGI_FORMAT_R10G10B10A2_UNORM, 4}, - {DXGI_FORMAT_R11G11B10_FLOAT, 4}, + {DXGI_FORMAT_R8_UINT, 1, 1}, + {DXGI_FORMAT_R8G8_UINT, 2, 2}, + {DXGI_FORMAT_R8G8B8A8_UINT, 4, 4}, + {DXGI_FORMAT_R16_UINT, 1, 2}, + {DXGI_FORMAT_R32_UINT, 1, 4}, + {DXGI_FORMAT_R32_SINT, 1, 4}, + {DXGI_FORMAT_R32G32_UINT, 2, 8}, + {DXGI_FORMAT_R32G32_SINT, 2, 8}, + {DXGI_FORMAT_R32G32B32A32_UINT, 4, 16}, + {DXGI_FORMAT_B8G8R8A8_UNORM, 4, 4}, + {DXGI_FORMAT_R10G10B10A2_UNORM, 4, 4}, + {DXGI_FORMAT_R11G11B10_FLOAT, 4, 4}, }; for (const Converted& entry : converted) if (entry.format == format) @@ -1994,6 +2006,7 @@ static bool aux_array_layout(DXGI_FORMAT format, uint32_t& arrayFormat, uint32_t arrayFormat = CUDA_FORMAT_FLOAT; channels = entry.channels; how = AuxLayout::Converted; + texelBytes = entry.texelBytes; return true; } return false; @@ -2246,6 +2259,17 @@ struct FrameParams unsigned int colorBaseX = 0, colorBaseY = 0, depthBaseX = 0, depthBaseY = 0; unsigned int mvBaseX = 0, mvBaseY = 0, outputBaseX = 0, outputBaseY = 0; bool hasExposure = false; + // The exposure texture's own geometry. A game may pass a per-pixel exposure buffer rather + // than the 1x1 scalar, and a capture that recorded a plane without its size could not say + // which it was. + unsigned int exposureWidth = 0, exposureHeight = 0; + // How this feature was created, copied off it so a capture records what the game asked for + // rather than what a replay would otherwise have to guess: the model, the render and output + // dimensions, the quality mode, the HDR/depth/exposure create flags, whether Output.Subrect + // origins are read, and the render preset in force. + unsigned int createWidth = 0, createHeight = 0, createOutWidth = 0, createOutHeight = 0; + int createQuality = 0, createFlags = 0, createSubrects = 0; + unsigned int createPreset = 0; bool vram = false; // inputs and output stay in VRAM (VRAM interop) bool split = false; // this frame presents its own result (split frames) unsigned motionDilation = 0; // actually applied, worker only @@ -2320,6 +2344,7 @@ struct AuxSurface UINT width = 0, height = 0; uint32_t arrayFormat = 0, channels = 0; AuxLayout how = AuxLayout::Verbatim; // whether the bytes reach the array as they are + uint32_t texelBytes = 0; // the caller's own texel size, for a capture uint32_t lastSeen = 0; // frame the caller last registered this resource unsigned int holds = 0; // queued frames still naming this surface bool dropping = false; // no longer registered; freed once holds reaches zero @@ -2338,7 +2363,8 @@ struct AuxSurface struct AuxBinding { AuxSurface* surface = nullptr; - std::vector names; + std::vector names; // what the denoiser reads it as + std::vector sources; // what the caller registered it under uint32_t frame = 0; }; @@ -2435,6 +2461,9 @@ struct Feature std::array creationPresets{}; bool cudaCreationDeferred = false; unsigned int preset = 0; + // DLSS.Enable.Output.Subrects as the caller set it at creation: it decides whether the + // Output.Subrect origins are read at all, so a replay must create the feature the same way. + int outputSubrects = 0; CudaEvent profileStart = nullptr, profileEnd = nullptr; CudaEvent outputReadyEvent = nullptr; // output wait pacing (D4R_SHIM_OUTPUT_POLL_US): smoothed time from the event record to its completion, kept @@ -4092,20 +4121,26 @@ static void dump_input_planes(const HostPlane (&planes)[4], uint32_t frame, cons if (written > 0 && written < MAX_PATH) write_capture_file(frame, names[index], path, bytes, host.size()); } - // The evaluation parameters, so the harness can replay the sequence. + // The evaluation parameters, so the harness can replay the sequence. The exposure plane's + // own geometry and the feature's creation state are recorded here too: a capture that kept a + // plane without its size, or a sequence without the model it ran under, would leave the + // replay guessing at both. char path[MAX_PATH]; const int written = std::snprintf(path, MAX_PATH, "%s\\frame-%06u-params.txt", directory, frame); if (written <= 0 || written >= MAX_PATH) return; - char text[1024]; + char text[1536]; const int length = std::snprintf( text, sizeof(text), "jitter %.9g %.9g\nmv_scale %.9g %.9g\nsharpness %.9g\npre_exposure %.9g\n" "exposure_scale %.9g\nframe_time %.9g\nreset %d\ninvert %d %d\nrender %u %u\n" - "color_base %u %u\ndepth_base %u %u\nmv_base %u %u\noutput_base %u %u\nhas_exposure %d\n", + "color_base %u %u\ndepth_base %u %u\nmv_base %u %u\noutput_base %u %u\nhas_exposure %d\n" + "exposure_size %u %u\ncreate %u %u %u %u %d %d %d %u\n", p.jitterX, p.jitterY, p.mvScaleX, p.mvScaleY, p.sharpness, p.preExposure, p.exposureScale, p.frameTime, p.reset, p.invertX, p.invertY, p.renderWidth, p.renderHeight, p.colorBaseX, p.colorBaseY, p.depthBaseX, - p.depthBaseY, p.mvBaseX, p.mvBaseY, p.outputBaseX, p.outputBaseY, p.hasExposure ? 1 : 0); + p.depthBaseY, p.mvBaseX, p.mvBaseY, p.outputBaseX, p.outputBaseY, p.hasExposure ? 1 : 0, p.exposureWidth, + p.exposureHeight, p.createWidth, p.createHeight, p.createOutWidth, p.createOutHeight, p.createQuality, + p.createFlags, p.createSubrects, p.createPreset); if (length > 0) write_capture_file(frame, "params", path, text, static_cast(length)); } @@ -5050,6 +5085,11 @@ static bool gather_aux_inputs(Feature& feature, ID3D12GraphicsCommandList* list, { InputSlot& slot = feature.inputs[slotIndex]; std::lock_guard lock(feature.auxMutex); + // A frame the input dump selected records this frame's guides too, so a replay of an RR + // sequence feeds the denoiser the game's own guides rather than the harness's synthetic + // ones - without which the replayed image cannot be compared with the game's. + char captureDirectory[MAX_PATH]; + const bool capture = input_dump_directory(frame, captureDirectory); std::vector bindings; for (const AuxInput& input : kAuxInputs) { @@ -5093,15 +5133,20 @@ static bool gather_aux_inputs(Feature& feature, ID3D12GraphicsCommandList* list, bound = bound || strcmp(name, input.publish) == 0; if (!bound) binding->names.push_back(input.publish); + bool sourceBound = false; + for (const char* name : binding->sources) + sourceBound = sourceBound || strcmp(name, input.source) == 0; + if (!sourceBound) + binding->sources.push_back(input.source); if (!firstBinding) continue; // aliases share one captured plane and one upload D3D12_RESOURCE_DESC desc; resource->GetDesc(&desc); - uint32_t arrayFormat = 0, channels = 0; + uint32_t arrayFormat = 0, channels = 0, texelBytes = 0; AuxLayout how = AuxLayout::Verbatim; if (desc.DepthOrArraySize != 1 || desc.Width > 0xFFFFFFFFull || - !aux_array_layout(desc.Format, arrayFormat, channels, how)) + !aux_array_layout(desc.Format, arrayFormat, channels, how, texelBytes)) { logf("Ray Reconstruction: denoiser input %s is %llux%u array=%u fmt=0x%x, which has no single-plane " "CUDA layout; this frame's evaluation is refused rather than run without it", @@ -5112,6 +5157,7 @@ static bool gather_aux_inputs(Feature& feature, ID3D12GraphicsCommandList* list, surface->arrayFormat = arrayFormat; surface->channels = channels; surface->how = how; + surface->texelBytes = texelBytes; surface->width = static_cast(desc.Width); surface->height = static_cast(desc.Height); VramCopy gpuCopy; @@ -5121,7 +5167,10 @@ static bool gather_aux_inputs(Feature& feature, ID3D12GraphicsCommandList* list, if (!record_aux_vram(list, *surface, slotIndex, gpuCopy, inputState)) return false; } - if (!surface->gpu[slotIndex] || env_uint("D4R_SHIM_VRAM_VERIFY", 0) != 0) + // A captured frame reads its guides back even with VRAM interop on: the capture + // records the caller's own bytes in the caller's own format, which is what a replay + // has to feed back for the same conversion to run over it. + if (!surface->gpu[slotIndex] || env_uint("D4R_SHIM_VRAM_VERIFY", 0) != 0 || capture) { if (!ensure_staging(surface->staging[slotIndex], resource, D3D12_HEAP_TYPE_READBACK)) return false; @@ -5292,6 +5341,125 @@ static bool upload_aux_inputs(Feature& feature, int slotIndex, uint32_t frame) return true; } +// Worker. Writes this frame's Ray Reconstruction guides next to the canonical input planes: +// the caller's own bytes in the caller's own format, the descriptor each was read with, and +// the per-frame settings, subrect origins and camera matrices the denoiser was given. A replay +// rebuilds the textures from those descriptors and uploads the recorded bytes, so the same +// conversion runs over the same data and the replayed image is comparable with the game's - +// which synthetic guides can never be. +// +// A guide whose bytes are not readable fails the whole capture: a descriptor without its +// plane, or a plane the replay silently skipped, is an image the denoiser would produce from +// fewer guides than the game gave it, and nothing downstream could tell. +static void dump_aux_guides(Feature& feature, int slotIndex, uint32_t frame, const FrameParams& p) +{ + char directory[MAX_PATH]; + if (!input_dump_directory(frame, directory)) + return; + const InputSlot& slot = feature.inputs[slotIndex]; + for (const AuxBinding& binding : slot.auxBindings) + if (binding.surface->staging[slotIndex].mapped == nullptr) + { + logf("frame %u: Ray Reconstruction guide %s is not readable, so this frame's capture is " + "incomplete and is not written", + frame, join_aux_names(binding.names).c_str()); + return; + } + std::string text = "guides " + std::to_string(slot.auxBindings.size()) + "\n"; + char path[MAX_PATH]; + for (size_t index = 0; index < slot.auxBindings.size(); ++index) + { + const AuxBinding& binding = slot.auxBindings[index]; + const AuxSurface& surface = *binding.surface; + // Both spellings are recorded: the names the caller registered the texture under, and + // the names the denoiser reads it as, which for the two albedos differ. + std::string sources, names; + for (const char* name : binding.sources) + sources += sources.empty() ? name : std::string(",") + name; + for (const char* name : binding.names) + names += names.empty() ? name : std::string(",") + name; + const int written = + std::snprintf(path, MAX_PATH, "%s\\frame-%06u-guide-%02zu.bin", directory, frame, index); + if (written <= 0 || written >= MAX_PATH) + { + logf("frame %u: the path for Ray Reconstruction guide %s does not fit; this frame's capture is " + "incomplete and is not written", + frame, join_aux_names(binding.names).c_str()); + return; + } + const Staging& staging = surface.staging[slotIndex]; + const size_t rowBytes = static_cast(surface.texelBytes) * surface.width; + const size_t bytes = rowBytes * surface.height; + uint8_t* const pinned = capture_pinned(path, bytes); + std::vector fallback; + if (pinned == nullptr) + fallback.resize(bytes); + uint8_t* const destination = pinned != nullptr ? pinned : fallback.data(); + for (UINT row = 0; row < surface.height; ++row) + std::memcpy(destination + static_cast(row) * rowBytes, + staging.mapped + staging.layout.Offset + + static_cast(row) * staging.layout.Footprint.RowPitch, + rowBytes); + if (pinned == nullptr) + write_capture_file(frame, "Ray Reconstruction guide", path, destination, bytes); + char line[512]; + const int length = std::snprintf(line, sizeof(line), + "guide %zu %s %s %u %u %u %u %d\n", index, sources.c_str(), names.c_str(), + static_cast(surface.format), surface.width, surface.height, + surface.texelBytes, surface.how == AuxLayout::Verbatim ? 0 : 1); + if (length <= 0) + return; + text += line; + } + // The scalar settings, the Subrect origins and the camera matrices, under the very + // parameter names the denoiser reads: a replay sets them by name, so a table that drifted + // here and there could not misroute them. + for (size_t index = 0; index < sizeof(kAuxScalars) / sizeof(kAuxScalars[0]); ++index) + { + char line[256]; + const int length = std::snprintf(line, sizeof(line), "scalar %s %d\n", kAuxScalars[index].name, + p.auxScalar[index]); + if (length <= 0) + return; + text += line; + } + for (size_t index = 0; index < sizeof(kAuxSubrectNames) / sizeof(kAuxSubrectNames[0]); ++index) + { + for (int axis = 0; axis < 2; ++axis) + { + char line[256]; + const int length = std::snprintf(line, sizeof(line), "subrect %s.Subrect.Base.%c %u\n", + kAuxSubrectNames[index], axis == 0 ? 'X' : 'Y', + p.auxSubrect[index][axis]); + if (length <= 0) + return; + text += line; + } + } + for (size_t index = 0; index < sizeof(kAuxMatrices) / sizeof(kAuxMatrices[0]); ++index) + { + char line[1024]; + int length = std::snprintf(line, sizeof(line), "matrix %s %d", kAuxMatrices[index], + p.auxMatrixSet[index] ? 1 : 0); + if (length <= 0) + return; + for (size_t element = 0; element < 16 && p.auxMatrixSet[index]; ++element) + { + const int written = std::snprintf(line + length, sizeof(line) - static_cast(length), " %.9g", + static_cast(p.auxMatrix[index][element])); + if (written <= 0 || static_cast(written) >= sizeof(line) - static_cast(length)) + return; + length += written; + } + text += line; + text += "\n"; + } + const int written = std::snprintf(path, MAX_PATH, "%s\\frame-%06u-guides.txt", directory, frame); + if (written <= 0 || written >= MAX_PATH) + return; + write_capture_file(frame, "Ray Reconstruction guide parameters", path, text.data(), text.size()); +} + // Worker. Points the denoiser's parameters at this frame's texture objects and forwards the // settings and camera matrices the caller registered for this frame. Every name this file // ever published is cleared first: the parameter object outlives the frame, so a name left @@ -6141,6 +6309,8 @@ static void run_evaluation(Feature* feature, int slotIndex, uint32_t frame, Fram { std::lock_guard lock(feature->auxMutex); auxUploaded = upload_aux_inputs(*feature, slotIndex, frame); + if (auxUploaded) + dump_aux_guides(*feature, slotIndex, frame, params); } if (!auxUploaded) { @@ -7093,6 +7263,7 @@ static NgxResult create_feature(unsigned int featureId, void* parameters, NgxHan feature->quality = quality; feature->flags = flags; const int subrects = get_int_or(parameters, "DLSS.Enable.Output.Subrects", 0); + feature->outputSubrects = subrects; // The render preset is chosen by two independent knobs. D4R_DLSS_PRESET forces the // Super Sampling model (NVSDK_NGX_DLSS_Hint_Render_Preset, e.g. 5 = E) and touches only // Super Sampling features; D4R_RR_PRESET forces the Ray Reconstruction model @@ -8393,12 +8564,21 @@ static NgxResult evaluate(ID3D12GraphicsCommandList* list, const NgxHandle* hand outputDesc.Width, outputDesc.Height, outputDesc.Format, outputSupported ? "" : " (unsupported)"); return NGX_FAIL_UNSUPPORTED_FORMAT; } + unsigned int exposureWidth = 0, exposureHeight = 0; if (exposure != nullptr) { D3D12_RESOURCE_DESC exposureDesc; exposure->GetDesc(&exposureDesc); + // Recorded before the format test: a capture that kept the plane but not its geometry + // could not say whether the game passed a scalar or a per-pixel exposure buffer. + exposureWidth = static_cast(exposureDesc.Width); + exposureHeight = static_cast(exposureDesc.Height); if (!supported_input(Plane::Exposure, exposureDesc.Format)) + { exposure = nullptr; + exposureWidth = 0; + exposureHeight = 0; + } } FrameParams p; @@ -8429,6 +8609,18 @@ static NgxResult evaluate(ID3D12GraphicsCommandList* list, const NgxHandle* hand p.outputBaseX = get_uint_or(parameters, "DLSS.Output.Subrect.Base.X", 0); p.outputBaseY = get_uint_or(parameters, "DLSS.Output.Subrect.Base.Y", 0); p.hasExposure = exposure != nullptr; + p.exposureWidth = exposureWidth; + p.exposureHeight = exposureHeight; + // Creation state, so a capture records the model and geometry the game asked for rather + // than what a replay would otherwise have to guess. + p.createWidth = feature->width; + p.createHeight = feature->height; + p.createOutWidth = feature->outWidth; + p.createOutHeight = feature->outHeight; + p.createQuality = feature->quality; + p.createFlags = feature->flags; + p.createSubrects = feature->outputSubrects; + p.createPreset = feature->preset; // Ray Reconstruction: the denoiser's own per-frame settings and camera matrices are read // here, on the game thread, while the caller's parameter object is certainly alive, and