← Blog

2026 August Release

2026 August Release

2026-09-02

August was a slower month. Other projects took a share of the calendar, and the release count shows it: six, where July had nineteen.

What the six contain does not read like a slow month.

The Bistro sample — 4.15 million triangles, 3,880 submeshes, 206 material slots, two mesh nodes and some furniture — measured 8.6 frames per second the first time anyone benchmarked it properly, on the 23rd. The month ends with it limited by vsync, the whole render doing its work in 0.79 ms of a 16.7 ms frame. Nothing was deleted from the scene to get there. No new rendering technique was added. The frame just stopped wasting itself, once there was a way to see where it went.

Along the way the last two visual items of the Bistro fidelity plan shipped: screen-space reflections and reflection probes.

Reflections That Know Where They Are

Tickets #157, #132

July gave the renderer image-based lighting: one HDR environment map acting as skybox, ambient light and reflection source. It is an enormous improvement over analytic lights alone, and it has one structural flaw — the environment is treated as infinitely distant. A shop window in the middle of a street reflects the sky. It cannot reflect the street.

Two techniques closed that, in the order their information is trustworthy.

Screen-Space Reflections

SSR reads the same depth-and-normal prepass SSAO already needed, marches each reflected ray through it, and — where the ray lands on something the camera can see — takes that surface's colour.

The complication is which colour. Turian's renderer is single-pass forward, so the main pass cannot sample the target it is currently writing into. SSR instead resolves before the main pass against the previous frame's already-composited output, reprojected into this frame through prev_view_proj * inverse(current_view). One frame of latency, in exchange for a reflection that exists at all.

The blend against the global environment map is gated on roughness, and deliberately narrow: full weight near mirror-smooth surfaces, faded out well before the ~0.74 roughness that most of Bistro's stucco and stone sits at. So the storefront glass sharpens and the pavement does not acquire reflections it should never have had.

Reflection Probes

SSR can only ever mirror what is on screen. Interiors, surfaces facing away from the camera, anything just outside the viewport — for all of those the ray misses and the shader falls back to the distant sky. Which is exactly the artefact SSR was added to remove.

Reflection probes fill that hole. A new ReflectionProbeComponent marks a box or sphere of influence — the same volume shape, blend distance and priority model that PostProcessVolumeComponent already uses for camera effects — and bakes the scene around its position into six cube faces. Those get GGX-prefiltered through the very same machinery the global HDRI goes through, so a local probe and the world environment are sampled identically by the shader; only the source differs.

Probes are static and bake once. The renderer captures at most one dirty probe per frame, never during Play mode, and re-bakes only when a probe's transform, shape or capture size actually changes — with an explicit Bake Now action in the Inspector for the case where the scene around a stationary probe changed instead.

The selection of which probe applies happens per submesh, not per node. Bistro's exterior is a single enormous multi-material mesh, so choosing one probe for a whole node would mean choosing one probe for an entire city block. The resolved assignment is cached against a generation counter that only moves on a successful bake, so static geometry with static probes re-tests nothing.

The priority is strict: screen-space reflection first where it hit, then the best local probe, then the global environment. Eight probes per scene.

Drag the slider to compare the terrace with local reflections off (before) and with SSR and probes both active (after). The wine glass carries most of it: flat and lavender with only the sky to reflect, warm and specular once the interior probe reaches it.

The Bistro terrace with screen-space reflections and reflection probes
The same frame with local reflections off — every reflective surface falls back to the distant sky

The full design, including why a bake-once static probe was the only option available — SDL3's GPU API exposes no ray tracing on any backend — is in ADR-0017.

Shadows That Respect Alpha

Tickets #132, #171

July's cascaded shadows treated every surface as solid. Foliage, authored as alpha-masked cards, therefore cast rectangles.

The shadow pass now classifies each material by how it occludes light: solid surfaces block it, blended surfaces transmit it and cast nothing at all, and masked surfaces occlude only where their cutout test keeps them. Leaves cast leaf-shaped shadows, and glass stops casting a wall.

The same area produced August's most instructive bug. Shadows in Bistro visibly slid across static geometry whenever the camera merely rotated. The obvious suspects — light-space maths, cascade fitting, handedness — were all innocent. The real cause was one missing line: this renderer's convention is that a render target's texel row 0 is the top while SDL3's NDC is Y-up, so every full-screen lookup flips V. SSAO did it. SSR did it. The shadow atlas lookup did not, and so read its cascade strip mirrored — about a mirror axis derived from the camera frustum and re-fitted every frame, which is precisely why the artefact tracked the camera.

