From adf6d2b0a1dbadddfada7b0b40001e4dfc7ffd37 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 1 Jun 2026 21:57:42 -0700 Subject: [PATCH 1/5] [Patch] Made user experience improvements --- .../Editor/AssetBrowserView.swift | 44 ++- Sources/UntoldEditor/Editor/EditorView.swift | 323 ++++++++++++++++++ .../Editor/StarterStreamModels.swift | 87 +++++ Sources/UntoldEditor/Editor/ToolbarView.swift | 26 ++ .../Renderer/EditorUntoldRendererConfig.swift | 4 +- .../Systems/EditorInputSystemAppKit.swift | 77 ++++- .../Systems/EditorRenderingSystem.swift | 68 ++-- 7 files changed, 593 insertions(+), 36 deletions(-) create mode 100644 Sources/UntoldEditor/Editor/StarterStreamModels.swift diff --git a/Sources/UntoldEditor/Editor/AssetBrowserView.swift b/Sources/UntoldEditor/Editor/AssetBrowserView.swift index 0021e21..178f30b 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowserView.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowserView.swift @@ -645,7 +645,7 @@ struct AssetBrowserView: View { } .sheet(isPresented: $showRemoteStreamSheet) { RemoteStreamImportSheet(urlString: $remoteStreamURLString) { - saveRemoteStream() + saveRemoteStream(loadImmediately: true) showRemoteStreamSheet = false } onCancel: { remoteStreamURLString = "" @@ -1392,6 +1392,9 @@ struct AssetBrowserView: View { let sceneName = manifestAsset.path.deletingPathExtension().lastPathComponent setEntityName(entityId: sceneRoot, name: sceneName) + clearSceneBatches() + GeometryStreamingSystem.shared.enabled = true + setEntityStreamScene(entityId: sceneRoot, url: manifestAsset.path) { success in DispatchQueue.main.async { if success { @@ -1409,7 +1412,7 @@ struct AssetBrowserView: View { showStatus("Loading stream model: \(sceneName)...") } - private func saveRemoteStream() { + private func saveRemoteStream(loadImmediately: Bool) { guard let basePath = assetBasePath else { showStatus("No project loaded", isError: true) return @@ -1431,16 +1434,44 @@ struct AssetBrowserView: View { let urlHash = String(format: "%08x", urlStr.utf8.reduce(UInt32(5381)) { ($0 &* 31) &+ UInt32($1) }) let name = "\(baseName)-\(urlHash)" + saveRemoteStreamAsset( + name: name, + displayName: baseName, + urlString: urlStr, + basePath: basePath, + loadImmediately: loadImmediately + ) + remoteStreamURLString = "" + } + + private func saveRemoteStreamAsset( + name: String, + displayName: String, + urlString: String, + basePath: URL, + loadImmediately: Bool + ) { let streamModelsFolder = basePath.appendingPathComponent("StreamModels", isDirectory: true) do { try FileManager.default.createDirectory(at: streamModelsFolder, withIntermediateDirectories: true) let fileURL = streamModelsFolder .appendingPathComponent(name) .appendingPathExtension("remotestream") - try urlStr.write(to: fileURL, atomically: true, encoding: .utf8) - remoteStreamURLString = "" + try urlString.write(to: fileURL, atomically: true, encoding: .utf8) loadAssets() - showStatus("Remote stream '\(baseName)' added") + let asset = Asset( + name: displayName, + category: AssetCategory.streamModels.rawValue, + path: fileURL, + isFolder: false + ) + selectedCategory = AssetCategory.streamModels.rawValue + folderPathStack = [] + if loadImmediately { + loadRemoteStreamModel(from: asset) + } else { + showStatus("Remote stream '\(displayName)' added") + } } catch { showStatus("Failed to save remote stream: \(error.localizedDescription)", isError: true) } @@ -1458,6 +1489,9 @@ struct AssetBrowserView: View { let sceneName = asset.name setEntityName(entityId: sceneRoot, name: sceneName) + clearSceneBatches() + GeometryStreamingSystem.shared.enabled = true + setEntityStreamScene(entityId: sceneRoot, url: url) { success in DispatchQueue.main.async { if success { diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index 7898e22..a24f829 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -11,13 +11,142 @@ public struct Asset: Identifiable { var isFolder: Bool = false } +private struct WelcomeStartView: View { + var onStarterStreamSelected: (StreamModelCatalogItem) -> Void + var onQuickPreview: (QuickPreviewImportMode) -> Void + var onNewProject: () -> Void + var onOpenProject: () -> Void + var onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("Start Here") + .font(.headline) + .foregroundColor(.white) + Spacer() + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.white.opacity(0.8)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .focusable(false) + .help("Dismiss") + } + + Menu { + ForEach(starterStreamModels) { item in + Button { + onStarterStreamSelected(item) + } label: { + Label(item.title, systemImage: "square.stack.3d.up.fill") + } + } + } label: { + Label("Starter Streams", systemImage: "square.stack.3d.up.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(Color.editorSecondaryAccent) + .menuStyle(.button) + .focusable(false) + + HStack(spacing: 8) { + Menu { + ForEach(QuickPreviewImportMode.allCases, id: \.self) { mode in + Button { + onQuickPreview(mode) + } label: { + Label(mode.menuTitle, systemImage: mode.systemImageName) + } + } + } label: { + Label("Load Preview", systemImage: "eye.fill") + .frame(maxWidth: .infinity) + } + .menuStyle(.button) + .focusable(false) + + Button(action: onNewProject) { + Label("New", systemImage: "hammer.fill") + .frame(maxWidth: .infinity) + } + .focusable(false) + + Button(action: onOpenProject) { + Label("Open", systemImage: "folder.fill") + .frame(maxWidth: .infinity) + } + .focusable(false) + } + } + .padding(16) + .frame(width: 360) + .background(Color.editorPanelBackground.opacity(0.96)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.editorDivider, lineWidth: 1) + ) + .cornerRadius(8) + .shadow(color: .black.opacity(0.25), radius: 16, x: 0, y: 8) + } +} + +private struct CameraControlHintsView: View { + var onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Label("Camera Controls", systemImage: "video.fill") + .font(.caption.weight(.semibold)) + .foregroundColor(.white) + Spacer() + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.white.opacity(0.8)) + .frame(width: 20, height: 20) + } + .buttonStyle(.plain) + .focusable(false) + .help("Dismiss") + } + + VStack(alignment: .leading, spacing: 6) { + Label("Two-finger drag to orbit", systemImage: "arrow.triangle.2.circlepath") + Label("Scroll or pinch to zoom", systemImage: "magnifyingglass") + Label("WASD moves, Q/E raises and lowers", systemImage: "keyboard") + } + .font(.caption) + .foregroundColor(.white.opacity(0.82)) + .labelStyle(.titleAndIcon) + } + .padding(12) + .frame(maxWidth: 320) + .background(Color.editorPanelBackground.opacity(0.94)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.editorDivider, lineWidth: 1) + ) + .cornerRadius(8) + .shadow(color: .black.opacity(0.25), radius: 12, x: 0, y: 6) + } +} + public struct EditorView: View { @State private var editor_entities: [EntityID] = getAllGameEntities() @StateObject private var selectionManager = SelectionManager() @StateObject private var sceneGraphModel = SceneGraphModel() + @ObservedObject private var editorBasePath = EditorAssetBasePath.shared @State private var assets: [String: [Asset]] = [:] @State private var selectedAsset: Asset? = nil @State private var isPlaying = false + @State private var showCreateProject = false + @State private var showInvalidProjectAlert = false + @State private var invalidProjectMessage = "" @State private var showSaveNamePrompt = false @State private var pendingSceneName: String = "untitled" @State private var showOverwriteAlert = false @@ -25,6 +154,9 @@ public struct EditorView: View { @State private var isSaveAs = false @State private var showSaveBasePathAlert = false @State private var useSceneCameraDuringPlay = false + @State private var showWelcomeStart = true + @State private var showCameraControlHints = false + @State private var cameraControlHintsDismissed = false @State private var showQuickPreviewWarning = false @State private var quickPreviewEntities: [(EntityID, String)] = [] @@ -68,6 +200,7 @@ public struct EditorView: View { onCreatePlane: editor_createPlane, onCreateCylinder: editor_createCylinder, onCreateCone: editor_createCone, + onStarterStreamSelected: editor_loadStarterStream, onQuickPreview: editor_handleQuickPreview ) Divider() @@ -97,6 +230,40 @@ public struct EditorView: View { .overlay(alignment: .topLeading) { EngineStatsOverlayView() } + .overlay { + if shouldShowWelcomeStart { + WelcomeStartView( + onStarterStreamSelected: { item in + showWelcomeStart = false + editor_loadStarterStream(item) + }, + onQuickPreview: { mode in + showWelcomeStart = false + editor_handleQuickPreview(mode: mode) + }, + onNewProject: { + showWelcomeStart = false + showCreateProject = true + }, + onOpenProject: { + showWelcomeStart = false + openExistingProjectFromWelcome() + }, + onDismiss: { + showWelcomeStart = false + } + ) + .padding() + } + } + .overlay(alignment: .bottom) { + if shouldShowCameraControlHints { + CameraControlHintsView { + dismissCameraControlHints() + } + .padding(.bottom, 14) + } + } TransformManipulationToolbar(controller: editorController!) .frame(height: 40) TabView { @@ -217,6 +384,14 @@ public struct EditorView: View { } message: { Text("A scene with that name already exists. Overwrite it?") } + .sheet(isPresented: $showCreateProject) { + CreateProjectView() + } + .alert("Invalid Project", isPresented: $showInvalidProjectAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(invalidProjectMessage) + } .alert("No Project Loaded", isPresented: $showSaveBasePathAlert) { Button("OK", role: .cancel) {} } message: { @@ -237,6 +412,94 @@ public struct EditorView: View { } } + private var shouldShowWelcomeStart: Bool { + showWelcomeStart + && editorBasePath.basePath == nil + && hasQuickPreviewContent() == false + } + + private var shouldShowCameraControlHints: Bool { + showCameraControlHints + && shouldShowWelcomeStart == false + && hasQuickPreviewContent() + } + + private func hasQuickPreviewContent() -> Bool { + getAllGameEntities().contains { entityId in + hasComponent(entityId: entityId, componentType: QuickPreviewComponent.self) + } + } + + private func revealCameraControlHintsIfNeeded() { + guard cameraControlHintsDismissed == false else { + return + } + + showCameraControlHints = true + } + + private func dismissCameraControlHints() { + cameraControlHintsDismissed = true + showCameraControlHints = false + } + + private func openExistingProjectFromWelcome() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = false + panel.message = "Select the UntoldEngine project folder (the folder containing the .xcodeproj file)" + panel.prompt = "Open Project" + + guard panel.runModal() == .OK, let projectURL = panel.url else { + showWelcomeStart = true + return + } + + let fm = FileManager.default + let projectName = projectURL.lastPathComponent + let xcodeProjectPath = projectURL.appendingPathComponent("\(projectName).xcodeproj") + guard fm.fileExists(atPath: xcodeProjectPath.path) else { + invalidProjectMessage = "This doesn't appear to be a valid UntoldEngine project.\n\nExpected to find: \(projectName).xcodeproj" + showInvalidProjectAlert = true + showWelcomeStart = true + return + } + + let gameDataPath = projectURL + .appendingPathComponent("Sources") + .appendingPathComponent(projectName) + .appendingPathComponent("GameData") + + if !fm.fileExists(atPath: gameDataPath.path) { + do { + try fm.createDirectory(at: gameDataPath, withIntermediateDirectories: true) + print("📁 Created missing GameData folder structure") + } catch { + invalidProjectMessage = "Failed to create GameData folder structure:\n\n\(error.localizedDescription)" + showInvalidProjectAlert = true + showWelcomeStart = true + return + } + } + + let assetFolders = ["Models", "StreamModels", "Animations", "Scenes", "Scripts", "Gaussians", "Materials", "HDR", "Shaders"] + for folder in assetFolders { + let folderURL = gameDataPath.appendingPathComponent(folder, isDirectory: true) + if !fm.fileExists(atPath: folderURL.path) { + try? fm.createDirectory(at: folderURL, withIntermediateDirectories: true) + } + } + + NotificationCenter.default.post(name: .projectWillSwitch, object: nil) + assetBasePath = gameDataPath + EditorAssetBasePath.shared.basePath = gameDataPath + + print("✅ Opened project: \(projectName)") + print("📁 Asset base path set to: \(gameDataPath.path)") + } + private func editor_handleSave() { guard assetBasePath != nil else { showSaveBasePathAlert = true @@ -713,6 +976,61 @@ public struct EditorView: View { // MARK: - Quick Preview + private func editor_loadStarterStream(_ item: StreamModelCatalogItem) { + deleteExistingQuickPreviewEntities() + + removeGizmo() + let entityId = createEntity() + let uniqueName = "QuickPreview-\(item.title)-\(entityId)" + setEntityName(entityId: entityId, name: uniqueName) + + if let quickPreviewComp = scene.assign(to: entityId, component: QuickPreviewComponent.self) { + quickPreviewComp.absoluteFilePath = item.manifestURL.absoluteString + quickPreviewComp.fileExtension = "json" + quickPreviewComp.originalFileName = item.title + } + + clearSceneBatches() + GeometryStreamingSystem.shared.enabled = true + + setEntityStreamScene(entityId: entityId, url: item.manifestURL) { success in + DispatchQueue.main.async { + if success { + applyStarterStreamCameraFrame(item) + revealCameraControlHintsIfNeeded() + print("✅ Starter stream loaded: \(item.title)") + } else { + print("⚠️ Failed to load starter stream: \(item.title)") + } + sceneGraphModel.refreshHierarchy() + } + } + + selectionManager.selectedEntity = entityId + editor_entities = getAllGameEntities() + sceneGraphModel.refreshHierarchy() + + print("ℹ️ Quick Preview mode: Starter stream loaded from \(item.manifestURL.absoluteString)") + print("⚠️ Note: Quick Preview entities cannot be saved to scenes") + } + + private func applyStarterStreamCameraFrame(_ item: StreamModelCatalogItem) { + let camera = findSceneCamera() + let frame = item.cameraFrame + + cameraLookAt(entityId: camera, eye: frame.eye, target: frame.target, up: cameraUpDefault) + CameraSystem.shared.activeCamera = camera + + if frame.usesOriginOrbit { + let radius = simd_length(frame.eye - frame.target) + if radius > 0.001 { + setOrbitOffset(entityId: camera, uTargetOffset: radius) + } + } else { + setOrbitOffset(entityId: camera, uTargetOffset: 25.0) + } + } + private func editor_handleQuickPreview(mode: QuickPreviewImportMode) { let openPanel = NSOpenPanel() openPanel.title = mode.filePickerTitle @@ -757,6 +1075,9 @@ public struct EditorView: View { // Load Untold runtime asset using absolute path setEntityMeshAsync(entityId: entityId, filename: absolutePath, withExtension: fileExtension) { success in if success { + DispatchQueue.main.async { + revealCameraControlHintsIfNeeded() + } print("✅ Quick Preview loaded: \(fileName).\(fileExtension)") } else { print("⚠️ Failed to load Quick Preview, using fallback: \(fileName).\(fileExtension)") @@ -768,6 +1089,7 @@ public struct EditorView: View { // Load Gaussian PLY using absolute path setEntityGaussian(entityId: entityId, filename: absolutePath, withExtension: fileExtension) + revealCameraControlHintsIfNeeded() print("✅ Quick Preview Gaussian loaded: \(fileName).\(fileExtension)") } else if fileExtension == "json" { clearSceneBatches() @@ -776,6 +1098,7 @@ public struct EditorView: View { setEntityStreamScene(entityId: entityId, url: fileURL) { success in DispatchQueue.main.async { if success { + revealCameraControlHintsIfNeeded() print("✅ Quick Preview stream model loaded: \(fileName).\(fileExtension)") } else { print("⚠️ Failed to load Quick Preview stream model: \(fileName).\(fileExtension)") diff --git a/Sources/UntoldEditor/Editor/StarterStreamModels.swift b/Sources/UntoldEditor/Editor/StarterStreamModels.swift new file mode 100644 index 0000000..9d262ea --- /dev/null +++ b/Sources/UntoldEditor/Editor/StarterStreamModels.swift @@ -0,0 +1,87 @@ +// +// StarterStreamModels.swift +// +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// +import Foundation +import simd + +struct StreamModelCatalogItem: Identifiable, Hashable { + let id: String + let title: String + let manifestURL: URL +} + +struct StreamModelCameraFrame { + let eye: simd_float3 + let target: simd_float3 + let usesOriginOrbit: Bool +} + +let starterStreamModels: [StreamModelCatalogItem] = [ + .init( + id: "dungeon", + title: "Game Dungeon", + manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/dungeon3/dungeon3.json")! + ), + .init( + id: "city", + title: "Cartoon City", + manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/city/city.json")! + ), + .init( + id: "f1car", + title: "Formula 1", + manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/F1Car/F1Car.json")! + ), + .init( + id: "airplane", + title: "Skyhawk", + manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/Shyhawk_stream/Skyhawks.json")! + ), + .init( + id: "porsche964", + title: "Porsche 964", + manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/Porsche964-stream/Porsche964-stream.json")! + ), +] + +extension StreamModelCatalogItem { + var cameraFrame: StreamModelCameraFrame { + switch id { + case "city": + StreamModelCameraFrame( + eye: simd_float3(0.00, 18.35, 73.56), + target: simd_float3(0.0, 0.0, -2.0), + usesOriginOrbit: false + ) + case "f1car": + StreamModelCameraFrame( + eye: simd_float3(0.0, 2.0, 6.0), + target: simd_float3(0.0, 0.0, 0.0), + usesOriginOrbit: true + ) + case "airplane": + StreamModelCameraFrame( + eye: simd_float3(0.0, 2.0, 3.0), + target: simd_float3(0.0, 0.0, 0.0), + usesOriginOrbit: true + ) + case "porsche964": + StreamModelCameraFrame( + eye: simd_float3(0.0, 7.0, 15.0), + target: simd_float3(0.0, 0.0, 0.0), + usesOriginOrbit: true + ) + default: + StreamModelCameraFrame( + eye: simd_float3(0.0, 1.0, 4.0), + target: simd_float3(0.0, 0.0, -2.0), + usesOriginOrbit: false + ) + } + } +} diff --git a/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift index 0225ee0..fa08714 100644 --- a/Sources/UntoldEditor/Editor/ToolbarView.swift +++ b/Sources/UntoldEditor/Editor/ToolbarView.swift @@ -31,6 +31,7 @@ var onCreatePlane: () -> Void var onCreateCylinder: () -> Void var onCreateCone: () -> Void + var onStarterStreamSelected: (StreamModelCatalogItem) -> Void = { _ in } var onQuickPreview: (QuickPreviewImportMode) -> Void @State private var isPlaying = false @@ -102,6 +103,31 @@ .buttonStyle(.plain) .focusable(false) + Menu { + ForEach(starterStreamModels) { item in + Button { + onStarterStreamSelected(item) + } label: { + Label(item.title, systemImage: "square.stack.3d.up.fill") + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: "square.stack.3d.up.fill") + Text("Starter Streams") + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .bold)) + } + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(Color.editorSecondaryAccent) + .foregroundColor(.white) + .cornerRadius(6) + } + .menuStyle(.borderlessButton) + .focusable(false) + .help("Load a starter stream model without creating a project") + Menu { ForEach(QuickPreviewImportMode.allCases, id: \.self) { mode in Button { diff --git a/Sources/UntoldEditor/Renderer/EditorUntoldRendererConfig.swift b/Sources/UntoldEditor/Renderer/EditorUntoldRendererConfig.swift index 9619f4a..be6a7a6 100644 --- a/Sources/UntoldEditor/Renderer/EditorUntoldRendererConfig.swift +++ b/Sources/UntoldEditor/Renderer/EditorUntoldRendererConfig.swift @@ -13,7 +13,9 @@ public extension UntoldRendererConfig { static var editor: UntoldRendererConfig { UntoldRendererConfig( initPipelineBlocks: EditorDefaultPipeLines(), - updateRenderingSystemCallback: EditorUpdateRenderingSystem + updateRenderingSystemCallback: { view in + EditorUpdateRenderingSystem(in: view) + } ) } } diff --git a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift index 2ec6309..27c3cd7 100644 --- a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift +++ b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift @@ -13,8 +13,16 @@ import simd import UntoldEngine + private final class EditorInputTargetViewRef { + weak var view: NSView? + } + + private let editorInputTargetViewRef = EditorInputTargetViewRef() + public extension InputSystem { func setupGestureRecognizers(view: NSView) { + editorInputTargetViewRef.view = view + // Pinch gesture let pinchGesture = NSMagnificationGestureRecognizer(target: self, action: #selector(handlePinch(_:))) view.addGestureRecognizer(pinchGesture) @@ -108,6 +116,10 @@ } func handleMouseScroll(_ event: NSEvent) { + guard isEventInsideEditorInputView(event) else { + return + } + var deltaX: Double = event.scrollingDeltaX var deltaY: Double = event.scrollingDeltaY @@ -128,14 +140,22 @@ scrollDelta = 0.01 * simd_float2(Float(deltaX), Float(deltaY)) - if deltaX != 0.0 || deltaY != 0.0 { - // if shiftKey{ - // delta=0.01*simd_float3(0.0,Float(deltaY),0.0) - // camera.moveCameraAlongAxis(uDelta: delta) - // } + if deltaY != 0.0 { + let zoomScale: Float = event.hasPreciseScrollingDeltas ? 0.025 : 0.15 + zoomSceneCamera(by: Float(deltaY) * zoomScale) + } + } - // camera.moveCameraAlongAxis(uDelta: delta) + private func isEventInsideEditorInputView(_ event: NSEvent) -> Bool { + guard let view = editorInputTargetViewRef.view, + let eventWindow = event.window, + eventWindow === view.window + else { + return false } + + let location = view.convert(event.locationInWindow, from: nil) + return view.bounds.contains(location) } func handlePinchGesture(_ gestureRecognizer: NSMagnificationGestureRecognizer, in _: NSView) { @@ -150,6 +170,7 @@ // determine the direction of the pinch let scaleDiff = currentScale - previousScale pinchDelta = 3.0 * simd_float3(0.0, 0.0, Float(1.0) * Float(scaleDiff)) + zoomSceneCamera(by: Float(scaleDiff) * 8.0) previousScale = currentScale @@ -157,11 +178,49 @@ } else if gestureRecognizer.state == .ended { previousScale = 1.0 + pinchDelta = .init(0, 0, 0) currentPinchGestureState = .ended } } + private func zoomSceneCamera(by delta: Float) { + guard delta.isFinite, abs(delta) > 0.0001 else { + return + } + + let camera = findSceneCamera() + guard let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else { + handleError(.noActiveCamera) + return + } + + let target = getCameraTarget(entityId: camera) + let eye = cameraComponent.localPosition + let targetVector = target - eye + let distance = simd_length(targetVector) + + guard distance > 0.001 else { + moveCameraAlongAxis(entityId: camera, uDelta: simd_float3(0, 0, delta)) + return + } + + let minDistance: Float = 0.25 + let maxForwardStep = max(0.0, distance - minDistance) + let maxBackwardStep = max(5.0, distance * 0.5) + let clampedDelta: Float = Swift.min(Swift.max(delta, -maxBackwardStep), maxForwardStep) + guard abs(clampedDelta) > 0.0001 else { + return + } + + let direction = simd_normalize(targetVector) + let newEye = eye + direction * clampedDelta + let currentUp = getCameraUp(entityId: camera) + let up = simd_length(currentUp) > 0.001 ? currentUp : cameraUpDefault + + cameraLookAt(entityId: camera, eye: newEye, target: target, up: up) + } + func mouseRaycast(gestureRecognizer: NSClickGestureRecognizer, in view: NSView) { guard editorController?.isEnabled == true else { return @@ -241,7 +300,11 @@ // Store initial state initialPanLocation = currentPanLocation currentPanGestureState = .began - setOrbitOffset(entityId: findSceneCamera(), uTargetOffset: length(cameraComponent.localPosition)) + let orbitDistance = simd_length(cameraComponent.localPosition - getCameraTarget(entityId: findSceneCamera())) + setOrbitOffset( + entityId: findSceneCamera(), + uTargetOffset: orbitDistance > 0.001 ? orbitDistance : length(cameraComponent.localPosition) + ) cameraControlMode = .orbiting // Editor-only: hit-test gizmo if editor/gizmo mode is active diff --git a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift index 84cc2a3..af13b98 100644 --- a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift +++ b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift @@ -10,7 +10,16 @@ import MetalKit import QuartzCore import UntoldEngine +@MainActor func EditorUpdateRenderingSystem(in view: MTKView) { + // While assets are loading, keep rendering from the last-known-good visible + // list and avoid ECS traversal in culling / gaussian prep. + let loading = AssetLoadingGate.shared.isLoadingAny + + if !loading { + visibleEntityIds = tripleVisibleEntities.snapshotForRead(frame: cullFrameIndex) + } + // Limit in-flight command buffers so triple-buffered culling data isn't overwritten commandBufferSemaphore.wait() @@ -19,27 +28,38 @@ func EditorUpdateRenderingSystem(in view: MTKView) { let renderTotalStart = CACurrentMediaTime() #endif renderInfo.lastCommandBuffer = commandBuffer + renderInfo.currentInFlightFrameSlot = acquireUniformFrameSlot() - #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 + // Keep scene-root-derived camera/light matrices current before culling + // and render passes read them. + SceneRootTransform.shared.updateIfNeeded() - 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 !loading { + #if ENGINE_STATS_ENABLED + let renderPrepStart = CACurrentMediaTime() + let cullingStart = CACurrentMediaTime() + #endif + EngineProfiler.shared.beginScope(.renderPrep) + EngineProfiler.shared.beginScope(.culling) + performFrustumCulling(commandBuffer: commandBuffer) + EngineProfiler.shared.endScope(.culling) + #if ENGINE_STATS_ENABLED + let cullingMs = (CACurrentMediaTime() - cullingStart) * 1000.0 + EngineStatsMonitor.shared.update { snapshot in + snapshot.timing.cullingMs += cullingMs + } + #endif + + executeGaussianDepth(commandBuffer) + executeBitonicSort(commandBuffer) + EngineProfiler.shared.endScope(.renderPrep) + #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 @@ -69,10 +89,12 @@ func EditorUpdateRenderingSystem(in view: MTKView) { #if ENGINE_STATS_ENABLED let encodeStart = CACurrentMediaTime() #endif + EngineProfiler.shared.beginScope(.encode) 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) + EngineProfiler.shared.endScope(.encode) #if ENGINE_STATS_ENABLED let encodeMs = (CACurrentMediaTime() - encodeStart) * 1000.0 EngineStatsMonitor.shared.update { snapshot in @@ -85,6 +107,8 @@ func EditorUpdateRenderingSystem(in view: MTKView) { commandBuffer.present(drawable) } + EngineProfiler.shared.attach(to: commandBuffer, label: "EditorFrame") + let visibleEntityIdsAtSubmission = visibleEntityIds commandBuffer.addCompletedHandler { cb in #if ENGINE_STATS_ENABLED let gpuExecutionMs = (cb.gpuEndTime - cb.gpuStartTime) * 1000.0 @@ -92,10 +116,8 @@ func EditorUpdateRenderingSystem(in view: MTKView) { #endif // Release the in-flight slot commandBufferSemaphore.signal() - DispatchQueue.main.async { - needsFinalizeDestroys = true - visibleEntityIds = tripleVisibleEntities.snapshotForRead(frame: cullFrameIndex) - } + needsFinalizeDestroys = true + MemoryBudgetManager.shared.markUsed(entityIds: visibleEntityIdsAtSubmission) } #if ENGINE_STATS_ENABLED From 555b101018ef291eb859449513c71fed92a43d92 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 1 Jun 2026 22:06:37 -0700 Subject: [PATCH 2/5] [Patch] Implemented collapsible children in the Scene Graph --- .../Editor/SceneHierarchyView.swift | 47 ++++++++++++---- .../Editor/SelectionManager.swift | 20 +++++++ .../SceneHierarchyViewTests.swift | 55 +++++++++++++++++++ 3 files changed, 111 insertions(+), 11 deletions(-) diff --git a/Sources/UntoldEditor/Editor/SceneHierarchyView.swift b/Sources/UntoldEditor/Editor/SceneHierarchyView.swift index 8dc77a1..b66eb91 100644 --- a/Sources/UntoldEditor/Editor/SceneHierarchyView.swift +++ b/Sources/UntoldEditor/Editor/SceneHierarchyView.swift @@ -117,6 +117,9 @@ struct SceneHierarchyView: View { struct EntityRow: View { let entityid: EntityID let entityName: String + var hasChildren: Bool = false + var isExpanded: Bool = true + var onToggleExpanded: () -> Void = {} @ObservedObject var selectionManager: SelectionManager @State private var isDragOver = false @@ -146,6 +149,17 @@ struct EntityRow: View { private var entityRowContent: some View { HStack(spacing: 8) { + Button(action: onToggleExpanded) { + Image(systemName: hasChildren ? (isExpanded ? "chevron.down" : "chevron.right") : "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(hasChildren ? .secondary : .clear) + .frame(width: 12, height: 12) + } + .buttonStyle(.plain) + .focusable(false) + .disabled(hasChildren == false) + .help(isExpanded ? "Collapse Children" : "Expand Children") + Image(systemName: isAssetNode ? (isBindableAssetMeshNode(entityid) ? "cube.fill" : "square.stack.3d.up") : "cube") .foregroundColor(isSelected ? .white : (isAssetNode ? .secondary : .gray)) @@ -173,10 +187,19 @@ struct HierarchyNode: View { } private var nodeContent: some View { - VStack(alignment: .leading, spacing: 4) { + let children = sceneGraphModel.getChildren(entityId: entityId) + let hasChildren = children.isEmpty == false + let isExpanded = sceneGraphModel.isExpanded(entityId: entityId) + + return VStack(alignment: .leading, spacing: 4) { EntityRow( entityid: entityId, entityName: entityName, + hasChildren: hasChildren, + isExpanded: isExpanded, + onToggleExpanded: { + sceneGraphModel.toggleExpanded(entityId: entityId) + }, selectionManager: selectionManager ) .contentShape(Rectangle()) @@ -197,16 +220,18 @@ struct HierarchyNode: View { ) // Children - ForEach(sceneGraphModel.getChildren(entityId: entityId), id: \.self) { childID in - HierarchyNode( - entityId: childID, - entityName: getEntityName(entityId: childID), - depth: depth + 1, - sceneGraphModel: sceneGraphModel, - selectionManager: selectionManager, - onParentEntity: onParentEntity, - onUnparentEntity: onUnparentEntity - ) + if isExpanded { + ForEach(children, id: \.self) { childID in + HierarchyNode( + entityId: childID, + entityName: getEntityName(entityId: childID), + depth: depth + 1, + sceneGraphModel: sceneGraphModel, + selectionManager: selectionManager, + onParentEntity: onParentEntity, + onUnparentEntity: onUnparentEntity + ) + } } } } diff --git a/Sources/UntoldEditor/Editor/SelectionManager.swift b/Sources/UntoldEditor/Editor/SelectionManager.swift index 194d581..6b3a532 100644 --- a/Sources/UntoldEditor/Editor/SelectionManager.swift +++ b/Sources/UntoldEditor/Editor/SelectionManager.swift @@ -53,6 +53,7 @@ struct MeshInspectionSelection: Equatable { class SceneGraphModel: ObservableObject { @Published var childrenMap: [EntityID: [EntityID]] = [:] + @Published private(set) var collapsedEntityIds: Set = [] func refreshHierarchy() { let allEntities = getAllGameEntities() @@ -64,11 +65,30 @@ class SceneGraphModel: ObservableObject { } return getEntityParent(entityId: entityId) ?? .invalid } + + let currentEntityIds = Set(allEntities) + collapsedEntityIds = collapsedEntityIds.intersection(currentEntityIds) } func getChildren(entityId: EntityID?) -> [EntityID] { childrenMap[entityId ?? .invalid] ?? [] } + + func hasChildren(entityId: EntityID) -> Bool { + getChildren(entityId: entityId).isEmpty == false + } + + func isExpanded(entityId: EntityID) -> Bool { + collapsedEntityIds.contains(entityId) == false + } + + func toggleExpanded(entityId: EntityID) { + if collapsedEntityIds.contains(entityId) { + collapsedEntityIds.remove(entityId) + } else { + collapsedEntityIds.insert(entityId) + } + } } class SelectionManager: ObservableObject { diff --git a/Tests/UntoldEditorTests/SceneHierarchyViewTests.swift b/Tests/UntoldEditorTests/SceneHierarchyViewTests.swift index 5b95edc..9a8ae73 100644 --- a/Tests/UntoldEditorTests/SceneHierarchyViewTests.swift +++ b/Tests/UntoldEditorTests/SceneHierarchyViewTests.swift @@ -230,6 +230,61 @@ final class SceneHierarchyViewTests: XCTestCase { XCTAssertEqual(children.count, 0, "Entity with no children should return empty array") } + func test_sceneGraphModel_entityWithChildrenReportsHasChildren() { + // Arrange + let parent = createEntity() + let child = createEntity() + registerSceneGraphComponent(entityId: parent) + registerSceneGraphComponent(entityId: child) + setParent(childId: child, parentId: parent) + + // Act + sceneGraphModel.refreshHierarchy() + + // Assert + XCTAssertTrue(sceneGraphModel.hasChildren(entityId: parent), "Parent should report children") + XCTAssertFalse(sceneGraphModel.hasChildren(entityId: child), "Leaf child should not report children") + } + + func test_sceneGraphModel_nodesAreExpandedByDefault() { + // Arrange + let entity = createEntity() + + // Act + sceneGraphModel.refreshHierarchy() + + // Assert + XCTAssertTrue(sceneGraphModel.isExpanded(entityId: entity), "Nodes should be expanded by default") + } + + func test_sceneGraphModel_toggleExpandedCollapsesAndExpandsNode() { + // Arrange + let entity = createEntity() + sceneGraphModel.refreshHierarchy() + + // Act / Assert + sceneGraphModel.toggleExpanded(entityId: entity) + XCTAssertFalse(sceneGraphModel.isExpanded(entityId: entity), "Toggle should collapse expanded nodes") + + sceneGraphModel.toggleExpanded(entityId: entity) + XCTAssertTrue(sceneGraphModel.isExpanded(entityId: entity), "Toggle should expand collapsed nodes") + } + + func test_sceneGraphModel_refreshPrunesCollapsedDestroyedEntities() { + // Arrange + let entity = createEntity() + sceneGraphModel.refreshHierarchy() + sceneGraphModel.toggleExpanded(entityId: entity) + XCTAssertFalse(sceneGraphModel.isExpanded(entityId: entity)) + + // Act + destroyEntity(entityId: entity) + sceneGraphModel.refreshHierarchy() + + // Assert + XCTAssertTrue(sceneGraphModel.isExpanded(entityId: entity), "Destroyed entities should be pruned from collapsed state") + } + // MARK: - SelectionManager Integration Tests func test_selectionManager_updatesSelectedEntity() { From 7fb8547e40f72f9c9c70128a53d2953a1fad22c4 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 2 Jun 2026 22:49:57 -0700 Subject: [PATCH 3/5] [Patch] Updated editor to TBDR pass --- .../UntoldEditor/Editor/InspectorView.swift | 110 ++++++++++++++++++ .../Systems/EditorRenderingSystem.swift | 11 +- .../EditorRenderingSystemTests.swift | 12 +- 3 files changed, 124 insertions(+), 9 deletions(-) diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index 1c74d7f..de55dc1 100644 --- a/Sources/UntoldEditor/Editor/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/InspectorView.swift @@ -308,6 +308,11 @@ struct InspectorView: View { AssetNodeInspectorBanner(entityId: entityId, selectionManager: selectionManager) } + if hasComponent(entityId: entityId, componentType: TileComponent.self) { + TileMeshListInspectorView(entityId: entityId) + Divider() + } + if let inspectedMesh = selectionManager.inspectedMesh, inspectedMesh.entityId == entityId { @@ -992,6 +997,111 @@ private struct AssetNodeInspectorBanner: View { } } +private struct TileMeshListInspectorView: View { + let entityId: EntityID + + private var tileComponent: TileComponent? { + scene.get(component: TileComponent.self, for: entityId) + } + + private var meshEntries: [TileMeshInspectorEntry] { + getEntityChildren(parentId: entityId) + .flatMap { childId -> [TileMeshInspectorEntry] in + guard let renderComponent = scene.get(component: RenderComponent.self, for: childId) else { + return [] + } + + return renderComponent.mesh.enumerated().map { meshIndex, mesh in + TileMeshInspectorEntry( + entityId: childId, + meshIndex: meshIndex, + name: mesh.name.trimmingCharacters(in: .whitespacesAndNewlines), + submeshCount: mesh.submeshes.count + ) + } + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("Tile Meshes", systemImage: "square.stack.3d.up") + .font(.headline) + Spacer() + Text("\(meshEntries.count)") + .font(.caption) + .foregroundColor(.secondary) + } + + if let tileComponent { + VStack(alignment: .leading, spacing: 4) { + tileInfoRow("Tile", tileComponent.tileId.isEmpty ? getEntityName(entityId: entityId) : tileComponent.tileId) + tileInfoRow("State", String(describing: tileComponent.state)) + tileInfoRow("Visual", String(describing: tileComponent.visualState)) + } + .font(.caption) + } + + if meshEntries.isEmpty { + Text("No resident meshes for this tile yet.") + .font(.caption) + .foregroundColor(.secondary) + } else { + VStack(alignment: .leading, spacing: 5) { + ForEach(meshEntries) { entry in + HStack(spacing: 6) { + Image(systemName: "cube.fill") + .font(.caption) + .foregroundColor(.secondary) + + Text(entry.displayName) + .font(.caption) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + Text("\(entry.submeshCount) sub") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + } + .padding(8) + .background(Color.secondary.opacity(0.08)) + .cornerRadius(8) + } + + private func tileInfoRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + .foregroundColor(.secondary) + Spacer() + Text(value) + .foregroundColor(.primary) + .lineLimit(1) + .truncationMode(.middle) + } + } +} + +private struct TileMeshInspectorEntry: Identifiable { + let entityId: EntityID + let meshIndex: Int + let name: String + let submeshCount: Int + + var id: String { + "\(entityId)-\(meshIndex)" + } + + var displayName: String { + name.isEmpty ? "Mesh \(meshIndex + 1)" : name + } +} + private struct ReadOnlyVectorView: View { let label: String let value: simd_float3 diff --git a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift index af13b98..8af5c10 100644 --- a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift +++ b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift @@ -168,17 +168,16 @@ func buildEditModeGraph() -> RenderGraphResult { graph[batchedShadowPass.id] = batchedShadowPass let modelPass = RenderPass( - id: "model", dependencies: [batchedShadowPass.id], execute: RenderPasses.modelExecution + id: "model", dependencies: [batchedShadowPass.id], execute: RenderPasses.combinedModelLightExecution ) graph[modelPass.id] = modelPass - // Add batched model pass (runs after regular model pass) - let batchedModelPass = RenderPass( - id: "batchedModel", dependencies: [modelPass.id], execute: RenderPasses.batchedModelExecution - ) + // Geometry and lighting now execute inside the TBDR model pass. Keep the + // legacy graph nodes as dependency anchors for editor overlays. + let batchedModelPass = RenderPass(id: "batchedModel", dependencies: [modelPass.id], execute: nil) graph[batchedModelPass.id] = batchedModelPass - let lightPass = RenderPass(id: "lightPass", dependencies: [batchedModelPass.id, modelPass.id, shadowPass.id], execute: RenderPasses.lightExecution) + let lightPass = RenderPass(id: "lightPass", dependencies: [batchedModelPass.id, modelPass.id, shadowPass.id], execute: nil) graph[lightPass.id] = lightPass let transparencyPass = RenderPass( diff --git a/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift b/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift index 87f3c5e..a8b6105 100644 --- a/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift +++ b/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift @@ -223,16 +223,21 @@ final class EditorRenderingSystemTests: XCTestCase { XCTAssertNil(graph["environment"], "Grid mode should not have environment pass after switching back") } - func test_buildEditModeGraph_allPassesHaveExecutionFunctions() { + func test_buildEditModeGraph_executablePassesHaveExecutionFunctions() { // Arrange renderEnvironment = false // Act let (graph, _) = buildEditModeGraph() - // Assert - Verify all passes have execution functions + // Assert - Verify non-stub passes have execution functions + let stubPassIDs: Set = ["batchedModel", "lightPass"] for (passID, pass) in graph { - XCTAssertNotNil(pass.execute, "Pass '\(passID)' should have an execution function") + if stubPassIDs.contains(passID) { + XCTAssertNil(pass.execute, "Pass '\(passID)' should be a dependency-only stub") + } else { + XCTAssertNotNil(pass.execute, "Pass '\(passID)' should have an execution function") + } } } @@ -313,6 +318,7 @@ final class EditorRenderingSystemTests: XCTestCase { return } + XCTAssertNil(lightPass.execute, "Light pass should be a dependency-only stub") XCTAssertEqual(lightPass.dependencies.count, 3, "Light pass should have exactly 3 dependencies") XCTAssertTrue(lightPass.dependencies.contains("model"), "Light pass should depend on model") XCTAssertTrue(lightPass.dependencies.contains("shadow"), "Light pass should depend on shadow") From d8bb84831ebeb6061cbd739940963d7a60211a32 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 2 Jun 2026 23:06:19 -0700 Subject: [PATCH 4/5] [Patch] Added SSAO parameters to Effects View --- .../UntoldEditor/Editor/EnvironmentView.swift | 10 ++-------- .../Renderer/EditorRenderPasses.swift | 10 ++++++++++ .../Systems/EditorRenderingSystem.swift | 2 +- .../EditorRenderPassesTests.swift | 20 +++++++++++++++++++ 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/Sources/UntoldEditor/Editor/EnvironmentView.swift b/Sources/UntoldEditor/Editor/EnvironmentView.swift index 8a22ed9..4f32449 100644 --- a/Sources/UntoldEditor/Editor/EnvironmentView.swift +++ b/Sources/UntoldEditor/Editor/EnvironmentView.swift @@ -351,14 +351,8 @@ struct SSAOEditorView: View { } UndoableEffectSlider(label: "Radius", undoName: "Change SSAO Radius", range: 0.1 ... 1.0, get: { settings.radius }, set: { settings.radius = $0 }) - -// Text("Bias") -// Slider(value: $settings.bias, in: 0.0 ... 0.1) -// Text(String(format: "%.4f", settings.bias)) -// -// Text("Intensity") -// Slider(value: $settings.intensity, in: 0.0 ... 2.0) -// Text(String(format: "%.2f", settings.intensity)) + UndoableEffectSlider(label: "Bias", undoName: "Change SSAO Bias", range: 0.0 ... 0.1, format: "%.4f", get: { settings.bias }, set: { settings.bias = $0 }) + UndoableEffectSlider(label: "Intensity", undoName: "Change SSAO Intensity", range: 0.0 ... 2.0, get: { settings.intensity }, set: { settings.intensity = $0 }) } .padding() } diff --git a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift index 1be0d8e..32082cd 100644 --- a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift +++ b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift @@ -13,6 +13,16 @@ import MetalKit import UntoldEngine extension RenderPasses { + static let editorPreCompositeExecution: (MTLCommandBuffer) -> Void = { commandBuffer in + let wasSSAOEnabled = SSAOParams.shared.enabled + SSAOParams.shared.enabled = false + defer { + SSAOParams.shared.enabled = wasSSAOEnabled + } + + RenderPasses.preCompositeExecution(commandBuffer) + } + static let gizmoExecution: (MTLCommandBuffer) -> Void = { commandBuffer in if activeEntity == .invalid { return diff --git a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift index 8af5c10..98fb030 100644 --- a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift +++ b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift @@ -211,7 +211,7 @@ func buildEditModeGraph() -> RenderGraphResult { graph[gaussianPass.id] = gaussianPass let preCompPass = RenderPass( - id: "precomp", dependencies: [modelPass.id, gizmoPass.id, spatialDebugPass.id, gaussianPass.id], execute: RenderPasses.preCompositeExecution + id: "precomp", dependencies: [modelPass.id, gizmoPass.id, spatialDebugPass.id, gaussianPass.id], execute: RenderPasses.editorPreCompositeExecution ) graph[preCompPass.id] = preCompPass diff --git a/Tests/UntoldEditorTests/EditorRenderPassesTests.swift b/Tests/UntoldEditorTests/EditorRenderPassesTests.swift index 493fa6c..13506da 100644 --- a/Tests/UntoldEditorTests/EditorRenderPassesTests.swift +++ b/Tests/UntoldEditorTests/EditorRenderPassesTests.swift @@ -103,6 +103,26 @@ final class EditorRenderPassesTests: XCTestCase { XCTAssertNotNil(closure, "highlightExecution closure should be accessible") } + func test_editorPreCompositeExecution_restoresSSAOEnabled() { + // Arrange + let originalSSAOEnabled = SSAOParams.shared.enabled + SSAOParams.shared.enabled = true + defer { + SSAOParams.shared.enabled = originalSSAOEnabled + } + + guard let commandBuffer = commandQueue.makeCommandBuffer() else { + XCTFail("Could not create command buffer") + return + } + + // Act + RenderPasses.editorPreCompositeExecution(commandBuffer) + + // Assert + XCTAssertTrue(SSAOParams.shared.enabled, "Editor pre-composite should not change the user's SSAO setting") + } + // MARK: - Early Return Condition Tests func test_gizmoExecution_returnsEarlyWhenActiveEntityIsInvalid() { From b1b62513e331db2ec72d34d9d4c6334dffca5d94 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 2 Jun 2026 23:49:15 -0700 Subject: [PATCH 5/5] [Release] Preparing release 0.13.0 --- CHANGELOG.md | 6 ++++++ Package.swift | 2 +- Sources/UntoldEditor/Editor/ToolbarView.swift | 2 +- Sources/UntoldEditor/main.swift | 4 ++-- Tests/UntoldEditorTests/EditorRenderingSystemTests.swift | 2 +- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4d781f..73adb47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ # Changelog +## v0.13.0 - 2026-06-03 +### 🐞 Fixes +- [Patch] Made user experience improvements (adf6d2b…) +- [Patch] Implemented collapsible children in the Scene Graph (555b101…) +- [Patch] Updated editor to TBDR pass (7fb8547…) +- [Patch] Added SSAO parameters to Effects View (d8bb848…) ## v0.12.14 - 2026-05-22 ### 🐞 Fixes - [Patch] Migrate editor ray picking to ScenePickingSystem (c059ced…) diff --git a/Package.swift b/Package.swift index 094b464..b2de770 100644 --- a/Package.swift +++ b/Package.swift @@ -13,7 +13,7 @@ let package = Package( // Use a branch during active development: // .package(url: "https://github.com/untoldengine/UntoldEngine.git", branch: "develop"), // Or pin to a release: - .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.12.14"), + .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.13.0"), ], targets: [ .executableTarget( diff --git a/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift index fa08714..558ab02 100644 --- a/Sources/UntoldEditor/Editor/ToolbarView.swift +++ b/Sources/UntoldEditor/Editor/ToolbarView.swift @@ -15,7 +15,7 @@ @ObservedObject var selectionManager: SelectionManager @ObservedObject var editorBasePath = EditorAssetBasePath.shared @ObservedObject private var statsStore = EditorEngineStatsStore.shared - private let editorVersionLabel = "v0.12.14" + private let editorVersionLabel = "v0.13.0" var onSave: () -> Void var onSaveAs: () -> Void diff --git a/Sources/UntoldEditor/main.swift b/Sources/UntoldEditor/main.swift index 851a736..e295f78 100644 --- a/Sources/UntoldEditor/main.swift +++ b/Sources/UntoldEditor/main.swift @@ -17,7 +17,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { var window: NSWindow! func applicationDidFinishLaunching(_: Notification) { - Logger.log(message: "Launching Untold Engine Editor v0.12.14") + Logger.log(message: "Launching Untold Engine Editor v0.13.0") // Step 1. Create and configure the window window = NSWindow( @@ -27,7 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { defer: false ) - window.title = "Untold Engine Editor v0.12.14" + window.title = "Untold Engine Editor v0.13.0" window.center() let hostingView = NSHostingView(rootView: EditorView()) diff --git a/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift b/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift index a8b6105..560b5c2 100644 --- a/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift +++ b/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift @@ -231,7 +231,7 @@ final class EditorRenderingSystemTests: XCTestCase { let (graph, _) = buildEditModeGraph() // Assert - Verify non-stub passes have execution functions - let stubPassIDs: Set = ["batchedModel", "lightPass"] + let stubPassIDs: Set = ["batchedModel", "lightPass"] for (passID, pass) in graph { if stubPassIDs.contains(passID) { XCTAssertNil(pass.execute, "Pass '\(passID)' should be a dependency-only stub")