Skip to content

fix: correct unordered access binding, storage image formats and subresource copies on Vulkan - #3311

Open
sasvdw wants to merge 5 commits into
stride3d:masterfrom
LazyWorksZA:fix/vulkan-uav-buffer-binding
Open

fix: correct unordered access binding, storage image formats and subresource copies on Vulkan#3311
sasvdw wants to merge 5 commits into
stride3d:masterfrom
LazyWorksZA:fix/vulkan-uav-buffer-binding

Conversation

@sasvdw

@sasvdw sasvdw commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

PR Details

Compute shaders that bind an unordered access view produced wrong results on Vulkan. Five separate defects caused this, and every one of them failed silently. The shader wrote to a harmless place, and the reads returned empty data.

Three tests that were disabled because of these defects now run again.

1. RWBuffer<T> bound as a storage image

ConvertDescriptorType mapped EffectParameterType.RWBuffer to VkDescriptorType.StorageImage. The bind path runs heapObject.Value as Texture, which returns null for a Buffer, and falls back to GraphicsDevice.EmptyTexture. Every dispatch wrote into a dummy image.

The type now maps to StorageTexelBuffer. The descriptor write joins the existing UniformTexelBuffer case, because both use pTexelBufferView. The two changes must land together, or the default: case throws.

2. Unordered-access-only textures produced no image view

GetImageView returned VkImageView.Null unless the texture had IsShaderResource. A texture created with only TextureFlags.UnorderedAccess therefore also bound EmptyTexture.

Relaxing that guard alone would add a second defect. The view usage was hardcoded to Sampled, and such an image never carries that flag. The view usage now comes from the flags the texture actually has.

3. Buffers and textures never advertised the compute stage

About twenty call sites pass NativePipelineStageMask to vkCmdPipelineBarrier, but the mask listed only vertex and fragment stages. Uploads and readbacks had no dependency edge against a dispatch. Storage buffers also reported VkAccessFlags.UniformRead instead of ShaderRead. An unordered-access-only texture fell through every branch to VkPipelineStageFlags.None, which is zero and is not a legal stage mask.

4. The SPIR-V emitter inferred a storage image format from the element type

GetStorageImageFormat mapped float4 to Rgba32f. In HLSL the view decides the format, not the element type. RWTexture2D<float4> binds equally to R8G8B8A8_UNorm and R32G32B32A32_Float. The validation layer states the result plainly: "Any loads or stores with the variable will produce undefined values to the whole image."

The emitter now lets the Unknown format flow through. This matches BufferType and matches what DXC emits. ShaderMixer already requests StorageImage{Read,Write}WithoutFormat for an Unknown format, and the device already enables both features.

5. CopyRegion ignored both subresource indices

The method accepted sourceSubresource and destinationSubResource but used neither. It addressed the copy with the texture MipLevel and ArraySlice, and with a layerCount of ArraySize. Two // Review: comments in the file already recorded this. The copy extent still came from the index, so a copy of a non-zero mip read a correctly sized window out of mip 0. Direct3D11 and Direct3D12 pass both indices to the underlying API, and Vulkan now decodes them the same way.

Three callers pass a non-zero index:

  • RadiancePrefilteringGGX and its non-compute variant copy mip 2 of a 1024 cubemap into a 256 output. They read the top-left corner of mip 0 instead.
  • CubemapRendererBase addressed array layer 0 with a layerCount of 6 against a source that has one layer.
  • VoxelStorageTextureClipmap landed every temporary mip on mip 0.

How the fifth defect was found

@Ethereal77 asked whether the images looked similar enough, because a count of differing pixels is a poor indicator on its own. That question found the defect.

The images did not look similar. Every cube face showed the top-left cell of a 4x4 grid, which is a 1024 to 256 crop. The original skip reason was wrong in three ways:

  • It named Lavapipe. The difference also reproduces on an NVIDIA discrete GPU.
  • It named the non-compute path. The compute path fails the same way.
  • It named mip 1. The affected level is mip 0.

TestRadiancePrefilteringGgx now runs on Vulkan with no skip. Mip 0 matches the Direct3D reference with a maximum channel difference of 1, against 249 before.

Tests

TestLambertPrefilteringSHPass2 and TestHammersley were skipped on Vulkan in 2020 with the reason "compute shaders are not supported yet" (19085cb). That was a blanket disable, not a diagnosis. Both pass now.

TestUnorderedAccessOnlyTexture is new. It writes dispatch coordinates into an unordered-access-only texture and asserts every texel. Without these fixes it reads back all zeros.

Per @Ethereal77's review, a test with no skip condition now uses [Fact] instead of [SkippableFact].

Gold images

