From 4d4d7f84cc9cb24d1924b2b9ecc0ca7d512eaa26 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Wed, 6 May 2026 11:54:16 -0700 Subject: [PATCH 1/2] [Patch] Migrate editor ray picking to ScenePickingSystem --- .../UntoldEditor/Editor/EditorSceneView.swift | 3 - .../Systems/EditorInputSystemAppKit.swift | 52 +-- .../Systems/RayModelIntersecions.swift | 322 ------------------ .../UntoldEditor/Utils/EditorGlobals.swift | 4 - 4 files changed, 1 insertion(+), 380 deletions(-) delete mode 100644 Sources/UntoldEditor/Systems/RayModelIntersecions.swift diff --git a/Sources/UntoldEditor/Editor/EditorSceneView.swift b/Sources/UntoldEditor/Editor/EditorSceneView.swift index 4574641..fc2d73b 100644 --- a/Sources/UntoldEditor/Editor/EditorSceneView.swift +++ b/Sources/UntoldEditor/Editor/EditorSceneView.swift @@ -26,9 +26,6 @@ struct EditorSceneView: View, UntoldRendererDelegate { CameraSystem.shared.activeCamera = sceneCamera - // Initialize ray vs model pipeline - initRayPickerCompute() - // Load Debug meshes and other editor / debug resources loadLightDebugMeshes() } diff --git a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift index c0457b5..32904da 100644 --- a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift +++ b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift @@ -476,62 +476,12 @@ guard let hit = pickEntity( rayOrigin: rayContext.rayOrigin, rayDirection: rayContext.rayDirection, - options: ScenePickOptions(isGizmoActive: gizmoActive) + options: ScenePickOptions(isGizmoActive: gizmoActive, backend: .octreeGPUPreferred) ) else { return (.invalid, false) } return (hit.entityId, true) } - - /// The gpu ray cast is not working for large scenes. TODO: fix getRaycasterdEntityGPU - internal func getRaycastedEntityGPU(currentLocation: NSPoint, view: NSView) -> (entityId: EntityID, hit: Bool) { - var hitEntityId: EntityID = .invalid - var hitEntity = false - - guard let cameraComponent = scene.get(component: CameraComponent.self, for: findSceneCamera()) else { - handleError(.noActiveCamera) - return (hitEntityId, hitEntity) - } - - let currentCGPoint = simd_float2(Float(currentLocation.x), Float(currentLocation.y)) - - let rayDirection: simd_float3 = rayDirectionInWorldSpace(uMouseLocation: currentCGPoint, uViewPortDim: simd_float2(Float(view.bounds.width), Float(view.bounds.height)), uPerspectiveSpace: renderInfo.perspectiveSpace, uViewSpace: cameraComponent.viewSpace) - - if getAllGameEntitiesWithMeshes().count == 0 { - return (hitEntityId, hitEntity) - } - - if let rtxCommandBuffer = renderInfo.commandQueue.makeCommandBuffer() { - executeRayVsModelHit(rtxCommandBuffer, cameraComponent.localPosition, rayDirection) - - rtxCommandBuffer.addCompletedHandler { commandBuffer in - if let error = commandBuffer.error { - // Handle error if any - print("Command buffer completed with error: \(error)") - } else { - if let data = bufferResources.rayModelInstanceBuffer?.contents().assumingMemoryBound(to: Int32.self) { - let value = data.pointee - - if value != -1 { - hitEntityId = accelStructResources.entityIDIndex[Int(value)] - hitEntity = true - } - } - } - - cleanUpAccelStructures() - } - - rtxCommandBuffer.commit() - rtxCommandBuffer.waitUntilCompleted() - } - - if hitEntity { - return (hitEntityId, hitEntity) - } - - return (hitEntityId, hitEntity) - } } #endif diff --git a/Sources/UntoldEditor/Systems/RayModelIntersecions.swift b/Sources/UntoldEditor/Systems/RayModelIntersecions.swift deleted file mode 100644 index bdbcb7b..0000000 --- a/Sources/UntoldEditor/Systems/RayModelIntersecions.swift +++ /dev/null @@ -1,322 +0,0 @@ -// -// RayModelIntersecions.swift -// -// -// Copyright (C) Untold Engine Studios -// Licensed under the GNU LGPL v3.0 or later. -// See the LICENSE file or for details. -// - -import CShaderTypes -import Foundation -import Metal -import UntoldEngine - -func newAccelerationStructure( - _ accelerationStructureDescriptor: MTLAccelerationStructureDescriptor, _ name: String -) -> MTLAccelerationStructure? { - // 1. Query for the sizes needed to store and build the acceleration structure - let accelSize: MTLAccelerationStructureSizes = renderInfo.device.accelerationStructureSizes( - descriptor: accelerationStructureDescriptor - ) - - // 2. Allocate an acceleration structure large enough for the descriptor. It only allocates memory. It does not build the structure - guard - let accelerationStructure: MTLAccelerationStructure = renderInfo.device - .makeAccelerationStructure(size: accelSize.accelerationStructureSize) - else { - print("failed to allocate acceleration structure") - return nil - } - - accelerationStructure.label = name - - // 3. Allocate scrath space - guard - let scratchBuffer: MTLBuffer = renderInfo.device.makeBuffer( - length: accelSize.buildScratchBufferSize, options: .storageModePrivate - ) - else { - print("Failed to allocate scratch buffer") - return nil - } - - // 4. Create a command buffer that performs the acceleration structure build - guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer(), - let commandEncoder = commandBuffer.makeAccelerationStructureCommandEncoder() - else { - print("Failed to create command buffer or command encoder") - return nil - } - - // 6. build acceleration structure - commandEncoder.build( - accelerationStructure: accelerationStructure, descriptor: accelerationStructureDescriptor, - scratchBuffer: scratchBuffer, scratchBufferOffset: 0 - ) - - // End encoding, and commit the command buffer so the GPU can start building the - // acceleration structure. - commandEncoder.endEncoding() - commandBuffer.commit() - - commandBuffer.waitUntilCompleted() - - if accelerationStructure.size == 0 { - print("Acceleration structure size is zero after building") - return nil - } - - return accelerationStructure -} - -// Create acceleration structures for the scene. The scene contains primitive acceleration -// structures and an instance acceleration structure. The primitive acceleration structures -// contain primitives, such as triangles and spheres. The instance acceleration structure contains -// copies, or instances, of the primitive acceleration structures, each with their own -// transformation matrix that describes where to place them in the scene. - -func createAccelerationStructures(_: Bool) { - let transformId = getComponentId(for: WorldTransformComponent.self) - let renderId = getComponentId(for: RenderComponent.self) - - var entities: [EntityID] = [] - - // only ray cast gizmo components - if gizmoActive, InputSystem.shared.keyState.shiftPressed == false { - let gizmoId = getComponentId(for: GizmoComponent.self) - entities = queryEntitiesWithComponentIds([transformId, renderId, gizmoId], in: scene) - } else { - entities = visibleEntityIds - } - - // Iterate over the entities found by the component query - for (i, entityId) in entities.enumerated() { - // Skip entities pending destroy - if scene.mask(for: entityId) == nil { continue } - - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { - handleError(.noRenderComponent, entityId) - continue - } - - guard let worldTransform = scene.get(component: WorldTransformComponent.self, for: entityId) else { - handleError(.noWorldTransformComponent, entityId) - continue - } - - guard scene.get(component: LocalTransformComponent.self, for: entityId) != nil else { - handleError(.noLocalTransformComponent, entityId) - continue - } - - let finalTransform = worldTransform.space - - // 1. Create a geometry descriptor - var geometryDescriptors: [MTLAccelerationStructureGeometryDescriptor] = [] - - for mesh in renderComponent.mesh { - let geometryDescriptor = MTLAccelerationStructureTriangleGeometryDescriptor() - - geometryDescriptor.vertexBuffer = mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer - geometryDescriptor.vertexStride = MemoryLayout.stride - geometryDescriptor.indexBuffer = mesh.submeshes[0].metalKitSubmesh.indexBuffer.buffer - geometryDescriptor.indexType = mesh.submeshes[0].metalKitSubmesh.indexType - geometryDescriptor.triangleCount = mesh.submeshes[0].metalKitSubmesh.indexCount / 3 - geometryDescriptor.vertexFormat = .float4 - - geometryDescriptors.append(geometryDescriptor) - } - - let column0: MTLPackedFloat3 = MTLPackedFloat3Make( - finalTransform.columns.0.x, finalTransform.columns.0.y, finalTransform.columns.0.z - ) - let column1: MTLPackedFloat3 = MTLPackedFloat3Make( - finalTransform.columns.1.x, finalTransform.columns.1.y, finalTransform.columns.1.z - ) - let column2: MTLPackedFloat3 = MTLPackedFloat3Make( - finalTransform.columns.2.x, finalTransform.columns.2.y, finalTransform.columns.2.z - ) - let column3: MTLPackedFloat3 = MTLPackedFloat3Make( - finalTransform.columns.3.x, finalTransform.columns.3.y, finalTransform.columns.3.z - ) - - let localSpace = MTLPackedFloat4x3( - columns: (column0, column1, column2, column3) - ) - - let mask: Int32 = GEOMETRY_MASK_TRIANGLE - - // 2. Create a primitive acceleration structure - let accelerationStructureDescriptor = - MTLPrimitiveAccelerationStructureDescriptor() - - // 3. Add geometry descriptor to acceleration structure descriptor - accelerationStructureDescriptor.geometryDescriptors = geometryDescriptors - - // build the acceleration structure - if let accelerationStructure: MTLAccelerationStructure = newAccelerationStructure( - accelerationStructureDescriptor, getEntityName(entityId: entityId) - ) { - // Add the acceleration structure to the array of primitive acceleration structures. - accelStructResources.primitiveAccelerationStructures.append(accelerationStructure) - accelStructResources.instanceTransforms.append(localSpace) - accelStructResources.accelerationStructIndex.append(UInt32(i)) - accelStructResources.entityIDIndex.append(entityId) - accelStructResources.mask.append(mask) - // print("Acceleration structure for \(String(describing: r.mesh.name)) properly created") - - } else { - print("Failed to create acceleration structure for entity \(String(describing: getEntityName(entityId: entityId)))") - } - } -} - -func createInstanceAccelerationStructures() { - // Allocate a buffer of acceleration structure instance descriptors. Each descriptor represents - // an instance of one of the primitive acceleration structures created above, with its own - // transformation matrix. - - // 2. ALlocate the instance descriptor buffer - let size = MemoryLayout.stride - let instanceDescriptorBufferSize = - size * accelStructResources.primitiveAccelerationStructures.count - - accelStructResources.instanceBuffer = renderInfo.device.makeBuffer( - length: instanceDescriptorBufferSize, options: .storageModeShared - )! - - // 3. Populate instance descriptors - let instaceDescriptorBuffer = accelStructResources.instanceBuffer!.contents().assumingMemoryBound( - to: MTLAccelerationStructureInstanceDescriptor.self - ) - - for (instanceIndex, _) in accelStructResources.primitiveAccelerationStructures.enumerated() { - instaceDescriptorBuffer[instanceIndex].accelerationStructureIndex = - accelStructResources.accelerationStructIndex[instanceIndex] - instaceDescriptorBuffer[instanceIndex].mask = UInt32(accelStructResources.mask[instanceIndex]) - - let transform = accelStructResources.instanceTransforms[instanceIndex] - - instaceDescriptorBuffer[instanceIndex].transformationMatrix = transform - } - - // create an instance acceleration structure descriptor - let instanceAccelDescriptor = - MTLInstanceAccelerationStructureDescriptor() - - instanceAccelDescriptor.instancedAccelerationStructures = - accelStructResources.primitiveAccelerationStructures - instanceAccelDescriptor.instanceCount = accelStructResources.primitiveAccelerationStructures.count - instanceAccelDescriptor.instanceDescriptorBuffer = accelStructResources.instanceBuffer - - // Create the instance acceleration structure that contains all instances in the scene. - accelStructResources.instanceAccelerationStructure = newAccelerationStructure( - instanceAccelDescriptor, "scene instance accel struct" - ) -} - -func initRayPickerCompute() { - // create ray vs model pipeline - // create kernel - guard - let rayModelIntersectKernel = renderInfo.library.makeFunction(name: "rayModelIntersectKernel") - else { - handleError(.kernelCreationFailed, rayModelIntersectPipeline.name!) - return - } - - // create a pipeline - do { - rayModelIntersectPipeline.pipelineState = try renderInfo.device.makeComputePipelineState( - function: rayModelIntersectKernel - ) - - rayModelIntersectPipeline.name = "ray vs model intersect pipe" - rayModelIntersectPipeline.success = true - } catch { - rayModelIntersectPipeline.success = false - handleError(.pipelineStateCreationFailed, rayModelIntersectPipeline.name!) - return - } - - bufferResources.rayModelInstanceBuffer = renderInfo.device.makeBuffer( - length: MemoryLayout.stride, options: .storageModeShared - ) -} - -func cleanUpAccelStructures() { - // clean up all acceleration structures resources - accelStructResources.primitiveAccelerationStructures.removeAll() - accelStructResources.instanceTransforms.removeAll() - accelStructResources.accelerationStructIndex.removeAll() - accelStructResources.entityIDIndex.removeAll() - accelStructResources.instanceAccelerationStructure = nil - accelStructResources.instanceBuffer = nil -} - -func prepareUserHitRayAccelStructures() { - // clean up all acceleration structures resources - cleanUpAccelStructures() - - // create acceleration structures - createAccelerationStructures(false) - - // create instance acceleration structures - createInstanceAccelerationStructures() -} - -func executeRayVsModelHit( - _ commandBuffer: MTLCommandBuffer, _ origin: simd_float3, _ direction: simd_float3 -) { - // prepare acceleration structure - prepareUserHitRayAccelStructures() - - if rayModelIntersectPipeline.success == false { - handleError(.pipelineStateNulled, rayModelIntersectPipeline.name!) - return - } - - // ray tracing - let computeEncoder: MTLComputeCommandEncoder = commandBuffer.makeComputeCommandEncoder()! - - computeEncoder.label = "User Ray-Hit pass" - - computeEncoder.setComputePipelineState(rayModelIntersectPipeline.pipelineState!) - - computeEncoder.setAccelerationStructure( - accelStructResources.instanceAccelerationStructure, - bufferIndex: Int(rayModelAccelStructIndex.rawValue) - ) - - computeEncoder.setBuffer( - accelStructResources.instanceBuffer, offset: 0, index: Int(rayModelBufferInstanceIndex.rawValue) - ) - - var rayOrigin = origin - var rayDirection = direction - - computeEncoder.setBytes( - &rayOrigin, length: MemoryLayout.stride, index: Int(rayModelOriginIndex.rawValue) - ) - - computeEncoder.setBytes( - &rayDirection, length: MemoryLayout.stride, - index: Int(rayModelDirectionIndex.rawValue) - ) - - computeEncoder.setBuffer( - bufferResources.rayModelInstanceBuffer, offset: 0, index: Int(rayModelInstanceHitIndex.rawValue) - ) - - // Set threadExecutionWidth and maxTotalThreadsPerThreadgroup to 1 to dispatch only one thread - let w = 1 - let h = 1 - - let threadsPerThreadgroup: MTLSize = MTLSizeMake(w, h, 1) - let threadsPerGrid: MTLSize = MTLSizeMake(1, 1, 1) // Dispatch a single thread - - computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup) - - computeEncoder.endEncoding() -} diff --git a/Sources/UntoldEditor/Utils/EditorGlobals.swift b/Sources/UntoldEditor/Utils/EditorGlobals.swift index e5dd968..476ef00 100644 --- a/Sources/UntoldEditor/Utils/EditorGlobals.swift +++ b/Sources/UntoldEditor/Utils/EditorGlobals.swift @@ -26,10 +26,6 @@ let gizmoDesiredScreenSize: Float = 75.0 // pixels var spawnDistance: Float = 2.0 -var accelStructResources = AccelStructResources() - -var rayModelIntersectPipeline = ComputePipeline() - /// Visual Debugger enum DebugSelection: Int { case normalOutput From 1f4b8de81dd6a653780af992f9fcf73c2982ee84 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Wed, 6 May 2026 12:01:52 -0700 Subject: [PATCH 2/2] [Patch] Added FPS Stats --- Sources/UntoldEditor/Editor/EditorView.swift | 3 + .../UntoldEditor/Editor/EngineStatsView.swift | 175 ++++++++++++++++++ .../UntoldEditor/Editor/EnvironmentView.swift | 102 ++++++++++ Sources/UntoldEditor/Editor/ToolbarView.swift | 39 ++++ .../Systems/EditorRenderingSystem.swift | 60 +++++- .../BuildEditModeGraphTests.swift | 20 +- .../EditorRenderingSystemTests.swift | 9 +- 7 files changed, 396 insertions(+), 12 deletions(-) create mode 100644 Sources/UntoldEditor/Editor/EngineStatsView.swift diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index f749ead..c543aba 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -94,6 +94,9 @@ public struct EditorView: View { VStack(spacing: 0) { EditorSceneView(renderer: renderer!) .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .topLeading) { + EngineStatsOverlayView() + } TransformManipulationToolbar(controller: editorController!) .frame(height: 40) TabView { diff --git a/Sources/UntoldEditor/Editor/EngineStatsView.swift b/Sources/UntoldEditor/Editor/EngineStatsView.swift new file mode 100644 index 0000000..4432a9f --- /dev/null +++ b/Sources/UntoldEditor/Editor/EngineStatsView.swift @@ -0,0 +1,175 @@ +// +// EngineStatsView.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +import Combine +import SwiftUI +import UntoldEngine + +enum EngineStatsOverlayMode { + case off + case simplified + case advanced +} + +final class EditorEngineStatsStore: ObservableObject { + static let shared = EditorEngineStatsStore() + + @Published private(set) var snapshot: EngineStatsSnapshot = .init() + @Published var overlayMode: EngineStatsOverlayMode = .off + @Published var loggingEnabled: Bool + @Published var loggingProfile: EngineStatsLoggingProfile + @Published var loggingIntervalSeconds: Double + + private var pollCancellable: AnyCancellable? + + private init() { + loggingEnabled = EngineStatsMonitor.shared.enableLogging + loggingProfile = EngineStatsMonitor.shared.loggingProfile + loggingIntervalSeconds = EngineStatsMonitor.shared.loggingIntervalSeconds + + snapshot = getEngineStatsSnapshot() + + pollCancellable = Timer.publish(every: 0.2, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.snapshot = getEngineStatsSnapshot() + } + } + + func setLoggingEnabled(_ enabled: Bool) { + loggingEnabled = enabled + setEngineStatsLogging( + enabled: loggingEnabled, + profile: loggingProfile, + intervalSeconds: loggingIntervalSeconds + ) + } + + func setLoggingProfile(_ profile: EngineStatsLoggingProfile) { + loggingProfile = profile + setEngineStatsLogging( + enabled: loggingEnabled, + profile: loggingProfile, + intervalSeconds: loggingIntervalSeconds + ) + } + + func setLoggingInterval(_ seconds: Double) { + loggingIntervalSeconds = max(0.1, seconds) + setEngineStatsLogging( + enabled: loggingEnabled, + profile: loggingProfile, + intervalSeconds: loggingIntervalSeconds + ) + } + + func setOverlaySimplifiedEnabled(_ enabled: Bool) { + if enabled { + overlayMode = .simplified + } else if overlayMode == .simplified { + overlayMode = .off + } + } + + func setOverlayAdvancedEnabled(_ enabled: Bool) { + if enabled { + overlayMode = .advanced + } else if overlayMode == .advanced { + overlayMode = .off + } + } +} + +struct EngineStatsOverlayView: View { + @ObservedObject private var store = EditorEngineStatsStore.shared + + var body: some View { + Group { + switch store.overlayMode { + case .off: + EmptyView() + case .simplified: + simplifiedOverlay(store.snapshot) + case .advanced: + advancedOverlay(store.snapshot) + } + } + } + + @ViewBuilder + private func simplifiedOverlay(_ snapshot: EngineStatsSnapshot) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("Engine Stats") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.white) + + Text(compactOverlayLine(snapshot)) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.white.opacity(0.95)) + .lineLimit(3) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.black.opacity(0.45)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + .cornerRadius(8) + .shadow(color: Color.black.opacity(0.2), radius: 8, x: 0, y: 2) + .allowsHitTesting(false) + .padding(12) + } + + @ViewBuilder + private func advancedOverlay(_ snapshot: EngineStatsSnapshot) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("Engine Stats (Advanced)") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.white) + + Text(formatEngineStatsOverlay(snapshot)) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.white.opacity(0.95)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.black.opacity(0.38)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + .cornerRadius(8) + .shadow(color: Color.black.opacity(0.2), radius: 8, x: 0, y: 2) + .allowsHitTesting(false) + .padding(12) + } + + private func compactOverlayLine(_ snapshot: EngineStatsSnapshot) -> String { + "F\(snapshot.frameIndex) " + + "fps \(formatFPS(snapshot.timing.frameTotalMs)) " + + "frame \(formatMs(snapshot.timing.frameTotalMs))ms " + + "upd \(formatMs(snapshot.timing.updateMs)) " + + "rnd \(formatMs(snapshot.timing.renderTotalMs)) " + + "cul \(formatMs(snapshot.timing.cullingMs)) " + + "| draws \(snapshot.render.drawCallsTotal) " + + "tris \(snapshot.render.trianglesTotal) " + + "vis \(snapshot.render.visibleInstances)" + } + + private func formatMs(_ value: Double) -> String { + String(format: "%.2f", value) + } + + private func formatFPS(_ frameMs: Double) -> String { + guard frameMs > 0 else { return "0.0" } + return String(format: "%.1f", 1000.0 / frameMs) + } +} diff --git a/Sources/UntoldEditor/Editor/EnvironmentView.swift b/Sources/UntoldEditor/Editor/EnvironmentView.swift index 9f848c3..5678bd4 100644 --- a/Sources/UntoldEditor/Editor/EnvironmentView.swift +++ b/Sources/UntoldEditor/Editor/EnvironmentView.swift @@ -418,6 +418,12 @@ struct PostProcessingEditorView: View { struct DebuggerEditorView: View { @ObservedObject var settings = DebugSettings.shared + @State private var spatialEnabled = false + @State private var showOctreeLeafBounds = false + @State private var occupiedOnly = true + @State private var maxLeafNodeCount = 2000 + @State private var leafColorMode: SpatialDebugLeafColorMode = .plain + @State private var colorRenderablesByLOD = false var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -428,7 +434,103 @@ struct DebuggerEditorView: View { Text(name) } } + + Divider() + + Text("Spatial Debugger") + .font(.headline) + + Toggle("Enable Spatial Debug", isOn: $spatialEnabled) + .onChange(of: spatialEnabled) { _, enabled in + if enabled, !showOctreeLeafBounds, !colorRenderablesByLOD { + showOctreeLeafBounds = true + } + applySpatialDebugSettings() + } + + VStack(alignment: .leading, spacing: 8) { + Toggle("Show Octree Leaf Bounds", isOn: $showOctreeLeafBounds) + .onChange(of: showOctreeLeafBounds) { _, _ in + applySpatialDebugSettings() + } + + Picker("Leaf Color Mode", selection: $leafColorMode) { + Text("Plain").tag(SpatialDebugLeafColorMode.plain) + Text("Residency").tag(SpatialDebugLeafColorMode.residency) + Text("Culling").tag(SpatialDebugLeafColorMode.culling) + } + .onChange(of: leafColorMode) { _, _ in + applySpatialDebugSettings() + } + + Toggle("Occupied Leaves Only", isOn: $occupiedOnly) + .onChange(of: occupiedOnly) { _, _ in + applySpatialDebugSettings() + } + + HStack(spacing: 8) { + Text("Max Leaf Nodes") + .font(.system(size: 12)) + + CommitAndDefocusIntField(value: Binding( + get: { maxLeafNodeCount }, + set: { newValue in + maxLeafNodeCount = max(0, newValue) + } + )) + .frame(width: 80) + .onChange(of: maxLeafNodeCount) { _, _ in + applySpatialDebugSettings() + } + } + } + .disabled(!spatialEnabled) + + Toggle("Color Renderables by LOD", isOn: $colorRenderablesByLOD) + .onChange(of: colorRenderablesByLOD) { _, _ in + applySpatialDebugSettings() + } + .disabled(!spatialEnabled) + + Button("Disable All Spatial Debug") { + disableSpatialDebugVisualization() + syncSpatialDebugSettingsFromEngine() + } + + Divider() + + Text("Stats overlay controls are in the top toolbar.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) } .padding() + .onAppear { + syncSpatialDebugSettingsFromEngine() + } + } + + private func applySpatialDebugSettings() { + guard spatialEnabled else { + disableSpatialDebugVisualization() + return + } + + setOctreeLeafBoundsDebug( + enabled: showOctreeLeafBounds, + maxLeafNodeCount: maxLeafNodeCount, + occupiedOnly: occupiedOnly, + colorMode: leafColorMode + ) + setLODLevelDebug(enabled: colorRenderablesByLOD) + } + + private func syncSpatialDebugSettingsFromEngine() { + let spatialDebug = SpatialDebugVisualization.shared + spatialEnabled = spatialDebug.enabled + showOctreeLeafBounds = spatialDebug.showOctreeLeafBounds + occupiedOnly = spatialDebug.octreeLeafOccupiedOnly + maxLeafNodeCount = spatialDebug.maxLeafNodeCount + leafColorMode = spatialDebug.octreeLeafColorMode + colorRenderablesByLOD = spatialDebug.colorRenderablesByLOD } } diff --git a/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift index 71dc186..4d0bc80 100644 --- a/Sources/UntoldEditor/Editor/ToolbarView.swift +++ b/Sources/UntoldEditor/Editor/ToolbarView.swift @@ -14,6 +14,7 @@ struct ToolbarView: View { @ObservedObject var selectionManager: SelectionManager @ObservedObject var editorBasePath = EditorAssetBasePath.shared + @ObservedObject private var statsStore = EditorEngineStatsStore.shared private let editorVersionLabel = "v0.12.10" var onSave: () -> Void @@ -149,6 +150,8 @@ .scaleEffect(0.85) .frame(height: 20) + Divider().frame(height: 24) + Menu { Button("Save Scene", systemImage: "square.and.arrow.down.on.square", action: onSave) Button("Save Scene As…", systemImage: "square.and.arrow.down", action: onSaveAs) @@ -165,6 +168,42 @@ } .menuStyle(.borderlessButton) .focusable(false) + + Divider().frame(height: 24) + + Toggle(isOn: Binding( + get: { statsStore.overlayMode != .off }, + set: { enabled in + if enabled { + statsStore.setOverlaySimplifiedEnabled(true) + } else { + statsStore.setOverlaySimplifiedEnabled(false) + statsStore.setOverlayAdvancedEnabled(false) + } + } + )) { + Text("FPS") + .font(.system(size: 11, weight: .semibold)) + } + .toggleStyle(.switch) + .scaleEffect(0.85) + .frame(height: 20) + + Toggle(isOn: Binding( + get: { statsStore.overlayMode == .advanced }, + set: { enabled in + if enabled { + statsStore.setOverlayAdvancedEnabled(true) + } else if statsStore.overlayMode != .off { + statsStore.setOverlaySimplifiedEnabled(true) + } + } + )) { + Text("FPS Advanced") + .font(.system(size: 11, weight: .semibold)) + } + .toggleStyle(.checkbox) + .disabled(statsStore.overlayMode == .off) } } diff --git a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift index 3f0d3b3..f523d4c 100644 --- a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift +++ b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift @@ -7,6 +7,7 @@ // See the LICENSE file or for details. // import MetalKit +import QuartzCore import UntoldEngine func EditorUpdateRenderingSystem(in view: MTKView) { @@ -14,11 +15,31 @@ func EditorUpdateRenderingSystem(in view: MTKView) { commandBufferSemaphore.wait() if let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() { + #if ENGINE_STATS_ENABLED + let renderTotalStart = CACurrentMediaTime() + #endif renderInfo.lastCommandBuffer = commandBuffer + + #if ENGINE_STATS_ENABLED + let renderPrepStart = CACurrentMediaTime() + let cullingStart = CACurrentMediaTime() + #endif performFrustumCulling(commandBuffer: commandBuffer) + #if ENGINE_STATS_ENABLED + let cullingMs = (CACurrentMediaTime() - cullingStart) * 1000.0 + EngineStatsMonitor.shared.update { snapshot in + snapshot.timing.cullingMs += cullingMs + } + #endif executeGaussianDepth(commandBuffer) executeBitonicSort(commandBuffer) + #if ENGINE_STATS_ENABLED + let renderPrepMs = (CACurrentMediaTime() - renderPrepStart) * 1000.0 + EngineStatsMonitor.shared.update { snapshot in + snapshot.timing.renderPrepMs += renderPrepMs + } + #endif if let renderPassDescriptor = view.currentRenderPassDescriptor { renderInfo.renderPassDescriptor = renderPassDescriptor @@ -45,14 +66,30 @@ func EditorUpdateRenderingSystem(in view: MTKView) { let sortedPasses = try! topologicalSortGraph(graph: graph) // execute it + #if ENGINE_STATS_ENABLED + let encodeStart = CACurrentMediaTime() + #endif executeGraph(graph, sortedPasses, commandBuffer) + // Keep editor in sync with runtime temporal HZB: + // render depth this frame -> build HZB -> consume next frame during culling + buildHZBDepthPyramid(commandBuffer) + #if ENGINE_STATS_ENABLED + let encodeMs = (CACurrentMediaTime() - encodeStart) * 1000.0 + EngineStatsMonitor.shared.update { snapshot in + snapshot.timing.encodeMs += encodeMs + } + #endif } if let drawable = view.currentDrawable { commandBuffer.present(drawable) } - commandBuffer.addCompletedHandler { _ in + commandBuffer.addCompletedHandler { cb in + #if ENGINE_STATS_ENABLED + let gpuExecutionMs = (cb.gpuEndTime - cb.gpuStartTime) * 1000.0 + EngineStatsMonitor.shared.recordGPUCompletion(executionMs: gpuExecutionMs) + #endif // Release the in-flight slot commandBufferSemaphore.signal() DispatchQueue.main.async { @@ -61,7 +98,18 @@ func EditorUpdateRenderingSystem(in view: MTKView) { } } + #if ENGINE_STATS_ENABLED + let submitStart = CACurrentMediaTime() + #endif commandBuffer.commit() + #if ENGINE_STATS_ENABLED + let submitMs = (CACurrentMediaTime() - submitStart) * 1000.0 + let renderTotalMs = (CACurrentMediaTime() - renderTotalStart) * 1000.0 + EngineStatsMonitor.shared.update { snapshot in + snapshot.timing.submitMs += submitMs + snapshot.timing.renderTotalMs += renderTotalMs + } + #endif } else { // Failed to create command buffer - release slot commandBufferSemaphore.signal() @@ -116,6 +164,14 @@ func buildEditModeGraph() -> RenderGraphResult { ) graph[transparencyPass.id] = transparencyPass + // Spatial debug overlays are rendered on top of lit scene color. + let spatialDebugPass = RenderPass( + id: "spatialDebug", + dependencies: [transparencyPass.id], + execute: RenderPasses.spatialDebugBoundsExecution + ) + graph[spatialDebugPass.id] = spatialDebugPass + let highlightPass = RenderPass( id: "outline", dependencies: [batchedModelPass.id], execute: RenderPasses.highlightExecution ) @@ -134,7 +190,7 @@ func buildEditModeGraph() -> RenderGraphResult { graph[gaussianPass.id] = gaussianPass let preCompPass = RenderPass( - id: "precomp", dependencies: [modelPass.id, gizmoPass.id, transparencyPass.id, gaussianPass.id], execute: RenderPasses.preCompositeExecution + id: "precomp", dependencies: [modelPass.id, gizmoPass.id, spatialDebugPass.id, gaussianPass.id], execute: RenderPasses.preCompositeExecution ) graph[preCompPass.id] = preCompPass diff --git a/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift b/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift index 60439b2..521ef75 100644 --- a/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift +++ b/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift @@ -65,7 +65,7 @@ final class BuildEditModeGraphTests: XCTestCase { let expectedIDs: Set = [ "environment", "shadow", "batchedShadow", "model", "batchedModel", "lightPass", - "transparency", "outline", "lightVisualPass", "gizmo", "precomp", "gaussian", "look", "outputTransform", + "transparency", "spatialDebug", "outline", "lightVisualPass", "gizmo", "precomp", "gaussian", "look", "outputTransform", ] XCTAssertEqual(Set(graph.keys), expectedIDs) @@ -76,11 +76,12 @@ final class BuildEditModeGraphTests: XCTestCase { assertDeps(graph, "batchedModel", ["model"]) assertDeps(graph, "lightPass", ["batchedModel", "model", "shadow"]) assertDeps(graph, "transparency", ["lightPass"]) + assertDeps(graph, "spatialDebug", ["transparency"]) assertDeps(graph, "outline", ["batchedModel"]) assertDeps(graph, "lightVisualPass", ["outline"]) assertDeps(graph, "gizmo", ["lightVisualPass"]) assertDeps(graph, "gaussian", ["model"]) - assertDeps(graph, "precomp", ["model", "gizmo", "transparency", "gaussian"]) + assertDeps(graph, "precomp", ["model", "gizmo", "spatialDebug", "gaussian"]) assertDeps(graph, "look", ["precomp"]) assertDeps(graph, "outputTransform", ["look"]) @@ -102,7 +103,7 @@ final class BuildEditModeGraphTests: XCTestCase { let expectedIDs: Set = [ "environment", "shadow", "batchedShadow", "model", "batchedModel", "lightPass", - "transparency", "outline", "lightVisualPass", "gizmo", "precomp", "gaussian", "look", "fxaa", "outputTransform", + "transparency", "spatialDebug", "outline", "lightVisualPass", "gizmo", "precomp", "gaussian", "look", "fxaa", "outputTransform", ] XCTAssertEqual(Set(graph.keys), expectedIDs) @@ -113,11 +114,12 @@ final class BuildEditModeGraphTests: XCTestCase { assertDeps(graph, "batchedModel", ["model"]) assertDeps(graph, "lightPass", ["batchedModel", "model", "shadow"]) assertDeps(graph, "transparency", ["lightPass"]) + assertDeps(graph, "spatialDebug", ["transparency"]) assertDeps(graph, "outline", ["batchedModel"]) assertDeps(graph, "lightVisualPass", ["outline"]) assertDeps(graph, "gizmo", ["lightVisualPass"]) assertDeps(graph, "gaussian", ["model"]) - assertDeps(graph, "precomp", ["model", "gizmo", "transparency", "gaussian"]) + assertDeps(graph, "precomp", ["model", "gizmo", "spatialDebug", "gaussian"]) assertDeps(graph, "look", ["precomp"]) assertDeps(graph, "fxaa", ["look"]) assertDeps(graph, "outputTransform", ["fxaa"]) @@ -140,7 +142,7 @@ final class BuildEditModeGraphTests: XCTestCase { let expectedIDs: Set = [ "grid", "shadow", "batchedShadow", "model", "batchedModel", "lightPass", - "transparency", "outline", "lightVisualPass", "gizmo", "precomp", "gaussian", "look", "outputTransform", + "transparency", "spatialDebug", "outline", "lightVisualPass", "gizmo", "precomp", "gaussian", "look", "outputTransform", ] XCTAssertEqual(Set(graph.keys), expectedIDs) @@ -151,11 +153,12 @@ final class BuildEditModeGraphTests: XCTestCase { assertDeps(graph, "batchedModel", ["model"]) assertDeps(graph, "lightPass", ["batchedModel", "model", "shadow"]) assertDeps(graph, "transparency", ["lightPass"]) + assertDeps(graph, "spatialDebug", ["transparency"]) assertDeps(graph, "outline", ["batchedModel"]) assertDeps(graph, "lightVisualPass", ["outline"]) assertDeps(graph, "gizmo", ["lightVisualPass"]) assertDeps(graph, "gaussian", ["model"]) - assertDeps(graph, "precomp", ["model", "gizmo", "transparency", "gaussian"]) + assertDeps(graph, "precomp", ["model", "gizmo", "spatialDebug", "gaussian"]) assertDeps(graph, "look", ["precomp"]) assertDeps(graph, "outputTransform", ["look"]) @@ -177,7 +180,7 @@ final class BuildEditModeGraphTests: XCTestCase { let expectedIDs: Set = [ "grid", "shadow", "batchedShadow", "model", "batchedModel", "lightPass", - "transparency", "outline", "lightVisualPass", "gizmo", "precomp", "gaussian", "look", "fxaa", "outputTransform", + "transparency", "spatialDebug", "outline", "lightVisualPass", "gizmo", "precomp", "gaussian", "look", "fxaa", "outputTransform", ] XCTAssertEqual(Set(graph.keys), expectedIDs) @@ -188,11 +191,12 @@ final class BuildEditModeGraphTests: XCTestCase { assertDeps(graph, "batchedModel", ["model"]) assertDeps(graph, "lightPass", ["batchedModel", "model", "shadow"]) assertDeps(graph, "transparency", ["lightPass"]) + assertDeps(graph, "spatialDebug", ["transparency"]) assertDeps(graph, "outline", ["batchedModel"]) assertDeps(graph, "lightVisualPass", ["outline"]) assertDeps(graph, "gizmo", ["lightVisualPass"]) assertDeps(graph, "gaussian", ["model"]) - assertDeps(graph, "precomp", ["model", "gizmo", "transparency", "gaussian"]) + assertDeps(graph, "precomp", ["model", "gizmo", "spatialDebug", "gaussian"]) assertDeps(graph, "look", ["precomp"]) assertDeps(graph, "fxaa", ["look"]) assertDeps(graph, "outputTransform", ["fxaa"]) diff --git a/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift b/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift index 167d96f..6a19095 100644 --- a/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift +++ b/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift @@ -78,6 +78,7 @@ final class EditorRenderingSystemTests: XCTestCase { XCTAssertNotNil(graph["outline"], "Highlight/outline pass should exist") XCTAssertNotNil(graph["lightVisualPass"], "Light visual pass should exist") XCTAssertNotNil(graph["gizmo"], "Gizmo pass should exist") + XCTAssertNotNil(graph["spatialDebug"], "Spatial debug pass should exist") XCTAssertNotNil(graph["precomp"], "Pre-composite pass should exist") XCTAssertNotNil(graph["look"], "Look pass should exist") XCTAssertNotNil(graph["outputTransform"], "Output transform pass should exist") @@ -106,6 +107,7 @@ final class EditorRenderingSystemTests: XCTestCase { XCTAssertNotNil(graph["outline"], "Highlight/outline pass should exist") XCTAssertNotNil(graph["lightVisualPass"], "Light visual pass should exist") XCTAssertNotNil(graph["gizmo"], "Gizmo pass should exist") + XCTAssertNotNil(graph["spatialDebug"], "Spatial debug pass should exist") XCTAssertNotNil(graph["precomp"], "Pre-composite pass should exist") XCTAssertNotNil(graph["look"], "Look pass should exist") XCTAssertNotNil(graph["outputTransform"], "Output transform pass should exist") @@ -134,11 +136,12 @@ final class EditorRenderingSystemTests: XCTestCase { XCTAssertEqual(graph["lightVisualPass"]?.dependencies, ["outline"], "Light visual pass should depend on outline") XCTAssertEqual(graph["gizmo"]?.dependencies, ["lightVisualPass"], "Gizmo should depend on light visual pass") XCTAssertEqual(graph["transparency"]?.dependencies, ["lightPass"], "Transparency should depend on light pass") + XCTAssertEqual(graph["spatialDebug"]?.dependencies, ["transparency"], "Spatial debug should depend on transparency") let precompDeps = graph["precomp"]?.dependencies ?? [] XCTAssertTrue(precompDeps.contains("model"), "Precomp should depend on model") XCTAssertTrue(precompDeps.contains("gizmo"), "Precomp should depend on gizmo") - XCTAssertTrue(precompDeps.contains("transparency"), "Precomp should depend on transparency") + XCTAssertTrue(precompDeps.contains("spatialDebug"), "Precomp should depend on spatialDebug") XCTAssertEqual(graph["look"]?.dependencies, ["precomp"], "Look should depend on precomp") XCTAssertEqual(graph["outputTransform"]?.dependencies, ["look"], "Output transform should depend on look") @@ -191,6 +194,8 @@ final class EditorRenderingSystemTests: XCTestCase { ("model", "outline"), ("outline", "lightVisualPass"), ("lightVisualPass", "gizmo"), + ("transparency", "spatialDebug"), + ("spatialDebug", "precomp"), ("gizmo", "precomp"), ("lightPass", "precomp"), ("precomp", "look"), @@ -291,7 +296,7 @@ final class EditorRenderingSystemTests: XCTestCase { XCTAssertEqual(precompPass.dependencies.count, 4, "Precomp should have exactly 4 dependencies") XCTAssertTrue(precompPass.dependencies.contains("model"), "Precomp should depend on model") XCTAssertTrue(precompPass.dependencies.contains("gizmo"), "Precomp should depend on gizmo") - XCTAssertTrue(precompPass.dependencies.contains("transparency"), "Precomp should depend on transparency") + XCTAssertTrue(precompPass.dependencies.contains("spatialDebug"), "Precomp should depend on spatialDebug") XCTAssertTrue(precompPass.dependencies.contains("gaussian"), "Precomp should depend on gaussian") }