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. + +![The city from street level](docs/city.png) + +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. + +![What the culling discarded](docs/culled.png) + +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. + +![The city from above](docs/topdown.png) + +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](docs/embree.png) + +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. + +![The scene](docs/scene.png) + +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% +``` + +![Cost against scene size](docs/scaling.png) + +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. + +![Which boxes survived](docs/verdict.png) + +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. + +![A slab through the middle of the cloud](docs/slice.png) + +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. + +![The city](CityCulling/docs/city.png) + +[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