graphics: on-disk cache for runtime-compiled shader bytecode

This commit is contained in:
mchughalex 2026-07-19 19:50:31 +01:00
parent 4a6e9afacb
commit b6c738c8e1
4 changed files with 174 additions and 13 deletions

View file

@ -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

View file

@ -7,9 +7,14 @@
#include <rex/graphics/d3d12/native_rhi_d3d12.h>
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <map>
#include <mutex>
#include <string>
#include <utility>
#include <vector>
@ -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<const uint8_t*>(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<unsigned long long>(h1),
static_cast<unsigned long long>(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_T>(size), &blob))) {
return nullptr;
}
f.seekg(0);
f.read(static_cast<char*>(blob->GetBufferPointer()),
static_cast<std::streamsize>(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<const char*>(blob->GetBufferPointer()),
static_cast<std::streamsize>(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,9 +746,12 @@ 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";
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,
@ -656,6 +765,16 @@ class NrDeviceD3D12 : public nrhi::Device {
return nullptr;
}
if (errors) errors->Release();
const auto compile_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
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)");
}
auto* shader = new NrShaderD3D12();
shader->blob = blob;
return shader;

View file

@ -1,6 +1,7 @@
#include <rex/graphics/native_guest_renderer.h>
#include <atomic>
#include <string>
#include <rex/cvar.h>
@ -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

View file

@ -24,6 +24,7 @@
#include <rex/ui/overlay/fps_overlay.h>
#include <rex/ui/overlay/settings_overlay.h>
#include <rex/graphics/graphics_system.h>
#include <rex/graphics/native_rhi.h>
#if REX_HAS_VULKAN
#include <rex/graphics/vulkan/graphics_system.h>
#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);