fix(render3d): Vulkan exposure, foliage depth and meadow alpha

Three things the first Vulkan frames on the RTX 3070 Ti showed against OpenGL on the same PC:

- Exposure: the adaptation pass reads the HDR scene's smallest mip, and a render target made
  without pixels had one level, so exposure came from a single texel. A target asked for mipmaps
  now grows a full chain (level 0 kept), and passes draw through level-0 views keyed by the
  image's generation.
- Foliage: the depth prepass and the lit pass (depth EQUAL) are different variants. Vulkan vertex
  stages now declare an invariant gl_Position so both land on the same depth.
- Alpha to coverage is enabled only on a multisampled pass. OpenGL ignores it without MSAA; Vulkan
  with one sample dropped every fragment under half alpha.

vk_resources also checks a big-endian 16-bit RGB upload (a normal map). VKRES OK; ludic-dev test
140 passed; the PC's Vulkan frame is validation-clean; OpenGL frames byte-identical at the five
viewpoints with 59 self-tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-15 12:29:24 +03:00
parent 072f9a848e
commit a674c9b372
33 changed files with 70 additions and 7 deletions

View file

@ -61,6 +61,19 @@ program VkResources {
check("RGB16F from floats (halved)", gvk_tex_storage(t4, false, GL_RGB16F, w, h, 1, true) and gvk_tex_upload(t4, GL_RGB16F, w, h, 1, GL_RGB, GL_FLOAT, fl))
check("half of 1.5 is 0x3E00", gvk_half(0x3FC00000) == 0x3E00)
# a 16-bit RGB PNG (a normal map): big-endian samples, swapped on upload, alpha filled opaque
let n16 = bytes(w * h * 6)
for i in 0 .. w * h * 3 { n16[i * 2] = (i >> 8) & 255; n16[i * 2 + 1] = i & 255 }
let t7 = gvk_tex_new()
gvk_unpack_swap = true
check("RGB16 swapped upload", gvk_tex_storage(t7, false, GL_RGB16, w, h, 1, true) and gvk_tex_upload(t7, GL_RGB16, w, h, 1, GL_RGB, GL_UNSIGNED_SHORT, n16))
gvk_unpack_swap = false
let b16 = bytes(w * h * 6)
check("RGB16 read-back call", gvk_tex_read(t7, GL_RGB16, w, h, GL_RGB, GL_UNSIGNED_SHORT, b16))
var s16 = true
for i in 0 .. w * h * 3 { if b16[i * 2] != (i & 255) or b16[i * 2 + 1] != ((i >> 8) & 255) { s16 = false } }
check("RGB16 samples arrive little-endian", s16)
# the shadow cascades: a depth array, and a render-sized half-float target with no pixels
let t5 = gvk_tex_new()
check("D32 array of 4", gvk_tex_storage(t5, true, GL_DEPTH_COMPONENT32F, 256, 256, 4, false))
@ -85,7 +98,7 @@ program VkResources {
check("a smaller upload keeps the buffer", gvk_buf_upload(b, 512, data) and gvk_buf[b] == first)
check("a larger upload grows it", gvk_buf_upload(b, 4096, data) and gvk_buf_size[b] >= 4096)
for t in 1 .. 7 { gvk_tex_release(t) }
for t in 1 .. 8 { gvk_tex_release(t) }
gvk_buf_release(b)
for i in 0 .. len(gvk_smp) { Vk.destroy_sampler(gvk_dev, gvk_smp[i], null) }
print(`vulkan: {gvk_n_allocs} allocations left after freeing`)

View file

@ -290,7 +290,9 @@ function gvk_pipeline(p: int, m: Mesh, st: GvkState, n_color: int, color_fmt: in
Vk.zero(ms, VkPipelineMultisampleStateCreateInfo_sizeof)
Vk.put_i32(ms, VkPipelineMultisampleStateCreateInfo_sType, VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO)
Vk.put_i32(ms, VkPipelineMultisampleStateCreateInfo_rasterizationSamples, samples)
Vk.put_i32(ms, VkPipelineMultisampleStateCreateInfo_alphaToCoverageEnable, st.a2c)
# OpenGL ignores alpha to coverage without a multisampled target; Vulkan with one sample would
# instead drop every fragment under half alpha, which erased the meadow's flowers
if samples > 1 { Vk.put_i32(ms, VkPipelineMultisampleStateCreateInfo_alphaToCoverageEnable, st.a2c) }
let ds = bytes(VkPipelineDepthStencilStateCreateInfo_sizeof)
Vk.zero(ds, VkPipelineDepthStencilStateCreateInfo_sizeof)
Vk.put_i32(ds, VkPipelineDepthStencilStateCreateInfo_sType, VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO)
@ -607,10 +609,12 @@ function gvk_frame_cb() -> pointer {
return gvk_cb
}
# a view of one layer of an array image, for a cascade drawn into on its own
# The view a pass draws into: level 0 only (an attachment view has exactly one level, and a target
# the exposure measure mipmaps has several), and one layer of an array image for a cascade drawn
# on its own. Keyed by the image's generation, so a replaced image never reuses a stale view.
function gvk_view_of(tex: int, layer1: int) -> long {
if layer1 == 0 { return gvk_tex_view[tex] }
let key = `{tex}:{layer1}`
if layer1 == 0 and gvk_tex_levels[tex] <= 1 and gvk_tex_layers[tex] <= 1 { return gvk_tex_view[tex] }
let key = `{tex}:{gvk_tex_gen[tex]}:{layer1}`
if gvk_layer_views == null { gvk_layer_views = new []string; gvk_layer_view = new []long }
for i in 0 .. len(gvk_layer_views) { if gvk_layer_views[i] == key { return gvk_layer_view[i] } }
let depth = gvk_tex_vkfmt[tex] == VK_FORMAT_D32_SFLOAT
@ -623,7 +627,7 @@ function gvk_view_of(tex: int, layer1: int) -> long {
let sr = VkImageViewCreateInfo_subresourceRange
if depth { Vk.put_i32(vci, sr + VkImageSubresourceRange_aspectMask, VK_IMAGE_ASPECT_DEPTH_BIT) } else { Vk.put_i32(vci, sr + VkImageSubresourceRange_aspectMask, VK_IMAGE_ASPECT_COLOR_BIT) }
Vk.put_i32(vci, sr + VkImageSubresourceRange_levelCount, 1)
Vk.put_i32(vci, sr + VkImageSubresourceRange_baseArrayLayer, layer1 - 1)
if layer1 > 0 { Vk.put_i32(vci, sr + VkImageSubresourceRange_baseArrayLayer, layer1 - 1) }
Vk.put_i32(vci, sr + VkImageSubresourceRange_layerCount, 1)
let out = bytes(8)
let zero: long = 0

View file

@ -52,6 +52,9 @@ var gvk_tex_layers: []int = null
var gvk_tex_vkfmt: []int = null
var gvk_tex_dims_w: []int = null
var gvk_tex_dims_h: []int = null
var gvk_tex_glfmt: []int = null # the format the renderer asked for (gpu.ludic's vocabulary)
var gvk_tex_array: []int = null # 1 for an array image
var gvk_tex_gen: []int = null # bumped whenever the image behind a handle is replaced
var gvk_unpack_swap: bool = false # GL_UNPACK_SWAP_BYTES: 16-bit PNG samples arrive big-endian
function gvk_tex_new() -> int {
@ -60,7 +63,9 @@ function gvk_tex_new() -> int {
gvk_tex_image = new []long; gvk_tex_view = new []long; gvk_tex_mem = new []long
gvk_tex_levels = new []int; gvk_tex_layers = new []int; gvk_tex_vkfmt = new []int
gvk_tex_dims_w = new []int; gvk_tex_dims_h = new []int
gvk_tex_glfmt = new []int; gvk_tex_array = new []int; gvk_tex_gen = new []int
push(gvk_tex_dims_w, 0); push(gvk_tex_dims_h, 0)
push(gvk_tex_glfmt, 0); push(gvk_tex_array, 0); push(gvk_tex_gen, 0)
# handle 0 is "no texture", as it is on OpenGL
push(gvk_tex_image, zero); push(gvk_tex_view, zero); push(gvk_tex_mem, zero)
push(gvk_tex_levels, 0); push(gvk_tex_layers, 0); push(gvk_tex_vkfmt, 0)
@ -68,6 +73,7 @@ function gvk_tex_new() -> int {
push(gvk_tex_image, zero); push(gvk_tex_view, zero); push(gvk_tex_mem, zero)
push(gvk_tex_levels, 0); push(gvk_tex_layers, 0); push(gvk_tex_vkfmt, 0)
push(gvk_tex_dims_w, 0); push(gvk_tex_dims_h, 0)
push(gvk_tex_glfmt, 0); push(gvk_tex_array, 0); push(gvk_tex_gen, 0)
return len(gvk_tex_image) - 1
}
@ -191,6 +197,8 @@ function gvk_tex_storage(tex: int, array: bool, ifmt: int, w: int, h: int, layer
gvk_tex_image[tex] = image; gvk_tex_view[tex] = gvk_handle(out); gvk_tex_mem[tex] = mem
gvk_tex_levels[tex] = levels; gvk_tex_layers[tex] = layers; gvk_tex_vkfmt[tex] = vkfmt
gvk_tex_dims_w[tex] = w; gvk_tex_dims_h[tex] = h
gvk_tex_glfmt[tex] = ifmt; gvk_tex_gen[tex] = gvk_tex_gen[tex] + 1
if array { gvk_tex_array[tex] = 1 } else { gvk_tex_array[tex] = 0 }
# a target starts cleared-to-nothing but readable: every level in the layout samplers expect
let cb = gvk_once_begin()
gvk_barrier(cb, image, depth, 0, levels, layers, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
@ -285,7 +293,42 @@ function gvk_tex_upload(tex: int, ifmt: int, w: int, h: int, layers: int, fmt: i
}
# The mip chain from level 0, each level blitted down from the one above it
# A target made without pixels has one level; the renderer asking it for mipmaps (the exposure
# measure reads the HDR scene's smallest level every frame) grows it a full chain, level 0 kept.
function gvk_tex_grow_mips(tex: int, w: int, h: int) -> bool {
let old_image = gvk_tex_image[tex]
let old_view = gvk_tex_view[tex]
let old_mem = gvk_tex_mem[tex]
let layers = gvk_tex_layers[tex]
let zero: long = 0
gvk_tex_image[tex] = zero
if not gvk_tex_storage(tex, gvk_tex_array[tex] == 1, gvk_tex_glfmt[tex], w, h, layers, true) { return false }
let cb = gvk_once_begin()
gvk_barrier(cb, old_image, false, 0, 1, layers, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL)
gvk_barrier(cb, gvk_tex_image[tex], false, 0, 1, layers, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL)
let ic = bytes(VkImageCopy_sizeof)
Vk.zero(ic, VkImageCopy_sizeof)
Vk.put_i32(ic, VkImageCopy_srcSubresource + VkImageSubresourceLayers_aspectMask, VK_IMAGE_ASPECT_COLOR_BIT)
Vk.put_i32(ic, VkImageCopy_srcSubresource + VkImageSubresourceLayers_layerCount, layers)
Vk.put_i32(ic, VkImageCopy_dstSubresource + VkImageSubresourceLayers_aspectMask, VK_IMAGE_ASPECT_COLOR_BIT)
Vk.put_i32(ic, VkImageCopy_dstSubresource + VkImageSubresourceLayers_layerCount, layers)
Vk.put_i32(ic, VkImageCopy_extent + VkExtent3D_width, w)
Vk.put_i32(ic, VkImageCopy_extent + VkExtent3D_height, h)
Vk.put_i32(ic, VkImageCopy_extent + VkExtent3D_depth, 1)
Vk.cmd_copy_image(cb, old_image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, gvk_tex_image[tex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ic)
gvk_barrier(cb, gvk_tex_image[tex], false, 0, 1, layers, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
let ok = gvk_once_end(cb)
Vk.destroy_image_view(gvk_dev, old_view, null)
Vk.destroy_image(gvk_dev, old_image, null)
Vk.free_memory(gvk_dev, old_mem, null)
gvk_n_allocs -= 1
return ok
}
function gvk_tex_mips(tex: int, w: int, h: int) -> bool {
if gvk_tex_levels[tex] <= 1 and (w > 1 or h > 1) and not gvk_is_depth(gvk_tex_glfmt[tex]) {
if not gvk_tex_grow_mips(tex, w, h) { return false }
}
let levels = gvk_tex_levels[tex]
if levels <= 1 { return true }
let image = gvk_tex_image[tex]
@ -372,6 +415,7 @@ function gvk_tex_release(tex: int) -> void {
gvk_n_allocs -= 1
gvk_tex_image[tex] = zero; gvk_tex_view[tex] = zero; gvk_tex_mem[tex] = zero
gvk_tex_levels[tex] = 0; gvk_tex_layers[tex] = 0; gvk_tex_vkfmt[tex] = 0
gvk_tex_gen[tex] = gvk_tex_gen[tex] + 1
}
# ---- samplers -----------------------------------------------------------------------------

View file

@ -173,7 +173,9 @@ function cmd_shaders() -> int {
# is untouched. No y flip: a Vulkan target's row 0 is where OpenGL's is (NDC y = -1), so render
# to texture, sampling and gl_FragCoord agree; only the present and the screenshot flip.
let depth_remap = "\n#undef main\nvoid main() {\n ludic_gl_main();\n gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5;\n}\n"
let vsrc = "#version 460\n" + defines + "#define main ludic_gl_main\n" + shd_file(parts[0]) + depth_remap
# invariant: the foliage's depth prepass and its lit pass (depth EQUAL) are different variants,
# and without it the compiler may compute their positions a hair apart and fail the test
let vsrc = "#version 460\n" + defines + "invariant gl_Position;\n#define main ludic_gl_main\n" + shd_file(parts[0]) + depth_remap
let fsrc = "#version 460\n" + defines + shd_noise + shd_lighting + shd_file(parts[1])
# one binding table for both stages
shd_names = new []pointer