render3d(vulkan): GPU-driven trees - one merged mesh per material, one draw per material; on by default

Every level of a kit tree or rock carries the same materials, so a GPU-culled layer merges each
material's levels into one mesh (layer_arena_build) and scatter_cull.comp writes a record per
(material, level) naming that level's index and vertex range; one indirect draw covers them all.
A conifer's lit pass and prepass go from 8 draws to 2, its shadow LOD from 6 to 2. Layers that do
not fit (card levels, other materials or attributes, 32-bit indices) keep the CPU path.

GPU culling is now the Vulkan default (R3D_GPU_CULL=0 turns it off). Camp benchmark, 400 frames:
PC 2791 -> 2657 draws, 4.3 -> 4.1 s (both runs); Mac 2791 -> 2657, 8.0/7.4 -> 7.7/7.2 s.
Self-tests 59/59 on both machines, PC validation 0 errors; frames within run-to-run noise; OpenGL
frames unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-15 16:44:00 +03:00
parent bda53f759e
commit d8300a7bf9
4 changed files with 130 additions and 26 deletions

View file

@ -27,10 +27,12 @@ layout(set = 0, binding = 0) uniform Params {
layout(set = 0, binding = 1) readonly buffer Src { float src[]; };
layout(set = 0, binding = 2) buffer Dst { float dst[]; };
// VkDrawIndexedIndirectCommand records, 5 uints each; the CPU fills indexCount / firstIndex /
// vertexOffset / firstInstance once, this writes instanceCount:
// 0 .. 15 level * 4 + prim the lit pass and the depth prepass
// 16 the impostor card bucket n_lods
// 17 .. 28 17 + bucket * 4 + prim level 2's mesh cast from buckets 0 .. 2 (the shadow LOD)
// vertexOffset / firstInstance once, this writes instanceCount. A prim is a material of the layer's
// merged meshes (scatter.ludic, layer_arena_build), and one material's records sit together so a
// single draw covers every level of it:
// 0 .. 15 prim * 4 + level the lit pass and the depth prepass
// 16 the impostor card bucket n_lods
// 17 .. 28 17 + prim * 3 + bucket level 2's range cast from buckets 0 .. 2 (the shadow LOD)
layout(set = 0, binding = 3) buffer Cmds { uint cmds[]; };
// how many instances landed in each bucket (levels, then the impostor), for the CPU to read
layout(set = 0, binding = 4) buffer Counts { uint counts[5]; };
@ -67,14 +69,14 @@ void main() {
}
for (uint k = 0u; k < 4u; k++) {
for (uint j = 0u; j < 4u; j++) {
uint c = (k * 4u + j) * 5u;
uint c = (j * 4u + k) * 5u;
cmds[c + 1u] = (k < n && j < pr.prims[k]) ? cnt[k] : 0u;
}
}
cmds[16u * 5u + 1u] = (pr.has_imp != 0u) ? cnt[n] : 0u;
for (uint b = 0u; b < 3u; b++) {
for (uint j = 0u; j < 4u; j++) {
uint c = (17u + b * 4u + j) * 5u;
uint c = (17u + j * 3u + b) * 5u;
cmds[c + 1u] = (n > 2u && b < n && j < pr.prims[2]) ? cnt[b] : 0u;
}
}