Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b289a92
[Performance] Instanced model and shadow shaders
Oct 15, 2026
daaabe9
[Chores] Rebuild the shader libraries
Oct 15, 2026
452c8c4
[Performance] Draw entities that share their meshes and materials as …
Oct 15, 2026
7bce966
[Performance] Walk the scene once for the directional shadows
Oct 4, 2026
6daceb9
[Performance] Remember whether a render component has blended submeshes
Oct 4, 2026
01a90fb
[Performance] Select LOD levels through a snapshot of the scene
Oct 4, 2026
803f7c7
[Performance] Narrow the streaming update to the entities that stream
Oct 4, 2026
aa0fa0f
[Performance] Gather the culling bounds without an array per entity
Oct 4, 2026
7f3a74a
[Docs] The walks over the entities outside the render passes
Oct 4, 2026
0067e70
[Bugfix] Draw the lone mesh of an entity that is in a batch
Oct 4, 2026
1b53ad7
[Bugfix] Tell apart in a batch the materials that differ in clear coa…
Oct 4, 2026
feee1ad
[Bugfix] Apply a batch built in the background when nothing else changes
Oct 4, 2026
c31fd05
[Bugfix] A static entity that no batch draws casts its shadow
Oct 4, 2026
f8c6012
[Feature] Batch in the background the static entities placed to stay
Oct 4, 2026
2a87c87
[Bugfix] A static entity that moves, loses its mark or is destroyed l…
Oct 4, 2026
8f9fcf5
[Performance] Ask whether an entity is marked before asking whether i…
Oct 4, 2026
c303a8b
[Performance] Batch the parts that a pack places once
Oct 4, 2026
997006f
[Test] The batching tests cast their shadows from a sun of their own
Oct 4, 2026
16a2c93
[Bugfix] A batch no longer draws a hidden entity, nor casts for one t…
Oct 4, 2026
d177b94
Merge feature/pack_single_batches at 16a2c931 (the large-scene stage …
Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Sources/CShaderTypes/ShaderTypes.h
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ typedef enum{
modelPassJointTransformIndex,
modelPassHasArmature,
modelPassOccluderShrinkIndex,
// The ModelInstanceUniform records of an instanced draw (vertexModelInstancedShader).
modelPassInstanceIndex,
}ModelPassBufferIndices;
typedef enum{
modelPassFragmentUniformIndex,
Expand All @@ -159,6 +161,21 @@ typedef enum{
modelPassFragmentSpotShadowUniformIndex,
}ModelPassFragmentBufferIndices;

// What one instance of an instanced model draw has of its own: its transforms. Draws that
// share their meshes, materials and ghost opacity are encoded once, with a record per
// instance; the draw's base instance is the index of its first record. Only the vertex
// stage reads the records; the fragment stage is fragmentModelShader, bound as for a
// single draw.
typedef struct
{
matrix_float4x4 modelViewMatrix;
matrix_float4x4 modelMatrix;
// The model matrix the instance was drawn with the previous frame, for the velocity
// (see Uniforms.prevModelMatrix).
matrix_float4x4 prevModelMatrix;
matrix_float3x3 normalMatrix;
} ModelInstanceUniform;

typedef enum{
deformationPassInPositionIndex,
deformationPassInNormalIndex,
Expand Down Expand Up @@ -655,6 +672,8 @@ typedef enum{
shadowPassLightPositionUniform,
shadowPassJointTransformIndex,
shadowPassHasArmature,
// The model matrix of each instance of an instanced shadow draw (vertexShadowInstancedShader).
shadowPassInstanceIndex,
}ShadowBufferIndices;

typedef struct{
Expand Down
68 changes: 65 additions & 3 deletions Sources/UntoldEngine/ECS/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,73 @@ public class WorldTransformComponent: Component {
}

public class RenderComponent: Component {
public var mesh: [Mesh]
public var mesh: [Mesh] {
didSet {
// Whatever changed (a material, a texture, the level of detail), the
// renderer has to look again at what the component draws.
instanceKey = .unresolved
if instancedSubmeshDraws != nil {
instancedSubmeshDraws = nil
}
if instancedShadowDraws != nil {
instancedShadowDraws = nil
}
blendedSubmeshes = nil
}
}

/// Whether any submesh of `mesh` is blended or lets light through: what the
/// transparency pass draws of the entity. Most entities have none, and the pass asks
/// every visible one each frame, so the answer is kept until the meshes change.
var hasBlendedSubmeshes: Bool {
if let known = blendedSubmeshes {
return known
}
var found = false
search: for meshIndex in mesh.indices {
for submeshIndex in mesh[meshIndex].submeshes.indices
where mesh[meshIndex].submeshes[submeshIndex].material?.hasTransparency == true
{
found = true
break search
}
}
blendedSubmeshes = found
return found
}

private var blendedSubmeshes: Bool?

/// What `mesh` draws, as a number the renderer groups instanced draws by: see
/// `RenderInstanceKeyTable`. Any change to `mesh` clears it.
var instanceKey: RenderInstanceKey = .unresolved

/// The draws of `mesh` in the opaque pass with everything they bind, prepared the
/// first time the component stands for its group in an instanced draw. Any change
/// to `mesh` clears them.
var instancedSubmeshDraws: [InstancedSubmeshDraw]?

/// The draws of `mesh` in a shadow pass, prepared the first time the component
/// stands for its group there: every submesh but those of glass, which casts no
/// shadow. Any change to `mesh` clears them.
var instancedShadowDraws: [InstancedShadowDraw]?
var assetURL: URL = .init(fileURLWithPath: "")
var assetName: String = ""
public var isVisible: Bool = true // Visibility flag for bulk loading
public var castsShadow: Bool = true
/// Whether the entity is drawn. A batch draws all of its entities, and casts a shadow
/// for all of them or for none, so the batching system is told when either flag
/// changes: a hidden entity leaves its batch, and one whose `castsShadow` changed
/// moves to a batch of entities that cast as it does.
public var isVisible: Bool = true { // Visibility flag for bulk loading
didSet {
if isVisible != oldValue { BatchingSystem.shared.noteRenderFlagsChanged() }
}
}

public var castsShadow: Bool = true {
didSet {
if castsShadow != oldValue { BatchingSystem.shared.noteRenderFlagsChanged() }
}
}

public required init() {
mesh = []
Expand Down
36 changes: 36 additions & 0 deletions Sources/UntoldEngine/Profiling/RenderStatsCollector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,42 @@ public extension MTLRenderCommandEncoder {
#endif
}

/// An instanced draw: `instanceCount` instances, the first of which reads the instance
/// record at `baseInstance`. The statistics count it as one draw call and
/// `instanceCount` times its triangles.
@inline(__always)
func drawIndexedPrimitivesTracked(
type: MTLPrimitiveType,
indexCount: Int,
indexType: MTLIndexType,
indexBuffer: MTLBuffer,
indexBufferOffset: Int,
instanceCount: Int,
baseInstance: Int,
category: RenderDrawCategory = .other
) {
drawIndexedPrimitives(
type: type,
indexCount: indexCount,
indexType: indexType,
indexBuffer: indexBuffer,
indexBufferOffset: indexBufferOffset,
instanceCount: instanceCount,
baseVertex: 0,
baseInstance: baseInstance
)
#if ENGINE_STATS_ENABLED
RenderStatsCollector.shared.recordIndexedDraw(
type: type,
indexCount: indexCount,
instanceCount: instanceCount,
category: category
)
#else
_ = category
#endif
}

@inline(__always)
func drawPrimitivesTracked(
type: MTLPrimitiveType,
Expand Down
56 changes: 50 additions & 6 deletions Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,24 @@ public func InitShadowPipeline() -> RenderPipeline? {
)
}

/// The shadow pipeline of instanced draws (see `InstancedDrawList`): the depth state of
/// `InitShadowPipeline`, a vertex stage that reads each instance's model matrix from a
/// buffer and takes the position stream alone. Nil on a device that cannot draw that
/// way (`InstancedDrawing.isSupported(by:)`): the pass then draws caster by caster.
public func InitShadowInstancedPipeline() -> RenderPipeline? {
guard let device = renderInfo.device, InstancedDrawing.isSupported(by: device) else { return nil }
return CreatePipeline(
vertexShader: "vertexShadowInstancedShader",
fragmentShader: nil,
vertexDescriptor: createShadowInstancedVertexDescriptor(),
colorFormats: [.invalid],
depthFormat: renderInfo.depthPixelFormat,
depthCompareFunction: MTLCompareFunction.less,
reverseZCompatible: false,
name: "Shadow Instanced Pipeline"
)
}

// MARK: Model pipeline

private var wf: WorkingColorFormats {
Expand All @@ -384,7 +402,30 @@ public func InitModelOpaquePipeline() -> RenderPipeline? {
makeModelPipeline(mayDiscard: false, name: "Model Pipeline (opaque)")
}

private func makeModelPipeline(mayDiscard: Bool, name: String) -> RenderPipeline? {
/// The model pipeline of instanced draws (see `InstancedDrawList`): the attachments, the
/// depth state, the function constants and the fragment function of `InitModelPipeline`
/// itself, so a group shades as a single draw does on every render path (deferred, hybrid,
/// forward); a vertex stage that reads each instance's transform from a
/// buffer, and the four vertex streams every mesh has. Nil on a device that cannot draw
/// that way (`InstancedDrawing.isSupported(by:)`): the pass then draws entity by entity.
public func InitModelInstancedPipeline() -> RenderPipeline? {
guard let device = renderInfo.device, InstancedDrawing.isSupported(by: device) else { return nil }
return makeModelPipeline(instanced: true, mayDiscard: true, name: "Model Instanced Pipeline")
}

/// The instanced model pipeline compiled without the discards, as `InitModelOpaquePipeline`:
/// the pass picks it for the groups whose material never needs them.
public func InitModelInstancedOpaquePipeline() -> RenderPipeline? {
guard let device = renderInfo.device, InstancedDrawing.isSupported(by: device) else { return nil }
return makeModelPipeline(instanced: true, mayDiscard: false, name: "Model Instanced Pipeline (opaque)")
}

private func makeModelPipeline(instanced: Bool = false, mayDiscard: Bool, name: String) -> RenderPipeline? {
let vertexShader = instanced ? "vertexModelInstancedShader" : "vertexModelShader"
// One fragment function for both: a second compilation of the same stage would differ
// from the first in the last bits it computes, and a group is to shade as a single
// draw does.
let fragmentShader = "fragmentModelShader"
let indirectInGBuffer = renderInfo.perFragmentIndirectLighting
let litInGBuffer = renderInfo.perFragmentLightingWanted
renderInfo.perFragmentIndirectLightingBuilt = indirectInGBuffer
Expand All @@ -393,8 +434,8 @@ private func makeModelPipeline(mayDiscard: Bool, name: String) -> RenderPipeline
handleError(.metalLibraryNotFound)
return nil
}
guard let vertexFunction = library.makeFunction(name: "vertexModelShader") else {
handleError(.shaderCreationFailed, "vertexModelShader")
guard let vertexFunction = library.makeFunction(name: vertexShader) else {
handleError(.shaderCreationFailed, vertexShader)
return nil
}
let constants = MTLFunctionConstantValues()
Expand All @@ -406,17 +447,17 @@ private func makeModelPipeline(mayDiscard: Bool, name: String) -> RenderPipeline
constants.setConstantValue(&litValue, type: .bool, index: Int(modelFunctionConstantLitInGBuffer.rawValue))
let fragmentFunction: MTLFunction
do {
fragmentFunction = try library.makeFunction(name: "fragmentModelShader", constantValues: constants)
fragmentFunction = try library.makeFunction(name: fragmentShader, constantValues: constants)
} catch {
handleError(.shaderCreationFailed, "fragmentModelShader (\(name)): \(error.localizedDescription)")
handleError(.shaderCreationFailed, "\(fragmentShader) (\(name)): \(error.localizedDescription)")
return nil
}

let desc = MTLRenderPipelineDescriptor()
desc.label = name
desc.vertexFunction = vertexFunction
desc.fragmentFunction = fragmentFunction
desc.vertexDescriptor = createModelVertexDescriptor()
desc.vertexDescriptor = instanced ? createModelInstancedVertexDescriptor() : createModelVertexDescriptor()
desc.depthAttachmentPixelFormat = renderInfo.depthPixelFormat
desc.rasterSampleCount = max(1, renderInfo.opaqueSampleCount)

Expand Down Expand Up @@ -1262,8 +1303,11 @@ public func DefaultPipeLines() -> [(RenderPipelineType, RenderPipelineInitBlock)
(.grid, InitGridPipeline),
(.sky, InitSkyPipeline),
(.shadow, InitShadowPipeline),
(.shadowInstanced, InitShadowInstancedPipeline),
(.model, InitModelPipeline),
(.modelOpaque, InitModelOpaquePipeline),
(.modelInstanced, InitModelInstancedPipeline),
(.modelInstancedOpaque, InitModelInstancedOpaquePipeline),
(.light, InitLightPipeline),
(.lightTile, InitLightTilePipeline),
(.geometry, InitGeometryPipeline),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,17 @@ public extension RenderPipelineType {
static let grid: RenderPipelineType = "grid"
static let sky: RenderPipelineType = "sky"
static let shadow: RenderPipelineType = "shadow"
/// The shadow pipeline for instanced draws: casters that share their meshes, one draw.
static let shadowInstanced: RenderPipelineType = "shadowInstanced"
static let model: RenderPipelineType = "model"
/// The model pipeline compiled without fragment discards, for the opaque draws that never
/// need them: the GPU resolves visibility before shading them.
static let modelOpaque: RenderPipelineType = "modelOpaque"
/// The model pipeline for instanced draws: entities that share their meshes and
/// materials, one draw.
static let modelInstanced: RenderPipelineType = "modelInstanced"
/// The instanced model pipeline compiled without fragment discards, as `modelOpaque`.
static let modelInstancedOpaque: RenderPipelineType = "modelInstancedOpaque"
static let light: RenderPipelineType = "light"
/// The TBDR light pass as a tile stage: lights each pixel once per colour it holds.
static let lightTile: RenderPipelineType = "lightTile"
Expand Down
47 changes: 47 additions & 0 deletions Sources/UntoldEngine/Renderer/RenderInitializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,17 @@ func updateOpaquePipelinesForSampleCount() {
if let modelOpaquePipeline = InitModelOpaquePipeline() {
PipelineManager.shared.update(rendererPipeLine: modelOpaquePipeline, forType: .modelOpaque)
}
// Only where the renderer was set up with them: the pass draws instanced when they are there.
if PipelineManager.shared.renderPipelinesByType[.modelInstanced] != nil,
let modelInstancedPipeline = InitModelInstancedPipeline()
{
PipelineManager.shared.update(rendererPipeLine: modelInstancedPipeline, forType: .modelInstanced)
}
if PipelineManager.shared.renderPipelinesByType[.modelInstancedOpaque] != nil,
let modelInstancedOpaquePipeline = InitModelInstancedOpaquePipeline()
{
PipelineManager.shared.update(rendererPipeLine: modelInstancedOpaquePipeline, forType: .modelInstancedOpaque)
}
if let lightPipeline = InitLightPipeline() {
PipelineManager.shared.update(rendererPipeLine: lightPipeline, forType: .light)
}
Expand Down Expand Up @@ -1381,6 +1392,42 @@ func initIBLResources() {
}
}

/// The vertex streams of an instanced shadow draw: the position stream alone, laid out as
/// `createShadowVertexDescriptor` lays it out.
func createShadowInstancedVertexDescriptor() -> MTLVertexDescriptor {
let vertexDescriptor = MTLVertexDescriptor()
let position = Int(shadowPassModelPositionIndex.rawValue)
vertexDescriptor.attributes[position].format = .float4
vertexDescriptor.attributes[position].bufferIndex = position
vertexDescriptor.attributes[position].offset = 0
vertexDescriptor.layouts[position].stride = MemoryLayout<simd_float4>.stride
vertexDescriptor.layouts[position].stepFunction = .perVertex
vertexDescriptor.layouts[position].stepRate = 1
return vertexDescriptor
}

/// The vertex streams of an instanced model draw: position, normal, texture coordinates
/// and tangent, laid out as `createModelVertexDescriptor` lays them out. A mesh drawn
/// instanced has no skeleton, so the joint streams are not part of the draw.
func createModelInstancedVertexDescriptor() -> MTLVertexDescriptor {
let vertexDescriptor = MTLVertexDescriptor()
let streams: [(index: Int, format: MTLVertexFormat, stride: Int)] = [
(Int(modelPassVerticesIndex.rawValue), .float4, MemoryLayout<simd_float4>.stride),
(Int(modelPassNormalIndex.rawValue), .float4, MemoryLayout<simd_float4>.stride),
(Int(modelPassUVIndex.rawValue), .float2, MemoryLayout<simd_float2>.stride),
(Int(modelPassTangentIndex.rawValue), .float4, MemoryLayout<simd_float4>.stride),
]
for stream in streams {
vertexDescriptor.attributes[stream.index].format = stream.format
vertexDescriptor.attributes[stream.index].bufferIndex = stream.index
vertexDescriptor.attributes[stream.index].offset = 0
vertexDescriptor.layouts[stream.index].stride = stream.stride
vertexDescriptor.layouts[stream.index].stepFunction = .perVertex
vertexDescriptor.layouts[stream.index].stepRate = 1
}
return vertexDescriptor
}

func createShadowVertexDescriptor() -> MTLVertexDescriptor {
// set the vertex descriptor
let vertexDescriptor = MTLVertexDescriptor()
Expand Down
Loading
Loading