diff --git a/.gitignore b/.gitignore
index 56107a5..96ab7b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,14 @@
# Screenshot produced by the sample at runtime (docs/ holds the committed ones).
HelloEmbree/screenshot.png
+
+# Images the sample writes at runtime (docs/ holds the committed ones).
+OcclusionCulling/scene.png
+OcclusionCulling/verdict.png
+OcclusionCulling/slice.png
+
+# Captures the CityCulling sample writes at runtime (docs/ holds the committed ones).
+CityCulling/city.png
+CityCulling/culled.png
+CityCulling/topdown.png
+CityCulling/embree.png
diff --git a/CityCulling/Camera.cs b/CityCulling/Camera.cs
new file mode 100644
index 0000000..50eed10
--- /dev/null
+++ b/CityCulling/Camera.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Numerics;
+
+namespace CityCulling
+{
+ ///
+ /// The camera, in two flavours of maths on purpose.
+ ///
+ ///
+ /// The frustum planes come from a projection, because culling
+ /// only needs the six planes and does not care about the depth convention. The matrix that
+ /// goes to the GPU is built separately in the renderer with Evergine.Mathematics and
+ /// reverseDepthBuffer: true, because Evergine's depth is reversed. Keeping the two
+ /// apart avoids converting vector types on the hot path, and the two describe the same view
+ /// volume either way.
+ ///
+ internal sealed class Camera
+ {
+ private readonly Vector4[] planes = new Vector4[6];
+
+ public Camera(Vector3 position, Vector3 target, float fovDegrees, float aspect, float near, float far)
+ {
+ this.Position = position;
+ this.Target = target;
+ this.FovDegrees = fovDegrees;
+ this.Aspect = aspect;
+ this.Near = near;
+ this.Far = far;
+
+ var view = Matrix4x4.CreateLookAt(position, target, Vector3.UnitY);
+ var projection = Matrix4x4.CreatePerspectiveFieldOfView(
+ fovDegrees * MathF.PI / 180.0f, aspect, near, far);
+
+ this.ExtractPlanes(view * projection);
+ }
+
+ public Vector3 Position { get; }
+
+ public Vector3 Target { get; }
+
+ public float FovDegrees { get; }
+
+ public float Aspect { get; }
+
+ public float Near { get; }
+
+ public float Far { get; }
+
+ /// Conservative AABB test: a box straddling a plane counts as inside.
+ public bool Intersects(in Vector3 min, in Vector3 max)
+ {
+ Vector3 centre = (min + max) * 0.5f;
+ Vector3 extent = (max - min) * 0.5f;
+
+ for (int i = 0; i < 6; i++)
+ {
+ Vector4 p = this.planes[i];
+ var normal = new Vector3(p.X, p.Y, p.Z);
+
+ if (Vector3.Dot(normal, centre) + p.W + Vector3.Dot(Vector3.Abs(normal), extent) < 0.0f)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private void ExtractPlanes(Matrix4x4 m)
+ {
+ this.planes[0] = Normalize(new Vector4(m.M14 + m.M11, m.M24 + m.M21, m.M34 + m.M31, m.M44 + m.M41));
+ this.planes[1] = Normalize(new Vector4(m.M14 - m.M11, m.M24 - m.M21, m.M34 - m.M31, m.M44 - m.M41));
+ this.planes[2] = Normalize(new Vector4(m.M14 + m.M12, m.M24 + m.M22, m.M34 + m.M32, m.M44 + m.M42));
+ this.planes[3] = Normalize(new Vector4(m.M14 - m.M12, m.M24 - m.M22, m.M34 - m.M32, m.M44 - m.M42));
+ this.planes[4] = Normalize(new Vector4(m.M13, m.M23, m.M33, m.M43));
+ this.planes[5] = Normalize(new Vector4(m.M14 - m.M13, m.M24 - m.M23, m.M34 - m.M33, m.M44 - m.M43));
+ }
+
+ private static Vector4 Normalize(Vector4 plane)
+ {
+ float length = new Vector3(plane.X, plane.Y, plane.Z).Length();
+ return length > 0.0f ? plane / length : plane;
+ }
+ }
+}
diff --git a/CityCulling/City.cs b/CityCulling/City.cs
new file mode 100644
index 0000000..52106aa
--- /dev/null
+++ b/CityCulling/City.cs
@@ -0,0 +1,309 @@
+using Evergine.Bindings.Embree;
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using EmbreeScene = Evergine.Bindings.Embree.Scene;
+
+namespace CityCulling
+{
+ /// Which mesh an object draws.
+ internal enum Primitive
+ {
+ Box,
+ Cylinder,
+ Cone,
+ Wedge,
+ }
+
+ /// One drawable object: a primitive, a placement, and a colour.
+ internal struct CityObject
+ {
+ public Primitive Primitive;
+ public Vector3 Centre;
+ public Vector3 HalfExtent;
+ public float Rotation;
+ public uint Colour;
+ }
+
+ ///
+ /// A city of primitives on a ground plane, and the Embree scene that mirrors it.
+ ///
+ ///
+ /// One Embree geometry per object, so every building has its own geomID and the occlusion
+ /// pass can answer per object rather than per triangle. The triangles go in already
+ /// transformed: the city never moves, so there is nothing to gain from instancing them on
+ /// the Embree side, and world-space geometry keeps the culling code free of transforms.
+ ///
+ internal sealed unsafe class City : IDisposable
+ {
+ /// geomID of the ground plane. It is in the scene so rays can be stopped by it,
+ /// but it is never culled — it is always drawn.
+ public const uint GroundGeomID = 0;
+
+ private Device device;
+
+ public City(int objectCount, float blockSize, int blocksPerSide, int seed)
+ {
+ this.device = Embree.NewDevice(null);
+ if (this.device.IsNull)
+ {
+ throw new InvalidOperationException($"rtcNewDevice failed: {Embree.GetDeviceError(Device.Null)}");
+ }
+
+ this.Handle = Embree.NewScene(this.device);
+ Embree.SetSceneFlags(this.Handle, SceneFlags.None);
+ Embree.SetSceneBuildQuality(this.Handle, BuildQuality.High);
+
+ this.Extent = blockSize * blocksPerSide * 0.5f;
+
+ // Ground first, so it takes geomID 0.
+ this.AddGround(this.Extent * 1.6f);
+
+ var random = new Random(seed);
+ var objects = new List(objectCount);
+ var min = new List(objectCount);
+ var max = new List(objectCount);
+
+ // Blocks with streets between them: buildings cluster inside a block and the gaps
+ // line up into corridors. At eye level those corridors are the only places you can
+ // see far, which is exactly the structure that makes occlusion culling worth doing.
+ float street = blockSize * 0.32f;
+ float usable = blockSize - street;
+
+ // Footprints already placed, so buildings do not grow through each other. Overlap is
+ // not just ugly: two objects sharing space fight for the same pixels, and a discarded
+ // one interpenetrating a kept one paints over it, which reads as a culling error in
+ // the debug view when the culling was right.
+ var placed = new List<(Vector3 Centre, float Radius)>(objectCount);
+
+ for (int i = 0; i < objectCount; i++)
+ {
+ float footprint = 0.0f;
+ float height = 0.0f;
+ Vector3 centre = default;
+ bool free = false;
+
+ for (int attempt = 0; attempt < 24 && !free; attempt++)
+ {
+ int bx = random.Next(blocksPerSide);
+ int bz = random.Next(blocksPerSide);
+
+ float blockX = (bx - ((blocksPerSide - 1) * 0.5f)) * blockSize;
+ float blockZ = (bz - ((blocksPerSide - 1) * 0.5f)) * blockSize;
+
+ footprint = Lerp(random, blockSize * 0.10f, blockSize * 0.22f);
+ height = MathF.Pow(Lerp(random, 0.0f, 1.0f), 2.2f);
+ height = Lerp2(4.0f, 46.0f, height);
+
+ centre = new Vector3(
+ blockX + Lerp(random, -usable * 0.5f, usable * 0.5f),
+ height * 0.5f,
+ blockZ + Lerp(random, -usable * 0.5f, usable * 0.5f));
+
+ float radius = footprint * 0.71f;
+ free = true;
+
+ foreach ((Vector3 other, float otherRadius) in placed)
+ {
+ float dx = other.X - centre.X;
+ float dz = other.Z - centre.Z;
+ if ((dx * dx) + (dz * dz) < (radius + otherRadius) * (radius + otherRadius))
+ {
+ free = false;
+ break;
+ }
+ }
+ }
+
+ if (!free)
+ {
+ // The blocks are full. Stop rather than start stacking buildings inside one
+ // another; the object count is a target, not a promise.
+ break;
+ }
+
+ placed.Add((centre, footprint * 0.71f));
+
+ var half = new Vector3(footprint * 0.5f, height * 0.5f, footprint * 0.5f);
+
+ var primitive = (Primitive)random.Next(4);
+
+ // A cool grey-blue palette with the occasional warm one, so the render reads as
+ // a city rather than confetti.
+ byte tone = (byte)random.Next(110, 210);
+ uint colour = random.Next(10) == 0
+ ? Pack((byte)(tone + 40), (byte)(tone * 0.72f), (byte)(tone * 0.45f))
+ : Pack((byte)(tone * 0.86f), (byte)(tone * 0.92f), tone);
+
+ objects.Add(new CityObject
+ {
+ Primitive = primitive,
+ Centre = centre,
+ HalfExtent = half,
+ Rotation = (float)random.NextDouble() * MathF.PI,
+ Colour = colour,
+ });
+
+ // The tight bound of the rotated footprint, not the circumscribed circle. The
+ // loose one puts every sample corner outside the solid, where the ray sails past
+ // and hits whatever is behind — which reads as "occluded" for an object in plain
+ // view.
+ float c = MathF.Abs(MathF.Cos(objects[i].Rotation));
+ float s2 = MathF.Abs(MathF.Sin(objects[i].Rotation));
+ float ex = (c * half.X) + (s2 * half.Z);
+ float ez = (s2 * half.X) + (c * half.Z);
+ min.Add(new Vector3(centre.X - ex, centre.Y - half.Y, centre.Z - ez));
+ max.Add(new Vector3(centre.X + ex, centre.Y + half.Y, centre.Z + ez));
+
+ this.AddObject(objects[i]);
+ }
+
+ this.Objects = objects.ToArray();
+ this.Min = min.ToArray();
+ this.Max = max.ToArray();
+
+ Embree.CommitScene(this.Handle);
+
+ Error error = Embree.GetDeviceError(this.device);
+ if (error != Error.None)
+ {
+ throw new InvalidOperationException($"Embree scene setup failed: {error}");
+ }
+ }
+
+ public EmbreeScene Handle { get; }
+
+ public CityObject[] Objects { get; }
+
+ /// Lower corner of each object's world AABB, indexed the same as Objects.
+ public Vector3[] Min { get; }
+
+ /// Upper corner of each object's world AABB.
+ public Vector3[] Max { get; }
+
+ /// Half the side of the ground the city sits on.
+ public float Extent { get; }
+
+ public int Count => this.Objects.Length;
+
+ ///
+ /// geomID of an object. Index 0 is the ground, so the objects start at 1.
+ ///
+ public uint GeomIDOf(int index) => (uint)index + 1;
+
+ public void Dispose()
+ {
+ Embree.ReleaseScene(this.Handle);
+ Embree.ReleaseDevice(this.device);
+ }
+
+ ///
+ /// Eight corners and the centre: the points the occlusion pass aims at.
+ ///
+ ///
+ /// Pulled 12% in from the AABB corners so they land inside the solid. A ray aimed exactly
+ /// at a corner grazes the surface at best, and for a cylinder or a cone the corner is
+ /// outside the shape altogether, so the ray misses and reports whatever stands behind.
+ /// Inside the volume the ray meets the object's own front face, which is the answer the
+ /// test is after.
+ ///
+ public void GetSamplePoints(int index, Span points)
+ {
+ Vector3 centre = (this.Min[index] + this.Max[index]) * 0.5f;
+ Vector3 lo = centre + ((this.Min[index] - centre) * 0.88f);
+ Vector3 hi = centre + ((this.Max[index] - centre) * 0.88f);
+
+ points[0] = new Vector3(lo.X, lo.Y, lo.Z);
+ points[1] = new Vector3(hi.X, lo.Y, lo.Z);
+ points[2] = new Vector3(lo.X, hi.Y, lo.Z);
+ points[3] = new Vector3(hi.X, hi.Y, lo.Z);
+ points[4] = new Vector3(lo.X, lo.Y, hi.Z);
+ points[5] = new Vector3(hi.X, lo.Y, hi.Z);
+ points[6] = new Vector3(lo.X, hi.Y, hi.Z);
+ points[7] = new Vector3(hi.X, hi.Y, hi.Z);
+ points[8] = (lo + hi) * 0.5f;
+ }
+
+ private static float Lerp(Random random, float min, float max) =>
+ min + ((max - min) * (float)random.NextDouble());
+
+ private static float Lerp2(float min, float max, float t) => min + ((max - min) * t);
+
+ private static uint Pack(byte r, byte g, byte b) =>
+ ((uint)r << 16) | ((uint)g << 8) | b;
+
+ private void AddGround(float extent)
+ {
+ Geometry geometry = Embree.NewGeometry(this.device, GeometryType.Triangle);
+
+ float* vertices = (float*)Embree.SetNewGeometryBuffer(
+ geometry, BufferType.Vertex, 0, Format.Float3, 3 * sizeof(float), 4);
+
+ ReadOnlySpan corners = stackalloc float[12]
+ {
+ -extent, 0, -extent,
+ extent, 0, -extent,
+ extent, 0, extent,
+ -extent, 0, extent,
+ };
+
+ for (int i = 0; i < corners.Length; i++)
+ {
+ vertices[i] = corners[i];
+ }
+
+ uint* indices = (uint*)Embree.SetNewGeometryBuffer(
+ geometry, BufferType.Index, 0, Format.Uint3, 3 * sizeof(uint), 2);
+
+ indices[0] = 0; indices[1] = 2; indices[2] = 1;
+ indices[3] = 0; indices[4] = 3; indices[5] = 2;
+
+ Embree.CommitGeometry(geometry);
+ Embree.AttachGeometry(this.Handle, geometry);
+ Embree.ReleaseGeometry(geometry);
+ }
+
+ private void AddObject(in CityObject o)
+ {
+ Mesh mesh = Meshes.Get(o.Primitive);
+
+ Geometry geometry = Embree.NewGeometry(this.device, GeometryType.Triangle);
+
+ float* vertices = (float*)Embree.SetNewGeometryBuffer(
+ geometry, BufferType.Vertex, 0, Format.Float3, 3 * sizeof(float), (nuint)mesh.Positions.Length);
+
+ float cos = MathF.Cos(o.Rotation);
+ float sin = MathF.Sin(o.Rotation);
+
+ for (int i = 0; i < mesh.Positions.Length; i++)
+ {
+ Vector3 p = mesh.Positions[i] * o.HalfExtent;
+ // Must match Evergine.Mathematics.Matrix4x4.CreateRotationY exactly. With the
+ // row-vector convention the shader uses, that is x' = x·cos + z·sin and
+ // z' = -x·sin + z·cos — the opposite sense to the textbook column-vector form.
+ // Getting this backwards rotates the geometry Embree sees away from the geometry
+ // the GPU draws, and the culling then discards objects that are plainly on screen.
+ var world = new Vector3(
+ (p.X * cos) + (p.Z * sin),
+ p.Y,
+ (-p.X * sin) + (p.Z * cos)) + o.Centre;
+
+ vertices[(i * 3) + 0] = world.X;
+ vertices[(i * 3) + 1] = world.Y;
+ vertices[(i * 3) + 2] = world.Z;
+ }
+
+ uint* indices = (uint*)Embree.SetNewGeometryBuffer(
+ geometry, BufferType.Index, 0, Format.Uint3, 3 * sizeof(uint), (nuint)(mesh.Indices.Length / 3));
+
+ for (int i = 0; i < mesh.Indices.Length; i++)
+ {
+ indices[i] = mesh.Indices[i];
+ }
+
+ Embree.CommitGeometry(geometry);
+ Embree.AttachGeometry(this.Handle, geometry);
+ Embree.ReleaseGeometry(geometry);
+ }
+ }
+}
diff --git a/CityCulling/CityCulling.csproj b/CityCulling/CityCulling.csproj
new file mode 100644
index 0000000..272ef78
--- /dev/null
+++ b/CityCulling/CityCulling.csproj
@@ -0,0 +1,23 @@
+
+
+
+ WinExe
+ net10.0-windows
+ true
+ True
+ disable
+ CityCulling
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CityCulling/Culling.cs b/CityCulling/Culling.cs
new file mode 100644
index 0000000..f44b829
--- /dev/null
+++ b/CityCulling/Culling.cs
@@ -0,0 +1,130 @@
+using Evergine.Bindings.Embree;
+using System;
+using System.Numerics;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using EmbreeScene = Evergine.Bindings.Embree.Scene;
+
+namespace CityCulling
+{
+ ///
+ /// The occlusion pass: for each object that survives the frustum, decide whether anything
+ /// in the city hides it.
+ ///
+ ///
+ /// Per-object rays with a single-ray query, which is what the OcclusionCulling benchmark in
+ /// this repository picked as the winner. On a thousand objects it came out at about 0.2 ms
+ /// against 0.6 ms for a visibility buffer, and it discarded more. Packets lost here because
+ /// filling eight lanes means giving up the early exit, and rays aimed at eight different
+ /// buildings diverge immediately, which is the case packet traversal is worst at.
+ ///
+ internal static unsafe class Culling
+ {
+ private const int SamplesPerObject = 9;
+
+ ///
+ /// Frustum stage: fills with surviving indices.
+ ///
+ public static int Frustum(City city, Camera camera, int[] candidates)
+ {
+ int count = 0;
+ for (int i = 0; i < city.Count; i++)
+ {
+ if (camera.Intersects(city.Min[i], city.Max[i]))
+ {
+ candidates[count++] = i;
+ }
+ }
+
+ return count;
+ }
+
+ ///
+ /// Occlusion stage. Marks for every candidate that at least
+ /// one sample ray reaches before anything else, and returns how many rays that took.
+ ///
+ public static long Occlusion(City city, Camera camera, int[] candidates, int candidateCount, bool[] visible)
+ {
+ Array.Clear(visible);
+
+ EmbreeScene handle = city.Handle;
+ Vector3 origin = camera.Position;
+ long rays = 0;
+ object counterLock = new();
+
+ Parallel.For(
+ 0,
+ candidateCount,
+ () => (Buffer: (IntPtr)NativeMemory.AlignedAlloc((nuint)sizeof(RayHit), 64), Rays: 0L),
+ (index, _, state) =>
+ {
+ RayHit* rayhit = (RayHit*)state.Buffer;
+
+ IntersectArguments args;
+ Embree.InitIntersectArguments(&args);
+ args.Flags = RayQueryFlags.Coherent;
+
+ int obj = candidates[index];
+ uint geomID = city.GeomIDOf(obj);
+
+ Span points = stackalloc Vector3[SamplesPerObject];
+ city.GetSamplePoints(obj, points);
+
+ long local = state.Rays;
+
+ for (int s = 0; s < SamplesPerObject; s++)
+ {
+ local++;
+ if (ReachesObject(handle, rayhit, &args, origin, points[s], geomID))
+ {
+ visible[obj] = true;
+ break;
+ }
+ }
+
+ return (state.Buffer, local);
+ },
+ state =>
+ {
+ NativeMemory.AlignedFree((void*)state.Buffer);
+ lock (counterLock)
+ {
+ rays += state.Rays;
+ }
+ });
+
+ return rays;
+ }
+
+ ///
+ /// Whether a ray aimed at reaches
+ /// before anything else.
+ ///
+ ///
+ /// Closest-hit rather than the cheaper any-hit. An occlusion ray that stops just short of
+ /// the sample point has the object occlude itself, because the point sits on its own
+ /// surface — measured on a thousand boxes that called a quarter of the plainly visible
+ /// ones hidden.
+ ///
+ private static bool ReachesObject(EmbreeScene scene, RayHit* rayhit, IntersectArguments* args, Vector3 origin, Vector3 target, uint geomID)
+ {
+ Vector3 direction = Vector3.Normalize(target - origin);
+
+ *rayhit = default;
+ rayhit->Ray.OrgX = origin.X;
+ rayhit->Ray.OrgY = origin.Y;
+ rayhit->Ray.OrgZ = origin.Z;
+ rayhit->Ray.DirX = direction.X;
+ rayhit->Ray.DirY = direction.Y;
+ rayhit->Ray.DirZ = direction.Z;
+ rayhit->Ray.Tnear = 0.0f;
+ rayhit->Ray.Tfar = float.PositiveInfinity;
+ rayhit->Ray.Mask = uint.MaxValue;
+ rayhit->Hit.GeomID = Embree.INVALID_GEOMETRY_ID;
+
+ Embree.Intersect1(scene, rayhit, args);
+
+ return rayhit->Hit.GeomID == geomID;
+ }
+ }
+}
diff --git a/CityCulling/MainForm.cs b/CityCulling/MainForm.cs
new file mode 100644
index 0000000..eb342fa
--- /dev/null
+++ b/CityCulling/MainForm.cs
@@ -0,0 +1,89 @@
+using Evergine.Forms;
+using System.Drawing;
+using System.Windows.Forms;
+
+namespace CityCulling
+{
+ ///
+ /// A plain Windows Forms window with an to render into and a
+ /// status bar showing what the culling did this frame.
+ ///
+ internal sealed class MainForm : Form
+ {
+ private readonly ToolStripStatusLabel cullingLabel;
+ private readonly ToolStripStatusLabel sceneLabel;
+
+ public MainForm(int width, int height)
+ {
+ this.Text = "CityCulling - Embree occlusion culling on the Evergine low-level API";
+ this.StartPosition = FormStartPosition.CenterScreen;
+ this.ClientSize = new Size(width, height + 60);
+ this.MinimumSize = new Size(640, 400);
+
+ this.RenderControl = new EvergineControl { Dock = DockStyle.Fill };
+
+ this.ShowCulledButton = new ToolStripButton("Show discarded in red")
+ {
+ CheckOnClick = true,
+ DisplayStyle = ToolStripItemDisplayStyle.Text,
+ };
+
+ this.PauseButton = new ToolStripButton("Pause camera")
+ {
+ CheckOnClick = true,
+ DisplayStyle = ToolStripItemDisplayStyle.Text,
+ };
+
+ this.CaptureButton = new ToolStripButton("Save captures")
+ {
+ DisplayStyle = ToolStripItemDisplayStyle.Text,
+ };
+
+ var toolStrip = new ToolStrip
+ {
+ GripStyle = ToolStripGripStyle.Hidden,
+ RenderMode = ToolStripRenderMode.System,
+ };
+
+ toolStrip.Items.Add(this.PauseButton);
+ toolStrip.Items.Add(new ToolStripSeparator());
+ toolStrip.Items.Add(this.ShowCulledButton);
+ toolStrip.Items.Add(new ToolStripSeparator());
+ toolStrip.Items.Add(this.CaptureButton);
+
+ this.cullingLabel = new ToolStripStatusLabel(string.Empty)
+ {
+ Spring = true,
+ TextAlign = ContentAlignment.MiddleLeft,
+ };
+
+ this.sceneLabel = new ToolStripStatusLabel(string.Empty);
+
+ var statusStrip = new StatusStrip();
+ statusStrip.Items.Add(this.cullingLabel);
+ statusStrip.Items.Add(this.sceneLabel);
+
+ this.Controls.Add(this.RenderControl);
+ this.Controls.Add(toolStrip);
+ this.Controls.Add(statusStrip);
+ }
+
+ public EvergineControl RenderControl { get; }
+
+ public ToolStripButton ShowCulledButton { get; }
+
+ public ToolStripButton PauseButton { get; }
+
+ public ToolStripButton CaptureButton { get; }
+
+ public void SetSceneInfo(int objects, int triangles) =>
+ this.sceneLabel.Text = $"{objects:N0} objects | {triangles:N0} triangles";
+
+ public void SetFrameInfo(int drawn, int total, double cullMs, double frameMs)
+ {
+ double culled = 100.0 * (total - drawn) / total;
+ this.cullingLabel.Text =
+ $"draw calls {drawn,5:N0} / {total,5:N0} | culled {culled,5:F1}% | culling {cullMs,5:F2} ms | frame {frameMs,5:F2} ms";
+ }
+ }
+}
diff --git a/CityCulling/Meshes.cs b/CityCulling/Meshes.cs
new file mode 100644
index 0000000..cdcb10e
--- /dev/null
+++ b/CityCulling/Meshes.cs
@@ -0,0 +1,212 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace CityCulling
+{
+ /// A unit mesh: positions in [-1, 1] on each axis, scaled per object.
+ internal sealed class Mesh
+ {
+ public Vector3[] Positions;
+ public Vector3[] Normals;
+ public uint[] Indices;
+ }
+
+ ///
+ /// The four primitives the city is built from, generated once and shared.
+ ///
+ ///
+ /// Unit-sized and centred, so one mesh serves every object of that kind: the GPU scales it
+ /// with the per-instance matrix, and the Embree side bakes the same scale into world-space
+ /// triangles. Faces are not shared between sides, because each needs its own normal for the
+ /// flat shading to read as edges.
+ ///
+ internal static class Meshes
+ {
+ private static readonly Dictionary Cache = new();
+
+ public static Mesh Get(Primitive primitive)
+ {
+ if (!Cache.TryGetValue(primitive, out Mesh mesh))
+ {
+ mesh = primitive switch
+ {
+ Primitive.Box => Box(),
+ Primitive.Cylinder => Cylinder(12),
+ Primitive.Cone => Cone(12),
+ Primitive.Wedge => Wedge(),
+ _ => Box(),
+ };
+
+ Cache[primitive] = mesh;
+ }
+
+ return mesh;
+ }
+
+ private static Mesh Box()
+ {
+ var positions = new List();
+ var normals = new List();
+ var indices = new List();
+
+ Span faceNormals = stackalloc Vector3[6]
+ {
+ new(0, 0, -1), new(0, 0, 1), new(0, -1, 0),
+ new(0, 1, 0), new(-1, 0, 0), new(1, 0, 0),
+ };
+
+ foreach (Vector3 n in faceNormals)
+ {
+ // Two vectors spanning the face, chosen so the winding comes out clockwise when
+ // seen from outside, which is what CullBack expects.
+ Vector3 u = MathF.Abs(n.Y) > 0.5f ? new Vector3(1, 0, 0) : new Vector3(0, 1, 0);
+ Vector3 v = Vector3.Cross(n, u);
+
+ uint b = (uint)positions.Count;
+ positions.Add(n - u - v);
+ positions.Add(n - u + v);
+ positions.Add(n + u + v);
+ positions.Add(n + u - v);
+
+ for (int i = 0; i < 4; i++)
+ {
+ normals.Add(n);
+ }
+
+ indices.Add(b); indices.Add(b + 1); indices.Add(b + 2);
+ indices.Add(b); indices.Add(b + 2); indices.Add(b + 3);
+ }
+
+ return Build(positions, normals, indices);
+ }
+
+ private static Mesh Cylinder(int segments)
+ {
+ var positions = new List();
+ var normals = new List();
+ var indices = new List();
+
+ for (int i = 0; i < segments; i++)
+ {
+ float a0 = i / (float)segments * MathF.Tau;
+ float a1 = (i + 1) / (float)segments * MathF.Tau;
+
+ var d0 = new Vector3(MathF.Cos(a0), 0, MathF.Sin(a0));
+ var d1 = new Vector3(MathF.Cos(a1), 0, MathF.Sin(a1));
+ Vector3 n = Vector3.Normalize(d0 + d1);
+
+ uint b = (uint)positions.Count;
+ positions.Add(new Vector3(d0.X, -1, d0.Z));
+ positions.Add(new Vector3(d0.X, 1, d0.Z));
+ positions.Add(new Vector3(d1.X, 1, d1.Z));
+ positions.Add(new Vector3(d1.X, -1, d1.Z));
+
+ for (int k = 0; k < 4; k++)
+ {
+ normals.Add(n);
+ }
+
+ indices.Add(b); indices.Add(b + 1); indices.Add(b + 2);
+ indices.Add(b); indices.Add(b + 2); indices.Add(b + 3);
+
+ // Cap, as a fan around the centre.
+ uint c = (uint)positions.Count;
+ positions.Add(new Vector3(0, 1, 0));
+ positions.Add(new Vector3(d0.X, 1, d0.Z));
+ positions.Add(new Vector3(d1.X, 1, d1.Z));
+ normals.Add(Vector3.UnitY); normals.Add(Vector3.UnitY); normals.Add(Vector3.UnitY);
+ indices.Add(c); indices.Add(c + 2); indices.Add(c + 1);
+ }
+
+ return Build(positions, normals, indices);
+ }
+
+ private static Mesh Cone(int segments)
+ {
+ var positions = new List();
+ var normals = new List();
+ var indices = new List();
+
+ for (int i = 0; i < segments; i++)
+ {
+ float a0 = i / (float)segments * MathF.Tau;
+ float a1 = (i + 1) / (float)segments * MathF.Tau;
+
+ var d0 = new Vector3(MathF.Cos(a0), -1, MathF.Sin(a0));
+ var d1 = new Vector3(MathF.Cos(a1), -1, MathF.Sin(a1));
+ var apex = new Vector3(0, 1, 0);
+
+ Vector3 n = Vector3.Normalize(Vector3.Cross(d1 - d0, apex - d0));
+
+ uint b = (uint)positions.Count;
+ positions.Add(d0); positions.Add(apex); positions.Add(d1);
+ normals.Add(n); normals.Add(n); normals.Add(n);
+ indices.Add(b); indices.Add(b + 1); indices.Add(b + 2);
+
+ uint c = (uint)positions.Count;
+ positions.Add(new Vector3(0, -1, 0)); positions.Add(d0); positions.Add(d1);
+ normals.Add(-Vector3.UnitY); normals.Add(-Vector3.UnitY); normals.Add(-Vector3.UnitY);
+ indices.Add(c); indices.Add(c + 1); indices.Add(c + 2);
+ }
+
+ return Build(positions, normals, indices);
+ }
+
+ /// A box with a sloped roof — a house shape, to break up the skyline.
+ private static Mesh Wedge()
+ {
+ var positions = new List();
+ var normals = new List();
+ var indices = new List();
+
+ void Quad(Vector3 a, Vector3 b, Vector3 c, Vector3 d)
+ {
+ Vector3 n = Vector3.Normalize(Vector3.Cross(b - a, c - a));
+ uint i0 = (uint)positions.Count;
+ positions.Add(a); positions.Add(b); positions.Add(c); positions.Add(d);
+ for (int k = 0; k < 4; k++) { normals.Add(n); }
+ indices.Add(i0); indices.Add(i0 + 1); indices.Add(i0 + 2);
+ indices.Add(i0); indices.Add(i0 + 2); indices.Add(i0 + 3);
+ }
+
+ void Tri(Vector3 a, Vector3 b, Vector3 c)
+ {
+ Vector3 n = Vector3.Normalize(Vector3.Cross(b - a, c - a));
+ uint i0 = (uint)positions.Count;
+ positions.Add(a); positions.Add(b); positions.Add(c);
+ normals.Add(n); normals.Add(n); normals.Add(n);
+ indices.Add(i0); indices.Add(i0 + 1); indices.Add(i0 + 2);
+ }
+
+ const float Eaves = 0.45f;
+ var ridgeBack = new Vector3(0, 1, -1);
+ var ridgeFront = new Vector3(0, 1, 1);
+
+ var blb = new Vector3(-1, -1, -1); var brb = new Vector3(1, -1, -1);
+ var blf = new Vector3(-1, -1, 1); var brf = new Vector3(1, -1, 1);
+ var tlb = new Vector3(-1, Eaves, -1); var trb = new Vector3(1, Eaves, -1);
+ var tlf = new Vector3(-1, Eaves, 1); var trf = new Vector3(1, Eaves, 1);
+
+ Quad(brb, trb, tlb, blb); // -Z wall
+ Quad(blf, tlf, trf, brf); // +Z wall
+ Quad(blb, tlb, tlf, blf); // -X wall
+ Quad(brf, trf, trb, brb); // +X wall
+ Quad(blb, blf, brf, brb); // floor
+ Quad(tlb, ridgeBack, ridgeFront, tlf); // -X roof slope
+ Quad(trf, ridgeFront, ridgeBack, trb); // +X roof slope
+ Tri(tlb, trb, ridgeBack); // back gable
+ Tri(trf, tlf, ridgeFront); // front gable
+
+ return Build(positions, normals, indices);
+ }
+
+ private static Mesh Build(List positions, List normals, List indices) =>
+ new()
+ {
+ Positions = positions.ToArray(),
+ Normals = normals.ToArray(),
+ Indices = indices.ToArray(),
+ };
+ }
+}
diff --git a/CityCulling/Png.cs b/CityCulling/Png.cs
new file mode 100644
index 0000000..8b40aa7
--- /dev/null
+++ b/CityCulling/Png.cs
@@ -0,0 +1,137 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+
+namespace CityCulling
+{
+ ///
+ /// A minimal PNG writer, so the sample stays dependency-free and runs on every RID the
+ /// binding ships. System.Drawing would not do: it is Windows-only on modern .NET.
+ ///
+ internal static class Png
+ {
+ ///
+ /// Writes an RGB image. is three bytes per pixel, row-major.
+ ///
+ public static void Write(string path, int width, int height, byte[] rgb)
+ {
+ // PNG wants a filter byte at the start of every scanline; 0 means "no filter".
+ var raw = new byte[height * ((width * 3) + 1)];
+ for (int y = 0; y < height; y++)
+ {
+ int source = y * width * 3;
+ int destination = y * ((width * 3) + 1);
+ raw[destination] = 0;
+ Array.Copy(rgb, source, raw, destination + 1, width * 3);
+ }
+
+ using var file = File.Create(path);
+ file.Write(new byte[] { 0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A });
+
+ var header = new byte[13];
+ WriteBigEndian(header, 0, (uint)width);
+ WriteBigEndian(header, 4, (uint)height);
+ header[8] = 8; // bit depth
+ header[9] = 2; // colour type: truecolour
+ WriteChunk(file, "IHDR", header);
+
+ WriteChunk(file, "IDAT", Deflate(raw));
+ WriteChunk(file, "IEND", Array.Empty());
+ }
+
+ ///
+ /// zlib stream: a two-byte header, raw deflate, and an Adler-32 of the uncompressed
+ /// data. DeflateStream produces the middle part; the wrapper has to be added by hand
+ /// because ZLibStream's header bytes are not what every decoder expects from a PNG.
+ ///
+ private static byte[] Deflate(byte[] data)
+ {
+ using var output = new MemoryStream();
+ output.WriteByte(0x78); // CM = deflate, CINFO = 32K window
+ output.WriteByte(0x01); // no preset dictionary, fastest compression
+
+ using (var deflate = new DeflateStream(output, CompressionLevel.Fastest, leaveOpen: true))
+ {
+ deflate.Write(data, 0, data.Length);
+ }
+
+ uint adler = Adler32(data);
+ output.WriteByte((byte)(adler >> 24));
+ output.WriteByte((byte)(adler >> 16));
+ output.WriteByte((byte)(adler >> 8));
+ output.WriteByte((byte)adler);
+
+ return output.ToArray();
+ }
+
+ private static void WriteChunk(Stream stream, string type, byte[] data)
+ {
+ var length = new byte[4];
+ WriteBigEndian(length, 0, (uint)data.Length);
+ stream.Write(length);
+
+ var payload = new byte[4 + data.Length];
+ for (int i = 0; i < 4; i++)
+ {
+ payload[i] = (byte)type[i];
+ }
+
+ Array.Copy(data, 0, payload, 4, data.Length);
+ stream.Write(payload);
+
+ var crc = new byte[4];
+ WriteBigEndian(crc, 0, Crc32(payload));
+ stream.Write(crc);
+ }
+
+ private static void WriteBigEndian(byte[] buffer, int offset, uint value)
+ {
+ buffer[offset + 0] = (byte)(value >> 24);
+ buffer[offset + 1] = (byte)(value >> 16);
+ buffer[offset + 2] = (byte)(value >> 8);
+ buffer[offset + 3] = (byte)value;
+ }
+
+ private static uint Adler32(byte[] data)
+ {
+ uint a = 1, b = 0;
+ foreach (byte value in data)
+ {
+ a = (a + value) % 65521;
+ b = (b + a) % 65521;
+ }
+
+ return (b << 16) | a;
+ }
+
+ private static readonly uint[] CrcTable = BuildCrcTable();
+
+ private static uint[] BuildCrcTable()
+ {
+ var table = new uint[256];
+ for (uint n = 0; n < 256; n++)
+ {
+ uint c = n;
+ for (int k = 0; k < 8; k++)
+ {
+ c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
+ }
+
+ table[n] = c;
+ }
+
+ return table;
+ }
+
+ private static uint Crc32(byte[] data)
+ {
+ uint c = 0xFFFFFFFFu;
+ foreach (byte value in data)
+ {
+ c = CrcTable[(c ^ value) & 0xFF] ^ (c >> 8);
+ }
+
+ return c ^ 0xFFFFFFFFu;
+ }
+ }
+}
diff --git a/CityCulling/Program.cs b/CityCulling/Program.cs
new file mode 100644
index 0000000..81c8784
--- /dev/null
+++ b/CityCulling/Program.cs
@@ -0,0 +1,499 @@
+using Evergine.Common.Graphics;
+using Evergine.DirectX11;
+using Evergine.Forms;
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using EvergineMath = Evergine.Mathematics;
+using Vector3 = System.Numerics.Vector3;
+
+namespace CityCulling
+{
+ ///
+ /// A city of primitives drawn with the Evergine low-level API, with Embree deciding which
+ /// objects reach the GPU.
+ ///
+ internal static class Program
+ {
+ private const uint Width = 1280;
+ private const uint Height = 720;
+
+ private const int ObjectCount = 1000;
+ private const float BlockSize = 34.0f;
+ private const int BlocksPerSide = 15;
+ private const int Seed = 20260811;
+
+ private const float EyeHeight = 2.2f; // street level, against buildings 4 to 46 tall
+ private const float OrbitRadius = 0.62f; // as a fraction of the city half-extent
+ private const float CaptureAngle = 0.9f; // fixed, so the captures are reproducible
+
+ private static GraphicsContext graphicsContext;
+ private static SwapChain swapChain;
+ private static CommandQueue commandQueue;
+ private static MainForm form;
+ private static City city;
+ private static Renderer renderer;
+
+ private static int[] candidates;
+ private static bool[] visible;
+ private static bool[] everything;
+
+ private static Stopwatch clock;
+ private static float cameraAngle = CaptureAngle;
+ private static bool captureRequested;
+ private static bool exitAfterCapture;
+ private static string outputDirectory;
+ private static int lastCandidateCount;
+ private static bool benchmark;
+ private static bool noCull;
+ private static int benchFrame;
+ private static readonly System.Collections.Generic.List BenchCull = new();
+ private static readonly System.Collections.Generic.List BenchFrame = new();
+ private static readonly System.Collections.Generic.List BenchVisible = new();
+ private static double lastMissedScreen;
+
+ [STAThread]
+ private static int Main(string[] args)
+ {
+ benchmark = args.Contains("--bench");
+ noCull = args.Contains("--no-cull");
+ bool capture = args.Contains("--capture");
+ exitAfterCapture = args.Contains("--exit");
+ captureRequested = capture;
+
+ outputDirectory = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
+
+ System.Windows.Forms.Application.EnableVisualStyles();
+ System.Windows.Forms.Application.SetCompatibleTextRenderingDefault(false);
+
+ form = new MainForm((int)Width, (int)Height);
+ form.CaptureButton.Click += (s, e) => captureRequested = true;
+ form.CreateControl();
+
+ graphicsContext = new DX11GraphicsContext();
+ graphicsContext.CreateDevice();
+
+ var swapChainDescription = new SwapChainDescription()
+ {
+ Width = (uint)form.RenderControl.ClientSize.Width,
+ Height = (uint)form.RenderControl.ClientSize.Height,
+ SurfaceInfo = new SurfaceInfo(form.RenderControl.Handle, SurfaceInfo.SurfaceTypes.Forms),
+ ColorTargetFormat = PixelFormat.R8G8B8A8_UNorm,
+ ColorTargetFlags = TextureFlags.RenderTarget | TextureFlags.ShaderResource,
+ DepthStencilTargetFormat = PixelFormat.D24_UNorm_S8_UInt,
+ DepthStencilTargetFlags = TextureFlags.DepthStencil,
+ SampleCount = TextureSampleCount.None,
+ IsWindowed = true,
+ RefreshRate = 60,
+ };
+
+ swapChain = graphicsContext.CreateSwapChain(swapChainDescription);
+ // VSync would clamp every frame to the refresh rate and hide what the culling costs.
+ swapChain.VerticalSync = !benchmark;
+
+ var windowSystem = new FormsWindowsSystem { AutoRegisterWindow = false };
+ windowSystem.RegisterLoopThreadControl(form);
+ windowSystem.Run(Load, Draw);
+
+ return 0;
+ }
+
+ private static void Load()
+ {
+ city = new City(ObjectCount, BlockSize, BlocksPerSide, Seed);
+ renderer = new Renderer(graphicsContext, city, swapChain.FrameBuffer);
+
+ candidates = new int[city.Count];
+ visible = new bool[city.Count];
+ everything = new bool[city.Count];
+ Array.Fill(everything, true);
+
+ commandQueue = graphicsContext.Factory.CreateCommandQueue();
+ clock = Stopwatch.StartNew();
+
+ int triangles = city.Objects.Sum(o => Meshes.Get(o.Primitive).Indices.Length / 3) + 2;
+ form.SetSceneInfo(city.Count, triangles);
+ }
+
+ private static void Draw()
+ {
+ long frameStart = Stopwatch.GetTimestamp();
+
+ if (benchmark)
+ {
+ // A full orbit in fixed steps, so the sweep covers every direction the camera can
+ // face rather than whichever ones a wall-clock animation happened to land on.
+ cameraAngle = CaptureAngle + ((float)benchFrame / BenchFrames * MathF.Tau);
+ }
+ else if (!form.PauseButton.Checked && !captureRequested)
+ {
+ cameraAngle = CaptureAngle + ((float)clock.Elapsed.TotalSeconds * 0.16f);
+ }
+
+ uint width = (uint)Math.Max(form.RenderControl.ClientSize.Width, 1);
+ uint height = (uint)Math.Max(form.RenderControl.ClientSize.Height, 1);
+
+ Camera camera = MakeCamera(cameraAngle, (float)width / height);
+
+ // The culling pass: frustum, then Embree occlusion. This is the whole point of the
+ // sample and the only part that is measured.
+ long cullStart = Stopwatch.GetTimestamp();
+ int candidateCount = Culling.Frustum(city, camera, candidates);
+ lastCandidateCount = candidateCount;
+
+ if (noCull)
+ {
+ // The counterfactual: draw everything and pay for it on the GPU instead.
+ Array.Fill(visible, true);
+ }
+ else
+ {
+ Culling.Occlusion(city, camera, candidates, candidateCount, visible);
+ }
+
+ double cullMs = Milliseconds(Stopwatch.GetTimestamp() - cullStart);
+
+ swapChain.InitFrame();
+
+ var viewProjection = ViewProjection(camera);
+ var light = new EvergineMath.Vector3(-0.42f, -0.78f, -0.46f);
+ light.Normalize();
+
+ var commandBuffer = commandQueue.CommandBuffer();
+ commandBuffer.Begin();
+ commandBuffer.SetViewports(new[] { new Viewport(0, 0, width, height) });
+ commandBuffer.SetScissorRectangles(new[] { new EvergineMath.Rectangle(0, 0, (int)width, (int)height) });
+
+ int drawn = renderer.Draw(commandBuffer, city, viewProjection, light, visible, form.ShowCulledButton.Checked);
+
+ commandBuffer.End();
+ commandBuffer.Commit();
+ commandQueue.Submit();
+ commandQueue.WaitIdle();
+
+ if (captureRequested)
+ {
+ captureRequested = false;
+ WriteCaptures(camera, viewProjection, light, width, height);
+
+ if (exitAfterCapture)
+ {
+ Environment.Exit(0);
+ }
+ }
+
+ swapChain.Present();
+
+ double frameMs = Milliseconds(Stopwatch.GetTimestamp() - frameStart);
+ form.SetFrameInfo(drawn, city.Count + 1, cullMs, frameMs);
+
+ if (benchmark)
+ {
+ benchFrame++;
+
+ // The first frames pay JIT and driver warm-up.
+ if (benchFrame > BenchWarmup)
+ {
+ BenchCull.Add(cullMs);
+ BenchFrame.Add(frameMs);
+ BenchVisible.Add(drawn);
+ }
+
+ if (benchFrame >= BenchWarmup + BenchFrames)
+ {
+ ReportBenchmark();
+ Environment.Exit(0);
+ }
+ }
+ }
+
+ ///
+ /// The camera orbits the city centre at street level, looking horizontally across it.
+ ///
+ ///
+ /// The eye height is the whole reason this scene culls well. Lift it above the rooftops
+ /// and almost everything is visible at once; at 2.2 units, with buildings from 4 to 46,
+ /// the first row of facades hides most of what is behind it.
+ ///
+ private static Camera MakeCamera(float angle, float aspect)
+ {
+ float radius = city.Extent * OrbitRadius;
+ var position = new Vector3(MathF.Cos(angle) * radius, EyeHeight, MathF.Sin(angle) * radius);
+ var target = new Vector3(MathF.Cos(angle + 2.2f) * radius * 0.35f, EyeHeight * 2.4f, MathF.Sin(angle + 2.2f) * radius * 0.35f);
+
+ return new Camera(position, target, 62.0f, aspect, 0.25f, city.Extent * 6.0f);
+ }
+
+ ///
+ /// The matrix that goes to the GPU, built with Evergine.Mathematics because Evergine's
+ /// depth is reversed and only its projection takes reverseDepthBuffer.
+ ///
+ private static EvergineMath.Matrix4x4 ViewProjection(Camera camera)
+ {
+ var view = EvergineMath.Matrix4x4.CreateLookAt(
+ new EvergineMath.Vector3(camera.Position.X, camera.Position.Y, camera.Position.Z),
+ new EvergineMath.Vector3(camera.Target.X, camera.Target.Y, camera.Target.Z),
+ EvergineMath.Vector3.Up);
+
+ var projection = EvergineMath.Matrix4x4.CreatePerspectiveFieldOfView(
+ camera.FovDegrees * MathF.PI / 180.0f, camera.Aspect, camera.Near, camera.Far, reverseDepthBuffer: true);
+
+ return view * projection;
+ }
+
+ private static double Milliseconds(long ticks) => ticks * 1000.0 / Stopwatch.Frequency;
+
+ private const int BenchWarmup = 30;
+ private const int BenchFrames = 360; // one full orbit, one degree at a time
+
+ ///
+ /// What the culling costs across a full orbit, and what share of a 60 Hz frame that is.
+ ///
+ private static void ReportBenchmark()
+ {
+ var cull = BenchCull.ToArray();
+ var frame = BenchFrame.ToArray();
+ var drawn = BenchVisible.ToArray();
+ Array.Sort(cull);
+ Array.Sort(frame);
+ Array.Sort(drawn);
+
+ double medianCull = cull[cull.Length / 2];
+
+ Console.WriteLine();
+ Console.WriteLine($"{city.Count:N0} objects, {BenchFrames} frames over a full orbit, VSync off{(noCull ? ", occlusion culling OFF" : string.Empty)}");
+ Console.WriteLine();
+ Console.WriteLine(" min median mean max");
+ Console.WriteLine($"culling {cull[0],8:F3} {medianCull,10:F3} {cull.Average(),10:F3} {cull[^1],10:F3} ms");
+ Console.WriteLine($"whole frame {frame[0],8:F3} {frame[frame.Length / 2],10:F3} {frame.Average(),10:F3} {frame[^1],10:F3} ms");
+ Console.WriteLine($"draw calls {drawn[0],8:N0} {drawn[drawn.Length / 2],10:N0} {drawn.Average(),10:F0} {drawn[^1],10:N0}");
+ Console.WriteLine();
+ if (!noCull)
+ {
+ Console.WriteLine($"Culling is {100.0 * medianCull / 16.6:F1}% of a 16.6 ms frame at 60 Hz ({medianCull:F2} ms of budget spent to avoid {city.Count - drawn[drawn.Length / 2]:N0} draw calls).");
+ }
+ }
+
+ ///
+ /// Three views of the same frame: what was drawn, what was thrown away, and the whole
+ /// city from above so the occlusion shadows behind each building are visible.
+ ///
+ private static void WriteCaptures(Camera camera, EvergineMath.Matrix4x4 viewProjection, EvergineMath.Vector3 light, uint width, uint height)
+ {
+ var lightForTopDown = new EvergineMath.Vector3(-0.35f, -0.9f, -0.25f);
+ lightForTopDown.Normalize();
+
+ RenderTo(viewProjection, light, visible, drawCulled: false, width, height, "city.png");
+ RenderTo(viewProjection, light, visible, drawCulled: true, width, height, "culled.png");
+ RenderTo(TopDown(camera), lightForTopDown, visible, drawCulled: true, width, height, "topdown.png", CameraMarkers(camera));
+ RayTraceEmbree(camera, width, height, "embree.png");
+
+ int visibleCount = 0;
+ for (int i = 0; i < visible.Length; i++)
+ {
+ if (visible[i]) { visibleCount++; }
+ }
+
+ Console.WriteLine($"objects {city.Count}, frustum candidates {lastCandidateCount}, visible {visibleCount}, culled {100.0 * (city.Count - visibleCount) / city.Count:F1}%");
+ Console.WriteLine($"screen area held by discarded objects (from Embree's own geometry): {lastMissedScreen:F2}%");
+ Console.WriteLine($"Wrote city.png, culled.png, topdown.png and embree.png to {outputDirectory}");
+ }
+
+ ///
+ /// The same view, but traced against the Embree scene and coloured per geomID.
+ ///
+ ///
+ /// A diagnostic, not a feature. The culling can only be as right as the agreement between
+ /// the triangles the GPU draws and the triangles Embree holds, and nothing else in the
+ /// sample would notice if the two drifted apart: the render would look fine and objects
+ /// would simply be culled for no visible reason. Put this next to city.png and any
+ /// disagreement is obvious.
+ ///
+ private static unsafe void RayTraceEmbree(Camera camera, uint width, uint height, string fileName)
+ {
+ var pixels = new byte[width * height * 3];
+ var counters = new int[2]; // 0 = pixels on objects, 1 = pixels on discarded objects
+
+ Vector3 forward = Vector3.Normalize(camera.Target - camera.Position);
+ Vector3 right = Vector3.Normalize(Vector3.Cross(forward, Vector3.UnitY));
+ Vector3 up = Vector3.Cross(right, forward);
+ float tanHalf = MathF.Tan(camera.FovDegrees * MathF.PI / 180.0f * 0.5f);
+
+ System.Threading.Tasks.Parallel.For(
+ 0,
+ (int)height,
+ () => (IntPtr)System.Runtime.InteropServices.NativeMemory.AlignedAlloc((nuint)sizeof(Evergine.Bindings.Embree.RayHit), 64),
+ (y, _, buffer) =>
+ {
+ var rayhit = (Evergine.Bindings.Embree.RayHit*)buffer;
+ Evergine.Bindings.Embree.IntersectArguments args;
+ Evergine.Bindings.Embree.Embree.InitIntersectArguments(&args);
+
+ for (int x = 0; x < width; x++)
+ {
+ float ndcX = (((x + 0.5f) / width * 2.0f) - 1.0f) * tanHalf * camera.Aspect;
+ float ndcY = (1.0f - ((y + 0.5f) / height * 2.0f)) * tanHalf;
+ Vector3 direction = Vector3.Normalize(forward + (right * ndcX) + (up * ndcY));
+
+ *rayhit = default;
+ rayhit->Ray.OrgX = camera.Position.X;
+ rayhit->Ray.OrgY = camera.Position.Y;
+ rayhit->Ray.OrgZ = camera.Position.Z;
+ rayhit->Ray.DirX = direction.X;
+ rayhit->Ray.DirY = direction.Y;
+ rayhit->Ray.DirZ = direction.Z;
+ rayhit->Ray.Tnear = 0.0f;
+ rayhit->Ray.Tfar = float.PositiveInfinity;
+ rayhit->Ray.Mask = uint.MaxValue;
+ rayhit->Hit.GeomID = Evergine.Bindings.Embree.Embree.INVALID_GEOMETRY_ID;
+
+ Evergine.Bindings.Embree.Embree.Intersect1(city.Handle, rayhit, &args);
+
+ uint id = rayhit->Hit.GeomID;
+ int p = (int)(((y * width) + x) * 3);
+
+ if (id == Evergine.Bindings.Embree.Embree.INVALID_GEOMETRY_ID)
+ {
+ pixels[p] = 100; pixels[p + 1] = 140; pixels[p + 2] = 240;
+ }
+ else if (id == City.GroundGeomID)
+ {
+ pixels[p] = 52; pixels[p + 1] = 54; pixels[p + 2] = 60;
+ }
+ else
+ {
+ // Green when the culling kept it, red when it did not: the same verdict
+ // as culled.png, but on Embree's own geometry.
+ bool kept = visible[id - 1];
+ if (!kept) { System.Threading.Interlocked.Increment(ref counters[1]); }
+ System.Threading.Interlocked.Increment(ref counters[0]);
+ pixels[p] = (byte)(kept ? 70 : 215);
+ pixels[p + 1] = (byte)(kept ? 190 : 55);
+ pixels[p + 2] = (byte)(kept ? 95 : 55);
+ }
+ }
+
+ return buffer;
+ },
+ buffer => System.Runtime.InteropServices.NativeMemory.AlignedFree((void*)buffer));
+
+ lastMissedScreen = counters[0] > 0 ? 100.0 * counters[1] / counters[0] : 0.0;
+ Png.Write(Path.Combine(outputDirectory, fileName), (int)width, (int)height, pixels);
+ }
+
+ private static EvergineMath.Matrix4x4 TopDown(Camera camera)
+ {
+ float span = city.Extent * 1.15f;
+
+ var view = EvergineMath.Matrix4x4.CreateLookAt(
+ new EvergineMath.Vector3(0, city.Extent * 3.0f, 0.01f),
+ EvergineMath.Vector3.Zero,
+ EvergineMath.Vector3.Up);
+
+ var projection = EvergineMath.Matrix4x4.CreateOrthographic(
+ span * 2.0f * ((float)Width / Height), span * 2.0f, 0.1f, city.Extent * 8.0f, reverseDepthBuffer: true);
+
+ return view * projection;
+ }
+
+ ///
+ /// The camera and its two horizontal frustum edges, as boxes, so the top-down view shows
+ /// where the viewer is and which wedge of the city it can see at all.
+ ///
+ private static System.Collections.Generic.List<(EvergineMath.Matrix4x4 World, EvergineMath.Vector4 Colour)> CameraMarkers(Camera camera)
+ {
+ var markers = new System.Collections.Generic.List<(EvergineMath.Matrix4x4, EvergineMath.Vector4)>();
+ var amber = new EvergineMath.Vector4(1.0f, 0.72f, 0.16f, 1.0f);
+
+ float marker = city.Extent * 0.02f;
+ markers.Add((
+ EvergineMath.Matrix4x4.CreateScale(marker, marker * 8.0f, marker) *
+ EvergineMath.Matrix4x4.CreateTranslation(camera.Position.X, marker * 8.0f, camera.Position.Z),
+ amber));
+
+ Vector3 forward = Vector3.Normalize(camera.Target - camera.Position);
+ Vector3 right = Vector3.Normalize(Vector3.Cross(forward, Vector3.UnitY));
+ float tanHalf = MathF.Tan(camera.FovDegrees * MathF.PI / 180.0f * 0.5f) * camera.Aspect;
+ float length = city.Extent * 2.2f;
+
+ foreach (float side in new[] { -1.0f, 1.0f })
+ {
+ Vector3 edge = Vector3.Normalize(forward + (right * tanHalf * side));
+ Vector3 mid = camera.Position + (edge * length * 0.5f);
+ float yaw = MathF.Atan2(edge.X, edge.Z);
+
+ markers.Add((
+ EvergineMath.Matrix4x4.CreateScale(marker * 0.22f, marker * 0.22f, length * 0.5f) *
+ EvergineMath.Matrix4x4.CreateRotationY(yaw) *
+ EvergineMath.Matrix4x4.CreateTranslation(mid.X, marker, mid.Z),
+ amber));
+ }
+
+ return markers;
+ }
+
+ private static unsafe void RenderTo(EvergineMath.Matrix4x4 viewProjection, EvergineMath.Vector3 light, bool[] set, bool drawCulled, uint width, uint height, string fileName, System.Collections.Generic.List<(EvergineMath.Matrix4x4 World, EvergineMath.Vector4 Colour)> markers = null)
+ {
+ var commandBuffer = commandQueue.CommandBuffer();
+ commandBuffer.Begin();
+ commandBuffer.SetViewports(new[] { new Viewport(0, 0, width, height) });
+ commandBuffer.SetScissorRectangles(new[] { new EvergineMath.Rectangle(0, 0, (int)width, (int)height) });
+ renderer.Draw(commandBuffer, city, viewProjection, light, set, drawCulled, markers);
+ commandBuffer.End();
+ commandBuffer.Commit();
+ commandQueue.Submit();
+ commandQueue.WaitIdle();
+
+ SaveColorTarget(Path.Combine(outputDirectory, fileName), width, height);
+ }
+
+ ///
+ /// Copies the swapchain colour target into a staging texture and writes it out — the same
+ /// staging plus MapMemory pattern Evergine's own SnapShoter uses.
+ ///
+ private static unsafe void SaveColorTarget(string path, uint width, uint height)
+ {
+ Texture source = swapChain.FrameBuffer.ColorTargets[0].Texture;
+
+ var stagingDescription = source.Description;
+ stagingDescription.Flags = TextureFlags.None;
+ stagingDescription.CpuAccess = ResourceCpuAccess.Read;
+ stagingDescription.Usage = ResourceUsage.Staging;
+ var staging = graphicsContext.Factory.CreateTexture(ref stagingDescription);
+
+ var commandBuffer = commandQueue.CommandBuffer();
+ commandBuffer.Begin();
+ commandBuffer.CopyTextureDataTo(source, staging);
+ commandBuffer.End();
+ commandBuffer.Commit();
+ commandQueue.Submit();
+ commandQueue.WaitIdle();
+
+ MappedResource mapped = graphicsContext.MapMemory(staging, MapMode.Read);
+
+ try
+ {
+ var pixels = new byte[width * height * 3];
+ for (int y = 0; y < height; y++)
+ {
+ byte* row = (byte*)mapped.Data + (y * mapped.RowPitch);
+ for (int x = 0; x < width; x++)
+ {
+ int destination = (int)(((y * width) + x) * 3);
+ pixels[destination + 0] = row[(x * 4) + 0];
+ pixels[destination + 1] = row[(x * 4) + 1];
+ pixels[destination + 2] = row[(x * 4) + 2];
+ }
+ }
+
+ Png.Write(path, (int)width, (int)height, pixels);
+ }
+ finally
+ {
+ graphicsContext.UnmapMemory(staging);
+ staging.Dispose();
+ }
+ }
+ }
+}
diff --git a/CityCulling/README.md b/CityCulling/README.md
new file mode 100644
index 0000000..ede1d45
--- /dev/null
+++ b/CityCulling/README.md
@@ -0,0 +1,147 @@
+# CityCulling
+
+A city of primitives drawn with the **Evergine low-level graphics API**, with **Embree deciding
+which objects reach the GPU**. The GPU draws; the CPU works out what is worth drawing.
+
+
+
+A thousand buildings — boxes, cylinders, cones and gabled wedges — laid out in blocks with
+streets between them, on a ground plane, and a camera orbiting at **street level**. The eye
+height is the whole point: at 2.2 units against buildings 4 to 46 tall, the first row of facades
+hides nearly everything behind it. Lift the camera above the rooftops and there is almost
+nothing left to cull.
+
+## What it does per frame
+
+1. **Frustum**, six planes against each object's world AABB.
+2. **Occlusion with Embree**: nine sample points per surviving object, one closest-hit ray each,
+ and the object is visible if any ray reaches it before anything else. Early exit on the first
+ sample that gets through.
+3. **One draw call per visible object.**
+
+The occlusion strategy is the winner from the
+[OcclusionCulling](../OcclusionCulling/README.md) benchmark in this repository: per-object rays,
+single-ray queries. There it came out three times cheaper than a visibility buffer and discarded
+more; packets lost because filling eight lanes gives up the early exit and rays aimed at eight
+different buildings diverge immediately.
+
+## Results at the capture angle
+
+```
+objects 1000, frustum candidates 538, visible 56, culled 94.4%
+screen area held by discarded objects: 1.56%
+```
+
+**56 draw calls instead of 1000.** The frustum alone gets it to 538 — at street level, half the
+city is behind you. Embree removes 482 more, which is the part frustum culling cannot do.
+
+
+
+The same frame with everything drawn and the discarded objects in red. Red on screen is a
+mistake, and what is left is slivers between buildings: 1.56% of the covered screen area.
+
+
+
+From above, with the camera in amber and its two frustum edges. The kept objects are the pale
+wedge just in front of the camera; everything beyond the first row of facades is red. That
+narrow wedge is what 94% culling looks like.
+
+## What it costs, across a full orbit
+
+`--bench` turns VSync off and sweeps a full circle in 360 steps, so the numbers cover every
+direction the camera can face rather than whichever ones an animation happened to land on.
+
+```
+ min median mean max
+culling 0.306 0.422 0.437 0.816 ms
+whole frame 0.533 0.696 0.705 2.050 ms
+draw calls 2 36 35 75
+```
+
+**The culling costs 0.42 ms, which is 2.5% of a 16.6 ms frame at 60 Hz**, and it removes 964
+draw calls.
+
+## And it does not pay for itself here
+
+`--bench --no-cull` skips the occlusion pass and draws everything:
+
+```
+ min median mean max
+culling 0.117 0.123 0.129 0.247 ms (frustum only)
+whole frame 0.418 0.589 0.635 1.369 ms
+draw calls 1,001 1,001 1001 1,001
+```
+
+The frame is **faster without the occlusion pass**: 0.59 ms against 0.70 ms. Taking the culling
+out of both, 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, and loses 0.11 ms on the deal.
+
+That is not a defect in the culling, it is the scene. These are boxes and cones of a couple of
+dozen triangles with a two-line shader; there is almost nothing to save by not drawing one. The
+pass breaks even when an average object costs about 0.3 µs more than it does here, and wins
+comfortably beyond that — which is to say, with real meshes, real materials and real overdraw.
+The 0.42 ms is what the technique costs; whether it is worth paying depends entirely on what a
+draw call costs you.
+
+Worth knowing before wiring this into an engine, and worth measuring there rather than trusting
+this number.
+
+## Things worth knowing before copying this
+
+**Evergine's depth is reversed.** `DepthStencilStates.ReadWrite` compares `GreaterEqual` and
+`ClearValue.Default` clears depth to 0, so the projection has to be built with
+`Matrix4x4.CreatePerspectiveFieldOfView(..., reverseDepthBuffer: true)` from
+`Evergine.Mathematics` — `System.Numerics` has no such parameter. Get it wrong and the scene is
+empty or inside out, which looks exactly like a culling bug and is not one. The sample uses
+Evergine.Mathematics for the matrices that go to the GPU and System.Numerics for the culling,
+which only needs the six frustum planes and does not care about the depth convention.
+
+**Rotations must match on both sides, and the sense is not the textbook one.** Evergine's
+`CreateRotationY` with the row-vector convention the shader uses gives `x' = x·cos + z·sin` and
+`z' = -x·sin + z·cos`. The Embree geometry is baked into world space by hand, so it has to use
+that exact form. Baking the opposite sense rotates the geometry Embree sees away from the
+geometry the GPU draws, and the culling then discards objects that are plainly on screen — with
+no other symptom.
+
+**You cannot update a buffer inside a render pass.** `ValidationLayer` rejects it, and while DX11
+lets it through with a trace, Vulkan and DX12 do not. That is why per-object data rides in a
+per-instance vertex buffer selected with `startInstanceLocation` rather than a constant buffer
+rewritten between draws.
+
+**Do not let the buildings overlap.** The first version placed them at random and let them
+interpenetrate; that 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. Interpenetration also makes the debug view lie: it shows red where the culling
+was right.
+
+**Sample points go inside the object, not on its bounding box.** A ray aimed at an AABB corner
+grazes the surface at best, and for a cylinder or a cone the corner is outside the shape
+entirely, so the ray sails past and reports whatever stands behind. The points here are pulled
+12% in from the corners.
+
+## `embree.png`, and why it exists
+
+
+
+The same view, traced against the Embree scene and coloured by verdict. It is a diagnostic, not
+a feature: the culling can only be as correct as the agreement between the triangles the GPU
+draws and the triangles Embree holds, and **nothing else in the sample would notice if the two
+drifted apart**. The render would look right and objects would simply vanish for no reason. Put
+this next to `city.png` and a disagreement is obvious in a second — it is what found the rotation
+bug above.
+
+It also produces the accuracy number, measured on Embree's own geometry rather than on anything
+the renderer believes.
+
+## Running it
+
+```bash
+dotnet run --project CityCulling -c Release
+```
+
+The window orbits the city; the status bar shows draw calls, cull percentage, culling cost and
+frame time. The toolbar can pause the camera, tint the discarded objects red, and write the
+captures. `--capture --exit` writes them at a fixed angle and quits.
+
+Windows only: this one is WinForms plus DX11. The binding itself runs on every RID it ships for,
+and `OcclusionCulling` is the cross-platform sample.
diff --git a/CityCulling/Renderer.cs b/CityCulling/Renderer.cs
new file mode 100644
index 0000000..504f587
--- /dev/null
+++ b/CityCulling/Renderer.cs
@@ -0,0 +1,350 @@
+using Evergine.Common.Graphics;
+using Evergine.Mathematics;
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using Buffer = Evergine.Common.Graphics.Buffer;
+
+namespace CityCulling
+{
+ /// Per-instance data: a world matrix and a colour.
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct InstanceData
+ {
+ public Matrix4x4 World;
+ public Vector4 Colour;
+ }
+
+ ///
+ /// Draws the city, one draw call per visible object.
+ ///
+ ///
+ /// One call each rather than instancing the lot, because that is what makes the culling
+ /// visible in the numbers: the frame goes from a thousand draw calls to whatever survives.
+ /// Per-object data still arrives through a per-instance vertex buffer, selected with
+ /// startInstanceLocation. Updating a constant buffer between draws would be the
+ /// obvious alternative and it is illegal: the validation layer rejects buffer updates inside
+ /// a render pass, and while DX11 lets it through, Vulkan and DX12 do not.
+ ///
+ internal sealed unsafe class Renderer : IDisposable
+ {
+ private const string ShaderSource = @"
+cbuffer Frame : register(b0)
+{
+ float4x4 ViewProjection;
+ float4 LightDirection;
+};
+
+struct VS_IN
+{
+ float3 position : POSITION;
+ float3 normal : NORMAL;
+ float4 world0 : TEXCOORD0;
+ float4 world1 : TEXCOORD1;
+ float4 world2 : TEXCOORD2;
+ float4 world3 : TEXCOORD3;
+ float4 colour : TEXCOORD4;
+};
+
+struct PS_IN
+{
+ float4 position : SV_POSITION;
+ float3 normal : NORMAL;
+ float4 colour : COLOR;
+};
+
+PS_IN VS(VS_IN input)
+{
+ float4x4 world = float4x4(input.world0, input.world1, input.world2, input.world3);
+
+ PS_IN output;
+ float4 worldPosition = mul(float4(input.position, 1.0), world);
+ output.position = mul(worldPosition, ViewProjection);
+ output.normal = normalize(mul(float4(input.normal, 0.0), world).xyz);
+ output.colour = input.colour;
+ return output;
+}
+
+float4 PS(PS_IN input) : SV_Target
+{
+ // Flat directional term plus ambient. Only so the city reads as solid volumes; the
+ // culling measurement is entirely on the CPU side and none of this touches it.
+ float ndotl = saturate(dot(normalize(input.normal), -LightDirection.xyz));
+ float shade = 0.35 + (0.65 * ndotl);
+ return float4(input.colour.rgb * shade, 1.0);
+}
+";
+
+ private readonly GraphicsContext graphicsContext;
+ private readonly Dictionary ranges = new();
+
+ private Buffer vertexBuffer;
+ private Buffer indexBuffer;
+ private Buffer instanceBuffer;
+ private Buffer frameBuffer;
+ private Buffer[] vertexBuffers;
+ private GraphicsPipelineState pipelineState;
+ private ResourceSet resourceSet;
+ private InstanceData[] instances;
+
+ public Renderer(GraphicsContext graphicsContext, City city, FrameBuffer target)
+ {
+ this.graphicsContext = graphicsContext;
+ this.instances = new InstanceData[city.Count + 16];
+
+ this.BuildGeometry(city);
+ this.BuildPipeline(target);
+ }
+
+ /// Index of the ground's slot in the shared vertex and index buffers.
+ private (uint StartIndex, uint IndexCount, uint BaseVertex) groundRange;
+
+ public void Dispose()
+ {
+ this.vertexBuffer?.Dispose();
+ this.indexBuffer?.Dispose();
+ this.instanceBuffer?.Dispose();
+ this.frameBuffer?.Dispose();
+ }
+
+ ///
+ /// Fills the instance buffer and issues the draws. selects the
+ /// objects; when is set, the discarded ones are drawn too,
+ /// tinted red, which is what makes the culling's mistakes visible.
+ ///
+ public int Draw(CommandBuffer commandBuffer, City city, Matrix4x4 viewProjection, Vector3 lightDirection, bool[] visible, bool drawCulled, List<(Matrix4x4 World, Vector4 Colour)> markers = null)
+ {
+ int slot = 0;
+
+ // The ground always goes in: it is in the Embree scene so rays can stop on it, but it
+ // is never a culling candidate.
+ this.instances[slot++] = new InstanceData
+ {
+ World = Matrix4x4.CreateScale(city.Extent * 1.6f, 1.0f, city.Extent * 1.6f),
+ Colour = new Vector4(0.20f, 0.21f, 0.24f, 1.0f),
+ };
+
+ var drawList = new List<(Primitive Primitive, int Slot)>(city.Count);
+
+ for (int i = 0; i < city.Count; i++)
+ {
+ bool kept = visible[i];
+ if (!kept && !drawCulled)
+ {
+ continue;
+ }
+
+ ref CityObject o = ref city.Objects[i];
+
+ Matrix4x4 world =
+ Matrix4x4.CreateScale(o.HalfExtent.X, o.HalfExtent.Y, o.HalfExtent.Z) *
+ Matrix4x4.CreateRotationY(o.Rotation) *
+ Matrix4x4.CreateTranslation(o.Centre.X, o.Centre.Y, o.Centre.Z);
+
+ Vector4 colour = kept
+ ? new Vector4(
+ ((o.Colour >> 16) & 0xFF) / 255.0f,
+ ((o.Colour >> 8) & 0xFF) / 255.0f,
+ (o.Colour & 0xFF) / 255.0f,
+ 1.0f)
+ : new Vector4(0.85f, 0.16f, 0.16f, 1.0f);
+
+ this.instances[slot] = new InstanceData { World = world, Colour = colour };
+ drawList.Add((o.Primitive, slot));
+ slot++;
+ }
+
+ // Markers for the diagnostic views: the camera and its frustum edges, drawn as boxes.
+ int markerStart = slot;
+ if (markers != null)
+ {
+ foreach ((Matrix4x4 world, Vector4 colour) in markers)
+ {
+ this.instances[slot++] = new InstanceData { World = world, Colour = colour };
+ }
+ }
+
+ // Written before the render pass opens, which is the rule the validation layer
+ // enforces and the reason the per-object data rides in a vertex buffer.
+ MappedResource mapped = this.graphicsContext.MapMemory(this.instanceBuffer, MapMode.Write);
+ fixed (InstanceData* source = this.instances)
+ {
+ System.Buffer.MemoryCopy(source, (void*)mapped.Data, (long)slot * sizeof(InstanceData), (long)slot * sizeof(InstanceData));
+ }
+
+ this.graphicsContext.UnmapMemory(this.instanceBuffer);
+
+ var frame = new FrameConstants { ViewProjection = viewProjection, LightDirection = new Vector4(lightDirection, 0.0f) };
+ commandBuffer.UpdateBufferData(this.frameBuffer, ref frame);
+
+ var renderPass = new RenderPassDescription(this.Target, ClearValue.Default);
+ commandBuffer.BeginRenderPass(ref renderPass);
+ commandBuffer.SetGraphicsPipelineState(this.pipelineState);
+ commandBuffer.SetResourceSet(this.resourceSet);
+ commandBuffer.SetVertexBuffers(this.vertexBuffers);
+ commandBuffer.SetIndexBuffer(this.indexBuffer, IndexFormat.UInt32);
+
+ commandBuffer.DrawIndexedInstanced(this.groundRange.IndexCount, 1, this.groundRange.StartIndex, this.groundRange.BaseVertex, 0);
+
+ foreach ((Primitive primitive, int instanceSlot) in drawList)
+ {
+ var range = this.ranges[primitive];
+ commandBuffer.DrawIndexedInstanced(range.IndexCount, 1, range.StartIndex, range.BaseVertex, (uint)instanceSlot);
+ }
+
+ if (markers != null)
+ {
+ var box = this.ranges[Primitive.Box];
+ for (int i = 0; i < markers.Count; i++)
+ {
+ commandBuffer.DrawIndexedInstanced(box.IndexCount, 1, box.StartIndex, box.BaseVertex, (uint)(markerStart + i));
+ }
+ }
+
+ commandBuffer.EndRenderPass();
+
+ return drawList.Count + 1;
+ }
+
+ /// The framebuffer this renderer's pipeline was built for.
+ public FrameBuffer Target { get; private set; }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct FrameConstants
+ {
+ public Matrix4x4 ViewProjection;
+ public Vector4 LightDirection;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct VertexPositionNormal
+ {
+ public Vector3 Position;
+ public Vector3 Normal;
+ }
+
+ private void BuildGeometry(City city)
+ {
+ var vertices = new List();
+ var indices = new List();
+
+ // A unit quad for the ground, then one copy of each primitive. Everything shares one
+ // vertex and one index buffer; a draw picks its mesh with startIndex/baseVertex.
+ this.groundRange = Append(
+ vertices,
+ indices,
+ new[]
+ {
+ new VertexPositionNormal { Position = new Vector3(-1, 0, -1), Normal = Vector3.Up },
+ new VertexPositionNormal { Position = new Vector3(1, 0, -1), Normal = Vector3.Up },
+ new VertexPositionNormal { Position = new Vector3(1, 0, 1), Normal = Vector3.Up },
+ new VertexPositionNormal { Position = new Vector3(-1, 0, 1), Normal = Vector3.Up },
+ },
+ new uint[] { 0, 1, 2, 0, 2, 3 });
+
+ foreach (Primitive primitive in Enum.GetValues())
+ {
+ Mesh mesh = Meshes.Get(primitive);
+ var meshVertices = new VertexPositionNormal[mesh.Positions.Length];
+ for (int i = 0; i < mesh.Positions.Length; i++)
+ {
+ meshVertices[i] = new VertexPositionNormal
+ {
+ Position = new Vector3(mesh.Positions[i].X, mesh.Positions[i].Y, mesh.Positions[i].Z),
+ Normal = new Vector3(mesh.Normals[i].X, mesh.Normals[i].Y, mesh.Normals[i].Z),
+ };
+ }
+
+ this.ranges[primitive] = Append(vertices, indices, meshVertices, mesh.Indices);
+ }
+
+ var vertexArray = vertices.ToArray();
+ var indexArray = indices.ToArray();
+
+ var vertexDescription = new BufferDescription(
+ (uint)(sizeof(VertexPositionNormal) * vertexArray.Length), BufferFlags.VertexBuffer, ResourceUsage.Default);
+ this.vertexBuffer = this.graphicsContext.Factory.CreateBuffer(vertexArray, ref vertexDescription);
+
+ var indexDescription = new BufferDescription(
+ (uint)(sizeof(uint) * indexArray.Length), BufferFlags.IndexBuffer, ResourceUsage.Default);
+ this.indexBuffer = this.graphicsContext.Factory.CreateBuffer(indexArray, ref indexDescription);
+
+ var instanceDescription = new BufferDescription(
+ (uint)(sizeof(InstanceData) * this.instances.Length), BufferFlags.VertexBuffer, ResourceUsage.Dynamic, ResourceCpuAccess.Write);
+ this.instanceBuffer = this.graphicsContext.Factory.CreateBuffer(ref instanceDescription);
+
+ this.vertexBuffers = new[] { this.vertexBuffer, this.instanceBuffer };
+ }
+
+ private static (uint StartIndex, uint IndexCount, uint BaseVertex) Append(
+ List vertices, List indices, VertexPositionNormal[] meshVertices, uint[] meshIndices)
+ {
+ uint baseVertex = (uint)vertices.Count;
+ uint startIndex = (uint)indices.Count;
+
+ vertices.AddRange(meshVertices);
+ indices.AddRange(meshIndices);
+
+ return (startIndex, (uint)meshIndices.Length, baseVertex);
+ }
+
+ private void BuildPipeline(FrameBuffer target)
+ {
+ this.Target = target;
+
+ var vertexShaderDescription = new ShaderDescription(
+ ShaderStages.Vertex, "VS", this.graphicsContext.ShaderCompile(ShaderSource, "VS", ShaderStages.Vertex).ByteCode);
+ var pixelShaderDescription = new ShaderDescription(
+ ShaderStages.Pixel, "PS", this.graphicsContext.ShaderCompile(ShaderSource, "PS", ShaderStages.Pixel).ByteCode);
+
+ var vertexShader = this.graphicsContext.Factory.CreateShader(ref vertexShaderDescription);
+ var pixelShader = this.graphicsContext.Factory.CreateShader(ref pixelShaderDescription);
+
+ var frameDescription = new BufferDescription((uint)sizeof(FrameConstants), BufferFlags.ConstantBuffer, ResourceUsage.Default);
+ this.frameBuffer = this.graphicsContext.Factory.CreateBuffer(ref frameDescription);
+
+ var layoutDescription = new ResourceLayoutDescription(
+ new LayoutElementDescription(0, ResourceType.ConstantBuffer, ShaderStages.Vertex | ShaderStages.Pixel));
+ var resourceLayout = this.graphicsContext.Factory.CreateResourceLayout(ref layoutDescription);
+
+ var resourceSetDescription = new ResourceSetDescription(resourceLayout, this.frameBuffer);
+ this.resourceSet = this.graphicsContext.Factory.CreateResourceSet(ref resourceSetDescription);
+
+ var layouts = new InputLayouts()
+ .Add(new LayoutDescription()
+ .Add(new ElementDescription(ElementFormat.Float3, ElementSemanticType.Position))
+ .Add(new ElementDescription(ElementFormat.Float3, ElementSemanticType.Normal)))
+ .Add(new LayoutDescription(VertexStepFunction.PerInstanceData, 1)
+ .Add(new ElementDescription(ElementFormat.Float4, ElementSemanticType.TexCoord, 0))
+ .Add(new ElementDescription(ElementFormat.Float4, ElementSemanticType.TexCoord, 1))
+ .Add(new ElementDescription(ElementFormat.Float4, ElementSemanticType.TexCoord, 2))
+ .Add(new ElementDescription(ElementFormat.Float4, ElementSemanticType.TexCoord, 3))
+ .Add(new ElementDescription(ElementFormat.Float4, ElementSemanticType.TexCoord, 4)));
+
+ var pipelineDescription = new GraphicsPipelineDescription()
+ {
+ PrimitiveTopology = PrimitiveTopology.TriangleList,
+ InputLayouts = layouts,
+ ResourceLayouts = new[] { resourceLayout },
+ Shaders = new GraphicsShaderStateDescription
+ {
+ VertexShader = vertexShader,
+ PixelShader = pixelShader,
+ },
+ RenderStates = new RenderStateDescription
+ {
+ RasterizerState = RasterizerStates.CullBack,
+ BlendState = BlendStates.Opaque,
+
+ // Evergine's depth is reversed: ReadWrite compares GreaterEqual and
+ // ClearValue.Default clears depth to 0. The projection must be built with
+ // reverseDepthBuffer: true to match, or nothing draws.
+ DepthStencilState = DepthStencilStates.ReadWrite,
+ },
+ Outputs = target.OutputDescription,
+ };
+
+ this.pipelineState = this.graphicsContext.Factory.CreateGraphicsPipeline(ref pipelineDescription);
+ }
+ }
+}
diff --git a/CityCulling/docs/city.png b/CityCulling/docs/city.png
new file mode 100644
index 0000000..95d1504
Binary files /dev/null and b/CityCulling/docs/city.png differ
diff --git a/CityCulling/docs/culled.png b/CityCulling/docs/culled.png
new file mode 100644
index 0000000..6046ae2
Binary files /dev/null and b/CityCulling/docs/culled.png differ
diff --git a/CityCulling/docs/embree.png b/CityCulling/docs/embree.png
new file mode 100644
index 0000000..c410230
Binary files /dev/null and b/CityCulling/docs/embree.png differ
diff --git a/CityCulling/docs/topdown.png b/CityCulling/docs/topdown.png
new file mode 100644
index 0000000..e71a5a1
Binary files /dev/null and b/CityCulling/docs/topdown.png differ
diff --git a/Embree.sln b/Embree.sln
index 0d0b98c..06d136e 100644
--- a/Embree.sln
+++ b/Embree.sln
@@ -9,6 +9,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Evergine.Bindings.Embree",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HelloEmbree", "HelloEmbree\HelloEmbree.csproj", "{E4461EA5-FD21-4449-A493-7BF77E32C8D2}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OcclusionCulling", "OcclusionCulling\OcclusionCulling.csproj", "{9D90D092-3E99-429C-8657-C4809D72375B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CityCulling", "CityCulling\CityCulling.csproj", "{29FBC446-BABE-4589-9557-07C01E3BED49}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -55,6 +59,30 @@ Global
{E4461EA5-FD21-4449-A493-7BF77E32C8D2}.Release|x64.Build.0 = Release|Any CPU
{E4461EA5-FD21-4449-A493-7BF77E32C8D2}.Release|x86.ActiveCfg = Release|Any CPU
{E4461EA5-FD21-4449-A493-7BF77E32C8D2}.Release|x86.Build.0 = Release|Any CPU
+ {9D90D092-3E99-429C-8657-C4809D72375B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9D90D092-3E99-429C-8657-C4809D72375B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9D90D092-3E99-429C-8657-C4809D72375B}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9D90D092-3E99-429C-8657-C4809D72375B}.Debug|x64.Build.0 = Debug|Any CPU
+ {9D90D092-3E99-429C-8657-C4809D72375B}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9D90D092-3E99-429C-8657-C4809D72375B}.Debug|x86.Build.0 = Debug|Any CPU
+ {9D90D092-3E99-429C-8657-C4809D72375B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9D90D092-3E99-429C-8657-C4809D72375B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9D90D092-3E99-429C-8657-C4809D72375B}.Release|x64.ActiveCfg = Release|Any CPU
+ {9D90D092-3E99-429C-8657-C4809D72375B}.Release|x64.Build.0 = Release|Any CPU
+ {9D90D092-3E99-429C-8657-C4809D72375B}.Release|x86.ActiveCfg = Release|Any CPU
+ {9D90D092-3E99-429C-8657-C4809D72375B}.Release|x86.Build.0 = Release|Any CPU
+ {29FBC446-BABE-4589-9557-07C01E3BED49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {29FBC446-BABE-4589-9557-07C01E3BED49}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {29FBC446-BABE-4589-9557-07C01E3BED49}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {29FBC446-BABE-4589-9557-07C01E3BED49}.Debug|x64.Build.0 = Debug|Any CPU
+ {29FBC446-BABE-4589-9557-07C01E3BED49}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {29FBC446-BABE-4589-9557-07C01E3BED49}.Debug|x86.Build.0 = Debug|Any CPU
+ {29FBC446-BABE-4589-9557-07C01E3BED49}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {29FBC446-BABE-4589-9557-07C01E3BED49}.Release|Any CPU.Build.0 = Release|Any CPU
+ {29FBC446-BABE-4589-9557-07C01E3BED49}.Release|x64.ActiveCfg = Release|Any CPU
+ {29FBC446-BABE-4589-9557-07C01E3BED49}.Release|x64.Build.0 = Release|Any CPU
+ {29FBC446-BABE-4589-9557-07C01E3BED49}.Release|x86.ActiveCfg = Release|Any CPU
+ {29FBC446-BABE-4589-9557-07C01E3BED49}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/OcclusionCulling/Camera.cs b/OcclusionCulling/Camera.cs
new file mode 100644
index 0000000..3adbbcf
--- /dev/null
+++ b/OcclusionCulling/Camera.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Numerics;
+
+namespace OcclusionCulling
+{
+ ///
+ /// A pinhole camera plus the six frustum planes derived from it.
+ ///
+ ///
+ /// The AABB test is the same scalar six-plane centre/half-extent form Evergine's
+ /// BoundingFrustum.Intersects(ref BoundingBox, out bool) uses, reproduced here so the
+ /// sample stays free of engine dependencies and runs on every RID the binding ships.
+ ///
+ internal sealed class Camera
+ {
+ private readonly Vector4[] planes = new Vector4[6];
+
+ public Camera(Vector3 position, Vector3 target, float fovDegrees, float aspect, float near, float far)
+ {
+ this.Position = position;
+ this.Forward = Vector3.Normalize(target - position);
+ this.Right = Vector3.Normalize(Vector3.Cross(this.Forward, Vector3.UnitY));
+ this.Up = Vector3.Cross(this.Right, this.Forward);
+
+ this.TanHalfFov = MathF.Tan(fovDegrees * MathF.PI / 180.0f * 0.5f);
+ this.Aspect = aspect;
+
+ var view = Matrix4x4.CreateLookAt(position, target, Vector3.UnitY);
+ var projection = Matrix4x4.CreatePerspectiveFieldOfView(
+ fovDegrees * MathF.PI / 180.0f, aspect, near, far);
+ this.ExtractPlanes(view * projection);
+ }
+
+ public Vector3 Position { get; }
+
+ public Vector3 Forward { get; }
+
+ public Vector3 Right { get; }
+
+ public Vector3 Up { get; }
+
+ public float TanHalfFov { get; }
+
+ public float Aspect { get; }
+
+ ///
+ /// Ray direction through a normalised screen position, both in [0, 1].
+ ///
+ public Vector3 RayDirection(float u, float v)
+ {
+ float x = ((u * 2.0f) - 1.0f) * this.TanHalfFov * this.Aspect;
+ float y = (1.0f - (v * 2.0f)) * this.TanHalfFov;
+ return Vector3.Normalize(this.Forward + (this.Right * x) + (this.Up * y));
+ }
+
+ ///
+ /// Tests an AABB against the six planes. Conservative: a box straddling a plane counts
+ /// as inside.
+ ///
+ public bool Intersects(in Vector3 min, in Vector3 max)
+ {
+ Vector3 centre = (min + max) * 0.5f;
+ Vector3 extent = (max - min) * 0.5f;
+
+ for (int i = 0; i < 6; i++)
+ {
+ Vector4 p = this.planes[i];
+ var normal = new Vector3(p.X, p.Y, p.Z);
+
+ float distance = Vector3.Dot(normal, centre) + p.W;
+ float radius = Vector3.Dot(Vector3.Abs(normal), extent);
+
+ if (distance + radius < 0.0f)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private void ExtractPlanes(Matrix4x4 m)
+ {
+ this.planes[0] = Normalize(new Vector4(m.M14 + m.M11, m.M24 + m.M21, m.M34 + m.M31, m.M44 + m.M41)); // left
+ this.planes[1] = Normalize(new Vector4(m.M14 - m.M11, m.M24 - m.M21, m.M34 - m.M31, m.M44 - m.M41)); // right
+ this.planes[2] = Normalize(new Vector4(m.M14 + m.M12, m.M24 + m.M22, m.M34 + m.M32, m.M44 + m.M42)); // bottom
+ this.planes[3] = Normalize(new Vector4(m.M14 - m.M12, m.M24 - m.M22, m.M34 - m.M32, m.M44 - m.M42)); // top
+ this.planes[4] = Normalize(new Vector4(m.M13, m.M23, m.M33, m.M43)); // near
+ this.planes[5] = Normalize(new Vector4(m.M14 - m.M13, m.M24 - m.M23, m.M34 - m.M33, m.M44 - m.M43)); // far
+ }
+
+ private static Vector4 Normalize(Vector4 plane)
+ {
+ float length = new Vector3(plane.X, plane.Y, plane.Z).Length();
+ return length > 0.0f ? plane / length : plane;
+ }
+ }
+}
diff --git a/OcclusionCulling/Culling.cs b/OcclusionCulling/Culling.cs
new file mode 100644
index 0000000..1d854dc
--- /dev/null
+++ b/OcclusionCulling/Culling.cs
@@ -0,0 +1,400 @@
+using Evergine.Bindings.Embree;
+using System;
+using System.Numerics;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using EmbreeScene = Evergine.Bindings.Embree.Scene;
+
+namespace OcclusionCulling
+{
+ ///
+ /// The occlusion culling passes under measurement.
+ ///
+ ///
+ /// Two families, because they answer different questions and their cost scales with
+ /// different things:
+ ///
+ /// - Per-object rays scale with the object count. This is what an engine runs to decide
+ /// which draw calls to submit.
+ /// - A visibility buffer scales with resolution. It is exact up to its sampling density
+ /// and does not care how many objects there are.
+ ///
+ /// Each comes in a single-ray and a 16-wide packet form.
+ ///
+ internal static unsafe class Culling
+ {
+ /// Sample points shot at each box: eight corners and the centre.
+ public const int SamplesPerBox = 9;
+
+ private const float TargetEpsilon = 1e-3f;
+
+ ///
+ /// Frustum stage. Fills with the boxes that survive and
+ /// returns how many there are.
+ ///
+ public static int Frustum(Scene scene, Camera camera, int[] candidates)
+ {
+ int count = 0;
+ for (int i = 0; i < scene.BoxCount; i++)
+ {
+ if (camera.Intersects(scene.Min[i], scene.Max[i]))
+ {
+ candidates[count++] = i;
+ }
+ }
+
+ return count;
+ }
+
+ // -----------------------------------------------------------------------------------
+ // A — per-object rays
+ // -----------------------------------------------------------------------------------
+
+ ///
+ /// One occlusion ray per sample point, stopping at the first sample that gets through.
+ ///
+ public static long PerObjectSingle(Scene scene, Camera camera, int[] candidates, int candidateCount, bool[] visible)
+ {
+ Array.Clear(visible);
+ long rays = 0;
+
+ EmbreeScene handle = scene.Handle;
+ Vector3 origin = camera.Position;
+
+ object counterLock = new();
+
+ Parallel.For(
+ 0,
+ candidateCount,
+ () => (Buffer: (IntPtr)NativeMemory.AlignedAlloc((nuint)sizeof(RayHit), 64), Rays: 0L),
+ (index, _, state) =>
+ {
+ RayHit* rayhit = (RayHit*)state.Buffer;
+ IntersectArguments args;
+ Embree.InitIntersectArguments(&args);
+ args.Flags = RayQueryFlags.Coherent;
+
+ int box = candidates[index];
+ Span points = stackalloc Vector3[SamplesPerBox];
+ scene.GetSamplePoints(box, points);
+
+ long local = state.Rays;
+
+ for (int s = 0; s < SamplesPerBox; s++)
+ {
+ local++;
+ if (ReachesBox(handle, rayhit, &args, origin, points[s], (uint)box))
+ {
+ visible[box] = true;
+ break;
+ }
+ }
+
+ return (state.Buffer, local);
+ },
+ state =>
+ {
+ NativeMemory.AlignedFree((void*)state.Buffer);
+ lock (counterLock)
+ {
+ rays += state.Rays;
+ }
+ });
+
+ return rays;
+ }
+
+ ///
+ /// The same test, eight rays at a time.
+ ///
+ ///
+ /// Packets cost the early exit: every sample of every candidate is traced, because the
+ /// lanes are filled before any of them is known to have got through. That is the trade
+ /// this benchmark exists to measure — more rays, but each one much cheaper.
+ ///
+ public static long PerObjectPacket(Scene scene, Camera camera, int[] candidates, int candidateCount, bool[] visible)
+ {
+ Array.Clear(visible);
+
+ int total = candidateCount * SamplesPerBox;
+ int packets = (total + 7) / 8;
+
+ EmbreeScene handle = scene.Handle;
+ Vector3 origin = camera.Position;
+
+ Parallel.For(
+ 0,
+ packets,
+ // Rays and mask in separate allocations. Packing the mask after the packet in one
+ // block put it exactly at the end of the reservation, and anything overrunning
+ // the packet by a byte then corrupted the allocator's bookkeeping instead of
+ // failing where the mistake was.
+ () => (Rays: (IntPtr)NativeMemory.AlignedAlloc((nuint)sizeof(RayHit8), 32),
+ Valid: (IntPtr)NativeMemory.AlignedAlloc(8 * sizeof(int), 32)),
+ (packet, _, buffers) =>
+ {
+ RayHit8* rays = (RayHit8*)buffers.Rays;
+ int* valid = (int*)buffers.Valid;
+
+ IntersectArguments args;
+ Embree.InitIntersectArguments(&args);
+ args.Flags = RayQueryFlags.Coherent;
+
+ // The buffer is reused across packets and starts uninitialised. Every lane
+ // has to hold a well-formed ray even when it is masked off: the traversal
+ // works on all eight lanes at once, and garbage in a disabled lane
+ // becomes a NaN in the SIMD maths that takes the whole packet down.
+ *rays = default;
+
+ int start = packet * 8;
+ Span points = stackalloc Vector3[SamplesPerBox];
+ int lastBox = -1;
+
+ for (int lane = 0; lane < 8; lane++)
+ {
+ int flat = start + lane;
+ if (flat >= total)
+ {
+ // Disabled, and empty: tnear > tfar leaves nothing to intersect.
+ valid[lane] = 0;
+ rays->Ray.DirZ[lane] = 1.0f;
+ rays->Ray.Tnear[lane] = 1.0f;
+ rays->Ray.Tfar[lane] = 0.0f;
+ continue;
+ }
+
+ int box = candidates[flat / SamplesPerBox];
+ if (box != lastBox)
+ {
+ scene.GetSamplePoints(box, points);
+ lastBox = box;
+ }
+
+ Vector3 direction = Vector3.Normalize(points[flat % SamplesPerBox] - origin);
+
+ valid[lane] = -1;
+ rays->Ray.OrgX[lane] = origin.X;
+ rays->Ray.OrgY[lane] = origin.Y;
+ rays->Ray.OrgZ[lane] = origin.Z;
+ rays->Ray.DirX[lane] = direction.X;
+ rays->Ray.DirY[lane] = direction.Y;
+ rays->Ray.DirZ[lane] = direction.Z;
+ rays->Ray.Tnear[lane] = 0.0f;
+ rays->Ray.Tfar[lane] = float.PositiveInfinity;
+ rays->Ray.Mask[lane] = uint.MaxValue;
+ rays->Hit.GeomID[lane] = Embree.INVALID_GEOMETRY_ID;
+ }
+
+ Embree.Intersect8(valid, handle, rays, &args);
+
+ for (int lane = 0; lane < 8; lane++)
+ {
+ int flat = start + lane;
+ if (flat >= total || valid[lane] == 0)
+ {
+ continue;
+ }
+
+ // This sample reached the box before anything else, so it is visible.
+ int box = candidates[flat / SamplesPerBox];
+ if (rays->Hit.GeomID[lane] == (uint)box)
+ {
+ visible[box] = true;
+ }
+ }
+
+ return buffers;
+ },
+ buffers =>
+ {
+ NativeMemory.AlignedFree((void*)buffers.Rays);
+ NativeMemory.AlignedFree((void*)buffers.Valid);
+ });
+
+ return total;
+ }
+
+ // -----------------------------------------------------------------------------------
+ // B — visibility buffer
+ // -----------------------------------------------------------------------------------
+
+ ///
+ /// Casts a primary ray per sample and marks whatever geometry it lands on. Exact up to
+ /// the sampling density.
+ ///
+ public static long VisibilityBufferSingle(Scene scene, Camera camera, int width, int height, bool[] visible, uint[] ids = null)
+ {
+ Array.Clear(visible);
+
+ EmbreeScene handle = scene.Handle;
+ Vector3 origin = camera.Position;
+
+ Parallel.For(
+ 0,
+ height,
+ () => (IntPtr)NativeMemory.AlignedAlloc((nuint)sizeof(RayHit), 64),
+ (y, _, buffer) =>
+ {
+ RayHit* rayhit = (RayHit*)buffer;
+ IntersectArguments args;
+ Embree.InitIntersectArguments(&args);
+ args.Flags = RayQueryFlags.Coherent;
+
+ for (int x = 0; x < width; x++)
+ {
+ Vector3 direction = camera.RayDirection((x + 0.5f) / width, (y + 0.5f) / height);
+
+ *rayhit = default;
+ rayhit->Ray.OrgX = origin.X;
+ rayhit->Ray.OrgY = origin.Y;
+ rayhit->Ray.OrgZ = origin.Z;
+ rayhit->Ray.DirX = direction.X;
+ rayhit->Ray.DirY = direction.Y;
+ rayhit->Ray.DirZ = direction.Z;
+ rayhit->Ray.Tnear = 0.0f;
+ rayhit->Ray.Tfar = float.PositiveInfinity;
+ rayhit->Ray.Mask = uint.MaxValue;
+ rayhit->Hit.GeomID = Embree.INVALID_GEOMETRY_ID;
+
+ Embree.Intersect1(handle, rayhit, &args);
+
+ uint id = rayhit->Hit.GeomID;
+ if (ids != null)
+ {
+ ids[(y * width) + x] = id;
+ }
+
+ if (id != Embree.INVALID_GEOMETRY_ID)
+ {
+ visible[id] = true;
+ }
+ }
+
+ return buffer;
+ },
+ buffer => NativeMemory.AlignedFree((void*)buffer));
+
+ return (long)width * height;
+ }
+
+ ///
+ /// The same buffer, eight pixels at a time. Consecutive pixels of a row share almost all of
+ /// their traversal, which is what the packet path is built for.
+ ///
+ public static long VisibilityBufferPacket(Scene scene, Camera camera, int width, int height, bool[] visible)
+ {
+ Array.Clear(visible);
+
+ EmbreeScene handle = scene.Handle;
+ Vector3 origin = camera.Position;
+ int packetsPerRow = (width + 7) / 8;
+
+ Parallel.For(
+ 0,
+ height,
+ () => (Rays: (IntPtr)NativeMemory.AlignedAlloc((nuint)sizeof(RayHit8), 32),
+ Valid: (IntPtr)NativeMemory.AlignedAlloc(8 * sizeof(int), 32)),
+ (y, _, buffers) =>
+ {
+ RayHit8* rayhit = (RayHit8*)buffers.Rays;
+ int* valid = (int*)buffers.Valid;
+
+ IntersectArguments args;
+ Embree.InitIntersectArguments(&args);
+ args.Flags = RayQueryFlags.Coherent;
+
+ for (int packet = 0; packet < packetsPerRow; packet++)
+ {
+ *rayhit = default;
+
+ for (int lane = 0; lane < 8; lane++)
+ {
+ int x = (packet * 8) + lane;
+ if (x >= width)
+ {
+ // See the comment in PerObjectPacket: a disabled lane still has
+ // to hold a well-formed, empty ray.
+ valid[lane] = 0;
+ rayhit->Ray.DirZ[lane] = 1.0f;
+ rayhit->Ray.Tnear[lane] = 1.0f;
+ rayhit->Ray.Tfar[lane] = 0.0f;
+ continue;
+ }
+
+ Vector3 direction = camera.RayDirection((x + 0.5f) / width, (y + 0.5f) / height);
+
+ valid[lane] = -1;
+ rayhit->Ray.OrgX[lane] = origin.X;
+ rayhit->Ray.OrgY[lane] = origin.Y;
+ rayhit->Ray.OrgZ[lane] = origin.Z;
+ rayhit->Ray.DirX[lane] = direction.X;
+ rayhit->Ray.DirY[lane] = direction.Y;
+ rayhit->Ray.DirZ[lane] = direction.Z;
+ rayhit->Ray.Tnear[lane] = 0.0f;
+ rayhit->Ray.Tfar[lane] = float.PositiveInfinity;
+ rayhit->Ray.Mask[lane] = uint.MaxValue;
+ rayhit->Hit.GeomID[lane] = Embree.INVALID_GEOMETRY_ID;
+ }
+
+ Embree.Intersect8(valid, handle, rayhit, &args);
+
+ for (int lane = 0; lane < 8; lane++)
+ {
+ if (valid[lane] == 0)
+ {
+ continue;
+ }
+
+ uint id = rayhit->Hit.GeomID[lane];
+ if (id != Embree.INVALID_GEOMETRY_ID)
+ {
+ visible[id] = true;
+ }
+ }
+ }
+
+ return buffers;
+ },
+ buffers =>
+ {
+ NativeMemory.AlignedFree((void*)buffers.Rays);
+ NativeMemory.AlignedFree((void*)buffers.Valid);
+ });
+
+ return (long)width * height;
+ }
+
+ ///
+ /// Whether a ray aimed at reaches box
+ /// before anything else.
+ ///
+ ///
+ /// Closest-hit, not the cheaper any-hit. The obvious formulation — an occlusion ray that
+ /// stops just short of the sample point — has the box occlude itself: the point sits on
+ /// its surface, so its own front face is in the way for every sample except the few
+ /// silhouette corners. Measured on this scene that answered "hidden" for a quarter of the
+ /// boxes that were plainly visible. Asking what the ray hits first costs more per ray and
+ /// is the question actually being asked.
+ ///
+ private static bool ReachesBox(EmbreeScene scene, RayHit* rayhit, IntersectArguments* args, Vector3 origin, Vector3 target, uint box)
+ {
+ Vector3 direction = Vector3.Normalize(target - origin);
+
+ *rayhit = default;
+ rayhit->Ray.OrgX = origin.X;
+ rayhit->Ray.OrgY = origin.Y;
+ rayhit->Ray.OrgZ = origin.Z;
+ rayhit->Ray.DirX = direction.X;
+ rayhit->Ray.DirY = direction.Y;
+ rayhit->Ray.DirZ = direction.Z;
+ rayhit->Ray.Tnear = 0.0f;
+ rayhit->Ray.Tfar = float.PositiveInfinity;
+ rayhit->Ray.Mask = uint.MaxValue;
+ rayhit->Hit.GeomID = Embree.INVALID_GEOMETRY_ID;
+
+ Embree.Intersect1(scene, rayhit, args);
+
+ return rayhit->Hit.GeomID == box;
+ }
+ }
+}
diff --git a/OcclusionCulling/OcclusionCulling.csproj b/OcclusionCulling/OcclusionCulling.csproj
new file mode 100644
index 0000000..b047f87
--- /dev/null
+++ b/OcclusionCulling/OcclusionCulling.csproj
@@ -0,0 +1,17 @@
+
+
+
+ Exe
+ net10.0
+ True
+ disable
+ true
+ true
+ OcclusionCulling
+
+
+
+
+
+
+
diff --git a/OcclusionCulling/Png.cs b/OcclusionCulling/Png.cs
new file mode 100644
index 0000000..daaf600
--- /dev/null
+++ b/OcclusionCulling/Png.cs
@@ -0,0 +1,137 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+
+namespace OcclusionCulling
+{
+ ///
+ /// A minimal PNG writer, so the sample stays dependency-free and runs on every RID the
+ /// binding ships. System.Drawing would not do: it is Windows-only on modern .NET.
+ ///
+ internal static class Png
+ {
+ ///
+ /// Writes an RGB image. is three bytes per pixel, row-major.
+ ///
+ public static void Write(string path, int width, int height, byte[] rgb)
+ {
+ // PNG wants a filter byte at the start of every scanline; 0 means "no filter".
+ var raw = new byte[height * ((width * 3) + 1)];
+ for (int y = 0; y < height; y++)
+ {
+ int source = y * width * 3;
+ int destination = y * ((width * 3) + 1);
+ raw[destination] = 0;
+ Array.Copy(rgb, source, raw, destination + 1, width * 3);
+ }
+
+ using var file = File.Create(path);
+ file.Write(new byte[] { 0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A });
+
+ var header = new byte[13];
+ WriteBigEndian(header, 0, (uint)width);
+ WriteBigEndian(header, 4, (uint)height);
+ header[8] = 8; // bit depth
+ header[9] = 2; // colour type: truecolour
+ WriteChunk(file, "IHDR", header);
+
+ WriteChunk(file, "IDAT", Deflate(raw));
+ WriteChunk(file, "IEND", Array.Empty());
+ }
+
+ ///
+ /// zlib stream: a two-byte header, raw deflate, and an Adler-32 of the uncompressed
+ /// data. DeflateStream produces the middle part; the wrapper has to be added by hand
+ /// because ZLibStream's header bytes are not what every decoder expects from a PNG.
+ ///
+ private static byte[] Deflate(byte[] data)
+ {
+ using var output = new MemoryStream();
+ output.WriteByte(0x78); // CM = deflate, CINFO = 32K window
+ output.WriteByte(0x01); // no preset dictionary, fastest compression
+
+ using (var deflate = new DeflateStream(output, CompressionLevel.Fastest, leaveOpen: true))
+ {
+ deflate.Write(data, 0, data.Length);
+ }
+
+ uint adler = Adler32(data);
+ output.WriteByte((byte)(adler >> 24));
+ output.WriteByte((byte)(adler >> 16));
+ output.WriteByte((byte)(adler >> 8));
+ output.WriteByte((byte)adler);
+
+ return output.ToArray();
+ }
+
+ private static void WriteChunk(Stream stream, string type, byte[] data)
+ {
+ var length = new byte[4];
+ WriteBigEndian(length, 0, (uint)data.Length);
+ stream.Write(length);
+
+ var payload = new byte[4 + data.Length];
+ for (int i = 0; i < 4; i++)
+ {
+ payload[i] = (byte)type[i];
+ }
+
+ Array.Copy(data, 0, payload, 4, data.Length);
+ stream.Write(payload);
+
+ var crc = new byte[4];
+ WriteBigEndian(crc, 0, Crc32(payload));
+ stream.Write(crc);
+ }
+
+ private static void WriteBigEndian(byte[] buffer, int offset, uint value)
+ {
+ buffer[offset + 0] = (byte)(value >> 24);
+ buffer[offset + 1] = (byte)(value >> 16);
+ buffer[offset + 2] = (byte)(value >> 8);
+ buffer[offset + 3] = (byte)value;
+ }
+
+ private static uint Adler32(byte[] data)
+ {
+ uint a = 1, b = 0;
+ foreach (byte value in data)
+ {
+ a = (a + value) % 65521;
+ b = (b + a) % 65521;
+ }
+
+ return (b << 16) | a;
+ }
+
+ private static readonly uint[] CrcTable = BuildCrcTable();
+
+ private static uint[] BuildCrcTable()
+ {
+ var table = new uint[256];
+ for (uint n = 0; n < 256; n++)
+ {
+ uint c = n;
+ for (int k = 0; k < 8; k++)
+ {
+ c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
+ }
+
+ table[n] = c;
+ }
+
+ return table;
+ }
+
+ private static uint Crc32(byte[] data)
+ {
+ uint c = 0xFFFFFFFFu;
+ foreach (byte value in data)
+ {
+ c = CrcTable[(c ^ value) & 0xFF] ^ (c >> 8);
+ }
+
+ return c ^ 0xFFFFFFFFu;
+ }
+ }
+}
diff --git a/OcclusionCulling/Program.cs b/OcclusionCulling/Program.cs
new file mode 100644
index 0000000..bbbbaba
--- /dev/null
+++ b/OcclusionCulling/Program.cs
@@ -0,0 +1,474 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Numerics;
+
+namespace OcclusionCulling
+{
+ ///
+ /// Measures what a CPU occlusion culling pass costs over a thousand boxes, with Embree
+ /// doing the visibility queries.
+ ///
+ internal static class Program
+ {
+ private const int BoxCount = 1000;
+ private const float VolumeExtent = 9.0f; // half the side of the cube they fill
+ private const float MinBoxSize = 0.4f;
+ private const float MaxBoxSize = 2.4f;
+ private const int Seed = 20260811; // fixed, so the scene is the same every run
+
+ private const int CullWidth = 320; // resolution of the visibility-buffer pass
+ private const int CullHeight = 180;
+ private const int TruthWidth = 1280; // ground truth, far denser
+ private const int TruthHeight = 720;
+
+ private const int WarmupIterations = 20;
+ private const int MeasuredIterations = 100;
+
+ private static int Main(string[] args)
+ {
+ bool images = args.Contains("--images");
+
+ string sweep = args.FirstOrDefault(a => a.StartsWith("--sweep", StringComparison.Ordinal));
+ if (sweep != null)
+ {
+ if (sweep.Contains('='))
+ {
+ // A child, measuring exactly one size and printing its row.
+ Sweep(sweep.Split('=')[1].Split(',').Select(int.Parse).ToArray(), header: false);
+ }
+ else
+ {
+ SweepInChildProcesses(new[] { 10, 50, 100, 200, 500, 1000 });
+ }
+
+ return 0;
+ }
+
+ using var scene = new Scene(BoxCount, VolumeExtent, MinBoxSize, MaxBoxSize, Seed);
+
+ // Outside the cloud, looking at its centre, close enough that the near boxes hide
+ // much of what is behind them. That is the situation occlusion culling exists for.
+ float extent = VolumeExtent;
+ var camera = MakeCamera(extent);
+
+ Console.WriteLine($"Scene : {scene.BoxCount:N0} boxes scattered at random, sizes {MinBoxSize}..{MaxBoxSize}, {scene.TriangleCount:N0} triangles, one geometry each");
+ Console.WriteLine($"Logical cores : {Environment.ProcessorCount}");
+ Console.WriteLine($"Widest packet : {scene.MaxPacketWidth} rays (asked of the device, not assumed)");
+ Console.WriteLine();
+
+ var candidates = new int[scene.BoxCount];
+ var visible = new bool[scene.BoxCount];
+ var truth = new bool[scene.BoxCount];
+
+ // Ground truth: a visibility buffer far denser than any of the passes below. What it
+ // finds is what is genuinely visible; everything else is a sampling artefact.
+ //
+ // Its per-box pixel counts matter as much as the visibility flags. In a grid this
+ // dense most of what is "visible" is visible through a gap a pixel or two wide, and
+ // counting those the same as a box filling a quarter of the screen would make the
+ // accuracy column say nothing useful.
+ var truthIds = new uint[TruthWidth * TruthHeight];
+ Culling.VisibilityBufferSingle(scene, camera, TruthWidth, TruthHeight, truth, truthIds);
+ int trulyVisible = truth.Count(v => v);
+
+ var pixelsPerBox = new long[scene.BoxCount];
+ long coveredPixels = 0;
+ foreach (uint id in truthIds)
+ {
+ if (id != Embree_INVALID)
+ {
+ pixelsPerBox[id]++;
+ coveredPixels++;
+ }
+ }
+
+ int frustumCount = Culling.Frustum(scene, camera, candidates);
+ Console.WriteLine($"Frustum pass : {frustumCount:N0} of {scene.BoxCount:N0} boxes survive");
+ Console.WriteLine($"Ground truth : {trulyVisible:N0} boxes actually visible at {TruthWidth}x{TruthHeight}");
+ Console.WriteLine();
+
+ var results = new[]
+ {
+ Measure("frustum only", scene, camera, candidates, visible, truth, pixelsPerBox, coveredPixels,
+ (s, c, cand, n, vis) => { Array.Clear(vis); for (int i = 0; i < n; i++) { vis[cand[i]] = true; } return 0; }),
+
+ Measure("per-object, single ray", scene, camera, candidates, visible, truth, pixelsPerBox, coveredPixels,
+ (s, c, cand, n, vis) => Culling.PerObjectSingle(s, c, cand, n, vis)),
+
+ Measure("per-object, 8-wide packets", scene, camera, candidates, visible, truth, pixelsPerBox, coveredPixels,
+ (s, c, cand, n, vis) => Culling.PerObjectPacket(s, c, cand, n, vis)),
+
+ Measure($"visibility buffer {CullWidth}x{CullHeight}, single ray", scene, camera, candidates, visible, truth, pixelsPerBox, coveredPixels,
+ (s, c, cand, n, vis) => Culling.VisibilityBufferSingle(s, c, CullWidth, CullHeight, vis)),
+
+ Measure($"visibility buffer {CullWidth}x{CullHeight}, 8-wide packets", scene, camera, candidates, visible, truth, pixelsPerBox, coveredPixels,
+ (s, c, cand, n, vis) => Culling.VisibilityBufferPacket(s, c, CullWidth, CullHeight, vis)),
+ };
+
+ Console.WriteLine("Method median mean min max rays visible culled miss screen extra");
+ foreach (var r in results)
+ {
+ Console.WriteLine(
+ $"{r.Name,-42} {r.Median,7:F3}ms {r.Mean,7:F3}ms {r.Min,7:F3}ms {r.Max,7:F3}ms {r.Rays,9:N0} {r.Visible,9:N0} {r.CulledPercent,6:F1}% {r.FalseNegatives,6:N0} {r.MissedScreenPercent,6:F2}% {r.FalsePositives,6:N0}");
+ }
+
+ Console.WriteLine();
+ Console.WriteLine("miss = visible boxes the pass discarded (these pop on screen)");
+ Console.WriteLine("screen = how much of the covered screen area those discarded boxes actually held");
+ Console.WriteLine("extra = hidden boxes the pass kept (these only cost draw calls)");
+
+ if (images)
+ {
+ WriteImages(scene, camera, candidates, frustumCount);
+ }
+
+ return 0;
+ }
+
+ ///
+ /// The same measurement at a range of scene sizes, to show how the cost scales.
+ ///
+ ///
+ /// The volume grows with the cube root of the box count, so density stays constant and
+ /// the camera pulls back with it. Holding the volume fixed instead would vary two things
+ /// at once — how many objects there are and how much they occlude each other — and the
+ /// curve would not say which of them moved the cost.
+ ///
+ ///
+ /// Runs each scene size in a process of its own and collects the rows.
+ ///
+ ///
+ /// One process per size, which looks like overkill and is not. Measuring six scenes in a
+ /// row inside one process moved the later numbers by half: with EMBREE_TASKING_SYSTEM
+ /// =INTERNAL every rtcNewDevice brings its own worker threads, and six devices' worth of
+ /// them leaves the machine in a different state from the one the first measurement saw.
+ /// Measured alone, the thousand-box scene reports 0.21 ms; measured sixth, 0.33 ms. The
+ /// shape of the curve is the whole point of a sweep, so it cannot be built out of numbers
+ /// that drift with their position in the list.
+ ///
+ private static void SweepInChildProcesses(int[] sizes)
+ {
+ Console.WriteLine("Constant density: the volume grows with the cube root of the box count.");
+ Console.WriteLine("Each size runs in its own process; see the remarks on SweepInChildProcesses.");
+ Console.WriteLine();
+ Console.WriteLine("boxes frustum per-object per-object visbuffer visbuffer culled miss");
+ Console.WriteLine(" single 8-packet single 8-packet screen");
+
+ foreach (int count in sizes)
+ {
+ var info = new ProcessStartInfo(Environment.ProcessPath)
+ {
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ };
+
+ foreach (string argument in Environment.GetCommandLineArgs().Skip(1).Where(a => !a.StartsWith("--sweep", StringComparison.Ordinal)))
+ {
+ info.ArgumentList.Add(argument);
+ }
+
+ info.ArgumentList.Add($"--sweep={count}");
+
+ using var child = Process.Start(info);
+ Console.Write(child.StandardOutput.ReadToEnd());
+ child.WaitForExit();
+ }
+ }
+
+ private static void Sweep(int[] sizes, bool header)
+ {
+ if (header)
+ {
+ Console.WriteLine("boxes frustum per-object per-object visbuffer visbuffer culled miss");
+ Console.WriteLine(" single 8-packet single 8-packet screen");
+ }
+
+ foreach (int count in sizes)
+ {
+ float extent = VolumeExtent * MathF.Cbrt(count / (float)BoxCount);
+
+ using var scene = new Scene(count, extent, MinBoxSize, MaxBoxSize, Seed);
+ var camera = MakeCamera(extent);
+
+ var candidates = new int[count];
+ var visible = new bool[count];
+ var truth = new bool[count];
+
+ var truthIds = new uint[TruthWidth * TruthHeight];
+ Culling.VisibilityBufferSingle(scene, camera, TruthWidth, TruthHeight, truth, truthIds);
+
+ var pixelsPerBox = new long[count];
+ long coveredPixels = 0;
+ foreach (uint id in truthIds)
+ {
+ if (id != Embree_INVALID)
+ {
+ pixelsPerBox[id]++;
+ coveredPixels++;
+ }
+ }
+
+ var frustum = Measure("f", scene, camera, candidates, visible, truth, pixelsPerBox, coveredPixels,
+ (s, c, cand, n, vis) => { Array.Clear(vis); for (int i = 0; i < n; i++) { vis[cand[i]] = true; } return 0; });
+ var single = Measure("s", scene, camera, candidates, visible, truth, pixelsPerBox, coveredPixels,
+ (s, c, cand, n, vis) => Culling.PerObjectSingle(s, c, cand, n, vis));
+ var packet = Measure("p", scene, camera, candidates, visible, truth, pixelsPerBox, coveredPixels,
+ (s, c, cand, n, vis) => Culling.PerObjectPacket(s, c, cand, n, vis));
+ var buffer = Measure("b", scene, camera, candidates, visible, truth, pixelsPerBox, coveredPixels,
+ (s, c, cand, n, vis) => Culling.VisibilityBufferSingle(s, c, CullWidth, CullHeight, vis));
+ var bufferPacket = Measure("bp", scene, camera, candidates, visible, truth, pixelsPerBox, coveredPixels,
+ (s, c, cand, n, vis) => Culling.VisibilityBufferPacket(s, c, CullWidth, CullHeight, vis));
+
+ Console.WriteLine(
+ $"{count,5:N0} {frustum.Median,8:F4} {single.Median,11:F4} {packet.Median,11:F4} {buffer.Median,11:F4} {bufferPacket.Median,11:F4} {single.CulledPercent,8:F1}% {single.MissedScreenPercent,6:F2}%");
+ }
+ }
+
+ private static Camera MakeCamera(float extent) => new Camera(
+ position: new Vector3(extent * 2.5f, extent * 1.4f, extent * 2.9f),
+ target: Vector3.Zero,
+ fovDegrees: 55.0f,
+ aspect: (float)CullWidth / CullHeight,
+ near: 0.1f,
+ far: extent * 20.0f);
+
+ private delegate long Pass(Scene scene, Camera camera, int[] candidates, int candidateCount, bool[] visible);
+
+ private static Result Measure(string name, Scene scene, Camera camera, int[] candidates, bool[] visible, bool[] truth, long[] pixelsPerBox, long coveredPixels, Pass pass)
+ {
+ int candidateCount = Culling.Frustum(scene, camera, candidates);
+
+ for (int i = 0; i < WarmupIterations; i++)
+ {
+ pass(scene, camera, candidates, candidateCount, visible);
+ }
+
+ var samples = new double[MeasuredIterations];
+ long rays = 0;
+
+ for (int i = 0; i < MeasuredIterations; i++)
+ {
+ long start = Stopwatch.GetTimestamp();
+ rays = pass(scene, camera, candidates, candidateCount, visible);
+ samples[i] = (Stopwatch.GetTimestamp() - start) * 1000.0 / Stopwatch.Frequency;
+ }
+
+ int visibleCount = 0, falseNegatives = 0, falsePositives = 0;
+ long missedPixels = 0;
+ for (int i = 0; i < visible.Length; i++)
+ {
+ if (visible[i])
+ {
+ visibleCount++;
+ if (!truth[i])
+ {
+ falsePositives++;
+ }
+ }
+ else if (truth[i])
+ {
+ falseNegatives++;
+ missedPixels += pixelsPerBox[i];
+ }
+ }
+
+ Array.Sort(samples);
+
+ return new Result
+ {
+ Name = name,
+ Median = samples[samples.Length / 2],
+ Mean = samples.Average(),
+ Min = samples[0],
+ Max = samples[^1],
+ Rays = rays,
+ Visible = visibleCount,
+ CulledPercent = 100.0 * (scene.BoxCount - visibleCount) / scene.BoxCount,
+ FalseNegatives = falseNegatives,
+ FalsePositives = falsePositives,
+ MissedScreenPercent = coveredPixels > 0 ? 100.0 * missedPixels / coveredPixels : 0.0,
+ };
+ }
+
+ private static void WriteImages(Scene scene, Camera camera, int[] candidates, int candidateCount)
+ {
+ const int Width = 960;
+ const int Height = 540;
+
+ var ids = new uint[Width * Height];
+ var seen = new bool[scene.BoxCount];
+ Culling.VisibilityBufferSingle(scene, camera, Width, Height, seen, ids);
+
+ var verdict = new bool[scene.BoxCount];
+ Culling.PerObjectSingle(scene, camera, candidates, candidateCount, verdict);
+
+ var truth = new bool[scene.BoxCount];
+ Culling.VisibilityBufferSingle(scene, camera, TruthWidth, TruthHeight, truth);
+
+ // 1. The scene itself, flat-coloured per object.
+ var scenePixels = new byte[Width * Height * 3];
+ for (int i = 0; i < ids.Length; i++)
+ {
+ Colour(ids[i], out byte r, out byte g, out byte b);
+ scenePixels[(i * 3) + 0] = r;
+ scenePixels[(i * 3) + 1] = g;
+ scenePixels[(i * 3) + 2] = b;
+ }
+
+ Png.Write("scene.png", Width, Height, scenePixels);
+
+ // 2. The same view, coloured by what the per-object pass decided. Red on screen is a
+ // box the pass discarded while it was in fact visible: that is popping.
+ var verdictPixels = new byte[Width * Height * 3];
+ for (int i = 0; i < ids.Length; i++)
+ {
+ uint id = ids[i];
+ byte r, g, b;
+ if (id == Embree_INVALID)
+ {
+ r = 24; g = 28; b = 36;
+ }
+ else if (verdict[id])
+ {
+ r = 60; g = 190; b = 90;
+ }
+ else
+ {
+ r = 220; g = 60; b = 60;
+ }
+
+ verdictPixels[(i * 3) + 0] = r;
+ verdictPixels[(i * 3) + 1] = g;
+ verdictPixels[(i * 3) + 2] = b;
+ }
+
+ Png.Write("verdict.png", Width, Height, verdictPixels);
+
+ // 3. A side view of the grid: what got kept, and what got thrown away.
+ WriteSlice(scene, camera, verdict, "slice.png");
+
+ Console.WriteLine();
+ Console.WriteLine($"Wrote scene.png, verdict.png and slice.png to {Directory.GetCurrentDirectory()}");
+ }
+
+ private const uint Embree_INVALID = uint.MaxValue;
+
+ ///
+ /// A horizontal slab through the middle of the cloud, seen from above. Boxes the pass
+ /// kept are bright, boxes it discarded are dark, and the camera is the cross.
+ ///
+ ///
+ /// A slab rather than everything: projecting the whole cloud onto the ground plane piles
+ /// boxes from every height into the same pixels, and the picture would look like the
+ /// culling was deciding at random.
+ ///
+ private static void WriteSlice(Scene scene, Camera camera, bool[] visible, string path)
+ {
+ const int Width = 720;
+ const int Height = 720;
+
+ float span = scene.Extent * 3.2f;
+
+ var pixels = new byte[Width * Height * 3];
+ for (int i = 0; i < pixels.Length; i += 3)
+ {
+ pixels[i] = 18; pixels[i + 1] = 20; pixels[i + 2] = 26;
+ }
+
+ // Only boxes whose centre falls in the middle band of the volume.
+ float band = scene.Extent * 0.18f;
+
+ for (int box = 0; box < scene.BoxCount; box++)
+ {
+ float centreY = (scene.Min[box].Y + scene.Max[box].Y) * 0.5f;
+ if (MathF.Abs(centreY) > band)
+ {
+ continue;
+ }
+
+ Vector3 lo = scene.Min[box];
+ Vector3 hi = scene.Max[box];
+
+ int x0 = ToPixel(lo.X, span, Width);
+ int x1 = ToPixel(hi.X, span, Width);
+ int y0 = ToPixel(-hi.Z, span, Height);
+ int y1 = ToPixel(-lo.Z, span, Height);
+
+ byte r, g, b;
+ if (visible[box])
+ {
+ r = 235; g = 235; b = 245;
+ }
+ else
+ {
+ r = 52; g = 56; b = 68;
+ }
+
+ for (int y = Math.Max(y0, 0); y <= Math.Min(y1, Height - 1); y++)
+ {
+ for (int x = Math.Max(x0, 0); x <= Math.Min(x1, Width - 1); x++)
+ {
+ int p = ((y * Width) + x) * 3;
+ pixels[p] = r; pixels[p + 1] = g; pixels[p + 2] = b;
+ }
+ }
+ }
+
+ int cx = ToPixel(camera.Position.X, span, Width);
+ int cy = ToPixel(-camera.Position.Z, span, Height);
+ for (int d = -9; d <= 9; d++)
+ {
+ Plot(pixels, Width, Height, cx + d, cy, 255, 170, 40);
+ Plot(pixels, Width, Height, cx, cy + d, 255, 170, 40);
+ }
+
+ Png.Write(path, Width, Height, pixels);
+ }
+
+ private static int ToPixel(float world, float span, int size) =>
+ (int)MathF.Round((world + span) / (span * 2.0f) * size);
+
+ private static void Plot(byte[] pixels, int width, int height, int x, int y, byte r, byte g, byte b)
+ {
+ if (x < 0 || y < 0 || x >= width || y >= height)
+ {
+ return;
+ }
+
+ int p = ((y * width) + x) * 3;
+ pixels[p] = r; pixels[p + 1] = g; pixels[p + 2] = b;
+ }
+
+ /// A stable, well-spread colour per geomID; grey for "nothing was hit".
+ private static void Colour(uint id, out byte r, out byte g, out byte b)
+ {
+ if (id == Embree_INVALID)
+ {
+ r = 24; g = 28; b = 36;
+ return;
+ }
+
+ uint h = (id * 2654435761u) ^ (id << 13);
+ r = (byte)(80 + (h & 0x7F));
+ g = (byte)(80 + ((h >> 8) & 0x7F));
+ b = (byte)(80 + ((h >> 16) & 0x7F));
+ }
+
+ private sealed class Result
+ {
+ public string Name;
+ public double Median;
+ public double Mean;
+ public double Min;
+ public double Max;
+ public long Rays;
+ public int Visible;
+ public double CulledPercent;
+ public int FalseNegatives;
+ public int FalsePositives;
+ public double MissedScreenPercent;
+ }
+ }
+}
diff --git a/OcclusionCulling/README.md b/OcclusionCulling/README.md
new file mode 100644
index 0000000..84b2c7c
--- /dev/null
+++ b/OcclusionCulling/README.md
@@ -0,0 +1,142 @@
+# OcclusionCulling
+
+What a CPU occlusion culling pass costs over a thousand objects, with Embree answering the
+visibility queries. No shading, no lighting, no shadows: this measures visibility and nothing
+else.
+
+
+
+A thousand boxes scattered at random through a cube, each with its own size on each axis, 12,000
+triangles, one Embree geometry per box so every one has its own `geomID`. The layout comes from a
+fixed seed, so the scene is the same scene on every run — 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.
+
+Scattered rather than gridded on purpose. 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. Random sizes and positions give irregular gaps, and those are what the technique
+actually has to cope with.
+
+## The two passes
+
+Both run after a frustum pass, which is the order a real engine uses. They answer different
+questions and their cost scales with different things.
+
+**Per-object rays.** Nine sample points per box (eight AABB corners and the centre), one ray
+each, stopping at the first sample that reaches the box. Cost scales with the object count. This
+is what an engine runs to decide which draw calls to submit.
+
+**Visibility buffer.** A 320×180 grid of primary rays; whatever geometry they land on is
+visible. Cost scales with resolution and does not care how many objects there are.
+
+Each comes in a single-ray and an 8-wide packet form.
+
+## Results
+
+24 logical cores, Embree 4.4.1 built with `EMBREE_MAX_ISA=AVX2`:
+
+```
+Method median mean rays visible culled miss screen
+frustum only 0.004ms 0.004ms 0 1,000 0.0% 0 0.00%
+per-object, single ray 0.205ms 0.204ms 7,861 229 77.1% 119 7.45%
+per-object, 8-wide packets 0.349ms 0.373ms 9,000 231 76.9% 117 7.14%
+visibility buffer 320x180, single ray 0.644ms 0.652ms 57,600 316 68.4% 30 0.15%
+visibility buffer 320x180, 8-wide packets 0.661ms 0.680ms 57,600 316 68.4% 30 0.15%
+```
+
+`miss` counts visible boxes the pass discarded — the ones that would pop. `screen` is how much of
+the covered screen area those boxes actually held, which is the number that matters: much of what
+is technically visible is visible through a gap a pixel or two wide.
+
+**The per-object pass costs 0.21 ms and removes three quarters of the draw calls.** Against a
+16.6 ms frame that is a bit over 1%, so it pays for itself as soon as the objects it removes cost
+more than that to draw.
+
+The two passes trade against each other rather than one being better. Per-object is three times
+cheaper and culls more (77% against 68%), but discards boxes holding 7.5% of the screen. The
+visibility buffer misses almost nothing — 0.15% — and is the one to reach for if popping matters
+more than the milliseconds.
+
+## How it scales
+
+`--sweep` repeats the measurement at 10, 50, 100, 200, 500 and 1,000 boxes, growing the volume
+with the cube root of the count so density stays constant and only the object count varies.
+
+```
+boxes frustum per-object per-object visbuffer visbuffer culled miss
+ single 8-packet single 8-packet screen
+ 10 0.0001 0.0092 0.0111 0.6522 0.5490 0.0% 0.00%
+ 50 0.0003 0.0203 0.0241 0.6140 0.5953 42.0% 7.87%
+ 100 0.0005 0.0258 0.0431 0.6238 0.5538 43.0% 5.93%
+ 200 0.0010 0.0487 0.0899 0.6219 0.6095 59.5% 4.97%
+ 500 0.0017 0.1218 0.2098 0.5709 0.5541 72.6% 8.39%
+1,000 0.0030 0.2077 0.3377 0.5793 0.5372 77.1% 7.45%
+```
+
+
+
+The two families scale differently, which is the whole reason to have both. Per-object cost
+tracks the object count, from 0.009 ms at ten boxes to 0.21 ms at a thousand. The visibility
+buffer sits flat around 0.6 ms whatever the object count — it is paying for 57,600 rays and does
+not care how many things they hit. It even drifts slightly cheaper as boxes are added, because a
+denser scene stops rays sooner.
+
+So per-object wins by a wide margin up to about a thousand objects, and the curves are heading
+for a crossing somewhere past that. Below a few hundred boxes it is nearly free: at 200 objects
+the pass costs 0.05 ms and removes 60% of the draw calls.
+
+**Each size runs in its own process, and that is not fussiness.** 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. With `EMBREE_TASKING_SYSTEM=INTERNAL` every
+`rtcNewDevice` brings its own worker threads, and six devices' worth leaves the machine in a
+different state from the one the first measurement saw. A sweep is about the shape of the curve,
+so it cannot be built from numbers that drift with their position in the list.
+
+
+
+Green is a box the per-object pass kept, red one it discarded. Every red patch is a fragment
+showing through a gap between nearer boxes.
+
+
+
+The middle slab seen from above, camera at the cross. Bright boxes survived, dark ones did not:
+the culling keeps the side facing the camera and throws away what is behind it.
+
+## Things worth knowing before copying this
+
+**Ask the device how wide a packet it supports.** `rtcIntersect16`/`rtcOccluded16` may only be
+called when `RTC_DEVICE_PROPERTY_NATIVE_RAY16_SUPPORTED` says so; calling them anyway is
+undefined behaviour, and in practice it corrupts the heap and takes the process down somewhere
+unrelated with no hint of the real cause. The binaries this package ships are built with
+`EMBREE_MAX_ISA=AVX2`, which tops out at 8 — 16 needs AVX-512. The sample prints the width it
+found.
+
+**Packets are not automatically faster.** Here they are 70% *slower* for the per-object pass,
+because filling eight lanes means giving up the early exit: the moment one sample proves a box
+visible the rest are pointless, but the lanes are already committed. 9,000 rays instead of 7,861,
+and the SIMD saving does not cover the difference. For the visibility buffer, where all the rays
+are needed anyway, the two come out level. Neither result was obvious in advance.
+
+**The two traversal paths do not agree exactly.** Single-ray finds 229 boxes visible, 8-wide
+finds 231, reproducibly. They are separate kernels inside Embree and they disagree on grazing
+hits. Two boxes in a thousand does not matter for culling, but it does mean the packet path is
+not a drop-in replacement anywhere the answer has to be bit-identical.
+
+**The obvious formulation of the per-object test is wrong.** Aiming an occlusion ray at a sample
+point on a box and stopping just short of it has the box occlude itself: the point is on its
+surface, so its own front face is in the way for all but a few silhouette corners. This asks what
+the ray hits *first* instead, which costs more per ray and answers the question actually being
+asked.
+
+**Ray structures must be aligned.** `RTCRay8` wants 32 bytes, `RTCRay16` wants 64, and a C# local
+guarantees neither. Every buffer here comes from `NativeMemory.AlignedAlloc`.
+
+## Running it
+
+```bash
+dotnet run --project OcclusionCulling -c Release
+```
+
+`--images` also writes `scene.png`, `verdict.png` and `slice.png` to the working directory. The
+PNG writer is about sixty lines in `Png.cs`, which keeps the sample free of dependencies and able
+to run on every RID the binding ships; `System.Drawing` would have pinned it to Windows.
diff --git a/OcclusionCulling/Scene.cs b/OcclusionCulling/Scene.cs
new file mode 100644
index 0000000..63e7b60
--- /dev/null
+++ b/OcclusionCulling/Scene.cs
@@ -0,0 +1,181 @@
+using Evergine.Bindings.Embree;
+using System;
+using System.Numerics;
+using System.Runtime.InteropServices;
+
+namespace OcclusionCulling
+{
+ ///
+ /// A grid of axis-aligned boxes, one Embree geometry each so every box gets its own
+ /// geomID. That identity is the whole point: occlusion culling answers "which objects do
+ /// I still have to draw", which needs per-object granularity, not per-triangle.
+ ///
+ internal sealed unsafe class Scene : IDisposable
+ {
+ private Device device;
+
+ /// How many boxes to scatter.
+ /// Half the side of the cube they are scattered in.
+ /// Smallest box edge.
+ /// Largest box edge.
+ ///
+ /// Fixed, and it has to be. The benchmark compares medians between runs, so the scene
+ /// must be the same scene every time; a clock-seeded layout would move the numbers around
+ /// and there would be no way to tell that from a real change.
+ ///
+ public Scene(int boxCount, float extent, float minSize, float maxSize, int seed)
+ {
+ this.BoxCount = boxCount;
+ this.Extent = extent;
+ this.Min = new Vector3[boxCount];
+ this.Max = new Vector3[boxCount];
+
+ this.device = Embree.NewDevice(null);
+ if (this.device.IsNull)
+ {
+ throw new InvalidOperationException($"rtcNewDevice failed: {Embree.GetDeviceError(Device.Null)}");
+ }
+
+ this.Handle = Embree.NewScene(this.device);
+
+ // The fastest traversal configuration: a high-quality static BVH, and none of
+ // Dynamic, Compact or Robust, each of which trades traversal speed for something
+ // this benchmark does not need.
+ Embree.SetSceneFlags(this.Handle, SceneFlags.None);
+ Embree.SetSceneBuildQuality(this.Handle, BuildQuality.High);
+
+ // Scattered at random through the volume, each with its own size along each axis, so
+ // no two boxes are alike and nothing lines up. A regular grid makes occlusion culling
+ // look better than it is: every occluder is the same size and sits exactly behind the
+ // one in front, which is the easiest case there is.
+ var random = new Random(seed);
+
+ for (int i = 0; i < boxCount; i++)
+ {
+ var centre = new Vector3(
+ Lerp(random, -extent, extent),
+ Lerp(random, -extent, extent),
+ Lerp(random, -extent, extent));
+
+ var half = new Vector3(
+ Lerp(random, minSize, maxSize),
+ Lerp(random, minSize, maxSize),
+ Lerp(random, minSize, maxSize)) * 0.5f;
+
+ this.Min[i] = centre - half;
+ this.Max[i] = centre + half;
+ this.AddBox(this.Min[i], this.Max[i]);
+ }
+
+ Embree.CommitScene(this.Handle);
+
+ Error error = Embree.GetDeviceError(this.device);
+ if (error != Error.None)
+ {
+ throw new InvalidOperationException($"Embree scene setup failed: {error}");
+ }
+ }
+
+ /// Gets the Embree scene.
+ public Evergine.Bindings.Embree.Scene Handle { get; }
+
+ ///
+ /// The widest ray packet this device actually supports.
+ ///
+ ///
+ /// This has to be asked, not assumed. Embree only allows rtcIntersectN/rtcOccludedN when
+ /// the matching property is set, and calling a wider one anyway is undefined behaviour —
+ /// in practice it corrupts the heap and takes the process down somewhere unrelated. The
+ /// binaries this package ships are built with EMBREE_MAX_ISA=AVX2, which tops out at 8;
+ /// 16 needs AVX-512.
+ ///
+ public int MaxPacketWidth =>
+ Embree.GetDeviceProperty(this.device, DeviceProperty.NativeRay16Supported) != 0 ? 16
+ : Embree.GetDeviceProperty(this.device, DeviceProperty.NativeRay8Supported) != 0 ? 8
+ : Embree.GetDeviceProperty(this.device, DeviceProperty.NativeRay4Supported) != 0 ? 4
+ : 1;
+
+ /// Gets half the side of the cube the boxes are scattered in.
+ public float Extent { get; }
+
+ /// Gets the total number of boxes, which is also the number of geomIDs.
+ public int BoxCount { get; }
+
+ /// Gets the lower corner of each box's AABB, indexed by geomID.
+ public Vector3[] Min { get; }
+
+ /// Gets the upper corner of each box's AABB, indexed by geomID.
+ public Vector3[] Max { get; }
+
+ /// Gets the total triangle count.
+ public int TriangleCount => this.BoxCount * 12;
+
+ private static float Lerp(Random random, float min, float max) =>
+ min + ((max - min) * (float)random.NextDouble());
+
+ public void Dispose()
+ {
+ Embree.ReleaseScene(this.Handle);
+ Embree.ReleaseDevice(this.device);
+ }
+
+ ///
+ /// The eight corners of a box, plus its centre. These are the sample points the
+ /// per-object method shoots at.
+ ///
+ public void GetSamplePoints(int box, Span points)
+ {
+ Vector3 lo = this.Min[box];
+ Vector3 hi = this.Max[box];
+
+ points[0] = new Vector3(lo.X, lo.Y, lo.Z);
+ points[1] = new Vector3(hi.X, lo.Y, lo.Z);
+ points[2] = new Vector3(lo.X, hi.Y, lo.Z);
+ points[3] = new Vector3(hi.X, hi.Y, lo.Z);
+ points[4] = new Vector3(lo.X, lo.Y, hi.Z);
+ points[5] = new Vector3(hi.X, lo.Y, hi.Z);
+ points[6] = new Vector3(lo.X, hi.Y, hi.Z);
+ points[7] = new Vector3(hi.X, hi.Y, hi.Z);
+ points[8] = (lo + hi) * 0.5f;
+ }
+
+ private void AddBox(Vector3 lo, Vector3 hi)
+ {
+ Geometry geometry = Embree.NewGeometry(this.device, GeometryType.Triangle);
+
+ float* vertices = (float*)Embree.SetNewGeometryBuffer(
+ geometry, BufferType.Vertex, 0, Format.Float3, 3 * sizeof(float), 8);
+
+ int v = 0;
+ for (int corner = 0; corner < 8; corner++)
+ {
+ vertices[v++] = (corner & 1) == 0 ? lo.X : hi.X;
+ vertices[v++] = (corner & 2) == 0 ? lo.Y : hi.Y;
+ vertices[v++] = (corner & 4) == 0 ? lo.Z : hi.Z;
+ }
+
+ uint* indices = (uint*)Embree.SetNewGeometryBuffer(
+ geometry, BufferType.Index, 0, Format.Uint3, 3 * sizeof(uint), 12);
+
+ // Corner bit 0 is X, bit 1 is Y, bit 2 is Z, so 0..7 indexes the cube corners.
+ ReadOnlySpan box = stackalloc uint[36]
+ {
+ 0, 2, 1, 1, 2, 3, // -Z
+ 4, 5, 6, 5, 7, 6, // +Z
+ 0, 1, 4, 1, 5, 4, // -Y
+ 2, 6, 3, 3, 6, 7, // +Y
+ 0, 4, 2, 2, 4, 6, // -X
+ 1, 3, 5, 3, 7, 5, // +X
+ };
+
+ for (int i = 0; i < box.Length; i++)
+ {
+ indices[i] = box[i];
+ }
+
+ Embree.CommitGeometry(geometry);
+ Embree.AttachGeometry(this.Handle, geometry);
+ Embree.ReleaseGeometry(geometry);
+ }
+ }
+}
diff --git a/OcclusionCulling/docs/scaling.png b/OcclusionCulling/docs/scaling.png
new file mode 100644
index 0000000..cc7f430
Binary files /dev/null and b/OcclusionCulling/docs/scaling.png differ
diff --git a/OcclusionCulling/docs/scene.png b/OcclusionCulling/docs/scene.png
new file mode 100644
index 0000000..c6f2c16
Binary files /dev/null and b/OcclusionCulling/docs/scene.png differ
diff --git a/OcclusionCulling/docs/slice.png b/OcclusionCulling/docs/slice.png
new file mode 100644
index 0000000..bb06aa5
Binary files /dev/null and b/OcclusionCulling/docs/slice.png differ
diff --git a/OcclusionCulling/docs/verdict.png b/OcclusionCulling/docs/verdict.png
new file mode 100644
index 0000000..6f3eb94
Binary files /dev/null and b/OcclusionCulling/docs/verdict.png differ
diff --git a/README.md b/README.md
index f0c3e36..8ca5ecc 100644
--- a/README.md
+++ b/README.md
@@ -74,8 +74,21 @@ binding.yml Manifest read by the Evergine.Bindings toolbox
EmbreeGen/ Generator console app (CppAst); vendored headers in Headers/
Evergine.Bindings.Embree/ The NuGet package: Generated/ bindings + runtimes/ natives
HelloEmbree/ Sample: CPU ray tracer drawn with the Evergine low-level API
+OcclusionCulling/ Sample: what a CPU occlusion culling pass costs
+CityCulling/ Sample: a city culled with Embree, drawn with the low-level API
```
+[CityCulling](CityCulling/README.md) is the two halves put together: a thousand buildings drawn
+through the Evergine low-level API, with Embree deciding each frame which of them reach the GPU. At
+street level it issues 56 draw calls instead of 1000.
+
+
+
+[OcclusionCulling](OcclusionCulling/README.md) measures a visibility pass over a thousand boxes scattered at random
+two ways — per-object rays and a visibility buffer — each with single rays and 8-wide packets,
+and reports both the cost and how much of the screen each one gets wrong. It is a console app
+with no dependencies, so unlike `HelloEmbree` it runs on every RID this package ships.
+
CI and CD are the shared workflows from
[EvergineTeam/Evergine.Bindings](https://github.com/EvergineTeam/Evergine.Bindings), and
[`binding.yml`](binding.yml) is what tells them where the upstream headers come from, which