Skip to content

Vulkan renderer: close out a bunch of GL parity gaps from TODO.md#3

Merged
Fran6nd merged 11 commits into
Fran6nd:vulkan-2from
Rakete175:vulkan-2
Jul 9, 2026
Merged

Vulkan renderer: close out a bunch of GL parity gaps from TODO.md#3
Fran6nd merged 11 commits into
Fran6nd:vulkan-2from
Rakete175:vulkan-2

Conversation

@Rakete175

Copy link
Copy Markdown

This PR works through most of the outstanding Vulkan-vs-GL parity items — model self-shadowing, bloom/lens-flare/outline fixes, a MoltenVK depth-sampling bug, shadow map upload perf, and a couple of shading bleed fixes on map/model geometry. Also fixes several stale entries in TODO.md that no longer matched the code.

Rendering features

Model self-shadowing via shadow map cascades

  • BasicModelVertexColor.vert/frag now sample the set-1 cascade shadow maps (same layout as BasicMap), computing per-cascade light-clip coords from world position and multiplying the result into the existing map-shadow term.
  • VulkanOptimizedVoxelModel extends its shared lit pipeline layout to two descriptor sets and binds the shadow-sampling set in both the prerender and sunlight passes. Ghost pipelines share the same passes/layout, so no extra work needed there.

Dynamic per-light lens flares (r_lensFlareDynamic)

  • VulkanLensFlareFilter no longer hardcodes the sun as the only flare source — sun + each dynamic light with useLensFlare now gets its own FlareRequest (projection, push constants, scanZ), with per-light culling logic ported directly from GLRenderer's dynamic-flare loop (distance attenuation, spotlight falloff, behind-camera rejection).
  • Capped at 8 flares/frame; descriptor pools sized to 256 sets / 768 samplers per frame slot to cover the worst case (8 flares × ~23 sets each).
  • VulkanRenderer::GetDynamicLights() added; the per-frame lights vector now survives until after the post-process chain instead of being cleared before the flare filter can read it (still cleared on dropped frames).
  • No new shaders — reuses the existing scanner/blur/draw pipelines per request.

Bloom parity with GLLensDustFilter

  • Added Gauss1DRGBA.vk.fs (RGBA gaussian kernel; the existing Gauss1D.vk.fs stays single-channel for DoF CoC).
  • VulkanBloomFilter now runs separable H+V gaussian blur at every downsample level, matching GL.
  • BloomComposite.vk.fs ports LensDust.fs: scene*0.95 + dust² * bloom*2 + grain*0.003, with per-frame film grain driven by a 128×128 RGBA8 noise texture refreshed via SampleRandom().
  • Known remaining gap (tracked in TODO): GL blurs the first downsample level before building the pyramid; Vulkan skips that pre-blur, so bloom is marginally sharper than GL.
  • SSAO intentionally left unported — would need a full depth prepass, a mid-frame render-pass split, and new sampler bindings across every lit pipeline. Documented rather than rushed.

Camera motion blur (VulkanCameraBlurFilter)

  • Straight port of GLCameraBlurFilter: rotation-only view diff vs. previous frame, camera-cut guard, log5 iteration count, radial-blur scaling — all replicated on the CPU side.
  • 5-tap depth²-weighted smear keeps the view weapon sharp; linear framebuffer means GL's linearize/sqrt round-trip isn't needed.
  • Inserted into the chain in GL's order: DoF → CameraBlur → Bloom. Gated on r_cameraBlur > 0 and !sceneDef.denyCameraBlur.

Bicubic resample filter (r_scaleFilter == 2)

  • New VulkanResampleBicubicFilter, a port of Danny Ruijters' 4-fetch bicubic B-spline upscale from the GL shader (license notice carried over). Skipped when render size already matches swapchain size, same as GL.
  • Swapchain blit now also respects r_scaleFilter == 0 (nearest) instead of always linear-filtering.

Outline tuning cvars

  • VulkanCavityOutlineFilter: hardcoded edge strength and depth threshold promoted to r_outlinesStrength (0–4, default 1) and r_outlinesDepthThreshold (0.001–1, default 0.05), read per frame.

Bug fixes

Lens flares not rendering at all (MoltenVK)

  • Root cause: sceneDepthSampleImage was D32_SFLOAT with a depth-aspect view, sampled as plain sampler2D. MoltenVK maps that to Metal's texture2d<float>, which silently returns 0 for depth-format images instead of erroring — so every visibility check failed.
  • Fix: store the image as R32_SFLOAT with a color-aspect view (same trick already used on the MSAA depth-resolve path), and replace the now-invalid hardware depth-compare with an in-shader step() in LensFlareScanner.vk.fs. Visual output unchanged.
  • This bug likely also explains Fog2's independently-tracked "dimmer than GL" issue — noted in TODO.md as a follow-up rather than fixed here, since only the lens flare path was touched.
  • Also fixed in passing: the visibility blur pass issued vkCmdDraw(6, ...) against a 3-vertex fullscreen-triangle shader, rasterizing a spurious second triangle. Harmless but wrong; now draws 3 vertices.

