Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 50 additions & 7 deletions Sources/UntoldEngine/Scenes/Builder/UntoldView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,41 @@ import SwiftUI

@MainActor
public struct UntoldView: View {
@State private var metalView: MTKView
private var renderer: UntoldRenderer?
private var content: [any NodeProtocol] = []
var options: UntoldViewOptions
private var contentBuilder: @MainActor () -> [any NodeProtocol]
private var updateHandler: (@MainActor (UpdateEvent) -> Void)?

public init(renderer: UntoldRenderer? = nil, @SceneBuilder _ content: @escaping @MainActor () -> [any NodeProtocol]) {
self.renderer = renderer ?? UntoldRenderer.create()
metalView = self.renderer?.metalView ?? MTKView()
self.content = content()
/// - Parameters:
/// - renderer: An externally owned renderer, or nil to let the view
/// create one. Either way the renderer is resolved once and survives
/// SwiftUI re-evaluations of this struct.
/// - options: Runtime-tunable view settings (target FPS, pause, clear
/// color). When SwiftUI re-evaluates the view with changed options,
/// only the difference is applied to the live `MTKView` — the
/// renderer and the scene are never recreated. Also settable through
/// the `options(_:)` / `preferredFramesPerSecond(_:)` / `paused(_:)`
/// modifiers.
/// - content: Scene content, built exactly once when the underlying
/// platform view is created (after the renderer is ready, so mesh
/// loading has a Metal device).
public init(
renderer: UntoldRenderer? = nil,
options: UntoldViewOptions = .default,
@SceneBuilder _ content: @escaping @MainActor () -> [any NodeProtocol]
) {
self.renderer = renderer
self.options = options
contentBuilder = content
}

public var body: some View {
SceneView(renderer: renderer, updateHandler: updateHandler)
SceneView(
renderer: renderer,
options: options,
setup: { _ = contentBuilder() },
updateHandler: updateHandler
)
}

/// Subscribes to the engine's per-frame update event (RealityKit
Expand All @@ -41,4 +63,25 @@ public struct UntoldView: View {
copy.updateHandler = handler
return copy
}

/// Replaces all runtime view options.
public func options(_ options: UntoldViewOptions) -> UntoldView {
var copy = self
copy.options = options
return copy
}

/// Sets the target frame rate of the live view.
public func preferredFramesPerSecond(_ fps: Int) -> UntoldView {
var copy = self
copy.options.preferredFramesPerSecond = fps
return copy
}

/// Pauses or resumes the draw loop of the live view.
public func paused(_ paused: Bool) -> UntoldView {
var copy = self
copy.options.isPaused = paused
return copy
}
}
133 changes: 104 additions & 29 deletions Sources/UntoldEngine/Scenes/SceneView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,32 +17,86 @@ import SwiftUI
#endif

public struct SceneView: ViewRepresentable {
var mtkView: MTKView
private var renderer: UntoldRenderer?
private var options: UntoldViewOptions
private var updateHandler: (@MainActor (UpdateEvent) -> Void)?
private var setupHandler: (@MainActor () -> Void)?

// TODO: Maybe we should thow an error on init instead of allowing nil renderer value
public init(renderer: UntoldRenderer? = nil, updateHandler: (@MainActor (UpdateEvent) -> Void)? = nil) {
self.renderer = renderer ?? UntoldRenderer.create()
public init(
renderer: UntoldRenderer? = nil,
options: UntoldViewOptions = .default,
setup: (@MainActor () -> Void)? = nil,
updateHandler: (@MainActor (UpdateEvent) -> Void)? = nil
) {
self.renderer = renderer
self.options = options
setupHandler = setup
self.updateHandler = updateHandler
mtkView = self.renderer!.metalView
}

/// Persists across SwiftUI re-inits of the view struct; owns the frame-event
/// subscription so it is created once and cancelled on dismantle.
/// Persists across SwiftUI re-inits of the view struct. Owns the renderer
/// (so a fallback-created one is made exactly once, not on every body
/// re-evaluation), the one-shot setup, the frame-event subscription, and
/// the last options applied to the MTKView.
@MainActor
public final class Coordinator {
var renderer: UntoldRenderer?
var handler: (@MainActor (UpdateEvent) -> Void)?
var subscription: EventSubscription?
var didRunSetup = false
var appliedOptions: UntoldViewOptions?

/// Applies only the properties that differ from the last applied
/// options, so unrelated SwiftUI re-evaluations never touch the view.
func apply(_ options: UntoldViewOptions, to view: MTKView) {
let previous = appliedOptions
guard previous != options else { return }

if previous?.preferredFramesPerSecond != options.preferredFramesPerSecond {
view.preferredFramesPerSecond = options.preferredFramesPerSecond
}
if previous?.isPaused != options.isPaused {
view.isPaused = options.isPaused
}
if previous?.clearColor != options.clearColor {
let c = options.clearColor
view.clearColor = MTLClearColor(
red: Double(c.x), green: Double(c.y), blue: Double(c.z), alpha: Double(c.w)
)
}
appliedOptions = options
}
}

public func makeCoordinator() -> Coordinator {
Coordinator()
}

/// Resolves the stable renderer: adopts the injected one on the first
/// call, creates a fallback otherwise, and never swaps it afterwards —
/// SwiftUI may re-init this struct freely without recreating anything.
@MainActor
private func resolveRenderer(_ coordinator: Coordinator) -> UntoldRenderer? {
if coordinator.renderer == nil {
coordinator.renderer = renderer ?? UntoldRenderer.create()
}
return coordinator.renderer
}

/// Runs the setup block exactly once, after the renderer exists so the
/// Metal device is available to resource-loading calls inside it.
@MainActor
private func runSetupIfNeeded(_ coordinator: Coordinator) {
guard !coordinator.didRunSetup else { return }
coordinator.didRunSetup = true
setupHandler?()
}

@MainActor
private func connect(_ coordinator: Coordinator) {
coordinator.handler = updateHandler
guard coordinator.subscription == nil, updateHandler != nil, let renderer else { return }
guard coordinator.subscription == nil, updateHandler != nil,
let renderer = coordinator.renderer else { return }
coordinator.subscription = renderer.onUpdate { [weak coordinator] event in
// The MTKView delegate draws on the main thread; trap loudly if a
// future host ever drives this renderer off-main.
Expand All @@ -52,44 +106,65 @@ public struct SceneView: ViewRepresentable {
}
}

@MainActor
private func makeView(context: Context) -> MTKView {
let view = resolveRenderer(context.coordinator)?.metalView ?? MTKView()
runSetupIfNeeded(context.coordinator)
connect(context.coordinator)
context.coordinator.apply(options, to: view)
return view
}

@MainActor
private func updateView(_ view: MTKView, context: Context) {
connect(context.coordinator)
context.coordinator.apply(options, to: view)
}

@MainActor
private static func dismantleView(coordinator: Coordinator) {
coordinator.subscription?.cancel()
coordinator.subscription = nil
coordinator.handler = nil
coordinator.appliedOptions = nil
}

#if os(macOS)
public func makeNSView(context: Context) -> MTKView {
connect(context.coordinator)
return mtkView
makeView(context: context)
}

public func updateNSView(_: MTKView, context: Context) {
connect(context.coordinator)
updateView(mtkView, context: context)
public func updateNSView(_ view: MTKView, context: Context) {
updateView(view, context: context)
}

public static func dismantleNSView(_: MTKView, coordinator: Coordinator) {
coordinator.subscription?.cancel()
coordinator.subscription = nil
coordinator.handler = nil
dismantleView(coordinator: coordinator)
}
#else
public func makeUIView(context: Context) -> MTKView {
connect(context.coordinator)
return mtkView
makeView(context: context)
}

public func updateUIView(_ mtkView: MTKView, context: Context) {
connect(context.coordinator)
updateView(mtkView, context: context)
public func updateUIView(_ view: MTKView, context: Context) {
updateView(view, context: context)
}

public static func dismantleUIView(_: MTKView, coordinator: Coordinator) {
coordinator.subscription?.cancel()
coordinator.subscription = nil
coordinator.handler = nil
dismantleView(coordinator: coordinator)
}
#endif

public func updateView(_: MTKView, context _: Context) {}

public func onInit(block: @escaping () -> Void) -> Self {
block()
return self
/// Registers a block that runs exactly once, when the platform view is
/// created and the renderer is ready. Use it for imperative scene setup
/// (loading meshes, creating entities).
///
/// - Note: The block used to run immediately at body-evaluation time, on
/// every SwiftUI re-evaluation. It is now deferred until the renderer
/// exists and runs a single time for the lifetime of the view.
public func onInit(block: @escaping @MainActor () -> Void) -> Self {
var copy = self
copy.setupHandler = block
return copy
}
}
47 changes: 47 additions & 0 deletions Sources/UntoldEngine/Scenes/UntoldViewOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// UntoldViewOptions.swift
// UntoldEngine
//
// Copyright (C) Untold Engine Studios
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import simd

/// Runtime-tunable settings for the SwiftUI host view.
///
/// Unlike `UntoldRendererConfig` — which is create-time, immutable renderer
/// configuration — these values may change while the view is alive. When
/// SwiftUI re-evaluates the view with new options, only the properties that
/// actually changed are applied to the live `MTKView`; the renderer is never
/// recreated. Every property in this struct must be applicable to the live
/// view — anything that requires rebuilding the render pipeline belongs in
/// `UntoldRendererConfig` instead, and anything that tunes the engine itself
/// (anti-aliasing, post-FX, LOD, ...) already has a live channel through the
/// engine settings API (`setRendering`, `setPostFX`, `setLOD`, ...).
public struct UntoldViewOptions: Equatable, Sendable {
/// Target frame rate, applied to `MTKView.preferredFramesPerSecond`.
public var preferredFramesPerSecond: Int

/// Pauses the draw loop (`MTKView.isPaused`). Simulation and rendering
/// stop and the last frame stays on screen. Use for menus, inactive
/// tabs, or battery saving.
public var isPaused: Bool

/// Clear color of the drawable, linear RGBA.
public var clearColor: simd_float4

public init(
preferredFramesPerSecond: Int = 60,
isPaused: Bool = false,
clearColor: simd_float4 = simd_float4(0, 0, 0, 1)
) {
self.preferredFramesPerSecond = preferredFramesPerSecond
self.isPaused = isPaused
self.clearColor = clearColor
}

public static let `default` = UntoldViewOptions()
}
Loading
Loading