Add checked Slang shaders for GPU visibility and HZB
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user