Two coupled defects came out with it: the cascade near plane was clipping any occluder taller than its own cascade sphere, and a near plane pinned at 0.01 collapsed the logarithmic term of the split scheme, leaving cascade 0 spread across roughly ten metres. Four regression tests were added, each verified to fail when its own fix is reverted.

The Camera Previewer

Placing a camera by reading transform numbers is guesswork. Turian now does what Unity does: select a single node carrying a Camera component and its view appears as an inset in the bottom-right corner of the Scene viewport, live.

It has the distinction of being the only feature this month that was shipped and then optimised two days later, by the performance work below — a 320×180 inset turned out to cost nearly as much as the viewport behind it.

Measure First

Tickets #158, #159

Bistro ran at roughly 20 fps in the Scene viewport and worse in Play. There were four plausible explanations and no way to choose between them, because the profiler could not see the frame at all: profiling was gated to Play mode, and disabled outright in release builds. The one number anybody had was the FPS counter in the status bar.

July's post described three successive rewrites of frustum culling, each motivated by measuring the last. August started by admitting that the same trap was open again, and building instrumentation instead of an optimisation.

A headless benchmark harness. turian-studio --benchmark opens a project, waits for import to finish, pins the editor camera to a named camera node, runs a warmup, records a fixed number of frames, writes a Perfetto-compatible trace.json plus a summary.json with p50/p95/p99 frame times, and exits. No focused window, no human, no variance from where the mouse happened to be.

Profiling outside Play mode, and per-pass attribution. draw_calls used to be one number for the whole frame; it now breaks down per pass — prepass, shadow, cull, main, transparent, post-process, upload — alongside triangle counts and a new indirect-command counter.

The harness found two bugs by simply being run, both of which would have produced confident nonsense. Its very first run reported a beautifully plausible p50 of 16.66 ms: vsync, measuring the display. Its first working run reported zero draw calls, because the Scene panel was not the active tab in its dock leaf and so never rendered. The harness now forces the pacing it needs, focuses the Scene panel transiently without disturbing the saved layout, and writes an explicit warning into summary.json when a run records no draws.

Then it produced the number:

2026-08-23 baseline
frame p50 115.7 ms
frame p95 118.9 ms
fps 8.6
draw calls / frame 4,006
CPU share of the frame 96 %
pass CPU draws indirect triangles
render.prepass 71.86 ms 2,435 0 2,479,823
render.main 16.39 ms 194 3,271 3,734,581
render.upload 12.39 ms 0 0 0
render.shadow 7.44 ms 776 13,084 14,938,324
render.transparent 2.60 ms 601 0 416,834
cull / ssao / ssr / post < 0.1 ms each 0 0 0

Three runs agreed to within 0.7 %. Two rows account for 73 % of the frame, and neither is a rendering technique — one is a pass issuing twelve times the draw calls of the pass drawing more geometry, and the other issues no draw calls at all.

That table is committed to the repository as bistro-perf-baseline.md, so every change after it quotes a before and an after.

Then, Actually Optimizing

A Prepass Issuing Twelve Times the Draws

Ticket #162

The depth-and-normal prepass arrived with SSAO and never learned about GPU-driven culling. It looped submeshes and submitted one draw each, while the main pass — drawing 1.25 million more triangles — issued 194 indirect multi-draws off a buffer the cull compute shader had already written.

The prepass could not use that buffer as written, because it ran before the cull dispatch. Both passes already forced a pass break, so the reorder cost nothing, and the prepass then read the same indirect commands the main pass does.

The planned part of the fix turned out to be unnecessary: opaque and alpha-masked geometry branch on a per-draw uniform, not on separate pipelines, and every submesh in a material group shares one material. No new pipeline, no shader change.

115.7 ms → 47.8 ms. The pass itself: 71.86 ms → 4.98 ms.

Re-Verifying, Every Frame, That Nothing Had Changed

Ticket #161

render.upload was 12.39 ms of pure overhead for zero draw calls: walking all 206 material GUIDs through three linear caches, every frame, to establish that the scene was still the scene it had loaded. Roughly 300,000 string comparisons per frame to learn nothing.

