feat(render3d): compute and indirect draws on Vulkan, and tree layers culled on the GPU (opt-in)

- Device: multiDrawIndirect, drawIndirectFirstInstance and drawIndirectCount where present.
- Buffers carry storage and indirect usage; a GPU-owned buffer is never swapped under a draw.
- Compute programs from shaders/compute.list (binding 0 parameters, 1.. storage buffers),
  built by `ludic-dev shaders`; gpu_compute / gpu_dispatch / gpu_draw_mesh_indirect in gpu.ludic.
- R3D_VK_PROBE=1: a dispatch read back (OK on the RTX 3070 Ti).
- scatter_cull.comp: a tree layer's frustum test and LOD split on the GPU, with the lit, prepass,
  impostor and shadow-LOD draws reading its records. Behind R3D_GPU_CULL=1 and off by default:
  at the camp it is slower (43.0 fps against 53.3), because the frame's cost is per-draw
  descriptor sets and it adds empty-level draws. Validation-clean; OpenGL frames unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-15 14:19:43 +03:00
parent 04cda22d19
commit c1eb5f399f
11 changed files with 463 additions and 15 deletions

View file

@ -37,6 +37,9 @@ function gvk_ext_in(props: bytes, n: int, want: string) -> bool {
# Instance, the first discrete GPU (else the first listed), its first graphics queue, and a
# device with the Tier 1 floor switched on. False, with gvk_why set, if any of it is missing:
# the caller stays on OpenGL.
var gvk_has_mdi: bool = false # multiDrawIndirect + drawIndirectFirstInstance
var gvk_has_dic: bool = false # drawIndirectCount
function gvk_init() -> bool {
if gvk_ready { return true }
if Vk.open() == 0 { gvk_why = "no Vulkan loader"; return false }
@ -145,6 +148,16 @@ function gvk_init() -> bool {
Vk.put_i32(want2, VkPhysicalDeviceFeatures2_sType, VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2)
Vk.put_ptr(want2, VkPhysicalDeviceFeatures2_pNext, want12)
if aniso { Vk.put_i32(want2, VkPhysicalDeviceFeatures2_features + VkPhysicalDeviceFeatures_samplerAnisotropy, 1) }
# GPU-driven drawing: one indirect buffer holds a layer's draws, and a compute pass may write
# how many of them there are. Asked for where the device has them; the renderer checks
# gvk_has_mdi / gvk_has_dic before it takes that path.
gvk_has_mdi = Vk.get_i32(f2, VkPhysicalDeviceFeatures2_features + VkPhysicalDeviceFeatures_multiDrawIndirect) == 1 and Vk.get_i32(f2, VkPhysicalDeviceFeatures2_features + VkPhysicalDeviceFeatures_drawIndirectFirstInstance) == 1
if gvk_has_mdi {
Vk.put_i32(want2, VkPhysicalDeviceFeatures2_features + VkPhysicalDeviceFeatures_multiDrawIndirect, 1)
Vk.put_i32(want2, VkPhysicalDeviceFeatures2_features + VkPhysicalDeviceFeatures_drawIndirectFirstInstance, 1)
}
gvk_has_dic = Vk.get_i32(f12, VkPhysicalDeviceVulkan12Features_drawIndirectCount) == 1
if gvk_has_dic { Vk.put_i32(want12, VkPhysicalDeviceVulkan12Features_drawIndirectCount, 1) }
Vk.put_i32(cnt, 0, 0)
Vk.enumerate_device_extension_properties(gvk_pd, null, cnt, null)
@ -186,7 +199,7 @@ function gvk_init() -> bool {
if not gvk_cmd_init() { return false }
gvk_ready = true
print(`r3d: vulkan on {gvk_device_name}, anisotropy up to {gvk_aniso_x(gvk_max_aniso)}x`)
print(`r3d: vulkan on {gvk_device_name}, anisotropy up to {gvk_aniso_x(gvk_max_aniso)}x, multi-draw indirect {gvk_has_mdi}, indirect count {gvk_has_dic}`)
return true
}