ludic/examples/rendering/vk_resources.ludic
Orkuncakilkaya c660ac881c feat(render3d): Vulkan textures, samplers and buffers
gpu_vk_res.ludic gains what gpu.ludic's texture and buffer handles stand for on Vulkan:

- gvk_tex_storage / _upload / _mips / _read / _release: an image and view per handle, a full mip
  chain when pixels come with it (the renderer asks for mipmaps after the upload) and one level
  for a target, every level in SHADER_READ_ONLY between uses. Uploads are converted to what the
  image stores: a missing alpha filled opaque (three-channel formats are stored with four), 16-bit
  PNG samples byte-swapped when the unpack state says so, 32-bit float HDR halved into half
  floats. Mips are blitted down level by level; a read-back brings level 0 home.
- gvk_sampler: one VkSampler per filter / wrap / compare / anisotropy combination, made when first
  asked for, with GL's defaults where the renderer set nothing.
- gvk_buf_*: vertex, index and instance buffers behind one handle, kept when an upload fits.
  Host-visible while the backend comes up.

gpu_vk.ludic switches on anisotropic sampling where the device has it and reads its limit.

examples/rendering/vk_resources.ludic checks it all: VKRES OK, validation-clean, every allocation
freed, on the RTX 3070 Ti (16x anisotropy) and on MoltenVK. One run on the Mac crashed while a
headless game run was using the GPU and did not come back in two reruns. OpenGL frames
byte-identical at the five viewpoints; 59 self-tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:57:28 +03:00

97 lines
5 KiB
Text

# vk_resources.ludic — render3d's Vulkan resources on their own: textures uploaded the way the
# renderer uploads them and read back, a mip chain, the samplers the renderer asks for, and a
# buffer filled and grown. Run it under the validation layer on each machine.
#
# gpu_vk_res.ludic speaks OpenGL's names for formats and filters, as gpu.ludic does, and those
# names only exist where Gl.* is named - hence the one Gl.pixel_scale() below. Nothing here
# opens an OpenGL context or draws with one.
# bin/ludicc examples/rendering/vk_resources.ludic --headless -o build/vk_resources
import "../../packages/ludic.render3d/gpu_vk.ludic"
import "../../packages/ludic.render3d/gpu_vk_res.ludic"
program VkResources {
property Marker { on: int = 1 }
model Anchor { Marker }
var failures: int = 0
function check(what: string, ok: bool) -> void {
if ok { print(`vulkan: {what} ok`) } else { print(`vulkan: {what} FAILED`); failures += 1 }
}
handler Boot phase Start {
spawn Anchor {}
print(`vulkan: OpenGL names spliced (pixel scale {Gl.pixel_scale()})`)
if not gvk_init() { print(`VKRES NONE: {gvk_why}`); quit() }
# RGBA8 up and back, byte for byte
let w = 64
let h = 32
let px = bytes(w * h * 4)
for i in 0 .. w * h { px[i * 4] = i & 255; px[i * 4 + 1] = (i >> 8) & 255; px[i * 4 + 2] = (i * 7) & 255; px[i * 4 + 3] = 200 }
let t1 = gvk_tex_new()
check("RGBA8 storage with mips", gvk_tex_storage(t1, false, GL_RGBA8, w, h, 1, true))
check("RGBA8 upload", gvk_tex_upload(t1, GL_RGBA8, w, h, 1, GL_RGBA, GL_UNSIGNED_BYTE, px))
check("RGBA8 mip chain (7 levels)", gvk_tex_levels[t1] == 7 and gvk_tex_mips(t1, w, h))
let back = bytes(w * h * 4)
check("RGBA8 read-back call", gvk_tex_read(t1, GL_RGBA8, w, h, GL_RGBA, GL_UNSIGNED_BYTE, back))
var same = true
for i in 0 .. w * h * 4 { if back[i] != px[i] { same = false } }
check("RGBA8 read-back matches", same)
# R32F, the terrain's height field path
let hf = bytes(w * h * 4)
for i in 0 .. w * h { Vk.put_i32(hf, i * 4, 0x40000000 + i) }
let t2 = gvk_tex_new()
check("R32F target", gvk_tex_storage(t2, false, GL_R32F, w, h, 1, false))
check("R32F upload", gvk_tex_upload(t2, GL_R32F, w, h, 1, GL_RED, GL_FLOAT, hf))
let hb = bytes(w * h * 4)
check("R32F read-back call", gvk_tex_read(t2, GL_R32F, w, h, GL_RED, GL_FLOAT, hb))
var hsame = true
for i in 0 .. w * h * 4 { if hb[i] != hf[i] { hsame = false } }
check("R32F read-back matches", hsame)
# RGB8 stored as RGBA, and an HDR image's float RGB into half floats
let rgb = bytes(w * h * 3)
for i in 0 .. w * h * 3 { rgb[i] = (i * 3) & 255 }
let t3 = gvk_tex_new()
check("SRGB8 expanded to RGBA", gvk_tex_storage(t3, false, GL_SRGB8, w, h, 1, true) and gvk_tex_upload(t3, GL_SRGB8, w, h, 1, GL_RGB, GL_UNSIGNED_BYTE, rgb))
let fl = bytes(w * h * 12)
for i in 0 .. w * h * 3 { Vk.put_i32(fl, i * 4, 0x3FC00000) }
let t4 = gvk_tex_new()
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)
# 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))
let t6 = gvk_tex_new()
check("RGBA16F target", gvk_tex_storage(t6, false, GL_RGBA16F, 320, 180, 1, false))
# the samplers the renderer asks for, and that asking twice returns the same one
let s1 = gvk_sampler(GL_LINEAR_MIPMAP_LINEAR, GL_LINEAR, GL_REPEAT, GL_REPEAT, 0, 0x41800000)
let s2 = gvk_sampler(GL_LINEAR, GL_LINEAR, GL_CLAMP_TO_EDGE, GL_CLAMP_TO_EDGE, 0, 0)
let s3 = gvk_sampler(GL_LINEAR, GL_LINEAR, GL_CLAMP_TO_BORDER, GL_CLAMP_TO_BORDER, GL_LEQUAL, 0)
let s4 = gvk_sampler(GL_NEAREST, GL_NEAREST, GL_CLAMP_TO_EDGE, GL_CLAMP_TO_EDGE, 0, 0)
let again = gvk_sampler(GL_LINEAR_MIPMAP_LINEAR, GL_LINEAR, GL_REPEAT, GL_REPEAT, 0, 0x41800000)
check("four samplers made", s1 != 0 and s2 != 0 and s3 != 0 and s4 != 0 and len(gvk_smp) == 4)
check("a sampler asked for twice is one sampler", again == s1)
# a buffer filled, filled again smaller (kept), then larger (grown)
let b = gvk_buf_new()
let data = bytes(4096)
for i in 0 .. 4096 { data[i] = i & 255 }
check("buffer upload", gvk_buf_upload(b, 1024, data))
let first = gvk_buf[b]
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) }
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`)
check("every allocation freed", gvk_n_allocs == 0)
gvk_shutdown()
if failures == 0 { print("VKRES OK") } else { print(`VKRES FAILED ({failures})`) }
quit()
}
}