Vulkan output for the two filtered mip levels of TestRadiancePrefilteringGgx differs from the Direct3D reference by more than the default tolerance. The test-gold-gen.yml workflow generated gold for Windows.Vulkan/Lavapipe and macOS.Vulkan/Apple M1. Linux, Android and iOS needed none, because the runtime fallback covers them. Mip 0 needed none on any platform, because it now matches the existing Direct3D gold.

To answer the question about pixel counts directly, here is the divergence measured against a control that predates this work:

Comparison max diff pixels differing [16+]
D3D11-WARP against D3D12-WARP (control) 1 0.1% 0
Vulkan-Lavapipe against D3D12-WARP 3 15.1% 0
macOS-AppleM1 against D3D12-WARP 6 3.1% 0

The Vulkan difference is uniform across all six cube faces, and it shows no correlation with luminance. This is rounding at the final 8-bit conversion, not a rendering difference. For contrast, mip 0 before the fix measured a maximum difference of 249, with 288,150 pixels in the [16+] bucket.

Verification

Debug build, so the validation layers load. Shader caches cleared between runs.

Suite Vulkan / Lavapipe Direct3D11 / WARP Direct3D12 / WARP
Stride.Graphics.Tests.11_0 (6 tests) 5 passed, 1 skipped 5 passed, 1 skipped 5 passed, 1 skipped
Stride.Graphics.Tests.10_0 (48 tests) 44 passed, 4 skipped 44 passed, 4 skipped 44 passed, 4 skipped

All three backends now report the same result. The remaining skip in 11_0 is TestComputeShader, which has been disabled on every backend since 2018 and is untouched here.

The 10_0 suite is included because the shader compiler change affects every RW texture in the engine. That covers the sixteen LightingTests and the material gold images, across the native SPIR-V path and the SPIRV-Cross to HLSL path.

The GameStudio editor was also built and run from this branch, and the FPS sample renders correctly. On Windows the editor host uses Direct3D11, so this covers the shader compiler change through the SPIRV-Cross to HLSL path, including skybox prefiltering and PBR materials. It does not cover the four Vulkan defects, which the suites above cover.

One caveat, flagged because a reviewer may hit it: TestHammersley on Direct3D12 failed twice during this work. Both failures came on the first Direct3D12 run after another backend had run, and the signature was all 1024 sample points missing. It then passed 8 consecutive full-suite runs, 5 runs in isolation, and a run against cleared shader caches. I could not isolate a trigger, and I have not established whether it relates to the shader compiler change here.

Notes

Two items found along the way. Both are out of scope, and each wants its own issue:

  • EffectCompilerCache keys cached bytecode on the effect input hash, which excludes the shader compiler version. A compiler change silently reuses stale bytecode until obj/stride/assetbuild and the per-target bin/**/cache directories are cleared by hand.
  • RadiancePrefilteringGGX, the compute prefilter, has no reference except a test that never enables it.

Related Issue

Types of changes

  • Docs change / refactoring / dependency upgrade
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • My change requires a change to the documentation.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • I have built and run the editor to try this change out.

Compute shaders binding an unordered access view produced wrong results on
Vulkan, silently, for four reasons:

- RWBuffer<T> mapped to VkDescriptorType.StorageImage, so the bind path cast
  the heap object to Texture, got null, and fell back to EmptyTexture. Now
  mapped to StorageTexelBuffer with the matching descriptor-write case.
- Textures created with UnorderedAccess but no ShaderResource produced no
  image view and also bound EmptyTexture. The view usage is now composed from
  the flags the texture actually has rather than hardcoding Sampled.
- Buffers and textures never advertised the compute pipeline stage, leaving
  uploads and readbacks unordered against a dispatch. Storage buffers also
  reported UniformRead instead of ShaderRead.
- The SPIR-V emitter inferred a storage image format from the shader element
  type, but in HLSL the view determines the format. The inference is removed
  so the type's Unknown format flows through, as BufferType already did.

Re-enables TestLambertPrefilteringSHPass2 and TestHammersley, blanket-skipped
on Vulkan in 2020 as "compute shaders are not supported yet". Adds
TestUnorderedAccessOnlyTexture, which reads back all zeros without these
fixes. TestRadiancePrefilteringGgx keeps its Vulkan skip with an accurate
reason: it exercises the non-compute prefilter and its Lavapipe output
differs from the Direct3D reference at mip 1, which predates this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread sources/engine/Stride.Graphics.Tests.11_0/TestUnorderedAccessOnlyTexture.cs Outdated
@Ethereal77

Copy link
Copy Markdown
Contributor

Looks good to me and makes sense, but as it touches parts of the shader compiler recently rewritten by Xen, I'd like if he also takes a look here.

and its Lavapipe output differs from the Direct3D reference at mip 1 by a margin that is not sampling variance (max diff 249 against a diff of 3 at the other two mips).

