Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -1002,7 +1002,8 @@ public func InitLookPipeline() -> RenderPipeline? {
fragmentShader: "fragmentLookShader",
vertexDescriptor: createPostProcessVertexDescriptor(),
colorFormats: [wf.lookOutput],
depthFormat: renderInfo.depthPixelFormat,
// The look pass draws into its own descriptor, colour alone: no depth to match.
depthFormat: .invalid,
depthEnabled: false,
name: "Look Pipeline"
)
Expand Down
19 changes: 14 additions & 5 deletions Sources/UntoldEngine/Renderer/RenderInitializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,15 @@ func initRenderPassDescriptors() {
depthAttachment: (textureResources.depthMap, .dontCare, .store, sceneDepthClearValue())
)

// The look pass: its colour alone (see RenderInfo.lookRenderPassDescriptor).
renderInfo.lookRenderPassDescriptor = createRenderPassDescriptor(
width: Int(renderInfo.viewPort.x),
height: Int(renderInfo.viewPort.y),
colorAttachments: [
(textureResources.lookTexture, .clear, .store, MTLClearColorMake(0.0, 0.0, 0.0, 0.0)),
]
)

// Gizmo Render Pass
renderInfo.gizmoRenderPassDescriptor = createRenderPassDescriptor(
width: Int(renderInfo.viewPort.x),
Expand Down Expand Up @@ -938,7 +947,7 @@ func initTextureResources() {
pixelFormat: wf.sceneColor,
width: viewportWidth,
height: viewportHeight,
usage: [.shaderRead, .renderTarget, .shaderWrite],
usage: [.shaderRead, .renderTarget], // no shaderWrite: it would forbid the GPU's lossless compression
storageMode: .shared
)

Expand Down Expand Up @@ -1021,7 +1030,7 @@ func initTextureResources() {
pixelFormat: wf.environment,
width: viewportWidth,
height: viewportHeight,
usage: [.shaderRead, .renderTarget, .shaderWrite],
usage: [.shaderRead, .renderTarget], // no shaderWrite: it would forbid the GPU's lossless compression
storageMode: .shared
)

Expand All @@ -1042,7 +1051,7 @@ func initTextureResources() {
pixelFormat: wf.sceneComposite,
width: viewportWidth,
height: viewportHeight,
usage: [.shaderRead, .renderTarget, .shaderWrite],
usage: [.shaderRead, .renderTarget], // no shaderWrite: it would forbid the GPU's lossless compression
storageMode: .shared
)

Expand All @@ -1052,7 +1061,7 @@ func initTextureResources() {
pixelFormat: wf.lookOutput,
width: viewportWidth,
height: viewportHeight,
usage: [.shaderRead, .renderTarget, .shaderWrite],
usage: [.shaderRead, .renderTarget], // no shaderWrite: it would forbid the GPU's lossless compression
storageMode: .shared
)

Expand All @@ -1062,7 +1071,7 @@ func initTextureResources() {
pixelFormat: wf.lookOutput,
width: viewportWidth,
height: viewportHeight,
usage: [.shaderRead, .renderTarget, .shaderWrite],
usage: [.shaderRead, .renderTarget], // no shaderWrite: it would forbid the GPU's lossless compression
storageMode: .shared
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ public extension RenderPasses {
) {
renderEncoder.setDepthStencilState(depthState)
}
renderEncoder.waitForFence(renderInfo.fence, before: .vertex)
renderEncoder.waitForFence(renderInfo.fence, before: .fragment)
renderEncoder.setCullMode(.none)

var viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace)
Expand Down
205 changes: 108 additions & 97 deletions Sources/UntoldEngine/Renderer/RenderPasses.swift

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions Sources/UntoldEngine/Renderer/RenderResources.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ public struct RenderInfo {
public var forwardRenderPassDescriptor: MTLRenderPassDescriptor!
public var gaussianRenderPassDescriptor: MTLRenderPassDescriptor!
public var postProcessRenderPassDescriptor: MTLRenderPassDescriptor!
/// The look pass's descriptor: its colour alone, no depth attachment. The post-process
/// descriptor carries the frame's depth for the passes that read it; the look pass does
/// not, and a depth attachment loaded and stored for nothing is a full-size read and
/// write per eye.
public var lookRenderPassDescriptor: MTLRenderPassDescriptor!
// CSM: one render pass descriptor per cascade (3 total), each targeting a separate array slice.
public var csmRenderPassDescriptors: [MTLRenderPassDescriptor] = []
public var pointShadowRenderPassDescriptors: [MTLRenderPassDescriptor] = []
Expand Down Expand Up @@ -119,9 +124,9 @@ public struct RenderInfo {
/// The opaque pass's descriptor for the pipelines as built: the forward one when the
/// geometry lights itself, the G-buffer's otherwise.
var opaquePassDescriptor: MTLRenderPassDescriptor? { perFragmentLightingBuilt ? forwardRenderPassDescriptor : offscreenRenderPassDescriptor }
/// The mesh entities the frame's culling put to the occlusion test (the culling set's
/// size), which decides with `HZBOcclusionCulling.minimumEntities` whether the frame's
/// depth is worth a pyramid.
/// The objects the frame's culling put to the occlusion test: entities with their
/// children, so a model's parts count as one. With `HZBOcclusionCulling.minimumEntities`
/// it decides whether the frame's depth is worth a pyramid.
public var hzbCandidateCount: Int = 0
/// Whether the output transform pass follows the temporal resolve directly in the frame's
/// graph (nothing registered between them), so the resolve may present the frame itself
Expand Down
15 changes: 15 additions & 0 deletions Sources/UntoldEngine/Renderer/RenderSceneSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ struct RenderSceneSnapshot {
component(.tileLODTag, pools.tileLODTag, of: entity)
}

/// The object `entity` is drawn as a part of: its parent in the scenegraph when it has
/// one, else itself. A model loaded from a file is one entity per node under the entity
/// the model was set on, so the parts of one model share this.
@inline(__always)
func object(of entity: Entity) -> EntityID {
guard entities[entity.index].mask.test(ids.scenegraph),
let scenegraph = pools.scenegraph?.component(at: entity.index, as: ScenegraphComponent.self),
scenegraph.parent != .invalid
else { return entity.entityId }
return scenegraph.parent
}

/// The scene channels of `entity`: those of its channel component, or the ones an
/// entity without the component gets from its name.
@inline(__always)
Expand Down Expand Up @@ -208,6 +220,7 @@ struct RenderSceneSnapshot {
let deformation = getComponentId(for: DeformationComponent.self)
let skeleton = getComponentId(for: SkeletonComponent.self)
let tileLODTag = getComponentId(for: TileLODTagComponent.self)
let scenegraph = getComponentId(for: ScenegraphComponent.self)

@inline(__always)
func traits(of mask: ComponentMask) -> RenderEntityTraits {
Expand Down Expand Up @@ -245,6 +258,7 @@ struct RenderSceneSnapshot {
let tileRepresentationFade: ComponentPool?
let deformation: ComponentPool?
let tileLODTag: ComponentPool?
let scenegraph: ComponentPool?

init(ids: ComponentIds, scene: Scene) {
render = scene.componentPool[ids.render]
Expand All @@ -257,6 +271,7 @@ struct RenderSceneSnapshot {
tileRepresentationFade = scene.componentPool[ids.tileRepresentationFade]
deformation = scene.componentPool[ids.deformation]
tileLODTag = scene.componentPool[ids.tileLODTag]
scenegraph = scene.componentPool[ids.scenegraph]
}
}
}
Expand Down
34 changes: 23 additions & 11 deletions Sources/UntoldEngine/Renderer/UntoldEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,17 +211,12 @@ public class UntoldRenderer: NSObject, MTKViewDelegate {

initScriptingSystem()

// Create defautls objects.
let gameCamera = createEntity()
setEntityName(entityId: gameCamera, name: "Main Camera")
createGameCamera(entityId: gameCamera)

let light = createEntity()
setEntityName(entityId: light, name: "Directional Light")
createDirLight(entityId: light)

setCamera(.active(gameCamera))
setDirectionalLight(.active(light))
// The default objects: a camera, which every path needs (on the headset it carries the
// head pose), and a sun, which the configuration decides and the headset by default
// does not get: in passthrough the room's light is the ARKit probe's, and a sun the room
// does not have would light and shadow the scene until the app replaced it; in full
// immersion the environment carries the light, and a sun is the app's to add.
createDefaultEntities(sun: configuration.defaultSun ?? !renderInfo.isXRStereoMode)

#if os(visionOS)
BatchingSystem.shared.applyRuntimeBatchingTuning(.visionOSBalanced)
Expand All @@ -232,6 +227,23 @@ public class UntoldRenderer: NSObject, MTKViewDelegate {
Logger.log(message: "Untold Engine Starting. Version 0.23.0")
}

/// The default camera, made active, and with `sun` the default directional light, made
/// active too: what a scene has before the app adds anything.
@discardableResult
func createDefaultEntities(sun: Bool) -> (camera: EntityID, sun: EntityID?) {
let gameCamera = createEntity()
setEntityName(entityId: gameCamera, name: "Main Camera")
createGameCamera(entityId: gameCamera)
setCamera(.active(gameCamera))
guard sun else { return (gameCamera, nil) }

let light = createEntity()
setEntityName(entityId: light, name: "Directional Light")
createDirLight(entityId: light)
setDirectionalLight(.active(light))
return (gameCamera, light)
}

public func initSizeableResources() {
if renderInfo.viewPort.x == 0 || renderInfo.viewPort.y == 0 { return }

Expand Down
10 changes: 9 additions & 1 deletion Sources/UntoldEngine/Renderer/UntoldRendererConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,25 @@ public struct UntoldRendererConfig {
var updateRenderingSystemCallback: UpdateRenderingSystemCallback

var updateXRRenderingSystemCallback: UpdateXRRenderingSystemCallback?
/// Whether the scene starts with a directional light, made active, beside its camera.
/// Unset, the platform decides: a sun on every path but the headset, where the
/// passthrough room is lit by its probe and the immersive environment by its own light,
/// so a sun is the app's to add. A project sets it to keep a sun on the headset (for
/// shadows its environment cannot cast) or to start without one elsewhere.
public var defaultSun: Bool?

public init(
metalView: MTKView? = nil,
initPipelineBlocks: [(RenderPipelineType, RenderPipelineInitBlock)],
updateRenderingSystemCallback: @escaping UpdateRenderingSystemCallback,
updateXRRenderingSystemCallback: UpdateXRRenderingSystemCallback? = nil
updateXRRenderingSystemCallback: UpdateXRRenderingSystemCallback? = nil,
defaultSun: Bool? = nil
) {
self.metalView = metalView
initRenderPipelineBlocks = initPipelineBlocks
self.updateRenderingSystemCallback = updateRenderingSystemCallback
self.updateXRRenderingSystemCallback = updateXRRenderingSystemCallback
self.defaultSun = defaultSun
}
}

Expand Down
34 changes: 24 additions & 10 deletions Sources/UntoldEngine/Systems/CullingSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,8 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) {

var entityAABBContainer: [EntityAABB] = []
entityAABBContainer.reserveCapacity(sceneSnapshot.entityCapacity) // Pre-allocate to avoid reallocation churn
// The objects the entities belong to (an entity with its children), for the pyramid's gate.
var objects = Set<EntityID>()

let smallObjectCulling = SmallObjectCulling.forCurrentFrame()

Expand Down Expand Up @@ -690,6 +692,8 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) {
}
}

objects.insert(sceneSnapshot.object(of: entity))

// In XR stereo mode, use a single AABB per entity. The tighter sub-AABBs
// produced by segmentation amplify temporal false-culling from 90 Hz head-
// tracking drift and cause segment-level occlusion popping. The unsegmented
Expand Down Expand Up @@ -719,6 +723,7 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) {
}

let count = entityAABBContainer.count
let objectCount = objects.count

// ensure we have enough capacity
entityAABBTripleBuffer.ensureCapacity(count)
Expand Down Expand Up @@ -803,7 +808,7 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) {
// few centimetres apart, the frames a camera step. The per-eye pyramids
// (hzbDepthPyramidEye) are allocated, but nothing renders the per-eye depth they
// would be built from.
renderInfo.hzbCandidateCount = count
renderInfo.hzbCandidateCount = objectCount
let ran0 = executeHZBOcclusionCulling(
commandBuffer,
cameraPosition: eyePosition(ofView: SceneRootTransform.shared.effectiveViewMatrix(renderInfo.xrEye0View)),
Expand Down Expand Up @@ -860,7 +865,7 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) {

} else {
// Mono path (macOS / non-stereo)
renderInfo.hzbCandidateCount = count
renderInfo.hzbCandidateCount = objectCount
let didRunOcclusion = executeHZBOcclusionCulling(
commandBuffer,
cameraPosition: eyePosition(ofView: effectiveViewMatrix),
Expand Down Expand Up @@ -990,6 +995,8 @@ func executeReduceScanFrustumCulling(_ commandBuffer: MTLCommandBuffer) {

var entityAABBContainer: [EntityAABB] = []
entityAABBContainer.reserveCapacity(sceneSnapshot.entityCapacity) // Pre-allocate to avoid reallocation churn
// The objects the entities belong to (an entity with its children), for the pyramid's gate.
var objects = Set<EntityID>()

let smallObjectCulling = SmallObjectCulling.forCurrentFrame()

Expand Down Expand Up @@ -1025,6 +1032,8 @@ func executeReduceScanFrustumCulling(_ commandBuffer: MTLCommandBuffer) {
}
}

objects.insert(sceneSnapshot.object(of: entity))

// get object AABB — elongated meshes are split into segments.
let segments = makeSegmentedEntityAABBs(
localMin: localTransformComponent.boundingBox.min,
Expand All @@ -1037,6 +1046,7 @@ func executeReduceScanFrustumCulling(_ commandBuffer: MTLCommandBuffer) {
}

let count = entityAABBContainer.count
let objectCount = objects.count

// ensure we have enough capacity
entityAABBTripleBuffer.ensureCapacity(count)
Expand Down Expand Up @@ -1174,7 +1184,7 @@ func executeReduceScanFrustumCulling(_ commandBuffer: MTLCommandBuffer) {
let finalVisibleCountBuffer = visibleCountTriple.bufferForWrite(frame: submitFrameIndex)
let finalVisibilityBuffer = visibilityTriple.bufferForWrite(frame: submitFrameIndex)

renderInfo.hzbCandidateCount = count
renderInfo.hzbCandidateCount = objectCount
let didRunOcclusion = executeHZBOcclusionCulling(
commandBuffer,
cameraPosition: eyePosition(ofView: effectiveViewMatrix),
Expand Down Expand Up @@ -1252,15 +1262,18 @@ public enum HZBOcclusionCulling {
/// `setRendering(.occlusionCullingMaxCameraStep(_:))`.
nonisolated(unsafe) static var maxCameraStep: Float = 0.5

/// The fewest mesh entities in the culling set for which the test, and the depth pyramid
/// it reads, built from a copy of the frame's depth every frame, are worth their cost:
/// below it nothing can hide anything. 2 by default, since one entity hides nothing but
/// itself. A frame with splat entities keeps the pyramid regardless: their cull reads it.
/// Set it with `setRendering(.occlusionCullingMinimumEntities(_:))`.
/// The fewest objects in the culling set for which the test, and the depth pyramid it
/// reads, built from a copy of the frame's depth every frame, are worth their cost: below
/// it nothing can hide anything. An object is an entity with its children: a model loaded
/// from a file is one entity per node under the entity it was set on, and the test
/// never hides one part of a model behind another, their bounds overlapping. 2 by
/// default, since one object hides nothing but itself. A frame with splat entities keeps
/// the pyramid regardless: their cull reads it. Set it with
/// `setRendering(.occlusionCullingMinimumEntities(_:))`.
nonisolated(unsafe) static var minimumEntities: Int = 2

/// Whether the frame's depth is worth a pyramid: the test is on, and the scene has enough
/// mesh entities or any splats for it to hide something next frame.
/// objects or any splats for it to hide something next frame.
static func pyramidWanted() -> Bool {
guard maxCameraStep > 0 else { return false }
if renderInfo.hzbCandidateCount >= minimumEntities { return true }
Expand All @@ -1269,7 +1282,8 @@ public enum HZBOcclusionCulling {
}
}

/// The fewest mesh entities for which the occlusion test runs and its depth pyramid is built.
/// The fewest objects (entities with their children) for which the occlusion test runs and
/// its depth pyramid is built.
public func getOcclusionCullingMinimumEntities() -> Int {
HZBOcclusionCulling.minimumEntities
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/UntoldEngine/Systems/RenderingSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1437,7 +1437,7 @@ private func lookPresentingPass(source: MTLTexture, destination: MTLTexture) ->
if let depthState = pipeline.depthState {
encoder.setDepthStencilState(depthState)
}
encoder.waitForFence(renderInfo.fence, before: .vertex)
encoder.waitForFence(renderInfo.fence, before: .fragment)
encoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0)
encoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1)
encoder.setFragmentTexture(source, index: 0)
Expand Down Expand Up @@ -1500,6 +1500,7 @@ public let lookRenderPass: RenderPasses.RenderPassExecution = { commandBuffer in
pipeline,
source: sourceTexture,
destination: destinationTexture,
descriptor: renderInfo.lookRenderPassDescriptor,
customization: colorGradingCustomization
)(commandBuffer)
return
Expand Down
3 changes: 3 additions & 0 deletions Sources/UntoldEngine/Systems/ShadowSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,9 @@ struct ShadowSystem {
return
}
guard directionalLight.castsShadow else { return }
// A sun at no intensity lights nothing, and the shading multiplies its shadow by that
// intensity: its cascades would be drawn, two depth maps a frame, for nothing.
guard (scene.get(component: LightComponent.self, for: lightEntity)?.intensity ?? 0) > 0 else { return }

// Rotate into visual-world space to match the root-rotation-aware corners/caster bounds
// below -- the light entity's own transform is scene-local, same as every other entity.
Expand Down
8 changes: 5 additions & 3 deletions Sources/UntoldEngine/Utils/EngineSettingsAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,11 @@ public enum RenderingProperty: Sendable {
/// frustum keeps. In scene units; the default is 0.5. See
/// `getOcclusionCullingMaxCameraStep()` to read the current value.
case occlusionCullingMaxCameraStep(Float)
/// The fewest mesh entities in the culling set for which the occlusion test runs and the
/// depth pyramid it reads is built and its depth copied, every frame. 2 by default: one
/// entity hides nothing but itself. A scene with splats keeps the pyramid regardless.
/// The fewest objects in the culling set for which the occlusion test runs and the depth
/// pyramid it reads is built and its depth copied, every frame. An entity with its
/// children is one object: the parts of a model loaded from a file hide nothing but
/// themselves. 2 by default: one object hides nothing but itself. A scene with splats
/// keeps the pyramid regardless.
case occlusionCullingMinimumEntities(Int)
/// Whether the XR frame is drawn through the rasterization rate maps the visionOS
/// compositor provides when the layer is configured with foveation enabled
Expand Down
Loading