# ==GPU-driven visibility== for large worlds <p class="doc-sub">// status: seedling</p> The traditional renderer asks the CPU to decide what to draw, then asks the GPU to draw it. That works until the scene contains millions of instances, a deep world hierarchy, or enough small meshes that submission overhead dominates. A GPU-driven renderer keeps resident scene data on the GPU, runs culling and LOD selection in compute, compacts the survivors, and feeds indirect draw commands directly to the graphics queue. The CPU still owns streaming, input, and high-level world policy. It simply stops making a per-object draw decision every frame. This is a natural extension of [[Compute shaders]], [[Spatial acceleration structures]], and the indirect-command details in [[Vulkan - learning log]] and [[OpenGL - learning log]]. ## What “GPU-driven” actually means There is a useful spectrum rather than one switch: | Renderer | CPU work per frame | GPU work | Good fit | |---|---|---|---| | CPU submission | walk objects, cull, sort, submit | draw visible objects | small scenes, simple tools | | GPU frustum culling | upload camera, submit one cull pass | reject bounds, write visible list | many instances, modest overdraw | | GPU LOD + compaction | upload resident scene only | choose LOD and build commands | large worlds with streaming | | GPU Hi-Z occlusion | upload camera and depth history | frustum + hierarchical depth tests | dense city / indoor scenes | | meshlet / mesh-shader pipeline | submit a few dispatches | task amplification and fine culling | very small triangles, modern hardware | The important boundary is **residency**. A compute shader can cull only objects whose bounds and draw data are already in GPU memory. Streaming a new terrain page or mesh remains a CPU/IO job; GPU visibility decides which resident pages get work. ## Scene data layout A structure-of-arrays layout is often friendlier to culling than an object-heavy array: ```c struct Instance { vec3 boundsCenter; float boundsRadius; uint meshId; uint materialId; uint lodBase; mat4 objectToWorld; }; struct DrawIndexedIndirectCommand { uint indexCount; uint instanceCount; uint firstIndex; int vertexOffset; uint firstInstance; }; ``` For a static world, store bounds in cell or page coordinates and apply a camera-relative origin in the shader. For animated or non-uniformly scaled objects, a precomputed world-space sphere is conservative and cheap; an axis-aligned box rejects more but costs more plane tests. If the object transform changes, update its bounds too — stale bounds produce both missing geometry and wasted work. The visible output can be either: - a compact `visibleInstanceIds[]` list consumed by the vertex shader; - one indirect command per mesh/material bucket; - one command per instance when the API and scene shape make that affordable. One command per object is easy to reason about but can recreate the submission overhead the GPU-driven design was meant to remove. Group by mesh, material, or LOD where possible. A `firstInstance` value can index a visible-instance table so a shared mesh is drawn once per bucket. ## Pass 1: frustum culling and LOD selection Extract six frustum planes from the camera's view-projection matrix. For a bounding sphere, the test is a dot product per plane: ```glsl bool sphereInsideFrustum(vec3 center, float radius, Plane planes[6]) { for (uint i = 0u; i < 6u; ++i) { if (dot(planes[i].normal, center) + planes[i].distance < -radius) return false; } return true; } uint chooseLod(float projectedRadius, LodTable lods[]) { // Hysteresis thresholds prevent a camera hovering at a boundary // from switching every frame. for (uint i = 0u; i < lodCount; ++i) if (projectedRadius >= lods[i].minPixels) return i; return lodCount - 1u; } ``` Projected radius can be approximated from distance and vertical field of view: ```c float pixelsPerWorldUnit = viewportHeight / (2.0f * tan(0.5f * verticalFov)); float projectedRadius = boundsRadius * pixelsPerWorldUnit / distanceToCamera; ``` For a hierarchy, test the parent first. If it is outside, reject every child. If it is fully inside, accept the subtree without repeating six plane tests per leaf; otherwise descend. A [[Spatial acceleration structures|BVH]], loose octree, or page hierarchy all work. The best structure is the one that matches how the world streams and how tightly its bounds fit. A direct append version is useful as a prototype: ```glsl layout(local_size_x = 128) in; layout(std430, binding = 0) readonly buffer Instances { Instance instance[]; }; layout(std430, binding = 1) writeonly buffer VisibleIds { uint id[]; }; layout(std430, binding = 2) buffer Counter { uint visibleCount; }; void main() { uint i = gl_GlobalInvocationID.x; if (i >= instanceCount) return; Instance x = instance[i]; if (!sphereInsideFrustum(x.boundsCenter, x.boundsRadius, frustum)) return; uint slot = atomicAdd(visibleCount, 1u); if (slot < visibleCapacity) { id[slot] = i; lodForInstance[slot] = chooseLod(projectedRadius(x), lods); } } ``` For a dense list, replace the global atomic with a count–scan–scatter pass like [[Marching Cubes on the GPU - Count, Scan, Emit]]. Each invocation writes a `0/1` visibility flag, scans it, and scatters survivors to exact addresses. This costs extra dispatches but gives deterministic ordering and makes per-bucket counts easier to build. ## Hierarchical Z occlusion Frustum culling cannot see that a building is behind another building. A hierarchical depth buffer (Hi-Z) supplies a conservative occlusion query without issuing one query per object: 1. Render or reuse a depth buffer from a previous frame. 2. Build a mip pyramid where each texel represents a conservative depth over a larger rectangle. 3. Project the object's bounds to screen space. 4. Select a mip whose texel footprint covers the projected rectangle. 5. Sample the relevant texels and compare the object's nearest depth to the pyramid's occluder depth. The reduction direction depends on the depth convention. With ordinary depth where smaller means nearer, the pyramid usually stores a minimum or a conservative value chosen so that a false **visible** result is possible but a false **occluded** result is not. With reversed-Z, the comparison and reduction direction change. Write the invariant in a test before optimising it: ```text if the test says “occluded”, every pixel covered by the object's projected bounds must be hidden by geometry according to the chosen depth convention. ``` An illustrative compute test: ```glsl ScreenRect rect = projectBounds(instance.bounds, viewProjection); uint mip = chooseMipForRect(rect.sizePixels); float pyramidDepth = sampleConservativeDepth(hiZ, rect, mip); float nearestObjectDepth = projectNearestDepth(instance.bounds, viewProjection); bool occluded = depthBehind(nearestObjectDepth, pyramidDepth); if (!occluded) appendVisible(instanceId); ``` Use the previous frame's depth for a first implementation. It makes the pass naturally asynchronous, but newly revealed objects can remain hidden for one frame. Keep a visibility history or a one-frame grace period, and always treat near-camera / large projected bounds conservatively. A wrong occlusion test causes holes, which are much harder to spot than harmless extra draws. ## Indirect drawing and synchronization On OpenGL, the compute pass writes a buffer bound as `GL_DRAW_INDIRECT_BUFFER`, then a draw consumes it with `glDrawElementsIndirect` or a multi-draw variant. Before the draw, issue a memory barrier that includes command data and any generated vertex/instance data: ```c glMemoryBarrier(GL_COMMAND_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT | GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT | GL_ELEMENT_ARRAY_BARRIER_BIT); ``` `GL_COMMAND_BARRIER_BIT` is the part that makes indirect command data visible to the later draw. The other bits correspond to the later resource uses; keep them precise when debugging and benchmark narrower masks after correctness is established. Vulkan makes the same dependency explicit with a barrier from compute shader writes to indirect command reads and vertex input reads. The command can be `vkCmdDrawIndexedIndirect`, or an indirect-count variant when the GPU writes the number of commands. The device limit `maxDrawIndirectCount` still applies, and count extensions/core versions need to be checked during device setup. See [[Vulkan - learning log]] for the general barrier model. The frame sequence is usually: ```text update camera + resident page table → cull / choose LOD → optional Hi-Z test → compact visible instances and build indirect commands → compute-to-draw barrier → depth / opaque / transparent draws → build next Hi-Z pyramid ``` If the depth pyramid is built after opaque draws, it can be used for the next frame. If it is built before culling in the same frame, the resource dependency is more direct but the CPU/GPU schedule may be less overlapped. ## Large-world details Visibility is not a substitute for world-coordinate design: - Keep page and cell coordinates as integers or doubles on the CPU. - Subtract a camera origin before sending positions to GPU shaders so local floats retain precision. - Put bounds and page IDs in the same rebased space as the current camera. - Do not let a nonresident page appear visible without a fallback proxy or a guaranteed streaming deadline. - Use temporal hysteresis for both LOD and occlusion, otherwise camera movement makes geometry pop. - Test the pathological views: looking across the entire world, looking straight down a street, and standing inside a large bound. Terrain is a particularly good fit. A quadtree or clipmap chooses resident terrain pages, a GPU pass culls their chunk bounds, and the generated meshes from [[Marching Cubes]] or [[Dual Contouring]] become ordinary indirect draws. The CPU still schedules dirty chunks and streaming; it does not need to submit each visible chunk individually. ## What I would build first 1. GPU frustum test over a flat instance array. 2. One visible-ID buffer and one indirect command per material/mesh bucket. 3. A debug mode drawing every bound in a unique colour. 4. LOD hysteresis and page residency. 5. Hi-Z occlusion using last frame's depth. 6. Hierarchical traversal and meshlet-level culling only after captures show that object-level culling is no longer the bottleneck. This order keeps missing geometry attributable. A dozen interacting culling heuristics make the first bug almost impossible to localise. ## Things that tripped me up - **The bound must be conservative.** A too-small sphere is not an optimisation; it is a missing-object bug. - **A world-space AABB is not stable under rotation.** Recompute it, use a sphere, or transform all eight corners. - **Reversed-Z changes Hi-Z logic.** Check both the pyramid reduction and the comparison, not only the projection matrix. - **Indirect commands are just buffers until synchronized.** A compute shader can finish writing while the draw still sees the old command. - **Atomic append capacity needs a guard.** Keep an overflow flag and render a visible warning in development. - **Occlusion has latency.** Use a grace frame or hysteresis so a camera turn does not hide newly visible geometry. - **GPU culling cannot make nonresident data resident.** Streaming and visibility are cooperating systems with different owners. - **Large coordinates poison float bounds.** Rebase positions and bounds together; debugging only the camera matrix misses the real source of jitter. - **One draw per instance can still be too many draws.** Compact by mesh/material and let `firstInstance` index a visible table. ## References - [OpenGL 4.6 Core Specification — indirect commands and shader memory barriers (Khronos)](https://registry.khronos.org/OpenGL/specs/gl/glspec46.core.pdf) - [Vulkan API Specification — indirect drawing and synchronization (Khronos)](https://registry.khronos.org/vulkan/specs/latest/html/) - [OpenGL `ARB_multi_draw_indirect` extension specification (Khronos)](https://registry.khronos.org/OpenGL/extensions/ARB/ARB_multi_draw_indirect.txt) - [Vulkan `VK_KHR_draw_indirect_count` extension specification (Khronos)](https://registry.khronos.org/vulkan/specs/latest/man/html/VK_KHR_draw_indirect_count.html) - [Karras, “Maximizing Parallelism in the Construction of BVHs, Octrees, and k-d Trees” (NVIDIA Research)](https://research.nvidia.com/publication/2012-06_Maximizing-Parallelism-Construction-BVHs-Octrees-and) - [GPU Driven Rendering Pipelines (SIGGRAPH course notes)](https://advances.realtimerendering.com/s2015/) --- Back to [[Notes/Index|Notes]] · see also [[Compute shaders]] · [[Spatial acceleration structures]] · [[Deferred vs forward rendering]] · [[OpenGL - learning log]] · [[Vulkan - learning log]] · [[Raym - Interactive Terrain Generation with Marching Cubes|Raym]]