Fog nadir flicker + 8-bit framebuffer banding

  • Fog.vk.fs: early-out on near-vertical view rays instead of snapping dir.xy, fixing flicker when looking straight down.
  • FramebufferManager: prefer A2B10G10R10_UNORM for the scene FB even with r_highPrec=0 (hardware permitting), so linear output doesn't land in an 8-bit target without gamma encoding.

Light bleed on map and model geometry

  • Map faces: vertices sit exactly on voxel boundaries, so shadow/AO/radiosity lookups could sample the neighboring column and leak a lit sliver on north faces. Added the fixedPositionAttribute (face-center, already computed but never wired to the shaders) as vertex attribute 4 in BasicMap.vert / BasicMapPhys.vert, matching GL's fixedPositionAttribute.
  • Model ghost-cursor faces: same class of bug, but face-center sampling doesn't apply to arbitrary model geometry. Fixed by offsetting the shadow sample point 0.05 units inward along the surface normal in BasicModelVertexColor.vert / BasicModelVertexColorPhys.vert.

Shadow map upload performance

  • VulkanMapShadowRenderer::Update no longer re-uploads the full 512×512 shadow bitmap on every block change. Dirty 32-texel words are coalesced into per-row spans and uploaded in one staging-buffer map/unmap + one multi-region vkCmdCopyBufferToImage. Falls back to a full upload past a dirty/span threshold. Removes the perf cliff during heavy building.

Housekeeping

  • Removed dead Water.frag / Water.vert (unreferenced, stale hardcoded sun direction).
  • Removed committed .spv blobs and gitignored them going forward — CMake regenerates SPIR-V and glslangValidator is already a hard build requirement.
  • TODO.md: checked off all items resolved above, and corrected stale entries that no longer matched the code.

Rakete175 added 11 commits July 7, 2026 22:46
Map shadow/AO/radiosity coords were sampled at the raw vertex
position. Vertices sit exactly on voxel boundaries, so the map-shadow
lookup could hit the neighboring column and leak a lit sliver along
face edges — visible as a thin band of light at the top of north
faces when viewed from the south.

The vertex already carries a "fixed position" (sx/sy/sz = face-center
* 2, chunk-local), which was computed to avoid this exact glitch but
never passed to the shaders. Add it as vertex attribute location 4
and use it for shadowCoord, aoCoord, and radiosityTextureCoord,
matching the GL renderer's fixedPositionAttribute.

- BasicMap.vert, BasicMapPhys.vert: new fixedPositionAttribute input;
  sample shadow/AO/radiosity at face center
- VulkanMapRenderer.cpp: add attribute 4 (R8G8B8_SINT, offset 16)
Same voxel-boundary shadow bleed as the map geometry fix, but for
models: the ghost cursor cube's faces lie exactly on voxel
boundaries, so the map-shadow lookup could hit the neighboring
column and show a lit sliver along face edges.

Models have arbitrary geometry, so the map renderer's face-center
sampling doesn't apply. Instead, offset the shadow sample point
slightly inward along the surface normal (0.05 units) — negligible
for regular models, and keeps boundary-aligned faces sampling their
own column.

- BasicModelVertexColor.vert, BasicModelVertexColorPhys.vert:
  sample map shadow at worldPos - normal * 0.05
shadow bitmap on any block change. Dirty 32-texel words are coalesced
into per-row spans, packed contiguously into the existing staging buffer
(single map/unmap), and copied with one multi-region
vkCmdCopyBufferToImage. Falls back to a full upload when >25% of the
image is dirty or spans exceed 256 (heavy edits, map load). Matches
GLMapShadowRenderer's glTexSubImage2D behavior; removes the perf cliff
in build-heavy games. Coarse min/max map path unchanged (already
cell-granular).

TODO.md corrections (three entries did not match reality):
- 'Water.frag / Fog2 hardcode the sun' -- false. Water*.vk.fs and
  Fog2.vk.fs already read GetSunDirection() via push constants. The
  hardcoded const lives in Water.frag, a dead unreferenced file; added
  a TODO to delete it.
- 'PreloadShaders is empty' (map + voxel model) -- false, both preload
  SPIR-V. Real remaining gap noted: pipelines still build lazily.
