feat(render3d): R3D_GFX=vk draws the frame through Vulkan, headless

gpu.ludic's calls branch to the Vulkan backend when it was chosen and came up; every OpenGL
statement is unchanged, only guarded. R3D_GFX=vk selects it in a headless run (the window's
swapchain is the next milestone) and falls back to OpenGL, with the reason, when the device or
the SPIR-V manifest is missing.

- Render state is cached as before and turned into pipelines at the draw; u_* and sampler binds
  go into the variant's uniform blocks; meshes, buffers and textures are gvk_* objects;
  framebuffer binds are dynamic-rendering passes; same-size blits are image copies; the screen,
  the photograph read-back, the present and the screenshot go through the frame.
- Work that submits on its own (uploads, read-backs, new or freed images and buffers) flushes the
  frame first, so it runs in OpenGL's order. A read may take fewer channels than the image has
  (the height field's R from its RGBA32F bake). Pipeline keys name vertex bindings by order, not
  buffer handle, so re-pointed instance buffers keep their pipeline.

The valley renders at frame 90 validation-clean on the RTX 3070 Ti and on MoltenVK. OpenGL frames
byte-identical at the five viewpoints; 59 self-tests pass with no GL error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-15 12:20:16 +03:00
parent 451bc9063c
commit 072f9a848e
3 changed files with 290 additions and 73 deletions

View file

@ -176,13 +176,20 @@ function gvk_depth_op(f: int) -> int {
return VK_COMPARE_OP_LESS
}
# the vertex layout a mesh recorded (gpu.ludic's attrs), as part of a pipeline key
# Buffers are named by the order they are first read in, not by handle: a scatter mesh re-pointed
# at another instance buffer keeps its layout, and so its pipeline.
function gvk_layout_key(m: Mesh) -> string {
if m == null or m.attrs == null { return "none" }
var k = ""
let seen = words(GPU_MAX_ATTRS)
var ns = 0
for i in 0 .. m.n_attrs {
let o = i * GPU_ATTR_W
if m.attrs[o + 1] == 0 { continue }
k = k + `{i}:{m.attrs[o]}:{m.attrs[o + 1]}:{m.attrs[o + 2]}:{m.attrs[o + 3]}:{m.attrs[o + 4]}:{m.attrs[o + 5]}:{m.attrs[o + 6]};`
var bi = -1
for q in 0 .. ns { if seen[q] == m.attrs[o] and bi < 0 { bi = q } }
if bi < 0 { bi = ns; seen[ns] = m.attrs[o]; ns += 1 }
k = k + `{i}:{bi}:{m.attrs[o + 1]}:{m.attrs[o + 2]}:{m.attrs[o + 3]}:{m.attrs[o + 4]}:{m.attrs[o + 5]}:{m.attrs[o + 6]};`
}
return k
}
@ -850,6 +857,16 @@ function gvk_draw(p: int, m: Mesh, st: GvkState, first: int, count: int, instanc
}
}
# The frame so far, submitted and waited for, so work that submits on its own - an upload, a
# read-back, a new or freed image or buffer - happens after the draws recorded before it, in the
# order OpenGL would have done them. Costs a submit per such call while the backend comes up.
function gvk_flush() -> void {
if gvk_cb == null { return }
gvk_pass_end()
gvk_once_end(gvk_cb)
gvk_cb = null
}
# the finished frame: submitted, and waited for
function gvk_present() -> void {
if gvk_cb == null { return }
@ -883,3 +900,136 @@ function gvk_screenshot(path: string) -> bool {
file_close(f)
return true
}
# ---- what gpu.ludic's Vulkan branches call -----------------------------------------------------
var gvk_fb_counter: int = 0
var gvk_prog_counter: int = 0
var gvk_wireframe: int = 0
var gvk_state: GvkState = null
# the manifest the programs are looked up in, from the renderer's own shader directory
function gvk_manifest() -> bool {
r3d_find_root()
gvk_spv_dir = `{r3d_root}/shaders/spv`
return gpu_manifest_load(`{gvk_spv_dir}/manifest.txt`) > 0 and len(gpu_variants) > 0
}
# headless: the screen is a colour and a depth image of the asked-for size
function gvk_open(w: int, h: int) -> bool {
gl_w = w
gl_h = h
if not gvk_frame_init() { return false }
return gvk_screen_make(w, h)
}
# a program handle for a variant; the key is gpu_program's, so gpu_program_key works on both
function gvk_program_new(vs: string, fs: string, defines: string) -> int {
gvk_prog_counter += 1
let p = gvk_prog_counter
let key = `{vs}|{fs}|{Text.replace(defines, "\n", ";")}`
if gpu_prog_ids == null { gpu_prog_ids = new []int; gpu_prog_keys = new []string }
push(gpu_prog_ids, p)
push(gpu_prog_keys, key)
if not gvk_program(p, key, gvk_spv_dir) { return 0 }
return p
}
function gvk_mesh_free(m: Mesh) -> void {
if m == null { return }
if m.vbufs != null { for i in 0 .. m.n_vbufs { if m.vbufs[i] > 0 { gvk_buf_release(m.vbufs[i]) } } }
m.n_vbufs = 0
m.vbo = 0
if m.ebo > 0 { gvk_buf_release(m.ebo); m.ebo = 0 }
}
function gvk_scissor(x: int, y: int, w: int, h: int) -> void {
if gvk_sc == null { return }
gvk_sc[0] = 1; gvk_sc[1] = x; gvk_sc[2] = y; gvk_sc[3] = w; gvk_sc[4] = h
}
function gvk_scissor_off() -> void { if gvk_sc != null { gvk_sc[0] = 0 } }
function gvk_viewport(x: int, y: int, w: int, h: int) -> void {
if gvk_vp == null { return }
gvk_vp[0] = x; gvk_vp[1] = y; gvk_vp[2] = w; gvk_vp[3] = h
}
function gvk_clear_color(r: int, g: int, b: int, a: int) -> void {
if gvk_clear_rgba == null { return }
gvk_clear_rgba[0] = r; gvk_clear_rgba[1] = g; gvk_clear_rgba[2] = b; gvk_clear_rgba[3] = a
}
function gvk_fb_colors(fb: int, n: int) -> void { if gvk_fb_ncolor != null and fb >= 0 and fb < 4096 { gvk_fb_ncolor[fb] = n } }
function gvk_fb_forget(fb: int) -> void {
if fb == gvk_fb_cur { gvk_pass_end() }
gvk_fb_colors(fb, 1)
}
# the render state gpu.ludic has cached, with OpenGL's defaults where nothing was set yet
function gvk_state_now() -> GvkState {
if gvk_state == null { gvk_state = new GvkState }
let st = gvk_state
st.depth_test = 0; if gpu_s_depth_test == 1 { st.depth_test = 1 }
st.depth_write = 1; if gpu_s_depth_write == 0 { st.depth_write = 0 }
st.depth_func = GL_LESS; if gpu_s_depth_func > 0 { st.depth_func = gpu_s_depth_func }
st.blend = 0; if gpu_s_blend == 1 { st.blend = 1 }
st.blend_src = GL_ONE; if gpu_s_blend_src >= 0 { st.blend_src = gpu_s_blend_src }
st.blend_dst = GL_ZERO; if gpu_s_blend_dst >= 0 { st.blend_dst = gpu_s_blend_dst }
st.cull = 0; if gpu_s_cull == 1 { st.cull = 1 }
st.cull_face = GL_BACK; if gpu_s_cull_face > 0 { st.cull_face = gpu_s_cull_face }
st.color_write = 1; if gpu_s_color_write == 0 { st.color_write = 0 }
st.a2c = 0; if gpu_s_a2c == 1 { st.a2c = 1 }
st.bias = 0; if gpu_s_bias == 1 { st.bias = 1 }
st.bias_factor = gpu_s_bias_f
st.bias_units = gpu_s_bias_u
st.wireframe = gvk_wireframe
return st
}
function gvk_draw_now(m: Mesh, first: int, count: int, instances: int) -> void {
gvk_draw(gpu_prog_cur, m, gvk_state_now(), first, count, instances, gpu_tx, GPU_TX_W, gpu_tx_cap, gpu_fb, gpu_fb_at(gvk_fb_cur))
}
# the colour (and / or depth) of the read framebuffer into the draw framebuffer, same size
function gvk_fb_att(fb: int, depth: bool) -> int {
if fb == 0 { if depth { return gvk_screen_depth }; return gvk_screen_color }
let o = gpu_fb_at(fb)
if o < 0 { return 0 }
if depth { return gpu_fb[o + 2] }
return gpu_fb[o]
}
function gvk_copy(cb: pointer, src: int, dst: int, depth: bool, w: int, h: int) -> void {
if src <= 0 or dst <= 0 or gvk_tex_image[src] == 0 or gvk_tex_image[dst] == 0 { return }
gvk_barrier(cb, gvk_tex_image[src], depth, 0, 1, gvk_tex_layers[src], VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL)
gvk_barrier(cb, gvk_tex_image[dst], depth, 0, 1, gvk_tex_layers[dst], VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL)
let ic = bytes(VkImageCopy_sizeof)
Vk.zero(ic, VkImageCopy_sizeof)
var aspect = VK_IMAGE_ASPECT_COLOR_BIT
if depth { aspect = VK_IMAGE_ASPECT_DEPTH_BIT }
Vk.put_i32(ic, VkImageCopy_srcSubresource + VkImageSubresourceLayers_aspectMask, aspect)
Vk.put_i32(ic, VkImageCopy_srcSubresource + VkImageSubresourceLayers_layerCount, 1)
Vk.put_i32(ic, VkImageCopy_dstSubresource + VkImageSubresourceLayers_aspectMask, aspect)
Vk.put_i32(ic, VkImageCopy_dstSubresource + VkImageSubresourceLayers_layerCount, 1)
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, gvk_tex_image[src], VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, gvk_tex_image[dst], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ic)
gvk_barrier(cb, gvk_tex_image[src], depth, 0, 1, gvk_tex_layers[src], VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
gvk_barrier(cb, gvk_tex_image[dst], depth, 0, 1, gvk_tex_layers[dst], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
}
function gvk_blit(w: int, h: int, mask: int) -> void {
gvk_pass_end()
let cb = gvk_frame_cb()
if (mask & GL_COLOR_BUFFER_BIT) != 0 { gvk_copy(cb, gvk_fb_att(gvk_fb_read, false), gvk_fb_att(gvk_fb_draw, false), false, w, h) }
if (mask & GL_DEPTH_BUFFER_BIT) != 0 { gvk_copy(cb, gvk_fb_att(gvk_fb_read, true), gvk_fb_att(gvk_fb_draw, true), true, w, h) }
}
# the screen as RGB8, bottom row first, as glReadPixels hands it back (a photograph)
function gvk_read_screen(w: int, h: int, out: pointer) -> void {
gvk_present()
let px = bytes(gvk_screen_w * gvk_screen_h * 4)
if not gvk_tex_read(gvk_screen_color, GL_RGBA8, gvk_screen_w, gvk_screen_h, GL_RGBA, GL_UNSIGNED_BYTE, px) { return }
let dst: pointer = out
for y in 0 .. h {
for x in 0 .. w {
let o = (y * gvk_screen_w + x) * 4
let q = (y * w + x) * 3
dst[q] = px[o]; dst[q + 1] = px[o + 1]; dst[q + 2] = px[o + 2]
}
}
}