Add an occlusion culling benchmark sample - #9
Conversation
Measures what a CPU visibility pass costs over a thousand boxes, two ways and at two ray widths, with the accuracy of each. No shading: this measures visibility. The per-object pass runs at 0.16 ms and removes two thirds of the draw calls, about 1% of a 16.6 ms frame. The visibility buffer is four times dearer, culls less, and is far more accurate. Both numbers come with what they get wrong. Three things this turned up that are worth having written down: Packet width has to be asked of the device. rtcIntersect16/rtcOccluded16 may only be called when RTC_DEVICE_PROPERTY_NATIVE_RAY16_SUPPORTED says so, and calling them anyway is undefined behaviour: the first version of this sample used 16 and corrupted the heap, surfacing as a crash inside AlignedFree with no hint of the real cause. The shipped binaries are AVX2, which tops out at 8. Packets are not automatically faster. They lose the per-object early exit -- once one sample proves a box visible the rest are wasted, but the lanes are already committed -- and 9,000 rays with SIMD come out slower than 7,165 without. For the visibility buffer, where every ray is needed anyway, they draw level. The obvious per-object test is wrong. An occlusion ray stopping just short of a sample point on the box has the box occlude itself, since the point sits on its own surface. Asking what the ray hits first costs more and answers the question actually being asked. Accuracy is reported weighted by screen area, not by object count. In a grid this dense most of what is technically visible is visible through a gap a pixel or two wide, so a raw count of missed objects reads as alarming while the pass is fine: 163 boxes missed, 3% of the screen.
API gate: additiveEvery symbol that existed still exists, unchanged. Nothing that compiled before stops compiling.
Enum and constant values are part of the measured surface: a renumbering keeps compiling and sends the wrong number to the driver, so it counts as a removal. |
A regular grid flatters occlusion culling: every occluder is the same size and sits exactly behind the one in front, which is the easiest case there is. A thousand boxes at random positions with random per-axis sizes gives irregular gaps, and those are what the technique actually has to cope with. The seed is fixed, because the benchmark compares medians between runs and a clock-seeded layout would move the numbers with no way to tell that from a real change. It changes the picture in both directions. There is more mutual occlusion, so the per-object pass now culls 77% rather than 66%. But irregular sizes make nine corner samples a worse proxy for a box, so what it wrongly discards goes from 3% of the screen to 7.5%, and each pass costs about a third more because fewer samples exit early and the BVH is less tidy. The slab view replaces the old middle-layer one, which indexed geomIDs assuming a grid and means nothing now; it takes the boxes whose centre falls in a band around the middle instead. One thing this scene surfaced that the grid did not: the single-ray and 8-wide paths disagree, reproducibly, on two boxes out of a thousand. They are separate kernels inside Embree and they differ on grazing hits. It does not matter for culling, but the packet path is not a drop-in replacement where the answer has to be bit-identical.
API gate: additiveEvery symbol that existed still exists, unchanged. Nothing that compiled before stops compiling.
Enum and constant values are part of the measured surface: a renumbering keeps compiling and sends the wrong number to the driver, so it counts as a removal. |
--sweep repeats the measurement at 10, 50, 100, 200, 500 and 1,000 boxes. The volume grows with the cube root of the count so density stays constant and the curve isolates the object count instead of mixing it with how much the boxes occlude each other. The two families scale differently, which is the reason to keep both. Per-object tracks the object count, 0.009 ms at ten boxes to 0.21 ms at a thousand. The visibility buffer is flat near 0.6 ms whatever the count -- it pays for 57,600 rays and does not care what they hit -- and drifts slightly cheaper as boxes are added, because a denser scene stops rays sooner. Per-object therefore wins comfortably up to a thousand objects, with the crossing somewhere past that. Each size runs in a process of its own. Measuring all six in a row inside one process moved the later numbers by half: the thousand-box scene reports 0.21 ms measured alone and 0.33 ms measured sixth. Every rtcNewDevice brings its own worker threads under EMBREE_TASKING_SYSTEM=INTERNAL, and six devices' worth leaves the machine in a state the first measurement never saw. A sweep exists to show the shape of a curve, so it cannot be built out of numbers that drift with their position in the list.
API gate: additiveEvery symbol that existed still exists, unchanged. Nothing that compiled before stops compiling.
Enum and constant values are part of the measured surface: a renumbering keeps compiling and sends the wrong number to the driver, so it counts as a removal. |
A city of primitives drawn through the Evergine low-level API, with Embree deciding each frame which objects reach the GPU. At street level, with a camera orbiting at eye height, it issues 56 draw calls instead of 1000: the frustum takes it to 538 and the occlusion pass removes 482 more, which is the part frustum culling cannot do. What it wrongly discards holds 1.56% of the screen. One draw call per object rather than instancing the lot, because that is the number occlusion culling exists to reduce. Per-object data still rides in a per-instance vertex buffer selected with startInstanceLocation: the validation layer rejects updating a buffer inside a render pass, and although DX11 lets it through with a trace, Vulkan and DX12 do not. Three things cost a wrong answer before this worked: Evergine's depth is reversed -- ReadWrite compares GreaterEqual and ClearValue.Default clears depth to 0 -- so the projection needs reverseDepthBuffer: true, which only Evergine.Mathematics offers. The sample keeps System.Numerics for the culling, which only needs the six frustum planes. The Embree geometry is baked into world space by hand and must use the same rotation sense as Evergine's CreateRotationY, which under the row-vector convention is x' = x·cos + z·sin. Baking the textbook form instead rotates what Embree sees away from what the GPU draws, and objects in plain view get discarded with no other symptom. Buildings must not interpenetrate. Placing them at random put the wrongly discarded screen area at 3.83%, because a discarded object growing through a kept one paints over it; rejecting overlapping footprints took it to 0.31% on the same layout, and stopped the debug view from showing red where the culling was right. embree.png is in the captures for that last class of bug: the same view traced against the Embree scene, so any drift between the two representations is visible at a glance. Nothing else in the sample would notice.
API gate: additiveEvery symbol that existed still exists, unchanged. Nothing that compiled before stops compiling.
Enum and constant values are part of the measured surface: a renumbering keeps compiling and sends the wrong number to the driver, so it counts as a removal. |
--bench sweeps a full orbit with VSync off: the culling runs at 0.42 ms median, 2.5% of a 16.6 ms frame, and removes 964 draw calls. --bench --no-cull draws everything instead, and the frame comes out faster: 0.59 ms against 0.70 ms. Net of the culling itself, 1,001 draw calls of this geometry cost 0.47 ms and 36 cost 0.27 ms, so the pass spends 0.30 ms of CPU to save 0.19 ms of drawing. That is the scene, not the technique. These are boxes and cones of a couple of dozen triangles with a two-line shader; there is nothing to save by not drawing one. But the sample would be misleading without the counterfactual next to it -- 94% culled reads like a win, and here it is not one.
API gate: additiveEvery symbol that existed still exists, unchanged. Nothing that compiled before stops compiling.
Enum and constant values are part of the measured surface: a renumbering keeps compiling and sends the wrong number to the driver, so it counts as a removal. |
Measures what a CPU occlusion culling pass costs over a thousand boxes, with Embree answering
the visibility queries. No shading, no lighting, no shadows.
A 10×10×10 grid, one Embree geometry per box so each has its own
geomID, camera outsidelooking in. Two passes, each single-ray and 8-wide, all after a frustum stage:
0.16 ms to remove two thirds of the draw calls, roughly 1% of a 16.6 ms frame.
Four things worth reviewing, because each of them cost a wrong answer first:
Packet width has to be asked of the device.
rtcIntersect16/rtcOccluded16may only becalled when
RTC_DEVICE_PROPERTY_NATIVE_RAY16_SUPPORTEDsays so. The first version used 16regardless; it corrupted the heap and crashed inside
AlignedFree, with nothing pointing at thereal cause. The binaries we ship are AVX2, which tops out at 8 — 16 needs AVX-512. The sample
queries it and prints what it found.
Packets are not automatically faster. They lose the per-object early exit: once one sample
proves a box visible the rest are wasted, but the lanes are already committed. 9,000 rays with
SIMD lose to 7,165 without. For the visibility buffer, where every ray is needed anyway, they
draw level. Worth having measured rather than assumed.
The obvious per-object test is wrong. An occlusion ray that stops just short of a sample
point on the box has the box occlude itself — the point is on its own surface, so its front face
is in the way for all but a few silhouette corners. Closest-hit costs more per ray and answers
the question actually being asked.
Accuracy is weighted by screen area, not object count. In a grid this dense most of what is
technically visible is visible through a gap a pixel or two wide, so "163 boxes missed" reads as
alarming while the pass is fine: those boxes hold 3% of the screen. The
screencolumn is theone to read, and the verdict image shows why — every red pixel is a sliver between boxes.
Console app with no dependencies, so unlike
HelloEmbreeit runs on all four RIDs. The PNGwriter is sixty lines in
Png.cs;System.Drawingwould have pinned it to Windows.