Skip to content

Add an occlusion culling benchmark sample - #9

Open
jcant0n wants to merge 5 commits into
mainfrom
feature/occlusion-culling-sample
Open

Add an occlusion culling benchmark sample#9
jcant0n wants to merge 5 commits into
mainfrom
feature/occlusion-culling-sample

Conversation

@jcant0n

@jcant0n jcant0n commented Aug 11, 2026

Copy link
Copy Markdown
Member

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 outside
looking in. Two passes, each single-ray and 8-wide, all after a frustum stage:

Method                                        median      rays   visible  culled   miss  screen
frustum only                                 0.004ms          0     1,000    0.0%      0   0.00%
per-object, single ray                       0.158ms      7,165       342   65.8%    163   3.07%
per-object, 8-wide packets                   0.218ms      9,000       342   65.8%    163   3.07%
visibility buffer 320x180, single ray        0.623ms     57,600       457   54.3%     48   0.16%
visibility buffer 320x180, 8-wide packets    0.626ms     57,600       457   54.3%     48   0.16%

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/rtcOccluded16 may only be
called when RTC_DEVICE_PROPERTY_NATIVE_RAY16_SUPPORTED says so. The first version used 16
regardless; it corrupted the heap and crashed inside AlignedFree, with nothing pointing at the
real 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 screen column is the
one to read, and the verdict image shows why — every red pixel is a sliver between boxes.

Console app with no dependencies, so unlike HelloEmbree it runs on all four RIDs. The PNG
writer is sixty lines in Png.cs; System.Drawing would have pinned it to Windows.

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.
@github-actions

Copy link
Copy Markdown

API gate: additive

Every symbol that existed still exists, unchanged. Nothing that compiled before stops compiling.

Target framework Added Removed
net10.0 +0 -0

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.
@github-actions

Copy link
Copy Markdown

API gate: additive

Every symbol that existed still exists, unchanged. Nothing that compiled before stops compiling.

Target framework Added Removed
net10.0 +0 -0

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.
@github-actions

Copy link
Copy Markdown

API gate: additive

Every symbol that existed still exists, unchanged. Nothing that compiled before stops compiling.

Target framework Added Removed
net10.0 +0 -0

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.
@github-actions

Copy link
Copy Markdown

API gate: additive

Every symbol that existed still exists, unchanged. Nothing that compiled before stops compiling.

Target framework Added Removed
net10.0 +0 -0

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.
@github-actions

Copy link
Copy Markdown

API gate: additive

Every symbol that existed still exists, unchanged. Nothing that compiled before stops compiling.

Target framework Added Removed
net10.0 +0 -0

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant