WINDAGE ENGINE v1.0.0
Contents

Docs / Rendering

Many copies

A wood is a few hundred trees of two or three kinds; a rubble field is a hundred stones of one. Drawn as separate objects, that is a draw call per copy per submesh, and the CPU cost grows with every copy. Drawn instanced, it is one draw per submesh per kind, and the copies are a buffer of transforms the GPU indexes.

Windage Engine splits this in two, the way it splits particles: InstanceBatches groups copies on the CPU and is tested without a GPU; InstanceBuffer puts the result where a shader can read it.

InstanceBatches

public nonisolated struct InstanceBatches<Key: Hashable & Sendable>: Sendable {
    public struct Batch: Sendable, Equatable {
        public let key: Key
        public let start: Int          // index of this kind's first transform
        public let count: Int
        public var byteOffset: Int     // start * MemoryLayout<simd_float4x4>.stride
    }
    public private(set) var transforms: [simd_float4x4]
    public private(set) var batches: [Batch]
    public init()
    public init<Copies: Sequence>(_ copies: Copies) where Copies.Element == (Key, simd_float4x4)
    public var isEmpty: Bool
}

Hand it (key, transform) pairs. The key is whatever names a kind in your game — a mesh name, an index into your list of loaded meshes. The result is every copy's transform in one array, kind after kind, and a Batch per kind saying where its run starts and how long it is.

Order is fixed, on purpose

Kinds come in the order they were first seen, and copies in the order they were given. A dictionary's order would change between runs, and with it which kind is drawn first — invisible in an opaque pass, and a flicker the day a transparent kind is instanced. The same input gives the same draws every run.

InstanceBuffer

public final class InstanceBuffer {
    public init(renderer: FrameRenderer)
    public func write<Key>(_ batches: InstanceBatches<Key>, frameSlot: Int) -> MTLBuffer?
}

write(_:frameSlot:) copies a frame's transforms into a buffer and returns it, or nil when there are none.

The engine draws nothing here. Your vertex function reads the copies, because the vertices are yours.

The shader contract

One float4x4 per copy, starting at the batch's byteOffset, indexed by [[instance_id]], applied on top of whatever per-object transform you already have. Bind the buffer's address plus byteOffset, then draw each submesh with instanceCount: batch.count.

The sample's instanced vertex function:

vertex VertexOut sampleInstancedVertex(VertexIn in [[stage_in]],
                                       uint instance [[instance_id]],
                                       constant Uniforms &uniforms [[buffer(1)]],
                                       const device float4x4 *copies [[buffer(7)]])
{
    const float4x4 model = copies[instance] * uniforms.model;
    // …
}

Buffer 7 is the sample's choice for the main pass. Only the shadow pass's index is fixed by the engine.

The sample's debris

SampleRubble.transforms(around:count:margin:seed:) scatters small boxes among a level's buildings — deterministic, so the recorded frames never drift — and the sample groups them as one kind:

rubble = InstanceBatches(SampleRubble.transforms(around: buildings).map { (class="p">"box", $0) })

Each frame, before any pass, it writes them:

rubbleCopies = rubbleBuffer.write(rubble, frameSlot: frameSlot)

Writing before the shadow passes means the shadows and the scene draw the same copies. Then the main pass:

for batch in rubble.batches {
    argumentTable.setAddress(copies.gpuAddress + UInt64(batch.byteOffset), index: 7)
    for submesh in mesh.submeshes {
        encoder.drawIndexedPrimitives(primitiveType: submesh.primitiveType,
                                      indexCount: submesh.indexCount,
                                      indexType: submesh.indexType,
                                      indexBuffer: submesh.indexBuffer.buffer.gpuAddress
                                          + UInt64(submesh.indexBuffer.offset),
                                      indexBufferLength: submesh.indexBuffer.length,
                                      instanceCount: batch.count)
    }
}

Every piece is evenly sized, so the shader can turn normals with the copy's own rotation. A copy squashed along one axis would need its inverse transpose, per copy.

Shadows for instanced copies

ShadowMap normally draws object by object. Copies are not objects, so they go in a batch that encodes its own draws:

ShadowBatch(pipeline: instancedShadowPipeline) { encoder, table, pass in … }

The engine binds the pipeline and the light matrix, then calls the closure once per sun cascade — and once per atlas tile and cube face for lamp shadows. Your closure binds what its vertex function reads and draws. The copies go at ShadowBufferIndex.instances (4):

batches.append(ShadowBatch(pipeline: instancedShadowPipeline) { encoder, table, _ in
    table.setAddress(uniforms, index: ShadowBufferIndex.uniforms)
    for (bufferIndex, vertexBuffer) in box.mesh.vertexBuffers.enumerated() {
        table.setAddress(vertexBuffer.buffer.gpuAddress + UInt64(vertexBuffer.offset), index: bufferIndex)
    }
    for batch in batchList {
        table.setAddress(copies.gpuAddress + UInt64(batch.byteOffset),
                         index: ShadowBufferIndex.instances)
        // draw each submesh with instanceCount: batch.count
    }
})

and the shadow vertex function reads them there:

vertex float4 sampleInstancedShadowVertex(VertexIn in [[stage_in]],
                                          uint instance [[instance_id]],
                                          constant Uniforms &uniforms [[buffer(1)]],
                                          constant float4x4 &lightViewProjection [[buffer(2)]],
                                          const device float4x4 *copies [[buffer(4)]])
{
    return lightViewProjection * (copies[instance] * uniforms.model * float4(in.position, 1.0));
}

The engine cannot cull a self-drawing batch per object, so it hands you a ShadowPass with the pass's target — which cascade, tile or cube face — and its viewProjection. The sample's debris is small enough to ignore it; a wood should build a Frustum from it and draw only the copies that pass can see — see culling. See shadows for the rest of the pass.

What it does not do