perf(render3d): Vulkan buffer uploads and per-frame mipmaps no longer wait on the GPU

- A buffer re-uploaded after a draw this frame read it gets fresh storage, as OpenGL orphans
  one; storage moved off or freed while the frame still reads it is destroyed after the frame's
  submit. Draws mark the buffers they bind. No flush for buffer work.
- Mipmaps asked for mid-frame (the exposure measure, every frame) are recorded into the open frame
  after its pass; growing a chain the first time keeps its one-shot path.
- R3D_VK_PROF prints, every 120 frames, draws and flushes per frame and the milliseconds spent
  finding pipelines, filling descriptor sets and inside draws.

The gain was small - the camp view headless at 1920x1080 on the RTX 3070 Ti went from 21.3 to
21.7 fps (OpenGL: 114 fps) - so these flushes were not what holds the frame; the profile is how
the rest is found. The frame is unchanged and 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 13:17:07 +03:00
parent 1b7408c6a1
commit 6942c36853
3 changed files with 100 additions and 18 deletions

View file

@ -331,9 +331,16 @@ function gvk_tex_mips(tex: int, w: int, h: int) -> bool {
}
let levels = gvk_tex_levels[tex]
if levels <= 1 { return true }
let cb = gvk_once_begin()
gvk_tex_mips_into(cb, tex, w, h)
return gvk_once_end(cb)
}
# the chain's blits and barriers recorded into cb (the frame's own, when one is open)
function gvk_tex_mips_into(cb: pointer, tex: int, w: int, h: int) -> void {
let levels = gvk_tex_levels[tex]
if levels <= 1 { return }
let image = gvk_tex_image[tex]
let layers = gvk_tex_layers[tex]
let cb = gvk_once_begin()
var sw = w
var sh = h
for lv in 1 .. levels {
@ -363,7 +370,6 @@ function gvk_tex_mips(tex: int, w: int, h: int) -> bool {
sw = dw
sh = dh
}
return gvk_once_end(cb)
}
# Level 0 of layer 0 back to the CPU, laid out the OpenGL way when the layouts agree (the terrain
@ -491,33 +497,58 @@ var gvk_buf: []long = null
var gvk_buf_mem: []long = null
var gvk_buf_size: []int = null
var gvk_buf_map: []pointer = null # host-visible buffers stay mapped for their whole life
var gvk_buf_used: []int = null # the frame (gvk_frame_no) a draw last bound the buffer in
var gvk_frame_no: int = 1
# Storage a buffer was moved off, or freed, while the frame that used it has not been submitted:
# destroyed at gvk_retire_flush, after the frame's work is done.
var gvk_retired_buf: []long = null
var gvk_retired_mem: []long = null
function gvk_buf_new() -> int {
let zero: long = 0
if gvk_buf == null {
gvk_buf = new []long; gvk_buf_mem = new []long; gvk_buf_size = new []int; gvk_buf_map = new []pointer
gvk_buf_used = new []int; gvk_retired_buf = new []long; gvk_retired_mem = new []long
# handle 0 is "no buffer", as it is on OpenGL
push(gvk_buf, zero); push(gvk_buf_mem, zero); push(gvk_buf_size, 0); push(gvk_buf_map, null)
push(gvk_buf, zero); push(gvk_buf_mem, zero); push(gvk_buf_size, 0); push(gvk_buf_map, null); push(gvk_buf_used, 0)
}
push(gvk_buf, zero); push(gvk_buf_mem, zero); push(gvk_buf_size, 0); push(gvk_buf_map, null)
push(gvk_buf, zero); push(gvk_buf_mem, zero); push(gvk_buf_size, 0); push(gvk_buf_map, null); push(gvk_buf_used, 0)
return len(gvk_buf) - 1
}
function gvk_buf_release(b: int) -> void {
if gvk_buf[b] == 0 { return }
let zero: long = 0
Vk.unmap_memory(gvk_dev, gvk_buf_mem[b])
Vk.destroy_buffer(gvk_dev, gvk_buf[b], null)
Vk.free_memory(gvk_dev, gvk_buf_mem[b], null)
gvk_n_allocs -= 1
gvk_buf[b] = zero; gvk_buf_mem[b] = zero; gvk_buf_size[b] = 0; gvk_buf_map[b] = null
if gvk_buf_used[b] == gvk_frame_no {
# a draw recorded this frame still reads it: destroy it once the frame has been submitted
push(gvk_retired_buf, gvk_buf[b]); push(gvk_retired_mem, gvk_buf_mem[b])
} else {
Vk.unmap_memory(gvk_dev, gvk_buf_mem[b])
Vk.destroy_buffer(gvk_dev, gvk_buf[b], null)
Vk.free_memory(gvk_dev, gvk_buf_mem[b], null)
gvk_n_allocs -= 1
}
gvk_buf[b] = zero; gvk_buf_mem[b] = zero; gvk_buf_size[b] = 0; gvk_buf_map[b] = null; gvk_buf_used[b] = 0
}
# the frame's submitted work is done: storage retired during it can go
function gvk_retire_flush() -> void {
if gvk_retired_buf == null { return }
for i in 0 .. len(gvk_retired_buf) {
Vk.unmap_memory(gvk_dev, gvk_retired_mem[i])
Vk.destroy_buffer(gvk_dev, gvk_retired_buf[i], null)
Vk.free_memory(gvk_dev, gvk_retired_mem[i], null)
gvk_n_allocs -= 1
}
gvk_retired_buf = new []long
gvk_retired_mem = new []long
}
# Room for at least n bytes behind handle b; a buffer that is already big enough is kept, so a
# stream re-filled every frame allocates once.
function gvk_buf_reserve(b: int, n: int) -> bool {
if b <= 0 or b >= len(gvk_buf) { return false }
if gvk_buf[b] != 0 and gvk_buf_size[b] >= n { return true }
# already big enough, and no draw this frame reads what is there: fill it in place
if gvk_buf[b] != 0 and gvk_buf_size[b] >= n and gvk_buf_used[b] != gvk_frame_no { return true }
gvk_buf_release(b)
var size = n
if size < 64 { size = 64 }