Ahoy!
Over the course of December I started working on a 7dfps entry and well… it’s still pretty much barely a thing. But I’ve learned and experienced a lot with what I have already.
Before getting into it, here are the limitations of the current system:
- No materials
- No heirarchical transforms
- No animation
- No shadows
- Insufficient lighting
It’s important to state this up front because I understand that I’ve reached a certain point and will probably have to change my approach as I work through my TODO list there.
Despite not considering this a complete solution or even a unique or good approach, I felt like writing about what I’ve done so far to document the journey in case I forget where I’ve gone and what my thought process was.
I also don’t want any readers to think I’m unaware of these limitations because I hope any feedback would come in the form of “Hey, if you do $THIS then the delta between what you have now and $FEATURE is just a couple of steps.”. :D
The Setup Link to heading
I’m working in C# using the following: MoonWorks, Sharp.GLTF, and Moontools.ECS.
MoonWorks provides a convenient but crucially thin wrapper over SDL3, Shaders are written in HLSL (because they are supported by Rider) and thanks to MoonWorks I lean heavily on SDL_shadercross to compile shaders at runtime to the various bytecode formats needed by each platform. I really can’t praise MoonWorks enough, if your only takeaway from this post is “gotta check out MoonWorks” then that’s great.
Loading Meshes Link to heading
I do not have a full and properly defined asset pipeline. Instead I just have some small conventions for a Content
folder in which I place shaders and gltf meshes which are automatically discovered and loaded at startup. Assets that are loaded get an ID (an index) and in the codebase I can lookup these asset IDs by using a resource name (which is essentially the filename without it’s extension).
Using gltf as a runtime format doesn’t make a lot of sense perhaps, but I don’t think it’s the worst idea either. My entire inspiration for giving C# a serious look and being inspired to start building with it is thanks to Celeste64 which uses gltf at runtime for it’s super awesome art assets.
Ok, so in the asset manager let’s see what we do at startup:
private static void LoadMeshes(ResourceUploader uploader)
{
Logger.LogInfo("Loading Meshes:");
foreach (var glbPath in Directory.EnumerateFiles(Content.MeshPath, "*.glb")) {
Logger.LogInfo($" => {Path.GetFileName(glbPath)}");
var name = Content.GetMeshResourceName(glbPath);
var id = StaticMesh.LoadGLTF(uploader, glbPath, name);
MeshIDs[name] = id;
}
}
- Get a “resource name” for the mesh
- Load the mesh, getting it’s ID
- Associate the resource name with the ID
Pretty simple! ;) Ok that StaticMesh.LoadGLTF
is doing a lot of lifting I guess.
The StaticMesh Data Link to heading
So what’s that StaticMesh
class? That is just a fairly simple class that associates mesh data buffer handles with some referential identity.
Here it is with the static api and gpu data declarations omitted:
public readonly record struct MeshID(int idx);
public class StaticMesh
{
public required MeshID ID;
public required string Name;
public required uint IndexCount;
public required Buffer VertexBuffer;
public required Buffer IndexBuffer;
#region Static API
// ... omitted ...
#endregion // Static API
#region GPU Data
// ... omitted ...
#endregion // GPU Data
}
GPU Data Link to heading
The GPU data is just our vertex and instance format. Let’s take a look at these in order to know what we expect to load from a gltf in our mesh folder:
[StructLayout(LayoutKind.Explicit, Size = 48)]
public struct Vertex() : IVertexType
{
[FieldOffset(0)] public Vector3 Position = Vector3.Zero;
[FieldOffset(12)] public Vector3 Normal = Vector3.UnitZ;
[FieldOffset(24)] public Vector2 Texcoord = Vector2.Zero;
[FieldOffset(32)] public Vector4 Color = Vector4.One;
public static VertexElementFormat[] Formats { get; } = [
VertexElementFormat.Float3,
VertexElementFormat.Float3,
VertexElementFormat.Float2,
VertexElementFormat.Float4,
];
public static uint[] Offsets { get; } = [
0,
12,
24,
32,
// 48
];
}
[StructLayout(LayoutKind.Sequential)]
public struct Instance()
{
public Matrix4x4 Transform = Matrix4x4.Identity;
}
So, pretty basic stuff. No tangents or other interesting attributes and instances are only transforms.
Layout Link to heading
The goal for me was to load a gltf file, and end up creating one buffer of Vertex
data and one buffer of indices. What this means is that we expect the position attribute of each discrete mesh in the gltf file to have it’s vertices transformed to world space rather than object space.
Model
class and a Mesh
class with a full heirarchy supported. I probably should have done that too… but there are many different ways to cry while peeling onions.A gltf file can contain many discrete mesh objects, so in order to support that we have to be certain we encode our indices correctly.
To give an example, let’s say we have a file with two triangles in it where each is a separate mesh:
mesh.glb
-> mesh_a
-> vertices: A, B, C
-> indices: 0, 1, 2
-> mesh_b
-> vertices: D, E, F
-> indices: 0, 1, 2
When we load this file, we will iterate over each of those meshes, pushing the vertices into our vertex buffer which will end up looking like:
{ A, B, C, D, E, F }
But if we just load the indices the same way we have a problem on our hands:
{ 0, 1, 2, 0, 1, 2 }
So what we need to do is maintain an indexStart
that takes the count of index buffer at the start of each mesh and is added to every index we copy into our index buffer.
For mesh_a indexStart
would be 0, so the indices remain {0, 1, 2}
. But for mesh_b we’ll have set indexStart
to 3, so the indices become {3, 4, 5}
for a final index buffer:
{ 0, 1, 2, 3, 4, 5 }
The StaticMesh API Link to heading
The API provides a small number of methods, let’s quickly list them here:
public class StaticMesh
{
// ...
#region Static API
private static readonly List<StaticMesh> Meshes = new();
public static StaticMesh ForID(MeshID ID)
{
return Meshes[ID.idx];
}
public static uint Bind(RenderPass pass, MeshID ID)
{
// ...
}
public static MeshID LoadGLTF(ResourceUploader uploader, string glbPath, string name)
{
// ...
}
public static VertexInputState CreateVertexInputState()
{
// ...
}
#endregion // Static API
#region GPU Data
// ...
#endregion // GPU Data
}
At runtime we have a place to keep instances of StaticMesh
in a single location and we’ll see how that gets filled in by the LoadGLTF
method.
ForID
: Retrieve the instance associated with the providedMeshID
(note the lack of any safety here… :facepalm:).CreateVertexInputState
: Used when creating a graphics pipeline.Bind
: Given aMeshID
andRenderPass
, bind the associatedStaticMesh
buffers to the provided render pass.LoadGLTF
: Create our mesh instance by loading data from the specified gltf file.
ForID Link to heading
Even though it’s possible to get at an instance with a given MeshID
, you really only need the MeshID
in order to bind before drawing. Maybe I don’t really need this here.
CreateVertexInputState Link to heading
Creating modern graphics pipelines requires a lot of lines of description of how your data is laid out (among other things of course). In order to dampen the line noise a bit and co-locate important info into a canonical location I’ve created this method which is implemented like so:
var attrs = Vertex.Formats.Select((format, index) => new VertexAttribute {
BufferSlot = 0,
Location = (uint)index,
Format = format,
Offset = Vertex.Offsets[index],
});
return new VertexInputState {
VertexAttributes = attrs.ToArray(),
VertexBufferDescriptions = [
VertexBufferDescription.Create<Vertex>(),
],
};
Note that VertexBufferDescription.Create
takes advantage of the fact that our Vertex
type implements IVertexType
. These are all part of how MoonWorks helps with the details of the underlying API and keeps your code focused on the details of what you’re trying to build. It’s important to emphasize though, that these helpers don’t needlessly abstract what’s happening or make too many assumptions. That took a lot of hard work!
Bind Link to heading
When it comes time to draw a mesh, it’s buffers must be bound. This method is nothing but a convenience implemented as such:
var mesh = Meshes[ID.idx];
pass.BindIndexBuffer(mesh.IndexBuffer, IndexElementSize.ThirtyTwo);
pass.BindVertexBuffers(mesh.VertexBuffer);
return mesh.IndexCount;
We return the index count, because that becomes important when actually making draw calls.
LoadGLTF Link to heading
With the vertex format decided on, we know how we intend to lay things out. Let’s look at the actual process of loading the gltf and accessing the required vertex attributes, which can be summarized as:
- Open the file, create local buffers for uploading.
- For each mesh:
- For each primitive:
- Get a pointer to required attributes
- Add a new
Vertex
instance to our local buffer - Add the indices for this primitive to our index buffer
- For each primitive:
- Upload vertex and index data to our gpu buffers
- Get the new ID of our mesh
- Add this mesh instance to our internal list
- Return the ID
Now let’s look with some closer detail into the part of the static API that implements this: StaticMesh.LoadGLTF
.
First we open the file, and prepare some local buffers:
var model = SharpGLTF.Schema2.ModelRoot.Load(glbPath);
var vertices = new List<Vertex>();
var indices = new List<uint>();
var indexStart = 0;
Then for each mesh, and each meshes primitives (these should be triangles when you export your mesh) we get the required attributes for the primitive:
foreach (var mesh in model.LogicalMeshes) {
foreach (var prim in mesh.Primitives) {
var positions = prim.GetVertexAccessor("POSITION").AsVector3Array();
var normals = prim.GetVertexAccessor("NORMAL").AsVector3Array();
var uvs = prim.GetVertexAccessor("TEXCOORD_0").AsVector2Array();
var colors = prim.GetVertexAccessor("COLOR_0").AsVector4Array();
// ...
}
}
For each attribute we get an Accessor instance, and convert it to an IList<T>?
where T
should line up with our previously defined Vertex
field types.
With handles to our primitive attributes we can create a Vertex
instance and add it to our local buffer:
vertices.AddRange(
positions.Select((pos, vi) => new Vertex {
Position = pos,
Normal = normals[vi],
Texcoord = uvs[vi],
Color = colors[vi],
}));
I’m using the Select
Linq expression to enumerate the positions of this primitive, using each vertex index subsequently to also access the normal, texcoord, and color attributes. The resulting IEnumerable
returned by the Select
method is consumed by AddRange
.
I haven’t actually measured this, so a kind reader might ultimately want to dispel any misunderstandings that I have, but by using Linq here, the intention is to lazily construct
Vertex
instances as they are inserted into our local buffer thereby being slightly more efficient.
Next up we do something similar for the indices:
var primIndices = prim.GetIndices();
indices.AddRange(primIndices.Select(index => (uint)(indexStart + index)));
indexStart += verts.Count;
Similar to what we’ve done for vertices, we insert indices into our local buffer and lazily add our indexStart
to each of them as they are inserted. Once we’ve done this, we update indexStart
by accumulating the vertex count of this primitive.
We can now upload our local buffers to the GPU:
var vertexBuffer =
uploader.CreateBuffer<Vertex>($"{name} - Vertices", CollectionsMarshal.AsSpan(vertices),
BufferUsageFlags.Vertex);
var indexBuffer =
uploader.CreateBuffer<uint>($"{name} - Indices", CollectionsMarshal.AsSpan(indices),
BufferUsageFlags.Index);
Finally, with our buffers filled we can add a StaticMesh
instance to our internal storage, and return the new MeshID
:
var id = new MeshID(Meshes.Count);
Meshes.Add(new StaticMesh {
ID = id,
Name = name,
VertexBuffer = vertexBuffer,
IndexBuffer = indexBuffer,
IndexCount = (uint)indices.Count,
});
return id;
The StaticMesh GraphicsPipeline Link to heading
Before we can talk about the renderer, we need to briefly peek at how the graphics pipeline is setup. It isn’t anything unusual or especially interesting but it’s useful to know:
private static GraphicsPipeline BuildMeshPipeline(GraphicsDevice device)
{
var vertShader = Assets.Shaders["Mesh.vert"];
var fragShader = Assets.Shaders["Mesh.frag"];
return GraphicsPipeline.Create(device, new GraphicsPipelineCreateInfo {
TargetInfo = new GraphicsPipelineTargetInfo {
DepthStencilFormat = device.SupportedDepthFormat,
HasDepthStencilTarget = true,
ColorTargetDescriptions = [
new ColorTargetDescription {
BlendState = ColorTargetBlendState.PremultipliedAlphaBlend,
Format = RenderTargetFormat,
},
],
},
VertexShader = vertShader,
FragmentShader = fragShader,
DepthStencilState = new DepthStencilState {
EnableDepthTest = true,
EnableDepthWrite = true,
CompareOp = CompareOp.LessOrEqual,
},
VertexInputState = StaticMesh.CreateVertexInputState(),
PrimitiveType = PrimitiveType.TriangleList,
RasterizerState = RasterizerState.CCW_CullBack,
MultisampleState = MultisampleState.None,
});
}
The RenderTargetFormat
is defined:
private const TextureFormat RenderTargetFormat = TextureFormat.B8G8R8A8Unorm;
The vertex shader is:
#include "Uniforms.hlsli"
#include "Mesh.hlsli"
cbuffer RenderGlobals : register(b0, space1)
{
RenderGlobals globals;
}
StructuredBuffer<InstanceData> instances : register(t0, space0);
VSOutput main(VSInput input)
{
float4x4 model = instances[input.InstanceID].Transform;
float4x4 viewProjection = mul(globals.Projection, globals.View);
float4 worldPosition = mul(model, float4(input.Position, 1.0));
VSOutput output;
output.Color = input.Color;
output.Position = mul(viewProjection, worldPosition);
output.Texcoord = input.Texcoord;
output.Normal = input.Normal;
return output;
}
And the fragment shader is:
#include "Uniforms.hlsli"
#include "Mesh.hlsli"
cbuffer RenderGlobals : register(b0, space3)
{
RenderGlobals globals;
};
FSOutput main(VSOutput input)
{
float3 lightDirection = normalize(float3(-.1, .5, .9));
float ambient = 0.25;
float diffuse = 0.85;
float facing = max(dot(input.Normal, lightDirection), 0.0) * diffuse;
FSOutput output;
output.Color = (input.Color * ambient) + facing;
output.Color.w = 1.0;
return output;
}
Cast your gaze upon my shame!
Seriously, thanks to shader hot-reloading I’m able to noodle around here before I build proper lighting. So judge me all you like, I’m only human.
As you can see I have a couple of hlsli files for uniforms shared by multiple pipelines, and then the mesh specific declarations:
struct RenderGlobals
{
float4x4 View;
float4x4 Projection;
float Near;
float Far;
float Time;
float Reserved;
};
struct VSInput
{
uint InstanceID : SV_InstanceID;
// Instance attributes
[[vk::location(0)]] float3 Position : POSITION;
[[vk::location(1)]] float3 Normal : NORMAL;
[[vk::location(2)]] float2 Texcoord : TEXCOORD0;
[[vk::location(3)]] float4 Color : COLOR0;
};
struct VSOutput
{
[[vk::location(0)]] float4 Position : SV_Position;
[[vk::location(1)]] float4 Color : COLOR0;
[[vk::location(2)]] float2 Texcoord : TEXCOORD0;
[[vk::location(3)]] float3 Normal : NORMAL;
};
struct FSOutput
{
float4 Color : SV_Target0;
};
struct InstanceData
{
float4x4 Transform;
};
The StaticMeshRenderer Link to heading
The renderer is built as an ECS system which maintains a handle on an instance buffer and an associated transfer buffer. We start out with 0 instances, but create our buffers with 1024 items just to avoid a lot of resizing right away. I don’t know and haven’t measured what the ideal default would be for this game, because I really haven’t gotten to the point where that kind of measurement is even actionable. :D
The key job of the renderer is to iterate over all entities with a particular set of components, group these into buckets by mesh id and then submit a draw call for each group.
Here is the render with implementations omitted:
using Buffer = MoonWorks.Graphics.Buffer;
using Filter = MoonTools.ECS.Filter;
namespace Slops.Graphics;
class StaticMeshRenderer(GraphicsDevice device, World world) : EntityComponentReader(world)
{
private const uint INITIAL_INSTANCE_MAX = 1024;
private uint InstancesTotal = 0;
private uint InstancesMax = INITIAL_INSTANCE_MAX;
private Buffer InstanceBuffer = CreateInstanceBuffer(device, INITIAL_INSTANCE_MAX);
private TransferBuffer TransferBuffer = CreateTransferBuffer(device, INITIAL_INSTANCE_MAX);
private readonly Dictionary<MeshID, uint> InstanceCounts = new();
private readonly Dictionary<MeshID, uint> InstanceOffsets = new();
private readonly Dictionary<MeshID, uint> NextTxBufIndex = new();
private readonly Filter VisibleMeshes = world.FilterBuilder
.Include<MeshComponent>()
.Include<TransformComponent>()
.Exclude<IsHidden>()
.Build();
private static Buffer CreateInstanceBuffer(GraphicsDevice device, uint count)
{
// ...
}
private static TransferBuffer CreateTransferBuffer(GraphicsDevice device, uint count)
{
// ...
}
private void Reset()
{
// ...
}
public void UploadInstanceData(CommandBuffer cmdbuf)
{
// ...
}
public void Draw(RenderPass pass)
{
// ...
}
}
Updating the instance buffer Link to heading
In our game mode, at the top of our Draw
method we start by calling StaticMeshRenderer.UploadInstanceData
providing it the CommandBuffer it needs in order to do it’s job. For all of the entities in our world with a mesh component and a transform component, we need to upload that transform to the instance buffer before we can draw anything.
Overview Link to heading
Let’s start with a simple illustration of what our UploadInstanceData
method does in case you’re like me and have trouble sometimes without visualizations.
Say we have the following entities in our world:
joe = { mesh=1, xform=A }
bob = { mesh=2, xform=B }
sally = { mesh=1, xform=C }
jane = { mesh=1, xform=D }
We get our instance counts:
instance_counts = {
mesh1: 3,
mesh2: 1,
}
Then we setup our instance offsets and our transfer buffer write index:
instance_offsets = {
mesh1: 0,
mesh2: 3,
}
tx_buffer_write_index = {
mesh1: 0,
mesh2: 3,
}
Now as we iterate over all of the entities, we track where to write the xform value in the instance buffer.
First with joe we end up with:
instances = { A, _, _, _ }
tx_buffer_write_index[mesh1]++
Now our next write position for {mesh1} is 1.
Next we get to bob. The write position for {mesh2} is 3, so we end up with:
instances = { A, _, _, B }
tx_buffer_write_index[mesh2]++
Hopefully you can see where this is going now. After sally we get:
instances = { A, C, _, B }
tx_buffer_write_index[mesh1]++
And after jane our final instance buffer looks like: { A, C, D, B }
.
Implementation Link to heading
In UploadInstanceData
the first thing we do is call Reset
which is implemented like so:
InstancesTotal = 0;
foreach (var id in Assets.MeshIDs.Values) {
InstanceCounts[id] = 0;
InstanceOffsets[id] = 0;
}
We then get a total instance count from our ECS filter and if there is nothing to draw, we just return:
InstancesTotal = (uint)VisibleMeshes.Count;
if (InstancesTotal == 0) {
return;
}
When we do have something to draw, we then check whether the number of instances in our world exceeds the currently allocated maximum. In the event that we do exceed that amount, we set a new maximum and recreate our buffers:
if (InstancesTotal > InstancesMax) {
Logger.LogInfo("Resizing static mesh instance buffers.");
while (InstancesMax < InstancesTotal) {
InstancesMax *= 2;
}
InstanceBuffer = CreateInstanceBuffer(device, InstancesMax);
TransferBuffer = CreateTransferBuffer(device, InstancesMax);
}
Next we iterate over all of the entities and build up the per-mesh instance counts. This was a sticky point originally and I had hoped at first to avoid iterating over the entities multiple times, but in practice that is way faster and more efficient than using anything in the standard library collections to let me build something up dynamically.
foreach (var entity in VisibleMeshes.Entities) {
var meshId = Get<MeshComponent>(entity).ID;
InstanceCounts[meshId] += 1;
}
Next is the trick why I thought it was easier to explain first with an illustration. Starting with the code:
uint offset = 0;
foreach (var meshId in Assets.MeshIDs.Values) {
InstanceOffsets[meshId] = offset;
NextTxBufIndex[meshId] = offset;
offset += InstanceCounts[meshId];
}
Here you see, that for every loaded mesh asset (I know I know) I’m taking the MeshID, and associating it with an offset
into the instance buffer. The offsets are accumulated by those previously gathered per-mesh instance counts and meshes with no entities using them just don’t matter even though they ostensibly get an invalid offset and write position.
Since I’m currently only dealing with a couple of meshes, and they’re all used, this approach is ok. I’ll revisit that if my mesh counts become very large, but only if the profiler suggests there is a problem.
We can see everything at work once we copy our transforms into the buffer:
var instanceData = TransferBuffer.Map<StaticMesh.Instance>(false);
foreach (var entity in VisibleMeshes.Entities) {
var meshId = Get<MeshComponent>(entity).ID;
var xform = Get<TransformComponent>(entity).Matrix;
var idx = NextTxBufIndex[meshId]++;
instanceData[(int)idx].Transform = xform;
}
TransferBuffer.Unmap();
Notice how I’m leaning on post-increment operator behavior to save a couple of lines… my shame is real.
With our transfer buffer updated, we submit the copy command and return:
var copyPass = cmdbuf.BeginCopyPass();
copyPass.UploadToBuffer(TransferBuffer, InstanceBuffer, false);
cmdbuf.EndCopyPass(copyPass);
Submitting draw calls Link to heading
Well, uploading the instance buffer is the only real work this thing does. The actual drawing is easy breezy:
public void Draw(RenderPass pass)
{
pass.BindVertexStorageBuffers(InstanceBuffer);
foreach (var meshId in Assets.MeshIDs.Values) {
var count = InstanceCounts[meshId];
var offset = InstanceOffsets[meshId];
if (count == 0) {
continue;
}
var indexCount = StaticMesh.Bind(pass, meshId);
pass.DrawIndexedPrimitives(indexCount, count, 0, 0, offset);
}
}
Appendix Link to heading
Just for the sake of it, here is the full source for the two classes:
StaticMesh.cs
:
using System.Runtime.InteropServices;
// Disambiguation
using Buffer = MoonWorks.Graphics.Buffer;
namespace Slops.Graphics;
public readonly record struct MeshID(int idx);
public class StaticMesh
{
public required MeshID ID;
public required string Name;
public required uint IndexCount;
public required Buffer VertexBuffer;
public required Buffer IndexBuffer;
#region Static API
private static readonly List<StaticMesh> Meshes = new();
public static StaticMesh ForID(MeshID ID)
{
return Meshes[ID.idx];
}
public static uint Bind(RenderPass pass, MeshID ID)
{
var mesh = Meshes[ID.idx];
pass.BindIndexBuffer(mesh.IndexBuffer, IndexElementSize.ThirtyTwo);
pass.BindVertexBuffers(mesh.VertexBuffer);
return mesh.IndexCount;
}
public static MeshID LoadGLTF(ResourceUploader uploader, string glbPath, string name)
{
var model = SharpGLTF.Schema2.ModelRoot.Load(glbPath);
var vertices = new List<Vertex>();
var indices = new List<uint>();
var indexStart = 0;
foreach (var mesh in model.LogicalMeshes) {
foreach (var prim in mesh.Primitives) {
var positions = prim.GetVertexAccessor("POSITION").AsVector3Array();
var normals = prim.GetVertexAccessor("NORMAL").AsVector3Array();
var uvs = prim.GetVertexAccessor("TEXCOORD_0").AsVector2Array();
var colors = prim.GetVertexAccessor("COLOR_0").AsVector4Array();
vertices.AddRange(
positions.Select((pos, vi) => new Vertex {
Position = pos,
Normal = normals[vi],
Texcoord = uvs[vi],
Color = colors[vi],
}));
var primIndices = prim.GetIndices();
indices.AddRange(primIndices.Select(index => (uint)(indexStart + index)));
indexStart += positions.Count;
}
}
var vertexBuffer =
uploader.CreateBuffer<Vertex>($"{name} - Vertices", CollectionsMarshal.AsSpan(vertices),
BufferUsageFlags.Vertex);
var indexBuffer =
uploader.CreateBuffer<uint>($"{name} - Indices", CollectionsMarshal.AsSpan(indices),
BufferUsageFlags.Index);
var id = new MeshID(Meshes.Count);
Meshes.Add(new StaticMesh {
ID = id,
Name = name,
VertexBuffer = vertexBuffer,
IndexBuffer = indexBuffer,
IndexCount = (uint)indices.Count,
});
return id;
}
public static VertexInputState CreateVertexInputState()
{
var attrs = Vertex.Formats.Select((format, index) => new VertexAttribute {
BufferSlot = 0,
Location = (uint)index,
Format = format,
Offset = Vertex.Offsets[index],
});
return new VertexInputState {
VertexAttributes = attrs.ToArray(),
VertexBufferDescriptions = [
VertexBufferDescription.Create<Vertex>(),
],
};
}
#endregion // Static API
#region GPU Data
[StructLayout(LayoutKind.Explicit, Size = 48)]
public struct Vertex() : IVertexType
{
[FieldOffset(0)] public Vector3 Position = Vector3.Zero;
[FieldOffset(12)] public Vector3 Normal = Vector3.UnitZ;
[FieldOffset(24)] public Vector2 Texcoord = Vector2.Zero;
[FieldOffset(32)] public Vector4 Color = Vector4.One;
public static VertexElementFormat[] Formats { get; } = [
VertexElementFormat.Float3,
VertexElementFormat.Float3,
VertexElementFormat.Float2,
VertexElementFormat.Float4,
];
public static uint[] Offsets { get; } = [
0,
12,
24,
32,
// 48
];
}
[StructLayout(LayoutKind.Sequential)]
public struct Instance()
{
public Matrix4x4 Transform = Matrix4x4.Identity;
}
#endregion // GPU Data
}
StaticMeshRenderer.cs
:
using Buffer = MoonWorks.Graphics.Buffer;
using Filter = MoonTools.ECS.Filter;
namespace Slops.Graphics;
class StaticMeshRenderer(GraphicsDevice device, World world) : EntityComponentReader(world)
{
private const uint INITIAL_INSTANCE_MAX = 1024;
private uint InstancesTotal = 0;
private uint InstancesMax = INITIAL_INSTANCE_MAX;
private Buffer InstanceBuffer = CreateInstanceBuffer(device, INITIAL_INSTANCE_MAX);
private TransferBuffer TransferBuffer = CreateTransferBuffer(device, INITIAL_INSTANCE_MAX);
private readonly Dictionary<MeshID, uint> InstanceCounts = new();
private readonly Dictionary<MeshID, uint> InstanceOffsets = new();
private readonly Dictionary<MeshID, uint> NextTxBufIndex = new();
private readonly Filter VisibleMeshes = world.FilterBuilder
.Include<MeshComponent>()
.Include<TransformComponent>()
.Exclude<IsHidden>()
.Build();
private static Buffer CreateInstanceBuffer(GraphicsDevice device, uint count)
{
return Buffer.Create<StaticMesh.Instance>(
device, BufferUsageFlags.GraphicsStorageRead, count);
}
private static TransferBuffer CreateTransferBuffer(GraphicsDevice device, uint count)
{
return TransferBuffer.Create<StaticMesh.Instance>(
device, TransferBufferUsage.Upload, count);
}
private void Reset()
{
InstancesTotal = 0;
foreach (var id in Assets.MeshIDs.Values) {
InstanceCounts[id] = 0;
InstanceOffsets[id] = 0;
}
}
public void UploadInstanceData(CommandBuffer cmdbuf)
{
Reset();
InstancesTotal = (uint)VisibleMeshes.Count;
if (InstancesTotal == 0) {
return;
}
if (InstancesTotal > InstancesMax) {
Logger.LogInfo("Resizing static mesh instance buffers.");
while (InstancesMax < InstancesTotal) {
InstancesMax *= 2;
}
InstanceBuffer = CreateInstanceBuffer(device, InstancesMax);
TransferBuffer = CreateTransferBuffer(device, InstancesMax);
}
foreach (var entity in VisibleMeshes.Entities) {
var meshId = Get<MeshComponent>(entity).ID;
InstanceCounts[meshId] += 1;
}
uint offset = 0;
foreach (var meshId in Assets.MeshIDs.Values) {
InstanceOffsets[meshId] = offset;
NextTxBufIndex[meshId] = offset;
offset += InstanceCounts[meshId];
}
System.Diagnostics.Debug.Assert(offset == InstancesTotal);
System.Diagnostics.Debug.Assert(offset < InstancesMax);
var instanceData = TransferBuffer.Map<StaticMesh.Instance>(false);
foreach (var entity in VisibleMeshes.Entities) {
var meshId = Get<MeshComponent>(entity).ID;
var xform = Get<TransformComponent>(entity).Matrix;
var idx = NextTxBufIndex[meshId]++;
instanceData[(int)idx].Transform = xform;
}
TransferBuffer.Unmap();
var copyPass = cmdbuf.BeginCopyPass();
copyPass.UploadToBuffer(TransferBuffer, InstanceBuffer, false);
cmdbuf.EndCopyPass(copyPass);
}
public void Draw(RenderPass pass)
{
pass.BindVertexStorageBuffers(InstanceBuffer);
foreach (var meshId in Assets.MeshIDs.Values) {
var count = InstanceCounts[meshId];
var offset = InstanceOffsets[meshId];
if (count == 0) {
continue;
}
var indexCount = StaticMesh.Bind(pass, meshId);
pass.DrawIndexedPrimitives(indexCount, count, 0, 0, offset);
}
}
}