With the prepass fixed, the same caches turned out to own most of the main pass too — 16 ms for 194 draw calls is about 83 µs each, far too slow to be submission. Building the parameters for one material group performed five texture lookups, each a linear scan with a memcmp over 633 cached textures.

Two changes: a dirty flag so the upload pass early-outs unless something actually changed, and GUID-keyed hash maps replacing all three linear caches.

47.8 ms → 12.8 ms. 8.6 fps to 78, with the fixed-camera output pixel-identical before and after.

Nine Times Faster, and a Differently-Shaped Frame

At that point render.scene was 5.62 ms of a 10.04 ms CPU frame, and the renderer was no longer the dominant cost — the editor's own UI was the other half. Three items remained worth doing, and the baseline document, not intuition, chose them.

The transparent pass (#168) had become the single largest render cost: 2.47 ms, 44 % of the render, larger than the opaque pass containing it. It is the one path that cannot adopt indirect draws, because correct alpha compositing requires per-submesh depth ordering and an indirect multi-draw draws in buffer order. So the fix was to make each of its 601 draws cheaper instead — passing draw parameters by pointer rather than by value, shrinking the per-submesh record to the four fields that actually vary, and value-comparing the uniform and sampler bindings to skip redundant ones. Measured in a release build, where the same pass costs 0.241 ms: 0.241 ms → 0.109 ms, texture binds halved, output bit-identical.

The per-draw uniform (#163) was 576 bytes, of which 400 were frame-constant: four cascade matrices and nine spherical-harmonic coefficients, re-pushed on every single draw. Split into a 128-byte per-draw block and a per-pass block pushed once. A smaller win than estimated — most Bistro draws go through the batched indirect path, so only the individual ones benefit — and worth recording as such.

The camera preview (#164) forced an extra full scene render every 100 ms whenever a camera was selected, regardless of whether anything had changed. A full render, including shadows, reflections and post-processing, is resolution-independent in CPU cost, so a 320×180 inset was costing nearly as much as the viewport behind it. It now renders when the previewed camera is actually stale, with a one-second fallback as a safety net.

The other half of that ticket is worth reporting too, because it was investigated and found not to be a bug: multiple Scene and Game viewports do not each render the scene, since the docking widget already yields only the active tab of a dock leaf. Confirmed by reading the code and cross-checked against the benchmark, where recorded render calls matched recorded frames exactly. No guard was added, because none was missing.

Where It Landed

baseline after #162 after #161
frame p50 115.7 ms 47.8 ms 12.8 ms
frame p95 118.9 ms 50.6 ms 13.3 ms
fps 8.6 20.9 78
draw calls / frame 4,006 1,765 1,765

And after the v3.6.2 work, re-measured at a normal 60 Hz: 1.40 ms of CPU in a 16.69 ms frame — 8 %, where the epic began at 96 %. render.scene itself reads 0.79 ms. Across the epic that is 108.2 ms → 0.79 ms, roughly 137×, and Bistro's original acceptance criterion — free-fly navigation at interactive framerate — is met with a great deal of room to spare.

Some honesty about those numbers, all of it also recorded in the baseline document. They come from a Debug build; release is faster in absolute terms, and the ratios are the point. They are one viewpoint at 1280×720, where MSAA, SSAO and SSR are under-represented. Headless frame times are a lower bound on interactive cost. And the GPU side is still essentially unmeasured — per-pass GPU fences were deferred precisely because the CPU was 96 % of the frame, which is a judgement that no longer holds. The 22.8 million triangles submitted per frame have not been reduced by any of this; that work is still ahead.

Quality You Can Dial

Ticket #160

ProjectSettings.graphics.quality had been a persisted, Inspector-editable field that no rendering code read. It now selects a real feature set.

Twelve toggles, all honoured: MSAA sample count, SSAO on/half-resolution/tap count, SSR on/half-resolution/step count, shadows on/atlas resolution/cascade count, reflection probes, and bloom. Plus render scale — the scene renders at a fraction of the output and the composite pass upsamples, while gizmos ride along with the scene and the editor's own UI stays crisp.

They are surfaced three ways: a Rendering group in the Profiler panel that applies live with no restart, low/medium/high/ultra presets baked into a shipped game at build time, and a --quality flag on the benchmark harness so a preset's cost can be measured headlessly.

high is the renderer's historical behaviour, and the defaults are a bit-exact no-op — an untouched project renders exactly as it did before.

One caveat recorded with the feature: most of this dial buys GPU time, which a CPU-zone profiler cannot see. ultra costs the same as high on the CPU. Grounding the presets properly needs the GPU-side measurement that is still outstanding.

A Frame Budget for the Shipped Game

Tickets #169, #170

A shipped Turian game ran its main loop with no pacing of any kind except the swapchain present. That was fine right up until the swapchain declined to hand over an image.

SDL offers two acquire functions. The generated loop was using the non-waiting one, which returns null when too many frames are in flight — normal behaviour, not an error, and SDL's own documentation says to use the waiting variant "unless you know what you are doing". The loop treated null as "skip this frame and carry on", and since nothing else in it blocked, it spun: on Bistro with vsync off it reported roughly 18,000 frames per second, almost none of which rendered anything, allocating and submitting an empty command buffer each time.

That reframed an earlier incident, too. A GPU driver reset during this work had been blamed on sustained benchmark load. A game submitting eighteen thousand empty command buffers a second is the better explanation.

The acquire call was fixed in the GPU wrapper. Two things in the loop itself were worth doing regardless, and shipped in v3.6.3: it now yields the rest of an iteration the swapchain gave it no image for, instead of burning a core on an occluded or minimized window, and it clamps delta time before anything integrates it, so a hitch or a breakpoint arrives at gameplay code as one capped step rather than a one-second jump.

Alongside that, graphics.frame_cap in the project settings seeds engine.Application.frame_cap, which the loop reads once per frame — so a script, or a future settings screen, can change the game's frame ceiling while it runs.

Ticket #150

The Scene viewport's mouse wheel used to move the camera a fixed number of world units per notch. There is no value that works for both a doorknob and a city block, which is why every DCC tool solved this the same way, and why Turian now does too.

The camera tracks a focus distance — how far ahead it is treated as looking. A wheel notch covers a fixed share of that distance rather than a fixed length, so zooming in decelerates and can never reach the focus point, let alone pass through it, while zooming out is unbounded. Panning converts a pixel drag through the projection at that same distance, which keeps the grabbed point under the cursor at any scale.

The distance stays honest as you move: flying forward with W reduces it, so arriving at a wall leaves the wheel calibrated to the wall rather than to wherever you started, and focusing on a selection re-calibrates it to what you framed. The two remaining knobs — a zoom fraction and a pan multiplier — exist for taste, not for rescuing the feel at a given scale, and live in Settings under Editor Camera.

Designed, Not Built

The Bistro fidelity plan has one item left: indirect diffuse light. Today the only diffuse indirect term is the environment's spherical harmonics, which carry no notion of position — a fragment under an awning, a fragment inside the shop, and a fragment in open sunlight all receive the identical hemisphere of sky. Ambient occlusion can darken that, but it cannot tint it, and it cannot replace it.

ADR-0018 designs the answer: a grid of light probes baked into a versioned asset and sampled per fragment, replacing the distant environment term with a position-dependent one. It also records why the three alternatives lost — DDGI needs hardware ray tracing that SDL3's GPU API does not expose, lightmaps need a second UV set the asset does not ship and the importer cannot generate, and voxel cone tracing costs too much for a renderer that was, at design time, submission-bound.

It is scheduled after the performance work for the obvious reason: it adds frame cost. That scheduling is why August looks the way it does.

Looking Ahead

The performance epic ends in an unusual place: with the measurements arguing against most of the work still queued behind it. Temporal shadow-cascade caching would shave part of a 0.16 ms pass. Hierarchical-Z occlusion culling reduces CPU submission and vertex work in a frame that now spends 8 % of itself on the CPU. Both were sized against a 96 % CPU frame that no longer exists, and both should be re-argued against a GPU measurement that does not exist yet.

So the next honest step is the one that was deferred for good reason and is now the blocker: per-pass GPU timing, on a machine nobody is using.

After that, the queue is level of detail — the only item that reduces 4.15 million triangles at the source — baked irradiance volumes for diffuse GI, per-material import overrides so a sample project can ship its own corrections, and a player-facing settings screen that puts August's quality dial in the hands of whoever ends up playing the game.

Download the latest release, explore the documentation, and join us on Discord or Matrix to follow along.

See you in the editor.

#release#engine#studio#graphics#performance