Add the stage-2 compute chain to the main frame:
- ComputePass wrapper + compile script for compute SPIR-V (hzb_build /
gpu_cull / gpu_emit); bindless set extended with cull UBO, visible flags,
compacted instances, indirect commands, HZB buffer and GBuffer depth.
- HZB: R32F max-downsampled mip chain per frame slot built from the
GBuffer depth after the GBuffer pass (buffer-based; offsets header at slot
start). Same-frame self-occlusion handled by testing the bounding sphere's
FRONT depth, and near-zero depth (uninitialized/corner garbage) never
occludes.
- Cull: per-instance Gribb-Hartmann frustum test + HZB occlusion test, flags
written per instance; emit compacts visible instances per CPU-provided
bucket layout and fills per-bucket indirect commands with atomic counters.
- Frame wiring: begin3DFrame defers opening the scene color pass when the
cull chain is live, so the compute section records before the opaque
draws; RenderSystem3D uploads sorted instances, runs cull+emit, opens the
pass and draws with GPU-written commands (per-bucket, empty buckets no-op).
Fallback to stage 1 when compute caps are missing. gpuDriven now forces
the GBuffer pass (HZB source).
- Known limitation: HZB is built from the same-frame GBuffer, so objects
already in the GBuffer cannot be occlusion-culled until the GBuffer itself
is culled (stage 3 vis buffer); the chain is verified with frustum culling
(2 of 5 instances culled) and pixel parity.
Tests: GpuDriven.opaqueForwardCullParity (5 spheres, 2 outside frustum ->
3 visible / 3 non-empty buckets, pixel parity < 0.03); all 6 GpuDriven
tests pass (integrated GPU; the AMD driver intermittently crashes at
vkCreateImage during init - environmental).
Stage 1 (CPU indirect draw) produced no geometry for the second draw:
- mesh3d_gpudriven.vert added pc.firstInstance on top of gl_InstanceIndex,
which already includes VkDrawIndexedIndirectCommand.firstInstance per the
Vulkan spec; the second command read instance 2 instead of 1 and sampled
garbage matrices. Drop the push-constant indirection entirely.
- gpuDrivenSubmitOpaque never bound vertex/index buffers; each GpuMesh owns
its own buffer pair (vertexBase/firstIndex are 0), so draws ran against
whatever the legacy path left bound. Track meshRecordOwners_ parallel to
the GPU mesh table and bind per-mesh at bucket boundaries.
Test: GpuDriven.opaqueForwardParityMultiTexture now uses a distinct bindless
slot-1 albedo on the second sphere; all 5 GpuDriven tests + IndirectBuilder
tests pass (integrated GPU). Also add the shared Windows crash-backtrace
handler (CrashHandler.h) to unit_test and document the repo debugging
playbook (incl. stale-object/layout pitfalls with unscanned Ninja deps) in
AGENTS.md.
End-to-end parity testing (gpuDriven on/off pixel comparison) exposed and
fixed several shading-path bugs, and documented two hard AMD driver
limitations on this machine (Radeon AI PRO R9700):
1. Missing ambient in the GPU-driven frame UBO: the per-frame UBO only set
ambient.w; ambient.rgb came from stale state. Now mirrors the legacy
draw path (mesh3dLighting.ambient).
2. Normal-map fallback: materials without a normal texture clamped to the
bindless slot 0, which is the WHITE placeholder - the legacy path uses a
(128,128,255) flat-normal texture. The fragment shader now skips
applyNormalMap when the material has no normal texture.
3. AMD driver limitation A: vkUpdateDescriptorSets on the sampler array
binding hangs for writes of >= 4 elements; placeholder fill uses
single-element writes (~1.1k calls at init, ~3 ms).
4. AMD driver limitation B: descriptor-array dynamic indexing only returns
correct results for element 0. Scenes with more than one distinct
texture therefore fall back to the legacy path via the new
gpuDrivenMaterialUsable() gate (RenderSystem3D eligibility check). The
proper fix (texture-array/atlas with layer indexing) is Phase 3 work.
Adds GpuDriven.opaqueForwardParity: renders the same single-texture scene
with the legacy and GPU-driven paths and asserts per-pixel luma agreement
(max delta < 0.03). Verified passing in a clean process; the AMD driver
wedges intermittently under repeated test runs on this machine, which is
tracked separately.
The gpuDriven feature remains opt-in (RenderControl, default OFF), so
default rendering is unchanged.
- GpuDrivenCaps: probe + enable Vulkan 1.2 core features (drawIndirectCount,
descriptor indexing, non-uniform indexing, update-after-bind) at device
creation; the toolchain's Vulkan headers omit the core drawIndirect bit so
it is assumed supported and left untouched.
- Bindless Set1: sampler2D array (4096) + samplerCube array (64) + SSBO table
array (64) with PARTIALLY_BOUND | UPDATE_AFTER_BIND; textures register on
creation, placeholder-filled with the 1x1 white texture/cubemap.
- GPU tables: GpuMeshRecord (bounds + index/vertex ranges) filled at mesh
upload and GpuMaterialRecord built from Material params, uploaded to
host-visible SSBOs with fixed capacity.
- FrameArena: per-frame-slot host-visible ring allocator, reset each
begin3DFrame; unused until phase 1 (indirect draw).
- Tests: GpuDriven.capsAvailable / bindlessTextureRegistration /
meshTableRegistration (GPU runtime verification pending driver recovery).
Rendering is unchanged: nothing consumes the new infrastructure yet.