From 91de20efc3924267f698004d2e6d7d05edb3d462 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 15 Oct 2026 13:06:06 +0200 Subject: [PATCH 1/9] [Patch] The depth pyramid's gate counts objects, not the parts of a model A model loaded from a file is one entity per node under the entity it was set on, so the viewer's single dartboard put many mesh entities into the culling set, and the pyramid's gate (minimumEntities, 2 by default) built the depth copy and the pyramid every frame, and ran the test for an object that hides nothing but itself: on the headset 1 to 2.5 ms of a 13 to 15 ms frame. The culling set now counts objects: an entity with its children, the snapshot's object(of:) being the entity's scenegraph parent or the entity itself. Both culling paths set hzbCandidateCount to that count; the gate, the setting and the accessor are documented in those terms. A render test puts three parts under one entity beside a box and sees two objects: the pyramid at a minimum of two, none at three, where four entities would have built it. --- .../Renderer/RenderResources.swift | 6 ++-- .../Renderer/RenderSceneSnapshot.swift | 15 ++++++++ .../UntoldEngine/Systems/CullingSystem.swift | 34 +++++++++++++------ .../Utils/EngineSettingsAPI.swift | 8 +++-- .../HZBPyramidGateTests.swift | 31 +++++++++++++++-- 5 files changed, 76 insertions(+), 18 deletions(-) diff --git a/Sources/UntoldEngine/Renderer/RenderResources.swift b/Sources/UntoldEngine/Renderer/RenderResources.swift index a8c855450..e6ca52660 100644 --- a/Sources/UntoldEngine/Renderer/RenderResources.swift +++ b/Sources/UntoldEngine/Renderer/RenderResources.swift @@ -119,9 +119,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 diff --git a/Sources/UntoldEngine/Renderer/RenderSceneSnapshot.swift b/Sources/UntoldEngine/Renderer/RenderSceneSnapshot.swift index b707dcacf..3178ede92 100644 --- a/Sources/UntoldEngine/Renderer/RenderSceneSnapshot.swift +++ b/Sources/UntoldEngine/Renderer/RenderSceneSnapshot.swift @@ -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) @@ -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 { @@ -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] @@ -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] } } } diff --git a/Sources/UntoldEngine/Systems/CullingSystem.swift b/Sources/UntoldEngine/Systems/CullingSystem.swift index d6ae15cbe..39b116ffa 100644 --- a/Sources/UntoldEngine/Systems/CullingSystem.swift +++ b/Sources/UntoldEngine/Systems/CullingSystem.swift @@ -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() let smallObjectCulling = SmallObjectCulling.forCurrentFrame() @@ -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 @@ -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) @@ -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)), @@ -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), @@ -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() let smallObjectCulling = SmallObjectCulling.forCurrentFrame() @@ -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, @@ -1037,6 +1046,7 @@ func executeReduceScanFrustumCulling(_ commandBuffer: MTLCommandBuffer) { } let count = entityAABBContainer.count + let objectCount = objects.count // ensure we have enough capacity entityAABBTripleBuffer.ensureCapacity(count) @@ -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), @@ -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 } @@ -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 } diff --git a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift index 2b8f4741a..5697aaa67 100644 --- a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift +++ b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift @@ -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 diff --git a/Tests/UntoldEngineRenderTests/HZBPyramidGateTests.swift b/Tests/UntoldEngineRenderTests/HZBPyramidGateTests.swift index 525901cd9..24bd9fff7 100644 --- a/Tests/UntoldEngineRenderTests/HZBPyramidGateTests.swift +++ b/Tests/UntoldEngineRenderTests/HZBPyramidGateTests.swift @@ -14,8 +14,9 @@ import simd import XCTest /// The depth pyramid, and the depth copy that feeds it, are built only for a frame whose depth -/// could hide something next frame: the occlusion test on, and enough mesh entities (or any -/// splats) in the scene. The GPU's own pass timing says which passes ran. +/// could hide something next frame: the occlusion test on, and enough objects (entities with +/// their children, or any splats) in the scene. The GPU's own pass timing says which passes +/// ran. @MainActor final class HZBPyramidGateTests: BaseRenderSetup { private var savedMinimum = 2 @@ -96,4 +97,30 @@ final class HZBPyramidGateTests: BaseRenderSetup { XCTAssertFalse(passes.contains("HZB"), "the test off: no pyramid") XCTAssertFalse(passes.contains("DepthCopy")) } + + func testTheModelsPartsCountAsOneObject() throws { + try XCTSkipUnless(renderInfo.device.supportsCounterSampling(.atStageBoundary), "no pass timing here") + // A model loaded from a file is one entity per node under the entity it was set on: + // its parts hide nothing but themselves, so they count as one object, like the box. + let model = createEntity() + for part in 0 ..< 3 { + let entity = createEntity() + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createCube(extent: 0.3), assetName: "Part\(part)") + setParent(childId: entity, parentId: model, offset: simd_float3(0.8 + Float(part) * 0.4, 0.5, -1)) + } + setVisibleEntities() + setRendering(.occlusionCullingMinimumEntities(2)) + setRendering(.occlusionCullingMaxCameraStep(0.5)) + var passes = passesRun() + XCTAssertEqual(renderInfo.hzbCandidateCount, 2, "the box and the model, not the box and three parts") + XCTAssertTrue(passes.contains("HZB"), "two objects: the pyramid is built: \(passes.sorted())") + + // Three parts and a box are four entities but two objects: below a minimum of three. + setRendering(.occlusionCullingMinimumEntities(3)) + passes = passesRun() + XCTAssertEqual(renderInfo.hzbCandidateCount, 2) + XCTAssertFalse(passes.contains("HZB"), "two objects below a minimum of three: no pyramid: \(passes.sorted())") + XCTAssertFalse(passes.contains("DepthCopy")) + XCTAssertFalse(renderInfo.hzbIsValid) + } } From 2ac8994d26dc1078e93d8e6215256344a073c29f Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 15 Oct 2026 13:48:44 +0200 Subject: [PATCH 2/9] [Patch] No transparency pass for a frame with nothing transparent to draw The pass made its encoder, loading and storing the lit colour and the depth of the whole frame, before it looked for a transparent surface: with none, every eye paid the encoder, its bindings and three light uploads to draw nothing. The draw list is gathered first now and the pass returns when it is empty, as the wireframe and occluder-shell passes do. A render test reads the GPU's own pass labels: no "Transparency" with an opaque box, the pass once its material blends. --- .../UntoldEngine/Renderer/RenderPasses.swift | 143 +++++++++--------- .../TransparencyPassGateTests.swift | 72 +++++++++ 2 files changed, 146 insertions(+), 69 deletions(-) create mode 100644 Tests/UntoldEngineRenderTests/TransparencyPassGateTests.swift diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 4f0edee75..d77ccb7a6 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -3852,6 +3852,80 @@ public enum RenderPasses { return } + let effectiveCameraPosition = shadingCameraPosition(cameraComponent) + + // What every draw of the pass shares, read once. + let sceneSnapshot = RenderSceneSnapshot() + let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) + let projectionMatrix = renderInfo.perspectiveSpace + let batchingEnabled = BatchingSystem.shared.isEnabled() + let colorsByLOD = SpatialDebugVisualization.shared.colorRenderablesByLOD + let colorsByStreamingTier = SpatialDebugVisualization.shared.colorRenderablesByStreamingTier + var renderModes = SceneChannelRenderModeMemo() + + // Build and sort transparent draw items back-to-front for correct alpha blending. + var transparentEntities: [( + entityId: EntityID, + render: RenderComponent, + world: WorldTransformComponent, + deformation: DeformationComponent?, + hasSkeleton: Bool, + distanceSq: Float + )] = [] + + for entityId in visibleEntityIds { + guard let entity = sceneSnapshot.entity(entityId) else { continue } + let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) + if renderMode == .hidden || renderMode == .wireframe { continue } + + if batchingEnabled, BatchingSystem.shared.isBatched(entityId: entityId) { + continue + } + + if !entity.traits.isDisjoint(with: [.sceneCamera, .camera, .gizmo, .light]) { continue } + // Blend submeshes go with the colour (there is no dither path here). + if meshSkipsColor(entity, in: sceneSnapshot) { continue } + + guard let renderComponent = sceneSnapshot.render(of: entity) else { + continue + } + guard let worldTransformComponent = sceneSnapshot.worldTransform(of: entity) else { + continue + } + + let hasTransparentSubmesh = renderComponent.mesh.contains { mesh in + mesh.submeshes.contains { submesh in + submesh.material?.hasTransparency ?? false + } + } + + if !hasTransparentSubmesh { + continue + } + + let worldPosition = simd_float3( + worldTransformComponent.space.columns.3.x, + worldTransformComponent.space.columns.3.y, + worldTransformComponent.space.columns.3.z + ) + let distanceSq = simd_length_squared(effectiveCameraPosition - worldPosition) + transparentEntities.append(( + entityId: entityId, + render: renderComponent, + world: worldTransformComponent, + deformation: sceneSnapshot.deformation(of: entity), + hasSkeleton: entity.traits.contains(.skeleton), + distanceSq: distanceSq + )) + } + + transparentEntities.sort { $0.distanceSq > $1.distanceSq } + + // No transparent surface, no pass: the encoder would load and store the lit colour and + // the depth of the whole frame to draw nothing, as the wireframe and occluder passes + // also refuse. + guard !transparentEntities.isEmpty else { return } + guard let encoderDescriptor = renderInfo.deferredRenderPassDescriptor else { handleError(.renderPassCreationFailed, "Deferred render pass descriptor not initialized") return @@ -3994,75 +4068,6 @@ public enum RenderPasses { index: Int(transparencyPassIBLRotationAngleIndex.rawValue) ) - let effectiveCameraPosition = shadingCameraPosition(cameraComponent) - - // What every draw of the pass shares, read once. - let sceneSnapshot = RenderSceneSnapshot() - let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) - let projectionMatrix = renderInfo.perspectiveSpace - let batchingEnabled = BatchingSystem.shared.isEnabled() - let colorsByLOD = SpatialDebugVisualization.shared.colorRenderablesByLOD - let colorsByStreamingTier = SpatialDebugVisualization.shared.colorRenderablesByStreamingTier - var renderModes = SceneChannelRenderModeMemo() - - // Build and sort transparent draw items back-to-front for correct alpha blending. - var transparentEntities: [( - entityId: EntityID, - render: RenderComponent, - world: WorldTransformComponent, - deformation: DeformationComponent?, - hasSkeleton: Bool, - distanceSq: Float - )] = [] - - for entityId in visibleEntityIds { - guard let entity = sceneSnapshot.entity(entityId) else { continue } - let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) - if renderMode == .hidden || renderMode == .wireframe { continue } - - if batchingEnabled, BatchingSystem.shared.isBatched(entityId: entityId) { - continue - } - - if !entity.traits.isDisjoint(with: [.sceneCamera, .camera, .gizmo, .light]) { continue } - // Blend submeshes go with the colour (there is no dither path here). - if meshSkipsColor(entity, in: sceneSnapshot) { continue } - - guard let renderComponent = sceneSnapshot.render(of: entity) else { - continue - } - guard let worldTransformComponent = sceneSnapshot.worldTransform(of: entity) else { - continue - } - - let hasTransparentSubmesh = renderComponent.mesh.contains { mesh in - mesh.submeshes.contains { submesh in - submesh.material?.hasTransparency ?? false - } - } - - if !hasTransparentSubmesh { - continue - } - - let worldPosition = simd_float3( - worldTransformComponent.space.columns.3.x, - worldTransformComponent.space.columns.3.y, - worldTransformComponent.space.columns.3.z - ) - let distanceSq = simd_length_squared(effectiveCameraPosition - worldPosition) - transparentEntities.append(( - entityId: entityId, - render: renderComponent, - world: worldTransformComponent, - deformation: sceneSnapshot.deformation(of: entity), - hasSkeleton: entity.traits.contains(.skeleton), - distanceSq: distanceSq - )) - } - - transparentEntities.sort { $0.distanceSq > $1.distanceSq } - for entry in transparentEntities { let entityId = entry.entityId let renderComponent = entry.render diff --git a/Tests/UntoldEngineRenderTests/TransparencyPassGateTests.swift b/Tests/UntoldEngineRenderTests/TransparencyPassGateTests.swift new file mode 100644 index 000000000..0e09cf4b5 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/TransparencyPassGateTests.swift @@ -0,0 +1,72 @@ +// +// TransparencyPassGateTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Metal +import simd +@testable import UntoldEngine +import XCTest + +/// The transparency pass runs only on a frame with a transparent surface to draw: its +/// encoder loads and stores the lit colour and the depth of the whole frame, which a frame +/// with nothing transparent would pay to draw nothing. The GPU's own pass timing says which +/// passes ran. +@MainActor +final class TransparencyPassGateTests: BaseRenderSetup { + private var box: EntityID = .invalid + + override func setUp() async throws { + try await super.setUp() + setRendering(.gpuPassTiming(.enabled)) + } + + override func tearDown() async throws { + setRendering(.gpuPassTiming(.disabled)) + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() { + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + cameraLookAt(entityId: camera, eye: simd_float3(0, 1, 4), target: .zero, up: simd_float3(0, 1, 0)) + box = createEntity() + setEntityMeshDirect(entityId: box, meshes: BasicPrimitives.createCube(extent: 1.0), assetName: "Box") + translateTo(entityId: box, position: simd_float3(0, 0.5, 0)) + let sun = createEntity() + createDirLight(entityId: sun) + rotateTo(entityId: sun, pitch: -40, yaw: 30, roll: 0) + } + + /// The labels of the passes the last frame ran, after a few frames. + private func passesRun() -> Set { + for _ in 0 ..< 4 { + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + } + return Set(GPUPassTiming.shared.lastFrame().passes.map(\.label)) + } + + func testNothingTransparentNoPass() throws { + try XCTSkipUnless(renderInfo.device.supportsCounterSampling(.atStageBoundary), "no pass timing here") + var passes = passesRun() + XCTAssertFalse(passes.contains("Transparency"), "nothing transparent: no pass: \(passes.sorted())") + + // A blended material on the box: the pass has a surface to draw. + let render = try XCTUnwrap(scene.get(component: RenderComponent.self, for: box)) + for mesh in render.mesh.indices { + for submesh in render.mesh[mesh].submeshes.indices { + render.mesh[mesh].submeshes[submesh].material?.alphaMode = .blend + } + } + passes = passesRun() + XCTAssertTrue(passes.contains("Transparency"), "a blended surface: the pass runs: \(passes.sorted())") + } +} From f71d96b0456220b023cfd04314e592e6d3c71e31 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 15 Oct 2026 13:48:45 +0200 Subject: [PATCH 3/9] [Patch] The scene passes wait for the fence before their fragment stage, not their vertex stage Every scene pass waited on the shared fence before its vertex stage, after the previous pass updated it after its fragment stage: the tiler of one pass could not run while the renderer of the previous one shaded, and the Forward pass's binning of a model's many parts (3.5 ms of vertex work on the headset) sat behind the shadows' and culling's fragments. A pass's vertex stage reads nothing a previous pass's fragments wrote (meshes, quads, joint streams), and every resource is hazard-tracked, so the wait moves to the fragment stage at all 23 sites, as the temporal resolve already had it; every update stays as it was. --- .../Renderer/RenderPasses+MuscleDebug.swift | 2 +- .../UntoldEngine/Renderer/RenderPasses.swift | 42 +++++++++---------- .../Systems/RenderingSystem.swift | 2 +- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Sources/UntoldEngine/Renderer/RenderPasses+MuscleDebug.swift b/Sources/UntoldEngine/Renderer/RenderPasses+MuscleDebug.swift index 544c16366..be1ff968a 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses+MuscleDebug.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses+MuscleDebug.swift @@ -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) diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index d77ccb7a6..96c918759 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -1432,7 +1432,7 @@ public enum RenderPasses { renderEncoder.pushDebugGroup("Shadow Cascade \(cascadeIdx)") renderEncoder.setRenderPipelineState(shadowPipeline.pipelineState!) renderEncoder.setDepthStencilState(shadowPipeline.depthState!) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setDepthBias(0.0015, slopeScale: 0.75, clamp: 0.02) renderEncoder.setViewport( MTLViewport(originX: 0, originY: 0, @@ -1545,7 +1545,7 @@ public enum RenderPasses { renderEncoder.pushDebugGroup("Batched Shadow Cascade \(cascadeIdx)") renderEncoder.setRenderPipelineState(shadowPipeline.pipelineState!) renderEncoder.setDepthStencilState(shadowPipeline.depthState!) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setDepthBias(0.0015, slopeScale: 0.75, clamp: 0.02) renderEncoder.setViewport( MTLViewport(originX: 0, originY: 0, @@ -1634,7 +1634,7 @@ public enum RenderPasses { renderEncoder.pushDebugGroup("Spot Shadow") renderEncoder.setRenderPipelineState(shadowPipeline.pipelineState!) renderEncoder.setDepthStencilState(shadowPipeline.depthState!) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setDepthBias(0.0015, slopeScale: 0.75, clamp: 0.02) renderEncoder.setViewport( MTLViewport(originX: 0, originY: 0, @@ -1774,7 +1774,7 @@ public enum RenderPasses { renderEncoder.pushDebugGroup("Point Shadow Face \(face)") renderEncoder.setRenderPipelineState(shadowPipeline.pipelineState!) renderEncoder.setDepthStencilState(shadowPipeline.depthState!) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setDepthBias(0.002, slopeScale: 0.9, clamp: 0.03) renderEncoder.setViewport( MTLViewport(originX: 0, originY: 0, @@ -1931,7 +1931,7 @@ public enum RenderPasses { renderEncoder.setRenderPipelineState(modelPipeline.pipelineState!) renderEncoder.setDepthStencilState(modelPipeline.depthState) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) // What every draw of the pass shares, read once. let pass = OpaquePassState(cameraComponent: cameraComponent) @@ -2200,7 +2200,7 @@ public enum RenderPasses { renderEncoder.setRenderPipelineState(modelPipeline.pipelineState!) renderEncoder.setDepthStencilState(modelPipeline.depthState) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) // Create identity uniform (batched vertices are already in world space) var batchUniforms = Uniforms() @@ -2535,7 +2535,7 @@ public enum RenderPasses { // ── Sub-pass 1: Unbatched geometry ────────────────────────────────────── renderEncoder.setRenderPipelineState(modelPipeline.pipelineState!) renderEncoder.setDepthStencilState(modelPipeline.depthState) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) for entityId in visibleEntityIds { guard let entity = sceneSnapshot.entity(entityId) else { continue } @@ -2954,7 +2954,7 @@ public enum RenderPasses { renderEncoder.setRenderPipelineState(ssaoPipeline.pipelineState!) // renderEncoder.setDepthStencilState(ssaoPipeline.depthState) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) renderEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) @@ -3063,7 +3063,7 @@ public enum RenderPasses { renderEncoder.setRenderPipelineState(ssaoBlurPipeline.pipelineState!) // renderEncoder.setDepthStencilState(ssaoBlurPipeline.depthState) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) renderEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) @@ -3186,7 +3186,7 @@ public enum RenderPasses { renderEncoder.pushDebugGroup("SSAO Low-Res Pass") renderEncoder.setRenderPipelineState(ssaoPipeline.pipelineState!) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) renderEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) @@ -3290,7 +3290,7 @@ public enum RenderPasses { renderEncoder.setRenderPipelineState(pipeline.pipelineState!) // renderEncoder.setDepthStencilState(pipeline.depthState) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) renderEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) @@ -3355,7 +3355,7 @@ public enum RenderPasses { renderEncoder.pushDebugGroup("SSAO Simple Blur Pass") renderEncoder.setRenderPipelineState(ssaoBlurPipeline.pipelineState!) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) renderEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) @@ -3407,7 +3407,7 @@ public enum RenderPasses { renderEncoder.pushDebugGroup("SSAO Upsample Pass") renderEncoder.setRenderPipelineState(upsamplePipeline.pipelineState!) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) renderEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) @@ -3501,7 +3501,7 @@ public enum RenderPasses { renderEncoder.pushDebugGroup("Light Pass") renderEncoder.setRenderPipelineState(lightPipeline.pipelineState!) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) renderEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) @@ -3765,7 +3765,7 @@ public enum RenderPasses { renderEncoder.setRenderPipelineState(preCompositePipeline.pipelineState!) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) renderEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) @@ -3968,7 +3968,7 @@ public enum RenderPasses { } else { renderEncoder.setDepthStencilState(transparencyPipeline.depthState) } - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) var transpCSM = shadowSystem.makeUniforms() transpCSM.cameraViewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) @@ -4320,7 +4320,7 @@ public enum RenderPasses { renderEncoder.pushDebugGroup("Mesh Occluder Shell Pass") renderEncoder.setRenderPipelineState(pipelineState) renderEncoder.setDepthStencilState(pipeline.depthState) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setCullMode(.none) renderEncoder.setTriangleFillMode(.fill) @@ -4463,7 +4463,7 @@ public enum RenderPasses { renderEncoder.pushDebugGroup("Wireframe Occlusion Depth Pass") renderEncoder.setRenderPipelineState(pipelineState) renderEncoder.setDepthStencilState(pipeline.depthState) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setCullMode(.none) renderEncoder.setTriangleFillMode(.fill) @@ -4662,7 +4662,7 @@ public enum RenderPasses { } else { renderEncoder.setDepthStencilState(wireframePipeline.depthState) } - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setCullMode(.none) var wireframeColor = wireframeSettings.color @@ -5034,7 +5034,7 @@ public enum RenderPasses { if let depthState = spatialDebugPipeline.depthState { 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) @@ -5350,7 +5350,7 @@ public enum RenderPasses { renderEncoder.setRenderPipelineState(pipeline.pipelineState!) - renderEncoder.waitForFence(renderInfo.fence, before: .vertex) + renderEncoder.waitForFence(renderInfo.fence, before: .fragment) renderEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) renderEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) diff --git a/Sources/UntoldEngine/Systems/RenderingSystem.swift b/Sources/UntoldEngine/Systems/RenderingSystem.swift index 44bdd75d9..1b8eb3362 100644 --- a/Sources/UntoldEngine/Systems/RenderingSystem.swift +++ b/Sources/UntoldEngine/Systems/RenderingSystem.swift @@ -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) From 2394ed066bcc707726aac377f39730f94f934714 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 15 Oct 2026 13:48:45 +0200 Subject: [PATCH 4/9] [Patch] The frame's colour textures without shaderWrite, so the GPU may compress them The lit colour (the MSAA resolve target), the environment, the composite, the look output and the anti-aliasing output were created with shaderWrite usage, which no kernel uses and which, per Apple's texture guidance, forbids the GPU's lossless compression of a texture: every pass that resolved into, read or wrote them moved uncompressed pixels. They keep their shared storage, so the tests' CPU reads stay as they were. --- Sources/UntoldEngine/Renderer/RenderInitializer.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/UntoldEngine/Renderer/RenderInitializer.swift b/Sources/UntoldEngine/Renderer/RenderInitializer.swift index 28b015e05..35bce4bc4 100644 --- a/Sources/UntoldEngine/Renderer/RenderInitializer.swift +++ b/Sources/UntoldEngine/Renderer/RenderInitializer.swift @@ -938,7 +938,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 ) @@ -1021,7 +1021,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 ) @@ -1042,7 +1042,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 ) @@ -1052,7 +1052,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 ) @@ -1062,7 +1062,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 ) From 79b043d8c535206a47fefef1c5fe4d22b1588441 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 15 Oct 2026 13:48:45 +0200 Subject: [PATCH 5/9] [Patch] The look pass draws into its own descriptor, colour alone The look pass drew through the post-process descriptor, which carries the frame's depth loaded and stored for the passes that read it as an attachment; the look pass never tests or writes depth, so each eye loaded and stored a full-size depth texture for nothing. It has its own descriptor now, colour alone, its pipeline built with no depth attachment; executePostProcess takes the descriptor to draw into and sets the depth load only when there is one. The other post-process passes keep the frame's depth. A render test checks the descriptor and that the look output still carries the lit frame. --- .../Renderer/Pipelines/RenderPipeLines.swift | 3 +- .../Renderer/RenderInitializer.swift | 9 +++ .../UntoldEngine/Renderer/RenderPasses.swift | 20 +++--- .../Renderer/RenderResources.swift | 5 ++ .../Systems/RenderingSystem.swift | 1 + .../LookPassDepthTests.swift | 62 +++++++++++++++++++ 6 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 Tests/UntoldEngineRenderTests/LookPassDepthTests.swift diff --git a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift index b63b37dc8..b271c3ec4 100644 --- a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift +++ b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift @@ -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" ) diff --git a/Sources/UntoldEngine/Renderer/RenderInitializer.swift b/Sources/UntoldEngine/Renderer/RenderInitializer.swift index 35bce4bc4..f8744a75d 100644 --- a/Sources/UntoldEngine/Renderer/RenderInitializer.swift +++ b/Sources/UntoldEngine/Renderer/RenderInitializer.swift @@ -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), diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 96c918759..811250c03 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -5306,10 +5306,15 @@ public enum RenderPasses { #endif } + /// A full-screen pass of `pipeline` from `source` into `destination`. It draws into the + /// post-process descriptor, which carries the frame's depth loaded and stored, unless + /// `descriptor` names another: a pipeline built with no depth attachment (the look pass) + /// brings its own, colour alone. static func executePostProcess( _ pipeline: RenderPipeline, source: MTLTexture, destination: MTLTexture, + descriptor: MTLRenderPassDescriptor? = nil, customization: @escaping (_ encoder: MTLRenderCommandEncoder) -> Void ) -> (MTLCommandBuffer) -> Void { { commandBuffer in @@ -5318,17 +5323,18 @@ public enum RenderPasses { return } - renderInfo.postProcessRenderPassDescriptor.colorAttachments[0].texture = destination - - renderInfo.postProcessRenderPassDescriptor.colorAttachments[0].loadAction = .clear - renderInfo.postProcessRenderPassDescriptor.colorAttachments[0].storeAction = .store - renderInfo.postProcessRenderPassDescriptor.depthAttachment.loadAction = .load - - guard let renderPassDescriptor = renderInfo.postProcessRenderPassDescriptor else { + guard let renderPassDescriptor = descriptor ?? renderInfo.postProcessRenderPassDescriptor else { handleError(.renderPassCreationFailed, "Post-process render pass descriptor not initialized") return } + renderPassDescriptor.colorAttachments[0].texture = destination + renderPassDescriptor.colorAttachments[0].loadAction = .clear + renderPassDescriptor.colorAttachments[0].storeAction = .store + if renderPassDescriptor.depthAttachment.texture != nil { + renderPassDescriptor.depthAttachment.loadAction = .load + } + // set your encoder here GPUPassTiming.shared.attach(renderPassDescriptor, "Post") guard diff --git a/Sources/UntoldEngine/Renderer/RenderResources.swift b/Sources/UntoldEngine/Renderer/RenderResources.swift index e6ca52660..746935690 100644 --- a/Sources/UntoldEngine/Renderer/RenderResources.swift +++ b/Sources/UntoldEngine/Renderer/RenderResources.swift @@ -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] = [] diff --git a/Sources/UntoldEngine/Systems/RenderingSystem.swift b/Sources/UntoldEngine/Systems/RenderingSystem.swift index 1b8eb3362..06b69cf95 100644 --- a/Sources/UntoldEngine/Systems/RenderingSystem.swift +++ b/Sources/UntoldEngine/Systems/RenderingSystem.swift @@ -1500,6 +1500,7 @@ public let lookRenderPass: RenderPasses.RenderPassExecution = { commandBuffer in pipeline, source: sourceTexture, destination: destinationTexture, + descriptor: renderInfo.lookRenderPassDescriptor, customization: colorGradingCustomization )(commandBuffer) return diff --git a/Tests/UntoldEngineRenderTests/LookPassDepthTests.swift b/Tests/UntoldEngineRenderTests/LookPassDepthTests.swift new file mode 100644 index 000000000..c8070bcab --- /dev/null +++ b/Tests/UntoldEngineRenderTests/LookPassDepthTests.swift @@ -0,0 +1,62 @@ +// +// LookPassDepthTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Metal +import simd +@testable import UntoldEngine +import XCTest + +/// The look pass draws into its own descriptor, colour alone: it never tests or writes +/// depth, and the frame's depth loaded and stored through it was a full-size read and write +/// per eye for nothing. The other post-process passes keep the frame's depth. +@MainActor +final class LookPassDepthTests: BaseRenderSetup { + override func tearDown() async throws { + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() { + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + cameraLookAt(entityId: camera, eye: simd_float3(0, 1, 4), target: .zero, up: simd_float3(0, 1, 0)) + let box = createEntity() + setEntityMeshDirect(entityId: box, meshes: BasicPrimitives.createCube(extent: 1.0), assetName: "Box") + translateTo(entityId: box, position: simd_float3(0, 0.5, 0)) + let sun = createEntity() + createDirLight(entityId: sun) + rotateTo(entityId: sun, pitch: -40, yaw: 30, roll: 0) + } + + private func readFloats(_ texture: MTLTexture) -> [Float] { + var halves = [UInt16](repeating: 0, count: texture.width * texture.height * 4) + halves.withUnsafeMutableBytes { bytes in + texture.getBytes(bytes.baseAddress!, bytesPerRow: texture.width * 8, from: MTLRegionMake2D(0, 0, texture.width, texture.height), mipmapLevel: 0) + } + return halves.map { Float(Float16(bitPattern: $0)) } + } + + func testTheLookPassCarriesNoDepthAttachment() throws { + let look = try XCTUnwrap(renderInfo.lookRenderPassDescriptor) + XCTAssertNil(look.depthAttachment.texture, "the look pass has no depth to load or store") + let post = try XCTUnwrap(renderInfo.postProcessRenderPassDescriptor) + XCTAssertNotNil(post.depthAttachment.texture, "the other post-process passes keep the frame's depth") + + for _ in 0 ..< 3 { + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + } + let output = try XCTUnwrap(textureResources.lookTexture) + XCTAssertTrue(output.usage.contains(.renderTarget)) + XCTAssertFalse(output.usage.contains(.shaderWrite), "no shader write: the GPU may compress it") + XCTAssertTrue(readFloats(output).contains { $0 > 0.01 }, "the look pass still wrote the lit frame") + } +} From 35dec584982b19838ee58eac239842f3b1b62235 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 15 Oct 2026 13:48:45 +0200 Subject: [PATCH 6/9] [Patch] No shadow cascades for a sun at zero intensity The viewer's sun switch leaves the directional light in place at intensity 0 with castsShadow on, and the cascades were drawn every frame regardless: two 2048x2048 depth maps of every part of the board, 0.5 to 2.9 ms a frame on the headset with no sun. The shading multiplies the sun's shadow by its intensity, so the cascade update now refuses a sun at zero and the shadow passes stand down; the picture is the same. A render test dims the scene's sun to zero and sees no "Shadows" pass, then lights it again. --- .../UntoldEngine/Systems/ShadowSystem.swift | 3 + .../ShadowPassGateTests.swift | 69 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 Tests/UntoldEngineRenderTests/ShadowPassGateTests.swift diff --git a/Sources/UntoldEngine/Systems/ShadowSystem.swift b/Sources/UntoldEngine/Systems/ShadowSystem.swift index 952ca4b79..ec21586ef 100644 --- a/Sources/UntoldEngine/Systems/ShadowSystem.swift +++ b/Sources/UntoldEngine/Systems/ShadowSystem.swift @@ -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. diff --git a/Tests/UntoldEngineRenderTests/ShadowPassGateTests.swift b/Tests/UntoldEngineRenderTests/ShadowPassGateTests.swift new file mode 100644 index 000000000..6f0f6fb69 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/ShadowPassGateTests.swift @@ -0,0 +1,69 @@ +// +// ShadowPassGateTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Metal +import simd +@testable import UntoldEngine +import XCTest + +/// The sun's shadow cascades are drawn only while the sun lights something: the shading +/// multiplies the shadow by the sun's intensity, so a sun at zero would have its two depth +/// maps drawn every frame for nothing. The GPU's own pass timing says which passes ran. +@MainActor +final class ShadowPassGateTests: BaseRenderSetup { + override func setUp() async throws { + try await super.setUp() + setRendering(.gpuPassTiming(.enabled)) + } + + override func tearDown() async throws { + setRendering(.gpuPassTiming(.disabled)) + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() { + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + cameraLookAt(entityId: camera, eye: simd_float3(0, 1, 4), target: .zero, up: simd_float3(0, 1, 0)) + let box = createEntity() + setEntityMeshDirect(entityId: box, meshes: BasicPrimitives.createCube(extent: 1.0), assetName: "Box") + translateTo(entityId: box, position: simd_float3(0, 0.5, 0)) + // The base scene's own directional light is the active sun; a second one would not be. + } + + /// The labels of the passes the last frame ran, after a few frames. + private func passesRun() -> Set { + for _ in 0 ..< 4 { + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + } + return Set(GPUPassTiming.shared.lastFrame().passes.map(\.label)) + } + + func testASunAtZeroIntensityDrawsNoCascades() throws { + try XCTSkipUnless(renderInfo.device.supportsCounterSampling(.atStageBoundary), "no pass timing here") + let sun = try XCTUnwrap(LightingSystem.shared.activeDirectionalLight, "the scene's sun") + updateLightIntensity(entityId: sun, intensity: 2.0) + var passes = passesRun() + XCTAssertTrue(passes.contains("Shadows"), "a lit sun casts: \(passes.sorted())") + XCTAssertTrue(shadowSystem.isActive) + + updateLightIntensity(entityId: sun, intensity: 0.0) + passes = passesRun() + XCTAssertFalse(passes.contains("Shadows"), "a sun at zero lights nothing: no cascades: \(passes.sorted())") + XCTAssertFalse(shadowSystem.isActive) + + updateLightIntensity(entityId: sun, intensity: 1.0) + passes = passesRun() + XCTAssertTrue(passes.contains("Shadows"), "lit again: the cascades are back: \(passes.sorted())") + } +} From fd081c844e79068d07129e282f1fedfec0f8aa81 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 15 Oct 2026 13:48:45 +0200 Subject: [PATCH 7/9] [Feature] The XR line lists every pass of the last frame with their sum, and the frame's own GPU time The per-second line named only the three heaviest passes, so the device's numbers could not be read against the frame's span: the shadows, the look, the culling and the transparency passes were timed and never shown. A second well past the deadline now lists every pass, heaviest first, with their sum (a label sums both eyes); the rest of the span lies between passes. The GPU figure adds the frame's own time after the frame before finished: a queued frame's command buffer is taken before the previous one is done, so the raw span overstates the frame's work. --- Sources/UntoldEngineXR/UntoldEngineXR.swift | 22 +++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/Sources/UntoldEngineXR/UntoldEngineXR.swift b/Sources/UntoldEngineXR/UntoldEngineXR.swift index 7ead4769a..fcb73d6f2 100644 --- a/Sources/UntoldEngineXR/UntoldEngineXR.swift +++ b/Sources/UntoldEngineXR/UntoldEngineXR.swift @@ -109,6 +109,11 @@ private var secondLeastSlackMs = Double.infinity private var secondSkippedWaitMaxMs = 0.0 private var secondGPUSumMs = 0.0 + /// The frames' GPU time after the frame before finished: the span of a frame's command + /// buffer starts when the GPU takes it, which with frames queued is before the frame + /// before is done, so the span overstates the frame's own work. + private var secondOwnGPUSumMs = 0.0 + private var lastGPUEndTime: CFTimeInterval = 0 private var secondPacedWaitSumMs = 0.0 private var secondPacedWaits = 0 /// The last second's frames past the deadline by more than its usable margin before the @@ -1503,6 +1508,8 @@ let gpuMs = (gpuEnd - gpuStart) * 1000.0 secondMaxGPUMs = max(secondMaxGPUMs, gpuMs) secondGPUSumMs += gpuMs + secondOwnGPUSumMs += max(0, (gpuEnd - max(gpuStart, lastGPUEndTime)) * 1000.0) + lastGPUEndTime = max(lastGPUEndTime, gpuEnd) if overrunMs > 0 { deadlineMissCount += 1 secondLate += 1 @@ -1525,7 +1532,7 @@ } let meanOverrunMs = secondLate > 0 ? secondOverrunSumMs / Double(secondLate) : 0 report = String(format: "XR frame timing: the GPU finished after the rendering deadline on %d of %d frames in the last second (worst +%.2f ms, mean +%.2f ms; %d late frames so far; input-time wait %@, %@%@)", secondLate, secondFrames, secondWorstMs, meanOverrunMs, deadlineMissCount, mode, lastFrameWaited ? "waited" : "not waited", pacingActiveNow ? String(format: "; half rate, every %d refreshes, landing +%d", halfRate.cadenceRefreshes, halfRate.landingRefreshes) : "") - report = (report ?? "") + String(format: "; GPU %.1f ms a frame, longest %.1f%@", secondGPUSumMs / Double(secondFrames), secondMaxGPUMs, secondPacedWaits > 0 ? String(format: ", paced wait %.1f ms", secondPacedWaitSumMs / Double(secondPacedWaits)) : "") + report = (report ?? "") + String(format: "; GPU %.1f ms a frame (%.1f ms its own after the frame before), longest %.1f%@", secondGPUSumMs / Double(secondFrames), secondOwnGPUSumMs / Double(secondFrames), secondMaxGPUMs, secondPacedWaits > 0 ? String(format: ", paced wait %.1f ms", secondPacedWaitSumMs / Double(secondPacedWaits)) : "") report = (report ?? "") + String(format: "; %d past the deadline's margin, %d refreshes skipped", secondMissed, secondSkipped) if secondTimedFrames > 0 { let timed = Double(secondTimedFrames) @@ -1534,13 +1541,15 @@ if secondSpacings > 0 { report = (report ?? "") + String(format: "; presentations spaced %.1f ms (%.1f to %.1f)", secondSpacingSumMs / Double(secondSpacings), secondSpacingMinMs, secondSpacingMaxMs) } - // With the GPU pass timing on, the heaviest passes of the last resolved frame - // name what a second of frames well past the deadline spent its time on. + // With the GPU pass timing on, every pass of the last resolved frame, heaviest + // first, with their sum: what a second of frames well past the deadline spent + // its time on, and how much of the frame's span lies between passes. if meanOverrunMs > 2.0, GPUPassTiming.shared.isEnabled { - let passes = GPUPassTiming.shared.lastFrame().passes.sorted { $0.totalMs > $1.totalMs }.prefix(3) + let passes = GPUPassTiming.shared.lastFrame().passes.sorted { $0.totalMs > $1.totalMs } if !passes.isEmpty { - let heaviest = passes.map { String(format: "%@ %.2f ms", $0.label, $0.totalMs) }.joined(separator: ", ") - report = (report ?? "") + "\nXR frame timing: the heaviest passes of the last frame: " + heaviest + let sum = passes.reduce(0.0) { $0 + $1.totalMs } + let list = passes.map { String(format: "%@ %.2f", $0.label, $0.totalMs) }.joined(separator: ", ") + report = (report ?? "") + String(format: "\nXR frame timing: the last frame's passes, %.1f ms in all (a label sums both eyes): ", sum) + list } } } @@ -1585,6 +1594,7 @@ secondSkippedWaitMaxMs = 0 secondMaxGPUMs = 0 secondGPUSumMs = 0 + secondOwnGPUSumMs = 0 secondPacedWaitSumMs = 0 secondPacedWaits = 0 secondMissed = 0 From c3cbb49b387dc91df111be2fe068a0f6617b902e Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 15 Oct 2026 13:49:54 +0200 Subject: [PATCH 8/9] [Docs] The frame's cost on the headset: the measurements, the cuts that held, what remains in the shaders --- docs/proposals/PresentationTimeAnimation.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/proposals/PresentationTimeAnimation.md b/docs/proposals/PresentationTimeAnimation.md index 3ef6ca9d4..0ebe5e016 100644 --- a/docs/proposals/PresentationTimeAnimation.md +++ b/docs/proposals/PresentationTimeAnimation.md @@ -155,6 +155,22 @@ With the GPU pass timing on, a second of late frames names its heaviest passes. `setRendering(.framePacing(.fullRate | .halfRateWhenLate | .halfRate))`, half rate when late by default. `XRHalfRatePolicy` decides from each second's measurements. It enters when, with no wait for the optimal input time left to skip, the display skipped a refresh for one frame in ten or more (from the spacing of the compositor's consecutive predicted presentations) or one frame in ten or more finished past the deadline by more than the deadline's margin, for two seconds running: the deadline sits 15 ms before the presentation on the headset, so a frame a few milliseconds past it is still shown on time (the full-rate runs with nearly every frame a millisecond late dropped nothing), and fewer hops than one in ten are fewer than the steps the half rate adds. While active the loop waits, before it asks the compositor for the next frame, until the next slot of a fixed grid: the instant the pacing started from plus a whole number of cadences (two refreshes, or three or four when the frame's typical GPU work, its mean span plus a fifth and 3 ms of encode, needs them; the cadence rises at once and falls after two fitting seconds, a millisecond's deadband at the boundary). The query itself never blocks, the compositor predicts the presentation three refreshes after it, so the grid, not the previous frame's presentation, sets the spacing, and the predicted presentations come exactly a cadence apart (22.2 ms flat at 90 Hz every second of the heavy run). Each frame is posed for the compositor's prediction plus the refreshes the frames land past it: none while they finish within the deadline's margin less a 2 ms safety, else as many as the rest reaches. Nothing waits for the GPU: the next frame's work queues behind the previous one's. (Lessons from the device: a first version let every other frame go unsubmitted, and the compositor never releases a frame taken and not submitted, three of them aborting the app with "We reached more than 3 frames in flight", so the pacing is in the taking; a second waited for each frame's GPU work before taking the next, and the idle gaps let the GPU clock down, the Forward pass going from 9 to 12 ms and the cadence from two to three, 30 fps, so the GPU is kept queued; a third snapped the query to the grid of the last frame's presentation instant, which the compositor predicts three refreshes ahead, so every frame waited a refresh too many, 30 fps again; a fourth counted the cadence from the instant the last frame was handed out, which drifted with the query's own lateness and gave an uneven 16.6 ms.) It leaves when the rendered frames' GPU work has fitted 80% of the refresh for two seconds running. `halfRatePacingActive` says the state; the per-second line adds "half rate, every N refreshes, landing +N", the frames past the margin and the refreshes skipped, and the spacing of the presentations; the viewer's switch maps to half rate when late or full rate, with "half-rate pacing active" in its summary. By eye on the device (2026-10-15, MSAA 4× with TAA and the occlusion cull at 90 Hz, the GPU at 13 to 15 ms a frame): the even 45 fps shows fewer jumps than the hopping full rate did, and still a visible stepping: a sweep at half the refresh rate is not smooth, it is evenly unsmooth, and the frame rate itself is the limit there. The cure for that configuration is the frame's GPU cost (the Forward pass at 6 to 9 ms, the 4× resolve, the HZB built for one object), not the timing. +### The frame's cost on the headset (`feature/xr_frame_cost`, in part) + +The heavy configuration (MSAA 4× with TAA, the occlusion cull on, foveated 2048×1984 per eye, render quality at the platform default) measured on 2026-10-15 at 90 Hz with the sun switch off: at full rate the GPU took 16.8 to 17.6 ms a frame (Forward 8.7 to 9.2, TAA 2.2 to 3.4, Shadows 1.7), and paced to every other refresh 14 to 16 ms (the Forward pass 10 to 14 at the lower clock an idle GPU falls to). The viewer's per-pass line, both eyes summed, with the board close: Forward 10.40 ms (3.46 vertex, 6.94 fragment), TAA 2.50, Shadows 1.17 (0.93 of it vertex), Post 0.76, Culling 0.14, Transparency 0.04. The budget is 11.1 ms. Five readers mapped the passes, the forward shading, the pyramid, the temporal resolve and the timing labels, and each candidate cut was refuted against the code before it was taken; the ones that held: + +- **The depth pyramid's gate counts objects.** A model loaded from a file is one entity per node under the entity it was set on, so one dartboard was many culling entities and built the depth copy, the pyramid and the occlusion test every frame (1 to 2.5 ms) for an object that hides nothing but itself. The gate counts an entity with its children as one. Confirmed on the device: HZB left the pass list. +- **No shadow cascades for a sun at zero intensity.** The viewer's sun switch leaves the light at intensity 0 with `castsShadow` on, and the cascades were drawn regardless, two 2048² depth maps of every part of the board: 0.5 to 2.9 ms a frame with no sun. The shading multiplies the shadow by the intensity, so the cascade update refuses a sun at zero. +- **The look pass without a depth attachment.** It drew through the post-process descriptor, which loads and stores the frame's depth for the passes that read it; the look pass never touches depth. It has its own colour-only descriptor and pipeline: a full-size depth read and write per eye gone. +- **The frame's colour textures compressible.** The lit colour, the composite, the look and anti-aliasing outputs and the environment were created with `shaderWrite` usage, which no kernel uses and which forbids the GPU's lossless compression of a texture; every resolve, read and write of them moved uncompressed pixels. +- **Fence waits before the fragment stage.** Every scene pass waited on the shared fence before its vertex stage, so one pass's tiler could not run while the previous pass shaded; a pass's vertex stage reads nothing a previous pass's fragments wrote, and every resource is hazard-tracked, so the wait moves to the fragment stage at 23 sites, as the temporal resolve already had it. The Forward pass's 3.5 ms of binning can now overlap the shadows' and culling's fragments. +- **No transparency pass with nothing transparent.** Its encoder loaded and stored both full-size attachments to draw nothing; the GPU spends 0.04 ms on an empty pass, so this one is tidiness. +- **The per-second line lists every pass with their sum**, and the frame's own GPU time after the frame before, so the device's numbers read against the span. + +What did not hold, and why: the half-size pyramid, its single encoder and the first eye's copy skipped matter only to multi-object scenes; the transparency pass's cost was a guess an order of magnitude too high; moving the grade into the temporal resolve changes the history's space; float to half across the forward shader is not safe without a device check. + +What remains is the Forward pass's fragment work (7 of its 10 ms) and the temporal resolve, whose levers are in the shaders: the sun's shadow taps and BRDF evaluated with the intensity at zero, the two equirectangular lookups with `atan2`/`asin` and a rotation per pixel, five anisotropic samples taken before the presence flags are read, normalizations of per-draw constants, and the resolve's rate-map decodes and reprojection done for the cleared pixels that are most of a passthrough frame. Every `.metal` file is included by `UntoldEngineKernels.metal` and only the prebuilt metallibs ship, so those cuts rebuild the kernel libraries and are a separate change. On the viewer's side, MSAA 2× under TAA costs 1.5 to 2 ms less than 4× for edges the temporal accumulation already resolves. + ### The true late latch (not done: a refactor, for a decision) The head pose is consumed on the CPU while the frame is encoded: the view and projection matrices are read at 41 places in `RenderPasses.swift` (each pass copies them into its own uniforms: the batches, the grid, the sky, the environment, the shadow cascades, the SSAO's projection scale), 7 in `CullingSystem.swift` (the frustum planes and the view-projection for the occlusion test), 5 in `TemporalAntiAliasing.swift` (the jitter and the reprojection), 3 in `GraphBuilder.swift`, and a few in the extension surface and the muscle debug pass. Encoding before the wait and writing the pose after it therefore needs one per-frame, per-eye camera buffer (view, projection, their inverses, the view-projection, the eye position, the jitter) bound at a fixed index for every pass and read by every shader in place of the copies; the frustum planes built on the GPU from it (a small kernel before the culls, or the culls reading the matrices directly); the temporal resolve taking the previous frame's buffer as its reprojection; the shadow cascades fitted from a provisional pose a few milliseconds older, which is harmless; and the drawable, with its foveation rate maps, taken before the wait, earlier than Apple's sample does, to be verified on the device first. It touches every pass and most shaders, a few days of work, and it should follow #1359 rather than ride on it. It buys about 3 ms of GPU window (the encode after the wait) with today's head pose; the adaptive wait buys about 9 ms with an older one when needed. From f82664cc71953d0d01df78ebcf1c26b1a39c41b1 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 15 Oct 2026 16:29:32 +0200 Subject: [PATCH 9/9] [Feature] The default sun is the configuration's choice, and the headset starts without one The renderer's setup made a camera and a directional light for every path, the headset included: a sun at intensity 1 casting cascades in a passthrough room that has its own light, until the app replaced it. The defaults are one function now (createDefaultEntities), the camera on every path, since the headset writes the head pose into it, and the sun by UntoldRendererConfig.defaultSun: unset, the platform decides, a sun everywhere but the headset, where the passthrough room is lit by its probe and the immersive environment by its own light; a project sets it to keep a sun on the headset (for shadows its environment cannot cast) or to start without one elsewhere. A render test covers both cases, and the XR architecture doc's comparison table has the row. --- .../UntoldEngine/Renderer/UntoldEngine.swift | 34 ++++++++++----- .../Renderer/UntoldRendererConfig.swift | 10 ++++- .../DefaultEntitiesTests.swift | 43 +++++++++++++++++++ docs/Architecture/xrRenderingSystem.md | 1 + 4 files changed, 76 insertions(+), 12 deletions(-) create mode 100644 Tests/UntoldEngineRenderTests/DefaultEntitiesTests.swift diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 057b09461..38d13928b 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -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) @@ -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 } diff --git a/Sources/UntoldEngine/Renderer/UntoldRendererConfig.swift b/Sources/UntoldEngine/Renderer/UntoldRendererConfig.swift index b27557650..72fd9a268 100644 --- a/Sources/UntoldEngine/Renderer/UntoldRendererConfig.swift +++ b/Sources/UntoldEngine/Renderer/UntoldRendererConfig.swift @@ -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 } } diff --git a/Tests/UntoldEngineRenderTests/DefaultEntitiesTests.swift b/Tests/UntoldEngineRenderTests/DefaultEntitiesTests.swift new file mode 100644 index 000000000..aa08fe1ba --- /dev/null +++ b/Tests/UntoldEngineRenderTests/DefaultEntitiesTests.swift @@ -0,0 +1,43 @@ +// +// DefaultEntitiesTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +/// What a scene has before the app adds anything: a camera on every path, since the headset +/// writes the head pose into it, and a sun everywhere but the headset, where the passthrough +/// room is lit by its probe and the immersive environment by its own light. +@MainActor +final class DefaultEntitiesTests: BaseRenderSetup { + override func tearDown() async throws { + destroyAllEntities() + try await super.tearDown() + } + + func testTheHeadsetGetsACameraAndNoSun() { + destroyAllEntities() + LightingSystem.shared.activeDirectionalLight = nil + let made = renderer.createDefaultEntities(sun: false) + XCTAssertNil(made.sun) + XCTAssertEqual(CameraSystem.shared.activeCamera, made.camera) + XCTAssertNil(LightingSystem.shared.activeDirectionalLight) + XCTAssertEqual(getDirectionalLightParameters().intensity, 0.0, "no sun lights the scene") + } + + func testTheOtherPathsGetASunToo() { + destroyAllEntities() + LightingSystem.shared.activeDirectionalLight = nil + let made = renderer.createDefaultEntities(sun: true) + XCTAssertEqual(CameraSystem.shared.activeCamera, made.camera) + XCTAssertEqual(LightingSystem.shared.activeDirectionalLight, made.sun) + XCTAssertEqual(getDirectionalLightParameters().intensity, 1.0) + } +} diff --git a/docs/Architecture/xrRenderingSystem.md b/docs/Architecture/xrRenderingSystem.md index 8e2b0cd42..d8953ce9c 100644 --- a/docs/Architecture/xrRenderingSystem.md +++ b/docs/Architecture/xrRenderingSystem.md @@ -267,6 +267,7 @@ Selection rays update from interaction events, not continuously as the user look | Frame timing | MTKView drives at display rate | CompositorServices dictates via `optimalInputTime` | | Eyes | 1 | 2 (per-eye loop over `drawable.views`) | | Compute passes | Once per frame | Once per frame (shared across both eyes) | +| Default scene | A camera and a directional light, both active | A camera, the entity the head pose is written into, and no directional light unless the configuration asks for one (`UntoldRendererConfig.defaultSun`): 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 (`DirectionalLightNode`, or `createDirLight` and `setDirectionalLight(.active(_:))`) | | View matrix | Camera entity transform | `(originFromDevice × deviceFromView)⁻¹` from ARKit anchor | | Projection | Camera component FOV | `drawable.computeProjection()` — asymmetric per-eye | | Present call | `commandBuffer.present(drawable)` | `drawable.encodePresent(commandBuffer:)` — encoded as GPU command |