Skip to content

feat: bring the Vulkan backend to parity with OpenGL and default to it - #182

Merged
TommyRadan merged 5 commits into
masterfrom
feature/vulkan-mipmap-generation
Jun 16, 2026
Merged

feat: bring the Vulkan backend to parity with OpenGL and default to it#182
TommyRadan merged 5 commits into
masterfrom
feature/vulkan-mipmap-generation

Conversation

@TommyRadan

@TommyRadan TommyRadan commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the remaining functional gaps between the Vulkan and OpenGL backends and makes Vulkan the default, as a step toward eventually deprecating OpenGL. Both backends still compile and the choice remains a runtime switch.

Changes

1. Vulkan mipmap generation (vk_device_texture.cpp)
Previously the Vulkan backend allocated a single mip level and generate_mipmaps was a no-op, so every mipmapped texture (materials, UI panes, cached images, the IBL skybox) sampled a degraded chain versus OpenGL's glGenerateMipmap. Now mipmapped colour textures allocate the full chain up front (when the format advertises a linear blit), and generate_mipmaps fills levels 1..n with a vkCmdBlitImage down-sample that walks all array layers (cube faces included), leaving the chain in SHADER_READ_ONLY_OPTIMAL. Depth/3D textures stay single-level.

2. GPU IBL prefilter on Vulkan (texture.hpp, vk_resources.hpp, vk_device_*.cpp, vk_command_encoder.*, ibl/environment.cpp)
supports_compute_prefilter() now returns true on Vulkan, so the IBL convolution runs on the GPU like OpenGL instead of falling back to the CPU. The supporting plumbing:

  • texture_descriptor gains an opt-in storage flag; the Vulkan image adds VK_IMAGE_USAGE_STORAGE_BIT only when set and the format backs a storage image, so ordinary sampled textures keep their usual usage set. OpenGL ignores the flag.
  • Storage descriptors bind a lazily-built, per-level image view (cube levels bind all six faces through one VK_IMAGE_VIEW_TYPE_CUBE view), cached on the texture and released with it.
  • The compute pass moves bound storage textures to VK_IMAGE_LAYOUT_GENERAL before dispatch and restores the sampled layout at end().

The convolution math and shaders are shared with OpenGL and unchanged.

3. Default backend → Vulkan (core/settings.*, CLAUDE.md)
ALPHAENGINE_GRAPHICS_BACKEND now defaults to vulkan (and the unknown-value fallback does too). OpenGL stays available via ALPHAENGINE_GRAPHICS_BACKEND=opengl.

Testing

  • Full Release build links clean.
  • clang-format (LLVM 18) and clang-tidy naming gates pass.

⚠️ Needs on-hardware validation

This environment has no Vulkan GPU, so the changes are compile/link-verified only — not run. The first on-hardware launch now takes the Vulkan path by default; please run it once with validation layers enabled (VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation) to confirm the mipmap blits and the cube storage-image / layout-transition handling in the IBL compute path. Fallback is ALPHAENGINE_GRAPHICS_BACKEND=opengl.

The Vulkan backend allocated a single mip level and left
generate_mipmaps as a no-op, so every mipmapped texture (materials,
UI panes, cached images, the IBL skybox) sampled a degraded chain
relative to OpenGL's glGenerateMipmap.

Allocate the full mip chain up front for mipmapped colour textures
whose format advertises a linear blit, and fill levels 1..n with a
vkCmdBlitImage down-sample that walks all array layers (so cube
faces are covered) and leaves the whole chain in
SHADER_READ_ONLY_OPTIMAL. Depth and 3D textures keep a single level,
and a format without linear-blit support falls back to one level
rather than an undefined chain.
The Vulkan backend reported no compute-prefilter support, so the IBL
environment fell back to the CPU convolution while OpenGL ran the
GPU path -- a performance gap on the road to dropping OpenGL.

Implement the storage-image plumbing the convolution needs and have
vk_device advertise supports_compute_prefilter:

- texture_descriptor gains an opt-in storage flag; the Vulkan image
  adds VK_IMAGE_USAGE_STORAGE_BIT only when set and the format backs
  a storage image, so ordinary sampled textures keep their usual
  usage set. The OpenGL backend ignores the flag (image binding is
  unconditional there).
- A storage descriptor must name a single mip level, so bind groups
  now reference a lazily-built, per-level image view (cube levels
  bind all six faces through one VK_IMAGE_VIEW_TYPE_CUBE view)
  instead of the whole-chain sampling view; the views are cached on
  the texture and released with it.
- The compute pass moves bound storage textures to
  VK_IMAGE_LAYOUT_GENERAL before dispatch and restores the sampled
  layout at end(), so the later lighting passes read them as
  combined image samplers.

Output already matched via the CPU fallback; this brings the GPU
path to parity. The convolution math and shaders are shared with
OpenGL and unchanged.
Flip the ALPHAENGINE_GRAPHICS_BACKEND default (and the unknown-value
fallback) from opengl to vulkan now that the Vulkan backend is at
feature parity -- mipmap generation and the GPU IBL prefilter both
land on it. OpenGL stays available via ALPHAENGINE_GRAPHICS_BACKEND=opengl.
@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown

CI build artifacts

Windows Debug and Release builds are attached to the workflow run summary under Artifacts:

  • AlphaEngine-windows-Debug — Debug AlphaEngine.exe + Khronos validation layer
  • AlphaEngine-windows-Release — Release AlphaEngine.exe

Run #247 — commit 2569b32.

Three pre-existing Vulkan-backend gaps surfaced once Vulkan became the
default and the PBR demo exercised it (the OpenGL backend tolerated all
three):

- Vertex stride: sphere and plane uploaded position+uv+normal (32B)
  but standard_material declares a tangent at location 3 (offset 32),
  so the bound stride fell short of the attribute extent. Generate
  tangents for both primitives like box already does. The pos/uv/normal
  offsets are unchanged, so materials that ignore the tangent still
  read correctly from the wider stride.

- Unbound samplers: a material that leaves a texture slot empty (no
  albedo/normal/... map, or no IBL cube when no environment is
  attached) left the descriptor unwritten, which Vulkan rejects for a
  statically-used binding. The device now keeps 1x1 white 2D and cube
  placeholders and binds the dimension-matching one for any empty
  texture slot; bind_group_layout_entry carries a dimension hint so the
  cube samplers (irradiance/prefiltered) get a cube, not a 2D, default.

- Scene depth layout: the off-screen depth attachment stayed in
  DEPTH_STENCIL_ATTACHMENT_OPTIMAL but the velocity pass samples it, so
  it now resolves to SHADER_READ_ONLY_OPTIMAL (swapchain depth, never
  sampled, stays an attachment), and the outgoing subpass dependency
  releases the depth write for the fragment-shader read.
submit() detached the recorded command buffer from the encoder via
release_command_buffer() but never freed it, so every frame leaked one
VkCommandBuffer into the pool. Over a long run that pile grows into the
hundreds of thousands, and tearing it all down in one vkDestroyCommandPool
stalled shutdown for tens of seconds (far worse under validation layers).
Runtime was unaffected because allocating a fresh buffer each frame is
cheap, which is why it only showed at exit.

Return each buffer to the pool once the GPU is done with it: the
off-screen-only path already waits on the queue, so it frees immediately;
the presenting path hands the buffer to the deferred-destroy queue, which
begin_frame drains only after vkWaitForFences -- so it is freed exactly
when the in-flight fence signals rather than leaked for the run.
@TommyRadan
TommyRadan merged commit 682a5af into master Jun 16, 2026
6 checks passed
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.

1 participant