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 28b015e05..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), @@ -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 ) @@ -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 ) @@ -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 ) @@ -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 ) @@ -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 ) 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 4f0edee75..811250c03 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) @@ -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 @@ -3894,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) @@ -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 @@ -4315,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) @@ -4458,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) @@ -4657,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 @@ -5029,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) @@ -5301,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 @@ -5313,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 @@ -5345,7 +5356,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/Renderer/RenderResources.swift b/Sources/UntoldEngine/Renderer/RenderResources.swift index a8c855450..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] = [] @@ -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 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/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/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/Systems/RenderingSystem.swift b/Sources/UntoldEngine/Systems/RenderingSystem.swift index 44bdd75d9..06b69cf95 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) @@ -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/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/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/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 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/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) + } } 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") + } +} 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())") + } +} 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())") + } +} 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 | 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.