diff --git a/.github/workflows/build-reusable.yml b/.github/workflows/build-reusable.yml index 1fb1577..8a5cc14 100644 --- a/.github/workflows/build-reusable.yml +++ b/.github/workflows/build-reusable.yml @@ -167,6 +167,14 @@ jobs: # shellcheck disable=SC2086 # intentional word-splitting for flag vars # vnesc builds static archives; CACHE_LIB_TYPE only selects the build/ dir name. + # + # Dawn/Tint OOMs GitHub-hosted runners when every matrix cell builds it. + # Default CI to Tint OFF; opt in per-job with -DVNE_SC_TINT=ON in extra_cmake_flags. + TINT_FLAG="-DVNE_SC_TINT=OFF" + if [[ "$EXTRA_FLAGS" == *VNE_SC_TINT* ]]; then + TINT_FLAG="" + fi + cmake -B "$BUILD_DIR" -S . \ -DCMAKE_BUILD_TYPE="$EFFECTIVE_BUILD_TYPE" \ -DVNE_SC_CI=ON \ @@ -174,11 +182,17 @@ jobs: -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ ${COVERAGE_FLAG} \ ${COMPILER_FLAGS} \ + ${TINT_FLAG} \ ${EXTRA_FLAGS} - name: Build shell: bash - run: cmake --build "$BUILD_DIR" --config "$EFFECTIVE_BUILD_TYPE" --parallel + run: | + # Unconstrained --parallel OOMs while compiling Dawn/Tint + Abseil + # (Linux "Killed", exit 143). Cap CI concurrency for memory headroom. + JOBS="${CMAKE_BUILD_PARALLEL_LEVEL:-2}" + echo "Building with --parallel ${JOBS}" + cmake --build "$BUILD_DIR" --config "$EFFECTIVE_BUILD_TYPE" --parallel "${JOBS}" - name: Verify coverage instrumentation (coverage builds only, Linux) if: runner.os == 'Linux' && inputs.enable_coverage diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8273f04..238d230 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,6 +219,8 @@ jobs: build_type: Release enable_coverage: false warnings_as_errors: true + # Sole Tint-on matrix cell (reusable defaults -DVNE_SC_TINT=OFF). + extra_cmake_flags: "-DVNE_SC_TINT=ON" lib_type: static secrets: inherit @@ -318,8 +320,9 @@ jobs: -DCMAKE_C_COMPILER=clang \ -DCMAKE_CXX_COMPILER=clang++ \ -DVNE_SC_CI=ON \ + -DVNE_SC_TINT=ON \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON - cmake --build build/static --config Release --parallel + cmake --build build/static --config Release --parallel 2 fi - name: Run clang-tidy diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 86ee429..95d709b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -69,8 +69,10 @@ jobs: cmake -B build/static -S . \ -DCMAKE_BUILD_TYPE=Release \ -DVNE_SC_CI=ON \ + -DVNE_SC_TINT=OFF \ -DWARNINGS_AS_ERRORS=OFF - cmake --build build/static --config Release --parallel + # Cap parallelism: Dawn/Tint + Abseil OOMs under full --parallel. + cmake --build build/static --config Release --parallel 2 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/CMakeLists.txt b/CMakeLists.txt index ee0d26b..1714324 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -137,7 +137,7 @@ option(VNE_SC_GLSLANG "Enable glslang GLSL->SPIR-V front-end (Phase 1, primar option(VNE_SC_DXC "Enable DXC HLSL->SPIR-V front-end (Phase 2 - stub only)" OFF) option(VNE_SC_SLANG "Enable Slang front-end (Phase 3 - stub only)" OFF) option(VNE_SC_SPIRVTOOLS "Enable SPIRV-Tools SPIR-V validator (OFF = no-op validator)" OFF) -option(VNE_SC_TINT "Enable Dawn Tint SPIR-V->WGSL cross-compiler (slow first configure)" OFF) +option(VNE_SC_TINT "Enable Dawn Tint SPIR-V->WGSL cross-compiler (slow first configure)" ON) option(VNE_SC_JSON "Enable nlohmann/json for manifests and bundle metadata" ON) option(ENABLE_DOXYGEN "Enable Doxygen documentation builds" OFF) diff --git a/README.md b/README.md index d8b101d..b917993 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ A compiled `.vneshader` is a directory: | File | Contents | |------|----------| | `bundle.header` | Binary index: stage list, entry points, file names | -| `reflection.bin` | Binary `ProgramReflection` (v2 format) | +| `reflection.bin` | Binary `ProgramReflection` (v3 format) | | `manifest.json` | Human-readable index (when `VNE_SC_JSON` is enabled) | | `.spv` | SPIR-V bytecode | | `.msl` | MSL source (when `msl` target requested) | diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index c4e8199..8821a6d 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -267,16 +267,61 @@ if(VNE_SC_TINT) # library that has negligible benefit from debug symbols. set(_vne_sc_saved_build_type "${CMAKE_BUILD_TYPE}") set(CMAKE_BUILD_TYPE "Release" CACHE STRING "" FORCE) + # Pin is fixed (chromium/6723). Skip ExternalProject update on reconfigure when + # that revision is already present: modern Git defaults to + # fetch.recurseSubmodules=on-demand, and Dawn's DAWN_FETCH_DEPENDENCIES + # checkouts often lack an `origin` remote, so a plain `git fetch` on dawn-src + # fails mid-configure. If the shallow checkout is missing the pin, wipe it so + # FetchContent re-clones (still without submodule recursion). + set(_vne_sc_dawn_tag "chromium/6723") + set(_vne_sc_dawn_src "${CMAKE_BINARY_DIR}/_deps/dawn-src") + if(EXISTS "${_vne_sc_dawn_src}/.git") + find_package(Git QUIET) + if(Git_FOUND) + execute_process( + COMMAND "${GIT_EXECUTABLE}" rev-parse --verify --quiet "${_vne_sc_dawn_tag}^{commit}" + WORKING_DIRECTORY "${_vne_sc_dawn_src}" + RESULT_VARIABLE _vne_sc_dawn_tag_ok + OUTPUT_QUIET + ERROR_QUIET) + set(_vne_sc_dawn_head "") + set(_vne_sc_dawn_wanted "") + if(_vne_sc_dawn_tag_ok EQUAL 0) + execute_process( + COMMAND "${GIT_EXECUTABLE}" rev-parse HEAD + WORKING_DIRECTORY "${_vne_sc_dawn_src}" + OUTPUT_VARIABLE _vne_sc_dawn_head + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + execute_process( + COMMAND "${GIT_EXECUTABLE}" rev-parse "${_vne_sc_dawn_tag}^{commit}" + WORKING_DIRECTORY "${_vne_sc_dawn_src}" + OUTPUT_VARIABLE _vne_sc_dawn_wanted + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + endif() + if(NOT _vne_sc_dawn_tag_ok EQUAL 0 OR NOT _vne_sc_dawn_head STREQUAL _vne_sc_dawn_wanted) + message(STATUS + "[vnesc] Dawn checkout missing or not at ${_vne_sc_dawn_tag}; " + "removing for FetchContent re-populate") + file(REMOVE_RECURSE + "${CMAKE_BINARY_DIR}/_deps/dawn-src" + "${CMAKE_BINARY_DIR}/_deps/dawn-build" + "${CMAKE_BINARY_DIR}/_deps/dawn-subbuild") + endif() + endif() + endif() FetchContent_Declare(dawn - GIT_REPOSITORY https://dawn.googlesource.com/dawn - GIT_TAG chromium/6723 - GIT_SHALLOW TRUE - GIT_SUBMODULES "" - GIT_SUBMODULES_RECURSE FALSE) + GIT_REPOSITORY https://dawn.googlesource.com/dawn + GIT_TAG ${_vne_sc_dawn_tag} + GIT_SHALLOW TRUE + GIT_SUBMODULES "" + GIT_SUBMODULES_RECURSE FALSE + UPDATE_DISCONNECTED TRUE) FetchContent_MakeAvailable(dawn) _vne_sc_fix_dawn_abseil_randen_copts() set(CMAKE_BUILD_TYPE "${_vne_sc_saved_build_type}" CACHE STRING "" FORCE) - message(STATUS "[vnesc] Dawn/Tint -> FetchContent chromium/6723 (Release build)") + message(STATUS "[vnesc] Dawn/Tint -> FetchContent ${_vne_sc_dawn_tag} (Release build)") endif() #============================================================================== @@ -310,6 +355,11 @@ function(vne_sc_link_tint target) message(FATAL_ERROR "vne_sc_link_tint: no libtint or tint target found from Dawn") endif() target_compile_definitions(${target} PRIVATE VNE_SC_TINT_ENABLED) + # Tint BlockAllocator uses alignas; MSVC C4324 (structure padded) is expected + # and must not fail WARNINGS_AS_ERRORS=/WX when compiling our Tint wrappers. + if(MSVC) + target_compile_options(${target} PRIVATE /wd4324) + endif() endfunction() #============================================================================== diff --git a/include/vertexnova/sc/sc_types.h b/include/vertexnova/sc/sc_types.h index 21dfb95..05457e5 100644 --- a/include/vertexnova/sc/sc_types.h +++ b/include/vertexnova/sc/sc_types.h @@ -185,6 +185,10 @@ struct ResourceBackendSlots { }; /// Classifies a reflected shader resource. +/// +/// @note Values must stay numerically identical to `vne::rhi::ReflectedResourceType` +/// (vnerhi/include/vertexnova/rhi/shader_reflection.h). `reflection.bin` stores the raw +/// value and vnerhi casts it straight across. enum class ReflectedResourceType : uint8_t { eUniformBuffer = 0, eStorageBuffer = 1, @@ -194,6 +198,10 @@ enum class ReflectedResourceType : uint8_t { ePushConstant = 5, eCombinedImageSampler = 6, eSampledCubemap = 7, + eSampled2DArray = 8, ///< texture2DArray / texture_2d_array + eSampledCubeArray = 9, ///< textureCubeArray / texture_cube_array + eReadOnlyStorageBuffer = 10, ///< SSBO declared readonly (WGSL `storage, read`) + eSampledImage3D = 11, ///< texture3D / texture_3d }; /// A single member of a reflected struct (e.g. a field inside a uniform block). @@ -219,6 +227,18 @@ struct ReflectedBindingInfo { ShaderStageFlags stages = ShaderStageFlags::eNone; ResourceBackendSlots slots; std::vector struct_members; ///< Non-empty for buffer types. + + // ----- Fields WebGPU needs to build a bind-group layout (reflection.bin v3) ----- + /// Storage-texture format, e.g. "rgba16float". Empty for non-storage-image bindings. + std::string storage_format_hint; + /// Storage-texture access: "read", "write", or "read_write". Empty when not applicable. + std::string storage_access_hint; + /// True for multisampled textures (`texture2DMS`, WGSL `texture_multisampled_2d`). + bool multisampled = false; + /// True for depth textures (WGSL `texture_depth_2d` and friends) -- drives the sample type. + bool depth_texture = false; + /// The buffer is re-offset per draw. Not derivable from SPIR-V; set by tooling/renderer. + bool dynamic_offset = false; }; /// Workgroup size for compute shaders. diff --git a/include/vertexnova/sc/shader_pipeline_builder.h b/include/vertexnova/sc/shader_pipeline_builder.h index f1d6b26..a099a49 100644 --- a/include/vertexnova/sc/shader_pipeline_builder.h +++ b/include/vertexnova/sc/shader_pipeline_builder.h @@ -72,7 +72,8 @@ struct PipelineBuildResult { * Per stage the pipeline is: * -# Cache lookup (key includes a source-level program fingerprint for MSL dense maps). * -# On miss: @ref IShaderFrontEnd::compile, optional validate, reflect, cross-compile, store. - * -# On hit: reuse the cached @ref StageArtifact (no front-end compile). + * -# On hit: reuse the cached @ref StageArtifact when it covers every requested + * target (no front-end compile); incomplete entries are rebuilt. * * When any stage misses and MSL dense program maps are enabled, one shared * @ref MetalBindingAllocator is built from the union of all stage SPIR-V diff --git a/include/vertexnova/sc/shader_reflection_binary.h b/include/vertexnova/sc/shader_reflection_binary.h index 185c1ab..30aa671 100644 --- a/include/vertexnova/sc/shader_reflection_binary.h +++ b/include/vertexnova/sc/shader_reflection_binary.h @@ -13,6 +13,12 @@ /** * @file shader_reflection_binary.h * @brief Binary serialization for @ref StageReflection and @ref ProgramReflection. + * + * @c ProgramReflection blobs (@c reflection.bin) use format version 3.0.0 + * (@c 0x00030000). Deserialization requires an exact version match; regenerate + * stale bundles. Version 3 stores the WebGPU bind-group fields on each binding + * (@c storage_format_hint, @c storage_access_hint, @c multisampled, + * @c depth_texture, @c dynamic_offset). */ #include "sc_types.h" diff --git a/src/vertexnova/sc/frontend/glslang_frontend.cpp b/src/vertexnova/sc/frontend/glslang_frontend.cpp index 00333ba..c7fceb3 100644 --- a/src/vertexnova/sc/frontend/glslang_frontend.cpp +++ b/src/vertexnova/sc/frontend/glslang_frontend.cpp @@ -28,7 +28,7 @@ namespace { constexpr std::streamsize kMaxShaderFileBytes = 64 * 1024 * 1024; -struct GlslangProcessLifetime { +struct GlslangProcessGuard { std::mutex mutex; int refs = 0; bool process_ok = false; @@ -57,9 +57,9 @@ struct GlslangProcessLifetime { } }; -GlslangProcessLifetime& glslangProcessLifetime() { - static GlslangProcessLifetime s_lifetime; - return s_lifetime; +GlslangProcessGuard& glslangProcessGuard() { + static GlslangProcessGuard s_guard; + return s_guard; } bool readBinaryFile(std::ifstream& file, std::string& out) { @@ -183,7 +183,7 @@ std::string buildPreamble(const std::vector& macros) { namespace vne::sc { GlslangFrontEnd::GlslangFrontEnd() - : initialized_(glslangProcessLifetime().acquire()) { + : initialized_(glslangProcessGuard().acquire()) { if (!initialized_) { VNE_LOG_ERROR << "GlslangFrontEnd: glslang::InitializeProcess() failed"; } else { @@ -193,7 +193,7 @@ GlslangFrontEnd::GlslangFrontEnd() GlslangFrontEnd::~GlslangFrontEnd() { if (initialized_) { - glslangProcessLifetime().release(); + glslangProcessGuard().release(); initialized_ = false; } } diff --git a/src/vertexnova/sc/gpu_layout_tools.cpp b/src/vertexnova/sc/gpu_layout_tools.cpp index 2b4da88..9aebcb3 100644 --- a/src/vertexnova/sc/gpu_layout_tools.cpp +++ b/src/vertexnova/sc/gpu_layout_tools.cpp @@ -244,6 +244,18 @@ std::string emitSingleBinding(const ReflectedBindingInfo& binding) { } out << "} " << blockNameToInstance(binding.name) << ";\n\n"; break; + case ReflectedResourceType::eReadOnlyStorageBuffer: + out << "layout(std430, set = " << binding.set << ", binding = " << binding.binding << ") readonly buffer " + << binding.name << " {\n"; + if (binding.struct_members.empty()) { + out << " float data[];\n"; + } else { + for (const auto& member : binding.struct_members) { + emitMemberDecl(out, memberGlslType(member), member); + } + } + out << "} " << blockNameToInstance(binding.name) << ";\n\n"; + break; case ReflectedResourceType::eSampler: out << "layout(set = " << binding.set << ", binding = " << binding.binding << ") uniform sampler " << binding.name << ";\n\n"; @@ -252,10 +264,22 @@ std::string emitSingleBinding(const ReflectedBindingInfo& binding) { out << "layout(set = " << binding.set << ", binding = " << binding.binding << ") uniform texture2D " << binding.name << ";\n\n"; break; + case ReflectedResourceType::eSampled2DArray: + out << "layout(set = " << binding.set << ", binding = " << binding.binding << ") uniform texture2DArray " + << binding.name << ";\n\n"; + break; case ReflectedResourceType::eSampledCubemap: out << "layout(set = " << binding.set << ", binding = " << binding.binding << ") uniform textureCube " << binding.name << ";\n\n"; break; + case ReflectedResourceType::eSampledCubeArray: + out << "layout(set = " << binding.set << ", binding = " << binding.binding << ") uniform textureCubeArray " + << binding.name << ";\n\n"; + break; + case ReflectedResourceType::eSampledImage3D: + out << "layout(set = " << binding.set << ", binding = " << binding.binding << ") uniform texture3D " + << binding.name << ";\n\n"; + break; case ReflectedResourceType::eCombinedImageSampler: out << "layout(set = " << binding.set << ", binding = " << binding.binding << ") uniform sampler2D " << binding.name << ";\n\n"; diff --git a/src/vertexnova/sc/reflector/spirvcross_reflector.cpp b/src/vertexnova/sc/reflector/spirvcross_reflector.cpp index 2745478..44fff19 100644 --- a/src/vertexnova/sc/reflector/spirvcross_reflector.cpp +++ b/src/vertexnova/sc/reflector/spirvcross_reflector.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -57,6 +58,76 @@ std::vector reflectStructMembers(const spirv_cro return members; } +// Maps a SPIR-V image format to the WGSL storage-texture format name WebGPU expects. +// Throws on Unknown / unsupported formats so storage_format_hint is never empty. +[[nodiscard]] std::string imageFormatName(spv::ImageFormat fmt) { + switch (fmt) { + case spv::ImageFormatRgba32f: + return "rgba32float"; + case spv::ImageFormatRgba16f: + return "rgba16float"; + case spv::ImageFormatR32f: + return "r32float"; + case spv::ImageFormatR16f: + return "r16float"; + case spv::ImageFormatRgba8: + return "rgba8unorm"; + case spv::ImageFormatRgba8Snorm: + return "rgba8snorm"; + case spv::ImageFormatRg32f: + return "rg32float"; + case spv::ImageFormatRg16f: + return "rg16float"; + case spv::ImageFormatRgba32i: + return "rgba32sint"; + case spv::ImageFormatRgba16i: + return "rgba16sint"; + case spv::ImageFormatRgba8i: + return "rgba8sint"; + case spv::ImageFormatR32i: + return "r32sint"; + case spv::ImageFormatRg32i: + return "rg32sint"; + case spv::ImageFormatRg16i: + return "rg16sint"; + case spv::ImageFormatR16i: + return "r16sint"; + case spv::ImageFormatR8i: + return "r8sint"; + case spv::ImageFormatRgba32ui: + return "rgba32uint"; + case spv::ImageFormatRgba16ui: + return "rgba16uint"; + case spv::ImageFormatRgba8ui: + return "rgba8uint"; + case spv::ImageFormatR32ui: + return "r32uint"; + case spv::ImageFormatRg32ui: + return "rg32uint"; + case spv::ImageFormatRg16ui: + return "rg16uint"; + case spv::ImageFormatR16ui: + return "r16uint"; + case spv::ImageFormatR8ui: + return "r8uint"; + case spv::ImageFormatR8: + return "r8unorm"; + case spv::ImageFormatRg8: + return "rg8unorm"; + case spv::ImageFormatR8Snorm: + return "r8snorm"; + case spv::ImageFormatRg8Snorm: + return "rg8snorm"; + case spv::ImageFormatRg8i: + return "rg8sint"; + case spv::ImageFormatRg8ui: + return "rg8uint"; + default: + throw std::runtime_error("SpirvCrossReflector: unsupported SPIR-V storage-image format (" + + std::to_string(static_cast(fmt)) + ")"); + } +} + // Populate one ReflectedBindingInfo from a SPIRV-Cross resource template void appendBinding(const spirv_cross::Compiler& compiler, @@ -73,10 +144,60 @@ void appendBinding(const spirv_cross::Compiler& compiler, const spirv_cross::SPIRType& ty = compiler.get_type(res.type_id); const uint32_t array_size = ty.array.empty() ? 1u : (ty.array[0] == 0u ? 0u : ty.array[0]); + // Refine the coarse "which SPIRV-Cross list did this come from" type using the actual + // image dimensionality. WebGPU needs the exact texture kind to build a bind-group layout + // (texture_2d vs texture_2d_array vs texture_3d vs texture_cube[_array], plus depth and + // multisampled), and previously only the DimCube case was handled -- which is why vnerhi's + // WebGPU backend re-parsed the emitted WGSL instead of trusting reflection.bin. ReflectedResourceType actual_type = Type; - if constexpr (Type == ReflectedResourceType::eSampledImage) { - if (ty.image.dim == spv::DimCube) { - actual_type = ReflectedResourceType::eSampledCubemap; + std::string storage_format_hint; + std::string storage_access_hint; + bool multisampled = false; + bool depth_texture = false; + + if constexpr (Type == ReflectedResourceType::eSampledImage + || Type == ReflectedResourceType::eCombinedImageSampler) { + const bool arrayed = ty.image.arrayed; + switch (ty.image.dim) { + case spv::DimCube: + actual_type = + arrayed ? ReflectedResourceType::eSampledCubeArray : ReflectedResourceType::eSampledCubemap; + break; + case spv::Dim3D: + actual_type = ReflectedResourceType::eSampledImage3D; + break; + case spv::Dim2D: + if (arrayed) { + actual_type = ReflectedResourceType::eSampled2DArray; + } + break; + default: + break; + } + multisampled = ty.image.ms; + depth_texture = ty.image.depth; + } + + if constexpr (Type == ReflectedResourceType::eStorageImage) { + storage_format_hint = imageFormatName(ty.image.format); + const bool non_writable = compiler.has_decoration(res.id, spv::DecorationNonWritable); + const bool non_readable = compiler.has_decoration(res.id, spv::DecorationNonReadable); + if (non_writable && !non_readable) { + storage_access_hint = "read"; + } else if (non_readable && !non_writable) { + storage_access_hint = "write"; + } else { + storage_access_hint = "read_write"; + } + multisampled = ty.image.ms; + } + + if constexpr (Type == ReflectedResourceType::eStorageBuffer) { + // A readonly SSBO must be declared as `storage, read` in WGSL, and is the only storage + // buffer form WebGPU allows in a vertex stage. + const spirv_cross::Bitset flags = compiler.get_buffer_block_flags(res.id); + if (flags.get(spv::DecorationNonWritable)) { + actual_type = ReflectedResourceType::eReadOnlyStorageBuffer; } } @@ -137,6 +258,10 @@ void appendBinding(const spirv_cross::Compiler& compiler, info.array_size = array_size; info.stages = ShaderStageFlags::eNone; info.slots = slots; + info.storage_format_hint = std::move(storage_format_hint); + info.storage_access_hint = std::move(storage_access_hint); + info.multisampled = multisampled; + info.depth_texture = depth_texture; if constexpr (Type == ReflectedResourceType::eUniformBuffer || Type == ReflectedResourceType::eStorageBuffer) { try { diff --git a/src/vertexnova/sc/shader_artifact_cache.cpp b/src/vertexnova/sc/shader_artifact_cache.cpp index 32d5ddb..52cf6f5 100644 --- a/src/vertexnova/sc/shader_artifact_cache.cpp +++ b/src/vertexnova/sc/shader_artifact_cache.cpp @@ -30,6 +30,9 @@ namespace { constexpr uint64_t kFnv1a64Prime = 0x100000001b3ULL; constexpr uint64_t kFnv1a64Offset = 0xcbf29ce484222325ULL; constexpr int kCacheKeyHexWidth = 16; +// Bumped when failure policy became fatal for reflection / cross-compile so +// stale incomplete .vnca entries are not treated as hits. +constexpr uint32_t kArtifactCacheSchemaVersion = 1; uint64_t fnv1a64(const void* data, size_t len) noexcept { uint64_t hash = kFnv1a64Offset; @@ -92,11 +95,12 @@ struct Reader { }; // StageArtifact binary format -// stage(u8) | entry_point(str) | spirv_count(u32) | spirv_data | +// schema(u32) | stage(u8) | entry_point(str) | spirv_count(u32) | spirv_data | // StageReflection | cross_count(u32) | { target(u8) | source(str) | ep(str) } * N std::string serializeArtifact(const StageArtifact& a) { Writer w; + w.u32(kArtifactCacheSchemaVersion); w.u8(static_cast(a.stage)); w.str(a.entry_point); w.u32(static_cast(a.spirv.size())); @@ -118,6 +122,10 @@ std::string serializeArtifact(const StageArtifact& a) { bool deserializeArtifact(const std::string& data, StageArtifact& out) { try { Reader r(data); + const uint32_t schema = r.u32(); + if (schema != kArtifactCacheSchemaVersion) { + return false; + } out.stage = static_cast(r.u8()); out.entry_point = r.str(); const uint32_t spirv_count = r.u32(); diff --git a/src/vertexnova/sc/shader_bundle.cpp b/src/vertexnova/sc/shader_bundle.cpp index 4a8180c..ee6cbe4 100644 --- a/src/vertexnova/sc/shader_bundle.cpp +++ b/src/vertexnova/sc/shader_bundle.cpp @@ -245,6 +245,14 @@ bool writeShaderBundle(const ShaderArtifact& artifact, const std::filesystem::pa return "combined_image_sampler"; case ReflectedResourceType::eSampledCubemap: return "sampled_cubemap"; + case ReflectedResourceType::eSampled2DArray: + return "sampled_2d_array"; + case ReflectedResourceType::eSampledCubeArray: + return "sampled_cube_array"; + case ReflectedResourceType::eReadOnlyStorageBuffer: + return "readonly_storage_buffer"; + case ReflectedResourceType::eSampledImage3D: + return "sampled_image_3d"; } return "unknown"; }; @@ -272,6 +280,7 @@ bool writeShaderBundle(const ShaderArtifact& artifact, const std::filesystem::pa switch (b.type) { case ReflectedResourceType::eUniformBuffer: case ReflectedResourceType::eStorageBuffer: + case ReflectedResourceType::eReadOnlyStorageBuffer: jm["buffer"] = b.slots.metal->buffer; break; case ReflectedResourceType::eCombinedImageSampler: @@ -280,6 +289,9 @@ bool writeShaderBundle(const ShaderArtifact& artifact, const std::filesystem::pa break; case ReflectedResourceType::eSampledImage: case ReflectedResourceType::eSampledCubemap: + case ReflectedResourceType::eSampled2DArray: + case ReflectedResourceType::eSampledCubeArray: + case ReflectedResourceType::eSampledImage3D: case ReflectedResourceType::eStorageImage: jm["texture"] = b.slots.metal->texture; break; diff --git a/src/vertexnova/sc/shader_pipeline_builder.cpp b/src/vertexnova/sc/shader_pipeline_builder.cpp index 1b74f15..0ce0d8b 100644 --- a/src/vertexnova/sc/shader_pipeline_builder.cpp +++ b/src/vertexnova/sc/shader_pipeline_builder.cpp @@ -28,6 +28,25 @@ CREATE_VNE_LOGGER_CATEGORY("vne.sc.pipeline") namespace vne::sc { +namespace { + +// Reject cache entries that lack output for every requested target. +// Complements the .vnca schema version: even current-schema entries must be +// complete before Phase B (reflect / cross-compile) is skipped. +bool cachedArtifactReady(const StageArtifact& artifact, const std::vector& targets) { + if (artifact.spirv.empty()) { + return false; + } + for (const CrossTarget target : targets) { + const CrossCompiledSource* cc = artifact.findCrossCompiled(target); + if (cc == nullptr || cc->source.empty()) { + return false; + } + } + return true; +} + +} // namespace ShaderPipelineBuilder::ShaderPipelineBuilder(std::shared_ptr front_end, std::shared_ptr cross_compiler, @@ -96,11 +115,15 @@ PipelineBuildResult ShaderPipelineBuilder::build(const PipelineBuildDesc& desc) ShaderArtifactCache::makeKey(sw.req, desc.targets, desc.metal_layout, metal_program_fingerprint); auto cached = cache->lookup(sw.cache_key); if (cached.has_value()) { - VNE_LOG_DEBUG << "ShaderPipelineBuilder: cache hit for stage " << static_cast(sw.req.stage); - sw.artifact = std::move(*cached); - sw.needs_backend = false; - work.push_back(std::move(sw)); - continue; + if (cachedArtifactReady(*cached, desc.targets)) { + VNE_LOG_DEBUG << "ShaderPipelineBuilder: cache hit for stage " << static_cast(sw.req.stage); + sw.artifact = std::move(*cached); + sw.needs_backend = false; + work.push_back(std::move(sw)); + continue; + } + VNE_LOG_DEBUG << "ShaderPipelineBuilder: ignoring incomplete cache entry for stage " + << static_cast(sw.req.stage); } } @@ -189,7 +212,10 @@ PipelineBuildResult ShaderPipelineBuilder::build(const PipelineBuildDesc& desc) if (rr.ok()) { sw.artifact.reflection = std::move(rr.reflection); } else { - VNE_LOG_WARN << "ShaderPipelineBuilder: reflection failed (non-fatal): " << rr.error; + result.code = rr.code; + result.error = "ShaderPipelineBuilder: reflection failed: " + rr.error; + VNE_LOG_ERROR << result.error; + return result; } } @@ -220,15 +246,11 @@ PipelineBuildResult ShaderPipelineBuilder::build(const PipelineBuildDesc& desc) cc.entry_point = std::move(ccres.entry_point); sw.artifact.cross_compiled.push_back(std::move(cc)); } else { - if (target == CrossTarget::eWGSL) { - VNE_LOG_WARN << "ShaderPipelineBuilder: WGSL cross-compile failed (non-fatal): " << ccres.error; - } else { - result.code = ccres.code; - result.error = "ShaderPipelineBuilder: cross-compile to target " - + std::to_string(static_cast(target)) + " failed: " + ccres.error; - VNE_LOG_ERROR << result.error; - return result; - } + result.code = ccres.code; + result.error = "ShaderPipelineBuilder: cross-compile to target " + + std::to_string(static_cast(target)) + " failed: " + ccres.error; + VNE_LOG_ERROR << result.error; + return result; } } } diff --git a/src/vertexnova/sc/shader_reflection_binary.cpp b/src/vertexnova/sc/shader_reflection_binary.cpp index be5efd6..cb15cf7 100644 --- a/src/vertexnova/sc/shader_reflection_binary.cpp +++ b/src/vertexnova/sc/shader_reflection_binary.cpp @@ -15,7 +15,18 @@ namespace { -constexpr uint32_t kReflectionBinaryVersion = 2u; // bumped: BackendSlot -> ResourceBackendSlots +// reflection.bin format version, v3.0.0, packed into the single u32 the header already +// carries: 0xMMmmpp (major, minor, patch). Readers require an exact match -- a stale bundle +// fails loudly and is regenerated rather than silently half-read. +// v3 adds WebGPU bind-group fields on each binding: storage_format_hint, +// storage_access_hint, multisampled, depth_texture, dynamic_offset. +// Keep in lockstep with vnerhi's shader_bundle_loader.cpp. +// (Independent of bundle.header's own version -- see shader_bundle.cpp.) +constexpr uint32_t kReflectionBinaryVersionMajor = 3u; +constexpr uint32_t kReflectionBinaryVersionMinor = 0u; +constexpr uint32_t kReflectionBinaryVersionPatch = 0u; +constexpr uint32_t kReflectionBinaryVersion = + (kReflectionBinaryVersionMajor << 16U) | (kReflectionBinaryVersionMinor << 8U) | kReflectionBinaryVersionPatch; constexpr uint32_t kMaxStringLen = 4096; constexpr uint32_t kMaxProgramStageCount = 16; constexpr uint32_t kMaxBindingCount = 256; @@ -226,6 +237,12 @@ void writeReflectedBindingInfo(Writer& w, const vne::sc::ReflectedBindingInfo& b for (const auto& m : b.struct_members) { writeReflectedStructMember(w, m); } + // ---- reflection.bin v3 WebGPU bind-group fields ---- + w.str(b.storage_format_hint); + w.str(b.storage_access_hint); + w.boolean(b.multisampled); + w.boolean(b.depth_texture); + w.boolean(b.dynamic_offset); } bool readReflectedBindingInfo(Reader& r, vne::sc::ReflectedBindingInfo& b) { @@ -254,6 +271,12 @@ bool readReflectedBindingInfo(Reader& r, vne::sc::ReflectedBindingInfo& b) { return false; } } + // ---- reflection.bin v3 WebGPU bind-group fields (required; version gate is exact) ---- + b.storage_format_hint = r.str(); + b.storage_access_hint = r.str(); + b.multisampled = r.boolean(); + b.depth_texture = r.boolean(); + b.dynamic_offset = r.boolean(); return r.ok(); } diff --git a/tests/shader_pipeline_builder_test.cpp b/tests/shader_pipeline_builder_test.cpp index d3748d3..41a844f 100644 --- a/tests/shader_pipeline_builder_test.cpp +++ b/tests/shader_pipeline_builder_test.cpp @@ -212,6 +212,10 @@ TEST_F(ShaderPipelineBuilderTest, MultiSetMetalSlotsAgreeAcrossStages) { } TEST_F(ShaderPipelineBuilderTest, WgslLeavesVulkanSetBindingIdentity) { +#ifndef VNE_SC_TINT_ENABLED + GTEST_SKIP() << "Tint not enabled (configure with -DVNE_SC_TINT=ON)"; +#endif + auto builder = makeGlslPipelineBuilder(); ASSERT_NE(builder, nullptr); @@ -231,4 +235,24 @@ TEST_F(ShaderPipelineBuilderTest, WgslLeavesVulkanSetBindingIdentity) { EXPECT_EQ(vs->reflection.bindings[0].binding, 0u); } +TEST_F(ShaderPipelineBuilderTest, WgslTargetFailsWhenTintUnavailable) { +#ifndef VNE_SC_TINT_ENABLED + auto builder = makeGlslPipelineBuilder(); + ASSERT_NE(builder, nullptr); + + PipelineBuildDesc desc; + desc.name = "wgsl_unavailable"; + desc.validate = false; + desc.use_cache = false; + desc.targets = {CrossTarget::eWGSL}; + desc.stages.push_back(makeVertexRequest()); + + auto result = builder->build(desc); + EXPECT_FALSE(result.ok()); + EXPECT_EQ(result.code, ResultCode::eUnavailable); +#else + GTEST_SKIP() << "Tint is enabled; unavailable path not exercised"; +#endif +} + } // namespace vne::sc::test diff --git a/tools/compile_shaders.py b/tools/compile_shaders.py index ca418c0..0075779 100755 --- a/tools/compile_shaders.py +++ b/tools/compile_shaders.py @@ -39,6 +39,8 @@ # --------------------------------------------------------------------------- _BUNDLE_MAGIC = b"VNSH" +# bundle.header format version. Independent of reflection.bin's own version +# (see shader_reflection_binary.cpp) -- the two are bumped separately. _BUNDLE_VERSION = 2