- Marked the shadow-upload item done.
- CPU side replicates GL exactly: rotation-only view diff vs previous
  frame, ReverseMatrix, camera-cut guard (diag < 0.3 skips unless radial
  blur active), movePixels estimate, log5 iteration count, shutter scale
  /5 per pass, radialBlur^2 scaling of the reverse matrix.
- Shaders CameraBlur.vk.vs/.fs: fullscreen triangle reprojected via
  reverseMatrix push constant; 5-tap depth^2-weighted smear so the view
  weapon (depth ~[0,0.1]) stays sharp. Y flipped in/out of the GL
  bottom-left math. Linear framebuffer, so GL's linearize/sqrt round
  trip is dropped.
- Multi-pass ping-pong through VulkanTemporaryImagePool; final pass
  writes the chain's currentOutput. Apply() returns false on skip
  (no motion / cut without radial blur) so the chain doesn't swap --
  mirrors GL returning the input handle untouched.
- Reads resolved depth (GetResolvedDepthImage), works with and without
  MSAA.

Chain insertion matches GL order: DoF -> CameraBlur -> Bloom
(GLRenderer.cpp:963). Gated on r_cameraBlur > 0 and
!sceneDef.denyCameraBlur; intensity = min(r_cameraBlur * 0.2, 1).

Sources and shaders are picked up by existing CMake globs; no build
system changes. TODO table updated.
 nearest

New VulkanResampleBicubicFilter (r_scaleFilter == 2): single fullscreen
pass running Danny Ruijters' 4-fetch bicubic B-spline upscale
(ResampleBicubic.vk.fs, straight port of the GL shader incl. its
license notice), rendering the render-res image into a swapchain-sized
temporary from VulkanTemporaryImagePool. The final swapchain blit then
runs 1:1. Skipped when render size already equals swapchain size,
matching GL which only resamples when scaling.

The swapchain blit also honors r_scaleFilter == 0 now
(VK_FILTER_NEAREST instead of always LINEAR). blitSrc/blitFilter are
resolved before the transfer barriers; currentInput is kept as the
render-res image for the screenshot mirror copy and gets its own
TRANSFER_SRC barrier when the bicubic path replaced it as blit source.

TODO.md: marked the resample row wired; corrected the water
dynamic-light stub entry -- it is already an intentional no-op and GL
water has no dynamic light pass either (stale TODO, fourth one so far).
 grain)

VulkanBloomFilter's final composite previously did a plain
scene*0.8 + bloom*0.2 mix; GL's actual r_bloom (GLLensDustFilter)
composites through a lens-dust overlay and adds per-frame film grain.
Ported:

- BloomComposite.vk.fs now implements LensDust.fs on a linear
  framebuffer: final = scene*0.95 + dust^2 * bloom * 2 + grain*0.003,
  with grain = fract(noise(uv1) + noise(uv2)) - 0.5 using GL's
  noiseTexCoordFactor (pushed as a vec4).
- Textures/LensDustTexture.jpg registered through the renderer and
  bound as the dust input (falls back to the bloom texture if the
  wrapper cast fails, degrading to roughly the old look).
- New 128x128 RGBA8 noise texture, refreshed every frame from
  SampleRandom() via a persistent host-coherent staging buffer
  (UpdateNoise mirrors GL's UpdateNoise; upload happens before the
  bloom passes, outside any render pass).
- Descriptor plumbing: 4-binding set layout for the composite,
  composite pipeline layout gains a vec4 push-constant range.

Remaining (noted in TODO): GL additionally runs Gauss1D H+V on the
first downsample level before building the pyramid; the Vulkan pyramid
skips that pre-blur, so the bloom is slightly sharper than GL. Also
recorded why SSAO stays unported for now: it needs a full depth
prepass, a mid-frame render-pass split for depth resolve, and a new
sampler binding in every lit map/model pipeline -- too invasive to land
untested in one blind commit.
VulkanLensFlareFilter::Filter no longer hard-codes the sun as the only
flare source. The sun/light projection, scanner push constants and
depth-based scanZ now live in a FlareRequest built per source; the sun
is simply the first request (infinity distance, warm tint), and under
r_lensFlareDynamic each dynamic light with useLensFlare adds another.
Per-light culls are copied verbatim from GLRenderer's dynamic-flare
loop: distance attenuation against radius^2 * 18, spotlight cone
falloff, and behind-camera rejection. Each request gets its own 64x64
visibility scan + 3x (H+V) Gauss blur chain; all quads then draw
additively inside the single final render pass after the scene
passthrough, so the pp chain still sees one input -> output hop.

