From d78a58a26fcdd743fa8c7268bc835b3e6f36cfd7 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Mon, 9 Feb 2026 07:13:57 -0700 Subject: [PATCH 1/4] [Patch] Added geometry streaming support --- .../UntoldEditor/Editor/InspectorView.swift | 196 +++++++++++++++++- 1 file changed, 195 insertions(+), 1 deletion(-) diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index 29fb8d5..5496bde 100644 --- a/Sources/UntoldEditor/Editor/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/InspectorView.swift @@ -295,6 +295,9 @@ struct InspectorView: View { let sortedComponents = sortEntityComponents(componentOption_Editor: mergedComponents) + // Geometry Streaming Section - Show for any entity with renderable hierarchy + GeometryStreamingEditorView(entityId: entityId, refreshView: refreshView) + // Static Batching Section - Show for any entity with renderable hierarchy StaticBatchingEditorView(entityId: entityId, refreshView: refreshView) @@ -461,7 +464,198 @@ struct InspectorView: View { } */ -// Standalone Static Batching Section +// MARK: - Geometry Streaming Section + +struct GeometryStreamingEditorView: View { + let entityId: EntityID + let refreshView: () -> Void + + @State private var streamingCheckboxState: Bool = false + @State private var streamingRadius: Float = 100.0 + @State private var unloadRadius: Float = 150.0 + @State private var priority: Int = 0 + + // Check if entity or any of its children have RenderComponent + private func hasRenderableHierarchy(entityId: EntityID) -> Bool { + // Check self + if hasComponent(entityId: entityId, componentType: RenderComponent.self) { + return true + } + + // Check children recursively + let children = getEntityChildren(parentId: entityId) + for child in children { + if hasRenderableHierarchy(entityId: child) { + return true + } + } + + return false + } + + // Check if entity or any of its children have StreamingComponent + private func isMarkedForStreaming(entityId: EntityID) -> Bool { + // Check self + if hasComponent(entityId: entityId, componentType: StreamingComponent.self) { + return true + } + + // Check children recursively + let children = getEntityChildren(parentId: entityId) + for child in children { + if isMarkedForStreaming(entityId: child) { + return true + } + } + + return false + } + + // Remove streaming component from entity and all children recursively + private func removeStreamingFromHierarchy(entityId: EntityID) { + // Remove from self + if hasComponent(entityId: entityId, componentType: StreamingComponent.self) { + scene.remove(component: StreamingComponent.self, from: entityId) + } + + // Remove from children recursively + let children = getEntityChildren(parentId: entityId) + for child in children { + removeStreamingFromHierarchy(entityId: child) + } + } + + var body: some View { + // Only show if entity or children have RenderComponent (but not lights) + if hasRenderableHierarchy(entityId: entityId), hasComponent(entityId: entityId, componentType: LightComponent.self) == false { + VStack(alignment: .leading, spacing: 4) { + Text("Geometry Streaming") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + let hasOwnRenderComponent = hasComponent(entityId: entityId, componentType: RenderComponent.self) + let labelText = hasOwnRenderComponent ? "Enable Streaming" : "Enable Streaming for Children" + let helpText = hasOwnRenderComponent + ? "Enable geometry streaming to dynamically load/unload mesh based on camera distance" + : "Enable geometry streaming for all children of this entity" + + Toggle(isOn: Binding( + get: { streamingCheckboxState }, + set: { isEnabled in + if isEnabled { + enableStreaming( + entityId: entityId, + streamingRadius: streamingRadius, + unloadRadius: unloadRadius, + priority: priority + ) + } else { + removeStreamingFromHierarchy(entityId: entityId) + } + streamingCheckboxState = isEnabled + refreshView() + } + )) { + HStack { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundColor(.green) + Text(labelText) + .font(.callout) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background(Color.secondary.opacity(0.05)) + .cornerRadius(8) + .help(helpText) + .onAppear { + // Update checkbox state when view appears + streamingCheckboxState = isMarkedForStreaming(entityId: entityId) + + // Load streaming parameters from first entity with StreamingComponent + if let streaming = scene.get(component: StreamingComponent.self, for: entityId) { + streamingRadius = streaming.streamingRadius + unloadRadius = streaming.unloadRadius + priority = streaming.priority + } else { + // Check children + let children = getEntityChildren(parentId: entityId) + for child in children { + if let streaming = scene.get(component: StreamingComponent.self, for: child) { + streamingRadius = streaming.streamingRadius + unloadRadius = streaming.unloadRadius + priority = streaming.priority + break + } + } + } + } + .onChange(of: entityId) { newEntityId in + // Update checkbox state when entity selection changes + streamingCheckboxState = isMarkedForStreaming(entityId: newEntityId) + } + + // Streaming parameters (only show if streaming is enabled) + if streamingCheckboxState { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Streaming Radius:") + .font(.caption) + TextField("100.0", value: $streamingRadius, format: .number) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .frame(width: 80) + } + + HStack { + Text("Unload Radius:") + .font(.caption) + TextField("150.0", value: $unloadRadius, format: .number) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .frame(width: 80) + } + + HStack { + Text("Priority:") + .font(.caption) + TextField("0", value: $priority, format: .number) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .frame(width: 80) + } + + Button("Apply Changes") { + // Re-apply streaming with new parameters + if streamingCheckboxState { + removeStreamingFromHierarchy(entityId: entityId) + enableStreaming( + entityId: entityId, + streamingRadius: streamingRadius, + unloadRadius: unloadRadius, + priority: priority + ) + refreshView() + } + } + .buttonStyle(PlainButtonStyle()) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.editorAccent) + .foregroundColor(.white) + .cornerRadius(6) + } + .padding(.top, 4) + .padding(.horizontal, 8) + .background(Color.secondary.opacity(0.05)) + .cornerRadius(8) + } + } + + Divider() + } + } +} + +// MARK: - Static Batching Section + struct StaticBatchingEditorView: View { let entityId: EntityID let refreshView: () -> Void From 16469ab4b55186fb707b1f188bf325e2025abad0 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Mon, 9 Feb 2026 17:06:22 -0700 Subject: [PATCH 2/4] [Patch] Fixed flickering issue --- .../UntoldEditor/Systems/EditorRenderingSystem.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift index 603b4a3..0fb1139 100644 --- a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift +++ b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift @@ -10,7 +10,11 @@ import MetalKit import UntoldEngine func EditorUpdateRenderingSystem(in view: MTKView) { + // Limit in-flight command buffers so triple-buffered culling data isn't overwritten + commandBufferSemaphore.wait() + if let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() { + renderInfo.lastCommandBuffer = commandBuffer performFrustumCulling(commandBuffer: commandBuffer) executeGaussianDepth(commandBuffer) @@ -49,13 +53,19 @@ func EditorUpdateRenderingSystem(in view: MTKView) { } commandBuffer.addCompletedHandler { _ in + // Release the in-flight slot + commandBufferSemaphore.signal() DispatchQueue.main.async { needsFinalizeDestroys = true visibleEntityIds = tripleVisibleEntities.snapshotForRead(frame: cullFrameIndex) + } } commandBuffer.commit() + } else { + // Failed to create command buffer - release slot + commandBufferSemaphore.signal() } } From 6e533e1d9097491eb70bad47ee544c768b37aa51 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Mon, 9 Feb 2026 17:07:53 -0700 Subject: [PATCH 3/4] [Patch] Fixed input fields to go un-focus --- .../UntoldEditor/Editor/InspectorView.swift | 9 +- .../Editor/NumericInputView.swift | 180 ++++++++++++++---- .../Systems/EditorRenderingSystem.swift | 1 - 3 files changed, 141 insertions(+), 49 deletions(-) diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index 5496bde..b0bbf0f 100644 --- a/Sources/UntoldEditor/Editor/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/InspectorView.swift @@ -601,24 +601,21 @@ struct GeometryStreamingEditorView: View { HStack { Text("Streaming Radius:") .font(.caption) - TextField("100.0", value: $streamingRadius, format: .number) - .textFieldStyle(RoundedBorderTextFieldStyle()) + CommitAndDefocusFloatField(value: $streamingRadius) .frame(width: 80) } HStack { Text("Unload Radius:") .font(.caption) - TextField("150.0", value: $unloadRadius, format: .number) - .textFieldStyle(RoundedBorderTextFieldStyle()) + CommitAndDefocusFloatField(value: $unloadRadius) .frame(width: 80) } HStack { Text("Priority:") .font(.caption) - TextField("0", value: $priority, format: .number) - .textFieldStyle(RoundedBorderTextFieldStyle()) + CommitAndDefocusIntField(value: $priority) .frame(width: 80) } diff --git a/Sources/UntoldEditor/Editor/NumericInputView.swift b/Sources/UntoldEditor/Editor/NumericInputView.swift index a4c22fd..fa6cfe3 100644 --- a/Sources/UntoldEditor/Editor/NumericInputView.swift +++ b/Sources/UntoldEditor/Editor/NumericInputView.swift @@ -7,15 +7,139 @@ // See the LICENSE file or for details. // #if canImport(AppKit) + import AppKit import simd import SwiftUI + private struct CommitAndDefocusTextField: NSViewRepresentable { + @Binding var text: String + let onSubmit: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text, onSubmit: onSubmit) + } + + func makeNSView(context: Context) -> NSTextField { + let textField = NSTextField(string: text) + textField.delegate = context.coordinator + textField.target = context.coordinator + textField.action = #selector(Coordinator.didSubmitFromAction(_:)) + textField.isBordered = true + textField.isBezeled = true + textField.bezelStyle = .roundedBezel + textField.lineBreakMode = .byClipping + return textField + } + + func updateNSView(_ nsView: NSTextField, context: Context) { + context.coordinator.onSubmit = onSubmit + + if nsView.stringValue != text { + nsView.stringValue = text + } + } + + final class Coordinator: NSObject, NSTextFieldDelegate { + private let text: Binding + var onSubmit: () -> Void + private var suppressNextEndEditingCommit = false + + init(text: Binding, onSubmit: @escaping () -> Void) { + self.text = text + self.onSubmit = onSubmit + } + + func controlTextDidChange(_ notification: Notification) { + guard let textField = notification.object as? NSTextField else { + return + } + + text.wrappedValue = textField.stringValue + } + + func controlTextDidEndEditing(_: Notification) { + if suppressNextEndEditingCommit { + suppressNextEndEditingCommit = false + return + } + + onSubmit() + } + + @objc func didSubmitFromAction(_ sender: NSControl) { + suppressNextEndEditingCommit = true + onSubmit() + sender.window?.makeFirstResponder(nil) + } + + func control(_ control: NSControl, textView _: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) + || commandSelector == #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)) + || commandSelector == #selector(NSResponder.insertLineBreak(_:)) + || commandSelector == #selector(NSResponder.insertTab(_:)) + || commandSelector == #selector(NSResponder.insertBacktab(_:)) + { + suppressNextEndEditingCommit = true + onSubmit() + control.window?.makeFirstResponder(nil) + return true + } + + return false + } + } + } + + public struct CommitAndDefocusFloatField: View { + @Binding var value: Float + @State private var tempValue = "0" + + public init(value: Binding) { + _value = value + } + + public var body: some View { + CommitAndDefocusTextField(text: $tempValue, onSubmit: { + if let parsed = Float(tempValue) { + value = parsed + } + }) + .onAppear { + tempValue = String(value) + } + .onChange(of: value) { _, newValue in + tempValue = String(newValue) + } + } + } + + public struct CommitAndDefocusIntField: View { + @Binding var value: Int + @State private var tempValue = "0" + + public init(value: Binding) { + _value = value + } + + public var body: some View { + CommitAndDefocusTextField(text: $tempValue, onSubmit: { + if let parsed = Int(tempValue) { + value = parsed + } + }) + .onAppear { + tempValue = String(value) + } + .onChange(of: value) { _, newValue in + tempValue = String(newValue) + } + } + } + public struct TextInputVectorView: View { let label: String @Binding var value: SIMD3 @State private var tempValues: [String] = ["0", "0", "0"] - @FocusState private var focusedField: Int? - @State private var lastFocusedField: Int? public init(label: String, value: Binding>) { self.label = label @@ -29,30 +153,17 @@ HStack { ForEach(0 ..< 3, id: \.self) { index in - TextField("", text: Binding( + CommitAndDefocusTextField(text: Binding( get: { tempValues[index] }, set: { tempValues[index] = $0 } - )) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .frame(width: 60) - .focused($focusedField, equals: index) - .onChange(of: value[index]) { _, newValue in - tempValues[index] = String(newValue) // Update when entity changes - } - .onSubmit { + ), onSubmit: { if let newValue = Float(tempValues[index]) { value[index] = newValue } - focusedField = nil - } - .onChange(of: focusedField) { oldValue, newValue in - // Commit when this field loses focus (e.g., via Tab) - if oldValue == index, newValue != index { - if let newValue = Float(tempValues[index]) { - value[index] = newValue - } - } - lastFocusedField = newValue + }) + .frame(width: 60) + .onChange(of: value[index]) { _, newValue in + tempValues[index] = String(newValue) // Update when entity changes } } } @@ -68,8 +179,6 @@ let label: String @Binding var value: Float @State private var tempValues: String = "0" - @FocusState private var focusedField: Int? - @State private var wasFocused: Bool = false public init(label: String, value: Binding) { self.label = label @@ -82,30 +191,17 @@ .font(.headline) HStack { - TextField("", text: Binding( + CommitAndDefocusTextField(text: Binding( get: { tempValues }, set: { tempValues = $0 } - )) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .frame(width: 60) - .focused($focusedField, equals: 1) - .onChange(of: value) { _, newValue in - tempValues = String(newValue) // Update when entity changes - } - .onSubmit { + ), onSubmit: { if let newValue = Float(tempValues) { value = newValue } - focusedField = nil - } - .onChange(of: focusedField) { _, newValue in - // Commit when focus leaves (e.g., Tab) - if wasFocused, newValue != 1 { - if let newValue = Float(tempValues) { - value = newValue - } - } - wasFocused = (newValue == 1) + }) + .frame(width: 60) + .onChange(of: value) { _, newValue in + tempValues = String(newValue) // Update when entity changes } } } diff --git a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift index 0fb1139..c5ae8a8 100644 --- a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift +++ b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift @@ -58,7 +58,6 @@ func EditorUpdateRenderingSystem(in view: MTKView) { DispatchQueue.main.async { needsFinalizeDestroys = true visibleEntityIds = tripleVisibleEntities.snapshotForRead(frame: cullFrameIndex) - } } From 11eae6c90e82e60146a64b8eec156d5fef2fe997 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Mon, 9 Feb 2026 23:32:52 -0700 Subject: [PATCH 4/4] [Release] Prepare release 0.10.0 --- CHANGELOG.md | 5 +++++ Package.swift | 2 +- Sources/UntoldEditor/Editor/ToolbarView.swift | 2 +- Sources/UntoldEditor/main.swift | 4 ++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ced03ad..d9da152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ # Changelog +## v0.10.0 - 2026-02-10 +### 🐞 Fixes +- [Feature] Added geometry streaming support (d78a58a…) +- [Patch] Fixed flickering issue (16469ab…) +- [Patch] Fixed input fields to go un-focus (6e533e1…) ## v0.9.0 - 2026-02-04 ### 🚀 Features - [Feature] Adde LOD support (319502a…) diff --git a/Package.swift b/Package.swift index fb76408..f27ce49 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.9.0"), + .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.10.0"), ], targets: [ .executableTarget( diff --git a/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift index 417d6f4..b620115 100644 --- a/Sources/UntoldEditor/Editor/ToolbarView.swift +++ b/Sources/UntoldEditor/Editor/ToolbarView.swift @@ -14,7 +14,7 @@ struct ToolbarView: View { @ObservedObject var selectionManager: SelectionManager @ObservedObject var editorBasePath = EditorAssetBasePath.shared - private let editorVersionLabel = "v0.9.0" + private let editorVersionLabel = "v0.10.0" var onSave: () -> Void var onSaveAs: () -> Void diff --git a/Sources/UntoldEditor/main.swift b/Sources/UntoldEditor/main.swift index 00005e6..5c2a85e 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.9.0") + Logger.log(message: "Launching Untold Engine Editor v0.10.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.8.2" + window.title = "Untold Engine Editor v0.10.0" window.center() let hostingView = NSHostingView(rootView: EditorView())