diff --git a/Package.swift b/Package.swift
index aeda5a8..fb76408 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.8.2"),
+ .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.9.0"),
],
targets: [
.executableTarget(
diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift
index a92c8e8..c23c1a9 100644
--- a/Sources/UntoldEditor/Editor/EditorView.swift
+++ b/Sources/UntoldEditor/Editor/EditorView.swift
@@ -124,6 +124,11 @@ public struct EditorView: View {
Label("Effects", systemImage: "cube")
}
+ StaticBatchingView()
+ .tabItem {
+ Label("Batching", systemImage: "square.3.layers.3d")
+ }
+
InspectorView(
selectionManager: selectionManager,
sceneGraphModel: sceneGraphModel,
diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift
index 2c20b14..29fb8d5 100644
--- a/Sources/UntoldEditor/Editor/InspectorView.swift
+++ b/Sources/UntoldEditor/Editor/InspectorView.swift
@@ -159,6 +159,24 @@ var availableComponents_Editor: [ComponentOption_Editor] = [
}
)
}),
+
+ ComponentOption_Editor(
+ id: getComponentId(for: LODComponent.self),
+ name: "LOD Component",
+ type: LODComponent.self,
+ view: { selectedId, asset, refreshView in
+ AnyView(
+ Group {
+ if let entityId = selectedId {
+ LODComponentEditorView(entityId: entityId, asset: asset, refreshView: refreshView)
+ }
+ }
+ )
+ },
+ onAdd: { entityId in
+ setEntityLodComponent(entityId: entityId)
+ }
+ ),
]
// Script Component - controlled by feature flag
@@ -277,6 +295,9 @@ struct InspectorView: View {
let sortedComponents = sortEntityComponents(componentOption_Editor: mergedComponents)
+ // Static Batching Section - Show for any entity with renderable hierarchy
+ StaticBatchingEditorView(entityId: entityId, refreshView: refreshView)
+
ForEach(sortedComponents, id: \.id) { editor_component in
VStack(alignment: .leading, spacing: 4) {
HStack {
@@ -407,6 +428,8 @@ struct InspectorView: View {
scene.remove(component: GaussianComponent.self, from: entityId)
} else if key == ObjectIdentifier(ScriptComponent.self) {
scene.remove(component: ScriptComponent.self, from: entityId)
+ } else if key == ObjectIdentifier(LODComponent.self) {
+ scene.remove(component: LODComponent.self, from: entityId)
}
refreshView()
@@ -438,6 +461,102 @@ struct InspectorView: View {
}
*/
+// Standalone Static Batching Section
+struct StaticBatchingEditorView: View {
+ let entityId: EntityID
+ let refreshView: () -> Void
+
+ @State private var staticBatchCheckboxState: Bool = false
+
+ // 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 StaticBatchComponent
+ private func isMarkedAsStatic(entityId: EntityID) -> Bool {
+ // Check self
+ if hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) {
+ return true
+ }
+
+ // Check children recursively
+ let children = getEntityChildren(parentId: entityId)
+ for child in children {
+ if isMarkedAsStatic(entityId: child) {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ 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("Static Batching")
+ .font(.headline)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ let hasOwnRenderComponent = hasComponent(entityId: entityId, componentType: RenderComponent.self)
+ let labelText = hasOwnRenderComponent ? "Mark as Static" : "Mark Children as Static"
+ let helpText = hasOwnRenderComponent
+ ? "Enable static batching for this entity (combines geometry to reduce draw calls)"
+ : "Enable static batching for all children of this entity (combines geometry to reduce draw calls)"
+
+ Toggle(isOn: Binding(
+ get: { staticBatchCheckboxState },
+ set: { isStatic in
+ if isStatic {
+ setEntityStaticBatchComponent(entityId: entityId)
+ } else {
+ removeEntityStaticBatchComponent(entityId: entityId)
+ }
+ staticBatchCheckboxState = isStatic
+ refreshView()
+ }
+ )) {
+ HStack {
+ Image(systemName: "square.3.layers.3d")
+ .foregroundColor(.blue)
+ 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
+ staticBatchCheckboxState = isMarkedAsStatic(entityId: entityId)
+ }
+ .onChange(of: entityId) { newEntityId in
+ // Update checkbox state when entity selection changes
+ staticBatchCheckboxState = isMarkedAsStatic(entityId: newEntityId)
+ }
+ }
+
+ Divider()
+ }
+ }
+}
+
struct RenderingEditorView: View {
let entityId: EntityID
let asset: Asset?
@@ -665,8 +784,25 @@ struct TransformationEditorView: View {
let entityId: EntityID
let refreshView: () -> Void
+ @State private var showStaticBatchWarning = false
+
var body: some View {
Text("Transform Properties")
+
+ // Warning banner if entity is marked as static
+ if hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) {
+ HStack {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.orange)
+ Text("This entity is marked for static batching. Transforming it will disable batching.")
+ .font(.caption)
+ .foregroundColor(.orange)
+ }
+ .padding(6)
+ .background(Color.orange.opacity(0.1))
+ .cornerRadius(6)
+ }
+
let localTransformComponent = scene.get(component: LocalTransformComponent.self, for: entityId)
let position = getLocalPosition(entityId: entityId)
let orientation = simd_float3(localTransformComponent!.rotationX, localTransformComponent!.rotationY, localTransformComponent!.rotationZ)
@@ -674,6 +810,7 @@ struct TransformationEditorView: View {
TextInputVectorView(label: "Position", value: Binding(
get: { position },
set: { newPosition in
+ handleTransformChange()
translateTo(entityId: entityId, position: newPosition)
refreshView()
}
@@ -682,6 +819,7 @@ struct TransformationEditorView: View {
TextInputVectorView(label: "Orientation", value: Binding(
get: { orientation },
set: { newOrientation in
+ handleTransformChange()
applyAxisRotations(entityId: entityId, axis: newOrientation)
refreshView()
}
@@ -690,11 +828,22 @@ struct TransformationEditorView: View {
TextInputVectorView(label: "Scale", value: Binding(
get: { scale },
set: { newScale in
+ handleTransformChange()
scaleTo(entityId: entityId, scale: newScale)
refreshView()
}
))
}
+
+ private func handleTransformChange() {
+ if hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) {
+ removeEntityStaticBatchComponent(entityId: entityId)
+ // Optionally regenerate batches without this entity
+ if isBatchingEnabled() {
+ generateBatches()
+ }
+ }
+ }
}
struct AnimationEditorView: View {
diff --git a/Sources/UntoldEditor/Editor/LODComponentEditorView.swift b/Sources/UntoldEditor/Editor/LODComponentEditorView.swift
new file mode 100644
index 0000000..06c6876
--- /dev/null
+++ b/Sources/UntoldEditor/Editor/LODComponentEditorView.swift
@@ -0,0 +1,192 @@
+//
+// LODComponentEditorView.swift
+//
+//
+// Copyright (C) Untold Engine Studios
+// Licensed under the GNU LGPL v3.0 or later.
+// See the LICENSE file or for details.
+//
+import SwiftUI
+import UntoldEngine
+
+struct LODComponentEditorView: View {
+ let entityId: EntityID
+ let asset: Asset?
+ let refreshView: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Image(systemName: "square.3.layers.3d")
+ .foregroundColor(.blue)
+ Text("LOD Levels")
+ .font(.headline)
+ }
+
+ if let lodComponent = scene.get(component: LODComponent.self, for: entityId) {
+ // Show existing LOD levels
+ ForEach(Array(lodComponent.lodLevels.enumerated()), id: \.offset) { index, lodLevel in
+ LODLevelRow(
+ entityId: entityId,
+ lodIndex: index,
+ lodLevel: lodLevel,
+ refreshView: refreshView
+ )
+ }
+
+ // Add LOD Level button
+ Button(action: {
+ addLODLevelFromFilePicker()
+ }) {
+ HStack(spacing: 6) {
+ Image(systemName: "plus.circle.fill")
+ .foregroundColor(.white)
+ Text("Add LOD Level")
+ .fontWeight(.regular)
+ }
+ .padding(.vertical, 6)
+ .padding(.horizontal, 10)
+ .background(Color.green)
+ .foregroundColor(.white)
+ .cornerRadius(6)
+ }
+ .buttonStyle(PlainButtonStyle())
+ .padding(.top, 8)
+ }
+ }
+ .padding(12)
+ .background(Color.blue.opacity(0.05))
+ .cornerRadius(8)
+ }
+
+ private func addLODLevelFromFilePicker() {
+ // Check if user has selected a model asset
+ guard let asset,
+ asset.category == "Models",
+ !asset.isFolder
+ else {
+ Logger.log(message: "⚠️ Please select a USDZ file from the Models section first")
+ return
+ }
+
+ let url = asset.path
+ let filename = url.deletingPathExtension().lastPathComponent
+ let ext = url.pathExtension
+
+ // Get the next LOD index
+ if let lodComponent = scene.get(component: LODComponent.self, for: entityId) {
+ let lodIndex = lodComponent.lodLevels.count
+
+ // Default distance based on index: 50, 100, 150, 200, etc.
+ let maxDistance = Float((lodIndex + 1) * 50)
+
+ // Add LOD level
+ addLODLevel(
+ entityId: entityId,
+ lodIndex: lodIndex,
+ fileName: filename,
+ withExtension: ext,
+ maxDistance: maxDistance
+ ) { success in
+ if success {
+ Logger.log(message: "✅ Added LOD level \(lodIndex): \(filename)")
+ refreshView()
+ } else {
+ Logger.log(message: "⚠️ Failed to add LOD level \(lodIndex)")
+ }
+ }
+ }
+ }
+}
+
+struct LODLevelRow: View {
+ let entityId: EntityID
+ let lodIndex: Int
+ let lodLevel: LODLevel
+ let refreshView: () -> Void
+
+ @State private var editingDistance: Bool = false
+ @State private var distanceValue: String = ""
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ // Top row: LOD badge and filename
+ HStack(spacing: 8) {
+ Text("LOD\(lodIndex)")
+ .font(.system(size: 10, weight: .bold))
+ .foregroundColor(.white)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 3)
+ .background(Color.blue)
+ .cornerRadius(4)
+
+ Text(lodLevel.url?.deletingPathExtension().lastPathComponent ?? "Unknown")
+ .font(.system(size: 11))
+ .foregroundColor(.primary)
+ .lineLimit(1)
+
+ Spacer()
+ }
+
+ // Bottom row: Distance and remove button
+ HStack(spacing: 8) {
+ Text("Distance:")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+
+ // Distance editor
+ if editingDistance {
+ TextField("Distance", text: $distanceValue)
+ .textFieldStyle(RoundedBorderTextFieldStyle())
+ .frame(width: 60)
+ .onSubmit {
+ if let newDistance = Float(distanceValue) {
+ updateLODDistance(newDistance: newDistance)
+ }
+ editingDistance = false
+ }
+ } else {
+ Button(action: {
+ distanceValue = String(format: "%.0f", lodLevel.maxDistance)
+ editingDistance = true
+ }) {
+ Text(String(format: "%.0f", lodLevel.maxDistance))
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(Color.secondary.opacity(0.1))
+ .cornerRadius(4)
+ }
+ .buttonStyle(PlainButtonStyle())
+ }
+
+ Spacer()
+
+ // Remove button
+ Button(action: {
+ removeLODLevel(entityId: entityId, lodIndex: lodIndex)
+ refreshView()
+ }) {
+ Image(systemName: "trash")
+ .foregroundColor(.red)
+ .font(.system(size: 12))
+ }
+ .buttonStyle(BorderlessButtonStyle())
+ }
+ }
+ .padding(.vertical, 8)
+ .padding(.horizontal, 8)
+ .background(Color.secondary.opacity(0.05))
+ .cornerRadius(6)
+ }
+
+ private func updateLODDistance(newDistance: Float) {
+ if let lodComponent = scene.get(component: LODComponent.self, for: entityId),
+ lodIndex < lodComponent.lodLevels.count
+ {
+ lodComponent.lodLevels[lodIndex].maxDistance = newDistance
+ refreshView()
+ }
+ }
+}
diff --git a/Sources/UntoldEditor/Editor/StaticBatchingView.swift b/Sources/UntoldEditor/Editor/StaticBatchingView.swift
new file mode 100644
index 0000000..12c8400
--- /dev/null
+++ b/Sources/UntoldEditor/Editor/StaticBatchingView.swift
@@ -0,0 +1,192 @@
+//
+// StaticBatchingView.swift
+//
+//
+// Copyright (C) Untold Engine Studios
+// Licensed under the GNU LGPL v3.0 or later.
+// See the LICENSE file or for details.
+//
+import SwiftUI
+import UntoldEngine
+
+@available(macOS 12.0, *)
+struct StaticBatchingView: View {
+ @State private var isBatchingEnabled: Bool = false
+ @State private var batchCount: Int = 0
+ @State private var showGenerateSuccess: Bool = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ // MARK: - Header
+
+ HStack(spacing: 6) {
+ Image(systemName: "square.3.layers.3d")
+ .foregroundColor(.accentColor)
+ .font(.system(size: 14))
+ Text("Static Batching")
+ .font(.headline)
+ .foregroundColor(.primary)
+ }
+ .padding(.bottom, 6)
+
+ Divider()
+
+ // MARK: - Enable Batching Toggle
+
+ VStack(alignment: .leading, spacing: 6) {
+ Toggle(isOn: $isBatchingEnabled) {
+ Label("Enable Batching", systemImage: isBatchingEnabled ? "checkmark.circle.fill" : "circle")
+ .font(.system(size: 12))
+ }
+ .toggleStyle(SwitchToggleStyle())
+ .scaleEffect(0.85)
+ .onChange(of: isBatchingEnabled) { _, newValue in
+ enableBatching(newValue)
+ }
+
+ Text("Enable the batching system globally")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ }
+
+ Divider()
+
+ // MARK: - Action Buttons
+
+ VStack(spacing: 8) {
+ // Generate Batches Button
+ Button(action: {
+ generateBatches()
+ updateBatchCount()
+ showGenerateSuccess = true
+
+ // Hide success message after 2 seconds
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
+ showGenerateSuccess = false
+ }
+ }) {
+ HStack(spacing: 6) {
+ Image(systemName: "square.stack.3d.up.fill")
+ .foregroundColor(.white)
+ .font(.system(size: 12))
+ Text("Generate Batches")
+ .font(.system(size: 12))
+ .fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 6)
+ .padding(.horizontal, 8)
+ .background(Color.blue)
+ .foregroundColor(.white)
+ .cornerRadius(6)
+ }
+ .buttonStyle(PlainButtonStyle())
+ .disabled(!isBatchingEnabled)
+
+ // Clear Batches Button
+ Button(action: {
+ clearSceneBatches()
+ updateBatchCount()
+ }) {
+ HStack(spacing: 6) {
+ Image(systemName: "trash.fill")
+ .foregroundColor(.white)
+ .font(.system(size: 12))
+ Text("Clear Batches")
+ .font(.system(size: 12))
+ .fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 6)
+ .padding(.horizontal, 8)
+ .background(Color.red.opacity(0.8))
+ .foregroundColor(.white)
+ .cornerRadius(6)
+ }
+ .buttonStyle(PlainButtonStyle())
+ }
+
+ // Success message
+ if showGenerateSuccess {
+ HStack {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.green)
+ Text("Batches generated successfully!")
+ .font(.system(size: 11))
+ .foregroundColor(.green)
+ }
+ .padding(6)
+ .background(Color.green.opacity(0.1))
+ .cornerRadius(6)
+ .transition(.opacity)
+ }
+
+ Divider()
+
+ // MARK: - Info Section
+
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Batch Statistics")
+ .font(.system(size: 12))
+ .fontWeight(.semibold)
+ .foregroundColor(.primary)
+
+ HStack {
+ Text("Active Batches:")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ Spacer()
+ Text("\(batchCount)")
+ .font(.system(size: 11))
+ .fontWeight(.medium)
+ .foregroundColor(.primary)
+ }
+ }
+ .padding(.vertical, 4)
+
+ Divider()
+
+ // MARK: - Help Section
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("How to use:")
+ .font(.system(size: 11))
+ .fontWeight(.semibold)
+ .foregroundColor(.primary)
+
+ Text("1. Mark entities as static in Inspector")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+
+ Text("2. Enable batching toggle above")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+
+ Text("3. Click 'Generate Batches'")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+
+ Text("Note: Moving a static entity will automatically disable its batching.")
+ .font(.system(size: 9))
+ .foregroundColor(.orange)
+ .padding(.top, 4)
+ }
+ .padding(.vertical, 4)
+
+ Spacer()
+ }
+ .padding(8)
+ .background(Color.secondary.opacity(0.1))
+ .cornerRadius(8)
+ .shadow(color: Color.black.opacity(0.1), radius: 3, x: 0, y: 1)
+ .onAppear {
+ isBatchingEnabled = UntoldEngine.isBatchingEnabled()
+ updateBatchCount()
+ }
+ }
+
+ private func updateBatchCount() {
+ // Get batch count from BatchingSystem
+ batchCount = BatchingSystem.shared.batchGroups.count
+ }
+}
diff --git a/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift
index 84b5ddc..417d6f4 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.8.1"
+ private let editorVersionLabel = "v0.9.0"
var onSave: () -> Void
var onSaveAs: () -> Void
diff --git a/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift b/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift
index 0048cde..39ae9c4 100644
--- a/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift
+++ b/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift
@@ -54,10 +54,22 @@ extension UntoldRenderer {
@inline(__always)
func refreshInspector() { editorController?.refreshInspector() }
+ // Remove static batching when entity is transformed via gizmo
+ @inline(__always)
+ func handleStaticBatchOnTransform(entityId: EntityID) {
+ if hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) {
+ removeEntityStaticBatchComponent(entityId: entityId)
+ if isBatchingEnabled() {
+ generateBatches()
+ }
+ }
+ }
+
switch (editorController!.activeMode, editorController!.activeAxis) {
// MARK: - Translate
case (.translate, .x) where InputSystem.shared.mouseActive:
+ handleStaticBatchOnTransform(entityId: activeEntity)
let axis = simd_float3(1, 0, 0)
let amt = computeAxisTranslationGizmo(
axisWorldDir: axis,
@@ -73,6 +85,7 @@ extension UntoldRenderer {
refreshInspector()
case (.translate, .y) where InputSystem.shared.mouseActive:
+ handleStaticBatchOnTransform(entityId: activeEntity)
let axis = simd_float3(0, 1, 0)
let amt = computeAxisTranslationGizmo(axisWorldDir: axis,
gizmoWorldPosition: getLocalPosition(entityId: activeEntity),
@@ -86,6 +99,7 @@ extension UntoldRenderer {
refreshInspector()
case (.translate, .z) where InputSystem.shared.mouseActive:
+ handleStaticBatchOnTransform(entityId: activeEntity)
let axis = simd_float3(0, 0, 1)
let amt = computeAxisTranslationGizmo(axisWorldDir: axis,
gizmoWorldPosition: getLocalPosition(entityId: activeEntity),
@@ -101,6 +115,7 @@ extension UntoldRenderer {
// MARK: - Rotate
case (.rotate, .x) where InputSystem.shared.mouseActive:
+ handleStaticBatchOnTransform(entityId: activeEntity)
let axis = simd_float3(1, 0, 0)
let angle = computeRotationAngleFromGizmo(
axis: axis,
@@ -118,6 +133,7 @@ extension UntoldRenderer {
refreshInspector()
case (.rotate, .y) where InputSystem.shared.mouseActive:
+ handleStaticBatchOnTransform(entityId: activeEntity)
let axis = simd_float3(0, 1, 0)
let angle = computeRotationAngleFromGizmo(
axis: axis,
@@ -135,6 +151,7 @@ extension UntoldRenderer {
refreshInspector()
case (.rotate, .z) where InputSystem.shared.mouseActive:
+ handleStaticBatchOnTransform(entityId: activeEntity)
let axis = simd_float3(0, 0, 1)
let angle = computeRotationAngleFromGizmo(
axis: axis,
@@ -154,6 +171,7 @@ extension UntoldRenderer {
// MARK: - Scale
case (.scale, .x) where InputSystem.shared.mouseActive:
+ handleStaticBatchOnTransform(entityId: activeEntity)
let axis = simd_float3(1, 0, 0)
let amt = computeAxisTranslationGizmo(axisWorldDir: axis,
gizmoWorldPosition: getLocalPosition(entityId: activeEntity),
@@ -169,6 +187,7 @@ extension UntoldRenderer {
refreshInspector()
case (.scale, .y) where InputSystem.shared.mouseActive:
+ handleStaticBatchOnTransform(entityId: activeEntity)
let axis = simd_float3(0, 1, 0)
let amt = computeAxisTranslationGizmo(axisWorldDir: axis,
gizmoWorldPosition: getLocalPosition(entityId: activeEntity),
@@ -184,6 +203,7 @@ extension UntoldRenderer {
refreshInspector()
case (.scale, .z) where InputSystem.shared.mouseActive:
+ handleStaticBatchOnTransform(entityId: activeEntity)
let axis = simd_float3(0, 0, 1)
let amt = computeAxisTranslationGizmo(axisWorldDir: axis,
gizmoWorldPosition: getLocalPosition(entityId: activeEntity),
diff --git a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift
index 937b240..603b4a3 100644
--- a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift
+++ b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift
@@ -82,16 +82,28 @@ func buildEditModeGraph() -> RenderGraphResult {
)
graph[shadowPass.id] = shadowPass
+ // Add batched shadow pass (runs after regular shadow pass)
+ let batchedShadowPass = RenderPass(
+ id: "batchedShadow", dependencies: [shadowPass.id], execute: RenderPasses.batchedShadowExecution
+ )
+ graph[batchedShadowPass.id] = batchedShadowPass
+
let modelPass = RenderPass(
- id: "model", dependencies: [shadowPass.id], execute: RenderPasses.modelExecution
+ id: "model", dependencies: [batchedShadowPass.id], execute: RenderPasses.modelExecution
)
graph[modelPass.id] = modelPass
- let lightPass = RenderPass(id: "lightPass", dependencies: [modelPass.id, shadowPass.id], execute: RenderPasses.lightExecution)
+ // Add batched model pass (runs after regular model pass)
+ let batchedModelPass = RenderPass(
+ id: "batchedModel", dependencies: [modelPass.id], execute: RenderPasses.batchedModelExecution
+ )
+ graph[batchedModelPass.id] = batchedModelPass
+
+ let lightPass = RenderPass(id: "lightPass", dependencies: [batchedModelPass.id, modelPass.id, shadowPass.id], execute: RenderPasses.lightExecution)
graph[lightPass.id] = lightPass
let highlightPass = RenderPass(
- id: "outline", dependencies: [modelPass.id], execute: RenderPasses.highlightExecution
+ id: "outline", dependencies: [batchedModelPass.id], execute: RenderPasses.highlightExecution
)
graph[highlightPass.id] = highlightPass
diff --git a/Sources/UntoldEditor/main.swift b/Sources/UntoldEditor/main.swift
index 5f466d2..00005e6 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.8.2")
+ Logger.log(message: "Launching Untold Engine Editor v0.9.0")
// Step 1. Create and configure the window
window = NSWindow(
diff --git a/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift b/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift
index 4803213..2239da6 100644
--- a/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift
+++ b/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift
@@ -60,16 +60,18 @@ final class BuildEditModeGraphTests: XCTestCase {
XCTAssertEqual(finalID, "precomp")
let expectedIDs: Set = [
- "environment", "shadow", "model", "lightPass",
+ "environment", "shadow", "batchedShadow", "model", "batchedModel", "lightPass",
"outline", "lightVisualPass", "gizmo", "precomp", "gaussian",
]
XCTAssertEqual(Set(graph.keys), expectedIDs)
assertDeps(graph, "environment", [])
assertDeps(graph, "shadow", ["environment"])
- assertDeps(graph, "model", ["shadow"])
- assertDeps(graph, "lightPass", ["model", "shadow"])
- assertDeps(graph, "outline", ["model"])
+ assertDeps(graph, "batchedShadow", ["shadow"])
+ assertDeps(graph, "model", ["batchedShadow"])
+ assertDeps(graph, "batchedModel", ["model"])
+ assertDeps(graph, "lightPass", ["batchedModel", "model", "shadow"])
+ assertDeps(graph, "outline", ["batchedModel"])
assertDeps(graph, "lightVisualPass", ["outline"])
assertDeps(graph, "gizmo", ["lightVisualPass"])
assertDeps(graph, "gaussian", ["model"])
@@ -89,16 +91,18 @@ final class BuildEditModeGraphTests: XCTestCase {
XCTAssertEqual(finalID, "precomp")
let expectedIDs: Set = [
- "grid", "shadow", "model", "lightPass",
+ "grid", "shadow", "batchedShadow", "model", "batchedModel", "lightPass",
"outline", "lightVisualPass", "gizmo", "precomp", "gaussian",
]
XCTAssertEqual(Set(graph.keys), expectedIDs)
assertDeps(graph, "grid", [])
assertDeps(graph, "shadow", ["grid"])
- assertDeps(graph, "model", ["shadow"])
- assertDeps(graph, "lightPass", ["model", "shadow"])
- assertDeps(graph, "outline", ["model"])
+ assertDeps(graph, "batchedShadow", ["shadow"])
+ assertDeps(graph, "model", ["batchedShadow"])
+ assertDeps(graph, "batchedModel", ["model"])
+ assertDeps(graph, "lightPass", ["batchedModel", "model", "shadow"])
+ assertDeps(graph, "outline", ["batchedModel"])
assertDeps(graph, "lightVisualPass", ["outline"])
assertDeps(graph, "gizmo", ["lightVisualPass"])
assertDeps(graph, "gaussian", ["model"])
diff --git a/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift b/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift
index ad817e5..aeeb135 100644
--- a/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift
+++ b/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift
@@ -67,7 +67,9 @@ final class EditorRenderingSystemTests: XCTestCase {
// Verify essential passes exist
XCTAssertNotNil(graph["shadow"], "Shadow pass should exist")
+ XCTAssertNotNil(graph["batchedShadow"], "Batched shadow pass should exist")
XCTAssertNotNil(graph["model"], "Model pass should exist")
+ XCTAssertNotNil(graph["batchedModel"], "Batched model pass should exist")
XCTAssertNotNil(graph["lightPass"], "Light pass should exist")
XCTAssertNotNil(graph["outline"], "Highlight/outline pass should exist")
XCTAssertNotNil(graph["lightVisualPass"], "Light visual pass should exist")
@@ -91,7 +93,9 @@ final class EditorRenderingSystemTests: XCTestCase {
// Verify essential passes exist
XCTAssertNotNil(graph["shadow"], "Shadow pass should exist")
+ XCTAssertNotNil(graph["batchedShadow"], "Batched shadow pass should exist")
XCTAssertNotNil(graph["model"], "Model pass should exist")
+ XCTAssertNotNil(graph["batchedModel"], "Batched model pass should exist")
XCTAssertNotNil(graph["lightPass"], "Light pass should exist")
XCTAssertNotNil(graph["outline"], "Highlight/outline pass should exist")
XCTAssertNotNil(graph["lightVisualPass"], "Light visual pass should exist")
@@ -112,10 +116,13 @@ final class EditorRenderingSystemTests: XCTestCase {
// Assert - Verify dependency chain
XCTAssertEqual(graph["grid"]?.dependencies.count, 0, "Grid pass should have no dependencies")
XCTAssertEqual(graph["shadow"]?.dependencies, ["grid"], "Shadow should depend on grid")
- XCTAssertEqual(graph["model"]?.dependencies, ["shadow"], "Model should depend on shadow")
+ XCTAssertEqual(graph["batchedShadow"]?.dependencies, ["shadow"], "Batched shadow should depend on shadow")
+ XCTAssertEqual(graph["model"]?.dependencies, ["batchedShadow"], "Model should depend on batchedShadow")
+ XCTAssertEqual(graph["batchedModel"]?.dependencies, ["model"], "Batched model should depend on model")
XCTAssertTrue(graph["lightPass"]?.dependencies.contains("model") ?? false, "Light pass should depend on model")
XCTAssertTrue(graph["lightPass"]?.dependencies.contains("shadow") ?? false, "Light pass should depend on shadow")
- XCTAssertEqual(graph["outline"]?.dependencies, ["model"], "Outline should depend on model")
+ XCTAssertTrue(graph["lightPass"]?.dependencies.contains("batchedModel") ?? false, "Light pass should depend on batchedModel")
+ XCTAssertEqual(graph["outline"]?.dependencies, ["batchedModel"], "Outline should depend on batchedModel")
XCTAssertEqual(graph["lightVisualPass"]?.dependencies, ["outline"], "Light visual pass should depend on outline")
XCTAssertEqual(graph["gizmo"]?.dependencies, ["lightVisualPass"], "Gizmo should depend on light visual pass")
@@ -135,7 +142,9 @@ final class EditorRenderingSystemTests: XCTestCase {
// Assert - Verify dependency chain
XCTAssertEqual(graph["environment"]?.dependencies.count, 0, "Environment pass should have no dependencies")
XCTAssertEqual(graph["shadow"]?.dependencies, ["environment"], "Shadow should depend on environment")
- XCTAssertEqual(graph["model"]?.dependencies, ["shadow"], "Model should depend on shadow")
+ XCTAssertEqual(graph["batchedShadow"]?.dependencies, ["shadow"], "Batched shadow should depend on shadow")
+ XCTAssertEqual(graph["model"]?.dependencies, ["batchedShadow"], "Model should depend on batchedShadow")
+ XCTAssertEqual(graph["batchedModel"]?.dependencies, ["model"], "Batched model should depend on model")
}
func test_buildEditModeGraph_canBeTopologicallySorted() {
@@ -285,9 +294,10 @@ final class EditorRenderingSystemTests: XCTestCase {
return
}
- XCTAssertEqual(lightPass.dependencies.count, 2, "Light pass should have exactly 2 dependencies")
+ 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")
+ XCTAssertTrue(lightPass.dependencies.contains("batchedModel"), "Light pass should depend on batchedModel")
}
// MARK: - Helper Methods