From b6c738c8e136957a28c604c86c0907ff57758a1e Mon Sep 17 00:00:00 2001 From: mchughalex <25671999+mchughalex@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:50:31 +0100 Subject: [PATCH] graphics: on-disk cache for runtime-compiled shader bytecode --- include/rex/graphics/native_rhi.h | 11 ++ src/graphics/d3d12/native_rhi_d3d12.cpp | 145 +++++++++++++++++++++--- src/graphics/native_guest_renderer.cpp | 20 ++++ src/ui/rex_app.cpp | 11 ++ 4 files changed, 174 insertions(+), 13 deletions(-) diff --git a/include/rex/graphics/native_rhi.h b/include/rex/graphics/native_rhi.h index aa51542..5ac8787 100644 --- a/include/rex/graphics/native_rhi.h +++ b/include/rex/graphics/native_rhi.h @@ -513,4 +513,15 @@ class Device { virtual ~Device() = default; }; +// Optional on-disk cache directory for runtime-compiled shader bytecode +// (the D3D12 backend's D3DCompile output; backends consuming prebuilt +// shaders ignore it). Entries are keyed by a content hash of the stage, +// entry point, macros and full source text, so source edits miss naturally +// and stale bytecode is never served. Set once during app startup, before +// the first CreateShader; empty (the default) disables caching. A missing +// or unwritable directory only costs compile time, never correctness. +void SetShaderBytecodeCacheDirectory(const char* path); +// The configured directory, or an empty string when caching is disabled. +const char* GetShaderBytecodeCacheDirectory(); + } // namespace rex::graphics::nrhi diff --git a/src/graphics/d3d12/native_rhi_d3d12.cpp b/src/graphics/d3d12/native_rhi_d3d12.cpp index 7a6e327..eb121d5 100644 --- a/src/graphics/d3d12/native_rhi_d3d12.cpp +++ b/src/graphics/d3d12/native_rhi_d3d12.cpp @@ -7,9 +7,14 @@ #include #include +#include +#include #include +#include +#include #include #include +#include #include #include @@ -29,6 +34,107 @@ using nrhi::HeapKind; using nrhi::ResourceState; using nrhi::TextureKind; +// ---- Shader bytecode cache ------------------------------------------------ +// D3DCompile of the scene uber shader costs whole seconds and runs on the +// render thread during pipeline creation, which holds the first presented +// frame (a launch black screen). Compiled DXBC is content-addressed on disk +// (see nrhi::SetShaderBytecodeCacheDirectory): the key hashes everything +// that affects codegen, so source edits miss naturally and entries never go +// stale. Every failure path falls back to compiling. + +uint64_t Fnv1a64(const void* data, size_t size, uint64_t hash) { + const uint8_t* p = static_cast(data); + for (size_t i = 0; i < size; ++i) { + hash ^= p[i]; + hash *= 1099511628211ull; + } + return hash; +} + +std::filesystem::path ShaderCachePath(const nrhi::ShaderDesc& desc, + const char* target) { + const char* dir = nrhi::GetShaderBytecodeCacheDirectory(); + if (dir == nullptr || dir[0] == '\0') { + return {}; + } + // Two independent FNV-1a streams form a 128-bit key; the terminating NUL + // fed with each field keeps concatenations unambiguous. + uint64_t h1 = 14695981039346656037ull; + uint64_t h2 = 0x84222325CBF29CE4ull; + const auto feed_str = [&](const char* s) { + const size_t n = std::strlen(s) + 1; + h1 = Fnv1a64(s, n, h1); + h2 = Fnv1a64(s, n, h2); + }; + feed_str("nrhi-dxbc-1"); // cache format tag + feed_str(target); + feed_str(desc.entry_point != nullptr ? desc.entry_point : ""); + if (desc.macros != nullptr) { + for (uint32_t i = 0; desc.macros[i].name != nullptr; ++i) { + feed_str(desc.macros[i].name); + feed_str(desc.macros[i].value != nullptr ? desc.macros[i].value : ""); + } + } + feed_str(desc.hlsl_source != nullptr ? desc.hlsl_source : ""); + char name[48]; + std::snprintf(name, sizeof(name), "%016llx%016llx.dxbc", + static_cast(h1), + static_cast(h2)); + return std::filesystem::path(dir) / name; +} + +ID3DBlob* TryLoadCachedBlob(const std::filesystem::path& path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f.is_open()) { + return nullptr; + } + const std::streamoff size = f.tellg(); + if (size < 8) { + return nullptr; + } + ID3DBlob* blob = nullptr; + if (FAILED(D3DCreateBlob(static_cast(size), &blob))) { + return nullptr; + } + f.seekg(0); + f.read(static_cast(blob->GetBufferPointer()), + static_cast(size)); + // The DXBC container magic rejects truncated or foreign files. + if (!f.good() || std::memcmp(blob->GetBufferPointer(), "DXBC", 4) != 0) { + blob->Release(); + return nullptr; + } + return blob; +} + +void StoreCachedBlob(const std::filesystem::path& path, ID3DBlob* blob) { + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + // Write-then-rename so a crash mid-write never leaves a truncated blob + // under the final name (the loader's DXBC magic check is the second line + // of defense). The per-process temp name keeps two instances compiling + // the same shader from interleaving writes. + std::filesystem::path tmp = path; + tmp += ".tmp" + std::to_string(GetCurrentProcessId()); + { + std::ofstream f(tmp, std::ios::binary | std::ios::trunc); + if (!f.is_open()) { + return; + } + f.write(static_cast(blob->GetBufferPointer()), + static_cast(blob->GetBufferSize())); + if (!f.good()) { + f.close(); + std::filesystem::remove(tmp, ec); + return; + } + } + std::filesystem::rename(tmp, path, ec); + if (ec) { + std::filesystem::remove(tmp, ec); + } +} + DXGI_FORMAT ToDxgi(Format format) { switch (format) { case Format::kR8G8B8A8_UNORM: @@ -640,22 +746,35 @@ class NrDeviceD3D12 : public nrhi::Device { ++macro_count; } } - ID3DBlob* blob = nullptr; - ID3DBlob* errors = nullptr; const char* target = desc.stage == nrhi::ShaderStage::kVertex ? "vs_5_0" : "ps_5_0"; - HRESULT hr = D3DCompile(desc.hlsl_source, std::strlen(desc.hlsl_source), - desc.name != nullptr ? desc.name : "nrhi_shader", - macro_count != 0 ? macros : nullptr, nullptr, desc.entry_point, - target, 0, 0, &blob, &errors); - if (FAILED(hr)) { - REXLOG_ERROR("nrhi-d3d12: shader compile failed ({} {}): {}", - desc.name != nullptr ? desc.name : "?", desc.entry_point, - errors != nullptr ? static_cast(errors->GetBufferPointer()) - : "no error blob"); + const std::filesystem::path cache_path = ShaderCachePath(desc, target); + ID3DBlob* blob = cache_path.empty() ? nullptr : TryLoadCachedBlob(cache_path); + if (blob == nullptr) { + ID3DBlob* errors = nullptr; + const auto compile_start = std::chrono::steady_clock::now(); + HRESULT hr = D3DCompile(desc.hlsl_source, std::strlen(desc.hlsl_source), + desc.name != nullptr ? desc.name : "nrhi_shader", + macro_count != 0 ? macros : nullptr, nullptr, desc.entry_point, + target, 0, 0, &blob, &errors); + if (FAILED(hr)) { + REXLOG_ERROR("nrhi-d3d12: shader compile failed ({} {}): {}", + desc.name != nullptr ? desc.name : "?", desc.entry_point, + errors != nullptr ? static_cast(errors->GetBufferPointer()) + : "no error blob"); + if (errors) errors->Release(); + return nullptr; + } if (errors) errors->Release(); - return nullptr; + const auto compile_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - compile_start) + .count(); + if (!cache_path.empty()) { + StoreCachedBlob(cache_path, blob); + } + REXLOG_INFO("nrhi-d3d12: compiled {} {} in {} ms{}", + desc.name != nullptr ? desc.name : "?", desc.entry_point, compile_ms, + cache_path.empty() ? "" : " (cached to disk)"); } - if (errors) errors->Release(); auto* shader = new NrShaderD3D12(); shader->blob = blob; return shader; diff --git a/src/graphics/native_guest_renderer.cpp b/src/graphics/native_guest_renderer.cpp index c037e4c..e05d608 100644 --- a/src/graphics/native_guest_renderer.cpp +++ b/src/graphics/native_guest_renderer.cpp @@ -1,6 +1,7 @@ #include #include +#include #include @@ -146,3 +147,22 @@ bool ShouldSuppressPassAtPitch(uint32_t surface_pitch) { } } // namespace rex::graphics + +namespace rex::graphics::nrhi { +namespace { + +// Set once during app startup before any CreateShader (see native_rhi.h); +// lives in this shared TU so every backend reads the same value. +std::string g_shader_bytecode_cache_dir; + +} // namespace + +void SetShaderBytecodeCacheDirectory(const char* path) { + g_shader_bytecode_cache_dir = path != nullptr ? path : ""; +} + +const char* GetShaderBytecodeCacheDirectory() { + return g_shader_bytecode_cache_dir.c_str(); +} + +} // namespace rex::graphics::nrhi diff --git a/src/ui/rex_app.cpp b/src/ui/rex_app.cpp index 00adbf9..4a05066 100644 --- a/src/ui/rex_app.cpp +++ b/src/ui/rex_app.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #if REX_HAS_VULKAN #include #endif @@ -357,6 +358,16 @@ bool ReXApp::SetupEnvironment() { config_path_ = path_config.config_path; resolved_defaults_ = std::move(path_config); + // Native-render RHI shader bytecode cache: the D3D12 backend runtime- + // compiles its HLSL, and the largest scene shader costs seconds on the + // render thread at first-frame pipeline creation; cached bytecode turns + // every launch after the first into a file read. Must be set before the + // graphics system creates its first shader. + if (!cache_root_.empty()) { + graphics::nrhi::SetShaderBytecodeCacheDirectory( + (cache_root_ / "nrhi_shaders").string().c_str()); + } + // Late-phase logging std::string log_file_cvar = REXCVAR_GET(log_file); std::string log_level_str = REXCVAR_GET(log_level);