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/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift
index 29fb8d5..b0bbf0f 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,195 @@ 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)
+ CommitAndDefocusFloatField(value: $streamingRadius)
+ .frame(width: 80)
+ }
+
+ HStack {
+ Text("Unload Radius:")
+ .font(.caption)
+ CommitAndDefocusFloatField(value: $unloadRadius)
+ .frame(width: 80)
+ }
+
+ HStack {
+ Text("Priority:")
+ .font(.caption)
+ CommitAndDefocusIntField(value: $priority)
+ .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
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/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/Systems/EditorRenderingSystem.swift b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift
index 603b4a3..c5ae8a8 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,6 +53,8 @@ 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)
@@ -56,6 +62,9 @@ func EditorUpdateRenderingSystem(in view: MTKView) {
}
commandBuffer.commit()
+ } else {
+ // Failed to create command buffer - release slot
+ commandBufferSemaphore.signal()
}
}
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())