diff --git a/CHANGELOG.md b/CHANGELOG.md
index e077b720d..2b302d6e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,48 @@
# Changelog
+## v0.15.0 - 2026-08-06
+### π Fixes
+- [Patch] Added material fidelity report to the exporter (a36166fβ¦)
+- [Patch] Added untoldengine export-tiles CLI command with RAM-aware parallel export (50221e3β¦)
+- [Patch] Fix HDR IBL energy calibration (fcda745β¦)
+- [Patch] Fix iOS simulator Metal kernel build output (2ccc688β¦)
+- [Patch] fixed abs path in serializer (7f8dd45β¦)
+- [Patch] Improve directional shadow softness for XR (ac5a1ffβ¦)
+- [Patch] Default quaternion rotations to identity instead of zero (06651faβ¦)
+- [Patch] Improve Blender-style light units, attenuation, and shadow behavior (a08fdedβ¦)
+- [Patch] Added debug views for hdr (95a9c6fβ¦)
+- [Patch] Stage world/studio-light HDR environments and transcode EXR to ZIP for engine compatibility (b782fd4β¦)
+- [Patch] Re-encode all file-backed material textures through Blender instead of raw-copying (6332c8fβ¦)
+- [Patch] Stage world/studio-light HDR environments in export-tiles too (2e351f2β¦)
+- [Patch] Fixed bake resolution (ee582e4β¦)
+- [Patch] Added new variable to setRendering (990520dβ¦)
+- [Patch] Fix joint transforms buffer CPU/GPU race with per-frame ring (f07c0fdβ¦)
+- [Patch] Restore encoder safety net with a closed flag (e8f9607β¦)
+- [Patch] Persist scene-authored color management and environment state (8980058β¦)
+- [Patch] Aggregate getAnimationPolicy over descendants (bee8546β¦)
+- [Patch] Fix MPS -destination must be writable- crash on sRGB texture resample (7924e4aβ¦)
+- [Patch] Create the camera component in CameraNode when missing (aeefa4cβ¦)
+- [Patch] Address review: redundant changeAnimation is a no-op (5f61789β¦)
+### π Docs
+- [Docs] Reorder topics in docs (30cf39eβ¦)
+- [Docs] Updated usage example (5c1bc45β¦)
+- [Docs] Updated engine documentation (f9a88b0β¦)
+- [Docs] Documented bake-materials and bake-color-management (a2696cfβ¦)
+- [Docs] Updated IBL usage documentation (845039dβ¦)
+- [Docs] Update the readme docs (96d0340β¦)
+- [Docs] Updated xr images (6ac8f82β¦)
+- [Docs] Added scene authored documentation (f9f0ea9β¦)
+- [Docs] add plugin architecture and authoring guidelines (e363a83β¦)
+- [Docs] Added community links (140edc0β¦)
+### π Features
+- [Feature] Add compiled animation clip sampling and pose layer foundation (8e3892bβ¦)
+- [Feature] Add per-frame update event with UntoldView onUpdate modifier (6c3dd20β¦)
+- [Feature] Add simulator fallback for the TBDR deferred renderer (d78c8c1β¦)
+- [Feature] Add per-entity animation policy (inherit/forceOn/forceOff) (24e9ad8β¦)
+- [Feature] Add primitive, light, and camera nodes to the scene builder DSL (3b8bc92β¦)
+- [Feature] Add physics backend plugin interface (260b23fβ¦)
+- [Feature] Add inertialized animation transitions to changeAnimation (08be3a6β¦)
+- [Feature] Drive external physics backends via an EngineExtension coordinator (c3b8b0eβ¦)
+- [Feature] Mesh and Gaussian share streaming path (0e088a9β¦)
## v0.14.3 - 2026-07-17
### π Fixes
- [Patch] Added the new public debug view - position (3dccbadβ¦)
diff --git a/README.md b/README.md
index c36f3acb9..510c58f49 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,8 @@
Request a Feature
Β·
Ask a Question
+ Β·
+ Join Our Discord
@@ -45,7 +47,9 @@ Untold Engine is built for developers and teams who:
Creator & Lead Developer:
https://www.haroldserrano.com
-
+[](https://vimeo.com/1176995991?fl=ip&fe=ec)
+
+Click to Play
---
@@ -78,6 +82,13 @@ workflow, product experience, and customer-specific behavior.
+## Requirements
+
+- macOS 26 or newer
+- Swift 6 / Xcode 26 or newer
+- Apple Silicon Mac
+- Supported platforms: macOS, iOS, and visionOS
+
## Try the Engine Right Now
The best first step is to run the Starter Demo. It is intentionally small and
@@ -93,7 +104,7 @@ Clone the repository and launch the Starter Demo:
```bash
git clone https://github.com/untoldengine/UntoldEngine.git
cd UntoldEngine
-git checkout v0.14.3
+git checkout v0.15.0
swift run StarterDemo
```
@@ -164,22 +175,7 @@ Untold Engine is well-suited for:
---
-# Commercial Use, Sponsored Features, and Support
-
-Untold Engine is open source under MPL-2.0 and can be used in commercial apps.
-Paid commercial options are available for teams that need private engine
-modifications, sponsored engine features, priority support, or custom terms.
-
-- **Sponsored open-source features** β fund roadmap-aligned engine work that is
- released into the public MPL engine.
-- **Commercial license** β keep private modifications to engine internals closed
- under commercial terms.
-- **Priority support / retainers** β get focused help with engine integration,
- rendering issues, performance, and production use.
-
-See [COMMERCIAL.md](COMMERCIAL.md) for commercial licensing details.
----
# Engine API
@@ -298,6 +294,23 @@ Full license: https://www.mozilla.org/MPL/2.0/
---
+# Commercial Use, Sponsored Features, and Support
+
+Untold Engine is open source under MPL-2.0 and can be used in commercial apps.
+Paid commercial options are available for teams that need private engine
+modifications, sponsored engine features, priority support, or custom terms.
+
+- **Sponsored open-source features** β fund roadmap-aligned engine work that is
+ released into the public MPL engine.
+- **Commercial license** β keep private modifications to engine internals closed
+ under commercial terms.
+- **Priority support / retainers** β get focused help with engine integration,
+ rendering issues, performance, and production use.
+
+See [COMMERCIAL.md](COMMERCIAL.md) for commercial licensing details.
+
+---
+
# Trademark
"Untold Engine" and the Untold Engine logo are trademarks of **Untold Engine Studios**. Forks may not use the name in a way that implies official endorsement. See [TRADEMARKS.md](TRADEMARKS.md).
@@ -308,4 +321,3 @@ Full license: https://www.mozilla.org/MPL/2.0/
- [GitHub Discussions](https://github.com/untoldengine/UntoldEngine/discussions) β ideas and questions
- [GitHub Issues](https://github.com/untoldengine/UntoldEngine/issues) β bugs and tasks
-- [Discord](https://discord.gg/pSckCPFxj) β ask questions, share projects, and discuss engine development
diff --git a/Sources/CShaderTypes/ShaderTypes.h b/Sources/CShaderTypes/ShaderTypes.h
index c96c5c50c..97dedb748 100644
--- a/Sources/CShaderTypes/ShaderTypes.h
+++ b/Sources/CShaderTypes/ShaderTypes.h
@@ -511,6 +511,10 @@ typedef enum{
gaussianTBDRRenderLocalCameraIndex,
}GaussianTBDRRenderBufferIndices;
+typedef enum{
+ gaussianTBDRDrawOpaqueDepthTextureIndex = 0,
+ }GaussianTBDRDrawTextureIndices;
+
typedef enum{
outputTransformPassEncodingModeIndex
}OutputTransformBufferIndices;
diff --git a/Sources/Demos/ShowcaseDemo/AppDelegate.swift b/Sources/Demos/ShowcaseDemo/AppDelegate.swift
index a49a463bb..7435f2e21 100644
--- a/Sources/Demos/ShowcaseDemo/AppDelegate.swift
+++ b/Sources/Demos/ShowcaseDemo/AppDelegate.swift
@@ -11,7 +11,7 @@
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private enum Constants {
- static let appVersion = "0.14.3"
+ static let appVersion = "0.15.0"
static let defaultWindowSize = NSSize(width: 1920, height: 1080)
static let minimumWindowSize = NSSize(width: 640, height: 480)
}
diff --git a/Sources/Sandbox/AppDelegate.swift b/Sources/Sandbox/AppDelegate.swift
index 96d6b880c..a42966f19 100644
--- a/Sources/Sandbox/AppDelegate.swift
+++ b/Sources/Sandbox/AppDelegate.swift
@@ -10,7 +10,7 @@
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private enum Constants {
- static let appVersion = "0.14.3"
+ static let appVersion = "0.15.0"
static let windowSize = NSSize(width: 1600, height: 900)
}
diff --git a/Sources/Sandbox/GameScene.swift b/Sources/Sandbox/GameScene.swift
index 8f54c4fa6..a4457fa2b 100644
--- a/Sources/Sandbox/GameScene.swift
+++ b/Sources/Sandbox/GameScene.swift
@@ -49,12 +49,6 @@
*/
// Uncomment to render a streamed scene
- /*
- let sceneRoot = createEntity()
- setEntityStreamScene(entityId: sceneRoot, url: URL(string: "https://d8pyi1c08k1w.cloudfront.net/dungeon3/dungeon3.json")!){ success in
- setSceneReady(success)
- }
- */
}
private func configureEngineSystems() {
diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift
index c7d5753b6..54f7e9d0e 100644
--- a/Sources/UntoldEngine/ECS/Components.swift
+++ b/Sources/UntoldEngine/ECS/Components.swift
@@ -780,6 +780,18 @@ public enum MeshStreamingState: String {
case unloading // Being unloaded
}
+/// Kind of asset a StreamingComponent loads/unloads.
+///
+/// Determines which loader `GeometryStreamingSystem.loadMesh`/`unloadMesh` dispatch to.
+/// `.mesh` goes through the existing `.untold`/OCC mesh pipeline; `.gaussianSplat` goes
+/// through `setEntityGaussianAsync`/`unloadGaussian`. The distance/radius scheduling,
+/// concurrency, and memory-budget eviction logic in `GeometryStreamingSystem` is shared
+/// across both kinds.
+public enum StreamingAssetKind {
+ case mesh
+ case gaussianSplat
+}
+
/// Component that marks an entity for geometry streaming
public class StreamingComponent: Component {
/// Distance from camera at which mesh starts loading
@@ -794,12 +806,15 @@ public class StreamingComponent: Component {
/// Filename of the asset (without extension)
public var assetFilename: String = ""
- /// File extension (e.g., "usdz")
+ /// File extension (e.g., "usdz", or "ply" for gaussian splats)
public var assetExtension: String = ""
/// Optional: specific mesh name within the asset
public var assetName: String?
+ /// Which loader this entity streams through. See `StreamingAssetKind`.
+ public var assetKind: StreamingAssetKind = .mesh
+
/// Current streaming state
public var state: MeshStreamingState = .unloaded
@@ -817,7 +832,8 @@ public class StreamingComponent: Component {
withExtension ext: String,
streamingRadius: Float = 100.0,
unloadRadius: Float = 150.0,
- priority: Int = 0
+ priority: Int = 0,
+ assetKind: StreamingAssetKind = .mesh
) {
self.init()
assetFilename = filename
@@ -825,6 +841,7 @@ public class StreamingComponent: Component {
self.streamingRadius = streamingRadius
self.unloadRadius = unloadRadius
self.priority = priority
+ self.assetKind = assetKind
}
}
diff --git a/Sources/UntoldEngine/Physics/PhysicsBackendRegistry.swift b/Sources/UntoldEngine/Physics/PhysicsBackendRegistry.swift
index d9a051abe..ea7dcf147 100644
--- a/Sources/UntoldEngine/Physics/PhysicsBackendRegistry.swift
+++ b/Sources/UntoldEngine/Physics/PhysicsBackendRegistry.swift
@@ -214,8 +214,9 @@ private struct InstalledPhysicsBackendPlugin {
/// active is rejected β uninstall first. When no plugin is installed the engine
/// uses its built-in integrator, exactly as before this registry existed.
///
-/// Install before `UntoldRenderer.create(...)`. Once the engine locks the registry
-/// for runtime, install and uninstall are rejected.
+/// Install before `UntoldRenderer.create(...)`. The registry locks for runtime on
+/// the first fixed substep the coordinator simulates with the installed backend;
+/// from then on install and uninstall are rejected.
public final class PhysicsBackendRegistry: @unchecked Sendable {
public static let shared = PhysicsBackendRegistry()
@@ -244,22 +245,28 @@ public final class PhysicsBackendRegistry: @unchecked Sendable {
}
lock.lock()
- defer { lock.unlock() }
guard !lockedForRuntime else {
let failure = PhysicsBackendPluginFailure(registryLocked: true)
failuresByPluginID[manifest.id] = failure
+ lock.unlock()
return .rejected(failure)
}
if let active = installedPlugin, active.manifest.id != manifest.id {
let failure = PhysicsBackendPluginFailure(conflictingPluginID: active.manifest.id)
failuresByPluginID[manifest.id] = failure
+ lock.unlock()
return .rejected(failure)
}
let replacing = installedPlugin != nil
installedPlugin = InstalledPhysicsBackendPlugin(manifest: manifest, backend: backend)
failuresByPluginID.removeValue(forKey: manifest.id)
+ lock.unlock()
+
+ // Outside the lock: configures the backend and schedules the coordinator
+ // in EngineExtensionRegistry (idempotent for a replace of the same ID).
+ PhysicsCoordinator.shared.backendDidInstall(backend)
return replacing ? .replaced : .installed
}
@@ -267,12 +274,15 @@ public final class PhysicsBackendRegistry: @unchecked Sendable {
@discardableResult
public func uninstall(id: String) -> Bool {
lock.lock()
- defer { lock.unlock() }
guard !lockedForRuntime, installedPlugin?.manifest.id == id else {
+ lock.unlock()
return false
}
installedPlugin = nil
failuresByPluginID.removeValue(forKey: id)
+ lock.unlock()
+
+ PhysicsCoordinator.shared.backendDidUninstall()
return true
}
@@ -314,5 +324,8 @@ public final class PhysicsBackendRegistry: @unchecked Sendable {
failuresByPluginID.removeAll()
lockedForRuntime = false
lock.unlock()
+
+ PhysicsCoordinator.shared.backendDidUninstall()
+ PhysicsCoordinator.shared.resetForTesting()
}
}
diff --git a/Sources/UntoldEngine/Physics/PhysicsCoordinator.swift b/Sources/UntoldEngine/Physics/PhysicsCoordinator.swift
new file mode 100644
index 000000000..686ecd1d8
--- /dev/null
+++ b/Sources/UntoldEngine/Physics/PhysicsCoordinator.swift
@@ -0,0 +1,262 @@
+//
+// PhysicsCoordinator.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 Foundation
+import simd
+
+/// Counts overflow reports and discards everything else. Event delivery to
+/// subscribers (and USC `OnCollision`) is wired in the physics-events PR;
+/// until then a backend's events are drained but go nowhere.
+final class InertPhysicsEventSink: PhysicsEventSink {
+ private(set) var droppedEventCount = 0
+
+ func receiveContact(_: PhysicsContactEvent) {}
+ func receiveTrigger(_: PhysicsTriggerEvent) {}
+ func receiveActivation(_: PhysicsBodyActivationEvent) {}
+ func reportDroppedEvents(count: Int) {
+ droppedEventCount += count
+ }
+
+ func reset() {
+ droppedEventCount = 0
+ }
+}
+
+/// Drives the installed external physics backend from the engine's
+/// fixed-timestep loop as an `EngineExtension` β no engine-core seams.
+///
+/// `PhysicsBackendRegistry` registers the coordinator with
+/// `EngineExtensionRegistry` when a backend plugin installs and unregisters it
+/// on uninstall, so with no backend installed nothing is scheduled and the
+/// engine behaves exactly as before. The built-in integrator is untouched
+/// either way: it keeps running for legacy `PhysicsComponents`/
+/// `KineticComponent` entities, while the external backend owns only entities
+/// carrying both `RigidBodyComponent` and `ColliderComponent`.
+///
+/// Each `fixedUpdate` (frame thread, once per fixed substep, right after
+/// `updatePhysicsSystem`):
+/// 1. locks `PhysicsBackendRegistry` for runtime on the first simulated substep,
+/// freezing the backend choice for the rest of the run;
+/// 2. diffs the current `RigidBodyComponent`+`ColliderComponent` query result
+/// against the backend's known-body set β `didAddBody`/`didRemoveBody`
+/// (destruction needs no extra hook: component cleanup removes the entity
+/// from the query and the diff picks it up);
+/// 3. batch-writes kinematic body targets;
+/// 4. steps the backend;
+/// 5. batch-reads active transforms back into `LocalTransformComponent` for
+/// dynamic bodies, marking them dirty for the scenegraph/octree exactly like
+/// the built-in integrator does;
+/// 6. drains buffered events.
+public final class PhysicsCoordinator: EngineExtension, @unchecked Sendable {
+ public static let shared = PhysicsCoordinator()
+
+ public let id = "com.untoldengine.physics.coordinator"
+
+ private let configLock = NSLock()
+ private var worldConfig = PhysicsWorldConfiguration()
+
+ // Frame-thread state: touched only from fixedUpdate and the install path,
+ // which the registry contract requires to happen before runtime starts.
+ private var knownBodies: Set
= []
+ private var readbackEntities: [EntityID] = []
+ private var readbackTransforms: [PhysicsBodyTransform] = []
+ private var kinematicEntities: [EntityID] = []
+ private var kinematicTransforms: [PhysicsBodyTransform] = []
+ let eventSink = InertPhysicsEventSink()
+
+ private init() {}
+
+ // MARK: - World configuration
+
+ /// Applies world settings to the active backend immediately and to any
+ /// backend installed later. The built-in integrator reads `gravity` from
+ /// here as well, so legacy and backend-owned bodies share one gravity.
+ public func setWorldConfiguration(_ config: PhysicsWorldConfiguration) {
+ configLock.lock()
+ worldConfig = config
+ configLock.unlock()
+ PhysicsBackendRegistry.shared.activeBackend()?.configure(config)
+ }
+
+ public func worldConfiguration() -> PhysicsWorldConfiguration {
+ configLock.lock()
+ defer { configLock.unlock() }
+ return worldConfig
+ }
+
+ var worldGravity: simd_float3 {
+ configLock.lock()
+ defer { configLock.unlock() }
+ return worldConfig.gravity
+ }
+
+ // MARK: - Install hooks (called by PhysicsBackendRegistry, post-transaction)
+
+ func backendDidInstall(_ backend: any PhysicsBackend) {
+ backend.configure(worldConfiguration())
+ knownBodies.removeAll()
+ eventSink.reset()
+ EngineExtensionRegistry.shared.register(self)
+ }
+
+ func backendDidUninstall() {
+ EngineExtensionRegistry.shared.unregister(id: id)
+ }
+
+ // MARK: - EngineExtension
+
+ public func fixedUpdate(deltaTime: Float, context _: EngineExtensionUpdateContext) {
+ guard let backend = PhysicsBackendRegistry.shared.activeBackend() else { return }
+
+ if !PhysicsBackendRegistry.shared.isLockedForRuntime {
+ PhysicsBackendRegistry.shared.lockForRuntime()
+ }
+
+ syncBodySet(with: backend)
+ pushKinematicTargets(to: backend)
+ backend.step(deltaTime: deltaTime)
+ pullTransforms(from: backend)
+ backend.drainEvents(into: eventSink)
+ }
+
+ public func willUnregister() {
+ knownBodies.removeAll()
+ }
+
+ /// Restores defaults. Test support only.
+ func resetForTesting() {
+ configLock.lock()
+ worldConfig = PhysicsWorldConfiguration()
+ configLock.unlock()
+ knownBodies.removeAll()
+ eventSink.reset()
+ }
+
+ // MARK: - Body lifecycle
+
+ private func syncBodySet(with backend: any PhysicsBackend) {
+ let bodyEntities = queryEntities(with: [
+ RigidBodyComponent.self, ColliderComponent.self, LocalTransformComponent.self,
+ ])
+ let currentSet = Set(bodyEntities)
+
+ for entity in knownBodies.subtracting(currentSet) {
+ backend.didRemoveBody(entity: entity)
+ knownBodies.remove(entity)
+ }
+
+ for entity in bodyEntities where !knownBodies.contains(entity) {
+ guard let descriptor = makeBodyDescriptor(entityId: entity) else { continue }
+ backend.didAddBody(entity: entity, descriptor: descriptor)
+ knownBodies.insert(entity)
+ }
+ }
+
+ private func makeBodyDescriptor(entityId: EntityID) -> PhysicsBodyDescriptor? {
+ guard let rigidBody = scene.get(component: RigidBodyComponent.self, for: entityId),
+ let collider = scene.get(component: ColliderComponent.self, for: entityId),
+ let transform = scene.get(component: LocalTransformComponent.self, for: entityId)
+ else {
+ return nil
+ }
+
+ return PhysicsBodyDescriptor(
+ motionType: rigidBody.motionType,
+ collider: PhysicsColliderDescriptor(
+ shape: collider.shape,
+ localOffset: collider.localOffset,
+ friction: collider.friction,
+ restitution: collider.restitution,
+ isTrigger: collider.isTrigger
+ ),
+ mass: rigidBody.mass,
+ layer: rigidBody.layer,
+ collisionMask: rigidBody.collisionMask,
+ gravityScale: rigidBody.gravityScale,
+ position: transform.position,
+ orientation: transform.rotation,
+ linearVelocity: rigidBody.initialLinearVelocity,
+ angularVelocity: rigidBody.initialAngularVelocity
+ )
+ }
+
+ // MARK: - Transform transfer
+
+ private func pushKinematicTargets(to backend: any PhysicsBackend) {
+ kinematicEntities.removeAll(keepingCapacity: true)
+ kinematicTransforms.removeAll(keepingCapacity: true)
+
+ for entity in knownBodies {
+ guard let rigidBody = scene.get(component: RigidBodyComponent.self, for: entity),
+ rigidBody.motionType == .kinematic,
+ let transform = scene.get(component: LocalTransformComponent.self, for: entity)
+ else {
+ continue
+ }
+ kinematicEntities.append(entity)
+ kinematicTransforms.append(
+ PhysicsBodyTransform(position: transform.position, orientation: transform.rotation)
+ )
+ }
+
+ guard !kinematicEntities.isEmpty else { return }
+
+ kinematicEntities.withUnsafeBufferPointer { entities in
+ kinematicTransforms.withUnsafeBufferPointer { transforms in
+ backend.writeKinematicTargets(
+ PhysicsBodyWriteBatch(entities: entities, transforms: transforms)
+ )
+ }
+ }
+ }
+
+ private func pullTransforms(from backend: any PhysicsBackend) {
+ let capacity = knownBodies.count
+ guard capacity > 0 else { return }
+
+ if readbackEntities.count < capacity {
+ readbackEntities = [EntityID](repeating: 0, count: capacity)
+ readbackTransforms = [PhysicsBodyTransform](
+ repeating: PhysicsBodyTransform(
+ position: .zero,
+ orientation: simd_quatf(ix: 0, iy: 0, iz: 0, r: 1)
+ ),
+ count: capacity
+ )
+ }
+
+ var written = 0
+ readbackEntities.withUnsafeMutableBufferPointer { entities in
+ readbackTransforms.withUnsafeMutableBufferPointer { transforms in
+ written = backend.readActiveTransforms(
+ into: PhysicsTransformReadBatch(entities: entities, transforms: transforms)
+ )
+ }
+ }
+
+ guard written > 0 else { return }
+
+ for index in 0 ..< min(written, capacity) {
+ let entity = readbackEntities[index]
+ guard knownBodies.contains(entity),
+ let rigidBody = scene.get(component: RigidBodyComponent.self, for: entity),
+ rigidBody.motionType == .dynamic,
+ let transform = scene.get(component: LocalTransformComponent.self, for: entity)
+ else {
+ continue
+ }
+
+ transform.position = readbackTransforms[index].position
+ transform.rotation = readbackTransforms[index].orientation
+ transform.transformDirty = true
+ anyTransformDirty = true
+ }
+ }
+}
diff --git a/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift b/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift
index c64f85c79..97ab709be 100644
--- a/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift
+++ b/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift
@@ -494,59 +494,7 @@ private func drawModelSurfaceMeshes(
index: Int(modelPassFragmentUniformIndex.rawValue)
)
- let jointTransformBuffer = mesh.skin?.jointTransformsBuffer
- var hasArmature = getEntityComponent(entityId: entityId, componentType: SkeletonComponent.self) != nil && jointTransformBuffer != nil
- renderEncoder.setVertexBytes(
- &hasArmature,
- length: MemoryLayout.stride,
- index: Int(modelPassHasArmature.rawValue)
- )
-
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer,
- offset: 0,
- index: Int(modelPassVerticesIndex.rawValue)
- )
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)].buffer,
- offset: 0,
- index: Int(modelPassNormalIndex.rawValue)
- )
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassUVIndex.rawValue)].buffer,
- offset: 0,
- index: Int(modelPassUVIndex.rawValue)
- )
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)].buffer,
- offset: 0,
- index: Int(modelPassTangentIndex.rawValue)
- )
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer,
- offset: 0,
- index: Int(modelPassJointIdIndex.rawValue)
- )
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer,
- offset: 0,
- index: Int(modelPassJointWeightsIndex.rawValue)
- )
-
- if let jointTransformBuffer {
- renderEncoder.setVertexBuffer(
- jointTransformBuffer,
- offset: 0,
- index: Int(modelPassJointTransformIndex.rawValue)
- )
- } else {
- var identityMatrix = matrix_identity_float4x4
- renderEncoder.setVertexBytes(
- &identityMatrix,
- length: MemoryLayout.stride,
- index: Int(modelPassJointTransformIndex.rawValue)
- )
- }
+ renderEncoder.bindModelVertexStreams(mesh: mesh, entityId: entityId)
for submesh in mesh.submeshes {
renderEncoder.drawIndexedPrimitivesTracked(
diff --git a/Sources/UntoldEngine/Renderer/RenderInitializer.swift b/Sources/UntoldEngine/Renderer/RenderInitializer.swift
index d1ad97bb5..348f5e3b8 100644
--- a/Sources/UntoldEngine/Renderer/RenderInitializer.swift
+++ b/Sources/UntoldEngine/Renderer/RenderInitializer.swift
@@ -790,6 +790,18 @@ func initTextureResources() {
storageMode: .private
)
+ // Gaussian pass reads a snapshot of the opaque depth to occlude splats behind
+ // walls/geometry. Copied fresh each frame from depthMap (see gaussianExecution).
+ textureResources.gaussianOpaqueDepthSnapshot = createTexture(
+ device: renderInfo.device,
+ label: "Gaussian Opaque Depth Snapshot",
+ pixelFormat: renderInfo.depthPixelFormat,
+ width: viewportWidth,
+ height: viewportHeight,
+ usage: [.shaderRead, .renderTarget],
+ storageMode: .private
+ )
+
// Deferred Depth Texture
textureResources.deferredDepthMap = createTexture(
device: renderInfo.device,
diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift
index 3d8c91fda..f1929a759 100644
--- a/Sources/UntoldEngine/Renderer/RenderPasses.swift
+++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift
@@ -1177,28 +1177,7 @@ public enum RenderPasses {
&modelUniforms, length: MemoryLayout.stride,
index: Int(shadowPassModelUniform.rawValue)
)
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(shadowPassModelPositionIndex.rawValue)].buffer,
- offset: 0, index: Int(shadowPassModelPositionIndex.rawValue)
- )
-
- let jointTransformBuffer = mesh.skin?.jointTransformsBuffer
- var hasArmature = scene.get(component: SkeletonComponent.self, for: entityId) != nil && jointTransformBuffer != nil
- renderEncoder.setVertexBytes(&hasArmature, length: MemoryLayout.stride, index: Int(shadowPassHasArmature.rawValue))
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer,
- offset: 0, index: Int(shadowPassJointIdIndex.rawValue)
- )
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer,
- offset: 0, index: Int(shadowPassJointWeightsIndex.rawValue)
- )
- if let jtb = jointTransformBuffer {
- renderEncoder.setVertexBuffer(jtb, offset: 0, index: Int(shadowPassJointTransformIndex.rawValue))
- } else {
- var identity = matrix_identity_float4x4
- renderEncoder.setVertexBytes(&identity, length: MemoryLayout.stride, index: Int(shadowPassJointTransformIndex.rawValue))
- }
+ renderEncoder.bindShadowVertexStreams(mesh: mesh, entityId: entityId)
for subMesh in mesh.submeshes {
renderEncoder.drawIndexedPrimitivesTracked(
@@ -1391,31 +1370,7 @@ public enum RenderPasses {
modelUniforms.projectionMatrix = renderInfo.perspectiveSpace
renderEncoder.setVertexBytes(&modelUniforms, length: MemoryLayout.stride, index: Int(shadowPassModelUniform.rawValue))
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(shadowPassModelPositionIndex.rawValue)].buffer,
- offset: 0,
- index: Int(shadowPassModelPositionIndex.rawValue)
- )
-
- let jointTransformBuffer = mesh.skin?.jointTransformsBuffer
- var hasArmature = scene.get(component: SkeletonComponent.self, for: entityId) != nil && jointTransformBuffer != nil
- renderEncoder.setVertexBytes(&hasArmature, length: MemoryLayout.stride, index: Int(shadowPassHasArmature.rawValue))
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer,
- offset: 0,
- index: Int(shadowPassJointIdIndex.rawValue)
- )
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer,
- offset: 0,
- index: Int(shadowPassJointWeightsIndex.rawValue)
- )
- if let jtb = jointTransformBuffer {
- renderEncoder.setVertexBuffer(jtb, offset: 0, index: Int(shadowPassJointTransformIndex.rawValue))
- } else {
- var identity = matrix_identity_float4x4
- renderEncoder.setVertexBytes(&identity, length: MemoryLayout.stride, index: Int(shadowPassJointTransformIndex.rawValue))
- }
+ renderEncoder.bindShadowVertexStreams(mesh: mesh, entityId: entityId)
for subMesh in mesh.submeshes {
renderEncoder.drawIndexedPrimitivesTracked(
@@ -1545,31 +1500,7 @@ public enum RenderPasses {
modelUniforms.projectionMatrix = renderInfo.perspectiveSpace
renderEncoder.setVertexBytes(&modelUniforms, length: MemoryLayout.stride, index: Int(shadowPassModelUniform.rawValue))
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(shadowPassModelPositionIndex.rawValue)].buffer,
- offset: 0,
- index: Int(shadowPassModelPositionIndex.rawValue)
- )
-
- let jointTransformBuffer = mesh.skin?.jointTransformsBuffer
- var hasArmature = scene.get(component: SkeletonComponent.self, for: entityId) != nil && jointTransformBuffer != nil
- renderEncoder.setVertexBytes(&hasArmature, length: MemoryLayout.stride, index: Int(shadowPassHasArmature.rawValue))
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer,
- offset: 0,
- index: Int(shadowPassJointIdIndex.rawValue)
- )
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer,
- offset: 0,
- index: Int(shadowPassJointWeightsIndex.rawValue)
- )
- if let jtb = jointTransformBuffer {
- renderEncoder.setVertexBuffer(jtb, offset: 0, index: Int(shadowPassJointTransformIndex.rawValue))
- } else {
- var identity = matrix_identity_float4x4
- renderEncoder.setVertexBytes(&identity, length: MemoryLayout.stride, index: Int(shadowPassJointTransformIndex.rawValue))
- }
+ renderEncoder.bindShadowVertexStreams(mesh: mesh, entityId: entityId)
for subMesh in mesh.submeshes {
renderEncoder.drawIndexedPrimitivesTracked(
@@ -1770,48 +1701,7 @@ public enum RenderPasses {
&modelUniforms, length: MemoryLayout.stride, index: Int(modelPassUniformIndex.rawValue)
)
- // Only enable armature path when a valid joint transform buffer exists.
- let jointTransformBuffer = mesh.skin?.jointTransformsBuffer
- var hasArmature = scene.get(component: SkeletonComponent.self, for: entityId) != nil && jointTransformBuffer != nil
-
- renderEncoder.setVertexBytes(&hasArmature, length: MemoryLayout.stride, index: Int(modelPassHasArmature.rawValue))
-
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer,
- offset: 0, index: Int(modelPassVerticesIndex.rawValue)
- )
-
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)].buffer,
- offset: 0, index: Int(modelPassNormalIndex.rawValue)
- )
-
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassUVIndex.rawValue)].buffer, offset: 0,
- index: Int(modelPassUVIndex.rawValue)
- )
-
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)].buffer,
- offset: 0, index: Int(modelPassTangentIndex.rawValue)
- )
-
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer,
- offset: 0, index: Int(modelPassJointIdIndex.rawValue)
- )
-
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer,
- offset: 0, index: Int(modelPassJointWeightsIndex.rawValue)
- )
-
- if let jointTransformBuffer {
- renderEncoder.setVertexBuffer(jointTransformBuffer, offset: 0, index: Int(modelPassJointTransformIndex.rawValue))
- } else {
- var identityMatrix = matrix_identity_float4x4
- renderEncoder.setVertexBytes(&identityMatrix, length: MemoryLayout.stride, index: Int(modelPassJointTransformIndex.rawValue))
- }
+ renderEncoder.bindModelVertexStreams(mesh: mesh, entityId: entityId)
renderEncoder.setFragmentBytes(
&modelUniforms, length: MemoryLayout.stride, index: Int(modelPassFragmentUniformIndex.rawValue)
@@ -2238,23 +2128,7 @@ public enum RenderPasses {
renderEncoder.setVertexBytes(&modelUniforms, length: MemoryLayout.stride, index: Int(modelPassUniformIndex.rawValue))
- let jointTransformBuffer = mesh.skin?.jointTransformsBuffer
- var hasArmature = scene.get(component: SkeletonComponent.self, for: entityId) != nil && jointTransformBuffer != nil
- renderEncoder.setVertexBytes(&hasArmature, length: MemoryLayout.stride, index: Int(modelPassHasArmature.rawValue))
-
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer, offset: 0, index: Int(modelPassVerticesIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)].buffer, offset: 0, index: Int(modelPassNormalIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassUVIndex.rawValue)].buffer, offset: 0, index: Int(modelPassUVIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)].buffer, offset: 0, index: Int(modelPassTangentIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer, offset: 0, index: Int(modelPassJointIdIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer, offset: 0, index: Int(modelPassJointWeightsIndex.rawValue))
-
- if let jtb = jointTransformBuffer {
- renderEncoder.setVertexBuffer(jtb, offset: 0, index: Int(modelPassJointTransformIndex.rawValue))
- } else {
- var identity = matrix_identity_float4x4
- renderEncoder.setVertexBytes(&identity, length: MemoryLayout.stride, index: Int(modelPassJointTransformIndex.rawValue))
- }
+ renderEncoder.bindModelVertexStreams(mesh: mesh, entityId: entityId)
renderEncoder.setFragmentBytes(&modelUniforms, length: MemoryLayout.stride, index: Int(modelPassFragmentUniformIndex.rawValue))
@@ -3644,59 +3518,7 @@ public enum RenderPasses {
index: Int(modelPassUniformIndex.rawValue)
)
- // Only enable armature path when a valid joint transform buffer exists.
- let jointTransformBuffer = mesh.skin?.jointTransformsBuffer
- var hasArmature = scene.get(component: SkeletonComponent.self, for: entityId) != nil && jointTransformBuffer != nil
- renderEncoder.setVertexBytes(
- &hasArmature,
- length: MemoryLayout.stride,
- index: Int(modelPassHasArmature.rawValue)
- )
-
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer,
- offset: 0,
- index: Int(modelPassVerticesIndex.rawValue)
- )
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)].buffer,
- offset: 0,
- index: Int(modelPassNormalIndex.rawValue)
- )
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassUVIndex.rawValue)].buffer,
- offset: 0,
- index: Int(modelPassUVIndex.rawValue)
- )
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)].buffer,
- offset: 0,
- index: Int(modelPassTangentIndex.rawValue)
- )
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer,
- offset: 0,
- index: Int(modelPassJointIdIndex.rawValue)
- )
- renderEncoder.setVertexBuffer(
- mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer,
- offset: 0,
- index: Int(modelPassJointWeightsIndex.rawValue)
- )
- if let jointTransformBuffer {
- renderEncoder.setVertexBuffer(
- jointTransformBuffer,
- offset: 0,
- index: Int(modelPassJointTransformIndex.rawValue)
- )
- } else {
- var identityMatrix = matrix_identity_float4x4
- renderEncoder.setVertexBytes(
- &identityMatrix,
- length: MemoryLayout.stride,
- index: Int(modelPassJointTransformIndex.rawValue)
- )
- }
+ renderEncoder.bindModelVertexStreams(mesh: mesh, entityId: entityId)
renderEncoder.setFragmentBytes(
&modelUniforms,
@@ -3916,30 +3738,7 @@ public enum RenderPasses {
index: Int(modelPassUniformIndex.rawValue)
)
- let jointTransformBuffer = mesh.skin?.jointTransformsBuffer
- var hasArmature = scene.get(component: SkeletonComponent.self, for: entityId) != nil && jointTransformBuffer != nil
- renderEncoder.setVertexBytes(
- &hasArmature,
- length: MemoryLayout.stride,
- index: Int(modelPassHasArmature.rawValue)
- )
-
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer, offset: 0, index: Int(modelPassVerticesIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)].buffer, offset: 0, index: Int(modelPassNormalIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassUVIndex.rawValue)].buffer, offset: 0, index: Int(modelPassUVIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)].buffer, offset: 0, index: Int(modelPassTangentIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer, offset: 0, index: Int(modelPassJointIdIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer, offset: 0, index: Int(modelPassJointWeightsIndex.rawValue))
- if let jointTransformBuffer {
- renderEncoder.setVertexBuffer(jointTransformBuffer, offset: 0, index: Int(modelPassJointTransformIndex.rawValue))
- } else {
- var identityMatrix = matrix_identity_float4x4
- renderEncoder.setVertexBytes(
- &identityMatrix,
- length: MemoryLayout.stride,
- index: Int(modelPassJointTransformIndex.rawValue)
- )
- }
+ renderEncoder.bindModelVertexStreams(mesh: mesh, entityId: entityId)
for subMesh in mesh.submeshes {
renderEncoder.drawIndexedPrimitives(
@@ -4155,30 +3954,7 @@ public enum RenderPasses {
index: Int(modelPassUniformIndex.rawValue)
)
- let jointTransformBuffer = mesh.skin?.jointTransformsBuffer
- var hasArmature = scene.get(component: SkeletonComponent.self, for: entityId) != nil && jointTransformBuffer != nil
- renderEncoder.setVertexBytes(
- &hasArmature,
- length: MemoryLayout.stride,
- index: Int(modelPassHasArmature.rawValue)
- )
-
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer, offset: 0, index: Int(modelPassVerticesIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)].buffer, offset: 0, index: Int(modelPassNormalIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassUVIndex.rawValue)].buffer, offset: 0, index: Int(modelPassUVIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)].buffer, offset: 0, index: Int(modelPassTangentIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer, offset: 0, index: Int(modelPassJointIdIndex.rawValue))
- renderEncoder.setVertexBuffer(mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer, offset: 0, index: Int(modelPassJointWeightsIndex.rawValue))
- if let jointTransformBuffer {
- renderEncoder.setVertexBuffer(jointTransformBuffer, offset: 0, index: Int(modelPassJointTransformIndex.rawValue))
- } else {
- var identityMatrix = matrix_identity_float4x4
- renderEncoder.setVertexBytes(
- &identityMatrix,
- length: MemoryLayout.stride,
- index: Int(modelPassJointTransformIndex.rawValue)
- )
- }
+ renderEncoder.bindModelVertexStreams(mesh: mesh, entityId: entityId)
if let edgeIndexBuffer = mesh.featureEdgeIndexBuffer, mesh.featureEdgeIndexCount > 0 {
renderEncoder.setTriangleFillMode(.fill)
@@ -4588,6 +4364,31 @@ public enum RenderPasses {
renderPassDescriptor.tileHeight = 32
renderPassDescriptor.imageblockSampleLength = initializePipelineState.imageblockSampleLength
+ // Snapshot the opaque depth before the pass so splats can be occluded by it.
+ // depthMap is also this pass's own .load'ed depth attachment below, and this
+ // engine never binds the same texture as both an attachment and a
+ // separately-sampled argument in one encoder (see copyOpaqueDepthForHZBExecution
+ // for the same precaution) β so the draw stage reads this copy instead.
+ if let sourceDepth = textureResources.depthMap,
+ let opaqueDepthSnapshot = textureResources.gaussianOpaqueDepthSnapshot
+ {
+ let width = min(sourceDepth.width, opaqueDepthSnapshot.width)
+ let height = min(sourceDepth.height, opaqueDepthSnapshot.height)
+ if width > 0, height > 0, let blitEncoder = commandBuffer.makeBlitCommandEncoder() {
+ blitEncoder.label = "Copy Opaque Depth for Gaussian Occlusion"
+ blitEncoder.copy(
+ from: sourceDepth,
+ sourceSlice: 0, sourceLevel: 0,
+ sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0),
+ sourceSize: MTLSize(width: width, height: height, depth: 1),
+ to: opaqueDepthSnapshot,
+ destinationSlice: 0, destinationLevel: 0,
+ destinationOrigin: MTLOrigin(x: 0, y: 0, z: 0)
+ )
+ blitEncoder.endEncoding()
+ }
+ }
+
guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else {
handleError(.renderPassCreationFailed, "Gaussian Pass")
return
@@ -4607,6 +4408,17 @@ public enum RenderPasses {
renderEncoder.setRenderPipelineState(drawPipelineState)
renderEncoder.setDepthStencilState(drawPipeline.depthState)
+ renderEncoder.setFragmentTexture(
+ textureResources.gaussianOpaqueDepthSnapshot,
+ index: Int(gaussianTBDRDrawOpaqueDepthTextureIndex.rawValue)
+ )
+ var gaussianDrawReverseZ = renderInfo.reverseZEnabled
+ renderEncoder.setFragmentBytes(
+ &gaussianDrawReverseZ,
+ length: MemoryLayout.stride,
+ index: Int(gaussianTBDRRenderReverseZIndex.rawValue)
+ )
+
let transformId = getComponentId(for: WorldTransformComponent.self)
let gaussianId = getComponentId(for: GaussianComponent.self)
let entities = queryEntitiesWithComponentIds([transformId, gaussianId], in: scene)
diff --git a/Sources/UntoldEngine/Renderer/RenderResources.swift b/Sources/UntoldEngine/Renderer/RenderResources.swift
index 59f7b5a1a..d042dc384 100644
--- a/Sources/UntoldEngine/Renderer/RenderResources.swift
+++ b/Sources/UntoldEngine/Renderer/RenderResources.swift
@@ -172,6 +172,12 @@ public struct TextureResources {
public var msaaDepthMap: MTLTexture?
public var msaaDeferredColorMap: MTLTexture?
public var hzbSourceDepthMap: MTLTexture?
+ /// Snapshot of the opaque scene's depth, copied before the Gaussian pass so splats
+ /// can be occluded by opaque geometry. A dedicated copy (rather than reading
+ /// `depthMap` directly) mirrors `hzbSourceDepthMap`'s pattern β `depthMap` is also
+ /// this pass's own `.load`ed depth attachment, and no other pass in this engine
+ /// binds the same texture as both an attachment and a separately-sampled argument.
+ public var gaussianOpaqueDepthSnapshot: MTLTexture?
public var environmentColorMap: MTLTexture?
// deferred
diff --git a/Sources/UntoldEngine/Renderer/RenderVertexStreamBinding.swift b/Sources/UntoldEngine/Renderer/RenderVertexStreamBinding.swift
new file mode 100644
index 000000000..b3c50d187
--- /dev/null
+++ b/Sources/UntoldEngine/Renderer/RenderVertexStreamBinding.swift
@@ -0,0 +1,91 @@
+//
+// RenderVertexStreamBinding.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 CShaderTypes
+import Metal
+import MetalKit
+import simd
+
+/// Mesh.metalKitMesh.vertexBuffers is laid out in model-descriptor order
+/// (ModelPassBufferIndices 0-5) regardless of which pass consumes it; the
+/// shadow pass sources from that same array but binds to its own slots.
+extension MTLRenderCommandEncoder {
+ func bindModelVertexStreams(mesh: Mesh, entityId: EntityID) {
+ let slots: [ModelPassBufferIndices] = [
+ modelPassVerticesIndex,
+ modelPassNormalIndex,
+ modelPassUVIndex,
+ modelPassTangentIndex,
+ modelPassJointIdIndex,
+ modelPassJointWeightsIndex,
+ ]
+ for slot in slots {
+ setVertexBuffer(
+ mesh.metalKitMesh.vertexBuffers[Int(slot.rawValue)].buffer,
+ offset: 0,
+ index: Int(slot.rawValue)
+ )
+ }
+ bindJointStreams(
+ mesh: mesh,
+ entityId: entityId,
+ hasArmatureIndex: Int(modelPassHasArmature.rawValue),
+ jointTransformIndex: Int(modelPassJointTransformIndex.rawValue)
+ )
+ }
+
+ func bindShadowVertexStreams(mesh: Mesh, entityId: EntityID) {
+ setVertexBuffer(
+ mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer,
+ offset: 0,
+ index: Int(shadowPassModelPositionIndex.rawValue)
+ )
+ setVertexBuffer(
+ mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer,
+ offset: 0,
+ index: Int(shadowPassJointIdIndex.rawValue)
+ )
+ setVertexBuffer(
+ mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer,
+ offset: 0,
+ index: Int(shadowPassJointWeightsIndex.rawValue)
+ )
+ bindJointStreams(
+ mesh: mesh,
+ entityId: entityId,
+ hasArmatureIndex: Int(shadowPassHasArmature.rawValue),
+ jointTransformIndex: Int(shadowPassJointTransformIndex.rawValue)
+ )
+ }
+
+ private func bindJointStreams(
+ mesh: Mesh,
+ entityId: EntityID,
+ hasArmatureIndex: Int,
+ jointTransformIndex: Int
+ ) {
+ // Only enable armature path when a valid joint transform buffer exists.
+ let jointTransformBuffer = mesh.skin?.jointTransformsBuffer
+ var hasArmature = getEntityComponent(entityId: entityId, componentType: SkeletonComponent.self) != nil
+ && jointTransformBuffer != nil
+ setVertexBytes(&hasArmature, length: MemoryLayout.stride, index: hasArmatureIndex)
+
+ if let jointTransformBuffer {
+ setVertexBuffer(jointTransformBuffer, offset: 0, index: jointTransformIndex)
+ } else {
+ var identityMatrix = matrix_identity_float4x4
+ setVertexBytes(
+ &identityMatrix,
+ length: MemoryLayout.stride,
+ index: jointTransformIndex
+ )
+ }
+ }
+}
diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift
index 2310b7b57..be7d9dc49 100644
--- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift
+++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift
@@ -197,7 +197,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate {
BatchingSystem.shared.applyRuntimeBatchingTuning(.macOSBalanced)
#endif
- Logger.log(message: "Untold Engine Starting. Version 0.14.3")
+ Logger.log(message: "Untold Engine Starting. Version 0.15.0")
}
public func initSizeableResources() {
diff --git a/Sources/UntoldEngine/Scenes/Builder/UntoldView.swift b/Sources/UntoldEngine/Scenes/Builder/UntoldView.swift
index f6ce8dce7..b8c6fe1e5 100644
--- a/Sources/UntoldEngine/Scenes/Builder/UntoldView.swift
+++ b/Sources/UntoldEngine/Scenes/Builder/UntoldView.swift
@@ -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
@@ -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
+ }
}
diff --git a/Sources/UntoldEngine/Scenes/SceneView.swift b/Sources/UntoldEngine/Scenes/SceneView.swift
index 3657e6905..bb1b66eb1 100644
--- a/Sources/UntoldEngine/Scenes/SceneView.swift
+++ b/Sources/UntoldEngine/Scenes/SceneView.swift
@@ -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.
@@ -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
}
}
diff --git a/Sources/UntoldEngine/Scenes/UntoldViewOptions.swift b/Sources/UntoldEngine/Scenes/UntoldViewOptions.swift
new file mode 100644
index 000000000..327eac0e0
--- /dev/null
+++ b/Sources/UntoldEngine/Scenes/UntoldViewOptions.swift
@@ -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()
+}
diff --git a/Sources/UntoldEngine/Shaders/Gaussians.metal b/Sources/UntoldEngine/Shaders/Gaussians.metal
index 60f8c7cee..d3500f195 100644
--- a/Sources/UntoldEngine/Shaders/Gaussians.metal
+++ b/Sources/UntoldEngine/Shaders/Gaussians.metal
@@ -352,7 +352,9 @@ vertex GaussianOutData vertexGaussianTBDRShader(
fragment GaussianTBDRFragmentStore fragmentGaussianTBDRShader(
GaussianOutData in [[stage_in]],
- GaussianTBDRFragmentValues previousValues [[imageblock_data]])
+ GaussianTBDRFragmentValues previousValues [[imageblock_data]],
+ depth2d opaqueDepth [[texture(gaussianTBDRDrawOpaqueDepthTextureIndex)]],
+ constant bool &reverseZ [[buffer(gaussianTBDRRenderReverseZIndex)]])
{
GaussianTBDRFragmentStore out;
@@ -360,6 +362,29 @@ fragment GaussianTBDRFragmentStore fragmentGaussianTBDRShader(
discard_fragment();
}
+ // Occlude against opaque geometry already in the depth buffer (a snapshot taken
+ // before this pass β see gaussianExecution). in.position.z is already normalized
+ // device depth from the same projection the opaque pass used, so it's directly
+ // comparable to the stored value with no linearization needed. A small bias avoids
+ // a hard cutoff exactly at surface intersections. Background pixels hold the clear
+ // value (the farthest depth in either convention), so splats over empty background
+ // are never occluded without any special-casing.
+ float storedOpaqueDepth = opaqueDepth.read(uint2(in.position.xy));
+ float splatDepth = in.position.z;
+ const float depthBias = 0.0005f;
+ bool occludedByOpaque = reverseZ
+ ? (splatDepth + depthBias < storedOpaqueDepth)
+ : (splatDepth > storedOpaqueDepth + depthBias);
+ if (occludedByOpaque) {
+ // discard_fragment() only suppresses standard color/depth attachment writes β
+ // it does NOT stop this function from computing and returning a value for the
+ // custom imageblock struct below, so relying on it alone here would let an
+ // occluded splat's contribution reach the accumulator anyway. Return the
+ // accumulator unchanged instead, so an occluded splat contributes nothing.
+ out.values = previousValues;
+ return out;
+ }
+
const float projYSign = 1.0f;
float2 d = calcScreenSpaceDelta(in.position.xy, in.coordxy, projYSign);
float power = calcPowerFromConic(in.conic, d);
diff --git a/Sources/UntoldEngine/Systems/GaussianSystem.swift b/Sources/UntoldEngine/Systems/GaussianSystem.swift
index db534bb1c..30c42b0c0 100644
--- a/Sources/UntoldEngine/Systems/GaussianSystem.swift
+++ b/Sources/UntoldEngine/Systems/GaussianSystem.swift
@@ -27,6 +27,7 @@ private func activeGaussianSortCount(_ component: GaussianComponent) -> Int {
}
private struct GaussianVisibleCountUpdate: @unchecked Sendable {
+ let entityId: EntityID
let component: GaussianComponent
let visibleCount: MTLBuffer
let splatCount: UInt
@@ -154,6 +155,7 @@ public func executeGaussianFrustumCulling(_ commandBuffer: MTLCommandBuffer) {
visibleCountUpdates.append(
GaussianVisibleCountUpdate(
+ entityId: entityId,
component: gaussianComponent,
visibleCount: visibleCount,
splatCount: gaussianComponent.splatCount
@@ -168,6 +170,16 @@ public func executeGaussianFrustumCulling(_ commandBuffer: MTLCommandBuffer) {
for update in updates {
let count = update.visibleCount.contents().load(as: UInt32.self)
update.component.visibleSplatCountForRendering = min(UInt(count), update.splatCount)
+
+ // Streamed splat entities carry no RenderComponent, so they're structurally
+ // excluded from the RenderComponent-keyed culling query that normally feeds
+ // MemoryBudgetManager.markUsed (see CullingSystem.swift). Without this, a loaded
+ // splat's lastUsedFrame would never advance and evictLRU would treat it as the
+ // stalest entity in the budget regardless of actual visibility. Mark used only
+ // when at least one splat survived this frame's frustum test.
+ if update.component.visibleSplatCountForRendering > 0 {
+ MemoryBudgetManager.shared.markUsed(entityId: update.entityId)
+ }
}
}
diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+GaussianStreaming.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+GaussianStreaming.swift
new file mode 100644
index 000000000..f8ecd50df
--- /dev/null
+++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+GaussianStreaming.swift
@@ -0,0 +1,126 @@
+//
+// GeometryStreamingSystem+GaussianStreaming.swift
+// UntoldEngine
+//
+// Gaussian-splat streaming load dispatch and unload. The actual asset load goes through
+// the shared, public setEntityGaussianAsync (RegistrationSystem.swift) β gaussians have no
+// cache/OCC layer analogous to MeshResourceManager, so unlike mesh streaming there is no
+// separate streaming-only load helper, just the dispatch/completion wrapper below.
+//
+// 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 Foundation
+
+extension GeometryStreamingSystem {
+ /// Dispatches the async load for a streamed Gaussian-splat entity and installs the
+ /// completion handling (state transition, residency event, monitor bookkeeping,
+ /// active-load slot release). Called from `loadMesh` once it has already reserved the
+ /// load slot and recorded scheduler-latency instrumentation β mirrors the structure of
+ /// `loadMesh`'s own mesh-path `Task`, but calls the shared `setEntityGaussianAsync`
+ /// instead of the mesh-only `loadMeshAsync`/`reloadLODEntity`, and skips
+ /// `incrementParentTileOCCCount` β that counter tracks progressive upload of a tile's own
+ /// OCC sub-meshes (`TileComponent.totalOCCStubs`), which splat props never contribute to;
+ /// incrementing it here would inflate the numerator past a denominator it never grew,
+ /// corrupting the tile's `visualState` completion fraction.
+ func loadGaussianStreamingEntity(entityId: EntityID, streaming: StreamingComponent, isNearBand: Bool) {
+ let filename = streaming.assetFilename
+ let ext = streaming.assetExtension
+
+ let task = Task {
+ let asyncLoadStart = CFAbsoluteTimeGetCurrent()
+ let success = await setEntityGaussianAsync(entityId: entityId, filename: filename, withExtension: ext)
+ let asyncLoadMs = (CFAbsoluteTimeGetCurrent() - asyncLoadStart) * 1000.0
+
+ var applyMs: Double = 0
+ withWorldMutationGate {
+ let applyStart = CFAbsoluteTimeGetCurrent()
+
+ // Guard against the cooperative-cancellation race, same as loadMesh's mesh path:
+ // unloadGaussian may have freed this entity while the load was in flight.
+ guard scene.exists(entityId) else {
+ releaseActiveLoad(entityId: entityId)
+ if isNearBand { releaseNearBandLoad(entityId: entityId) }
+ return
+ }
+
+ guard let s = scene.get(component: StreamingComponent.self, for: entityId),
+ s.state == .loading
+ else {
+ releaseActiveLoad(entityId: entityId)
+ if isNearBand { releaseNearBandLoad(entityId: entityId) }
+ return
+ }
+
+ if success {
+ s.state = .loaded
+ s.lastVisibleFrame = currentFrame
+
+ let event = AssetResidencyChangedEvent(
+ entityId: entityId,
+ assetURL: URL(fileURLWithPath: ""),
+ meshName: s.assetFilename,
+ isResident: true
+ )
+ SystemEventBus.shared.queueResidencyChange(event)
+ markLoadedStreamingEntity(entityId)
+ SystemIntegrationMonitor.shared.recordStreamingLoad()
+ } else {
+ s.state = .unloaded
+ handleError(.meshStreamingFailed, entityId)
+ }
+ releaseActiveLoad(entityId: entityId)
+ if isNearBand { releaseNearBandLoad(entityId: entityId) }
+ applyMs = (CFAbsoluteTimeGetCurrent() - applyStart) * 1000.0
+ }
+ recordLoadCompletion(success: success, asyncLoadMs: asyncLoadMs, applyMs: applyMs, wasLODReload: false)
+ }
+
+ streaming.loadTask = task
+ }
+
+ /// Tears down a streamed Gaussian-splat entity's GPU resources and removes it from the
+ /// shared memory budget. Mirrors `unloadMesh` (`GeometryStreamingSystem+MeshStreaming.swift`)
+ /// but skips the mesh-only bits (RenderComponent, LODComponent, MeshResourceManager,
+ /// BatchingSystem static-batch retirement) that don't apply to splats.
+ func unloadGaussian(entityId: EntityID) {
+ guard let streaming = scene.get(component: StreamingComponent.self, for: entityId),
+ streaming.state == .loaded
+ else { return }
+
+ firstRangeTimestamps.removeValue(forKey: entityId)
+
+ let unloadStart = CFAbsoluteTimeGetCurrent()
+ withWorldMutationGate {
+ streaming.state = .unloading
+
+ streaming.loadTask?.cancel()
+ streaming.loadTask = nil
+
+ removeEntityGaussian(entityId: entityId)
+
+ // removeEntityGaussian already unregisters from MemoryBudgetManager, but the call
+ // is idempotent, so calling it here too keeps this function correct even if
+ // removeEntityGaussian's cleanup behavior changes later.
+ MemoryBudgetManager.shared.unregisterMesh(entityId: entityId)
+
+ unmarkLoadedStreamingEntity(entityId)
+
+ streaming.state = .unloaded
+
+ let event = AssetResidencyChangedEvent(
+ entityId: entityId,
+ assetURL: URL(fileURLWithPath: ""),
+ meshName: streaming.assetFilename,
+ isResident: false
+ )
+ SystemEventBus.shared.queueResidencyChange(event)
+ SystemIntegrationMonitor.shared.recordStreamingUnload()
+ }
+ let unloadMs = (CFAbsoluteTimeGetCurrent() - unloadStart) * 1000.0
+ updateLastUnloadDuration(unloadMs)
+ }
+}
diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+MeshStreaming.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+MeshStreaming.swift
index e386c321f..958cfe90c 100644
--- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+MeshStreaming.swift
+++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+MeshStreaming.swift
@@ -39,6 +39,11 @@ extension GeometryStreamingSystem {
)
}
+ if streaming.assetKind == .gaussianSplat {
+ loadGaussianStreamingEntity(entityId: entityId, streaming: streaming, isNearBand: isNearBand)
+ return
+ }
+
let hasLOD = scene.get(component: LODComponent.self, for: entityId) != nil
let filename = streaming.assetFilename
@@ -369,6 +374,11 @@ extension GeometryStreamingSystem {
streaming.state == .loaded
else { return }
+ if streaming.assetKind == .gaussianSplat {
+ unloadGaussian(entityId: entityId)
+ return
+ }
+
// Clear first-detection timestamp so a future re-approach records a fresh baseline.
firstRangeTimestamps.removeValue(forKey: entityId)
diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift
index 460369592..908e99ae5 100644
--- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift
+++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift
@@ -910,6 +910,18 @@ extension GeometryStreamingSystem {
var result: [EntityID] = []
for childId in getEntityChildren(parentId: parentId) {
if let streaming = scene.get(component: StreamingComponent.self, for: childId) {
+ // Gaussian-splat props are externally-attached, persistent content β unlike
+ // .mesh StreamingComponent children, they are never generated by the tile's
+ // own USDC/OCC parse pipeline (there is no such thing as a gaussian OCC
+ // sub-stub). They must survive the tile's own unload/reparse cycles rather
+ // than being torn down alongside its regenerable mesh descendants, so skip
+ // them entirely: do not cancel their load task, unmark them, collect them
+ // for destruction, or recurse into them. Their own streamingRadius/
+ // unloadRadius continues to govern them independently via the normal
+ // StreamingComponent update() loop.
+ if streaming.assetKind == .gaussianSplat {
+ continue
+ }
streaming.loadTask?.cancel()
streaming.loadTask = nil
unmarkLoadedStreamingEntity(childId)
@@ -1048,3 +1060,36 @@ extension GeometryStreamingSystem {
}
}
}
+
+// MARK: - Tile lookup by position
+
+/// Finds the tile stub entity (an entity carrying `TileComponent`, registered by
+/// `setEntityStreamScene`) whose world-space bounding box contains `point`.
+///
+/// Tile stub bounds are already in world space (tile transforms are identity), so a
+/// direct AABB containment check against `LocalTransformComponent.boundingBox` is valid.
+/// Useful for attaching an ad-hoc streaming prop (e.g. a Gaussian splat) to whichever
+/// tile occupies a given world position β see `setEntityGaussianStreamable`.
+///
+/// - Parameters:
+/// - point: World-space position to test against each candidate tile's bounds.
+/// - searchRadius: Octree query radius in world units. Only needs to cover roughly
+/// one tile/room footprint; defaults to a generous 200m.
+/// - Returns: The tile entity containing `point`, or `nil` if none is found within
+/// `searchRadius`.
+public func findTileEntity(containing point: simd_float3, searchRadius: Float = 200.0) -> EntityID? {
+ let nearby = OctreeSystem.shared.queryNear(point: point, radius: searchRadius)
+ for entityId in nearby {
+ guard scene.get(component: TileComponent.self, for: entityId) != nil,
+ let local = scene.get(component: LocalTransformComponent.self, for: entityId)
+ else { continue }
+ let (minB, maxB) = local.boundingBox
+ if point.x >= minB.x, point.x <= maxB.x,
+ point.y >= minB.y, point.y <= maxB.y,
+ point.z >= minB.z, point.z <= maxB.z
+ {
+ return entityId
+ }
+ }
+ return nil
+}
diff --git a/Sources/UntoldEngine/Systems/PhysicsSystem.swift b/Sources/UntoldEngine/Systems/PhysicsSystem.swift
index 1e2e8d6fb..1bf22105f 100644
--- a/Sources/UntoldEngine/Systems/PhysicsSystem.swift
+++ b/Sources/UntoldEngine/Systems/PhysicsSystem.swift
@@ -99,13 +99,14 @@ public func updatePhysicsSystem(deltaTime: Float) {
let physicsId = getComponentId(for: PhysicsComponents.self)
let transformId = getComponentId(for: LocalTransformComponent.self)
let entities = queryEntitiesWithComponentIds([kineticId, physicsId, transformId], in: scene)
+ let gravity = PhysicsCoordinator.shared.worldGravity
for entity in entities {
if isPhysicsComponentPaused(entityId: entity) {
continue
}
- addGravity(entityId: entity, gravity: simd_float3(0.0, -9.8, 0.0)) // add gravity
+ addGravity(entityId: entity, gravity: gravity)
addDrag(entityId: entity, deltaTime: deltaTime)
accumulateForces(entityId: entity, deltaTime: deltaTime) // Apply accumulated forces to acceleration
accumulateMoment(entityId: entity, deltaTime: deltaTime)
diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift
index e1244f199..c8a2f84f3 100644
--- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift
+++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift
@@ -3195,10 +3195,28 @@ public func loadRawMesh(
return Mesh.makeDefaultMesh()
}
-public func setEntityGaussian(entityId: EntityID, filename: String, withExtension: String) {
+/// Built Metal resources for a parsed Gaussian splat asset, ready to attach to an entity.
+/// Shared by `setEntityGaussian` (synchronous) and `setEntityGaussianAsync` (off-thread) so
+/// there is a single implementation of the PLY-parse/buffer-build/SH-pack pipeline.
+private struct GaussianLoadResult {
+ let splatCount: UInt
+ let gaussianSortedIndices: MTLBuffer
+ let gaussianVisibleIndices: MTLBuffer
+ let gaussianVisibleCount: MTLBuffer
+ let encodedSplatBuffer: MTLBuffer
+ let sphericalHarmonicsBuffer: MTLBuffer?
+ let sphericalHarmonicsMetadata: GaussianSHMetadata?
+ let spaceUniform: [MTLBuffer?]
+ /// Sum of all GPU buffer bytes above, for `MemoryBudgetManager` registration.
+ let estimatedGPUBytes: Int
+}
+
+/// Reads a `.ply` Gaussian splat asset from disk and builds its GPU buffers.
+/// Returns `nil` on any failure, calling `handleError` internally β callers just guard-return.
+private func buildGaussianLoadResult(filename: String, withExtension: String) -> GaussianLoadResult? {
guard let url = LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension, subResource: nil) else {
handleError(.filenameNotFound, filename)
- return
+ return nil
}
// Attempt to read Gaussian splats, handling errors internally
@@ -3207,20 +3225,20 @@ public func setEntityGaussian(entityId: EntityID, filename: String, withExtensio
asset = try PLYReader.readGaussianAsset(from: url)
} catch {
handleError(.assetDataMissing, "Failed to read Gaussian splats from \(filename): \(error.localizedDescription)")
- return
+ return nil
}
let splats = asset.splats
// Check if we exceed the buffer capacity
guard splats.count <= Int(maxNumOfGaussians) else {
handleError(.bufferAllocationFailed, "Too many Gaussian splats: \(splats.count) exceeds maximum \(maxNumOfGaussians)")
- return
+ return nil
}
let splatCount = UInt(splats.count)
guard splatCount > 0 else {
handleError(.assetDataMissing, "Gaussian splat file contains no vertices: \(filename)")
- return
+ return nil
}
guard let gaussianSortedIndices = renderInfo.device.makeBuffer(
@@ -3228,7 +3246,7 @@ public func setEntityGaussian(entityId: EntityID, filename: String, withExtensio
options: .storageModeShared
) else {
handleError(.bufferAllocationFailed, "Gaussian sorted-index buffer is nil")
- return
+ return nil
}
guard let gaussianVisibleIndices = renderInfo.device.makeBuffer(
@@ -3236,7 +3254,7 @@ public func setEntityGaussian(entityId: EntityID, filename: String, withExtensio
options: .storageModeShared
) else {
handleError(.bufferAllocationFailed, "Gaussian visible-index buffer is nil")
- return
+ return nil
}
guard let gaussianVisibleCount = renderInfo.device.makeBuffer(
@@ -3244,13 +3262,13 @@ public func setEntityGaussian(entityId: EntityID, filename: String, withExtensio
options: .storageModeShared
) else {
handleError(.bufferAllocationFailed, "Gaussian visible-count buffer is nil")
- return
+ return nil
}
gaussianVisibleCount.contents().storeBytes(of: UInt32(splatCount), as: UInt32.self)
guard let encodedSplatBuffer = renderInfo.device.makeBuffer(length: MemoryLayout.stride * Int(splatCount), options: .storageModeShared) else {
handleError(.bufferAllocationFailed, "Encoded Gaussian splat buffer is nil")
- return
+ return nil
}
let encodedPointer = encodedSplatBuffer.contents().bindMemory(
@@ -3269,7 +3287,7 @@ public func setEntityGaussian(entityId: EntityID, filename: String, withExtensio
}
} catch {
handleError(.assetDataMissing, "Failed to pack spherical harmonics from \(filename): \(error.localizedDescription)")
- return
+ return nil
}
let sphericalHarmonicsBuffer: MTLBuffer?
@@ -3281,7 +3299,7 @@ public func setEntityGaussian(entityId: EntityID, filename: String, withExtensio
)
guard sphericalHarmonicsBuffer != nil else {
handleError(.bufferAllocationFailed, "Gaussian spherical-harmonics buffer is nil")
- return
+ return nil
}
sphericalHarmonicsBuffer?.label = "Gaussian Spherical Harmonics"
} else {
@@ -3293,23 +3311,141 @@ public func setEntityGaussian(entityId: EntityID, filename: String, withExtensio
options: [MTLResourceOptions.storageModeShared])
}
+ var estimatedGPUBytes = 0
+ estimatedGPUBytes += gaussianSortedIndices.length
+ estimatedGPUBytes += gaussianVisibleIndices.length
+ estimatedGPUBytes += gaussianVisibleCount.length
+ estimatedGPUBytes += encodedSplatBuffer.length
+ estimatedGPUBytes += sphericalHarmonicsBuffer?.length ?? 0
+ for buffer in spaceUniform {
+ estimatedGPUBytes += buffer.length
+ }
+
+ return GaussianLoadResult(
+ splatCount: splatCount,
+ gaussianSortedIndices: gaussianSortedIndices,
+ gaussianVisibleIndices: gaussianVisibleIndices,
+ gaussianVisibleCount: gaussianVisibleCount,
+ encodedSplatBuffer: encodedSplatBuffer,
+ sphericalHarmonicsBuffer: sphericalHarmonicsBuffer,
+ sphericalHarmonicsMetadata: packedSphericalHarmonics?.metadata,
+ spaceUniform: spaceUniform,
+ estimatedGPUBytes: estimatedGPUBytes
+ )
+}
+
+/// Registers `GaussianComponent` on `entityId` from a built `GaussianLoadResult` and records
+/// its GPU footprint with `MemoryBudgetManager`. Must be called from within a world-mutation
+/// gate (`withWorldMutationGate`).
+private func applyGaussianLoadResult(_ result: GaussianLoadResult, to entityId: EntityID) {
+ registerComponent(entityId: entityId, componentType: GaussianComponent.self)
+
+ guard let gaussianComponent = scene.get(component: GaussianComponent.self, for: entityId) else {
+ handleError(.noRenderComponent, entityId)
+ return
+ }
+
+ gaussianComponent.splatCount = result.splatCount
+ gaussianComponent.visibleSplatCountForRendering = result.splatCount
+ gaussianComponent.gaussianSortedIndices = result.gaussianSortedIndices
+ gaussianComponent.gaussianVisibleIndices = result.gaussianVisibleIndices
+ gaussianComponent.gaussianVisibleCount = result.gaussianVisibleCount
+ gaussianComponent.encodedSplatData = result.encodedSplatBuffer
+ gaussianComponent.sphericalHarmonicsData = result.sphericalHarmonicsBuffer
+ gaussianComponent.sphericalHarmonicsMetadata = result.sphericalHarmonicsMetadata
+ gaussianComponent.spaceUniform = result.spaceUniform
+
+ MemoryBudgetManager.shared.registerMesh(entityId: entityId, meshSizeBytes: result.estimatedGPUBytes)
+}
+
+public func setEntityGaussian(entityId: EntityID, filename: String, withExtension: String) {
+ guard let result = buildGaussianLoadResult(filename: filename, withExtension: withExtension) else {
+ return
+ }
+
withWorldMutationGate {
- registerComponent(entityId: entityId, componentType: GaussianComponent.self)
+ applyGaussianLoadResult(result, to: entityId)
+ }
+}
- guard let gaussianComponent = scene.get(component: GaussianComponent.self, for: entityId) else {
- handleError(.noRenderComponent, entityId)
- return
- }
+/// Asynchronously reads and encodes a `.ply` Gaussian splat asset and attaches it to
+/// `entityId` without blocking the main thread. Parsing, per-splat encoding, and spherical-
+/// harmonics packing all run before the world-mutation gate is acquired; only the final
+/// component registration runs under `withWorldMutationGate`, mirroring `setEntityMeshAsync`'s
+/// pattern of keeping GPU resource work outside the gate.
+///
+/// `async -> Bool` (rather than `setEntityMeshAsync`'s fire-and-forget/completion shape) so
+/// `GeometryStreamingSystem.loadMesh` can `await` it directly from its own dispatch `Task`,
+/// the same way it awaits the mesh path. Used both by direct callers that want a non-blocking
+/// one-off load, and internally by the streaming system for distance-streamed splat props β
+/// gaussians have no cache/OCC layer analogous to `MeshResourceManager`, so unlike mesh
+/// streaming there is no separate streaming-only loader.
+@discardableResult
+public func setEntityGaussianAsync(
+ entityId: EntityID,
+ filename: String,
+ withExtension: String,
+ completion: (@Sendable (Bool) -> Void)? = nil
+) async -> Bool {
+ guard let result = buildGaussianLoadResult(filename: filename, withExtension: withExtension) else {
+ completion?(false)
+ return false
+ }
- gaussianComponent.splatCount = splatCount
- gaussianComponent.visibleSplatCountForRendering = splatCount
- gaussianComponent.gaussianSortedIndices = gaussianSortedIndices
- gaussianComponent.gaussianVisibleIndices = gaussianVisibleIndices
- gaussianComponent.gaussianVisibleCount = gaussianVisibleCount
- gaussianComponent.encodedSplatData = encodedSplatBuffer
- gaussianComponent.sphericalHarmonicsData = sphericalHarmonicsBuffer
- gaussianComponent.sphericalHarmonicsMetadata = packedSphericalHarmonics?.metadata
- gaussianComponent.spaceUniform = spaceUniform
+ withWorldMutationGate {
+ applyGaussianLoadResult(result, to: entityId)
+ }
+
+ completion?(true)
+ return true
+}
+
+/// Registers `entityId` as a distance-streamed Gaussian-splat prop, so
+/// `GeometryStreamingSystem` loads/unloads it based on camera distance the same way it
+/// does the surrounding tile geometry β rather than loading it immediately the way
+/// `setEntityGaussian`/`setEntityGaussianAsync` do.
+///
+/// `entityId` should already be positioned (e.g. via `translateTo`/`rotateTo`) before
+/// calling this β the entity's current `LocalTransformComponent.position` is used to find
+/// which tile it belongs to, via `findTileEntity(containing:)`. If no tile is found there,
+/// this logs a warning and leaves `entityId` as a plain, non-streaming entity.
+///
+/// `boundingBoxHalfExtent` has no default on purpose: `GeometryStreamingSystem`'s frustum
+/// gate needs a real local-space volume on the entity before it ever loads (the splat's
+/// true extent isn't known until the asset is parsed). A zero-size placeholder collapses
+/// the gate to a single exact point, making re-streaming unreliable once the camera moves
+/// away and back β pick a box roughly matching the prop's real-world size.
+public func setEntityGaussianStreamable(
+ entityId: EntityID,
+ filename: String,
+ withExtension ext: String,
+ streamingRadius: Float = 100.0,
+ unloadRadius: Float = 150.0,
+ boundingBoxHalfExtent: simd_float3,
+ priority: Int = 0
+) {
+ guard let local = scene.get(component: LocalTransformComponent.self, for: entityId) else {
+ handleError(.noLocalTransformComponent, entityId)
+ return
+ }
+
+ guard let tileEntity = findTileEntity(containing: local.position) else {
+ Logger.logWarning(message: "[RegistrationSystem] setEntityGaussianStreamable: no tile found containing position \(local.position) for entity \(entityId) β entity left non-streaming.")
+ return
+ }
+
+ local.boundingBox = (min: -boundingBoxHalfExtent, max: boundingBoxHalfExtent)
+
+ setParent(childId: entityId, parentId: tileEntity)
+ OctreeSystem.shared.registerEntity(entityId)
+
+ if let streaming = scene.assign(to: entityId, component: StreamingComponent.self) {
+ streaming.assetKind = .gaussianSplat
+ streaming.assetFilename = filename
+ streaming.assetExtension = ext
+ streaming.streamingRadius = streamingRadius
+ streaming.unloadRadius = unloadRadius
+ streaming.priority = priority
}
}
@@ -3535,6 +3671,10 @@ func removeEntityGaussian(entityId: EntityID) {
gaussianComponent.visibleSplatCountForRendering = 0
gaussianComponent.spaceUniform.removeAll()
scene.remove(component: GaussianComponent.self, from: entityId)
+ // Idempotent β safe to call again if `unloadGaussian` already unregistered this
+ // entity as part of a streaming unload. Keeps MemoryBudgetManager's ledger accurate
+ // for entities destroyed directly (e.g. a non-streamed setEntityGaussian caller).
+ MemoryBudgetManager.shared.unregisterMesh(entityId: entityId)
}
}
diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air
index cadabab69..c0ab4b40f 100644
Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air differ
diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib
index f46314ed4..8e3e6ff4c 100644
Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib differ
diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib
index 4181b771a..359da21ea 100644
Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-iossim.metallib differ
diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air
index ca8eb58a5..efed062af 100644
Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.air differ
diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib
index 9b6644279..b1e402ead 100644
Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvos.metallib differ
diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air
index 2161de5de..a49a9a7f9 100644
Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.air differ
diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib
index ff4d91f2a..cd99f5a94 100644
Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-tvossim.metallib differ
diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air
index c10afd569..a4b68449c 100644
Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.air differ
diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib
index e709987f5..02e17e677 100644
Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib differ
diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air
index 16d45fb80..6ecccd2a2 100644
Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.air differ
diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib
index c9b28398b..fabbf86e3 100644
Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib differ
diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib
index ba04641af..379ad6841 100644
Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib differ
diff --git a/Tests/UntoldEngineRenderTests/GaussianRenderingTest.swift b/Tests/UntoldEngineRenderTests/GaussianRenderingTest.swift
index 00f964c7a..f00c90625 100644
--- a/Tests/UntoldEngineRenderTests/GaussianRenderingTest.swift
+++ b/Tests/UntoldEngineRenderTests/GaussianRenderingTest.swift
@@ -448,6 +448,143 @@ final class GaussianRenderingTest: BaseRenderSetup {
"Sorted passes should equal total passes")
}
+ // MARK: - Depth occlusion against opaque geometry
+
+ //
+ // These tests deliberately avoid assuming which exact screen pixel the splat cloud
+ // projects to (a real .ply point cloud isn't necessarily centered on its entity's
+ // transform origin). Instead, a large cube is used as an occluder, sized and
+ // positioned so its near face covers the entire view frustum near the camera β its
+ // depth is written at *every* pixel, so it occludes the splat wherever it actually
+ // renders, without needing pixel-perfect alignment. "Not occluded" is verified by the
+ // splat's own baseline visibility (maxAlphaAnywhere) with no occluder present at all.
+
+ // A sphere large enough (relative to its distance) to cover the whole frame puts the
+ // camera inside its own volume β its surface then faces away from the camera and gets
+ // back-face culled, i.e. it stops rendering entirely. A cube's flat near face avoids
+ // that: as long as the camera sits in front of that face (not inside the cube), it
+ // fully blocks the view within its angular footprint regardless of size.
+ @discardableResult
+ private func addFullFrameOccludingCube(at position: simd_float3, extent: Float = 8.0) -> EntityID {
+ let entity = createEntity()
+ var meshes = BasicPrimitives.createCube(extent: extent)
+ // BasicPrimitives meshes carry no material (ModelIO primitives don't set one),
+ // and combinedModelLightExecution silently skips any submesh with `material == nil`
+ // (RenderPasses.swift:2262) β no draw call, no depth write. Assign a plain opaque
+ // material so this occluder actually renders instead of being invisibly skipped.
+ let defaultMaterial = Material(runtimeMaterial: RuntimeMaterialSource(), device: renderInfo.device)
+ for meshIndex in meshes.indices {
+ for submeshIndex in meshes[meshIndex].submeshes.indices {
+ meshes[meshIndex].submeshes[submeshIndex].material = defaultMaterial
+ }
+ }
+ if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) {
+ renderComponent.mesh = meshes
+ renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/occluder.untold")
+ }
+ if let local = scene.get(component: LocalTransformComponent.self, for: entity) {
+ local.position = position
+ local.boundingBox = Mesh.computeMeshBoundingBox(for: meshes)
+ }
+ if let world = scene.get(component: WorldTransformComponent.self, for: entity) {
+ var space = matrix_identity_float4x4
+ space.columns.3 = simd_float4(position, 1.0)
+ world.space = space
+ }
+ setVisibleEntities()
+ return entity
+ }
+
+ /// Scans the whole texture for the maximum alpha value found anywhere β a
+ /// registration-independent stand-in for "is the splat visible somewhere in frame."
+ private func maxAlphaAnywhere(in texture: MTLTexture) -> Float {
+ precondition(texture.pixelFormat == .rgba16Float, "Test assumes the Gaussian target is rgba16Float")
+ let width = texture.width
+ let height = texture.height
+ let bytesPerPixel = 8
+ let bytesPerRow = width * bytesPerPixel
+ let dataSize = bytesPerRow * height
+ let rawData = UnsafeMutableRawPointer.allocate(byteCount: dataSize, alignment: 1)
+ defer { rawData.deallocate() }
+ texture.getBytes(rawData, bytesPerRow: bytesPerRow, from: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0)
+ let ptr = rawData.bindMemory(to: Float16.self, capacity: width * height * 4)
+ var best: Float = 0
+ for i in 0 ..< (width * height) {
+ let a = Float(ptr[i * 4 + 3])
+ if a > best { best = a }
+ }
+ return best
+ }
+
+ private func renderAndReadMaxAlpha() -> Float {
+ renderer.draw(in: renderer.metalView)
+ let expectation = XCTestExpectation(description: "Gaussian render")
+ var result: Float = -1
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
+ result = self.maxAlphaAnywhere(in: renderInfo.gaussianRenderPassDescriptor.colorAttachments[Int(0)].texture!)
+ expectation.fulfill()
+ }
+ wait(for: [expectation], timeout: TimeInterval(timeoutFactor))
+ return result
+ }
+
+ /// Baseline: the test splat, alone, is visible somewhere in frame. Establishes that
+ /// the occlusion tests below have something real to occlude.
+ func testGaussianOcclusion_splatAloneIsVisible() {
+ let camera = createTestCamera()
+ cameraLookAt(entityId: camera, eye: simd_float3(0, 3, 7), target: simd_float3(0, 0, 0), up: simd_float3(0, 1, 0))
+
+ let alpha = renderAndReadMaxAlpha()
+ XCTAssertGreaterThan(alpha, 0.05, "β Splat alone with no occluder should be visible somewhere in frame, got \(alpha)")
+ }
+
+ /// A splat behind a closer opaque object covering the whole frame must be fully
+ /// occluded. Regression test for the depth-read added to `fragmentGaussianTBDRShader`
+ /// (previously the draw pipeline had `depthCompareFunction: .always, depthEnabled: false`
+ /// and never read the opaque depth at all, so splats always rendered on top regardless
+ /// of geometry in front).
+ func testGaussianOcclusion_hiddenBehindCloserOpaqueMesh() {
+ let eye = simd_float3(0, 3, 7)
+ let target = simd_float3(0, 0, 0)
+
+ let camera = createTestCamera()
+ cameraLookAt(entityId: camera, eye: eye, target: target, up: simd_float3(0, 1, 0))
+
+ // Cube center placed so its near face (facing the camera) sits ~1 unit in front
+ // of the camera, well outside the cube itself, with a wide enough face to cover
+ // the frame at that distance.
+ let nearPoint = eye + 0.657 * (target - eye) // distance β5.0 from eye; near face β1.0
+ addFullFrameOccludingCube(at: nearPoint, extent: 8.0)
+
+ let alpha = renderAndReadMaxAlpha()
+ XCTAssertLessThan(
+ alpha, 0.05,
+ "β Splat behind a closer full-frame opaque mesh should be fully occluded, got maxAlpha=\(alpha)"
+ )
+ }
+
+ /// The mirror case: a large opaque object placed *beyond* the splat (farther from
+ /// the camera) must not occlude it, even though it's big enough to otherwise span the
+ /// whole frame β exercises the actual depth comparison, not just "no occluder at all."
+ func testGaussianOcclusion_notOccludedByFartherOpaqueMesh() {
+ let eye = simd_float3(0, 3, 7)
+ let target = simd_float3(0, 0, 0)
+
+ let camera = createTestCamera()
+ cameraLookAt(entityId: camera, eye: eye, target: target, up: simd_float3(0, 1, 0))
+
+ // Well beyond the splat on the same eyeβtarget line (near face still farther
+ // from the camera than the target) β must NOT occlude it, regardless of size.
+ let farPoint = eye + 2.0 * (target - eye)
+ addFullFrameOccludingCube(at: farPoint, extent: 8.0)
+
+ let alpha = renderAndReadMaxAlpha()
+ XCTAssertGreaterThan(
+ alpha, 0.05,
+ "β Splat should remain visible when the opaque mesh is farther away, got maxAlpha=\(alpha)"
+ )
+ }
+
// MARK: - Helper Methods
func createTestCamera() -> EntityID {
diff --git a/Tests/UntoldEngineRenderTests/GaussianStreamingTest.swift b/Tests/UntoldEngineRenderTests/GaussianStreamingTest.swift
new file mode 100644
index 000000000..a86693303
--- /dev/null
+++ b/Tests/UntoldEngineRenderTests/GaussianStreamingTest.swift
@@ -0,0 +1,423 @@
+//
+// GaussianStreamingTest.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
+@testable import UntoldEngine
+import XCTest
+
+/// Verifies that Gaussian-splat props (`StreamingComponent.assetKind == .gaussianSplat`)
+/// participate correctly in `GeometryStreamingSystem`'s distance-based load/unload lifecycle,
+/// share `MemoryBudgetManager`'s geometry budget and LRU eviction pool with streamed meshes,
+/// and are kept `markUsed` while visible so they aren't evicted ahead of genuinely stale
+/// entities. Mirrors the CPU-side control-flow style of `GeometryStreamingEvictionTests`.
+@MainActor
+final class GaussianStreamingTest: BaseRenderSetup {
+ override func setUp() async throws {
+ try await super.setUp()
+
+ GeometryStreamingSystem.shared.reset()
+ GeometryStreamingSystem.shared.enabled = true
+ GeometryStreamingSystem.shared.updateInterval = 0.0 // no throttle
+ GeometryStreamingSystem.shared.maxConcurrentLoads = 3
+ GeometryStreamingSystem.shared.enableFrustumGate = false // deterministic candidate detection
+ GeometryStreamingSystem.shared.maxQueryRadius = 500.0
+ GeometryStreamingSystem.shared.visibleEvictionProtectionRadius = 30.0
+
+ MemoryBudgetManager.shared.clear()
+ MemoryBudgetManager.shared.enabled = true
+ MemoryBudgetManager.shared.geometryBudget = 100 * 1024 * 1024 // 100 MB
+ MemoryBudgetManager.shared.textureBudget = 0
+ MemoryBudgetManager.shared.highWaterMark = 0.85
+ MemoryBudgetManager.shared.lowWaterMark = 0.70
+
+ visibleEntityIds = []
+ }
+
+ override func tearDown() async throws {
+ GeometryStreamingSystem.shared.reset()
+ GeometryStreamingSystem.shared.enabled = false
+ GeometryStreamingSystem.shared.updateInterval = 0.1
+ GeometryStreamingSystem.shared.enableFrustumGate = true
+ MemoryBudgetManager.shared.clear()
+ visibleEntityIds = []
+ try await super.tearDown()
+ }
+
+ // MARK: - Helpers
+
+ /// Create a tile-owned, `.unloaded` streaming entity configured for the gaussian path,
+ /// registered in the octree so `update()`'s spatial query can discover it. Mirrors
+ /// `GeometryStreamingEvictionTests.makeUnloadedEntity`.
+ @discardableResult
+ private func makeUnloadedGaussianEntity(
+ distance: Float,
+ streamingRadius: Float = 10.0,
+ unloadRadius: Float = 20.0,
+ filename: String = "test_gaussians",
+ withExtension ext: String = "ply"
+ ) -> EntityID {
+ let tileRoot = createEntity()
+ if let tileComp = scene.assign(to: tileRoot, component: TileComponent.self) {
+ // Keep this stub out of the tile-parse candidate loop entirely β it exists
+ // only to satisfy loadMesh's isTileOwned ownership guard.
+ tileComp.state = .parsed
+ }
+
+ let entityId = createEntity()
+
+ if let local = scene.assign(to: entityId, component: LocalTransformComponent.self) {
+ local.boundingBox = (
+ min: simd_float3(distance - 0.5, -0.5, -0.5),
+ max: simd_float3(distance + 0.5, 0.5, 0.5)
+ )
+ }
+ if let world = scene.assign(to: entityId, component: WorldTransformComponent.self) {
+ world.space = simd_float4x4(1.0)
+ }
+ if let scenegraph = scene.assign(to: entityId, component: ScenegraphComponent.self) {
+ scenegraph.parent = tileRoot
+ scenegraph.level = 1
+ }
+ if let streaming = scene.assign(to: entityId, component: StreamingComponent.self) {
+ streaming.state = .unloaded
+ streaming.assetKind = .gaussianSplat
+ streaming.assetFilename = filename
+ streaming.assetExtension = ext
+ streaming.streamingRadius = streamingRadius
+ streaming.unloadRadius = unloadRadius
+ }
+
+ OctreeSystem.shared.registerEntity(entityId)
+ return entityId
+ }
+
+ /// Create an entity that already looks loaded via the gaussian streaming path β state
+ /// `.loaded`, tracked in the streaming system's loaded set, carrying a `GaussianComponent`,
+ /// and registered with `MemoryBudgetManager` β without going through a real async PLY
+ /// load. Mirrors `GeometryStreamingEvictionTests.makeLoadedEntity`.
+ @discardableResult
+ private func makeLoadedGaussianEntity(
+ distance: Float,
+ memoryBytes: Int,
+ streamingRadius: Float = 10.0,
+ unloadRadius: Float = 20.0
+ ) -> EntityID {
+ let entityId = createEntity()
+
+ if let local = scene.assign(to: entityId, component: LocalTransformComponent.self) {
+ local.boundingBox = (
+ min: simd_float3(distance - 0.5, -0.5, -0.5),
+ max: simd_float3(distance + 0.5, 0.5, 0.5)
+ )
+ }
+ if let world = scene.assign(to: entityId, component: WorldTransformComponent.self) {
+ world.space = simd_float4x4(1.0)
+ }
+ if let streaming = scene.assign(to: entityId, component: StreamingComponent.self) {
+ streaming.state = .loaded
+ streaming.assetKind = .gaussianSplat
+ streaming.assetFilename = "dummy_splat"
+ streaming.assetExtension = "ply"
+ streaming.streamingRadius = streamingRadius
+ streaming.unloadRadius = unloadRadius
+ streaming.lastVisibleFrame = 1
+ }
+ _ = scene.assign(to: entityId, component: GaussianComponent.self)
+
+ GeometryStreamingSystem.shared.registerLoadedEntity(entityId)
+ MemoryBudgetManager.shared.registerMesh(
+ entityId: entityId,
+ meshSizeBytes: memoryBytes,
+ textureSizeBytes: 0
+ )
+
+ return entityId
+ }
+
+ // MARK: - StreamingAssetKind default
+
+ func testStreamingComponentDefaultsToMeshAssetKind() {
+ let component = StreamingComponent()
+ XCTAssertEqual(component.assetKind, .mesh, "β Default assetKind should be .mesh so existing mesh streaming call sites are unaffected")
+ }
+
+ // MARK: - setEntityGaussianStreamable / findTileEntity
+
+ /// End-to-end check of the consolidated API: given a positioned entity and a tile
+ /// whose bounds contain that position, `setEntityGaussianStreamable` should parent it
+ /// under that tile, register it with the octree, and configure it as a real gaussian
+ /// streaming candidate β equivalent to `makeUnloadedGaussianEntity`'s manual assembly,
+ /// but via the one public call callers are meant to use.
+ func testSetEntityGaussianStreamable_attachesToContainingTileAndStreamsCorrectly() async {
+ let tileRoot = createEntity()
+ if let tileComp = scene.assign(to: tileRoot, component: TileComponent.self) {
+ tileComp.state = .parsed
+ }
+ if let tileLocal = scene.get(component: LocalTransformComponent.self, for: tileRoot) {
+ tileLocal.boundingBox = (min: simd_float3(-10, -10, -10), max: simd_float3(10, 10, 10))
+ }
+ OctreeSystem.shared.registerEntity(tileRoot)
+
+ let entity = createEntity()
+ translateTo(entityId: entity, position: .zero)
+
+ setEntityGaussianStreamable(
+ entityId: entity,
+ filename: "test_gaussians",
+ withExtension: "ply",
+ streamingRadius: 10.0,
+ unloadRadius: 20.0,
+ boundingBoxHalfExtent: simd_float3(1, 1, 1)
+ )
+
+ XCTAssertEqual(
+ scene.get(component: ScenegraphComponent.self, for: entity)?.parent, tileRoot,
+ "β Entity should be parented under the tile whose bounds contain its position"
+ )
+
+ let streaming = scene.get(component: StreamingComponent.self, for: entity)
+ XCTAssertEqual(streaming?.assetKind, .gaussianSplat, "β assetKind should be set to .gaussianSplat")
+ XCTAssertEqual(streaming?.assetFilename, "test_gaussians")
+ XCTAssertEqual(streaming?.assetExtension, "ply")
+ XCTAssertEqual(streaming?.streamingRadius, 10.0)
+ XCTAssertEqual(streaming?.unloadRadius, 20.0)
+ XCTAssertEqual(streaming?.state, .unloaded, "β Entity should start unloaded, same as any other streaming candidate")
+
+ // Drive a real update() tick and confirm it streams in exactly like a manually
+ // assembled gaussian streaming entity would.
+ GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.1)
+ await scene.get(component: StreamingComponent.self, for: entity)?.loadTask?.value
+
+ XCTAssertEqual(scene.get(component: StreamingComponent.self, for: entity)?.state, .loaded, "β Entity should load once registered and in range")
+ XCTAssertNotNil(scene.get(component: GaussianComponent.self, for: entity), "β GaussianComponent should be attached after load")
+ }
+
+ /// If no tile contains the entity's position, the entity should be left as a plain,
+ /// non-streaming entity rather than crashing or silently half-configuring it.
+ func testSetEntityGaussianStreamable_noContainingTileLeavesEntityNonStreaming() {
+ let entity = createEntity()
+ translateTo(entityId: entity, position: simd_float3(1000, 1000, 1000)) // no tile out here
+
+ setEntityGaussianStreamable(
+ entityId: entity,
+ filename: "test_gaussians",
+ withExtension: "ply",
+ boundingBoxHalfExtent: simd_float3(1, 1, 1)
+ )
+
+ XCTAssertNil(
+ scene.get(component: StreamingComponent.self, for: entity),
+ "β Entity should not get a StreamingComponent when no containing tile is found"
+ )
+ }
+
+ /// `findTileEntity` should return the tile whose bounds contain the query point, and
+ /// `nil` when nothing does.
+ func testFindTileEntity_returnsContainingTileOrNil() {
+ let tileRoot = createEntity()
+ _ = scene.assign(to: tileRoot, component: TileComponent.self)
+ if let tileLocal = scene.get(component: LocalTransformComponent.self, for: tileRoot) {
+ tileLocal.boundingBox = (min: simd_float3(-5, -5, -5), max: simd_float3(5, 5, 5))
+ }
+ OctreeSystem.shared.registerEntity(tileRoot)
+
+ XCTAssertEqual(findTileEntity(containing: .zero), tileRoot, "β Should find the tile containing the origin")
+ XCTAssertNil(findTileEntity(containing: simd_float3(1000, 1000, 1000)), "β Should return nil when no tile contains the point")
+ }
+
+ // MARK: - Load lifecycle
+
+ func testGaussianEntityOutsideStreamingRadiusStaysUnloaded() {
+ let entity = makeUnloadedGaussianEntity(distance: 500.0, streamingRadius: 10.0, unloadRadius: 20.0)
+
+ GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.1)
+
+ let streaming = scene.get(component: StreamingComponent.self, for: entity)
+ XCTAssertEqual(streaming?.state, .unloaded, "β Entity far outside its streaming radius must stay unloaded")
+ XCTAssertNil(scene.get(component: GaussianComponent.self, for: entity), "β No GaussianComponent should be attached before the entity streams in")
+ }
+
+ func testGaussianEntityLoadsWhenCameraApproaches() async {
+ let entity = makeUnloadedGaussianEntity(distance: 0.0, streamingRadius: 10.0, unloadRadius: 20.0)
+
+ GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.1)
+
+ // loadMesh dispatched an async Task onto StreamingComponent.loadTask β await it
+ // directly rather than polling, since Task exposes `.value`.
+ let task = scene.get(component: StreamingComponent.self, for: entity)?.loadTask
+ await task?.value
+
+ let streaming = scene.get(component: StreamingComponent.self, for: entity)
+ XCTAssertEqual(streaming?.state, .loaded, "β Entity within streaming radius should finish loading")
+
+ let gaussian = scene.get(component: GaussianComponent.self, for: entity)
+ XCTAssertNotNil(gaussian, "β GaussianComponent should be attached after a successful streamed load")
+ XCTAssertGreaterThan(gaussian?.splatCount ?? 0, 0, "β Loaded splat count should be nonzero")
+
+ XCTAssertTrue(
+ MemoryBudgetManager.shared.isTracked(entityId: entity),
+ "β Streamed gaussian entity should be registered with MemoryBudgetManager, same as a streamed mesh"
+ )
+ }
+
+ func testGaussianEntityUnloadsWhenCameraLeaves() {
+ let entity = makeLoadedGaussianEntity(distance: 0.0, memoryBytes: 1024, streamingRadius: 10.0, unloadRadius: 20.0)
+ visibleEntityIds = []
+
+ let farCameraPosition = simd_float3(1000, 0, 0)
+ for _ in 0 ..< 3 {
+ GeometryStreamingSystem.shared.update(cameraPosition: farCameraPosition, deltaTime: 0.1)
+ }
+
+ let streaming = scene.get(component: StreamingComponent.self, for: entity)
+ XCTAssertEqual(streaming?.state, .unloaded, "β Entity beyond unloadRadius should unload")
+ XCTAssertNil(scene.get(component: GaussianComponent.self, for: entity), "β GaussianComponent should be removed on unload")
+ XCTAssertFalse(
+ MemoryBudgetManager.shared.isTracked(entityId: entity),
+ "β MemoryBudgetManager entry should be removed on unload so budget/eviction accounting stays accurate"
+ )
+ }
+
+ func testGaussianEntityReloadsAfterCameraReturns() async {
+ let entity = makeUnloadedGaussianEntity(distance: 0.0, streamingRadius: 10.0, unloadRadius: 20.0)
+
+ // First approach: load.
+ GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.1)
+ await scene.get(component: StreamingComponent.self, for: entity)?.loadTask?.value
+
+ XCTAssertEqual(scene.get(component: StreamingComponent.self, for: entity)?.state, .loaded, "Pre-condition: first load should succeed")
+ XCTAssertNotNil(scene.get(component: GaussianComponent.self, for: entity), "Pre-condition: GaussianComponent should be attached after first load")
+
+ // Retreat: unload.
+ visibleEntityIds = []
+ let farCameraPosition = simd_float3(1000, 0, 0)
+ for _ in 0 ..< 3 {
+ GeometryStreamingSystem.shared.update(cameraPosition: farCameraPosition, deltaTime: 0.1)
+ }
+ XCTAssertEqual(scene.get(component: StreamingComponent.self, for: entity)?.state, .unloaded, "Pre-condition: entity should unload when far")
+ XCTAssertNil(scene.get(component: GaussianComponent.self, for: entity), "Pre-condition: GaussianComponent should be removed on unload")
+
+ // Re-approach: should load again.
+ GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.1)
+ let secondTask = scene.get(component: StreamingComponent.self, for: entity)?.loadTask
+ await secondTask?.value
+
+ let streaming = scene.get(component: StreamingComponent.self, for: entity)
+ XCTAssertEqual(streaming?.state, .loaded, "β Entity should reload after camera re-enters streaming radius")
+
+ let gaussian = scene.get(component: GaussianComponent.self, for: entity)
+ XCTAssertNotNil(gaussian, "β GaussianComponent should be re-attached after reload")
+ XCTAssertGreaterThan(gaussian?.splatCount ?? 0, 0, "β Reloaded splat count should be nonzero")
+ }
+
+ // MARK: - Tile ownership / tile unload interaction
+
+ /// Regression test: `GeometryStreamingSystem.unloadTile` tears down every scenegraph
+ /// descendant of a parsed tile via `collectTileDescendants` β necessary for the tile's
+ /// own regenerable mesh content, but a gaussian-splat prop parented under the tile
+ /// (the pattern `GameScene.addPoolTableSplat` uses to satisfy `isTileOwned`) must
+ /// survive that sweep rather than being destroyed, since it is never regenerated by
+ /// the tile's own parse pipeline. Without the assetKind == .gaussianSplat guard in
+ /// collectTileDescendants, this destroys the splat entity outright the first time the
+ /// surrounding tile unloads β after that, nothing recreates it, so it never streams
+ /// back in no matter how many times the camera re-approaches.
+ func testGaussianSplatSurvivesParentTileUnload() {
+ let tileEntity = createEntity()
+ guard let tileComp = scene.assign(to: tileEntity, component: TileComponent.self) else {
+ XCTFail("Failed to assign TileComponent")
+ return
+ }
+ tileComp.state = .parsed
+ tileComp.tileId = "test_tile"
+
+ // A regular mesh-kind child, representing tile-owned regenerable content β
+ // this one SHOULD still be destroyed on tile unload. Uses setParent (not manual
+ // ScenegraphComponent.parent assignment) since getEntityChildren reads the
+ // parent's `children` array, which only setParent populates.
+ let meshChild = createEntity()
+ setParent(childId: meshChild, parentId: tileEntity)
+ if let streaming = scene.assign(to: meshChild, component: StreamingComponent.self) {
+ streaming.assetKind = .mesh
+ streaming.state = .loaded
+ }
+
+ // A gaussian-splat prop attached the same way GameScene.addPoolTableSplat does β
+ // this one must survive.
+ let splatChild = createEntity()
+ setParent(childId: splatChild, parentId: tileEntity)
+ if let streaming = scene.assign(to: splatChild, component: StreamingComponent.self) {
+ streaming.assetKind = .gaussianSplat
+ streaming.state = .loaded
+ }
+ _ = scene.assign(to: splatChild, component: GaussianComponent.self)
+
+ GeometryStreamingSystem.shared.unloadTile(entityId: tileEntity)
+
+ XCTAssertFalse(scene.exists(meshChild), "β Regular mesh-kind tile child should still be destroyed on tile unload")
+ XCTAssertTrue(scene.exists(splatChild), "β Gaussian-splat child must survive tile unload β it is not tile-owned regenerable content")
+ XCTAssertNotNil(scene.get(component: GaussianComponent.self, for: splatChild), "β Splat's GaussianComponent should be untouched by tile unload")
+ XCTAssertEqual(
+ scene.get(component: StreamingComponent.self, for: splatChild)?.state, .loaded,
+ "β Splat's own streaming state should be unaffected by tile unload β it's governed independently by its own radii"
+ )
+ }
+
+ // MARK: - Shared eviction pool
+
+ func testFarGaussianEntityIsEvictedUnderMemoryPressureAheadOfCloseOne() {
+ let closeEntity = makeLoadedGaussianEntity(distance: 5.0, memoryBytes: 50 * 1024 * 1024, streamingRadius: 500, unloadRadius: 500)
+ let farEntity = makeLoadedGaussianEntity(distance: 200.0, memoryBytes: 51 * 1024 * 1024, streamingRadius: 500, unloadRadius: 500)
+
+ // Both visible; total 101 MB > 85 MB high-water mark on a 100 MB budget.
+ visibleEntityIds = [closeEntity, farEntity]
+
+ XCTAssertTrue(MemoryBudgetManager.shared.shouldEvict(), "Pre-condition: budget should be over threshold")
+
+ GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 1.0)
+
+ let closeState = scene.get(component: StreamingComponent.self, for: closeEntity)?.state
+ let farState = scene.get(component: StreamingComponent.self, for: farEntity)?.state
+
+ XCTAssertEqual(closeState, .loaded, "β Close visible splat (within visibleEvictionProtectionRadius) must not be evicted")
+ XCTAssertEqual(farState, .unloaded, "β Far visible splat must be evicted through the same evictLRU pool meshes use")
+ XCTAssertNil(scene.get(component: GaussianComponent.self, for: farEntity), "β Evicted splat's GaussianComponent should be torn down")
+ }
+
+ // MARK: - markUsed correctness
+
+ /// `executeGaussianFrustumCulling` must call `MemoryBudgetManager.markUsed` for entities
+ /// that survive its per-splat frustum test, since gaussian-only entities are structurally
+ /// excluded from the RenderComponent-keyed query that normally feeds `markUsed`
+ /// (see CullingSystem.swift). Without this, a loaded, on-screen splat would look
+ /// permanently stale to `evictLRU`.
+ func testVisibleGaussianEntityIsMarkedUsedByFrustumCulling() {
+ let entity = createEntity()
+ setEntityGaussian(entityId: entity, filename: "test_gaussians", withExtension: "ply")
+ XCTAssertNotNil(scene.get(component: GaussianComponent.self, for: entity), "Pre-condition: gaussian asset should load synchronously")
+
+ // setEntityGaussian already registers with MemoryBudgetManager at the current frame;
+ // capture that baseline so we can detect markUsed advancing it once rendering runs.
+ let beforeFrame = MemoryBudgetManager.shared.getLastUsedFrame(for: entity)
+
+ let expectation = XCTestExpectation(description: "Gaussian render pass completes")
+ renderer.draw(in: renderer.metalView)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
+ expectation.fulfill()
+ }
+ wait(for: [expectation], timeout: TimeInterval(timeoutFactor))
+
+ let afterFrame = MemoryBudgetManager.shared.getLastUsedFrame(for: entity)
+ XCTAssertNotNil(afterFrame, "β Entity should still be tracked by MemoryBudgetManager after rendering")
+ XCTAssertNotEqual(
+ afterFrame, beforeFrame,
+ "β markUsed should have advanced lastUsedFrame for a splat entity that survived frustum culling this frame"
+ )
+ }
+}
diff --git a/Tests/UntoldEngineTests/NativeFormatTests.swift b/Tests/UntoldEngineTests/NativeFormatTests.swift
index 822167909..b33e70738 100644
--- a/Tests/UntoldEngineTests/NativeFormatTests.swift
+++ b/Tests/UntoldEngineTests/NativeFormatTests.swift
@@ -264,6 +264,22 @@ final class NativeFormatTests: XCTestCase {
}
}
+ func testUnknownCoreChunkTypeIsIgnored() throws {
+ // A core-range chunk type this runtime does not know (e.g. one added by a
+ // newer format revision) must not prevent the rest of the asset from loading.
+ let futureChunkType = UntoldChunkType(rawValue: 22)
+ let fixture = makeTinyFixture(pluginChunks: [
+ (futureChunkType, Data([0x01, 0x02, 0x03, 0x04]), 0),
+ ])
+
+ let decoded = try UntoldReader().readAsset(from: fixture.fileData)
+
+ XCTAssertTrue(decoded.chunks.contains(where: { $0.chunkType == futureChunkType }))
+ XCTAssertTrue(decoded.pluginChunks.isEmpty)
+ XCTAssertEqual(decoded.meshes.count, 1)
+ XCTAssertEqual(decoded.entities.count, 1)
+ }
+
func testColorManagementRoundtripsThroughRuntimeLoader() throws {
// Reuses the tiny fixture's existing texture (index 0, "albedo.ktx2")
// as the LUT reference, to exercise the same index -> URL resolution
diff --git a/Tests/UntoldEngineTests/PhysicsCoordinatorTests.swift b/Tests/UntoldEngineTests/PhysicsCoordinatorTests.swift
new file mode 100644
index 000000000..1e5610347
--- /dev/null
+++ b/Tests/UntoldEngineTests/PhysicsCoordinatorTests.swift
@@ -0,0 +1,377 @@
+//
+// PhysicsCoordinatorTests.swift
+// UntoldEngineTests
+//
+// 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
+@testable import UntoldEngine
+import XCTest
+
+private final class RecordingPhysicsBackend: PhysicsBackend, @unchecked Sendable {
+ let id: String
+ let capabilities: PhysicsCapabilities = []
+
+ private(set) var configurations: [PhysicsWorldConfiguration] = []
+ private(set) var addedBodies: [(entity: EntityID, descriptor: PhysicsBodyDescriptor)] = []
+ private(set) var removedBodies: [EntityID] = []
+ private(set) var stepDeltas: [Float] = []
+ private(set) var kinematicWrites: [[(entity: EntityID, transform: PhysicsBodyTransform)]] = []
+ private(set) var drainCount = 0
+ private(set) var callOrder: [String] = []
+
+ /// Transforms handed back on the next `readActiveTransforms`.
+ var pendingReadback: [(entity: EntityID, transform: PhysicsBodyTransform)] = []
+ var droppedEventsToReport = 0
+
+ init(id: String = "com.example.recordingphysics") {
+ self.id = id
+ }
+
+ func configure(_ config: PhysicsWorldConfiguration) {
+ configurations.append(config)
+ callOrder.append("configure")
+ }
+
+ func didAddBody(entity: EntityID, descriptor: PhysicsBodyDescriptor) {
+ addedBodies.append((entity, descriptor))
+ callOrder.append("addBody")
+ }
+
+ func didRemoveBody(entity: EntityID) {
+ removedBodies.append(entity)
+ callOrder.append("removeBody")
+ }
+
+ func step(deltaTime: Float) {
+ stepDeltas.append(deltaTime)
+ callOrder.append("step")
+ }
+
+ func drainEvents(into sink: any PhysicsEventSink) {
+ drainCount += 1
+ callOrder.append("drain")
+ if droppedEventsToReport > 0 {
+ sink.reportDroppedEvents(count: droppedEventsToReport)
+ }
+ }
+
+ func writeKinematicTargets(_ batch: PhysicsBodyWriteBatch) {
+ var entries: [(EntityID, PhysicsBodyTransform)] = []
+ for index in 0 ..< batch.entities.count {
+ entries.append((batch.entities[index], batch.transforms[index]))
+ }
+ kinematicWrites.append(entries)
+ callOrder.append("writeKinematic")
+ }
+
+ func readActiveTransforms(into batch: PhysicsTransformReadBatch) -> Int {
+ callOrder.append("read")
+ let count = min(pendingReadback.count, batch.capacity)
+ for index in 0 ..< count {
+ batch.entities[index] = pendingReadback[index].entity
+ batch.transforms[index] = pendingReadback[index].transform
+ }
+ return count
+ }
+}
+
+private struct RecordingBackendPlugin: PhysicsBackendPlugin {
+ let manifest: PhysicsBackendPluginManifest
+ let backend: RecordingPhysicsBackend
+
+ init(pluginID: String = "com.example.recordingphysics") {
+ manifest = PhysicsBackendPluginManifest(
+ id: pluginID,
+ displayName: "Recording Physics",
+ version: PhysicsBackendVersion(major: 1, minor: 0, patch: 0),
+ requiredAPIVersion: .current
+ )
+ backend = RecordingPhysicsBackend(id: pluginID)
+ }
+
+ func makeBackend() -> any PhysicsBackend {
+ backend
+ }
+}
+
+@MainActor
+final class PhysicsCoordinatorTests: XCTestCase {
+ override func setUp() async throws {
+ resetEngineTestState()
+ }
+
+ override func tearDown() {
+ PhysicsBackendRegistry.shared.resetForTesting()
+ super.tearDown()
+ }
+
+ private func makeContext() -> EngineExtensionUpdateContext {
+ EngineExtensionUpdateContext(
+ viewport: SIMD2(640, 480),
+ immersionStyle: .none,
+ frameIndex: 0,
+ currentEye: 0,
+ isPrimaryEye: true
+ )
+ }
+
+ private func tick(_ deltaTime: Float = 1.0 / 60.0) {
+ PhysicsCoordinator.shared.fixedUpdate(deltaTime: deltaTime, context: makeContext())
+ }
+
+ @discardableResult
+ private func installRecordingPlugin() -> RecordingPhysicsBackend {
+ let plugin = RecordingBackendPlugin()
+ XCTAssertEqual(PhysicsBackendRegistry.shared.install(plugin), .installed)
+ return plugin.backend
+ }
+
+ private func makeBodyEntity(
+ motionType: PhysicsMotionType = .dynamic,
+ position: simd_float3 = .zero
+ ) -> EntityID {
+ let entityId = createEntity()
+ registerComponent(entityId: entityId, componentType: RigidBodyComponent.self)
+ registerComponent(entityId: entityId, componentType: ColliderComponent.self)
+ scene.get(component: RigidBodyComponent.self, for: entityId)?.motionType = motionType
+ scene.get(component: LocalTransformComponent.self, for: entityId)?.position = position
+ return entityId
+ }
+
+ // MARK: - Scheduling
+
+ func testInstallSchedulesCoordinatorAndUninstallRemovesIt() {
+ XCTAssertFalse(
+ EngineExtensionRegistry.shared.registeredIDs().contains(PhysicsCoordinator.shared.id)
+ )
+
+ installRecordingPlugin()
+ XCTAssertTrue(
+ EngineExtensionRegistry.shared.registeredIDs().contains(PhysicsCoordinator.shared.id)
+ )
+
+ XCTAssertTrue(PhysicsBackendRegistry.shared.uninstall(id: "com.example.recordingphysics"))
+ XCTAssertFalse(
+ EngineExtensionRegistry.shared.registeredIDs().contains(PhysicsCoordinator.shared.id)
+ )
+ }
+
+ func testInstallConfiguresBackendWithCurrentWorldConfiguration() {
+ var config = PhysicsWorldConfiguration()
+ config.gravity = simd_float3(0.0, -3.7, 0.0)
+ PhysicsCoordinator.shared.setWorldConfiguration(config)
+
+ let backend = installRecordingPlugin()
+ XCTAssertEqual(backend.configurations.count, 1)
+ XCTAssertEqual(backend.configurations.first?.gravity, simd_float3(0.0, -3.7, 0.0))
+ }
+
+ func testSetWorldConfigurationReconfiguresActiveBackend() {
+ let backend = installRecordingPlugin()
+ XCTAssertEqual(backend.configurations.count, 1)
+
+ var config = PhysicsWorldConfiguration()
+ config.gravity = simd_float3(0.0, -1.6, 0.0)
+ PhysicsCoordinator.shared.setWorldConfiguration(config)
+
+ XCTAssertEqual(backend.configurations.count, 2)
+ XCTAssertEqual(backend.configurations.last?.gravity, simd_float3(0.0, -1.6, 0.0))
+ }
+
+ func testFixedUpdateWithoutBackendIsInert() {
+ _ = makeBodyEntity()
+ tick()
+ // Nothing to assert beyond "no trap": no backend, no coordinator work.
+ XCTAssertFalse(PhysicsBackendRegistry.shared.isLockedForRuntime)
+ }
+
+ // MARK: - Runtime lock
+
+ func testFirstSimulatedSubstepLocksRegistry() {
+ installRecordingPlugin()
+ XCTAssertFalse(PhysicsBackendRegistry.shared.isLockedForRuntime)
+
+ tick()
+ XCTAssertTrue(PhysicsBackendRegistry.shared.isLockedForRuntime)
+
+ XCTAssertFalse(PhysicsBackendRegistry.shared.uninstall(id: "com.example.recordingphysics"))
+ if case .rejected = PhysicsBackendRegistry.shared.install(RecordingBackendPlugin()) {
+ } else {
+ XCTFail("Install after the first simulated substep should be rejected")
+ }
+ }
+
+ // MARK: - Body lifecycle
+
+ func testBodyEntityIsAddedWithDescriptorSnapshot() throws {
+ let backend = installRecordingPlugin()
+ let entityId = makeBodyEntity(position: simd_float3(1.0, 2.0, 3.0))
+
+ let rigidBody = scene.get(component: RigidBodyComponent.self, for: entityId)
+ rigidBody?.mass = 4.0
+ rigidBody?.layer = 2
+ rigidBody?.initialLinearVelocity = simd_float3(0.0, 0.0, 5.0)
+ let collider = scene.get(component: ColliderComponent.self, for: entityId)
+ collider?.shape = .box(halfExtents: simd_float3(0.5, 0.5, 0.5))
+ collider?.isTrigger = true
+
+ tick()
+
+ XCTAssertEqual(backend.addedBodies.count, 1)
+ let added = try XCTUnwrap(backend.addedBodies.first)
+ XCTAssertEqual(added.entity, entityId)
+ XCTAssertEqual(added.descriptor.motionType, .dynamic)
+ XCTAssertEqual(added.descriptor.mass, 4.0)
+ XCTAssertEqual(added.descriptor.layer, 2)
+ XCTAssertEqual(added.descriptor.position, simd_float3(1.0, 2.0, 3.0))
+ XCTAssertEqual(added.descriptor.linearVelocity, simd_float3(0.0, 0.0, 5.0))
+ XCTAssertEqual(added.descriptor.collider.shape, .box(halfExtents: simd_float3(0.5, 0.5, 0.5)))
+ XCTAssertTrue(added.descriptor.collider.isTrigger)
+
+ // Known bodies are not re-added on later substeps.
+ tick()
+ XCTAssertEqual(backend.addedBodies.count, 1)
+ }
+
+ func testEntityWithoutColliderIsNotABody() {
+ let backend = installRecordingPlugin()
+ let entityId = createEntity()
+ registerComponent(entityId: entityId, componentType: RigidBodyComponent.self)
+
+ tick()
+ XCTAssertTrue(backend.addedBodies.isEmpty)
+ }
+
+ func testDestroyedEntityIsRemovedFromBackend() {
+ let backend = installRecordingPlugin()
+ let entityId = makeBodyEntity()
+
+ tick()
+ XCTAssertEqual(backend.addedBodies.count, 1)
+
+ destroyEntity(entityId: entityId)
+ scene.finalizePendingDestroys()
+
+ tick()
+ XCTAssertEqual(backend.removedBodies, [entityId])
+ }
+
+ func testRemovingPhysicsComponentsRemovesBody() {
+ let backend = installRecordingPlugin()
+ let entityId = makeBodyEntity()
+
+ tick()
+ XCTAssertEqual(backend.addedBodies.count, 1)
+
+ scene.remove(component: RigidBodyComponent.self, from: entityId)
+
+ tick()
+ XCTAssertEqual(backend.removedBodies, [entityId])
+ }
+
+ // MARK: - Transform transfer
+
+ func testDynamicTransformReadbackUpdatesLocalTransform() throws {
+ let backend = installRecordingPlugin()
+ let entityId = makeBodyEntity(position: simd_float3(0.0, 10.0, 0.0))
+
+ let newOrientation = simd_quatf(angle: .pi / 2, axis: simd_float3(0.0, 1.0, 0.0))
+ backend.pendingReadback = [(
+ entityId,
+ PhysicsBodyTransform(position: simd_float3(0.0, 9.5, 0.0), orientation: newOrientation)
+ )]
+
+ anyTransformDirty = false
+ tick()
+
+ let transform = try XCTUnwrap(scene.get(component: LocalTransformComponent.self, for: entityId))
+ XCTAssertEqual(transform.position, simd_float3(0.0, 9.5, 0.0))
+ XCTAssertEqual(transform.rotation.vector, newOrientation.vector)
+ XCTAssertTrue(transform.transformDirty)
+ XCTAssertTrue(anyTransformDirty)
+ }
+
+ func testReadbackIgnoresKinematicAndUnknownEntities() throws {
+ let backend = installRecordingPlugin()
+ let kinematicId = makeBodyEntity(motionType: .kinematic, position: simd_float3(1.0, 0.0, 0.0))
+ let strangerId: EntityID = 987_654
+
+ backend.pendingReadback = [
+ (kinematicId, PhysicsBodyTransform(
+ position: simd_float3(5.0, 5.0, 5.0),
+ orientation: simd_quatf(ix: 0, iy: 0, iz: 0, r: 1)
+ )),
+ (strangerId, PhysicsBodyTransform(
+ position: simd_float3(6.0, 6.0, 6.0),
+ orientation: simd_quatf(ix: 0, iy: 0, iz: 0, r: 1)
+ )),
+ ]
+
+ tick()
+
+ let transform = try XCTUnwrap(scene.get(component: LocalTransformComponent.self, for: kinematicId))
+ XCTAssertEqual(transform.position, simd_float3(1.0, 0.0, 0.0))
+ }
+
+ func testKinematicTargetsAreBatchWritten() throws {
+ let backend = installRecordingPlugin()
+ let kinematicId = makeBodyEntity(motionType: .kinematic, position: simd_float3(2.0, 0.0, 2.0))
+ _ = makeBodyEntity(motionType: .dynamic)
+
+ tick()
+
+ XCTAssertEqual(backend.kinematicWrites.count, 1)
+ let write = try XCTUnwrap(backend.kinematicWrites.first)
+ XCTAssertEqual(write.count, 1)
+ XCTAssertEqual(write.first?.entity, kinematicId)
+ XCTAssertEqual(write.first?.transform.position, simd_float3(2.0, 0.0, 2.0))
+ }
+
+ // MARK: - Step orchestration
+
+ func testSubstepCallOrderAndEventDrain() {
+ let backend = installRecordingPlugin()
+ _ = makeBodyEntity(motionType: .kinematic)
+ backend.droppedEventsToReport = 3
+
+ tick(1.0 / 60.0)
+
+ XCTAssertEqual(backend.callOrder, ["configure", "addBody", "writeKinematic", "step", "read", "drain"])
+ XCTAssertEqual(backend.stepDeltas, [1.0 / 60.0])
+ XCTAssertEqual(backend.drainCount, 1)
+ XCTAssertEqual(PhysicsCoordinator.shared.eventSink.droppedEventCount, 3)
+ }
+
+ // MARK: - Built-in integrator gravity seam
+
+ func testBuiltInIntegratorReadsConfigurableGravity() {
+ let entityId = createEntity()
+ registerComponent(entityId: entityId, componentType: PhysicsComponents.self)
+ registerComponent(entityId: entityId, componentType: KineticComponent.self)
+ setMass(entityId: entityId, mass: 1.0)
+ setGravityScale(entityId: entityId, gravityScale: 1.0)
+
+ var config = PhysicsWorldConfiguration()
+ config.gravity = .zero
+ PhysicsCoordinator.shared.setWorldConfiguration(config)
+
+ updatePhysicsSystem(deltaTime: 1.0 / 60.0)
+ XCTAssertEqual(getVelocity(entityId: entityId), .zero)
+
+ PhysicsCoordinator.shared.setWorldConfiguration(PhysicsWorldConfiguration())
+ updatePhysicsSystem(deltaTime: 1.0 / 60.0)
+ XCTAssertLessThan(getVelocity(entityId: entityId).y, 0.0)
+ }
+
+ func testDefaultWorldConfigurationMatchesLegacyGravity() {
+ XCTAssertEqual(
+ PhysicsCoordinator.shared.worldConfiguration().gravity,
+ simd_float3(0.0, -9.8, 0.0)
+ )
+ }
+}
diff --git a/Tests/UntoldEngineTests/UntoldViewOptionsTests.swift b/Tests/UntoldEngineTests/UntoldViewOptionsTests.swift
new file mode 100644
index 000000000..5170ffd3b
--- /dev/null
+++ b/Tests/UntoldEngineTests/UntoldViewOptionsTests.swift
@@ -0,0 +1,108 @@
+//
+// UntoldViewOptionsTests.swift
+// UntoldEngineTests
+//
+// 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 MetalKit
+import simd
+@testable import UntoldEngine
+import XCTest
+
+/// Covers the runtime view options: struct semantics, the UntoldView
+/// modifiers, and the coordinator-side diffing that applies changes to a
+/// live MTKView. None of this needs a Metal device.
+@MainActor
+final class UntoldViewOptionsTests: XCTestCase {
+ // MARK: - Struct semantics
+
+ func testDefaults() {
+ let options = UntoldViewOptions.default
+
+ XCTAssertEqual(options.preferredFramesPerSecond, 60)
+ XCTAssertFalse(options.isPaused)
+ XCTAssertEqual(options.clearColor, simd_float4(0, 0, 0, 1))
+ }
+
+ func testEquality() {
+ XCTAssertEqual(UntoldViewOptions(), UntoldViewOptions.default)
+ XCTAssertNotEqual(UntoldViewOptions(preferredFramesPerSecond: 30), UntoldViewOptions.default)
+ XCTAssertNotEqual(UntoldViewOptions(isPaused: true), UntoldViewOptions.default)
+ XCTAssertNotEqual(UntoldViewOptions(clearColor: simd_float4(1, 0, 0, 1)), UntoldViewOptions.default)
+ }
+
+ // MARK: - UntoldView modifiers
+
+ func testUntoldViewModifiersUpdateOptions() {
+ let view = UntoldView(renderer: nil) {}
+
+ XCTAssertEqual(view.options, .default)
+ XCTAssertEqual(view.preferredFramesPerSecond(30).options.preferredFramesPerSecond, 30)
+ XCTAssertTrue(view.paused(true).options.isPaused)
+
+ let replaced = view.options(UntoldViewOptions(preferredFramesPerSecond: 120, isPaused: true))
+ XCTAssertEqual(replaced.options.preferredFramesPerSecond, 120)
+ XCTAssertTrue(replaced.options.isPaused)
+ }
+
+ func testUntoldViewModifiersDoNotMutateOriginal() {
+ let view = UntoldView(renderer: nil) {}
+ _ = view.preferredFramesPerSecond(30)
+
+ XCTAssertEqual(view.options.preferredFramesPerSecond, 60)
+ }
+
+ // MARK: - Coordinator apply diffing
+
+ func testFirstApplySetsEveryProperty() {
+ let coordinator = SceneView.Coordinator()
+ let view = MTKView()
+ let options = UntoldViewOptions(
+ preferredFramesPerSecond: 30,
+ isPaused: true,
+ clearColor: simd_float4(0.5, 0.25, 0, 1)
+ )
+
+ coordinator.apply(options, to: view)
+
+ XCTAssertEqual(view.preferredFramesPerSecond, 30)
+ XCTAssertTrue(view.isPaused)
+ XCTAssertEqual(view.clearColor.red, 0.5, accuracy: 1e-6)
+ XCTAssertEqual(view.clearColor.green, 0.25, accuracy: 1e-6)
+ XCTAssertEqual(coordinator.appliedOptions, options)
+ }
+
+ func testApplyOnlyTouchesChangedProperties() {
+ let coordinator = SceneView.Coordinator()
+ let view = MTKView()
+ coordinator.apply(.default, to: view)
+
+ // Perturb the view behind the coordinator's back: if apply diffs
+ // correctly, an FPS-only change must leave these values alone.
+ view.isPaused = true
+ view.clearColor = MTLClearColor(red: 0.9, green: 0, blue: 0, alpha: 1)
+
+ var options = UntoldViewOptions.default
+ options.preferredFramesPerSecond = 120
+ coordinator.apply(options, to: view)
+
+ XCTAssertEqual(view.preferredFramesPerSecond, 120)
+ XCTAssertTrue(view.isPaused, "Unchanged isPaused option must not be re-applied")
+ XCTAssertEqual(view.clearColor.red, 0.9, accuracy: 1e-6, "Unchanged clearColor option must not be re-applied")
+ }
+
+ func testApplyWithEqualOptionsIsANoOp() {
+ let coordinator = SceneView.Coordinator()
+ let view = MTKView()
+ coordinator.apply(.default, to: view)
+
+ view.preferredFramesPerSecond = 15
+ coordinator.apply(.default, to: view)
+
+ XCTAssertEqual(view.preferredFramesPerSecond, 15, "Equal options must not touch the view at all")
+ }
+}
diff --git a/docs/API/GettingStarted.md b/docs/API/GettingStarted.md
index 4e9a00a1f..576451d45 100644
--- a/docs/API/GettingStarted.md
+++ b/docs/API/GettingStarted.md
@@ -23,7 +23,7 @@ Clone the repository and launch the demo:
```bash
git clone https://github.com/untoldengine/UntoldEngine.git
cd UntoldEngine
-git checkout v0.14.3
+git checkout v0.15.0
swift run ShowcaseDemo
```
diff --git a/docs/API/UsingGaussianSystem.md b/docs/API/UsingGaussianSystem.md
index 22316bff2..29f14ea96 100644
--- a/docs/API/UsingGaussianSystem.md
+++ b/docs/API/UsingGaussianSystem.md
@@ -11,6 +11,7 @@ Start by creating an entity that represents your Gaussian Splat object.
```swift
let myEntity = createEntity()
```
+
---
### Step 2: Link a Gaussian Splat to the Entity
@@ -39,3 +40,85 @@ Once everything is set up:
2. Your Gaussian Splat model will appear in the game window.
3. If the model is not visible or appears incorrect, revisit the file path and format to ensure everything is loaded correctly.
+---
+
+## Streaming Gaussian Splats in Large Scenes
+
+`setEntityGaussian` loads a splat immediately and keeps it resident for the lifetime of the
+entity β fine for a small number of always-visible splats, but not what you want for props
+scattered across a large tile-streamed scene (chairs, tables, decor inside a streamed
+building). Loading every one of those up front defeats the point of streaming, and the
+engine has no way to unload them again on its own.
+
+For that case, use `setEntityGaussianStreamable` instead. It registers the entity with
+`GeometryStreamingSystem`, which loads and unloads it automatically based on camera
+distance β the same way it already handles the surrounding streamed tile geometry.
+
+### Prerequisites
+
+This only makes sense in a scene that is already using tile-based streaming β i.e. one
+loaded with `setEntityStreamScene` (see [Using the Geometry Streaming System](UsingGeometryStreamingSystem.md)).
+`setEntityGaussianStreamable` attaches the splat to whichever tile stub's bounds contain
+the entity's position, so it needs those tile stubs to already exist. Call it **after**
+`setEntityStreamScene`'s completion handler has fired β tile stubs are guaranteed to be
+registered by then.
+
+### Step 1: Create and position the entity
+
+Position and orient the entity *before* registering it for streaming β the position at the
+time you call `setEntityGaussianStreamable` is what determines which tile it gets attached
+to.
+
+```swift
+let streamSplat = createEntity()
+translateTo(entityId: streamSplat, position: simd_float3(2.0, 0.0, -4.0))
+rotateTo(entityId: streamSplat, angle: 180.0, axis: simd_float3(1.0, 0.0, 0.0))
+```
+
+### Step 2: Register it for streaming
+
+```swift
+setEntityGaussianStreamable(
+ entityId: streamSplat,
+ filename: "chair",
+ withExtension: "ply",
+ streamingRadius: 30.0,
+ unloadRadius: 45.0,
+ boundingBoxHalfExtent: simd_float3(0.5, 0.8, 0.5)
+)
+```
+
+Parameters:
+
+- `entityId`: The entity created and positioned in Step 1.
+- `filename` / `withExtension`: Same as `setEntityGaussian` β the `.ply` file to stream in
+ once the camera is in range.
+- `streamingRadius`: Distance from the camera at which the splat starts loading.
+- `unloadRadius`: Distance beyond which the splat unloads. Should be larger than
+ `streamingRadius` to avoid load/unload thrashing at the boundary.
+- `boundingBoxHalfExtent`: A local-space half-extent for the entity, roughly matching the
+ splat's real-world size. **This has no default and must be supplied.** Without a real
+ bounding box, the streaming system's frustum gate collapses to a zero-extent point at
+ the entity's exact position β the splat may load once but then fail to reliably
+ re-stream in after the camera moves away and back, because re-entry depends on the
+ camera looking at that exact point rather than anywhere near the prop.
+- `priority`: Optional. Higher-priority entities load first when multiple candidates are
+ in range at once. Defaults to `0`.
+
+> Note: If no tile is found containing the entity's position, `setEntityGaussianStreamable`
+> logs a warning and leaves the entity as a plain, non-streaming entity (no `StreamingComponent`
+> is attached) β it will not crash, but it also will not load. Double-check the position
+> against the streamed scene's tile bounds if this happens.
+
+### Which function should I use?
+
+- **`setEntityGaussian`** β load immediately, stays resident. Use for a small number of
+ splats that should always be visible (e.g. a single hero object, a standalone demo scene).
+- **`setEntityGaussianAsync`** β same immediate/resident behavior, but the parse and GPU
+ upload happen off the main thread. Use for a one-off splat load where you don't want a
+ frame hitch, but still don't need distance-based unloading.
+- **`setEntityGaussianStreamable`** β registers the entity with `GeometryStreamingSystem`
+ instead of loading anything itself. Use for splat props inside a large, tile-streamed
+ scene, where you want the same load-when-near/unload-when-far behavior the rest of the
+ scene already gets.
+
diff --git a/docs/index.md b/docs/index.md
index 90712d8b5..bfba49de2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -13,18 +13,6 @@
Ask a Question
-