Add checked Slang shaders for GPU visibility and HZB

This commit is contained in:
Emil
2026-09-23 22:20:34 +03:00
parent f8cd73b95f
commit 0d2cae18fb
11 changed files with 560 additions and 13 deletions
+6
View File
@@ -148,6 +148,12 @@ int test_main(int argc, char** argv) {
const auto first = builds.wait(builds.start_build());
check(first.state == "succeeded", "Valid custom schema v2 publishes: " + first.error);
const auto directory = path_from_utf8(first.result.at("directory").get<std::string>());
for (const auto* entry : {"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"})
check(fs::is_regular_file(directory / "shaders" /
(std::string(entry) + extension)),
"Published Player contains every checked P2 shader artifact");
const auto player = path_from_utf8(first.result.at("player").get<std::string>());
const auto schema = path_from_utf8(first.result.at("schema").get<std::string>());
const auto last_build = builds.config().cache_root / "last_build.json";
+3 -1
View File
@@ -60,7 +60,9 @@ int tool_main(int argc, char** argv) {
for (const auto* target : {"faset_player", "faset_schema_exporter"})
fs::copy_file(self, build / (std::string(target) + suffix),
fs::copy_options::overwrite_existing);
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"})
atomic_write(build / "shaders" / (std::string(entry) + extension), "fixture\n");
return 0;
@@ -0,0 +1,71 @@
#include "shader_contract.hpp"
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <filesystem>
#include <iostream>
#include <stdexcept>
#include <string>
namespace fs = std::filesystem;
namespace {
void require(bool valid, const char* message) {
if (!valid)
throw std::runtime_error(message);
}
template <class Function> void must_reject(Function&& function, const char* message) {
try {
function();
} catch (const std::exception&) {
return;
}
throw std::runtime_error(message);
}
} // namespace
int main() {
const auto original = fs::path(FASET_TEST_SHADER_DIRECTORY);
const auto temporary = fs::temp_directory_path() / "faset-gpu-shader-contract-test";
struct Cleanup {
fs::path path;
~Cleanup() { std::error_code ignored; fs::remove_all(path, ignored); }
} cleanup{temporary};
try {
fs::create_directories(temporary);
constexpr const char* entries[] = {"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"};
for (const auto* entry : entries)
for (const auto* extension : {".spv", ".reflection.json"}) {
const auto file = std::string(entry) + extension;
fs::copy_file(original / file, temporary / file, fs::copy_options::overwrite_existing);
}
auto shaders = faset::render::detail::load_gpu_shader_bundle(temporary);
for (const auto& shader : shaders)
require(!shader.words.empty() && !shader.layout_fingerprint.empty(),
"Every P2 entry is valid SPIR-V with checked metadata");
auto shader_file = temporary / "gpuCullMain.spv";
const auto shader_bytes = faset::read_text(shader_file);
faset::atomic_write(shader_file, "corrupt");
must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); },
"Corrupt P2 SPIR-V must be rejected");
faset::atomic_write(shader_file, shader_bytes);
auto reflection_file = temporary / "gpuPostCullMain.reflection.json";
auto metadata = faset::read_json(reflection_file);
metadata["layout_fingerprint"] = "tampered";
faset::atomic_write_json(reflection_file, metadata);
must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); },
"Tampered P2 layout fingerprint must be rejected");
metadata = faset::read_json(original / "gpuPostCullMain.reflection.json");
metadata["layout"]["descriptors"][0]["element_stride"] = 208;
metadata["layout_fingerprint"] = faset::sha256(metadata["layout"].dump());
faset::atomic_write_json(reflection_file, metadata);
must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); },
"A consistently rehashed but incompatible GPU record stride must be rejected");
fs::remove(temporary / "gpuHzbMain.spv");
must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); },
"Missing P2 entry must be rejected");
std::cout << "GPU shader bundle validates every entry and rejects corrupt or missing artifacts\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+6 -2
View File
@@ -46,7 +46,9 @@ int main() {
try {
const auto bundle = temporary / "shaders";
fs::create_directories(bundle);
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"}) {
const auto name = std::string(entry) + extension;
fs::copy_file(path_from_utf8(FASET_TEST_SHADER_DIRECTORY) / name, bundle / name);
@@ -76,7 +78,9 @@ int main() {
require(deep_bundle.native().size() > 300,
"Shader file fixture must exceed the legacy Windows path limit");
fs::create_directories(native_io_path(deep_bundle));
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"}) {
const auto name = std::string(entry) + extension;
atomic_write(deep_bundle / name, read_text(bundle / name));
+81
View File
@@ -0,0 +1,81 @@
"""The cooked shader interface must preserve GPU resource kinds and element strides."""
import importlib.util
import json
import os
from pathlib import Path
import struct
import subprocess
import sys
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "tools" / "compile_shader.py"
SPEC = importlib.util.spec_from_file_location("faset_compile_shader", SCRIPT)
assert SPEC and SPEC.loader
shader = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(shader)
def parameter(name: str, index: int, shape: str, access: str, stride: int | None = None):
result = {"kind": "scalar", "scalarType": "uint32", "sizes": []}
if stride is not None:
result["sizes"] = [{"kind": "uniform", "value": stride, "alignment": 16}]
return {
"name": name,
"binding": {"kind": "descriptorTableSlot", "index": index, "space": 0},
"type": {
"kind": "resource",
"baseShape": shape,
"access": access,
"resultType": result,
"sizes": [{"kind": "descriptorTableSlot", "value": 1}],
},
}
class ReflectionTests(unittest.TestCase):
def test_gpu_storage_resources_keep_kind_and_stride(self):
parameters = [
parameter("instances", 0, "structuredBuffer", "read", 224),
parameter("visibleIds", 1, "structuredBuffer", "readWrite", 4),
parameter("depthOutput", 2, "texture2D", "readWrite"),
parameter("depthInput", 3, "texture2D", "read"),
]
raw = {
"parameters": parameters,
"entryPoints": [{
"name": "gpuCullMain", "stage": "compute",
"bindings": [{"name": p["name"], "binding": {"used": 1}} for p in parameters],
}],
}
spirv = struct.pack("<5I", 0x07230203, 0x00010600, 0, 1, 0)
layout = shader.normalize(raw, spirv, "gpuCullMain")["layout"]
descriptors = layout["descriptors"]
self.assertEqual(
[(d["type"], d.get("element_stride")) for d in descriptors],
[("storage_buffer", 224), ("storage_buffer", 4),
("storage_image_2d", None), ("sampled_image_2d", None)],
)
def test_real_slang_compute_interface(self):
compiler = os.environ["FASET_TEST_SLANGC"]
with tempfile.TemporaryDirectory(prefix="faset-shader-reflection-") as directory:
process = subprocess.run(
[sys.executable, str(SCRIPT), "--compiler", compiler, "--source",
str(SCRIPT.parents[1] / "shaders" / "gpu_scene.slang"), "--entry",
"gpuCullMain", "--define", "FASET_GPU_CULL=1", "--output", directory],
capture_output=True, text=True,
)
self.assertEqual(process.returncode, 0, process.stderr)
metadata = json.loads((Path(directory) / "gpuCullMain.reflection.json").read_text())
bindings = {item["binding"]: item for item in metadata["layout"]["descriptors"]}
self.assertEqual([bindings[n]["element_stride"] for n in (0, 1, 2, 3, 4, 5, 6, 9)],
[224, 16, 16, 4, 16, 4, 4, 208])
self.assertEqual([bindings[n]["type"] for n in (7, 8)],
["sampled_image_2d", "sampled_image_2d"])
if __name__ == "__main__":
unittest.main()