Supporting changes:
- VulkanRenderer::GetDynamicLights() getter; the per-frame lights
  vector now survives until after the post-process chain (it was
  cleared right after the opaque/dlight passes, before the flare
  filter could read it) and is cleared just before the swapchain blit.
  The EndScene dropped-frame path still clears it immediately.
- Dynamic flares capped at 8 per frame and the filter's descriptor
  pools grown to 256 sets / 768 samplers per frame slot (each flare
  costs ~23 sets: scanner + 6 blurs + up to 15 quads). GL has no cap,
  but GL also does not have to pre-size descriptor pools; 8 visible
  muzzle-flash flares in one frame is already generous.

No new shaders: scanner, blur and flare-draw pipelines are reused
per request. TODO table row updated
…urns 0)

The lens flare visibility scanner always sampled 0 from the depth
texture, so the sun/light occlusion test failed everywhere and no
flare quads ever passed the visibility check.

Root cause: VulkanFramebufferManager::sceneDepthSampleImage was a
D32_SFLOAT image with a VK_IMAGE_ASPECT_DEPTH_BIT view, sampled in
shaders as plain `sampler2D`. MoltenVK maps GLSL sampler2D to Metal's
texture2d<float>, which cannot read a depth-format image (that needs
depth2d<float>) — the read silently returns 0 instead of failing
loudly. Every post-process pass sampling scene depth was affected;
this file only fixes the lens flare path, but Fog2's independently
tracked "dimmer than GL" issue is a likely second symptom of the same
bug (noted in TODO.md for follow-up).

Fix: store sceneDepthSampleImage as R32_SFLOAT with a COLOR_BIT view
instead, matching the MSAA depth-resolve path, which already worked
around this by resolving depth into an R32F color target. The
depth-to-depth vkCmdCopyImage becomes a cross-aspect depth-to-color
copy (valid since Vulkan 1.1 / VK_KHR_maintenance1); same 32-bit float
bit pattern, no format conversion needed.

Since depth is now a color image, hardware depth-compare
(sampler2DShadow + VK_COMPARE_OP_LESS) is also invalid on it. Replaced
with an equivalent in-shader `step(scanPos.z, depth)` compare in
LensFlareScanner.vk.fs, and removed the now-unused
depthShadowSampler. Softness still comes from the existing 3x Gauss
blur, so visual output is unchanged.

Also fixed while in this code path: the visibility blur pass called
vkCmdDraw(6, ...) against PassThrough.vk.vs, a 3-vertex fullscreen
triangle — rasterizing a spurious degenerate second triangle. Harmless
under the blur pass's non-blended output, but wrong; corrected to
vkCmdDraw(3, ...).
- Fog.vk.fs: early-out on near-vertical view rays instead of snapping
  dir.xy, which caused fragment-level flicker when looking straight down
- FramebufferManager: prefer A2B10G10R10_UNORM for the scene FB even when
  r_highPrec=0 (hardware permitting), so linear shader output never lands
  in an 8-bit target without gamma encoding
- Remove dead Water.frag/Water.vert (unreferenced, stale hardcoded sun)
- Remove committed .spv blobs and gitignore them; CMake regenerates all
  SPIR-V and glslangValidator is already a hard build requirement
- TODO.md: check off the four resolved items
- Add Gauss1DRGBA.vk.fs (RGBA port of the GL Gauss1D kernel; the existing
  Gauss1D.vk.fs is single-channel and reserved for the DoF CoC path)
- VulkanBloomFilter: run the separable H+V gaussian on every downsample
  level, matching GLLensDustFilter; adds a dedicated gauss pipeline/layout
  and defers temp-image returns until recording completes
- VulkanCavityOutlineFilter: promote the hardcoded edge strength and depth
  threshold to r_outlinesStrength (0-4, default 1) and
  r_outlinesDepthThreshold (0.001-1, default 0.05), read per frame
- TODO.md: mark the bloom delta and outline-tuning items resolved
- BasicModelVertexColor.vert/frag: declare the set-1 shadow-sampling
  layout (cascadeMatrix UBO + three cascade depth maps, matching
  BasicMap), compute per-cascade light-clip coordinates from the world
  position, and multiply the model-on-model shadow term into the
  existing map-shadow factor
- VulkanOptimizedVoxelModel: extend the shared lit pipeline layout to
  two descriptor sets (set 1 = the shadow map renderer's sampling
  layout) and bind the sampling set in the prerender and sunlight
  passes; the ghost pipelines draw from the same passes and their
  fragment shaders remain compatible with the wider layout
- TODO.md: mark the model self-shadowing follow-up as done
@Fran6nd Fran6nd merged commit bc5dc60 into Fran6nd:vulkan-2 Jul 9, 2026
7 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.

2 participants