No visible things to show, because the biggest feature add was an improved audio system, and the now the engine fork uses OpenAL on all platforms. Major architectural/perf improvements to the Vulkan renderer.
AI written summary:
It is less photogenic, but this is the part that actually raises the engine's ceiling:
bindless material descriptors,
GPU-driven draw submission, a
texture residency manager,
compute skinning,
static shadow caching, a
9.4x faster shader build, and a complete
environmental audio stack.
Everything below is opt-in and Vulkan-only unless noted. All numbers measured on an AMD Radeon RX 6650 XT.
Two of these were the direct result of an architecture audit: the engine was a DX11-shaped renderer on a modern Vulkan core, missing three standard pillars — bindless descriptors, GPU-driven submission, and parallel command recording. The first two are now in. The third turned out not to be needed, and I will explain why, because that is more interesting than shipping it would have been.
1. Bindless material textures
Previously every material switch re-allocated and rewrote a descriptor set. With many unique materials on screen this dominates: descriptor churn, and pool pressure up to 16384 sets per frame.
Material textures now live in a
global set-1 texture table (descriptor indexing, UPDATE_AFTER_BIND, 65536 slots, fence-gated slot recycling), indexed by a small per-material constant. A macro swap in the shader header re-routes every Col/Nrm/Ext/Det/Lum sample, so no shader needed hand-editing — the regenerated pak's set-0 layouts simply contain no material textures at all.
That last detail matters: the churn is gone
structurally. There is no runtime flag to forget to enable.
Measured, 512 unique materials:
- 533 -> 22 descriptor sets per frame
- 1.95 -> 1.29 ms
- +51% fps
Pixel-identical against the committed baseline; clean validation soak.
Demo:
Tutorial_17_MaterialStress
Bindless is also the precondition for everything in section 2.
2. GPU-driven rendering: compute culling + indirect draw
The engine culled per object on the CPU, per light, with no occlusion culling at all, and recorded every draw on one thread. multiDrawIndirect was enabled but never actually issued.
There is now a persistent GPU-instanced layer: register N instances once, and a compute kernel culls and compacts them into draw chunks that feed
vkCmdDrawIndexedIndirectCount. Zero CPU per-instance work, using the mesh's existing shaders —
no new shaders and no pak regeneration.
What is in it:
- Frustum culling on GPU — an exact port of the engine's own sphere-vs-frustum test, so results match the CPU path.
- Hi-Z occlusion culling, two-phase. The layer occludes itself: standing inside a dense field with no external occluders, 13,074 -> 67 survivors (99.5% culled), and disocclusions are caught in the same frame.
- Per-cascade shadow culling — each shadow cascade re-culls on the GPU and indirect-draws survivors.
- Automatic world migration — Game::Static and Game::Kinematic objects move onto the layer as they stream in, and off as they stream out. Meshes with LODs become per-LOD banded layers whose distance bands are an exact port of the CPU LOD selection, so the GPU path draws the same LOD the CPU path would, in every view, at every distance.
Measured (262k instances):
- Frustum only: 4.24 -> 1.85 ms (2.3x)
- Full stack: 8.8 -> 1.54 ms (5.7x)
- CPU command recording: 26.6 -> 1.5 ms
Pixel-identical A/Bs — zero false culls.
Demos:
Tutorial_17_GpuCull, and
Tutorial_14_GpuWorld (a real streamed world with thousands of statics flying in and out of range).
An engine-wide bug this uncovered: under CSM load the per-draw uniform ring silently wrapped mid-frame (65k casters x 4 cascades is roughly 10x the ring budget), corrupting already-bound slots. That was a latent bug for anyone with heavy shadow scenes, not just the new path. The ring now grows on overflow.
And the honest part. The win scales with density. At ~29k cheap statics with shadows on, the CPU path is still ahead (6.8 vs 7.8 ms) — the per-cascade dispatch rounds are a fixed cost that needs volume to amortise. This is a scalability feature, not a free win in every scene, which is why it stays opt-in.
3. The pillar I did not ship
The third missing pillar was parallel command recording — record the G-buffer and each shadow cascade on separate threads into secondary command buffers. It is a standard modern design and it was on the roadmap.
I measured before writing it. The GPU-driven layer had already taken single-threaded CPU recording from 26.6 ms to 1.5 ms. What remains is serial pipeline overhead, not parallelisable draw loops — so a week-scale threading refactor (per-thread secondary CBs, per-context pools, a thread-safe uniform ring) would buy under 1 ms on frames that are GPU-bound anyway.
So it is closed, not done, and I would rather say that plainly than ship a feature to tick a box. Same outcome for a per-frame arena allocator: an LD_PRELOAD counting shim showed steady-state allocation is under 10 mallocs per frame, so there was nothing to win.
4. Environmental audio — the immersion layer
This is the change I am happiest with: the engine had solid audio plumbing (OpenAL Soft, Opus/FLAC, real voice virtualization with priority management) and
no immersion layer whatsoever. No reverb. No HRTF. No occlusion. Every space — cathedral, cupboard, open field — sounded identical and anechoic.
OpenAL Soft ships EFX and HRTF for free. The engine simply never asked for them.
Now shipped,
with zero new dependencies:
- Reverb (EFX) — SND_REVERB, a 26-preset EAX vocabulary. Live 3D sources attach a reverb send automatically; 2D/UI/music stay dry by construction.
- Reverb zones — Ball/Box volumes, innermost wins, with a classic two-slot equal-power crossfade. Walking through a doorway fades between spaces smoothly, and retargeting mid-fade stays smooth.
- Occlusion and obstruction — physics-raycast, using the Wwise-style split: a wall between you and the source muffles the dry path (obstruction), and full occlusion escalates into the reverb sends too. Shoulder-offset rays, round-robin throttled, smoothed.
- HRTF + doppler — proper binaural positioning on headphones.
- Decoded-PCM sample bank — cached PCM served through the existing reader, so looping, seeking, muffling and virtualization all keep working while per-play decode cost drops to zero.
Demo:
Tutorial_09_EnvironmentalAudio — a stone room, a doorway, and an open field, with
R/
O/
H/
B toggling reverb, occlusion, HRTF and the sample bank so you can hear each one switch in and out.
OpenAL across platforms. The environmental stack is OpenAL-only, and Windows historically shipped XAudio/DirectSound. Rather than port the whole thing to XAudio2 XAPO effects, Windows now moves to openal-soft as well — the headers and Win64 import library were already vendored in-repo, so it is a define flip plus the DLL. One sound provider, one environmental stack, everywhere. (Landed; still wants validation on a Windows box, and there is a one-line rollback if it misbehaves.)
Design doc:
docs/audio-design.md
One thing I deliberately did not build: ray-traced audio occlusion against the RT acceleration structure. It sounds great in a feature list, and I have the TLAS sitting right there. But collision proxies are
acoustically better occluders than render triangles — sound wavelengths are metre-scale and diffract around the detail geometry that a TLAS is full of — the ray counts involved do not need a GPU, and a GPU readback adds latency to a system that must react instantly. It was a marketing bullet, not audible value, so it is struck. Material-based transmission and portal propagation are the real follow-ons.
5. Texture residency / VRAM budget
Every referenced texture was promoted to full resolution and kept there — no residency, no budget, no eviction. Your working set was "every texture the world has ever touched, at full res", which is a hard ceiling for open worlds.
EE_TEX_BUDGET=<MB> now enforces an LRU byte budget. Material textures timestamp themselves on bind (the bindless registration hook gives this for free), and a throttled manager demotes the least-recently-used textures one mip step under pressure, promoting them back once there is headroom, with 15% hysteresis so it cannot oscillate.
The neat part: demote and promote are the
same operation — a reload through the engine's existing streaming loader with a shrink override. Small pak read, background stream, the standard hot swap. Deferred image destruction plus bindless view re-validation make live swaps safe by construction.
Verified: budget off, or a huge budget, is pixel-identical to stock; a 1 MB budget demotes to fit exactly; raising the budget mid-run recovers to
pixel-perfect full quality.
A bug worth knowing about if you write background systems: Time.frame() freezes when the app is not drawing. Anything clocking off it silently stops in an unfocused or minimized window. The manager ticks from the update path instead, so streaming survives alt-tab.
6. Compute skinning (the raster half)
Last post covered GPU-skinned characters for ray tracing. That was half the story — the other half is that skinning was VS-only, so the same linear-blend skin was recomputed in
every pass: early-Z, opaque, and once per shadow cascade.
With
EE_VK_GPU_SKIN=1, a character deforms
once per frame into a real vertex buffer, and every pass afterwards — G-buffer, mirrors, all shadow views, and the RT acceleration structure — draws that same deformed buffer. One deform serves raster and ray tracing.
It also gets
exact per-limb motion vectors: the kernel skins by the previous frame's pose into a second position channel, so TAA sees a limb's true motion instead of just the camera's.
Honest number: about -2.4% GPU on an 800-character idle crowd. Vertex skinning is a smaller slice of the frame than you would guess — CPU animation update dominates that scene. The real value here is correctness (RT and motion vectors), not the frame time.
7. Static shadow caching for local lights
D.localShadowCache(max_lights). Every shadowed light previously re-rasterised all its geometry every frame. Now each light keeps a cached static depth atlas and only re-renders dynamic objects per frame; lights with no dynamics bind the cache directly with zero copies. It is camera-motion-proof by construction, because it is all in light space.
Honest perf: -22% at 512px shadow maps, roughly break-even at 1024, and it
loses at 2048 in a scene of cheap instanced barrels — copying a 96 MB depth atlas per light beats re-rendering geometry that was nearly free to begin with. The win condition is scenes where per-light static shadow rendering is genuinely expensive. It is opt-in for exactly that reason.
(Chasing an apparent culling leak here turned out to be my own demo: point-light range grows with the square root of power, so those lights were ~90 m across and legitimately touched everything. Engine culling was correct. Emptiness is now decided by one cheap sphere query, so empty lights are truly free.)
8. Shader build: 2h11m -> 13m56s
Boolean shader dimensions (dither, gamma, alpha test, detail, material blend, UV scale, and Forward's nine light dims) were being baked into full permutations, so the pak universe — and the cold build — grew combinatorially.
They are now
specialization constants, resolved at pipeline creation. Two compiler-side changes turn a smaller pak into saved time: specialization values are excluded from the shader cache key, and identical compiles are deduplicated within a run.
- Cold Vulkan shader regen: 2h11m -> 13m56s (9.4x)
- Forward: 39,080 -> 10,664 compiles
- Engine.pak: -27 MB (Forward 76.8 -> 37.1 MB, Deferred 29.8 -> 14.4 MB, Blend Light 21.0 -> 9.1 MB)
- Output bit-identical; stock content pixel-identical
Part of that came from noticing the forward renderer is compiled out of the runtime entirely — so roughly 17 of the 33 remaining minutes were spent building shaders nothing could ever load. Now gated (and reversible), which also removed 108 MB of unloadable paks from the repo.
9. Async compute, and a batch of small wins
EE_VK_ASYNC_COMPUTE=1 routes the GI ray-traced moments pass onto the dedicated compute queue — it reads the acceleration structure and writes an atlas consumed next frame, so it is genuinely async-clean. Two timelines (a shared one violates execution-order monotonicity), per-slot fences, concurrent sharing for cross-queue resources. About 0.09 ms/frame off the graphics queue, zero validation errors, pixel-parity.
Also landed, each small and each measured:
- Static vertex/index buffers no longer take a dedicated allocation — they suballocate through VMA. Every mesh used to burn one vkAllocateMemory, heading for the ~4096 allocation ceiling in a mesh-heavy world.
- GI capture dispatches batched — one 3D dispatch per pass instead of one micro-dispatch per probe with a full barrier between each.
- Immutable samplers — the five never-recreated samplers are baked into the layouts. The other four are settings-mutable, so baking them would dangle every layout on a settings change; they stay written.
- Dynamic vertex ring 32 KB -> 2 MB — kills most mid-frame buffer orphaning.
- PhysX TGS solver — opt-in via EE_PHYSX_TGS, cheaper stability.
Correctness
The whole stack went through an adversarial multi-agent review before merge — reviewers instructed to
refute each finding against the code rather than confirm it. It found 9 real defects: a use-after-free in the sound bank, two more memory bugs, a GPU-cull shadow-gate hole, a spec violation, and four feature-breaking logic errors — including one where the compute-skinning motion vectors
never actually worked and had been silently inert for standard meshes.
Every one of them was behind an opt-in lever, so the default path never regressed. All fixed. A forced synchronization-validation soak reports zero hazards.
I mention it because it is the honest counterweight to a features list: this many opt-in systems will hide broken paths unless something goes looking for them on purpose.
Summary
- Bindless materials — 533 -> 22 descriptor sets/frame, +51% fps at 512 materials
- GPU-driven rendering — compute cull + Hi-Z + indirect draw, 8.8 -> 1.54 ms at 262k instances
- Environmental audio — EFX reverb, zones, occlusion, HRTF, PCM bank; openal-soft on every platform
- Texture residency — EE_TEX_BUDGET, LRU mip demote/promote, pixel-perfect recovery
- Compute skinning — deform once per frame, exact per-limb motion vectors
- Static shadow cache — D.localShadowCache
- Spec constants — cold shader build 9.4x faster, pak -27 MB
- Async compute — GI moments on the dedicated queue
Design docs are in the repo:
docs/audio-design.md,
docs/clustered-froxel-lights-design.md,
docs/rt-shadows-design.md,
docs/rt-reflections-design.md.
In this engine fork:
https://github.com/DrewGilpin/EsenthelEngine