Have you checked the resulting images? Apart from having 249+ differing pixels, do they look similar enough? I ask because for some tests, pixel difference counts are not a very good indicator, as little floating point differences between platforms may affect final colors a little bit, but enough to count as different pixel.

@sasvdw

sasvdw commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

I've compared it visually and have been trying to figure out if I can fix the one skipped level as it's looking like a miplevel missmatch that's leading to the 249+ differing pixels.

sasvdw and others added 3 commits August 4, 2026 12:58
CopyRegion accepted a source and a destination subresource index but ignored
both. It addressed the copy with the texture MipLevel and ArraySlice instead.
The extent still came from the index, so a copy of a non-zero mip read a
correctly sized window out of mip 0. Direct3D11 and Direct3D12 pass both
indices to the underlying API. Vulkan now decodes them the same way.

Three callers pass a non-zero index:

- RadiancePrefilteringGGX and its non-compute variant copy mip 2 of a 1024
  cubemap into a 256 output, and read the top-left corner of mip 0 instead.
- CubemapRendererBase addressed array layer 0 with a layerCount of 6 against a
  source with one layer.
- VoxelStorageTextureClipmap landed every temporary mip on mip 0.

This change re-enables TestRadiancePrefilteringGgx on Vulkan. The skip reason
named Lavapipe, the non-compute path and mip 1, and all three were wrong. The
difference also occurs on an NVIDIA GPU, on the compute path, and at mip 0.
Mip 0 now matches the Direct3D reference with a maximum channel difference of
1, against 249 before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestHammersley and TestLambertPrefilteringSHPass2 lost their Vulkan skips
earlier in this branch. TestUnorderedAccessOnlyTexture never had one.
SkippableFact without a Skip call adds only indirection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sasvdw
sasvdw marked this pull request as draft August 4, 2026 17:58
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

🤖 Draft PR — automatic CI is skipped to save runner minutes.

  • Mark the PR ready for review to run the full automatic CI — or add a ci-run-on-draft label to run it now without leaving draft.
  • Or arm a specific opt-in suite: ci-enduser, ci-editor, ci-ios, ci-android.

The two removed XML parameters restated the parameter name. The storage image
format comment now leads with the rule in active voice, because it exists to
stop the inference from being added again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sasvdw sasvdw changed the title fix: correct UAV binding and storage image formats on Vulkan fix: correct unordered access binding, storage image formats and subresource copies on Vulkan Aug 4, 2026
@sasvdw
sasvdw marked this pull request as ready for review August 4, 2026 19:58
@sasvdw

sasvdw commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

@Ethereal77, a close inspection with the CompareGold tool highlighted another bug I was able to pin down and fix where TestRadiancePrefilteringGgx was reading from the wrong mip level.

@Ethereal77

Copy link
Copy Markdown
Contributor

Good thing.

I noticed in your images that the bottom and the back faces of the cubemap show a very noticeable seam. The filtering must be failing there somehow across that seam.

Btw, I've had no opportunity yet to try the new gold image tests infrastructure (it has been recently revamped). I don't know if @xen2 wants reference images to be uploaded to this repo (probably not, as that would increase the LFS size by a lot) or some other place.

@sasvdw

sasvdw commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Btw, I've had no opportunity yet to try the new gold image tests infrastructure (it has been recently revamped). I don't know if @xen2 wants reference images to be uploaded to this repo (probably not, as that would increase the LFS size by a lot) or some other place.

I'll await their feedback here. Didn't spot anything in the GPU-TESTING.md document, and most of the comments in the tooling seem to indicate that gold images need to be put where I've got them. Ultimately, if the better fix is to loosen the divergence thresholds between the D3D12+WARP and the Vulkan+Lavapipe tests, that's easy enough to do.

@Ethereal77

Copy link
Copy Markdown
Contributor

the same seam exists in the D3D12 goldens, so likely a pre-existing issue if there is one

Indeed, I've found the seam in gold images all the way back to 2018! It is a bug in the filtered cubemap should be continuous across all its seams. That discontinuity is a high-frequency difference which the filter has failed to reduce.

A filtered cubemap is "equivalent" to approximate the illumination function over the sphere. The prefiltered levels are just for approximating different roughness levels in a PBR pipeline. The cubemap faces is only a discretization of this sphere, so it must still be continuous. If not, visible artifacts may appear on rough surfaces.

@Ethereal77

Copy link
Copy Markdown
Contributor

Please, can you create an issue for the prefiltering bug? I'm far from my main PC, and as you have recently reproduced the bug and have images to show it...
As I said above, I've found badly generated cubemaps all the way to 2018, so this is an old issue that predates Stride.

@sasvdw

sasvdw commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@Ethereal77 I'll get a bug logged. Very likely I'll just have a look at fixing it as well 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants