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
+25
View File
@@ -17,6 +17,23 @@ foreach(FASET_ENTRY vertexMain fragmentMain shadowMain)
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/baseline.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM)
list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json")
endforeach()
foreach(FASET_ENTRY gpuVertexMain gpuShadowMain gpuCullMain gpuHzbMain gpuPostCullMain)
if(FASET_ENTRY STREQUAL "gpuVertexMain" OR FASET_ENTRY STREQUAL "gpuShadowMain")
set(FASET_GPU_DEFINE FASET_GPU_GRAPHICS=1)
elseif(FASET_ENTRY STREQUAL "gpuHzbMain")
set(FASET_GPU_DEFINE FASET_GPU_HZB=1)
else()
set(FASET_GPU_DEFINE FASET_GPU_CULL=1)
endif()
set(FASET_SHADER_OUTPUT "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.spv")
add_custom_command(OUTPUT "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json"
COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py"
--compiler "${SLANGC_EXECUTABLE}" --source "${PROJECT_SOURCE_DIR}/shaders/gpu_scene.slang"
--entry "${FASET_ENTRY}" --define "${FASET_GPU_DEFINE}" --output "${FASET_SHADER_DIRECTORY}"
BYPRODUCTS "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.slang-reflection.json"
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/gpu_scene.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM)
list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json")
endforeach()
add_custom_command(OUTPUT "${FASET_SHADER_DIRECTORY}/compatibility.spv"
COMMAND "${SLANGC_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/shaders/compatibility.hlsl"
-entry compatibilityMain -stage compute -target spirv -profile spirv_1_6
@@ -49,6 +66,14 @@ if(BUILD_TESTING)
FASET_PYTHON_EXECUTABLE="${Python3_EXECUTABLE}")
add_test(NAME render_shader_reload COMMAND faset_render_reload_tests)
set_tests_properties(render_shader_reload PROPERTIES LABELS "gpu")
add_executable(faset_render_gpu_shader_contract_tests "${PROJECT_SOURCE_DIR}/tests/render_gpu_shader_contract_tests.cpp")
target_include_directories(faset_render_gpu_shader_contract_tests PRIVATE "${PROJECT_SOURCE_DIR}/src/render")
target_link_libraries(faset_render_gpu_shader_contract_tests PRIVATE faset_render faset_core)
target_compile_definitions(faset_render_gpu_shader_contract_tests PRIVATE FASET_TEST_SHADER_DIRECTORY="${FASET_SHADER_DIRECTORY}")
add_test(NAME render_gpu_shader_contract COMMAND faset_render_gpu_shader_contract_tests)
add_test(NAME render_shader_reflection COMMAND "${CMAKE_COMMAND}" -E env
"FASET_TEST_SLANGC=${SLANGC_EXECUTABLE}"
"${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tests/test_shader_reflection.py")
add_executable(faset_render_window_tests "${PROJECT_SOURCE_DIR}/tests/render_window_tests.cpp")
target_link_libraries(faset_render_window_tests PRIVATE faset_render SDL3::SDL3)
add_test(NAME render_window_lifecycle COMMAND faset_render_window_tests "${CMAKE_BINARY_DIR}/window-test")
+256
View File
@@ -0,0 +1,256 @@
// GPU-visible opaque scene. All host records use 16-byte lanes; reflected strides
// are validated before pipelines are created. Sprites/UI and shadow caster selection
// remain independent of camera culling.
struct GpuSceneVertex {
float3 position : POSITION;
float3 normal : NORMAL;
float4 color : COLOR0;
float2 uv : TEXCOORD0;
};
struct GpuSceneOutput {
float4 position : SV_Position;
float3 world : TEXCOORD0;
float3 normal : NORMAL;
float4 color : COLOR0;
float2 material : TEXCOORD1;
float2 uv : TEXCOORD2;
};
struct InstanceRecord {
column_major float4x4 model; // 0..63
float4 normalRow0; // 64..79, inverse-transpose 3x3
float4 normalRow1; // 80..95
float4 normalRow2; // 96..111
float4 color; // 112..127
float4 material; // 128..143: roughness, metallic, texture flags
float4 currentCenter; // 144..159: world AABB center
float4 currentExtent; // 160..175: world AABB half extents
float4 previousCenter; // 176..191
float4 previousExtent; // 192..207
uint4 metadata; // 208..223: x=previousValid, others reserved
};
struct ViewRecord {
column_major float4x4 currentViewProjection; // 0..63
column_major float4x4 previousViewProjection; // 64..127
float4 currentViewport; // 128..143: x/y/width/height in target pixels
float4 previousViewport; // 144..159
uint4 currentHzbSize; // 160..175: width/height/mipCount/reserved
uint4 previousHzbSize; // 176..191
uint4 flags; // 192..207: x=historyValid
};
struct BinRecord {
uint candidateFirst, candidateCount, visibleBase, capacity;
};
struct Candidate {
uint instanceId, binIndex, flags, reserved;
};
// Exactly VkDrawIndirectCommand: vertexCount, instanceCount, firstVertex, firstInstance.
struct IndirectArgs {
uint vertexCount, instanceCount, firstVertex, firstInstance;
};
#if defined(FASET_GPU_GRAPHICS)
struct GpuFrameParameters {
column_major float4x4 lightViewProjection; // same first 96 bytes as baseline fragment
float4 lightDirection;
float4 eye;
uint4 drawInfo; // x=visible ID range base; firstInstance is always zero
};
[[vk::push_constant]] ConstantBuffer<GpuFrameParameters> gpuFrame;
[[vk::binding(0,1)]] StructuredBuffer<InstanceRecord> gfxInstances;
[[vk::binding(1,1)]] StructuredBuffer<uint> gfxVisibleIds;
[[vk::binding(2,1)]] StructuredBuffer<ViewRecord> gfxViews;
[shader("vertex")]
GpuSceneOutput gpuVertexMain(GpuSceneVertex vertex, uint drawInstance : SV_InstanceID) {
InstanceRecord instance = gfxInstances[gfxVisibleIds[gpuFrame.drawInfo.x + drawInstance]];
float4 world = mul(instance.model, float4(vertex.position, 1));
GpuSceneOutput output;
output.position = mul(gfxViews[0].currentViewProjection, world);
output.world = world.xyz;
float3 normal = float3(dot(instance.normalRow0.xyz, vertex.normal),
dot(instance.normalRow1.xyz, vertex.normal),
dot(instance.normalRow2.xyz, vertex.normal));
float normalLength = length(normal);
output.normal = normalLength > 1e-8 ? normal / normalLength : float3(0, 0, 0);
output.color = vertex.color * instance.color;
output.material = instance.material.xy;
output.uv = vertex.uv;
return output;
}
[shader("vertex")]
float4 gpuShadowMain(GpuSceneVertex vertex, uint drawInstance : SV_InstanceID) : SV_Position {
InstanceRecord instance = gfxInstances[gfxVisibleIds[gpuFrame.drawInfo.x + drawInstance]];
return mul(gpuFrame.lightViewProjection, mul(instance.model, float4(vertex.position, 1)));
}
#elif defined(FASET_GPU_CULL)
struct CullParameters {
uint candidateCount;
uint deferredCapacity;
uint reserved0;
uint reserved1;
};
[[vk::push_constant]] ConstantBuffer<CullParameters> cullParameters;
[[vk::binding(0,0)]] StructuredBuffer<InstanceRecord> cullInstances;
[[vk::binding(1,0)]] StructuredBuffer<Candidate> candidates;
[[vk::binding(2,0)]] StructuredBuffer<BinRecord> bins;
[[vk::binding(3,0)]] RWStructuredBuffer<uint> visibleIds;
[[vk::binding(4,0)]] RWStructuredBuffer<IndirectArgs> args;
[[vk::binding(5,0)]] RWStructuredBuffer<uint> deferredIds;
[[vk::binding(6,0)]] RWStructuredBuffer<uint> deferredCount;
[[vk::binding(7,0)]] Texture2D<float> previousHzb;
[[vk::binding(8,0)]] Texture2D<float> currentHzb;
[[vk::binding(9,0)]] StructuredBuffer<ViewRecord> cullViews;
float4 boundsCorner(float4 center, float4 extent, uint corner) {
return float4(center.xyz + extent.xyz * float3((corner & 1) != 0 ? 1 : -1,
(corner & 2) != 0 ? 1 : -1,
(corner & 4) != 0 ? 1 : -1), 1);
}
bool finiteClip(float4 clip) {
return all(isfinite(clip));
}
// A plane may reject an AABB only when all eight corners are strictly outside.
// Nonfinite arithmetic fails open so malformed data cannot cause disappearing meshes.
bool inFrustum(float4 center, float4 extent, float4x4 viewProjection) {
uint rejected[6] = {0, 0, 0, 0, 0, 0};
[unroll] for (uint corner = 0; corner < 8; ++corner) {
float4 clip = mul(viewProjection, boundsCorner(center, extent, corner));
if (!finiteClip(clip)) return true;
float planes[6] = {clip.x + clip.w, clip.w - clip.x,
clip.y + clip.w, clip.w - clip.y,
clip.z, clip.w - clip.z};
[unroll] for (uint plane = 0; plane < 6; ++plane)
rejected[plane] += planes[plane] < 0 ? 1 : 0;
}
[unroll] for (uint plane = 0; plane < 6; ++plane)
if (rejected[plane] == 8) return false;
return true;
}
// Ordinary Z: the HZB contains the furthest depth (maximum) in every footprint.
// The nearest candidate depth must be farther than *all* those samples to occlude.
bool occluded(float4 center, float4 extent, float4x4 viewProjection,
float4 viewport, uint4 pyramidSize, Texture2D<float> pyramid) {
if (pyramidSize.x == 0 || pyramidSize.y == 0 || pyramidSize.z == 0 ||
viewport.z <= 0 || viewport.w <= 0) return false;
float2 minPixel = float2(1e30, 1e30), maxPixel = float2(-1e30, -1e30);
float nearestDepth = 1;
[unroll] for (uint corner = 0; corner < 8; ++corner) {
float4 clip = mul(viewProjection, boundsCorner(center, extent, corner));
// Near-plane crossings and perspective singularities are always visible.
if (!finiteClip(clip) || clip.w <= 0 || clip.z <= 0 || clip.z >= clip.w) return false;
float3 projected = clip.xyz / clip.w;
float2 pixel = viewport.xy + (projected.xy * 0.5 + 0.5) * viewport.zw;
if (!all(isfinite(pixel)) || !isfinite(projected.z)) return false;
minPixel = min(minPixel, pixel);
maxPixel = max(maxPixel, pixel);
nearestDepth = min(nearestDepth, projected.z);
}
if (minPixel.x < 0 || minPixel.y < 0 || maxPixel.x >= pyramidSize.x ||
maxPixel.y >= pyramidSize.y) return false;
float extentPixels = max(maxPixel.x - minPixel.x, maxPixel.y - minPixel.y);
uint mip = min((uint)ceil(log2(max(extentPixels, 1.0))), pyramidSize.z - 1);
uint2 size = max(uint2(1, 1), (pyramidSize.xy + ((1u << mip) - 1)) >> mip);
uint2 first = min((uint2)floor(minPixel / (1u << mip)), size - 1);
uint2 last = min((uint2)floor(maxPixel / (1u << mip)), size - 1);
float furthest = 0;
for (uint y = first.y; y <= last.y; ++y)
for (uint x = first.x; x <= last.x; ++x)
furthest = max(furthest, pyramid.Load(int3(x, y, mip)));
return nearestDepth > furthest + 0.0001;
}
bool appendVisible(uint binIndex, uint instanceId) {
BinRecord bin = bins[binIndex];
uint observed = args[binIndex].instanceCount;
while (observed < bin.capacity) {
uint previous;
InterlockedCompareExchange(args[binIndex].instanceCount, observed, observed + 1, previous);
if (previous == observed) {
visibleIds[bin.visibleBase + observed] = instanceId;
return true;
}
observed = previous;
}
return false;
}
bool appendDeferred(uint candidateIndex) {
uint observed = deferredCount[0];
while (observed < cullParameters.deferredCapacity) {
uint previous;
InterlockedCompareExchange(deferredCount[0], observed, observed + 1, previous);
if (previous == observed) {
deferredIds[observed] = candidateIndex;
return true;
}
observed = previous;
}
return false;
}
[shader("compute")]
[numthreads(64, 1, 1)]
void gpuCullMain(uint3 dispatchId : SV_DispatchThreadID) {
uint index = dispatchId.x;
if (index >= cullParameters.candidateCount) return;
Candidate candidate = candidates[index];
InstanceRecord instance = cullInstances[candidate.instanceId];
ViewRecord view = cullViews[0];
if (!inFrustum(instance.currentCenter, instance.currentExtent,
view.currentViewProjection)) return;
bool guessedHidden = view.flags.x != 0 && instance.metadata.x != 0 &&
occluded(instance.previousCenter, instance.previousExtent,
view.previousViewProjection, view.previousViewport,
view.previousHzbSize, previousHzb);
if (guessedHidden && appendDeferred(index)) return;
appendVisible(candidate.binIndex, candidate.instanceId);
}
[shader("compute")]
[numthreads(64, 1, 1)]
void gpuPostCullMain(uint3 dispatchId : SV_DispatchThreadID) {
uint index = dispatchId.x;
if (index >= cullParameters.deferredCapacity || index >= deferredCount[0]) return;
Candidate candidate = candidates[deferredIds[index]];
InstanceRecord instance = cullInstances[candidate.instanceId];
ViewRecord view = cullViews[0];
if (!occluded(instance.currentCenter, instance.currentExtent,
view.currentViewProjection, view.currentViewport,
view.currentHzbSize, currentHzb))
appendVisible(candidate.binIndex, candidate.instanceId);
}
#elif defined(FASET_GPU_HZB)
struct HzbParameters {
uint sourceWidth, sourceHeight, outputWidth, outputHeight;
};
[[vk::push_constant]] ConstantBuffer<HzbParameters> hzbParameters;
[[vk::binding(0,0)]] Texture2D<float> hzbSource;
[[vk::binding(1,0)]] RWTexture2D<float> hzbOutput;
[shader("compute")]
[numthreads(8, 8, 1)]
void gpuHzbMain(uint3 dispatchId : SV_DispatchThreadID) {
uint2 pixel = dispatchId.xy;
if (pixel.x >= hzbParameters.outputWidth || pixel.y >= hzbParameters.outputHeight) return;
if (hzbParameters.outputWidth >= hzbParameters.sourceWidth &&
hzbParameters.outputHeight >= hzbParameters.sourceHeight) {
// Mip 0 copies depth into a power-of-two base. Missing edge texels are
// ordinary-Z far depth, so a padded region can never hide geometry.
hzbOutput[pixel] = pixel.x < hzbParameters.sourceWidth &&
pixel.y < hzbParameters.sourceHeight
? hzbSource.Load(int3(pixel, 0)) : 1.0;
return;
}
float furthest = 0;
[unroll] for (uint y = 0; y < 2; ++y)
[unroll] for (uint x = 0; x < 2; ++x) {
uint2 child = pixel * 2 + uint2(x, y);
// Ordinary-Z clear/far depth is 1. Padding therefore cannot occlude.
float depth = child.x < hzbParameters.sourceWidth &&
child.y < hzbParameters.sourceHeight
? hzbSource.Load(int3(child, 0)) : 1.0;
furthest = max(furthest, depth);
}
hzbOutput[pixel] = furthest;
}
#else
#error Select FASET_GPU_GRAPHICS, FASET_GPU_CULL, or FASET_GPU_HZB.
#endif
+10 -2
View File
@@ -334,7 +334,11 @@ struct BuildService::Impl {
copy_required_file(exporter, staging / ("faset_schema_exporter" + executable_suffix()));
for (const auto* file : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
"vertexMain.reflection.json", "fragmentMain.reflection.json",
"shadowMain.reflection.json"})
"shadowMain.reflection.json", "gpuVertexMain.spv",
"gpuShadowMain.spv", "gpuCullMain.spv", "gpuHzbMain.spv",
"gpuPostCullMain.spv", "gpuVertexMain.reflection.json",
"gpuShadowMain.reflection.json", "gpuCullMain.reflection.json",
"gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"})
copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file);
copy_runtime_libraries(job, player, staging, native_directory, configuration);
Json manifest{{"format", "faset.build"},
@@ -582,7 +586,11 @@ struct BuildService::Impl {
staging / ("faset_player" + executable_suffix()));
for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
"vertexMain.reflection.json", "fragmentMain.reflection.json",
"shadowMain.reflection.json"})
"shadowMain.reflection.json", "gpuVertexMain.spv",
"gpuShadowMain.spv", "gpuCullMain.spv", "gpuHzbMain.spv",
"gpuPostCullMain.spv", "gpuVertexMain.reflection.json",
"gpuShadowMain.reflection.json", "gpuCullMain.reflection.json",
"gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"})
copy_required_file(build_directory / "shaders" / shader,
staging / "shaders" / shader);
for (const auto& entry : fs::directory_iterator(build_directory)) {
+82 -5
View File
@@ -85,7 +85,72 @@ void validate_layout(const Json& layout, std::string_view entry) {
locations(layout.at("outputs"), {}, "shadow outputs");
}
}
void validate_spirv(const std::vector<std::uint32_t>& words, bool fragment) {
void validate_gpu_layout(const Json& layout, std::string_view entry) {
const bool graphics = entry == "gpuVertexMain" || entry == "gpuShadowMain";
const bool hzb = entry == "gpuHzbMain";
const bool compute = !graphics;
require(layout.at("stage") == (compute ? "compute" : "vertex"), "GPU shader stage changed");
const auto& descriptors = layout.at("descriptors");
const std::size_t expected_count = graphics ? 3 : hzb ? 2 : 10;
require(descriptors.is_array() && descriptors.size() == expected_count,
"GPU descriptor count changed");
const std::array<int, 10> compute_strides{224, 16, 16, 4, 16, 4, 4, 0, 0, 208};
const std::array<int, 3> graphics_strides{224, 4, 208};
for (std::size_t i = 0; i < expected_count; ++i) {
const auto& binding = descriptors[i];
require(binding.at("set") == (graphics ? 1 : 0) && binding.at("binding") == i &&
binding.at("count") == 1,
"GPU descriptor set, binding or count changed");
const int stride = graphics ? graphics_strides[i] : hzb ? 0 : compute_strides[i];
const char* type = hzb ? (i == 0 ? "sampled_image_2d" : "storage_image_2d")
: stride > 0 ? "storage_buffer" : "sampled_image_2d";
require(binding.at("type") == type, "GPU descriptor type changed");
if (stride > 0)
require(binding.at("element_stride") == stride, "GPU storage record stride changed");
}
const auto& constants = layout.at("push_constants");
require(constants.is_array() && constants.size() == 1 &&
constants[0].at("offset") == 0 &&
constants[0].at("size") == (graphics ? 112 : 16),
"GPU push-constant block changed");
const auto& members = constants[0].at("members");
require(members.is_array() && members.size() == 4, "GPU push-constant fields changed");
const int graphics_offsets[] = {0, 64, 80, 96};
const int graphics_sizes[] = {64, 16, 16, 16};
const char* graphics_types[] = {"float32x4x4", "float32x4", "float32x4", "uint32x4"};
for (std::size_t i = 0; i < 4; ++i) {
require(members[i].at("offset") == (graphics ? graphics_offsets[i] : int(i) * 4) &&
members[i].at("size") == (graphics ? graphics_sizes[i] : 4) &&
members[i].at("type") == (graphics ? graphics_types[i] : "uint32"),
"GPU push-constant layout changed");
}
const auto& blocks = layout.at("spirv_push_constants");
require(blocks.is_array() && blocks.size() == 1, "GPU SPIR-V push block changed");
const auto& actual = blocks[0].at("members");
require(actual.is_array() && actual.size() == 4, "GPU SPIR-V push members changed");
for (std::size_t i = 0; i < 4; ++i)
require(actual[i].at("member") == i &&
actual[i].at("offset") == (graphics ? graphics_offsets[i] : int(i) * 4),
"GPU SPIR-V push offsets changed");
if (graphics) {
require(actual[0].at("matrix_layout") == "row-major" &&
actual[0].at("matrix_stride") == 16,
"GPU SPIR-V matrix storage convention changed");
locations(layout.at("inputs"),
{"float32x3", "float32x3", "float32x4", "float32x2"},
"GPU vertex inputs");
if (entry == "gpuVertexMain")
locations(layout.at("outputs"),
{"float32x3", "float32x3", "float32x4", "float32x2", "float32x2"},
"GPU vertex outputs");
else
locations(layout.at("outputs"), {}, "GPU shadow outputs");
} else {
locations(layout.at("inputs"), {}, "GPU compute inputs");
locations(layout.at("outputs"), {}, "GPU compute outputs");
}
}
void validate_spirv(const std::vector<std::uint32_t>& words, std::uint32_t execution_model) {
require(words.size() >= 5 && words[0] == 0x07230203 && words[1] >= 0x00010000 &&
words[1] <= 0x00010600 && words[3] > 0 && words[3] < (1u << 20) && words[4] == 0,
"invalid SPIR-V header");
@@ -101,7 +166,7 @@ void validate_spirv(const std::vector<std::uint32_t>& words, bool fragment) {
const auto* terminator = static_cast<const char*>(std::memchr(name, 0, available));
require(terminator != nullptr, "unterminated SPIR-V entry name");
if (std::string_view(name, terminator - name) == "main") {
require(words[offset + 1] == (fragment ? 4u : 0u), "SPIR-V entry stage changed");
require(words[offset + 1] == execution_model, "SPIR-V entry stage changed");
entry_found = true;
}
}
@@ -109,7 +174,8 @@ void validate_spirv(const std::vector<std::uint32_t>& words, bool fragment) {
}
require(entry_found, "SPIR-V main entry point missing");
}
detail::ShaderCode load(const std::filesystem::path& directory, const char* entry) {
detail::ShaderCode load(const std::filesystem::path& directory, const char* entry,
bool gpu = false) {
const auto bytes = read_bounded(directory / (std::string(entry) + ".spv"), 16 * 1024 * 1024);
require(bytes.size() >= 20 && bytes.size() % 4 == 0, "invalid SPIR-V byte length");
const auto metadata = Json::parse(
@@ -122,12 +188,17 @@ detail::ShaderCode load(const std::filesystem::path& directory, const char* entr
const auto& layout = metadata.at("layout");
const auto fingerprint = faset::sha256(layout.dump());
require(metadata.at("layout_fingerprint") == fingerprint, "layout fingerprint mismatch");
validate_layout(layout, entry);
if (gpu)
validate_gpu_layout(layout, entry);
else
validate_layout(layout, entry);
detail::ShaderCode result;
result.layout_fingerprint = fingerprint;
result.words.resize(bytes.size() / 4);
std::memcpy(result.words.data(), bytes.data(), bytes.size());
validate_spirv(result.words, std::string_view(entry) == "fragmentMain");
validate_spirv(result.words, gpu ? ((std::string_view(entry) == "gpuVertexMain" ||
std::string_view(entry) == "gpuShadowMain") ? 0u : 5u)
: (std::string_view(entry) == "fragmentMain" ? 4u : 0u));
return result;
}
} // namespace
@@ -136,6 +207,12 @@ detail::load_shader_bundle(const std::filesystem::path& directory) {
return {load(directory, "vertexMain"), load(directory, "fragmentMain"),
load(directory, "shadowMain")};
}
std::array<detail::ShaderCode, 5>
detail::load_gpu_shader_bundle(const std::filesystem::path& directory) {
return {load(directory, "gpuVertexMain", true), load(directory, "gpuShadowMain", true),
load(directory, "gpuCullMain", true), load(directory, "gpuHzbMain", true),
load(directory, "gpuPostCullMain", true)};
}
void validate_shader_bundle(const std::filesystem::path& directory) {
(void)detail::load_shader_bundle(directory);
}
+2
View File
@@ -11,4 +11,6 @@ struct ShaderCode {
std::string layout_fingerprint;
};
std::array<ShaderCode, 3> load_shader_bundle(const std::filesystem::path& directory);
// Order: opaque vertex, optional instanced shadow vertex, main cull, HZB, post cull.
std::array<ShaderCode, 5> load_gpu_shader_bundle(const std::filesystem::path& directory);
} // namespace faset::render::detail
+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()
+18 -3
View File
@@ -109,13 +109,22 @@ def normalize(raw: dict, bytecode: bytes, entry_name: str) -> dict:
if ty["kind"] == "array":
count = ty["elementCount"]
ty = ty["elementType"]
element_stride = None
if ty["kind"] == "samplerState":
descriptor_type = "sampler"
elif ty["kind"] == "resource" and ty.get("baseShape") == "texture2D":
descriptor_type = "sampled_image_2d"
descriptor_type = "storage_image_2d" if ty.get("access") == "readWrite" else "sampled_image_2d"
elif ty["kind"] == "resource" and ty.get("baseShape") == "structuredBuffer":
descriptor_type = "storage_buffer"
element_stride = next((item["value"] for item in ty["resultType"].get("sizes", []) if item["kind"] == "uniform"), None)
if not isinstance(element_stride, int) or element_stride <= 0:
raise ValueError(f"Structured buffer lacks a valid element stride: {parameter['name']}")
else:
raise ValueError(f"Unsupported descriptor kind: {ty}")
descriptors.append({"name": parameter["name"], "set": binding.get("space", 0), "binding": binding["index"], "type": descriptor_type, "count": count, "used": used.get(parameter["name"], True)})
descriptor = {"name": parameter["name"], "set": binding.get("space", 0), "binding": binding["index"], "type": descriptor_type, "count": count, "used": used.get(parameter["name"], True)}
if element_stride is not None:
descriptor["element_stride"] = element_stride
descriptors.append(descriptor)
else:
raise ValueError(f"Unsupported global shader binding: {binding['kind']}")
inputs, input_builtins = interface(entry.get("parameters", []), "varyingInput")
@@ -130,13 +139,19 @@ def main() -> int:
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--entry", required=True)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--define", action="append", default=[],
help="Slang preprocessor definition, NAME or NAME=VALUE")
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".shader-", dir=args.output) as temporary:
directory = Path(temporary)
spirv = directory / f"{args.entry}.spv"
raw = directory / f"{args.entry}.slang-reflection.json"
process = subprocess.run([args.compiler, str(args.source), "-entry", args.entry, "-target", "spirv", "-profile", "spirv_1_6", "-matrix-layout-column-major", "-o", str(spirv), "-reflection-json", str(raw)])
command = [args.compiler, str(args.source), "-entry", args.entry, "-target", "spirv",
"-profile", "spirv_1_6", "-matrix-layout-column-major"]
command += [f"-D{definition}" for definition in args.define]
command += ["-o", str(spirv), "-reflection-json", str(raw)]
process = subprocess.run(command)
if process.returncode:
return process.returncode
normalized = normalize(json.loads(raw.read_text(encoding="utf-8")), spirv.read_bytes(), args.entry)