diff --git a/README.md b/README.md index a2ec42d9..f2a610d1 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ Untold Engine is well-suited for: - [Camera System](docs/API/UsingCameraSystem.md) - [Rendering System](docs/API/UsingRenderingSystem.md) - [Lighting System](docs/API/UsingLightingSystem.md) +- [Light Portals](docs/API/UsingLightPortals.md) - [Materials](docs/API/UsingMaterials.md) - [Input System](docs/API/UsingInputSystem.md) - [Physics System](docs/API/UsingPhysicsSystem.md) diff --git a/Sources/CShaderTypes/ShaderTypes.h b/Sources/CShaderTypes/ShaderTypes.h index 4c513c49..50a547c0 100644 --- a/Sources/CShaderTypes/ShaderTypes.h +++ b/Sources/CShaderTypes/ShaderTypes.h @@ -73,6 +73,8 @@ typedef struct{ simd_float3 up; simd_float2 bounds; float intensity; + float range; + float nearSourceSuppressionRadius; bool twoSided; }AreaLightUniform; diff --git a/Sources/UntoldEngine/Mesh/Skeleton.swift b/Sources/UntoldEngine/Mesh/Skeleton.swift index 58ad836c..1b83839e 100644 --- a/Sources/UntoldEngine/Mesh/Skeleton.swift +++ b/Sources/UntoldEngine/Mesh/Skeleton.swift @@ -76,7 +76,11 @@ class Skeleton { /// Computes local joint transforms based on an animation clip private func computeLocalPose(at time: Float, with animationClip: AnimationClip) -> [simd_float4x4] { jointPaths.indices.map { index in - animationClip.getPose(at: time * animationClip.speed, jointPath: jointPaths[index]) + animationClip.getPose( + at: time * animationClip.speed, + jointPath: jointPaths[index], + fallback: restTransform[index] + ) ?? restTransform[index] } } @@ -300,10 +304,45 @@ class AnimationClip { /// Retrieves the interpolated pose for a joint at a specific time func getPose(at time: Float, jointPath: String) -> float4x4? { + getPose(at: time, jointPath: jointPath, fallback: .identity) + } + + /// Retrieves the interpolated pose while preserving rest-pose channels that are + /// not authored by the clip. Many skeletal clips animate rotation only for most + /// joints; those joints must keep their rest translation offsets. The runtime + /// format does not store scale animation, so keep the rest-pose local scale too. + func getPose(at time: Float, jointPath: String, fallback: float4x4) -> float4x4? { guard let animation = jointAnimation[jointPath] else { return nil } - let rotation = animation.getRotation(at: time) ?? simd_quatf(simd_float4x4.identity) - let translation = animation.getTranslation(at: time) ?? simd_float3(repeating: 0) - return float4x4(translation: translation) * float4x4(rotation) + let fallbackScale = Self.localScale(from: fallback) + let fallbackRotation = Self.localRotation(from: fallback, scale: fallbackScale) + let rotation = animation.getRotation(at: time) ?? fallbackRotation + let translation = animation.getTranslation(at: time) ?? simd_float3( + fallback.columns.3.x, + fallback.columns.3.y, + fallback.columns.3.z + ) + return float4x4(translation: translation) * float4x4(rotation) * float4x4(scale: fallbackScale) + } + + private static func localScale(from matrix: float4x4) -> SIMD3 { + SIMD3( + simd_length(SIMD3(matrix.columns.0.x, matrix.columns.0.y, matrix.columns.0.z)), + simd_length(SIMD3(matrix.columns.1.x, matrix.columns.1.y, matrix.columns.1.z)), + simd_length(SIMD3(matrix.columns.2.x, matrix.columns.2.y, matrix.columns.2.z)) + ) + } + + private static func localRotation(from matrix: float4x4, scale: SIMD3) -> simd_quatf { + let epsilon: Float = 0.000001 + let sx = max(scale.x, epsilon) + let sy = max(scale.y, epsilon) + let sz = max(scale.z, epsilon) + let rotationMatrix = matrix_float3x3(columns: ( + SIMD3(matrix.columns.0.x, matrix.columns.0.y, matrix.columns.0.z) / sx, + SIMD3(matrix.columns.1.x, matrix.columns.1.y, matrix.columns.1.z) / sy, + SIMD3(matrix.columns.2.x, matrix.columns.2.y, matrix.columns.2.z) / sz + )) + return simd_normalize(simd_quatf(rotationMatrix)) } // MARK: - Private Helpers diff --git a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift index c217349c..1b2dab49 100644 --- a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift +++ b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift @@ -729,6 +729,19 @@ public func InitIBLPreFilterPipeline() -> RenderPipeline? { ) } +public func InitXRIBLCubePreFilterPipeline() -> RenderPipeline? { + CreatePipeline( + vertexShader: "vertexIBLPreFilterShader", + fragmentShader: "fragmentXRIBLCubePreFilterShader", + vertexDescriptor: createIBLPreFilterVertexDescriptor(), + colorFormats: [wf.ibl, wf.ibl, wf.ibl], + depthFormat: .invalid, + depthCompareFunction: .less, + depthEnabled: false, + name: "XR IBL Cube Pre-Filter Pipeline" + ) +} + public func InitLookPipeline() -> RenderPipeline? { CreatePipeline( vertexShader: "vertexLookShader", @@ -920,6 +933,7 @@ public func DefaultPipeLines() -> [(RenderPipelineType, RenderPipelineInitBlock) (.ssaoUpsample, InitSSAOUpsamplePipeline), (.environment, InitEnvironmentPipeline), (.iblPreFilter, InitIBLPreFilterPipeline), + (.xrIBLCubePreFilter, InitXRIBLCubePreFilterPipeline), (.gaussianTBDRInitialize, InitGaussianTBDRInitializePipeline), (.gaussianTBDRDraw, InitGaussianTBDRDrawPipeline), (.gaussianTBDRPostprocess, InitGaussianTBDRPostprocessPipeline), diff --git a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift index 9233aca3..907a8471 100644 --- a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift +++ b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift @@ -45,6 +45,7 @@ public extension RenderPipelineType { static let ssaoUpsample: RenderPipelineType = "ssaoUpsample" static let environment: RenderPipelineType = "environment" static let iblPreFilter: RenderPipelineType = "iblPreFilter" + static let xrIBLCubePreFilter: RenderPipelineType = "xrIBLCubePreFilter" static let gaussianTBDRInitialize: RenderPipelineType = "gaussianTBDRInitialize" static let gaussianTBDRDraw: RenderPipelineType = "gaussianTBDRDraw" static let gaussianTBDRPostprocess: RenderPipelineType = "gaussianTBDRPostprocess" diff --git a/Sources/UntoldEngine/Renderer/PostProcessRenderPasses.swift b/Sources/UntoldEngine/Renderer/PostProcessRenderPasses.swift index ebd84f7b..fad68a37 100644 --- a/Sources/UntoldEngine/Renderer/PostProcessRenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/PostProcessRenderPasses.swift @@ -115,3 +115,70 @@ func executeIBLPreFilterPass(uCommandBuffer: MTLCommandBuffer, _ envTexture: MTL } } } + +public func executeXRIBLCubePreFilterPass( + commandBuffer: MTLCommandBuffer, + environmentCubeTexture: MTLTexture, + target: RuntimeEnvironmentLightingTextureSet +) -> Bool { + guard environmentCubeTexture.textureType == .typeCube else { + Logger.logWarning(message: "[XRLighting] Environment probe texture is not a cube texture") + return false + } + + guard let iblPrefilterPipeline = PipelineManager.shared.renderPipelinesByType[.xrIBLCubePreFilter] else { + handleError(.pipelineStateNulled, "xrIBLCubePreFilterPipeline is nil") + return false + } + + guard iblPrefilterPipeline.success, + let pipelineState = iblPrefilterPipeline.pipelineState + else { + return false + } + + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.renderTargetWidth = target.irradianceMap.width + renderPassDescriptor.renderTargetHeight = target.irradianceMap.height + + renderPassDescriptor.colorAttachments[0].texture = target.irradianceMap + renderPassDescriptor.colorAttachments[0].loadAction = .dontCare + renderPassDescriptor.colorAttachments[0].storeAction = .store + + renderPassDescriptor.colorAttachments[1].texture = target.specularMap + renderPassDescriptor.colorAttachments[1].loadAction = .dontCare + renderPassDescriptor.colorAttachments[1].storeAction = .store + + renderPassDescriptor.colorAttachments[2].texture = target.brdfMap + renderPassDescriptor.colorAttachments[2].loadAction = .dontCare + renderPassDescriptor.colorAttachments[2].storeAction = .store + + guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { + return false + } + + renderEncoder.setRenderPipelineState(pipelineState) + renderEncoder.pushDebugGroup("XR IBL Cube Pre-Filter Pass") + renderEncoder.label = "XR IBL Cube Pre-Filter Pass" + renderEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) + renderEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) + renderEncoder.setFragmentTexture(environmentCubeTexture, index: 0) + renderEncoder.drawIndexedPrimitivesTracked( + type: .triangle, + indexCount: 6, + indexType: .uint16, + indexBuffer: bufferResources.quadIndexBuffer!, + indexBufferOffset: 0 + ) + renderEncoder.popDebugGroup() + renderEncoder.endEncoding() + + guard let blitEncoder = commandBuffer.makeBlitCommandEncoder() else { + return false + } + blitEncoder.label = "XR IBL Specular Mipmap Generation" + blitEncoder.generateMipmaps(for: target.specularMap) + blitEncoder.endEncoding() + + return true +} diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index ae404324..c6bd8d2a 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -1961,9 +1961,10 @@ public enum RenderPasses { renderEncoder.setFragmentBytes(&csmUniforms, length: MemoryLayout.stride, index: Int(lightPassLightOrthoViewMatrixIndex.rawValue)) renderEncoder.setFragmentTexture(textureResources.csmShadowMap, index: Int(lightPassShadowTextureIndex.rawValue)) - renderEncoder.setFragmentTexture(textureResources.irradianceMap, index: Int(lightPassIBLIrradianceTextureIndex.rawValue)) - renderEncoder.setFragmentTexture(textureResources.specularMap, index: Int(lightPassIBLSpecularTextureIndex.rawValue)) - renderEncoder.setFragmentTexture(textureResources.iblBRDFMap, index: Int(lightPassIBLBRDFMapTextureIndex.rawValue)) + let environmentLighting = resolveCurrentEnvironmentLighting() + renderEncoder.setFragmentTexture(environmentLighting.irradianceMap, index: Int(lightPassIBLIrradianceTextureIndex.rawValue)) + renderEncoder.setFragmentTexture(environmentLighting.specularMap, index: Int(lightPassIBLSpecularTextureIndex.rawValue)) + renderEncoder.setFragmentTexture(environmentLighting.brdfMap, index: Int(lightPassIBLBRDFMapTextureIndex.rawValue)) renderEncoder.setFragmentTexture(textureResources.areaTextureLTCMag, index: Int(lightPassAreaLTCMagTextureIndex.rawValue)) renderEncoder.setFragmentTexture(textureResources.areaTextureLTCMat, index: Int(lightPassAreaLTCMatTextureIndex.rawValue)) @@ -2000,8 +2001,8 @@ public enum RenderPasses { ) var brdfParameters = IBLParamsUniform() - brdfParameters.applyIBL = applyIBL - brdfParameters.ambientIntensity = ambientIntensity + brdfParameters.applyIBL = environmentLighting.applyIBL + brdfParameters.ambientIntensity = environmentLighting.ambientIntensity renderEncoder.setFragmentBytes(&brdfParameters, length: MemoryLayout.stride, index: Int(lightPassIBLParamIndex.rawValue)) var lightPassRotationAngle = envRotationAngle @@ -2676,20 +2677,22 @@ public enum RenderPasses { renderEncoder.setFragmentTexture(textureResources.areaTextureLTCMag, index: Int(lightPassAreaLTCMagTextureIndex.rawValue)) + let environmentLighting = resolveCurrentEnvironmentLighting() + // ibl renderEncoder.setFragmentTexture( - textureResources.irradianceMap, index: Int(lightPassIBLIrradianceTextureIndex.rawValue) + environmentLighting.irradianceMap, index: Int(lightPassIBLIrradianceTextureIndex.rawValue) ) renderEncoder.setFragmentTexture( - textureResources.specularMap, index: Int(lightPassIBLSpecularTextureIndex.rawValue) + environmentLighting.specularMap, index: Int(lightPassIBLSpecularTextureIndex.rawValue) ) renderEncoder.setFragmentTexture( - textureResources.iblBRDFMap, index: Int(lightPassIBLBRDFMapTextureIndex.rawValue) + environmentLighting.brdfMap, index: Int(lightPassIBLBRDFMapTextureIndex.rawValue) ) var brdfParameters = IBLParamsUniform() - brdfParameters.applyIBL = applyIBL - brdfParameters.ambientIntensity = ambientIntensity + brdfParameters.applyIBL = environmentLighting.applyIBL + brdfParameters.ambientIntensity = environmentLighting.ambientIntensity renderEncoder.setFragmentBytes( &brdfParameters, length: MemoryLayout.stride, @@ -2983,16 +2986,17 @@ public enum RenderPasses { textureResources.areaTextureLTCMag, index: Int(transparencyPassAreaLTCMagTextureIndex.rawValue) ) + let environmentLighting = resolveCurrentEnvironmentLighting() renderEncoder.setFragmentTexture( - textureResources.irradianceMap, + environmentLighting.irradianceMap, index: Int(transparencyPassIBLIrradianceTextureIndex.rawValue) ) renderEncoder.setFragmentTexture( - textureResources.specularMap, + environmentLighting.specularMap, index: Int(transparencyPassIBLSpecularTextureIndex.rawValue) ) renderEncoder.setFragmentTexture( - textureResources.iblBRDFMap, + environmentLighting.brdfMap, index: Int(transparencyPassIBLBRDFMapTextureIndex.rawValue) ) renderEncoder.setFragmentTexture( @@ -3001,8 +3005,8 @@ public enum RenderPasses { ) var iblParameters = IBLParamsUniform() - iblParameters.applyIBL = applyIBL - iblParameters.ambientIntensity = ambientIntensity + iblParameters.applyIBL = environmentLighting.applyIBL + iblParameters.ambientIntensity = environmentLighting.ambientIntensity renderEncoder.setFragmentBytes( &iblParameters, length: MemoryLayout.stride, diff --git a/Sources/UntoldEngine/Renderer/RuntimeEnvironmentLighting.swift b/Sources/UntoldEngine/Renderer/RuntimeEnvironmentLighting.swift new file mode 100644 index 00000000..3b0715c8 --- /dev/null +++ b/Sources/UntoldEngine/Renderer/RuntimeEnvironmentLighting.swift @@ -0,0 +1,284 @@ +// +// RuntimeEnvironmentLighting.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 Metal +import simd + +public enum RuntimeEnvironmentLightingMode: Sendable, Equatable { + case authoredOnly + case staticIBL + case realWorldEstimate +} + +public struct RuntimeEnvironmentLighting { + public var irradianceMap: MTLTexture? + public var specularMap: MTLTexture? + public var brdfMap: MTLTexture? + public var intensityScale: Float + public var tintColor: simd_float3 + public var timestamp: CFTimeInterval + public var isValid: Bool + + public init( + irradianceMap: MTLTexture?, + specularMap: MTLTexture?, + brdfMap: MTLTexture?, + intensityScale: Float = 1.0, + tintColor: simd_float3 = simd_float3(1.0, 1.0, 1.0), + timestamp: CFTimeInterval = Date().timeIntervalSinceReferenceDate, + isValid: Bool + ) { + self.irradianceMap = irradianceMap + self.specularMap = specularMap + self.brdfMap = brdfMap + self.intensityScale = intensityScale + self.tintColor = tintColor + self.timestamp = timestamp + self.isValid = isValid + } +} + +func resolveCurrentEnvironmentLighting() -> ResolvedEnvironmentLighting { + RuntimeEnvironmentLightingStore.shared.resolve( + staticIrradianceMap: textureResources.irradianceMap, + staticSpecularMap: textureResources.specularMap, + staticBRDFMap: textureResources.iblBRDFMap, + staticIBLEnabled: applyIBL, + ambientIntensity: ambientIntensity + ) +} + +public struct RuntimeEnvironmentLightingTextureSet { + public var irradianceMap: MTLTexture + public var specularMap: MTLTexture + public var brdfMap: MTLTexture +} + +public struct ResolvedEnvironmentLighting { + public var irradianceMap: MTLTexture? + public var specularMap: MTLTexture? + public var brdfMap: MTLTexture? + public var applyIBL: Bool + public var ambientIntensity: Float + public var mode: RuntimeEnvironmentLightingMode + public var fallbackReason: String? +} + +public func makeRuntimeEnvironmentLightingTextureSet(labelPrefix: String) -> RuntimeEnvironmentLightingTextureSet? { + let currentRenderInfo = renderInfo + let wf = currentRenderInfo.colorPipeline.working + let iblSize = 256 + + guard let irradianceMap = createTexture( + device: currentRenderInfo.device, + label: "\(labelPrefix) Irradiance Texture", + pixelFormat: wf.ibl, + width: iblSize, + height: iblSize, + usage: [.shaderRead, .renderTarget], + storageMode: .private + ) else { return nil } + + guard let specularMap = createTexture( + device: currentRenderInfo.device, + label: "\(labelPrefix) Specular Texture", + pixelFormat: wf.ibl, + width: iblSize, + height: iblSize, + usage: [.shaderRead, .renderTarget], + storageMode: .private, + mipMapLevels: 6 + ) else { return nil } + + guard let brdfMap = createTexture( + device: currentRenderInfo.device, + label: "\(labelPrefix) BRDF Texture", + pixelFormat: wf.ibl, + width: iblSize, + height: iblSize, + usage: [.shaderRead, .renderTarget], + storageMode: .private + ) else { return nil } + + return RuntimeEnvironmentLightingTextureSet( + irradianceMap: irradianceMap, + specularMap: specularMap, + brdfMap: brdfMap + ) +} + +public final class RuntimeEnvironmentLightingStore: @unchecked Sendable { + public static let shared = RuntimeEnvironmentLightingStore() + + private let lock = NSLock() + private var modeValue: RuntimeEnvironmentLightingMode = .staticIBL + private var realWorldLightingContributionValue: Float = 1.0 + private var xrLightingValue: RuntimeEnvironmentLighting? + private var modeObservers: [UUID: @Sendable (RuntimeEnvironmentLightingMode) -> Void] = [:] + + private init() {} + + public var mode: RuntimeEnvironmentLightingMode { + get { + lock.lock() + let value = modeValue + lock.unlock() + return value + } + set { + setMode(newValue) + } + } + + public func setMode(_ mode: RuntimeEnvironmentLightingMode, notifyIfUnchanged: Bool = false) { + lock.lock() + let oldMode = modeValue + modeValue = mode + let shouldNotify = notifyIfUnchanged || oldMode != mode + let observers = shouldNotify ? Array(modeObservers.values) : [] + lock.unlock() + + resetLightPortalAreaLightCache() + + for observer in observers { + observer(mode) + } + } + + @discardableResult + public func observeLightingModeChanges( + _ observer: @escaping @Sendable (RuntimeEnvironmentLightingMode) -> Void + ) -> UUID { + let id = UUID() + lock.lock() + modeObservers[id] = observer + lock.unlock() + return id + } + + public func removeLightingModeChangeObserver(_ id: UUID) { + lock.lock() + modeObservers.removeValue(forKey: id) + lock.unlock() + } + + public var realWorldLightingContribution: Float { + get { + lock.lock() + let value = realWorldLightingContributionValue + lock.unlock() + return value + } + set { + lock.lock() + realWorldLightingContributionValue = Self.sanitizedContribution(newValue) + lock.unlock() + resetLightPortalAreaLightCache() + } + } + + public func publishXRLighting(_ lighting: RuntimeEnvironmentLighting?) { + lock.lock() + xrLightingValue = lighting + lock.unlock() + resetLightPortalAreaLightCache() + } + + public func latestXRLighting() -> RuntimeEnvironmentLighting? { + lock.lock() + let value = xrLightingValue + lock.unlock() + return value + } + + public func reset() { + lock.lock() + let oldMode = modeValue + modeValue = .staticIBL + realWorldLightingContributionValue = 1.0 + xrLightingValue = nil + let observers = oldMode == .staticIBL ? [] : Array(modeObservers.values) + lock.unlock() + + resetLightPortalAreaLightCache() + + for observer in observers { + observer(.staticIBL) + } + } + + public func resolve( + staticIrradianceMap: MTLTexture?, + staticSpecularMap: MTLTexture?, + staticBRDFMap: MTLTexture?, + staticIBLEnabled: Bool, + ambientIntensity: Float + ) -> ResolvedEnvironmentLighting { + lock.lock() + let currentMode = modeValue + let realWorldLightingContribution = realWorldLightingContributionValue + let xrLighting = xrLightingValue + lock.unlock() + + let staticLighting = ResolvedEnvironmentLighting( + irradianceMap: staticIrradianceMap, + specularMap: staticSpecularMap, + brdfMap: staticBRDFMap, + applyIBL: staticIBLEnabled, + ambientIntensity: ambientIntensity, + mode: .staticIBL, + fallbackReason: nil + ) + + switch currentMode { + case .authoredOnly: + return ResolvedEnvironmentLighting( + irradianceMap: staticIrradianceMap, + specularMap: staticSpecularMap, + brdfMap: staticBRDFMap, + applyIBL: false, + ambientIntensity: ambientIntensity, + mode: .authoredOnly, + fallbackReason: nil + ) + + case .staticIBL: + return staticLighting + + case .realWorldEstimate: + guard let xrLighting, + xrLighting.isValid, + let irradianceMap = xrLighting.irradianceMap, + let specularMap = xrLighting.specularMap, + let brdfMap = xrLighting.brdfMap + else { + var fallback = staticLighting + fallback.fallbackReason = "XR lighting unavailable" + return fallback + } + + return ResolvedEnvironmentLighting( + irradianceMap: irradianceMap, + specularMap: specularMap, + brdfMap: brdfMap, + applyIBL: true, + ambientIntensity: ambientIntensity * xrLighting.intensityScale * realWorldLightingContribution, + mode: currentMode, + fallbackReason: nil + ) + } + } + + private static func sanitizedContribution(_ value: Float) -> Float { + guard value.isFinite else { return 1.0 } + return max(value, 0.0) + } +} diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 1f7c90c4..c14c18fa 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -434,6 +434,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { // Integration/HZB monitors remain runtime-driven and are available in all build configs. SystemIntegrationMonitor.shared.tick() HZBDebugMonitor.shared.tick() + LightPortalSystem.shared.logDiagnosticsIfDue() #if ENGINE_STATS_ENABLED EngineStatsMonitor.shared.tick() #endif diff --git a/Sources/UntoldEngine/Scripting/USCInterpreter.swift b/Sources/UntoldEngine/Scripting/USCInterpreter.swift index a4cc5322..3e8eeec4 100644 --- a/Sources/UntoldEngine/Scripting/USCInterpreter.swift +++ b/Sources/UntoldEngine/Scripting/USCInterpreter.swift @@ -1120,6 +1120,21 @@ public class USCInterpreter: @unchecked Sendable { case "D": return InputSystem.shared.keyState.dPressed case "Q": return InputSystem.shared.keyState.qPressed case "E": return InputSystem.shared.keyState.ePressed + case "H": return InputSystem.shared.keyState.hPressed + case "TAB": return InputSystem.shared.keyState.tabPressed + case "F": return InputSystem.shared.keyState.fPressed + case "F1": return InputSystem.shared.keyState.f1Pressed + case "F2": return InputSystem.shared.keyState.f2Pressed + case "F3": return InputSystem.shared.keyState.f3Pressed + case "F4": return InputSystem.shared.keyState.f4Pressed + case "F5": return InputSystem.shared.keyState.f5Pressed + case "F6": return InputSystem.shared.keyState.f6Pressed + case "F7": return InputSystem.shared.keyState.f7Pressed + case "F8": return InputSystem.shared.keyState.f8Pressed + case "F9": return InputSystem.shared.keyState.f9Pressed + case "F10": return InputSystem.shared.keyState.f10Pressed + case "F11": return InputSystem.shared.keyState.f11Pressed + case "F12": return InputSystem.shared.keyState.f12Pressed case "SPACE": return InputSystem.shared.keyState.spacePressed case "SHIFT": return InputSystem.shared.keyState.shiftPressed case "CTRL": return InputSystem.shared.keyState.ctrlPressed diff --git a/Sources/UntoldEngine/Shaders/LightShader.metal b/Sources/UntoldEngine/Shaders/LightShader.metal index d75f35b2..c2ff9cc6 100644 --- a/Sources/UntoldEngine/Shaders/LightShader.metal +++ b/Sources/UntoldEngine/Shaders/LightShader.metal @@ -150,6 +150,43 @@ LightContribution computeSpotLightContribution(constant SpotLightUniform &light, return outC; } +static inline float areaLightSurfaceDistance(constant AreaLightUniform &light, + float3 P, + float3 emittingNormal) { + float3 rightN = normalize(light.right); + float3 upN = normalize(light.up); + float3 toLightSample = P - light.position; + float planeDistance = abs(dot(toLightSample, emittingNormal)); + float2 rectangleDistance = abs(float2(dot(toLightSample, rightN), dot(toLightSample, upN))) - light.bounds * 0.5; + float edgeDistance = length(max(rectangleDistance, float2(0.0))); + return length(float2(planeDistance, edgeDistance)); +} + +static inline float areaLightRangeAttenuation(constant AreaLightUniform &light, + float3 P, + float3 emittingNormal) { + if (light.range <= 0.0) { + return 1.0; + } + + float distanceToLight = areaLightSurfaceDistance(light, P, emittingNormal); + return 1.0 - smoothstep(light.range * 0.75, light.range, distanceToLight); +} + +static inline float areaLightNearSourceAttenuation(constant AreaLightUniform &light, + float3 P, + float3 emittingNormal) { + if (light.nearSourceSuppressionRadius <= 0.0) { + return 1.0; + } + + float sourceDistance = areaLightSurfaceDistance(light, P, emittingNormal); + return smoothstep( + light.nearSourceSuppressionRadius * 0.35, + light.nearSourceSuppressionRadius, + sourceDistance + ); +} LightContribution evaluateAreaLight(constant AreaLightUniform &light, float4 verticesInWorldSpace, @@ -229,10 +266,12 @@ LightContribution evaluateAreaLight(constant AreaLightUniform &light, float3 f0 = mix(float3(0.04), inBaseColor.rgb, metallic); float3 fresnelScale = f0 * t2.x + (1.0 - f0) * t2.y; float3 diffuseBRDF = inBaseColor.rgb * (1.0 - metallic); + float lightAttenuation = areaLightRangeAttenuation(light, P, emittingNormal) + * areaLightNearSourceAttenuation(light, P, emittingNormal); LightContribution outC; - outC.diff = (half3)(light.intensity * light.color * Lo_diffuse * diffuseBRDF); - outC.spec = light.intensity * light.color * Lo_spec * fresnelScale; + outC.diff = (half3)(lightAttenuation * light.intensity * light.color * Lo_diffuse * diffuseBRDF); + outC.spec = lightAttenuation * light.intensity * light.color * Lo_spec * fresnelScale; return outC; diff --git a/Sources/UntoldEngine/Shaders/iblPreFilterShaders.metal b/Sources/UntoldEngine/Shaders/iblPreFilterShaders.metal index 9b142f39..4915f755 100644 --- a/Sources/UntoldEngine/Shaders/iblPreFilterShaders.metal +++ b/Sources/UntoldEngine/Shaders/iblPreFilterShaders.metal @@ -40,3 +40,66 @@ fragment IBLFragmentOut fragmentIBLPreFilterShader(VertexCompositeOutput in [[st return out; } + +static float3 xrIBLNormalFromEquirectUV(float2 texCoords) { + float thetaN = M_PI_F * (1.0 - texCoords.y); + float phiN = 2.0 * M_PI_F * (1.0 - texCoords.x); + return float3(sin(thetaN) * cos(phiN), sin(thetaN) * sin(phiN), cos(thetaN)); +} + +static float4 diffuseImportanceMapCube(float2 texCoords, texturecube environmentTexture) { + constexpr sampler s(coord::normalized, + filter::linear, + mip_filter::none, + address::clamp_to_edge); + + constexpr uint sampleCount = 128u; + float3 normal = xrIBLNormalFromEquirectUV(texCoords); + float3x3 normalSpace = getNormalSpace(normal); + float3 result = float3(0.0); + + for (uint n = 1u; n <= sampleCount; n++) { + float2 p = hammersley(n, sampleCount); + float theta = asin(sqrt(p.y)); + float phi = 2.0 * M_PI_F * p.x; + float3 pos = float3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta)); + float3 posGlob = normalize(normalSpace * pos); + result += environmentTexture.sample(s, posGlob).rgb; + } + + return float4(result / float(sampleCount), 1.0); +} + +static float4 specularImportanceMapCube(float2 texCoords, texturecube environmentTexture) { + constexpr sampler s(coord::normalized, + filter::linear, + mip_filter::none, + address::clamp_to_edge); + + constexpr float shininess = 600.0; + constexpr uint sampleCount = 128u; + float3 normal = xrIBLNormalFromEquirectUV(texCoords); + float3x3 normalSpace = getNormalSpace(normal); + float3 result = float3(0.0); + + for (uint n = 1u; n <= sampleCount; n++) { + float2 p = hammersley(n, sampleCount); + float theta = acos(pow(1.0 - p.y, 1.0 / (shininess + 1.0))); + float phi = 2.0 * M_PI_F * p.x; + float3 pos = float3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta)); + float3 posGlob = normalize(normalSpace * pos); + result += environmentTexture.sample(s, posGlob).rgb; + } + + result = result / float(sampleCount) * (shininess + 2.0) / (shininess + 1.0); + return float4(result, 1.0); +} + +fragment IBLFragmentOut fragmentXRIBLCubePreFilterShader(VertexCompositeOutput in [[stage_in]], + texturecube environmentTexture [[texture(0)]]) { + IBLFragmentOut out; + out.irradiance = diffuseImportanceMapCube(in.uvCoords, environmentTexture); + out.specular = specularImportanceMapCube(in.uvCoords, environmentTexture); + out.brdfMap = BRDFIntegrationMap(1.0 - in.uvCoords.y, in.uvCoords.x); + return out; +} diff --git a/Sources/UntoldEngine/Systems/AnimationSystem.swift b/Sources/UntoldEngine/Systems/AnimationSystem.swift index 42f5363e..249af93f 100644 --- a/Sources/UntoldEngine/Systems/AnimationSystem.swift +++ b/Sources/UntoldEngine/Systems/AnimationSystem.swift @@ -45,40 +45,97 @@ public final class AnimationSystem: @unchecked Sendable { /// instead of add an ifelse conditional jump. private func updateAnimationSystemDummy(deltaTime _: Float) {} -private func resolveDescendantEntity( +private func collectDescendantEntities( entityId: EntityID, - matches: (EntityID) -> Bool -) -> EntityID? { + matches: (EntityID) -> Bool, + visited: inout Set +) -> [EntityID] { + guard visited.insert(entityId).inserted else { + return [] + } + + var result: [EntityID] = [] if matches(entityId) { - return entityId + result.append(entityId) } guard let scenegraph = scene.get(component: ScenegraphComponent.self, for: entityId) else { - return nil + return result } for childId in scenegraph.children { - if let resolved = resolveDescendantEntity(entityId: childId, matches: matches) { - return resolved - } + result.append(contentsOf: collectDescendantEntities(entityId: childId, matches: matches, visited: &visited)) } - return nil + return result } -func resolveEntityWithAnimationComponent(entityId: EntityID) -> EntityID? { - resolveDescendantEntity(entityId: entityId) { +private func resolveDescendantEntities( + entityId: EntityID, + matches: (EntityID) -> Bool +) -> [EntityID] { + var visited: Set = [] + return collectDescendantEntities(entityId: entityId, matches: matches, visited: &visited) +} + +private func resolveDescendantEntity( + entityId: EntityID, + matches: (EntityID) -> Bool +) -> EntityID? { + resolveDescendantEntities(entityId: entityId, matches: matches).first +} + +func resolveEntitiesWithAnimationComponent(entityId: EntityID) -> [EntityID] { + resolveDescendantEntities(entityId: entityId) { scene.get(component: AnimationComponent.self, for: $0) != nil } } -func resolveEntityForAnimationBinding(entityId: EntityID) -> EntityID? { - resolveDescendantEntity(entityId: entityId) { +func resolveEntityWithAnimationComponent(entityId: EntityID) -> EntityID? { + resolveEntitiesWithAnimationComponent(entityId: entityId).first +} + +func resolveEntitiesForAnimationBinding(entityId: EntityID) -> [EntityID] { + resolveDescendantEntities(entityId: entityId) { scene.get(component: SkeletonComponent.self, for: $0) != nil && scene.get(component: RenderComponent.self, for: $0) != nil } } +func resolveEntityForAnimationBinding(entityId: EntityID) -> EntityID? { + resolveEntitiesForAnimationBinding(entityId: entityId).first +} + +private func animationComponentsForEntityOrDescendants(entityId: EntityID) -> [(EntityID, AnimationComponent)] { + let targetEntityIds = resolveEntitiesWithAnimationComponent(entityId: entityId) + return targetEntityIds.compactMap { targetEntityId in + guard let animationComponent = scene.get(component: AnimationComponent.self, for: targetEntityId) else { + return nil + } + return (targetEntityId, animationComponent) + } +} + +private func animationComponentsContainingClip(entityId: EntityID, name: String) -> [(EntityID, AnimationComponent, AnimationClip)] { + animationComponentsForEntityOrDescendants(entityId: entityId).compactMap { targetEntityId, animationComponent in + guard let animationClip = animationComponent.animationClips[name] else { + return nil + } + return (targetEntityId, animationComponent, animationClip) + } +} + +func resolveAnimationBindingTargetEntities(entityId: EntityID) -> [EntityID] { + let targetEntityIds = resolveEntitiesForAnimationBinding(entityId: entityId) + return targetEntityIds.isEmpty ? [entityId] : targetEntityIds +} + +private func hasAnyAnimationComponent(entityId: EntityID) -> Bool { + resolveDescendantEntity(entityId: entityId) { + scene.get(component: AnimationComponent.self, for: $0) != nil + } != nil +} + private func updateAnimationSystem(deltaTime: Float) { currentGlobalTime += deltaTime @@ -123,49 +180,58 @@ private func updateAnimationSystem(deltaTime: Float) { } public func pauseAnimationComponent(entityId: EntityID, isPaused: Bool) { - let targetEntityId = resolveEntityWithAnimationComponent(entityId: entityId) ?? entityId - guard let animationComponent = scene.get(component: AnimationComponent.self, for: targetEntityId) else { + let animationComponents = animationComponentsForEntityOrDescendants(entityId: entityId) + guard animationComponents.isEmpty == false else { handleError(.noAnimationComponent, entityId) return } - animationComponent.pause = isPaused + for (_, animationComponent) in animationComponents { + animationComponent.pause = isPaused + } } public func isAnimationComponentPaused(entityId: EntityID) -> Bool { - let targetEntityId = resolveEntityWithAnimationComponent(entityId: entityId) ?? entityId - guard let animationComponent = scene.get(component: AnimationComponent.self, for: targetEntityId) else { + let animationComponents = animationComponentsForEntityOrDescendants(entityId: entityId) + guard animationComponents.isEmpty == false else { handleError(.noAnimationComponent, entityId) return true } - return animationComponent.pause + return animationComponents.allSatisfy { _, animationComponent in + animationComponent.pause + } } public func changeAnimation(entityId: EntityID, name: String, withPause: Bool = false) { - let targetEntityId = resolveEntityWithAnimationComponent(entityId: entityId) ?? entityId - guard let animationComponent = scene.get(component: AnimationComponent.self, for: targetEntityId) else { + guard hasAnyAnimationComponent(entityId: entityId) else { handleError(.noAnimationComponent, entityId) return } - guard let animationClip = animationComponent.animationClips[name] else { + let matchingComponents = animationComponentsContainingClip(entityId: entityId, name: name) + guard matchingComponents.isEmpty == false else { handleError(.noAnimationClip, name, entityId) return } - animationComponent.currentAnimation = animationClip - animationComponent.pause = withPause + for (_, animationComponent, animationClip) in matchingComponents { + animationComponent.currentAnimation = animationClip + animationComponent.pause = withPause + } } public func setAnimationPlaybackSpeed(entityId: EntityID, speed: Float) { - let targetEntityId = resolveEntityWithAnimationComponent(entityId: entityId) ?? entityId - guard let animationComponent = scene.get(component: AnimationComponent.self, for: targetEntityId) else { + let animationComponents = animationComponentsForEntityOrDescendants(entityId: entityId) + guard animationComponents.isEmpty == false else { handleError(.noAnimationComponent, entityId) return } - animationComponent.playbackSpeed = max(0.0, speed) + let clampedSpeed = max(0.0, speed) + for (_, animationComponent) in animationComponents { + animationComponent.playbackSpeed = clampedSpeed + } } public func getAnimationPlaybackSpeed(entityId: EntityID) -> Float { @@ -179,20 +245,19 @@ public func getAnimationPlaybackSpeed(entityId: EntityID) -> Float { } public func getAllAnimationClips(entityId: EntityID) -> [String] { - let targetEntityId = resolveEntityWithAnimationComponent(entityId: entityId) ?? entityId - guard let animationComponent = scene.get(component: AnimationComponent.self, for: targetEntityId) else { - return [] - } - - return animationComponent.getAllAnimationClips() + let clipNames = animationComponentsForEntityOrDescendants(entityId: entityId) + .flatMap { _, animationComponent in animationComponent.getAllAnimationClips() } + return Array(Set(clipNames)).sorted() } public func removeAnimationClip(entityId: EntityID, animationClip: String) { - let targetEntityId = resolveEntityWithAnimationComponent(entityId: entityId) ?? entityId - guard let animationComponent = scene.get(component: AnimationComponent.self, for: targetEntityId) else { + let animationComponents = animationComponentsForEntityOrDescendants(entityId: entityId) + guard animationComponents.isEmpty == false else { handleError(.noAnimationComponent, entityId) return } - animationComponent.removeAnimationClip(animationClip: animationClip) + for (_, animationComponent) in animationComponents { + animationComponent.removeAnimationClip(animationClip: animationClip) + } } diff --git a/Sources/UntoldEngine/Systems/InputSystem+Keyboard.swift b/Sources/UntoldEngine/Systems/InputSystem+Keyboard.swift index f3857c81..57bbb993 100644 --- a/Sources/UntoldEngine/Systems/InputSystem+Keyboard.swift +++ b/Sources/UntoldEngine/Systems/InputSystem+Keyboard.swift @@ -14,6 +14,10 @@ public struct KeyState { public var wPressed = false, aPressed = false, sPressed = false, dPressed = false + public var hPressed = false, tabPressed = false, fPressed = false + public var f1Pressed = false, f2Pressed = false, f3Pressed = false, f4Pressed = false + public var f5Pressed = false, f6Pressed = false, f7Pressed = false, f8Pressed = false + public var f9Pressed = false, f10Pressed = false, f11Pressed = false, f12Pressed = false public var jPressed = false, kPressed = false, lPressed = false public var qPressed = false, ePressed = false public var spacePressed = false, shiftPressed = false, ctrlPressed = false @@ -126,6 +130,66 @@ public extension InputSystem { !isTextInputFocused } + private var hKeyCode: UInt16 { + 4 + } + + private var tabKeyCode: UInt16 { + 48 + } + + private var fKeyCode: UInt16 { + 3 + } + + private var f1KeyCode: UInt16 { + 122 + } + + private var f2KeyCode: UInt16 { + 120 + } + + private var f3KeyCode: UInt16 { + 99 + } + + private var f4KeyCode: UInt16 { + 118 + } + + private var f5KeyCode: UInt16 { + 96 + } + + private var f6KeyCode: UInt16 { + 97 + } + + private var f7KeyCode: UInt16 { + 98 + } + + private var f8KeyCode: UInt16 { + 100 + } + + private var f9KeyCode: UInt16 { + 101 + } + + private var f10KeyCode: UInt16 { + 109 + } + + private var f11KeyCode: UInt16 { + 103 + } + + private var f12KeyCode: UInt16 { + 111 + } + func keyPressed(_ keyCode: UInt16) { switch keyCode { case kVK_ANSI_A: keyState.aPressed = true @@ -134,6 +198,21 @@ public extension InputSystem { case kVK_ANSI_S: keyState.sPressed = true case kVK_ANSI_Q: keyState.qPressed = true case kVK_ANSI_E: keyState.ePressed = true + case hKeyCode: keyState.hPressed = true + case tabKeyCode: keyState.tabPressed = true + case fKeyCode: keyState.fPressed = true + case f1KeyCode: keyState.f1Pressed = true + case f2KeyCode: keyState.f2Pressed = true + case f3KeyCode: keyState.f3Pressed = true + case f4KeyCode: keyState.f4Pressed = true + case f5KeyCode: keyState.f5Pressed = true + case f6KeyCode: keyState.f6Pressed = true + case f7KeyCode: keyState.f7Pressed = true + case f8KeyCode: keyState.f8Pressed = true + case f9KeyCode: keyState.f9Pressed = true + case f10KeyCode: keyState.f10Pressed = true + case f11KeyCode: keyState.f11Pressed = true + case f12KeyCode: keyState.f12Pressed = true case kVK_ANSI_Space: keyState.spacePressed = true case kVK_ANSI_J: keyState.jPressed = true case kVK_ANSI_K: keyState.kPressed = true @@ -153,6 +232,21 @@ public extension InputSystem { case kVK_ANSI_S: keyState.sPressed = false case kVK_ANSI_Q: keyState.qPressed = false case kVK_ANSI_E: keyState.ePressed = false + case hKeyCode: keyState.hPressed = false + case tabKeyCode: keyState.tabPressed = false + case fKeyCode: keyState.fPressed = false + case f1KeyCode: keyState.f1Pressed = false + case f2KeyCode: keyState.f2Pressed = false + case f3KeyCode: keyState.f3Pressed = false + case f4KeyCode: keyState.f4Pressed = false + case f5KeyCode: keyState.f5Pressed = false + case f6KeyCode: keyState.f6Pressed = false + case f7KeyCode: keyState.f7Pressed = false + case f8KeyCode: keyState.f8Pressed = false + case f9KeyCode: keyState.f9Pressed = false + case f10KeyCode: keyState.f10Pressed = false + case f11KeyCode: keyState.f11Pressed = false + case f12KeyCode: keyState.f12Pressed = false case kVK_ANSI_J: keyState.jPressed = false case kVK_ANSI_K: keyState.kPressed = false case kVK_ANSI_L: keyState.lPressed = false diff --git a/Sources/UntoldEngine/Systems/LightPortalSystem.swift b/Sources/UntoldEngine/Systems/LightPortalSystem.swift new file mode 100644 index 00000000..7829c245 --- /dev/null +++ b/Sources/UntoldEngine/Systems/LightPortalSystem.swift @@ -0,0 +1,595 @@ +// +// LightPortalSystem.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 + +public struct LightPortalCandidate: Sendable { + public let entityId: EntityID + public let channels: SceneChannel + public let mode: SceneChannelLightPortalMode + public let worldTransform: simd_float4x4 + public let localBoundsMin: simd_float3 + public let localBoundsMax: simd_float3 +} + +public struct LightPortalDiscoveryDiagnostics: Equatable, Sendable { + public var scannedRenderableEntityCount: Int + public var candidateCount: Int + public var skippedHiddenCount: Int + public var skippedInvisibleRenderComponentCount: Int + public var skippedDisabledPortalCount: Int + public var skippedInvalidGeometryCount: Int + + public static let empty = LightPortalDiscoveryDiagnostics( + scannedRenderableEntityCount: 0, + candidateCount: 0, + skippedHiddenCount: 0, + skippedInvisibleRenderComponentCount: 0, + skippedDisabledPortalCount: 0, + skippedInvalidGeometryCount: 0 + ) +} + +public struct LightPortalProxyLight: Equatable, Sendable { + public let sourceEntityId: EntityID + public let channels: SceneChannel + public let position: simd_float3 + public let forward: simd_float3 + public let right: simd_float3 + public let up: simd_float3 + public let bounds: simd_float2 + public let color: simd_float3 + public let intensity: Float + public let range: Float + public let distanceToCamera: Float + public let useRealWorldTint: Bool +} + +public struct LightPortalResolutionDiagnostics: Equatable, Sendable { + public var discoveredCandidateCount: Int + public var activePortalCount: Int + public var skippedByActivationDistanceCount: Int + public var maxActivePortals: Int + + public static let empty = LightPortalResolutionDiagnostics( + discoveredCandidateCount: 0, + activePortalCount: 0, + skippedByActivationDistanceCount: 0, + maxActivePortals: 0 + ) +} + +public struct LightPortalPerformanceDiagnostics: Equatable, Sendable { + public var lastDiscoveryDurationMs: Double? + public var lastResolutionDurationMs: Double? + public var lastScannedRenderableEntityCount: Int + public var lastDiscoveredCandidateCount: Int + public var lastResolvedProxyLightCount: Int + + public static let empty = LightPortalPerformanceDiagnostics( + lastDiscoveryDurationMs: nil, + lastResolutionDurationMs: nil, + lastScannedRenderableEntityCount: 0, + lastDiscoveredCandidateCount: 0, + lastResolvedProxyLightCount: 0 + ) +} + +public final class LightPortalSystem: @unchecked Sendable { + public static let shared = LightPortalSystem() + + private let minimumPortalSurfaceDimension: Float = 0.01 + private let maximumPortalThicknessFraction: Float = 0.25 + private let lock = NSLock() + private var lastDiagnostics: LightPortalDiscoveryDiagnostics = .empty + private var lastResolutionDiagnostics: LightPortalResolutionDiagnostics = .empty + private var lastPerformanceDiagnostics: LightPortalPerformanceDiagnostics = .empty + private var lastDiagnosticsLogTime: Double = 0 + + private init() {} + + public func discoverCandidates() -> [LightPortalCandidate] { + let startTime = Date().timeIntervalSinceReferenceDate + guard hasSceneChannelLightPortalsEnabled() else { + setDiagnostics(.empty) + setPerformanceDiagnostics(.empty) + return [] + } + + let renderComponentId = getComponentId(for: RenderComponent.self) + let worldTransformComponentId = getComponentId(for: WorldTransformComponent.self) + let localTransformComponentId = getComponentId(for: LocalTransformComponent.self) + let entityIds = queryEntitiesWithComponentIds( + [renderComponentId, worldTransformComponentId, localTransformComponentId], + in: scene + ).sorted() + + var candidates: [LightPortalCandidate] = [] + var diagnostics = LightPortalDiscoveryDiagnostics.empty + diagnostics.scannedRenderableEntityCount = entityIds.count + + for entityId in entityIds { + guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { + continue + } + + if renderComponent.isVisible == false { + diagnostics.skippedInvisibleRenderComponentCount += 1 + continue + } + + if shouldHideSceneEntity(entityId: entityId) { + diagnostics.skippedHiddenCount += 1 + continue + } + + let channels = getEntitySceneChannels(entityId: entityId) + let mode = sceneChannelLightPortalMode(for: channels) + guard case .enabled = mode else { + diagnostics.skippedDisabledPortalCount += 1 + continue + } + + guard let worldTransform = scene.get(component: WorldTransformComponent.self, for: entityId), + let localTransform = scene.get(component: LocalTransformComponent.self, for: entityId) + else { + continue + } + + guard isValidPortalGeometry(localBoundsMin: localTransform.boundingBox.min, localBoundsMax: localTransform.boundingBox.max) else { + diagnostics.skippedInvalidGeometryCount += 1 + continue + } + + candidates.append( + LightPortalCandidate( + entityId: entityId, + channels: channels, + mode: mode, + worldTransform: worldTransform.space, + localBoundsMin: localTransform.boundingBox.min, + localBoundsMax: localTransform.boundingBox.max + ) + ) + } + + diagnostics.candidateCount = candidates.count + setDiagnostics(diagnostics) + updatePerformanceDiagnostics { performance in + performance.lastDiscoveryDurationMs = Self.elapsedMilliseconds(since: startTime) + performance.lastScannedRenderableEntityCount = diagnostics.scannedRenderableEntityCount + performance.lastDiscoveredCandidateCount = diagnostics.candidateCount + } + return candidates + } + + public func resolveProxyLights(cameraPosition: simd_float3) -> [LightPortalProxyLight] { + let candidates = discoverCandidates() + let resolved = resolveProxyLights(from: candidates, cameraPosition: cameraPosition) + setResolutionDiagnostics(resolved.diagnostics) + return resolved.proxyLights + } + + public func resolveProxyLightsForActiveCamera() -> [LightPortalProxyLight] { + guard let camera = CameraSystem.shared.activeCamera, + let cameraComponent = scene.get(component: CameraComponent.self, for: camera) + else { + let candidates = discoverCandidates() + let resolved = resolveProxyLights(from: candidates, cameraPosition: nil) + setResolutionDiagnostics(resolved.diagnostics) + return resolved.proxyLights + } + + return resolveProxyLights(cameraPosition: SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition)) + } + + public func discoveryDiagnostics() -> LightPortalDiscoveryDiagnostics { + lock.lock() + let diagnostics = lastDiagnostics + lock.unlock() + return diagnostics + } + + public func resolutionDiagnostics() -> LightPortalResolutionDiagnostics { + lock.lock() + let diagnostics = lastResolutionDiagnostics + lock.unlock() + return diagnostics + } + + public func performanceDiagnostics() -> LightPortalPerformanceDiagnostics { + lock.lock() + let diagnostics = lastPerformanceDiagnostics + lock.unlock() + return diagnostics + } + + func resetDiagnostics() { + setDiagnostics(.empty) + setResolutionDiagnostics(.empty) + setPerformanceDiagnostics(.empty) + lock.lock() + lastDiagnosticsLogTime = 0 + lock.unlock() + } + + /// Logs light portal diagnostics at most once per `interval` seconds. + /// + /// No-op when the LightPortal log category is disabled. Enable with + /// `setLogger(.category(.lightPortal, true))`. + public func logDiagnosticsIfDue(interval: Double = 1.0) { + guard Logger.isEnabled(category: .lightPortal) else { return } + let now = CFAbsoluteTimeGetCurrent() + lock.lock() + guard now - lastDiagnosticsLogTime >= interval else { + lock.unlock() + return + } + lastDiagnosticsLogTime = now + let discovery = lastDiagnostics + let resolution = lastResolutionDiagnostics + let performance = lastPerformanceDiagnostics + lock.unlock() + + emitDiagnostics(discovery: discovery, resolution: resolution, performance: performance) + } + + /// Emits light portal diagnostics immediately, bypassing the rate-limit timer. + /// + /// No-op when the LightPortal log category is disabled. + public func logDiagnosticsNow() { + guard Logger.isEnabled(category: .lightPortal) else { return } + lock.lock() + lastDiagnosticsLogTime = CFAbsoluteTimeGetCurrent() + let discovery = lastDiagnostics + let resolution = lastResolutionDiagnostics + let performance = lastPerformanceDiagnostics + lock.unlock() + + emitDiagnostics(discovery: discovery, resolution: resolution, performance: performance) + } + + private func setDiagnostics(_ diagnostics: LightPortalDiscoveryDiagnostics) { + lock.lock() + lastDiagnostics = diagnostics + lock.unlock() + } + + private func setResolutionDiagnostics(_ diagnostics: LightPortalResolutionDiagnostics) { + lock.lock() + lastResolutionDiagnostics = diagnostics + lock.unlock() + } + + private func setPerformanceDiagnostics(_ diagnostics: LightPortalPerformanceDiagnostics) { + lock.lock() + lastPerformanceDiagnostics = diagnostics + lock.unlock() + } + + private func updatePerformanceDiagnostics(_ update: (inout LightPortalPerformanceDiagnostics) -> Void) { + lock.lock() + update(&lastPerformanceDiagnostics) + lock.unlock() + } + + private func emitDiagnostics( + discovery: LightPortalDiscoveryDiagnostics, + resolution: LightPortalResolutionDiagnostics, + performance: LightPortalPerformanceDiagnostics + ) { + let render = getLightPortalRenderDiagnostics() + Logger.log( + message: "[LightPortal] scanned=\(discovery.scannedRenderableEntityCount)" + + " candidates=\(discovery.candidateCount)" + + " active=\(resolution.activePortalCount)/\(resolution.maxActivePortals)" + + " portalLights=\(render.portalAreaLightCount)" + + " authoredAreaLights=\(render.authoredAreaLightCount)" + + " remainingAreaLightCapacity=\(render.remainingAreaLightCapacity)", + category: LogCategory.lightPortal.rawValue + ) + Logger.log( + message: "[LightPortal] discoveryMs=\(Self.formattedMilliseconds(performance.lastDiscoveryDurationMs))" + + " resolutionMs=\(Self.formattedMilliseconds(performance.lastResolutionDurationMs))" + + " skippedHidden=\(discovery.skippedHiddenCount)" + + " skippedInvisible=\(discovery.skippedInvisibleRenderComponentCount)" + + " skippedDisabled=\(discovery.skippedDisabledPortalCount)" + + " skippedInvalidGeometry=\(discovery.skippedInvalidGeometryCount)" + + " skippedByDistance=\(resolution.skippedByActivationDistanceCount)", + category: LogCategory.lightPortal.rawValue + ) + Logger.log( + message: "[LightPortal] envScale=\(String(format: "%.3f", render.environmentIntensityScale))" + + " contribution=\(String(format: "%.3f", render.realWorldLightingContribution))" + + " xrValid=\(render.xrLightingValid)" + + " intensityMax=\(Self.formattedFloat(render.maxEffectivePortalIntensity))" + + " fallback=\(render.fallbackReason ?? "none")", + category: LogCategory.lightPortal.rawValue + ) + } + + private func resolveProxyLights( + from candidates: [LightPortalCandidate], + cameraPosition: simd_float3? + ) -> (proxyLights: [LightPortalProxyLight], diagnostics: LightPortalResolutionDiagnostics) { + let startTime = Date().timeIntervalSinceReferenceDate + var diagnostics = LightPortalResolutionDiagnostics.empty + diagnostics.discoveredCandidateCount = candidates.count + + defer { + updatePerformanceDiagnostics { performance in + performance.lastResolutionDurationMs = Self.elapsedMilliseconds(since: startTime) + performance.lastDiscoveredCandidateCount = diagnostics.discoveredCandidateCount + performance.lastResolvedProxyLightCount = diagnostics.activePortalCount + } + } + + var maxActivePortals = 0 + var activeChannelCapsByRawValue: [UInt64: Int] = [:] + var resolvedProxyLights: [LightPortalProxyLight] = [] + var channelRawValuesByEntityId: [EntityID: [UInt64]] = [:] + + for candidate in candidates { + guard case let .enabled( + intensity, + range, + useRealWorldTint, + candidateMaxActivePortals, + activationDistance + ) = candidate.mode else { + continue + } + + maxActivePortals = max(maxActivePortals, candidateMaxActivePortals) + let portalChannelRawValues = enabledLightPortalRawChannelValues(in: candidate.channels) + channelRawValuesByEntityId[candidate.entityId] = portalChannelRawValues + for rawValue in portalChannelRawValues { + let channel = SceneChannel(rawValue: rawValue) + if case let .enabled(_, _, _, channelMaxActivePortals, _) = getSceneChannelLightPortalMode(channel) { + activeChannelCapsByRawValue[rawValue] = channelMaxActivePortals + } + } + + let position = portalCenter(candidate) + let frame = portalFrame(candidate) + let distanceToCamera: Float + if let cameraPosition { + distanceToCamera = distanceToPortalRectangle(cameraPosition, center: position, frame: frame) + if distanceToCamera > activationDistance { + diagnostics.skippedByActivationDistanceCount += 1 + continue + } + } else { + distanceToCamera = 0.0 + } + + resolvedProxyLights.append( + LightPortalProxyLight( + sourceEntityId: candidate.entityId, + channels: candidate.channels, + position: position, + forward: frame.forward, + right: frame.right, + up: frame.up, + bounds: frame.bounds, + color: simd_float3(1.0, 1.0, 1.0), + intensity: intensity, + range: range, + distanceToCamera: distanceToCamera, + useRealWorldTint: useRealWorldTint + ) + ) + } + + resolvedProxyLights.sort { + if $0.distanceToCamera == $1.distanceToCamera { + return $0.sourceEntityId < $1.sourceEntityId + } + return $0.distanceToCamera < $1.distanceToCamera + } + + diagnostics.maxActivePortals = maxActivePortals + if maxActivePortals <= 0 { + diagnostics.activePortalCount = 0 + return ([], diagnostics) + } + + var selectedProxyLights: [LightPortalProxyLight] = [] + var activeCountsByRawValue: [UInt64: Int] = [:] + for proxyLight in resolvedProxyLights { + let rawValues = channelRawValuesByEntityId[proxyLight.sourceEntityId] ?? [] + let cappedRawValues = rawValues.filter { activeChannelCapsByRawValue[$0] != nil } + + if cappedRawValues.isEmpty { + if selectedProxyLights.count >= maxActivePortals { + continue + } + } else { + let hasRemainingChannelCapacity = cappedRawValues.allSatisfy { rawValue in + let cap = activeChannelCapsByRawValue[rawValue] ?? 0 + return (activeCountsByRawValue[rawValue] ?? 0) < cap + } + guard hasRemainingChannelCapacity else { continue } + } + + selectedProxyLights.append(proxyLight) + for rawValue in cappedRawValues { + activeCountsByRawValue[rawValue, default: 0] += 1 + } + } + diagnostics.activePortalCount = selectedProxyLights.count + return (selectedProxyLights, diagnostics) + } + + private static func elapsedMilliseconds(since startTime: TimeInterval) -> Double { + (Date().timeIntervalSinceReferenceDate - startTime) * 1000.0 + } + + private static func formattedMilliseconds(_ value: Double?) -> String { + guard let value else { return "n/a" } + return String(format: "%.3f", value) + } + + private static func formattedFloat(_ value: Float?) -> String { + guard let value else { return "n/a" } + return String(format: "%.3f", value) + } + + private func portalCenter(_ candidate: LightPortalCandidate) -> simd_float3 { + let localCenter = (candidate.localBoundsMin + candidate.localBoundsMax) * 0.5 + let worldCenter = simd_mul(candidate.worldTransform, simd_float4(localCenter, 1.0)) + return simd_float3(worldCenter.x, worldCenter.y, worldCenter.z) + } + + private struct PortalFrame { + var forward: simd_float3 + var right: simd_float3 + var up: simd_float3 + var bounds: simd_float2 + } + + private struct PortalAxis { + var index: Int + var localSize: Float + } + + private func portalFrame(_ candidate: LightPortalCandidate) -> PortalFrame { + let localSize = abs(candidate.localBoundsMax - candidate.localBoundsMin) + let axes = [ + PortalAxis(index: 0, localSize: localSize.x), + PortalAxis(index: 1, localSize: localSize.y), + PortalAxis(index: 2, localSize: localSize.z), + ].sorted { + if $0.localSize == $1.localSize { + return $0.index < $1.index + } + return $0.localSize > $1.localSize + } + + let rightAxis = axes[0].index + let upAxis = axes[1].index + let normalAxis = axes[2].index + + let rightWorld = worldAxis(candidate.worldTransform, axis: rightAxis) + let upWorld = worldAxis(candidate.worldTransform, axis: upAxis) + let forwardWorld = worldAxis(candidate.worldTransform, axis: normalAxis) + + return PortalFrame( + forward: normalizedAxis(forwardWorld, fallback: simd_float3(0.0, 0.0, 1.0)), + right: normalizedAxis(rightWorld, fallback: simd_float3(1.0, 0.0, 0.0)), + up: normalizedAxis(upWorld, fallback: simd_float3(0.0, 1.0, 0.0)), + bounds: simd_float2( + max(localSizeForAxis(localSize, axis: rightAxis) * simd_length(rightWorld), 0.001), + max(localSizeForAxis(localSize, axis: upAxis) * simd_length(upWorld), 0.001) + ) + ) + } + + private func isValidPortalGeometry(localBoundsMin: simd_float3, localBoundsMax: simd_float3) -> Bool { + let localSize = abs(localBoundsMax - localBoundsMin) + guard localSize.x.isFinite, localSize.y.isFinite, localSize.z.isFinite else { + return false + } + + let sortedSizes = [localSize.x, localSize.y, localSize.z].sorted(by: >) + guard sortedSizes[0] >= minimumPortalSurfaceDimension, + sortedSizes[1] >= minimumPortalSurfaceDimension + else { + return false + } + + let thickness = sortedSizes[2] + return thickness <= sortedSizes[1] * maximumPortalThicknessFraction + } + + private func enabledLightPortalRawChannelValues(in channels: SceneChannel) -> [UInt64] { + rawChannelValues(in: channels).filter { rawValue in + let channel = SceneChannel(rawValue: rawValue) + if case .enabled = getSceneChannelLightPortalMode(channel) { + return true + } + return false + } + } + + private func rawChannelValues(in channels: SceneChannel) -> [UInt64] { + var values: [UInt64] = [] + var remaining = channels.rawValue + while remaining != 0 { + let rawValue = remaining & (~remaining &+ 1) + values.append(rawValue) + remaining &= ~rawValue + } + return values + } + + private func distanceToPortalRectangle(_ point: simd_float3, center: simd_float3, frame: PortalFrame) -> Float { + let toPoint = point - center + let planeDistance = abs(simd_dot(toPoint, frame.forward)) + let rectangleDistance = abs(simd_float2(simd_dot(toPoint, frame.right), simd_dot(toPoint, frame.up))) - frame.bounds * 0.5 + let edgeDistance = simd_length(max(rectangleDistance, simd_float2.zero)) + return simd_length(simd_float2(planeDistance, edgeDistance)) + } + + private func worldAxis(_ transform: simd_float4x4, axis: Int) -> simd_float3 { + switch axis { + case 0: + return simd_float3(transform.columns.0.x, transform.columns.0.y, transform.columns.0.z) + case 1: + return simd_float3(transform.columns.1.x, transform.columns.1.y, transform.columns.1.z) + default: + return simd_float3(transform.columns.2.x, transform.columns.2.y, transform.columns.2.z) + } + } + + private func localSizeForAxis(_ localSize: simd_float3, axis: Int) -> Float { + switch axis { + case 0: + return localSize.x + case 1: + return localSize.y + default: + return localSize.z + } + } + + private func normalizedAxis(_ value: simd_float3, fallback: simd_float3) -> simd_float3 { + let lengthSquared = simd_length_squared(value) + guard lengthSquared > 0.000001, lengthSquared.isFinite else { + return fallback + } + return simd_normalize(value) + } +} + +public func discoverSceneLightPortalCandidates() -> [LightPortalCandidate] { + LightPortalSystem.shared.discoverCandidates() +} + +public func getLightPortalDiscoveryDiagnostics() -> LightPortalDiscoveryDiagnostics { + LightPortalSystem.shared.discoveryDiagnostics() +} + +public func resolveSceneLightPortalProxyLights(cameraPosition: simd_float3) -> [LightPortalProxyLight] { + LightPortalSystem.shared.resolveProxyLights(cameraPosition: cameraPosition) +} + +public func resolveSceneLightPortalProxyLightsForActiveCamera() -> [LightPortalProxyLight] { + LightPortalSystem.shared.resolveProxyLightsForActiveCamera() +} + +public func getLightPortalResolutionDiagnostics() -> LightPortalResolutionDiagnostics { + LightPortalSystem.shared.resolutionDiagnostics() +} + +public func getLightPortalPerformanceDiagnostics() -> LightPortalPerformanceDiagnostics { + LightPortalSystem.shared.performanceDiagnostics() +} diff --git a/Sources/UntoldEngine/Systems/LightingSystem.swift b/Sources/UntoldEngine/Systems/LightingSystem.swift index 3b0564ca..7f1dd292 100644 --- a/Sources/UntoldEngine/Systems/LightingSystem.swift +++ b/Sources/UntoldEngine/Systems/LightingSystem.swift @@ -18,6 +18,10 @@ public final class LightingSystem: @unchecked Sendable { private let activeDirectionalLightLock = NSLock() private var _activeDirectionalLight: EntityID? + private let lightPortalRenderDiagnosticsLock = NSLock() + private var latestLightPortalRenderDiagnostics = LightPortalRenderDiagnostics.empty + private let lightPortalAreaLightCacheLock = NSLock() + private var lightPortalAreaLightCache: LightPortalAreaLightCache? private init() {} @@ -33,6 +37,63 @@ public final class LightingSystem: @unchecked Sendable { activeDirectionalLightLock.unlock() } } + + fileprivate func setLightPortalRenderDiagnostics(_ diagnostics: LightPortalRenderDiagnostics) { + lightPortalRenderDiagnosticsLock.lock() + latestLightPortalRenderDiagnostics = diagnostics + lightPortalRenderDiagnosticsLock.unlock() + } + + fileprivate func getLightPortalRenderDiagnostics() -> LightPortalRenderDiagnostics { + lightPortalRenderDiagnosticsLock.lock() + let diagnostics = latestLightPortalRenderDiagnostics + lightPortalRenderDiagnosticsLock.unlock() + return diagnostics + } + + fileprivate func cachedLightPortalAreaLights( + frameIndex: Int, + authoredAreaLightCount: Int, + remainingAreaLightCapacity: Int + ) -> (areaLights: [AreaLight], diagnostics: LightPortalRenderDiagnostics)? { + lightPortalAreaLightCacheLock.lock() + let cache = lightPortalAreaLightCache + lightPortalAreaLightCacheLock.unlock() + + guard cache?.frameIndex == frameIndex, + cache?.authoredAreaLightCount == authoredAreaLightCount, + cache?.remainingAreaLightCapacity == remainingAreaLightCapacity, + let cache + else { + return nil + } + + return (cache.areaLights, cache.diagnostics) + } + + fileprivate func setCachedLightPortalAreaLights( + frameIndex: Int, + authoredAreaLightCount: Int, + remainingAreaLightCapacity: Int, + areaLights: [AreaLight], + diagnostics: LightPortalRenderDiagnostics + ) { + lightPortalAreaLightCacheLock.lock() + lightPortalAreaLightCache = LightPortalAreaLightCache( + frameIndex: frameIndex, + authoredAreaLightCount: authoredAreaLightCount, + remainingAreaLightCapacity: remainingAreaLightCapacity, + areaLights: areaLights, + diagnostics: diagnostics + ) + lightPortalAreaLightCacheLock.unlock() + } + + fileprivate func resetLightPortalAreaLightCache() { + lightPortalAreaLightCacheLock.lock() + lightPortalAreaLightCache = nil + lightPortalAreaLightCacheLock.unlock() + } } public struct DirectionalLight { @@ -67,9 +128,73 @@ public struct AreaLight { var up: simd_float3 = .init(0.0, 1.0, 0.0) // Up vector defining the surface orientation var bounds: simd_float2 = .one var intensity: Float = 1.0 // Light intensity + var range: Float = 0.0 // Maximum influence distance; 0 keeps legacy unlimited area-light behavior + var nearSourceSuppressionRadius: Float = 0.0 // Radius near the light surface that fades contribution; 0 disables var twoSided: Bool = false // Whether the light emits from both sides } +private enum LightPortalLightingTuning { + static let suppressionFractionOfMinDimension: Float = 0.2 + static let minSuppressionRadius: Float = 0.15 + static let maxSuppressionRadius: Float = 0.5 +} + +private struct LightPortalAreaLightCache { + var frameIndex: Int + var authoredAreaLightCount: Int + var remainingAreaLightCapacity: Int + var areaLights: [AreaLight] + var diagnostics: LightPortalRenderDiagnostics +} + +public struct LightPortalRenderDiagnostics: Equatable, Sendable { + public var authoredAreaLightCount: Int + public var portalAreaLightCount: Int + public var remainingAreaLightCapacity: Int + public var environmentIntensityScale: Float + public var xrIntensityScale: Float? + public var environmentTintColor: simd_float3 + public var realWorldLightingContribution: Float + public var xrLightingValid: Bool + public var minEffectivePortalIntensity: Float? + public var maxEffectivePortalIntensity: Float? + public var averageEffectivePortalIntensity: Float? + public var totalEffectivePortalIntensity: Float + public var fallbackReason: String? + + public static let empty = LightPortalRenderDiagnostics( + authoredAreaLightCount: 0, + portalAreaLightCount: 0, + remainingAreaLightCapacity: 0, + environmentIntensityScale: 1.0, + xrIntensityScale: nil, + environmentTintColor: simd_float3(1.0, 1.0, 1.0), + realWorldLightingContribution: 1.0, + xrLightingValid: false, + minEffectivePortalIntensity: nil, + maxEffectivePortalIntensity: nil, + averageEffectivePortalIntensity: nil, + totalEffectivePortalIntensity: 0.0, + fallbackReason: nil + ) +} + +private func setLightPortalRenderDiagnostics(_ diagnostics: LightPortalRenderDiagnostics) { + LightingSystem.shared.setLightPortalRenderDiagnostics(diagnostics) +} + +public func getLightPortalRenderDiagnostics() -> LightPortalRenderDiagnostics { + LightingSystem.shared.getLightPortalRenderDiagnostics() +} + +public func resetLightPortalRenderDiagnostics() { + setLightPortalRenderDiagnostics(.empty) +} + +public func resetLightPortalAreaLightCache() { + LightingSystem.shared.resetLightPortalAreaLightCache() +} + private func applyDefaultLightOrientation(entityId: EntityID) { // Engine light emission is defined as local -Z transformed into world space. // Rotate identity lights so the default emission direction points along -Y. @@ -759,9 +884,179 @@ func getAreaLights() -> [AreaLight] { areaLights.append(areaLight) } + appendLightPortalAreaLights(to: &areaLights) return areaLights } +private func appendLightPortalAreaLights(to areaLights: inout [AreaLight]) { + let authoredAreaLightCount = areaLights.count + let remainingCapacity = max(maxAreaLights - areaLights.count, 0) + let frameIndex = cullFrameIndex + guard remainingCapacity > 0 else { + let diagnostics = inactiveLightPortalRenderDiagnostics( + authoredAreaLightCount: authoredAreaLightCount, + remainingAreaLightCapacity: 0, + fallbackReason: "No remaining area-light capacity" + ) + setLightPortalRenderDiagnostics(diagnostics) + return + } + guard hasSceneChannelLightPortalsEnabled() else { + setLightPortalRenderDiagnostics(.empty) + return + } + + if let cached = LightingSystem.shared.cachedLightPortalAreaLights( + frameIndex: frameIndex, + authoredAreaLightCount: authoredAreaLightCount, + remainingAreaLightCapacity: remainingCapacity + ) { + areaLights.append(contentsOf: cached.areaLights) + setLightPortalRenderDiagnostics(cached.diagnostics) + return + } + + let proxyLights = resolveSceneLightPortalProxyLightsForActiveCamera() + guard proxyLights.isEmpty == false else { + let diagnostics = inactiveLightPortalRenderDiagnostics( + authoredAreaLightCount: authoredAreaLightCount, + remainingAreaLightCapacity: remainingCapacity, + fallbackReason: "No active portal proxy lights" + ) + LightingSystem.shared.setCachedLightPortalAreaLights( + frameIndex: frameIndex, + authoredAreaLightCount: authoredAreaLightCount, + remainingAreaLightCapacity: remainingCapacity, + areaLights: [], + diagnostics: diagnostics + ) + setLightPortalRenderDiagnostics(diagnostics) + return + } + + let environment = lightPortalEnvironmentIntensityScale() + var effectiveIntensities: [Float] = [] + var portalAreaLights: [AreaLight] = [] + for proxyLight in proxyLights.prefix(remainingCapacity) { + let effectiveIntensity = proxyLight.intensity * (proxyLight.useRealWorldTint ? environment.scale : 1.0) + var areaLight = AreaLight() + areaLight.position = proxyLight.position + areaLight.color = proxyLight.useRealWorldTint ? environment.tintColor : proxyLight.color + areaLight.intensity = effectiveIntensity + areaLight.range = proxyLight.range + areaLight.forward = proxyLight.forward + areaLight.right = proxyLight.right + areaLight.up = proxyLight.up + areaLight.bounds = proxyLight.bounds + areaLight.twoSided = true + areaLight.nearSourceSuppressionRadius = lightPortalNearSourceSuppressionRadius(for: proxyLight) + portalAreaLights.append(areaLight) + effectiveIntensities.append(effectiveIntensity) + } + + let totalEffectiveIntensity = effectiveIntensities.reduce(0.0, +) + let diagnostics = LightPortalRenderDiagnostics( + authoredAreaLightCount: authoredAreaLightCount, + portalAreaLightCount: effectiveIntensities.count, + remainingAreaLightCapacity: remainingCapacity, + environmentIntensityScale: environment.scale, + xrIntensityScale: environment.xrIntensityScale, + environmentTintColor: environment.tintColor, + realWorldLightingContribution: environment.realWorldLightingContribution, + xrLightingValid: environment.xrLightingValid, + minEffectivePortalIntensity: effectiveIntensities.min(), + maxEffectivePortalIntensity: effectiveIntensities.max(), + averageEffectivePortalIntensity: effectiveIntensities.isEmpty ? nil : totalEffectiveIntensity / Float(effectiveIntensities.count), + totalEffectivePortalIntensity: totalEffectiveIntensity, + fallbackReason: environment.fallbackReason + ) + LightingSystem.shared.setCachedLightPortalAreaLights( + frameIndex: frameIndex, + authoredAreaLightCount: authoredAreaLightCount, + remainingAreaLightCapacity: remainingCapacity, + areaLights: portalAreaLights, + diagnostics: diagnostics + ) + areaLights.append(contentsOf: portalAreaLights) + setLightPortalRenderDiagnostics(diagnostics) +} + +private func inactiveLightPortalRenderDiagnostics( + authoredAreaLightCount: Int, + remainingAreaLightCapacity: Int, + fallbackReason: String +) -> LightPortalRenderDiagnostics { + LightPortalRenderDiagnostics( + authoredAreaLightCount: authoredAreaLightCount, + portalAreaLightCount: 0, + remainingAreaLightCapacity: remainingAreaLightCapacity, + environmentIntensityScale: 1.0, + xrIntensityScale: nil, + environmentTintColor: simd_float3(1.0, 1.0, 1.0), + realWorldLightingContribution: RuntimeEnvironmentLightingStore.shared.realWorldLightingContribution, + xrLightingValid: false, + minEffectivePortalIntensity: nil, + maxEffectivePortalIntensity: nil, + averageEffectivePortalIntensity: nil, + totalEffectivePortalIntensity: 0.0, + fallbackReason: fallbackReason + ) +} + +private func lightPortalNearSourceSuppressionRadius(for proxyLight: LightPortalProxyLight) -> Float { + let minDimension = max(min(proxyLight.bounds.x, proxyLight.bounds.y), 0.001) + return min( + max( + minDimension * LightPortalLightingTuning.suppressionFractionOfMinDimension, + LightPortalLightingTuning.minSuppressionRadius + ), + LightPortalLightingTuning.maxSuppressionRadius + ) +} + +private func lightPortalEnvironmentIntensityScale() -> ( + scale: Float, + xrIntensityScale: Float?, + tintColor: simd_float3, + realWorldLightingContribution: Float, + xrLightingValid: Bool, + fallbackReason: String? +) { + let realWorldLightingContribution = RuntimeEnvironmentLightingStore.shared.realWorldLightingContribution + switch RuntimeEnvironmentLightingStore.shared.mode { + case .realWorldEstimate: + guard let xrLighting = RuntimeEnvironmentLightingStore.shared.latestXRLighting(), + xrLighting.isValid + else { + return ( + scale: 0.0, + xrIntensityScale: nil, + tintColor: simd_float3(1.0, 1.0, 1.0), + realWorldLightingContribution: realWorldLightingContribution, + xrLightingValid: false, + fallbackReason: "XR lighting unavailable for real-world portal tint" + ) + } + return ( + scale: xrLighting.intensityScale * realWorldLightingContribution, + xrIntensityScale: xrLighting.intensityScale, + tintColor: xrLighting.tintColor, + realWorldLightingContribution: realWorldLightingContribution, + xrLightingValid: true, + fallbackReason: nil + ) + case .authoredOnly, .staticIBL: + return ( + scale: 1.0, + xrIntensityScale: nil, + tintColor: simd_float3(1.0, 1.0, 1.0), + realWorldLightingContribution: realWorldLightingContribution, + xrLightingValid: false, + fallbackReason: "Runtime environment mode is not XR real-world lighting" + ) + } +} + func getAreaLightCount() -> Int { let lightComponentID = getComponentId(for: AreaLightComponent.self) diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index 9817210f..9a6a2f48 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -941,15 +941,17 @@ private func registerUntoldRuntimeAsset( } // Register animation clips embedded in the asset (e.g. redplayer.untold walk/run cycles). - // Resolve to the skinned descendant if the root is a container (skeleton hierarchy). + // Resolve to every skinned descendant so split characters animate all meshes. let animClips = runtimeAsset.animationClips if !animClips.isEmpty { - let animTarget = resolveEntityForAnimationBinding(entityId: entityId) ?? entityId - if let animComp = ensureAnimationComponent(entityId: animTarget, errorEntityId: entityId) { - let registeredNames = registerRuntimeAnimationClips(animClips, preferredName: animClips.first?.name ?? "", to: animComp) - appendAnimationSourceURLIfNeeded(url, to: animComp) - if animComp.currentAnimation == nil, let first = registeredNames.first { - animComp.currentAnimation = animComp.animationClips[first] + let animTargets = resolveAnimationBindingTargetEntities(entityId: entityId) + for animTarget in animTargets { + if let animComp = ensureAnimationComponent(entityId: animTarget, errorEntityId: entityId) { + let registeredNames = registerRuntimeAnimationClips(animClips, preferredName: animClips.first?.name ?? "", to: animComp) + appendAnimationSourceURLIfNeeded(url, to: animComp) + if animComp.currentAnimation == nil, let first = registeredNames.first { + animComp.currentAnimation = animComp.animationClips[first] + } } } } @@ -2448,6 +2450,7 @@ func removeEntityMesh(entityId: EntityID) { renderComponent.cleanUp() scene.remove(component: RenderComponent.self, from: entityId) removedAnyResourceOwner = true + resetLightPortalAreaLightCache() } // deassocate entity to mesh @@ -2470,8 +2473,8 @@ func removeEntityMesh(entityId: EntityID) { } public func setEntityAnimations(entityId: EntityID, filename: String, withExtension: String, name: String) { - let targetEntityId = resolveEntityForAnimationBinding(entityId: entityId) ?? entityId - guard scene.get(component: SkeletonComponent.self, for: targetEntityId) != nil else { + let targetEntityIds = resolveAnimationBindingTargetEntities(entityId: entityId) + guard targetEntityIds.contains(where: { scene.get(component: SkeletonComponent.self, for: $0) != nil }) else { handleError(.noSkeletonComponent, entityId) return } @@ -2494,17 +2497,23 @@ public func setEntityAnimations(entityId: EntityID, filename: String, withExtens } withWorldMutationGate { - guard let animationComponent = ensureAnimationComponent(entityId: targetEntityId, errorEntityId: entityId) else { - return - } + for targetEntityId in targetEntityIds { + guard scene.get(component: SkeletonComponent.self, for: targetEntityId) != nil else { + continue + } - let registeredNames = registerRuntimeAnimationClips(runtimeClips, preferredName: name, to: animationComponent) - appendAnimationSourceURLIfNeeded(url, to: animationComponent) + guard let animationComponent = ensureAnimationComponent(entityId: targetEntityId, errorEntityId: entityId) else { + continue + } - if animationComponent.currentAnimation == nil, - let selectedName = registeredNames.first(where: { $0 == name }) ?? registeredNames.first - { - animationComponent.currentAnimation = animationComponent.animationClips[selectedName] + let registeredNames = registerRuntimeAnimationClips(runtimeClips, preferredName: name, to: animationComponent) + appendAnimationSourceURLIfNeeded(url, to: animationComponent) + + if animationComponent.currentAnimation == nil, + let selectedName = registeredNames.first(where: { $0 == name }) ?? registeredNames.first + { + animationComponent.currentAnimation = animationComponent.animationClips[selectedName] + } } } return @@ -2671,6 +2680,7 @@ func registerRenderComponent(entityId: EntityID, meshes: [Mesh], url: URL, asset renderComponent.assetName = assetName renderComponent.assetURL = url entityMeshMap[entityId] = resolvedMeshes + resetLightPortalAreaLightCache() let entityName = getEntityName(entityId: entityId) let channelSourceName = entityName.isEmpty ? assetName : entityName setDefaultEntitySceneChannels(entityId: entityId, channels: defaultSceneChannels(forName: channelSourceName)) diff --git a/Sources/UntoldEngine/Systems/SpatialManipulationSystem.swift b/Sources/UntoldEngine/Systems/SpatialManipulationSystem.swift index 98767c10..0462ebe1 100644 --- a/Sources/UntoldEngine/Systems/SpatialManipulationSystem.swift +++ b/Sources/UntoldEngine/Systems/SpatialManipulationSystem.swift @@ -12,6 +12,13 @@ import Foundation import simd + public enum SpatialDragPlane: Sendable, Equatable { + case unconstrained + case xy + case xz + case yz + } + private struct SpatialTranslationSession { var entityId: EntityID var planePoint: simd_float3 @@ -53,6 +60,7 @@ var entityId: EntityID var initialInputDevicePositionWorld: simd_float3 var initialEntityWorldPosition: simd_float3 + var dragPlane: SpatialDragPlane } private struct AnchoredSceneDragSession { @@ -319,7 +327,13 @@ /// /// Call this each frame from your input loop. /// It captures the initial hand + entity world positions and applies absolute displacement. - public func processAnchoredPinchDragLifecycle(from state: XRSpatialInputState, entityId: EntityID? = nil, sensitivity: Float = 1.0) { + public func processAnchoredPinchDragLifecycle( + from state: XRSpatialInputState, + entityId: EntityID? = nil, + sensitivity: Float = 1.0, + dragPlane: SpatialDragPlane = .unconstrained, + positionTransform: ((simd_float3) -> simd_float3)? = nil + ) { if state.currentPhase == .ended || state.currentPhase == .cancelled { endAnchoredPinchDrag() return @@ -333,7 +347,7 @@ } if anchoredPinchDragSession == nil { - beginAnchoredPinchDragIfNeeded(from: state, entityId: entityId) + beginAnchoredPinchDragIfNeeded(from: state, entityId: entityId, dragPlane: dragPlane) } guard state.spatialDragActive, @@ -354,15 +368,27 @@ else { return } let clampedSensitivity = max(sensitivity, 0) - let delta = (currentInputDevicePosition - session.initialInputDevicePositionWorld) * clampedSensitivity + let delta = projectedDragDelta( + (currentInputDevicePosition - session.initialInputDevicePositionWorld) * clampedSensitivity, + onto: session.dragPlane + ) guard delta.x.isFinite, delta.y.isFinite, delta.z.isFinite else { return } - let targetWorldPosition = session.initialEntityWorldPosition + delta + let rawTargetWorldPosition = session.initialEntityWorldPosition + delta + let targetWorldPosition = positionTransform?(rawTargetWorldPosition) ?? rawTargetWorldPosition + guard targetWorldPosition.x.isFinite, + targetWorldPosition.y.isFinite, + targetWorldPosition.z.isFinite + else { return } let targetLocalPosition = worldPositionToLocal(entityId: session.entityId, worldPosition: targetWorldPosition) translateTo(entityId: session.entityId, position: targetLocalPosition) } - public func beginAnchoredPinchDragIfNeeded(from state: XRSpatialInputState, entityId: EntityID? = nil) { + public func beginAnchoredPinchDragIfNeeded( + from state: XRSpatialInputState, + entityId: EntityID? = nil, + dragPlane: SpatialDragPlane = .unconstrained + ) { guard anchoredPinchDragSession == nil else { return } guard let target = resolveManipulationTarget(explicitEntityId: entityId, state: state), @@ -382,7 +408,8 @@ anchoredPinchDragSession = AnchoredPinchDragSession( entityId: target, initialInputDevicePositionWorld: initialInputDevicePosition, - initialEntityWorldPosition: getPosition(entityId: target) + initialEntityWorldPosition: getPosition(entityId: target), + dragPlane: dragPlane ) } @@ -390,6 +417,19 @@ anchoredPinchDragSession = nil } + private func projectedDragDelta(_ delta: simd_float3, onto dragPlane: SpatialDragPlane) -> simd_float3 { + switch dragPlane { + case .unconstrained: + return delta + case .xy: + return simd_float3(delta.x, delta.y, 0) + case .xz: + return simd_float3(delta.x, 0, delta.z) + case .yz: + return simd_float3(0, delta.y, delta.z) + } + } + // MARK: - Anchored Scene Drag /// Session-based anchored drag that translates the entire scene root. diff --git a/Sources/UntoldEngine/Systems/TransformSystem.swift b/Sources/UntoldEngine/Systems/TransformSystem.swift index 4f7dd53d..9fd357fb 100644 --- a/Sources/UntoldEngine/Systems/TransformSystem.swift +++ b/Sources/UntoldEngine/Systems/TransformSystem.swift @@ -22,6 +22,8 @@ private func syncCameraTransformIfNeeded(entityId: EntityID, localTransformCompo } func syncWorldTransformAndMarkOctreeDirty(entityId: EntityID) { + resetLightPortalAreaLightCache() + // Keep world transforms and octree bounds current for the whole hierarchy. // Imported multi-mesh assets usually have renderable children under a non-render root. guard scene.get(component: ScenegraphComponent.self, for: entityId) != nil else { diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air index b875e1c6..02c7744b 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 317a013e..b1cd9eb9 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 7edd29d6..c8fd5a41 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 00b7a77a..ce68df5b 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 abcba155..96336145 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 3aabb006..23895dc2 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 a74f74a4..38721866 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 046dd04e..9c8adbb2 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 1d5ced19..5a82aa64 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 9066ac7c..e9ea6cf5 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 851da72f..7fda7833 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 42f13b85..de6d4f94 100644 Binary files a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib and b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib differ diff --git a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift index d2ac5968..92080fee 100644 --- a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift +++ b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift @@ -67,6 +67,8 @@ public enum RenderingToggle: Sendable { public enum RenderingEnvironmentProperty: Sendable { case ibl(Bool) case visible(Bool) + case lightingMode(RuntimeEnvironmentLightingMode) + case realWorldLightingContribution(Float) } public enum WireframeProperty: Sendable { @@ -98,6 +100,10 @@ private func applyRenderingEnvironmentProperty(_ property: RenderingEnvironmentP applyIBL = value case let .visible(value): renderEnvironment = value + case let .lightingMode(value): + RuntimeEnvironmentLightingStore.shared.mode = value + case let .realWorldLightingContribution(value): + RuntimeEnvironmentLightingStore.shared.realWorldLightingContribution = value } } diff --git a/Sources/UntoldEngine/Utils/FuncUtils.swift b/Sources/UntoldEngine/Utils/FuncUtils.swift index a8e8c1fa..5c4c9453 100644 --- a/Sources/UntoldEngine/Utils/FuncUtils.swift +++ b/Sources/UntoldEngine/Utils/FuncUtils.swift @@ -953,7 +953,26 @@ public func getMaterialEmmissive(entityId: EntityID, meshIndex: Int = 0, submesh getMaterial(entityId: entityId, meshIndex: meshIndex, submeshIndex: submeshIndex)?.emissiveValue ?? .zero } -public func updateMaterialEmmisive(entityId: EntityID, emmissive: simd_float3, meshIndex: Int = 0, submeshIndex: Int = 0) { +public func updateMaterialEmmisive( + entityId: EntityID, + emmissive: simd_float3, + recursive: Bool = false, + meshIndex: Int = 0, + submeshIndex: Int = 0 +) { + if recursive { + for targetEntityId in entityAndDescendantIds(entityId) { + updateMaterialEmmisive( + entityId: targetEntityId, + emmissive: emmissive, + recursive: false, + meshIndex: meshIndex, + submeshIndex: submeshIndex + ) + } + return + } + guard updateMaterial(entityId: entityId, meshIndex: meshIndex, submeshIndex: submeshIndex, mutate: { $0.emissiveValue = emmissive }) else { return } @@ -990,8 +1009,21 @@ public func getMaterialOpacity(entityId: EntityID, meshIndex: Int = 0, submeshIn public func updateMaterialOpacity( entityId: EntityID, opacity: Float, - applyToAllSubmeshes: Bool = true + applyToAllSubmeshes: Bool = true, + recursive: Bool = false ) { + if recursive { + for targetEntityId in entityAndDescendantIds(entityId) { + updateMaterialOpacity( + entityId: targetEntityId, + opacity: opacity, + applyToAllSubmeshes: applyToAllSubmeshes, + recursive: false + ) + } + return + } + let clampedOpacity = max(0.0, min(1.0, opacity)) guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { return @@ -1048,6 +1080,27 @@ public func updateMaterialOpacity( refreshStaticBatchingForMaterialChange(entityId: entityId) } +private func entityAndDescendantIds(_ entityId: EntityID) -> [EntityID] { + var entityIds: [EntityID] = [entityId] + var visited: Set = [entityId] + var index = 0 + + while index < entityIds.count { + let currentEntityId = entityIds[index] + index += 1 + + guard let scenegraphComponent = scene.get(component: ScenegraphComponent.self, for: currentEntityId) else { + continue + } + + for childId in scenegraphComponent.children where visited.insert(childId).inserted { + entityIds.append(childId) + } + } + + return entityIds +} + func makeFloat4Texture(data: [simd_float4], width: Int, height: Int) -> MTLTexture? diff --git a/Sources/UntoldEngine/Utils/InputSystemAPI.swift b/Sources/UntoldEngine/Utils/InputSystemAPI.swift new file mode 100644 index 00000000..ea28c9d2 --- /dev/null +++ b/Sources/UntoldEngine/Utils/InputSystemAPI.swift @@ -0,0 +1,221 @@ +// +// InputSystemAPI.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 + +// MARK: - Input Config + +public enum XRInputProperty: Sendable { + case pickingBackend(ScenePickingBackendPreference) + case twoHandRotateAxisMode(XRSpatialTwoHandRotateAxisMode) + case sceneReady(Bool) +} + +public enum InputProperty: Sendable { + case xr(XRInputProperty) +} + +public func setInput(_ property: InputProperty) { + switch property { + case let .xr(xrProperty): + applyXRInputProperty(xrProperty) + } +} + +private func applyXRInputProperty(_ property: XRInputProperty) { + switch property { + case let .pickingBackend(preference): + InputSystem.shared.setXRSpatialPickingBackendPreference(preference) + case let .twoHandRotateAxisMode(mode): + InputSystem.shared.setXRTwoHandRotateAxisMode(mode) + case let .sceneReady(ready): + InputSystem.shared.setXRSceneReady(ready) + } +} + +// MARK: - Input Actions + +public func registerXREvents() { + InputSystem.shared.registerXREvents() +} + +public func unregisterXREvents() { + InputSystem.shared.unregisterXREvents() +} + +// MARK: - Input Queries + +public func getXRSpatialInputState() -> XRSpatialInputState { + InputSystem.shared.xrSpatialInputState +} + +public func isXRSceneReady() -> Bool { + InputSystem.shared.isXRSceneReady() +} + +// MARK: - Spatial Manipulation Config (visionOS) + +#if os(visionOS) + + public enum SpatialManipulationProperty: Sendable { + case inputEpsilon(Float) + case intentTranslationThreshold(Float) + case intentRotationThreshold(Float) + case intentDominanceRatio(Float) + case zoomScale(min: Float, max: Float) + case rotationDeltaLimit(perFrame: Float, twoHand: Float) + case twoHandRotationDeadzone(Float) + case rotationSmoothing(factor: Float, deadzone: Float) + case classificationFrames(Int) + } + + public func setSpatialManipulation(_ property: SpatialManipulationProperty) { + let system = SpatialManipulationSystem.shared + switch property { + case let .inputEpsilon(value): + system.inputEpsilon = max(value, 0) + case let .intentTranslationThreshold(value): + system.intentTranslationThresholdMeters = max(value, 0) + case let .intentRotationThreshold(value): + system.intentRotationThresholdRadians = max(value, 0) + case let .intentDominanceRatio(value): + system.intentDominanceRatio = max(value, 1) + case let .zoomScale(minScale, maxScale): + system.minZoomScale = min(minScale, maxScale) + system.maxZoomScale = max(minScale, maxScale) + case let .rotationDeltaLimit(perFrame, twoHand): + system.maxRotationDeltaPerFrameRadians = max(perFrame, 0) + system.maxTwoHandRotationDeltaPerFrameRadians = max(twoHand, 0) + case let .twoHandRotationDeadzone(value): + system.twoHandRotationDeadzoneRadians = max(value, 0) + case let .rotationSmoothing(factor, deadzone): + system.rotationDeltaSmoothingFactor = min(max(factor, 0), 1) + system.rotationDeltaDeadzoneRadians = max(deadzone, 0) + case let .classificationFrames(value): + system.manipulationClassificationFrames = max(value, 0) + } + } + + // MARK: - Spatial Manipulation Lifecycle + + public func processPinchTransformLifecycle(from state: XRSpatialInputState) { + SpatialManipulationSystem.shared.processPinchTransformLifecycle(from: state) + } + + public func applyPinchDragIfNeeded( + from state: XRSpatialInputState, + entityId: EntityID? = nil, + sensitivity: Float = 1.0 + ) { + SpatialManipulationSystem.shared.applyPinchDragIfNeeded(from: state, entityId: entityId, sensitivity: sensitivity) + } + + public func processAnchoredPinchDragLifecycle( + from state: XRSpatialInputState, + entityId: EntityID? = nil, + sensitivity: Float = 1.0, + dragPlane: SpatialDragPlane = .unconstrained, + positionTransform: ((simd_float3) -> simd_float3)? = nil + ) { + SpatialManipulationSystem.shared.processAnchoredPinchDragLifecycle( + from: state, + entityId: entityId, + sensitivity: sensitivity, + dragPlane: dragPlane, + positionTransform: positionTransform + ) + } + + public func processAnchoredSceneDragLifecycle(from state: XRSpatialInputState, sensitivity: Float = 1.0) { + SpatialManipulationSystem.shared.processAnchoredSceneDragLifecycle(from: state, sensitivity: sensitivity) + } + + public func processAnchoredSceneManipulationLifecycle( + from state: XRSpatialInputState, + dragSensitivity: Float = 1.0, + rotateSensitivity: Float = 1.0 + ) { + SpatialManipulationSystem.shared.processAnchoredSceneManipulationLifecycle( + from: state, + dragSensitivity: dragSensitivity, + rotateSensitivity: rotateSensitivity + ) + } + + public func processAnchoredSceneRotateLifecycle(from state: XRSpatialInputState, sensitivity: Float = 1.0) { + SpatialManipulationSystem.shared.processAnchoredSceneRotateLifecycle(from: state, sensitivity: sensitivity) + } + + public func applyTwoHandZoomIfNeeded( + from state: XRSpatialInputState, + entityId: EntityID? = nil, + sensitivity: Float = 1.0 + ) { + SpatialManipulationSystem.shared.applyTwoHandZoomIfNeeded(from: state, entityId: entityId, sensitivity: sensitivity) + } + + public func applyTwoHandRotateIfNeeded( + from state: XRSpatialInputState, + entityId: EntityID? = nil, + sensitivity: Float = 1.0, + axisOverrideWorld: simd_float3? = nil + ) { + SpatialManipulationSystem.shared.applyTwoHandRotateIfNeeded( + from: state, + entityId: entityId, + sensitivity: sensitivity, + axisOverrideWorld: axisOverrideWorld + ) + } + + // MARK: - Spatial Manipulation Session Control + + public func resetSpatialManipulation() { + SpatialManipulationSystem.shared.reset() + } + + public func endSpatialManipulation() { + SpatialManipulationSystem.shared.endSpatialManipulation() + } + + public func beginAnchoredPinchDragIfNeeded( + from state: XRSpatialInputState, + entityId: EntityID? = nil, + dragPlane: SpatialDragPlane = .unconstrained + ) { + SpatialManipulationSystem.shared.beginAnchoredPinchDragIfNeeded( + from: state, + entityId: entityId, + dragPlane: dragPlane + ) + } + + public func endAnchoredPinchDrag() { + SpatialManipulationSystem.shared.endAnchoredPinchDrag() + } + + public func beginAnchoredSceneDragIfNeeded(from state: XRSpatialInputState) { + SpatialManipulationSystem.shared.beginAnchoredSceneDragIfNeeded(from: state) + } + + public func endAnchoredSceneDrag() { + SpatialManipulationSystem.shared.endAnchoredSceneDrag() + } + + public func endAnchoredSceneManipulation() { + SpatialManipulationSystem.shared.endAnchoredSceneManipulation() + } + + public func endAnchoredSceneRotate() { + SpatialManipulationSystem.shared.endAnchoredSceneRotate() + } + +#endif diff --git a/Sources/UntoldEngine/Utils/LightingSystemAPI.swift b/Sources/UntoldEngine/Utils/LightingSystemAPI.swift new file mode 100644 index 00000000..37485f23 --- /dev/null +++ b/Sources/UntoldEngine/Utils/LightingSystemAPI.swift @@ -0,0 +1,113 @@ +// +// LightingSystemAPI.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 + +// MARK: - Light sub-domain enums + +public enum DirectionalLightSetting: Sendable { + case active +} + +public enum PointLightProperty: Sendable { + case radius(Float) + case falloff(Float) + case attenuation(simd_float3) +} + +public enum SpotLightProperty: Sendable { + case coneAngle(Float) + case falloff(Float) + case radius(Float) + case attenuation(simd_float3) +} + +public enum AreaLightProperty: Sendable { + case twoSided(Bool) +} + +// MARK: - Top-level light property enum + +public enum LightEntityProperty: Sendable { + case color(simd_float3) + case intensity(Float) + case directional(DirectionalLightSetting) + case point(PointLightProperty) + case spot(SpotLightProperty) + case area(AreaLightProperty) +} + +// MARK: - Facade + +public func setLight(entityId: EntityID, _ property: LightEntityProperty) { + switch property { + case let .color(value): + updateLightColor(entityId: entityId, color: value) + case let .intensity(value): + updateLightIntensity(entityId: entityId, intensity: value) + case let .directional(setting): + applyDirectionalLightSetting(entityId: entityId, setting) + case let .point(pointProperty): + applyPointLightProperty(entityId: entityId, pointProperty) + case let .spot(spotProperty): + applySpotLightProperty(entityId: entityId, spotProperty) + case let .area(areaProperty): + applyAreaLightProperty(entityId: entityId, areaProperty) + } +} + +// MARK: - Private applicators + +private func applyDirectionalLightSetting(entityId: EntityID, _ setting: DirectionalLightSetting) { + switch setting { + case .active: + guard hasComponent(entityId: entityId, componentType: DirectionalLightComponent.self) else { + Logger.logWarning(message: "[LightingSystem] Cannot set active directional light. Entity \(entityId) has no DirectionalLightComponent.") + return + } + LightingSystem.shared.activeDirectionalLight = entityId + } +} + +private func applyPointLightProperty(entityId: EntityID, _ property: PointLightProperty) { + switch property { + case let .radius(value): + updateLightRadius(entityId: entityId, radius: value) + case let .falloff(value): + updateLightFalloff(entityId: entityId, falloff: value) + case let .attenuation(value): + updateLightAttenuation(entityId: entityId, attenuation: value) + } +} + +private func applySpotLightProperty(entityId: EntityID, _ property: SpotLightProperty) { + switch property { + case let .coneAngle(value): + updateLightConeAngle(entityId: entityId, coneAngle: value) + case let .falloff(value): + updateLightFalloff(entityId: entityId, falloff: value) + case let .radius(value): + updateLightRadius(entityId: entityId, radius: value) + case let .attenuation(value): + updateLightAttenuation(entityId: entityId, attenuation: value) + } +} + +private func applyAreaLightProperty(entityId: EntityID, _ property: AreaLightProperty) { + switch property { + case let .twoSided(value): + guard let areaLightComponent = scene.get(component: AreaLightComponent.self, for: entityId) else { + handleError(.noAreaLightComponent) + return + } + areaLightComponent.twoSided = value + } +} diff --git a/Sources/UntoldEngine/Utils/Logger.swift b/Sources/UntoldEngine/Utils/Logger.swift index 87e1f369..08068056 100644 --- a/Sources/UntoldEngine/Utils/Logger.swift +++ b/Sources/UntoldEngine/Utils/Logger.swift @@ -34,6 +34,7 @@ public enum LogCategory: String, CaseIterable, Sendable { case integration = "Integration" case xrCamera = "XRCamera" case batching = "Batching" + case lightPortal = "LightPortal" } public struct LogEvent: Identifiable, Sendable { @@ -199,6 +200,7 @@ public enum Logger { LogCategory.textureLoading.rawValue, LogCategory.xrCamera.rawValue, LogCategory.batching.rawValue, + LogCategory.lightPortal.rawValue, ] private var categoryOverrides: [String: Bool] = [:] diff --git a/Sources/UntoldEngine/Utils/SceneContextVisibility.swift b/Sources/UntoldEngine/Utils/SceneContextVisibility.swift index 1789242c..51d7a94e 100644 --- a/Sources/UntoldEngine/Utils/SceneContextVisibility.swift +++ b/Sources/UntoldEngine/Utils/SceneContextVisibility.swift @@ -38,9 +38,21 @@ public enum SceneChannelRenderMode: Equatable, Sendable { case passthroughGhost(opacity: Float) } +public enum SceneChannelLightPortalMode: Equatable, Sendable { + case disabled + case enabled( + intensity: Float = 1.0, + range: Float = 6.0, + useRealWorldTint: Bool = true, + maxActivePortals: Int = 8, + activationDistance: Float = 15.0 + ) +} + public enum SceneChannelProperty: Sendable { case renderMode(SceneChannelRenderMode) case pickParticipation(Bool) + case lightPortal(SceneChannelLightPortalMode) } private final class SceneChannelVisibilityState: @unchecked Sendable { @@ -129,6 +141,111 @@ private final class SceneChannelVisibilityState: @unchecked Sendable { } } +private final class SceneChannelLightPortalState: @unchecked Sendable { + static let shared = SceneChannelLightPortalState() + + private let lock = NSLock() + private var modesByChannelRawValue: [UInt64: SceneChannelLightPortalMode] = [:] + + func setMode(_ channel: SceneChannel, _ mode: SceneChannelLightPortalMode) { + lock.lock() + for rawValue in rawChannelValues(in: channel) { + switch mode { + case .disabled: + modesByChannelRawValue.removeValue(forKey: rawValue) + case .enabled: + modesByChannelRawValue[rawValue] = sanitizedMode(mode) + } + } + lock.unlock() + } + + func mode(for channels: SceneChannel) -> SceneChannelLightPortalMode { + lock.lock() + let rawValues = rawChannelValues(in: channels) + let modes = rawValues.compactMap { modesByChannelRawValue[$0] } + lock.unlock() + + guard modes.isEmpty == false else { return .disabled } + + var intensity: Float = 0.0 + var range: Float = 0.0 + var useRealWorldTint = false + var maxActivePortals = 0 + var activationDistance: Float = 0.0 + + for mode in modes { + guard case let .enabled( + modeIntensity, + modeRange, + modeUseRealWorldTint, + modeMaxActivePortals, + modeActivationDistance + ) = mode else { + continue + } + + intensity = max(intensity, modeIntensity) + range = max(range, modeRange) + useRealWorldTint = useRealWorldTint || modeUseRealWorldTint + maxActivePortals = max(maxActivePortals, modeMaxActivePortals) + activationDistance = max(activationDistance, modeActivationDistance) + } + + return .enabled( + intensity: intensity, + range: range, + useRealWorldTint: useRealWorldTint, + maxActivePortals: maxActivePortals, + activationDistance: activationDistance + ) + } + + func hasEnabledPortals() -> Bool { + lock.lock() + let enabled = modesByChannelRawValue.isEmpty == false + lock.unlock() + return enabled + } + + func reset() { + lock.lock() + modesByChannelRawValue = [:] + lock.unlock() + } + + private func rawChannelValues(in channels: SceneChannel) -> [UInt64] { + var values: [UInt64] = [] + var remaining = channels.rawValue + while remaining != 0 { + let rawValue = remaining & (~remaining &+ 1) + values.append(rawValue) + remaining &= ~rawValue + } + return values + } + + private func sanitizedMode(_ mode: SceneChannelLightPortalMode) -> SceneChannelLightPortalMode { + switch mode { + case .disabled: + return .disabled + case let .enabled(intensity, range, useRealWorldTint, maxActivePortals, activationDistance): + return .enabled( + intensity: sanitizedNonNegativeFinite(intensity, fallback: 1.0), + range: max(sanitizedNonNegativeFinite(range, fallback: 6.0), 0.001), + useRealWorldTint: useRealWorldTint, + maxActivePortals: max(maxActivePortals, 0), + activationDistance: sanitizedNonNegativeFinite(activationDistance, fallback: 15.0) + ) + } + } + + private func sanitizedNonNegativeFinite(_ value: Float, fallback: Float) -> Float { + guard value.isFinite else { return fallback } + return max(value, 0.0) + } +} + private final class SceneChannelInteractionState: @unchecked Sendable { static let shared = SceneChannelInteractionState() @@ -202,14 +319,17 @@ private final class SceneChannelPrefixRegistry: @unchecked Sendable { public func registerSceneChannelPrefix(_ prefix: String, channels: SceneChannel) { SceneChannelPrefixRegistry.shared.register(prefix: prefix, channels: channels) + resetLightPortalAreaLightCache() } public func unregisterSceneChannelPrefix(_ prefix: String) { SceneChannelPrefixRegistry.shared.unregister(prefix: prefix) + resetLightPortalAreaLightCache() } public func resetSceneChannelPrefixes() { SceneChannelPrefixRegistry.shared.reset() + resetLightPortalAreaLightCache() } public func defaultSceneChannels(forName name: String, isRenderable: Bool = true) -> SceneChannel { @@ -237,6 +357,7 @@ private func setEntitySceneChannels(entityId: EntityID, channels: SceneChannel, if scene.get(component: EntitySceneChannelsComponent.self, for: entityId) != nil { scene.remove(component: EntitySceneChannelsComponent.self, from: entityId) refreshStaticBatchingForSceneChannelChange(entityId: entityId) + resetLightPortalAreaLightCache() } return } @@ -251,6 +372,7 @@ private func setEntitySceneChannels(entityId: EntityID, channels: SceneChannel, component.usesDefaultChannels = usesDefaultChannels if oldChannels != channels { refreshStaticBatchingForSceneChannelChange(entityId: entityId) + resetLightPortalAreaLightCache() } } } @@ -270,6 +392,7 @@ public func removeEntitySceneChannels(entityId: EntityID, channels: SceneChannel } if oldChannels != getEntitySceneChannels(entityId: entityId) { refreshStaticBatchingForSceneChannelChange(entityId: entityId) + resetLightPortalAreaLightCache() } } @@ -289,9 +412,13 @@ public func setSceneChannel(_ channel: SceneChannel, _ property: SceneChannelPro switch property { case let .renderMode(mode): SceneChannelVisibilityState.shared.setRenderMode(channel, mode) + resetLightPortalAreaLightCache() case let .pickParticipation(enabled): SceneChannelInteractionState.shared.setPickParticipation(channel, enabled: enabled) markScenePickingDirty(forChannel: channel) + case let .lightPortal(mode): + SceneChannelLightPortalState.shared.setMode(channel, mode) + resetLightPortalAreaLightCache() } } @@ -307,6 +434,10 @@ public func getSceneChannelPickParticipation(_ channel: SceneChannel) -> Bool { SceneChannelInteractionState.shared.isPickEnabled(for: channel) } +public func getSceneChannelLightPortalMode(_ channel: SceneChannel) -> SceneChannelLightPortalMode { + SceneChannelLightPortalState.shared.mode(for: channel) +} + @available(*, deprecated, message: "Use setSceneChannel(_:, .renderMode(_:)) instead") public func setSceneChannelRenderMode(_ channel: SceneChannel, _ mode: SceneChannelRenderMode) { setSceneChannel(channel, .renderMode(mode)) @@ -320,6 +451,8 @@ public func setSceneChannelVisible(_ channel: SceneChannel, _ visible: Bool) { public func resetSceneChannelVisibility() { SceneChannelVisibilityState.shared.reset() SceneChannelInteractionState.shared.reset() + SceneChannelLightPortalState.shared.reset() + resetLightPortalAreaLightCache() } public func shouldHideSceneEntity(entityId: EntityID) -> Bool { @@ -378,6 +511,23 @@ public func passthroughGhostOpacity(for channels: SceneChannel) -> Float? { } } +public func sceneChannelLightPortalMode(for channels: SceneChannel) -> SceneChannelLightPortalMode { + SceneChannelLightPortalState.shared.mode(for: channels) +} + +public func shouldUseSceneChannelsAsLightPortals(_ channels: SceneChannel) -> Bool { + switch sceneChannelLightPortalMode(for: channels) { + case .enabled: + return true + case .disabled: + return false + } +} + +public func hasSceneChannelLightPortalsEnabled() -> Bool { + SceneChannelLightPortalState.shared.hasEnabledPortals() +} + public func shouldPreserveSceneEntityIdentity(entityId: EntityID) -> Bool { hasEntitySceneChannel(entityId: entityId, channel: .preserveIdentity) } diff --git a/Sources/UntoldEngineXR/UntoldEngineXR.swift b/Sources/UntoldEngineXR/UntoldEngineXR.swift index 371b6acc..5a4e1839 100644 --- a/Sources/UntoldEngineXR/UntoldEngineXR.swift +++ b/Sources/UntoldEngineXR/UntoldEngineXR.swift @@ -24,7 +24,7 @@ case full } - public final class UntoldEngineXR { + public final class UntoldEngineXR: @unchecked Sendable { private var renderer: UntoldRenderer? private var _isRunning = false private let lock = NSLock() @@ -39,11 +39,21 @@ private var missingAnchorFrameCount: Int = 0 private var lastAnchorDiagnosticsLogTime: CFTimeInterval = 0 private let anchorDiagnosticsLogIntervalSeconds: CFTimeInterval = 1.0 + private var arKitProviderRunAttemptCount: Int = 0 + private let arKitProviderRunLock = NSLock() + private var arKitProviderRunInProgress = false + private var pendingARKitProviderRunReason: String? + private var lastARKitProviderRunFailureTime: CFTimeInterval = -.infinity + private let arKitProviderFailureCooldownSeconds: CFTimeInterval = 2.0 + private var lastXRStallDiagnosticsLogTime: CFTimeInterval = 0 + private let xrStallDiagnosticsLogIntervalSeconds: CFTimeInterval = 1.0 // Reuse render pass descriptors to avoid allocation churn (2 eyes × 90 FPS = 180 allocs/sec) private let passDescriptorLeft = MTLRenderPassDescriptor() private let passDescriptorRight = MTLRenderPassDescriptor() private let spatialGestureRecognizer = XRSpatialGestureRecognizer() + private let xrEnvironmentLightingSystem = XREnvironmentLightingSystem() + private var runtimeLightingModeObserverId: UUID? /// Task handle for the plane-update monitor so we can cancel it on shutdown. private var planeMonitorTask: Task? @@ -66,39 +76,19 @@ initUntoldXR(device: device, commandQueue: commandQueue, layerRenderer: layerRenderer) } + deinit { + if let runtimeLightingModeObserverId { + RuntimeEnvironmentLightingStore.shared.removeLightingModeChangeObserver(runtimeLightingModeObserverId) + } + } + @MainActor public func initUntoldXR(device: MTLDevice, commandQueue: MTLCommandQueue, layerRenderer: LayerRenderer) { configureSpatialEventBridge() + registerRuntimeLightingModeObserverIfNeeded() + applyXRLightingMode(RuntimeEnvironmentLightingStore.shared.mode) - // Start ARKit tracking asynchronously - // Use unstructured Task to avoid blocking initialization - let worldTracking = worldTracking - let arSession = arSession - let planeDetection = planeDetection - Task { - do { - guard worldTracking.state != .running else { return } - - // Check world sensing authorization before attempting to run. - let authStatus = await arSession.queryAuthorization(for: [.worldSensing]) - if authStatus[.worldSensing] == .denied { - print("⚠️ World sensing authorization denied — plane detection disabled. Grant permission in Settings > Privacy > World Sensing.") - // Still run with world tracking only so device tracking works. - try await arSession.run([worldTracking]) - return - } - - var providers: [any DataProvider] = [worldTracking] - if PlaneDetectionProvider.isSupported { - providers.append(planeDetection) - } else { - print("⚠️ PlaneDetectionProvider is not supported on this device") - } - try await arSession.run(providers) - } catch { - print("⚠️ Failed to start ARKit providers: \(error)") - } - } + startARKitProviders(reason: "startup") // Start monitoring plane anchor updates in the background. if PlaneDetectionProvider.isSupported { @@ -249,9 +239,149 @@ lock.unlock() planeMonitorTask?.cancel() planeMonitorTask = nil + xrEnvironmentLightingSystem.setEnabled(false) RealSurfacePlaneStore.shared.clear() } + private func startARKitProviders(reason: String = "unspecified") { + #if canImport(ARKit) + scheduleARKitProviderRun(reason: reason) + #endif + } + + private func scheduleARKitProviderRun(reason: String) { + #if canImport(ARKit) + let now = CACurrentMediaTime() + let delaySeconds: CFTimeInterval + + arKitProviderRunLock.lock() + if arKitProviderRunInProgress { + pendingARKitProviderRunReason = reason + arKitProviderRunLock.unlock() + print("XR ARKit providers run coalesced: reason=\(reason)") + return + } + + let timeSinceFailure = now - lastARKitProviderRunFailureTime + delaySeconds = max(arKitProviderFailureCooldownSeconds - timeSinceFailure, 0.0) + arKitProviderRunInProgress = true + arKitProviderRunLock.unlock() + + launchARKitProviderRunTask(reason: reason, delaySeconds: delaySeconds) + #endif + } + + private func launchARKitProviderRunTask(reason: String, delaySeconds: CFTimeInterval) { + #if canImport(ARKit) + Task { [weak self] in + guard let self else { return } + + if delaySeconds > 0.0 { + let delayText = String(format: "%.2f", delaySeconds * 1000.0) + print("XR ARKit providers run delayed by failure cooldown: delayMs=\(delayText), reason=\(reason)") + try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000.0)) + if Task.isCancelled { + finishARKitProviderRun(succeeded: false) + return + } + } + + let effectiveReason = consumePendingARKitProviderRunReason(defaultReason: reason) + let succeeded = await runARKitProvidersOnce(reason: effectiveReason) + finishARKitProviderRun(succeeded: succeeded) + } + #endif + } + + private func consumePendingARKitProviderRunReason(defaultReason: String) -> String { + arKitProviderRunLock.lock() + let reason = pendingARKitProviderRunReason ?? defaultReason + pendingARKitProviderRunReason = nil + arKitProviderRunLock.unlock() + return reason + } + + private func finishARKitProviderRun(succeeded: Bool) { + #if canImport(ARKit) + let pendingReason: String? + arKitProviderRunLock.lock() + if !succeeded { + lastARKitProviderRunFailureTime = CACurrentMediaTime() + } + pendingReason = pendingARKitProviderRunReason + pendingARKitProviderRunReason = nil + arKitProviderRunInProgress = false + arKitProviderRunLock.unlock() + + if let pendingReason { + scheduleARKitProviderRun(reason: pendingReason) + } + #else + _ = succeeded + #endif + } + + #if canImport(ARKit) + private func runARKitProvidersOnce(reason: String) async -> Bool { + let worldTracking = worldTracking + let arSession = arSession + let planeDetection = planeDetection + let xrEnvironmentLightingSystem = xrEnvironmentLightingSystem + + let attempt = nextARKitProviderRunAttempt() + let startTime = CACurrentMediaTime() + + do { + // Check world sensing authorization before attempting plane detection. + let authStatus = await arSession.queryAuthorization(for: [.worldSensing]) + let worldSensingAllowed = authStatus[.worldSensing] != .denied + + var providers: [any DataProvider] = [worldTracking] + var providerNames = ["WorldTrackingProvider"] + + if worldSensingAllowed { + if PlaneDetectionProvider.isSupported { + providers.append(planeDetection) + providerNames.append("PlaneDetectionProvider") + } else { + print("⚠️ PlaneDetectionProvider is not supported on this device") + } + } else { + print("⚠️ World sensing authorization denied — plane detection disabled. Grant permission in Settings > Privacy > World Sensing.") + } + + if let lightingProvider = xrEnvironmentLightingSystem.providerForSession { + providers.append(lightingProvider) + providerNames.append("EnvironmentLightingProvider") + } + + let providerList = providerNames.joined(separator: ",") + print("XR ARKit providers run starting: attempt=\(attempt), reason=\(reason), providers=\(providerList), worldSensingAllowed=\(worldSensingAllowed)") + try await arSession.run(providers) + xrEnvironmentLightingSystem.markProviderRunning(xrEnvironmentLightingSystem.providerForSession != nil) + let durationMs = (CACurrentMediaTime() - startTime) * 1000.0 + let durationText = String(format: "%.2f", durationMs) + print("XR ARKit providers run completed: attempt=\(attempt), durationMs=\(durationText), worldTrackingState=\(String(describing: worldTracking.state))") + return true + } catch { + xrEnvironmentLightingSystem.markProviderRunning(false) + let durationMs = (CACurrentMediaTime() - startTime) * 1000.0 + let durationText = String(format: "%.2f", durationMs) + print("XR ARKit providers run failed: attempt=\(attempt), durationMs=\(durationText), worldTrackingState=\(String(describing: worldTracking.state)), error=\(error)") + print("⚠️ Failed to start ARKit providers: \(error)") + return false + } + } + #endif + + private func nextARKitProviderRunAttempt() -> Int { + arKitProviderRunLock.lock() + arKitProviderRunAttemptCount += 1 + let attempt = arKitProviderRunAttemptCount + arKitProviderRunLock.unlock() + return attempt + } + private func isRunning() -> Bool { lock.lock() let running = _isRunning @@ -409,13 +539,13 @@ } else if let cachedAnchor = lastValidDeviceAnchor { missingAnchorFrameCount += 1 if shouldLogAnchorDiagnostics() { - print("⚠️ XR device anchor missing for \(missingAnchorFrameCount) frame(s); using cached anchor") + printXRAnchorDiagnostics(message: "XR device anchor missing; using cached anchor") } drawable.deviceAnchor = cachedAnchor } else { missingAnchorFrameCount += 1 if shouldLogAnchorDiagnostics() { - print("⚠️ XR device anchor missing for \(missingAnchorFrameCount) frame(s); no cached anchor available") + printXRAnchorDiagnostics(message: "XR device anchor missing; no cached anchor available") } } // Note: If we have no cached anchor either, drawable.deviceAnchor remains nil @@ -508,7 +638,19 @@ func executeXRSystemPass(frame _: LayerRenderer.Frame, drawable: LayerRenderer.Drawable, loading: Bool) { // Wait for available command buffer slot to prevent unbounded memory growth - commandBufferSemaphore.wait() + let semaphoreWaitStart = CACurrentMediaTime() + let firstWaitResult = commandBufferSemaphore.wait(timeout: .now() + .milliseconds(100)) + if firstWaitResult == .timedOut { + let waitMs = (CACurrentMediaTime() - semaphoreWaitStart) * 1000.0 + if shouldLogXRStallDiagnostics() { + printXRCommandBufferStallDiagnostics(waitMs: waitMs, phase: "initial-timeout") + } + commandBufferSemaphore.wait() + } + let semaphoreWaitMs = (CACurrentMediaTime() - semaphoreWaitStart) * 1000.0 + if semaphoreWaitMs > 16.0, shouldLogXRStallDiagnostics() { + printXRCommandBufferStallDiagnostics(waitMs: semaphoreWaitMs, phase: "acquired") + } guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() else { // Failed to create command buffer - release semaphore @@ -674,6 +816,60 @@ return true } + private func shouldLogXRStallDiagnostics() -> Bool { + let now = CACurrentMediaTime() + guard now - lastXRStallDiagnosticsLogTime >= xrStallDiagnosticsLogIntervalSeconds else { + return false + } + + lastXRStallDiagnosticsLogTime = now + return true + } + + private func printXRAnchorDiagnostics(message: String) { + #if canImport(ARKit) + let portalDiagnostics = getLightPortalRenderDiagnostics() + let lightingDiagnostics = xrEnvironmentLightingSystem.diagnostics() + let layerState = layerRenderer.map { String(describing: $0.state) } ?? "nil" + print( + "⚠️ \(message): missingFrameCount=\(missingAnchorFrameCount), " + + "worldTrackingState=\(String(describing: worldTracking.state)), " + + "layerState=\(layerState), " + + "lastValidAnchorAvailable=\(lastValidDeviceAnchor != nil), " + + "xrLightingValid=\(lightingDiagnostics.latestProbeTextureValid), " + + "xrIntensityScale=\(String(describing: lightingDiagnostics.latestIntensityScale)), " + + "portalAreaLightCount=\(portalDiagnostics.portalAreaLightCount), " + + "totalPortalIntensity=\(portalDiagnostics.totalEffectivePortalIntensity), " + + "portalFallback=\(String(describing: portalDiagnostics.fallbackReason))" + ) + #else + _ = message + #endif + } + + private func printXRCommandBufferStallDiagnostics(waitMs: Double, phase: String) { + #if canImport(ARKit) + let portalDiagnostics = getLightPortalRenderDiagnostics() + let lightingDiagnostics = xrEnvironmentLightingSystem.diagnostics() + let layerState = layerRenderer.map { String(describing: $0.state) } ?? "nil" + let waitText = String(format: "%.2f", waitMs) + print( + "⚠️ XR command buffer semaphore stall: phase=\(phase), waitMs=\(waitText), " + + "missingAnchorFrameCount=\(missingAnchorFrameCount), " + + "worldTrackingState=\(String(describing: worldTracking.state)), " + + "layerState=\(layerState), " + + "xrLightingValid=\(lightingDiagnostics.latestProbeTextureValid), " + + "xrIntensityScale=\(String(describing: lightingDiagnostics.latestIntensityScale)), " + + "portalAreaLightCount=\(portalDiagnostics.portalAreaLightCount), " + + "totalPortalIntensity=\(portalDiagnostics.totalEffectivePortalIntensity), " + + "portalFallback=\(String(describing: portalDiagnostics.fallbackReason))" + ) + #else + _ = waitMs + _ = phase + #endif + } + private func queryDeviceAnchorIfTrackingRunning(atTimestamp timestamp: TimeInterval) -> DeviceAnchor? { #if canImport(ARKit) guard worldTracking.state == .running else { @@ -691,23 +887,17 @@ #if canImport(ARKit) let now = CACurrentMediaTime() guard now - lastWorldTrackingRecoveryAttemptTime >= worldTrackingRecoveryCooldownSeconds else { + if shouldLogAnchorDiagnostics() { + printXRAnchorDiagnostics(message: "XR world tracking recovery suppressed by cooldown") + } return } lastWorldTrackingRecoveryAttemptTime = now - let worldTracking = worldTracking - let arSession = arSession - - Task { - do { - guard worldTracking.state != .running else { return } - try await arSession.run([worldTracking]) - print("✓ XR world tracking restarted") - } catch { - print("⚠️ XR world tracking recovery failed: \(error)") - } - } + guard worldTracking.state != .running else { return } + startARKitProviders(reason: "worldTrackingState=\(String(describing: worldTracking.state))") + print("✓ XR ARKit providers recovery scheduled: worldTrackingState=\(String(describing: worldTracking.state)), missingAnchorFrameCount=\(missingAnchorFrameCount)") #endif } @@ -737,6 +927,40 @@ break } } + + public func setXRLightingMode(_ mode: RuntimeEnvironmentLightingMode) { + RuntimeEnvironmentLightingStore.shared.setMode(mode, notifyIfUnchanged: true) + } + + public func setXRLightingContribution(_ factor: Float) { + RuntimeEnvironmentLightingStore.shared.realWorldLightingContribution = factor + } + + private func applyXRLightingMode(_ mode: RuntimeEnvironmentLightingMode) { + switch mode { + case .realWorldEstimate: + xrEnvironmentLightingSystem.setEnabled(true) + case .authoredOnly, .staticIBL: + xrEnvironmentLightingSystem.setEnabled(false) + } + } + + private func registerRuntimeLightingModeObserverIfNeeded() { + guard runtimeLightingModeObserverId == nil else { return } + + runtimeLightingModeObserverId = RuntimeEnvironmentLightingStore.shared.observeLightingModeChanges { [weak self] mode in + self?.handleRuntimeLightingModeChanged(mode) + } + } + + private func handleRuntimeLightingModeChanged(_ mode: RuntimeEnvironmentLightingMode) { + applyXRLightingMode(mode) + startARKitProviders(reason: "RuntimeEnvironmentLightingStore.mode=\(mode)") + } + + public func xrEnvironmentLightingDiagnostics() -> XREnvironmentLightingDiagnostics { + xrEnvironmentLightingSystem.diagnostics() + } } #endif diff --git a/Sources/UntoldEngineXR/XREnvironmentLightingSystem.swift b/Sources/UntoldEngineXR/XREnvironmentLightingSystem.swift new file mode 100644 index 00000000..73155cd0 --- /dev/null +++ b/Sources/UntoldEngineXR/XREnvironmentLightingSystem.swift @@ -0,0 +1,548 @@ +// +// XREnvironmentLightingSystem.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/. + +#if os(visionOS) + import Foundation + import Metal + import simd + import UntoldEngine + #if canImport(ARKit) + import ARKit + #endif + + public struct XREnvironmentLightingDiagnostics: Sendable, Equatable { + public var enabled: Bool + public var providerSupported: Bool + public var providerRunning: Bool + public var latestProbeTimestamp: CFTimeInterval? + public var latestProbeTextureValid: Bool + public var latestCameraScaleReference: Float? + public var latestIntensityScale: Float + public var latestTintColor: simd_float3 + public var prefilterInFlight: Bool + public var lastPrefilterDurationMs: Double? + public var realWorldLightingContribution: Float + public var acceptedProbeUpdateCount: Int + public var skippedProbeUpdateCount: Int + public var fallbackReason: String? + } + + public final class XREnvironmentLightingSystem: @unchecked Sendable { + private let lock = NSLock() + private var enabledValue = false + private var providerRunningValue = false + private var latestProbeTimestampValue: CFTimeInterval? + private var latestProbeTextureValidValue = false + private var latestCameraScaleReferenceValue: Float? + private var latestIntensityScaleValue: Float = 1.0 + private var latestTintColorValue = simd_float3(1.0, 1.0, 1.0) + private var prefilterInFlightValue = false + private var lastPrefilterDurationMsValue: Double? + private var acceptedProbeUpdateCountValue = 0 + private var skippedProbeUpdateCountValue = 0 + private var fallbackReasonValue: String? + private var lastAcceptedProbeUpdateTime: CFTimeInterval = 0 + private var probeMonitorTask: Task? + private var textureSets: [RuntimeEnvironmentLightingTextureSet] = [] + private var currentReadTextureSetIndex = 0 + private var prefilterGeneration: UInt64 = 0 + private let cameraScaleReferenceWhitePoint: Float = 5000.0 + + #if canImport(ARKit) + private let environmentLightEstimationProvider: EnvironmentLightEstimationProvider? + #endif + + public var minimumProbeUpdateInterval: CFTimeInterval = 0.5 + + public init() { + #if canImport(ARKit) + if EnvironmentLightEstimationProvider.isSupported { + environmentLightEstimationProvider = EnvironmentLightEstimationProvider() + } else { + environmentLightEstimationProvider = nil + } + #endif + } + + deinit { + probeMonitorTask?.cancel() + } + + public var enabled: Bool { + lock.lock() + let value = enabledValue + lock.unlock() + return value + } + + public var providerSupported: Bool { + #if canImport(ARKit) + environmentLightEstimationProvider != nil + #else + false + #endif + } + + #if canImport(ARKit) + public var providerForSession: (any DataProvider)? { + guard enabled, let environmentLightEstimationProvider else { return nil } + return environmentLightEstimationProvider + } + #endif + + public func setEnabled(_ enabled: Bool) { + lock.lock() + enabledValue = enabled + if !enabled { + fallbackReasonValue = nil + latestProbeTimestampValue = nil + latestProbeTextureValidValue = false + latestCameraScaleReferenceValue = nil + latestIntensityScaleValue = 1.0 + latestTintColorValue = simd_float3(1.0, 1.0, 1.0) + providerRunningValue = false + prefilterInFlightValue = false + lastPrefilterDurationMsValue = nil + lastAcceptedProbeUpdateTime = 0 + prefilterGeneration &+= 1 + RuntimeEnvironmentLightingStore.shared.publishXRLighting(nil) + } + lock.unlock() + + if enabled { + startProbeMonitor() + } else { + stopProbeMonitor() + } + } + + public func markProviderRunning(_ running: Bool) { + lock.lock() + providerRunningValue = running + lock.unlock() + } + + public func shouldAcceptProbeUpdate(timestamp: CFTimeInterval) -> Bool { + lock.lock() + defer { lock.unlock() } + + guard enabledValue else { + skippedProbeUpdateCountValue += 1 + fallbackReasonValue = "XR lighting disabled" + return false + } + + guard timestamp - lastAcceptedProbeUpdateTime >= minimumProbeUpdateInterval else { + skippedProbeUpdateCountValue += 1 + return false + } + + lastAcceptedProbeUpdateTime = timestamp + latestProbeTimestampValue = timestamp + acceptedProbeUpdateCountValue += 1 + fallbackReasonValue = nil + return true + } + + public func publishUnavailableProbe(reason: String) { + lock.lock() + latestProbeTextureValidValue = false + fallbackReasonValue = reason + lock.unlock() + RuntimeEnvironmentLightingStore.shared.publishXRLighting(nil) + } + + #if canImport(ARKit) + private func startProbeMonitor() { + guard probeMonitorTask == nil else { return } + guard let provider = environmentLightEstimationProvider else { + publishUnavailableProbe(reason: "Environment light estimation unsupported") + return + } + + probeMonitorTask = Task(priority: .utility) { [weak self, provider] in + for await update in provider.anchorUpdates { + if Task.isCancelled { break } + self?.handleProbeAnchorUpdate(update) + } + } + } + + private func stopProbeMonitor() { + probeMonitorTask?.cancel() + probeMonitorTask = nil + } + + private func handleProbeAnchorUpdate(_ update: AnchorUpdate) { + switch update.event { + case .added, .updated: + let anchor = update.anchor + guard shouldAcceptProbeUpdate(timestamp: anchor.timestamp) else { return } + + let hasTexture = anchor.environmentTexture != nil + let intensityScale = updateProbeCameraScaleReference( + anchor.cameraScaleReference, + hasTexture: hasTexture + ) + + if let environmentTexture = anchor.environmentTexture { + scheduleProbePrefilter( + environmentTexture: environmentTexture, + timestamp: anchor.timestamp, + intensityScale: intensityScale, + retainedAnchor: anchor + ) + } + + case .removed: + publishUnavailableProbe(reason: "XR probe anchor removed") + + @unknown default: + publishUnavailableProbe(reason: "Unknown XR probe update") + } + } + #else + private func startProbeMonitor() {} + + private func stopProbeMonitor() {} + #endif + + private func ensureTextureSets() -> Bool { + if textureSets.count == 2 { return true } + + guard let first = makeRuntimeEnvironmentLightingTextureSet(labelPrefix: "XR Runtime IBL A"), + let second = makeRuntimeEnvironmentLightingTextureSet(labelPrefix: "XR Runtime IBL B") + else { + fallbackReasonValue = "Unable to allocate XR IBL textures" + return false + } + + textureSets = [first, second] + currentReadTextureSetIndex = 0 + return true + } + + private func beginPrefilterIfPossible() -> (textureSetIndex: Int, textureSet: RuntimeEnvironmentLightingTextureSet, generation: UInt64)? { + lock.lock() + defer { lock.unlock() } + + guard enabledValue else { + fallbackReasonValue = "XR lighting disabled" + return nil + } + + guard !prefilterInFlightValue else { + skippedProbeUpdateCountValue += 1 + return nil + } + + guard ensureTextureSets() else { + return nil + } + + let writeIndex = currentReadTextureSetIndex == 0 ? 1 : 0 + let textureSet = textureSets[writeIndex] + prefilterGeneration &+= 1 + prefilterInFlightValue = true + fallbackReasonValue = nil + return (writeIndex, textureSet, prefilterGeneration) + } + + private func finishPrefilter( + succeeded: Bool, + textureSetIndex: Int, + generation: UInt64, + timestamp: CFTimeInterval, + intensityScale: Float, + tintColor: simd_float3, + durationMs: Double + ) { + lock.lock() + + guard enabledValue, generation == prefilterGeneration else { + lock.unlock() + return + } + + prefilterInFlightValue = false + lastPrefilterDurationMsValue = durationMs + + guard succeeded, textureSets.indices.contains(textureSetIndex) else { + fallbackReasonValue = "XR probe prefilter failed" + lock.unlock() + RuntimeEnvironmentLightingStore.shared.publishXRLighting(nil) + return + } + + currentReadTextureSetIndex = textureSetIndex + let textureSet = textureSets[textureSetIndex] + latestTintColorValue = tintColor + fallbackReasonValue = nil + lock.unlock() + + RuntimeEnvironmentLightingStore.shared.publishXRLighting( + RuntimeEnvironmentLighting( + irradianceMap: textureSet.irradianceMap, + specularMap: textureSet.specularMap, + brdfMap: textureSet.brdfMap, + intensityScale: intensityScale, + tintColor: tintColor, + timestamp: timestamp, + isValid: true + ) + ) + } + + private func updateProbeCameraScaleReference(_ cameraScaleReference: Float?, hasTexture: Bool) -> Float { + lock.lock() + defer { lock.unlock() } + + latestProbeTextureValidValue = hasTexture + latestCameraScaleReferenceValue = cameraScaleReference + if !hasTexture { + fallbackReasonValue = "XR probe texture unavailable" + latestIntensityScaleValue = 0.0 + latestTintColorValue = simd_float3(1.0, 1.0, 1.0) + RuntimeEnvironmentLightingStore.shared.publishXRLighting(nil) + } + + guard let cameraScaleReference, + cameraScaleReference.isFinite, + cameraScaleReference > 0.0001 + else { + latestIntensityScaleValue = hasTexture ? 1.0 : 0.0 + return latestIntensityScaleValue + } + + let intensityScale = min(max(cameraScaleReference / cameraScaleReferenceWhitePoint, 0.0), 8.0) + latestIntensityScaleValue = intensityScale + return intensityScale + } + + private struct EnvironmentTintReadback { + var buffer: MTLBuffer + var pixelFormat: MTLPixelFormat + var width: Int + var height: Int + var bytesPerRow: Int + var faceBytes: Int + var faceCount: Int + } + + private func scheduleProbePrefilter( + environmentTexture: MTLTexture, + timestamp: CFTimeInterval, + intensityScale: Float, + retainedAnchor: Any + ) { + guard environmentTexture.textureType == .typeCube else { + publishUnavailableProbe(reason: "XR probe texture is not a cube texture") + return + } + + guard let prefilter = beginPrefilterIfPossible() else { return } + let textureSetIndex = prefilter.textureSetIndex + let textureSet = prefilter.textureSet + let generation = prefilter.generation + + guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() else { + finishPrefilter( + succeeded: false, + textureSetIndex: textureSetIndex, + generation: generation, + timestamp: timestamp, + intensityScale: intensityScale, + tintColor: simd_float3(1.0, 1.0, 1.0), + durationMs: 0 + ) + return + } + + commandBuffer.label = "XR Environment Probe IBL Prefilter" + let startTime = Date().timeIntervalSinceReferenceDate + let tintReadback = encodeEnvironmentTintReadback( + commandBuffer: commandBuffer, + environmentTexture: environmentTexture + ) + let encoded = executeXRIBLCubePreFilterPass( + commandBuffer: commandBuffer, + environmentCubeTexture: environmentTexture, + target: textureSet + ) + + guard encoded else { + finishPrefilter( + succeeded: false, + textureSetIndex: textureSetIndex, + generation: generation, + timestamp: timestamp, + intensityScale: intensityScale, + tintColor: simd_float3(1.0, 1.0, 1.0), + durationMs: 0 + ) + return + } + + commandBuffer.addCompletedHandler { [weak self, retainedAnchor] commandBuffer in + _ = retainedAnchor + let durationMs = (Date().timeIntervalSinceReferenceDate - startTime) * 1000.0 + let tintColor = tintReadback.map { self?.environmentTint(from: $0) ?? simd_float3(1.0, 1.0, 1.0) } + ?? simd_float3(1.0, 1.0, 1.0) + self?.finishPrefilter( + succeeded: commandBuffer.status == .completed, + textureSetIndex: textureSetIndex, + generation: generation, + timestamp: timestamp, + intensityScale: intensityScale, + tintColor: tintColor, + durationMs: durationMs + ) + } + commandBuffer.commit() + } + + private func encodeEnvironmentTintReadback( + commandBuffer: MTLCommandBuffer, + environmentTexture: MTLTexture + ) -> EnvironmentTintReadback? { + guard let bytesPerPixel = environmentTintBytesPerPixel(for: environmentTexture.pixelFormat), + let blit = commandBuffer.makeBlitCommandEncoder() + else { return nil } + + let mipLevel = max(environmentTexture.mipmapLevelCount - 1, 0) + let width = max(environmentTexture.width >> mipLevel, 1) + let height = max(environmentTexture.height >> mipLevel, 1) + let bytesPerRow = width * bytesPerPixel + let faceBytes = bytesPerRow * height + let faceCount = 6 + let bufferLength = faceBytes * faceCount + + guard let buffer = renderInfo.device.makeBuffer(length: bufferLength, options: .storageModeShared) else { + blit.endEncoding() + return nil + } + buffer.label = "XR Environment Probe Tint Readback" + + for face in 0 ..< faceCount { + blit.copy( + from: environmentTexture, + sourceSlice: face, + sourceLevel: mipLevel, + sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0), + sourceSize: MTLSize(width: width, height: height, depth: 1), + to: buffer, + destinationOffset: face * faceBytes, + destinationBytesPerRow: bytesPerRow, + destinationBytesPerImage: faceBytes + ) + } + blit.endEncoding() + + return EnvironmentTintReadback( + buffer: buffer, + pixelFormat: environmentTexture.pixelFormat, + width: width, + height: height, + bytesPerRow: bytesPerRow, + faceBytes: faceBytes, + faceCount: faceCount + ) + } + + private func environmentTintBytesPerPixel(for pixelFormat: MTLPixelFormat) -> Int? { + switch pixelFormat { + case .rgba16Float: + return 8 + case .rgba32Float: + return 16 + case .rgba8Unorm, .rgba8Unorm_srgb, .bgra8Unorm, .bgra8Unorm_srgb: + return 4 + default: + return nil + } + } + + private func environmentTint(from readback: EnvironmentTintReadback) -> simd_float3 { + let contents = readback.buffer.contents() + var total = simd_float3(0.0, 0.0, 0.0) + var sampleCount: Float = 0.0 + + for face in 0 ..< readback.faceCount { + let faceBase = face * readback.faceBytes + for y in 0 ..< readback.height { + let rowBase = faceBase + y * readback.bytesPerRow + for x in 0 ..< readback.width { + let offset = rowBase + x * environmentTintBytesPerPixel(for: readback.pixelFormat)! + total += environmentTintPixel(contents: contents, offset: offset, pixelFormat: readback.pixelFormat) + sampleCount += 1.0 + } + } + } + + guard sampleCount > 0.0 else { return simd_float3(1.0, 1.0, 1.0) } + let average = total / sampleCount + let luminance = simd_dot(average, simd_float3(0.2126, 0.7152, 0.0722)) + guard luminance.isFinite, luminance > 0.0001 else { return simd_float3(1.0, 1.0, 1.0) } + + let tint = average / luminance + return simd_clamp(tint, simd_float3(0.25, 0.25, 0.25), simd_float3(2.5, 2.5, 2.5)) + } + + private func environmentTintPixel(contents: UnsafeMutableRawPointer, offset: Int, pixelFormat: MTLPixelFormat) -> simd_float3 { + switch pixelFormat { + case .rgba16Float: + let pointer = contents.advanced(by: offset).assumingMemoryBound(to: UInt16.self) + return simd_float3( + Float(Float16(bitPattern: pointer[0])), + Float(Float16(bitPattern: pointer[1])), + Float(Float16(bitPattern: pointer[2])) + ) + case .rgba32Float: + let pointer = contents.advanced(by: offset).assumingMemoryBound(to: Float.self) + return simd_float3(pointer[0], pointer[1], pointer[2]) + case .rgba8Unorm, .rgba8Unorm_srgb: + let pointer = contents.advanced(by: offset).assumingMemoryBound(to: UInt8.self) + return simd_float3(Float(pointer[0]), Float(pointer[1]), Float(pointer[2])) / 255.0 + case .bgra8Unorm, .bgra8Unorm_srgb: + let pointer = contents.advanced(by: offset).assumingMemoryBound(to: UInt8.self) + return simd_float3(Float(pointer[2]), Float(pointer[1]), Float(pointer[0])) / 255.0 + default: + return simd_float3(1.0, 1.0, 1.0) + } + } + + public func diagnostics() -> XREnvironmentLightingDiagnostics { + lock.lock() + #if canImport(ARKit) + let providerRunning = environmentLightEstimationProvider?.state == .running + #else + let providerRunning = providerRunningValue + #endif + let diagnostics = XREnvironmentLightingDiagnostics( + enabled: enabledValue, + providerSupported: providerSupported, + providerRunning: providerRunning, + latestProbeTimestamp: latestProbeTimestampValue, + latestProbeTextureValid: latestProbeTextureValidValue, + latestCameraScaleReference: latestCameraScaleReferenceValue, + latestIntensityScale: latestIntensityScaleValue, + latestTintColor: latestTintColorValue, + prefilterInFlight: prefilterInFlightValue, + lastPrefilterDurationMs: lastPrefilterDurationMsValue, + realWorldLightingContribution: RuntimeEnvironmentLightingStore.shared.realWorldLightingContribution, + acceptedProbeUpdateCount: acceptedProbeUpdateCountValue, + skippedProbeUpdateCount: skippedProbeUpdateCountValue, + fallbackReason: fallbackReasonValue + ) + lock.unlock() + return diagnostics + } + } +#endif diff --git a/Tests/UntoldEngineRenderTests/AnimationTest.swift b/Tests/UntoldEngineRenderTests/AnimationTest.swift index a05d671e..ed033c0c 100644 --- a/Tests/UntoldEngineRenderTests/AnimationTest.swift +++ b/Tests/UntoldEngineRenderTests/AnimationTest.swift @@ -77,6 +77,56 @@ final class AnimationTests: BaseRenderSetup { XCTAssertEqual(animationComponent.currentTime, 0.5, accuracy: 0.0001) } + func test_rotationOnlyClipPreservesRestTranslation() throws { + let restTransform = matrix4x4Translation(0.25, 1.5, -0.75) + + let runtimeClip = RuntimeAnimationClip( + name: "rotationOnly", + duration: 1.0, + channels: [ + RuntimeAnimationChannel( + jointPath: "/Hips/Spine", + rotations: [ + RuntimeRotationKeyframe(time: 0.0, value: SIMD4(0.0, 0.0, 0.0, 1.0)), + ] + ), + ] + ) + let clip = AnimationClip(runtimeClip: runtimeClip) + let pose = try XCTUnwrap(clip.getPose(at: 0.0, jointPath: "/Hips/Spine", fallback: restTransform)) + + XCTAssertEqual(pose.columns.3.x, restTransform.columns.3.x, accuracy: 0.0001) + XCTAssertEqual(pose.columns.3.y, restTransform.columns.3.y, accuracy: 0.0001) + XCTAssertEqual(pose.columns.3.z, restTransform.columns.3.z, accuracy: 0.0001) + } + + func test_animationPosePreservesRestScale() throws { + let restScale = SIMD3(0.01, 0.02, 0.03) + let restTransform = matrix4x4Translation(0.25, 1.5, -0.75) * matrix4x4Scale(restScale.x, restScale.y, restScale.z) + let runtimeClip = RuntimeAnimationClip( + name: "rotationWithScaledRestPose", + duration: 1.0, + channels: [ + RuntimeAnimationChannel( + jointPath: "/Hips", + translations: [ + RuntimeTranslationKeyframe(time: 0.0, value: SIMD3(0.5, 1.0, 1.5)), + ], + rotations: [ + RuntimeRotationKeyframe(time: 0.0, value: SIMD4(0.0, 0.0, 0.0, 1.0)), + ] + ), + ] + ) + + let clip = AnimationClip(runtimeClip: runtimeClip) + let pose = try XCTUnwrap(clip.getPose(at: 0.0, jointPath: "/Hips", fallback: restTransform)) + + XCTAssertEqual(simd_length(SIMD3(pose.columns.0.x, pose.columns.0.y, pose.columns.0.z)), restScale.x, accuracy: 0.0001) + XCTAssertEqual(simd_length(SIMD3(pose.columns.1.x, pose.columns.1.y, pose.columns.1.z)), restScale.y, accuracy: 0.0001) + XCTAssertEqual(simd_length(SIMD3(pose.columns.2.x, pose.columns.2.y, pose.columns.2.z)), restScale.z, accuracy: 0.0001) + } + private func runSamples(save: (_ tex: MTLTexture, _ name: String) -> Void) throws { resetAnimationPlaybackState() diff --git a/Tests/UntoldEngineRenderTests/LightSystemTest.swift b/Tests/UntoldEngineRenderTests/LightSystemTest.swift index 45c24f4f..a1cba2f4 100644 --- a/Tests/UntoldEngineRenderTests/LightSystemTest.swift +++ b/Tests/UntoldEngineRenderTests/LightSystemTest.swift @@ -107,6 +107,79 @@ final class LightSystemTest: BaseRenderSetup { assertVector(getLightTransformForwardAxis(entityId: area), equals: simd_float3(0.0, 1.0, 0.0)) assertVector(getLightEmissionDirection(entityId: area), equals: simd_float3(0.0, -1.0, 0.0)) assertVector(getAreaLights().first?.forward ?? .zero, equals: simd_float3(0.0, 1.0, 0.0)) + XCTAssertEqual(getAreaLights().first?.nearSourceSuppressionRadius ?? -1.0, 0.0) + } + + func testLightShaderUniformABIStaysStable() { + XCTAssertEqual(MemoryLayout.stride, MemoryLayout.stride) + XCTAssertEqual(MemoryLayout.alignment, MemoryLayout.alignment) + XCTAssertEqual(MemoryLayout.stride, 64) + XCTAssertEqual(MemoryLayout.alignment, 16) + XCTAssertEqual(MemoryLayout.offset(of: \PointLightUniform.attenuation), 0) + XCTAssertEqual(MemoryLayout.offset(of: \PointLightUniform.position), 16) + XCTAssertEqual(MemoryLayout.offset(of: \PointLightUniform.color), 32) + XCTAssertEqual(MemoryLayout.offset(of: \PointLightUniform.intensity), 48) + XCTAssertEqual(MemoryLayout.offset(of: \PointLightUniform.radius), 52) + + XCTAssertEqual(MemoryLayout.stride, MemoryLayout.stride) + XCTAssertEqual(MemoryLayout.alignment, MemoryLayout.alignment) + XCTAssertEqual(MemoryLayout.stride, 80) + XCTAssertEqual(MemoryLayout.alignment, 16) + XCTAssertEqual(MemoryLayout.offset(of: \SpotLightUniform.attenuation), 0) + XCTAssertEqual(MemoryLayout.offset(of: \SpotLightUniform.direction), 16) + XCTAssertEqual(MemoryLayout.offset(of: \SpotLightUniform.position), 32) + XCTAssertEqual(MemoryLayout.offset(of: \SpotLightUniform.color), 48) + XCTAssertEqual(MemoryLayout.offset(of: \SpotLightUniform.intensity), 64) + XCTAssertEqual(MemoryLayout.offset(of: \SpotLightUniform.innerCone), 68) + XCTAssertEqual(MemoryLayout.offset(of: \SpotLightUniform.outerCone), 72) + + XCTAssertEqual(MemoryLayout.stride, MemoryLayout.stride) + XCTAssertEqual(MemoryLayout.alignment, MemoryLayout.alignment) + XCTAssertEqual(MemoryLayout.stride, 112) + XCTAssertEqual(MemoryLayout.alignment, 16) + XCTAssertEqual(MemoryLayout.offset(of: \AreaLightUniform.position), 0) + XCTAssertEqual(MemoryLayout.offset(of: \AreaLightUniform.color), 16) + XCTAssertEqual(MemoryLayout.offset(of: \AreaLightUniform.forward), 32) + XCTAssertEqual(MemoryLayout.offset(of: \AreaLightUniform.right), 48) + XCTAssertEqual(MemoryLayout.offset(of: \AreaLightUniform.up), 64) + XCTAssertEqual(MemoryLayout.offset(of: \AreaLightUniform.bounds), 80) + XCTAssertEqual(MemoryLayout.offset(of: \AreaLightUniform.intensity), 88) + XCTAssertEqual(MemoryLayout.offset(of: \AreaLightUniform.range), 92) + XCTAssertEqual(MemoryLayout.offset(of: \AreaLightUniform.nearSourceSuppressionRadius), 96) + XCTAssertEqual(MemoryLayout.offset(of: \AreaLightUniform.twoSided), 100) + } + + func testAreaLightShaderUniformLayoutIncludesPortalFields() { + var light = AreaLight() + light.position = simd_float3(1.0, 2.0, 3.0) + light.color = simd_float3(0.75, 0.5, 0.25) + light.forward = simd_float3(0.0, 0.0, 1.0) + light.right = simd_float3(1.0, 0.0, 0.0) + light.up = simd_float3(0.0, 1.0, 0.0) + light.bounds = simd_float2(4.0, 2.0) + light.intensity = 1.5 + light.range = 6.0 + light.nearSourceSuppressionRadius = 0.35 + light.twoSided = true + + let uniform = [light].withUnsafeBufferPointer { buffer in + UnsafeRawBufferPointer( + start: buffer.baseAddress, + count: MemoryLayout.stride + ).load(as: AreaLightUniform.self) + } + + assertVector(uniform.position, equals: light.position) + assertVector(uniform.color, equals: light.color) + assertVector(uniform.forward, equals: light.forward) + assertVector(uniform.right, equals: light.right) + assertVector(uniform.up, equals: light.up) + XCTAssertEqual(uniform.bounds.x, light.bounds.x, accuracy: 0.0001) + XCTAssertEqual(uniform.bounds.y, light.bounds.y, accuracy: 0.0001) + XCTAssertEqual(uniform.intensity, light.intensity, accuracy: 0.0001) + XCTAssertEqual(uniform.range, light.range, accuracy: 0.0001) + XCTAssertEqual(uniform.nearSourceSuppressionRadius, light.nearSourceSuppressionRadius, accuracy: 0.0001) + XCTAssertTrue(uniform.twoSided) } func testGetDirLightParameters() { diff --git a/Tests/UntoldEngineRenderTests/NativeFormatRegistrationTests.swift b/Tests/UntoldEngineRenderTests/NativeFormatRegistrationTests.swift index 9eab2126..64c2bfc3 100644 --- a/Tests/UntoldEngineRenderTests/NativeFormatRegistrationTests.swift +++ b/Tests/UntoldEngineRenderTests/NativeFormatRegistrationTests.swift @@ -247,9 +247,67 @@ final class NativeFormatRegistrationTests: BaseRenderSetup { XCTAssertNotNil(animationComponent.currentAnimation) } + func testSetEntityAnimationsRegistersEverySkinnedDescendant() { + guard let animationURL = Bundle.module.url(forResource: "running", withExtension: "untold") else { + XCTFail("Failed to locate running.untold") + return + } + + let originalResourceURLFn = LoadingSystem.shared.resourceURLFn + LoadingSystem.shared.resourceURLFn = { name, ext, subName in + if name == "running", ext == "untold" { + return animationURL + } + return getResourceURL(resourceName: name, ext: ext, subName: subName) + } + defer { LoadingSystem.shared.resourceURLFn = originalResourceURLFn } + + let rootEntity = createEntity() + registerTransformComponent(entityId: rootEntity) + registerSceneGraphComponent(entityId: rootEntity) + + let shirtEntity = makeSkinnedRenderChild(named: "CH38_Shirt", parent: rootEntity) + let bodyEntity = makeSkinnedRenderChild(named: "CH38_Body", parent: rootEntity) + + let bindingEntities = resolveEntitiesForAnimationBinding(entityId: rootEntity) + XCTAssertEqual(Set(bindingEntities), Set([shirtEntity, bodyEntity])) + + setEntityAnimations(entityId: rootEntity, filename: "running", withExtension: "untold", name: "running") + + for entityId in [shirtEntity, bodyEntity] { + guard let animationComponent = scene.get(component: AnimationComponent.self, for: entityId) else { + XCTFail("Expected AnimationComponent on skinned child \(entityId)") + continue + } + + XCTAssertNotNil(animationComponent.animationClips["running"]) + XCTAssertNotNil(animationComponent.currentAnimation) + } + + changeAnimation(entityId: rootEntity, name: "running", withPause: true) + XCTAssertTrue(isAnimationComponentPaused(entityId: rootEntity)) + + setAnimationPlaybackSpeed(entityId: rootEntity, speed: 0.5) + XCTAssertEqual(getAnimationPlaybackSpeed(entityId: rootEntity), 0.5, accuracy: 0.0001) + + removeAnimationClip(entityId: rootEntity, animationClip: "running") + XCTAssertFalse(getAllAnimationClips(entityId: rootEntity).contains("running")) + } + private func findEntity(named name: String) -> EntityID? { reverseEntityNameMap[name]?.first(where: { scene.exists($0) && getEntityName(entityId: $0) == name }) } + + private func makeSkinnedRenderChild(named name: String, parent: EntityID) -> EntityID { + let entityId = createEntity() + setEntityName(entityId: entityId, name: name) + registerTransformComponent(entityId: entityId) + registerSceneGraphComponent(entityId: entityId) + registerComponent(entityId: entityId, componentType: RenderComponent.self) + registerComponent(entityId: entityId, componentType: SkeletonComponent.self) + setParent(childId: entityId, parentId: parent) + return entityId + } } private struct SceneAuthoredUntoldFixture { diff --git a/Tests/UntoldEngineRenderTests/RendererTest.swift b/Tests/UntoldEngineRenderTests/RendererTest.swift index ba292de5..e8b4b400 100644 --- a/Tests/UntoldEngineRenderTests/RendererTest.swift +++ b/Tests/UntoldEngineRenderTests/RendererTest.swift @@ -9,6 +9,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import CShaderTypes +import Metal import simd import UniformTypeIdentifiers @testable import UntoldEngine @@ -338,6 +339,15 @@ final class RendererTests: BaseRenderSetup { XCTAssertTrue(outputPipeline.success, "Output transform pipeline should compile successfully") } + func testXRIBLCubePreFilterPipelineInitialized() { + guard let pipeline = PipelineManager.shared.renderPipelinesByType[.xrIBLCubePreFilter] else { + XCTFail("XR IBL cube prefilter pipeline should be initialized") + return + } + + XCTAssertTrue(pipeline.success, "XR IBL cube prefilter pipeline should compile successfully") + } + func testSMAAEdgesPipelineInitialized() { guard let pipeline = PipelineManager.shared.renderPipelinesByType[.smaaEdges] else { XCTFail("SMAA edges pipeline should be initialized") @@ -536,3 +546,197 @@ final class RendererTests: BaseRenderSetup { XCTAssertEqual(tex.pixelFormat, .rgba8Unorm_srgb) } } + +final class LightPortalRendererTests: BaseRenderSetup { + private let portalChannel = SceneChannel.userCustom(index: 30) + + override func initializeAssets() { + ambientIntensity = 0.0 + applyIBL = false + renderEnvironment = false + SSAOParams.shared.enabled = false + + let camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + cameraLookAt( + entityId: camera, + eye: simd_float3(0.0, 2.0, 6.0), + target: simd_float3(0.0, 0.0, 0.0), + up: simd_float3(0.0, 1.0, 0.0) + ) + + let receiver = createEntity() + setEntityMeshDirect(entityId: receiver, meshes: BasicPrimitives.createCube(extent: 1.0), assetName: "PortalReceiver") + scaleTo(entityId: receiver, scale: simd_float3(4.0, 0.12, 4.0)) + translateTo(entityId: receiver, position: simd_float3(0.0, -0.7, 0.0)) + + let portal = createEntity() + setEntityMeshDirect(entityId: portal, meshes: BasicPrimitives.createCube(extent: 1.0), assetName: "PortalSurface") + scene.get(component: LocalTransformComponent.self, for: portal)?.boundingBox = ( + min: simd_float3(-0.5, -0.5, -0.025), + max: simd_float3(0.5, 0.5, 0.025) + ) + scaleTo(entityId: portal, scale: simd_float3(2.0, 2.0, 0.05)) + translateTo(entityId: portal, position: simd_float3(0.0, 1.1, 2.0)) + setEntitySceneChannels(entityId: portal, channels: portalChannel) + setSceneChannel(portalChannel, .lightPortal(.disabled)) + } + + func testLightPortalIncreasesRenderedLightPassBrightness() { + let disabledLuminance = renderLightPassAverageLuminance() + + setSceneChannel( + portalChannel, + .lightPortal(.enabled( + intensity: 18.0, + range: 8.0, + useRealWorldTint: false, + maxActivePortals: 1, + activationDistance: 20.0 + )) + ) + cullFrameIndex &+= 1 + + let enabledLuminance = renderLightPassAverageLuminance() + let diagnostics = getLightPortalRenderDiagnostics() + + XCTAssertEqual(diagnostics.portalAreaLightCount, 1) + XCTAssertGreaterThan(diagnostics.totalEffectivePortalIntensity, 0.0) + XCTAssertGreaterThan( + enabledLuminance, + disabledLuminance + 0.002, + "Portal-enabled render should produce measurably brighter light-pass pixels" + ) + } + + func testLightPortalRangeAttenuationLimitsRenderedContribution() { + setSceneChannel( + portalChannel, + .lightPortal(.enabled( + intensity: 18.0, + range: 0.25, + useRealWorldTint: false, + maxActivePortals: 1, + activationDistance: 20.0 + )) + ) + cullFrameIndex &+= 1 + let shortRangeLuminance = renderLightPassAverageLuminance() + + setSceneChannel( + portalChannel, + .lightPortal(.enabled( + intensity: 18.0, + range: 8.0, + useRealWorldTint: false, + maxActivePortals: 1, + activationDistance: 20.0 + )) + ) + cullFrameIndex &+= 1 + let normalRangeLuminance = renderLightPassAverageLuminance() + + XCTAssertEqual(getLightPortalRenderDiagnostics().portalAreaLightCount, 1) + XCTAssertGreaterThan( + normalRangeLuminance, + shortRangeLuminance + 0.002, + "Portal range attenuation should reduce contribution outside a short range" + ) + } + + func testMeshRegistrationInvalidatesPortalAreaLightCacheWithinFrame() { + let meshChannel = SceneChannel.userCustom(index: 31) + registerSceneChannelPrefix("WIN_CACHE_", channels: meshChannel) + setSceneChannel(meshChannel, .lightPortal(.enabled( + intensity: 3.0, + range: 8.0, + useRealWorldTint: false, + maxActivePortals: 1, + activationDistance: 20.0 + ))) + + let portal = createEntity() + setEntityName(entityId: portal, name: "WIN_CACHE_Portal") + setEntityMeshDirect(entityId: portal, meshes: BasicPrimitives.createCube(extent: 1.0), assetName: "WIN_CACHE_Portal") + scene.get(component: LocalTransformComponent.self, for: portal)?.boundingBox = ( + min: simd_float3(-0.5, -0.5, -0.025), + max: simd_float3(0.5, 0.5, 0.025) + ) + scaleTo(entityId: portal, scale: simd_float3(2.0, 2.0, 0.05)) + translateTo(entityId: portal, position: simd_float3(0.0, 1.1, 2.0)) + + let initialLights = getAreaLights() + setEntityMeshDirect(entityId: portal, meshes: BasicPrimitives.createCube(extent: 1.0), assetName: "WIN_CACHE_Portal") + let updatedLights = getAreaLights() + + XCTAssertEqual(initialLights.count, 1) + XCTAssertTrue(updatedLights.isEmpty) + XCTAssertEqual(getLightPortalDiscoveryDiagnostics().skippedInvalidGeometryCount, 1) + } + + private func renderLightPassAverageLuminance( + file: StaticString = #filePath, + line: UInt = #line + ) -> Float { + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + + guard let texture = renderInfo.deferredRenderPassDescriptor.colorAttachments[Int(colorTarget.rawValue)].texture else { + XCTFail("Expected light-pass color texture", file: file, line: line) + return 0.0 + } + + return averageLuminance( + texture: texture, + normalizedRegion: (x: 0.35, y: 0.35, width: 0.3, height: 0.3), + file: file, + line: line + ) + } + + private func averageLuminance( + texture: MTLTexture, + normalizedRegion: (x: Float, y: Float, width: Float, height: Float), + file: StaticString = #filePath, + line: UInt = #line + ) -> Float { + let x = max(0, min(texture.width - 1, Int(Float(texture.width) * normalizedRegion.x))) + let y = max(0, min(texture.height - 1, Int(Float(texture.height) * normalizedRegion.y))) + let width = max(1, min(texture.width - x, Int(Float(texture.width) * normalizedRegion.width))) + let height = max(1, min(texture.height - y, Int(Float(texture.height) * normalizedRegion.height))) + let region = MTLRegionMake2D(x, y, width, height) + + switch texture.pixelFormat { + case .rgba16Float: + let bytesPerPixel = 8 + let bytesPerRow = width * bytesPerPixel + let sampleCount = width * height + var data = [UInt16](repeating: 0, count: sampleCount * 4) + data.withUnsafeMutableBytes { rawBuffer in + texture.getBytes( + rawBuffer.baseAddress!, + bytesPerRow: bytesPerRow, + from: region, + mipmapLevel: 0 + ) + } + + var total: Float = 0.0 + for index in 0 ..< sampleCount { + let base = index * 4 + let rgb = simd_float3( + Float(Float16(bitPattern: data[base])), + Float(Float16(bitPattern: data[base + 1])), + Float(Float16(bitPattern: data[base + 2])) + ) + total += simd_dot(rgb, simd_float3(0.2126, 0.7152, 0.0722)) + } + return total / Float(sampleCount) + + default: + XCTFail("Unsupported light-pass pixel format: \(texture.pixelFormat)", file: file, line: line) + return 0.0 + } + } +} diff --git a/Tests/UntoldEngineRenderTests/RuntimeEnvironmentLightingTests.swift b/Tests/UntoldEngineRenderTests/RuntimeEnvironmentLightingTests.swift new file mode 100644 index 00000000..5990efcd --- /dev/null +++ b/Tests/UntoldEngineRenderTests/RuntimeEnvironmentLightingTests.swift @@ -0,0 +1,273 @@ +// +// RuntimeEnvironmentLightingTests.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 Metal +@testable import UntoldEngine +import XCTest + +final class RuntimeEnvironmentLightingTests: XCTestCase { + override func tearDown() { + RuntimeEnvironmentLightingStore.shared.reset() + super.tearDown() + } + + func testDefaultModePreservesStaticIBLBehavior() { + RuntimeEnvironmentLightingStore.shared.reset() + + let resolved = RuntimeEnvironmentLightingStore.shared.resolve( + staticIrradianceMap: nil, + staticSpecularMap: nil, + staticBRDFMap: nil, + staticIBLEnabled: true, + ambientIntensity: 0.7 + ) + + XCTAssertEqual(resolved.mode, .staticIBL) + XCTAssertTrue(resolved.applyIBL) + XCTAssertEqual(resolved.ambientIntensity, 0.7, accuracy: 0.0001) + XCTAssertNil(resolved.fallbackReason) + } + + func testAuthoredOnlyDisablesIBLWithoutChangingStaticTextureInputs() { + RuntimeEnvironmentLightingStore.shared.mode = .authoredOnly + + let resolved = RuntimeEnvironmentLightingStore.shared.resolve( + staticIrradianceMap: nil, + staticSpecularMap: nil, + staticBRDFMap: nil, + staticIBLEnabled: true, + ambientIntensity: 0.4 + ) + + XCTAssertEqual(resolved.mode, .authoredOnly) + XCTAssertFalse(resolved.applyIBL) + XCTAssertEqual(resolved.ambientIntensity, 0.4, accuracy: 0.0001) + } + + func testRealWorldEstimateFallsBackToStaticIBLWhenNoXRLightingIsAvailable() { + RuntimeEnvironmentLightingStore.shared.mode = .realWorldEstimate + + let resolved = RuntimeEnvironmentLightingStore.shared.resolve( + staticIrradianceMap: nil, + staticSpecularMap: nil, + staticBRDFMap: nil, + staticIBLEnabled: true, + ambientIntensity: 0.5 + ) + + XCTAssertEqual(resolved.mode, .staticIBL) + XCTAssertTrue(resolved.applyIBL) + XCTAssertEqual(resolved.ambientIntensity, 0.5, accuracy: 0.0001) + XCTAssertEqual(resolved.fallbackReason, "XR lighting unavailable") + } + + func testInvalidXRLightingFallsBackToStaticIBL() { + RuntimeEnvironmentLightingStore.shared.mode = .realWorldEstimate + RuntimeEnvironmentLightingStore.shared.publishXRLighting( + RuntimeEnvironmentLighting( + irradianceMap: nil, + specularMap: nil, + brdfMap: nil, + intensityScale: 2.0, + isValid: false + ) + ) + + let resolved = RuntimeEnvironmentLightingStore.shared.resolve( + staticIrradianceMap: nil, + staticSpecularMap: nil, + staticBRDFMap: nil, + staticIBLEnabled: false, + ambientIntensity: 0.2 + ) + + XCTAssertEqual(resolved.mode, .staticIBL) + XCTAssertFalse(resolved.applyIBL) + XCTAssertEqual(resolved.ambientIntensity, 0.2, accuracy: 0.0001) + XCTAssertEqual(resolved.fallbackReason, "XR lighting unavailable") + } + + func testRenderingEnvironmentSettingUpdatesLightingMode() { + setRendering(.environment(.lightingMode(.realWorldEstimate))) + + XCTAssertEqual(RuntimeEnvironmentLightingStore.shared.mode, .realWorldEstimate) + } + + func testRenderingEnvironmentLightingModeNotifiesObservers() { + let recorder = LightingModeRecorder() + let observerId = RuntimeEnvironmentLightingStore.shared.observeLightingModeChanges { mode in + recorder.append(mode) + } + defer { + RuntimeEnvironmentLightingStore.shared.removeLightingModeChangeObserver(observerId) + } + + setRendering(.environment(.lightingMode(.realWorldEstimate))) + + XCTAssertEqual(recorder.values, [.realWorldEstimate]) + } + + func testLightingModeCanNotifyObserversWhenSetToSameValueForLifecycleRefresh() { + RuntimeEnvironmentLightingStore.shared.mode = .realWorldEstimate + + let recorder = LightingModeRecorder() + let observerId = RuntimeEnvironmentLightingStore.shared.observeLightingModeChanges { mode in + recorder.append(mode) + } + defer { + RuntimeEnvironmentLightingStore.shared.removeLightingModeChangeObserver(observerId) + } + + RuntimeEnvironmentLightingStore.shared.setMode(.realWorldEstimate, notifyIfUnchanged: true) + + XCTAssertEqual(recorder.values, [.realWorldEstimate]) + } + + func testRenderingEnvironmentSettingUpdatesRealWorldLightingContribution() { + setRendering(.environment(.realWorldLightingContribution(0.35))) + + XCTAssertEqual(RuntimeEnvironmentLightingStore.shared.realWorldLightingContribution, 0.35, accuracy: 0.0001) + } + + func testValidXRLightingOverridesStaticIBLAndAppliesIntensityScale() throws { + guard let device = MTLCreateSystemDefaultDevice() else { + throw XCTSkip("Metal device is required for texture-backed XR lighting test") + } + + let xrIrradiance = try makeTexture(device: device, label: "XR Irradiance") + let xrSpecular = try makeTexture(device: device, label: "XR Specular") + let xrBRDF = try makeTexture(device: device, label: "XR BRDF") + let staticIrradiance = try makeTexture(device: device, label: "Static Irradiance") + let staticSpecular = try makeTexture(device: device, label: "Static Specular") + let staticBRDF = try makeTexture(device: device, label: "Static BRDF") + + RuntimeEnvironmentLightingStore.shared.mode = .realWorldEstimate + RuntimeEnvironmentLightingStore.shared.publishXRLighting( + RuntimeEnvironmentLighting( + irradianceMap: xrIrradiance, + specularMap: xrSpecular, + brdfMap: xrBRDF, + intensityScale: 1.5, + isValid: true + ) + ) + + let resolved = RuntimeEnvironmentLightingStore.shared.resolve( + staticIrradianceMap: staticIrradiance, + staticSpecularMap: staticSpecular, + staticBRDFMap: staticBRDF, + staticIBLEnabled: false, + ambientIntensity: 0.4 + ) + + XCTAssertEqual(resolved.mode, .realWorldEstimate) + XCTAssertTrue(resolved.applyIBL) + XCTAssertTrue(resolved.irradianceMap === xrIrradiance) + XCTAssertTrue(resolved.specularMap === xrSpecular) + XCTAssertTrue(resolved.brdfMap === xrBRDF) + XCTAssertEqual(resolved.ambientIntensity, 0.6, accuracy: 0.0001) + XCTAssertNil(resolved.fallbackReason) + } + + func testRealWorldLightingContributionScalesExistingXRLighting() throws { + guard let device = MTLCreateSystemDefaultDevice() else { + throw XCTSkip("Metal device is required for texture-backed XR lighting test") + } + + RuntimeEnvironmentLightingStore.shared.mode = .realWorldEstimate + RuntimeEnvironmentLightingStore.shared.realWorldLightingContribution = 0.25 + try RuntimeEnvironmentLightingStore.shared.publishXRLighting( + RuntimeEnvironmentLighting( + irradianceMap: makeTexture(device: device, label: "XR Irradiance"), + specularMap: makeTexture(device: device, label: "XR Specular"), + brdfMap: makeTexture(device: device, label: "XR BRDF"), + intensityScale: 2.0, + isValid: true + ) + ) + + let resolved = RuntimeEnvironmentLightingStore.shared.resolve( + staticIrradianceMap: nil, + staticSpecularMap: nil, + staticBRDFMap: nil, + staticIBLEnabled: true, + ambientIntensity: 0.8 + ) + + XCTAssertEqual(resolved.ambientIntensity, 0.4, accuracy: 0.0001) + XCTAssertNil(resolved.fallbackReason) + } + + func testRealWorldLightingContributionClampsNegativeValues() { + RuntimeEnvironmentLightingStore.shared.realWorldLightingContribution = -0.5 + + XCTAssertEqual(RuntimeEnvironmentLightingStore.shared.realWorldLightingContribution, 0.0, accuracy: 0.0001) + } + + func testIncompleteXRLightingFallsBackWithoutApplyingIntensityScale() { + RuntimeEnvironmentLightingStore.shared.mode = .realWorldEstimate + RuntimeEnvironmentLightingStore.shared.publishXRLighting( + RuntimeEnvironmentLighting( + irradianceMap: nil, + specularMap: nil, + brdfMap: nil, + intensityScale: 2.5, + isValid: true + ) + ) + + let resolved = RuntimeEnvironmentLightingStore.shared.resolve( + staticIrradianceMap: nil, + staticSpecularMap: nil, + staticBRDFMap: nil, + staticIBLEnabled: true, + ambientIntensity: 0.4 + ) + + XCTAssertEqual(resolved.mode, .staticIBL) + XCTAssertEqual(resolved.ambientIntensity, 0.4, accuracy: 0.0001) + XCTAssertEqual(resolved.fallbackReason, "XR lighting unavailable") + } + + private func makeTexture(device: MTLDevice, label: String) throws -> MTLTexture { + let descriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .rgba8Unorm, + width: 1, + height: 1, + mipmapped: false + ) + descriptor.usage = [.shaderRead] + + guard let texture = device.makeTexture(descriptor: descriptor) else { + throw XCTSkip("Unable to allocate test texture: \(label)") + } + + texture.label = label + return texture + } +} + +private final class LightingModeRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storedValues: [RuntimeEnvironmentLightingMode] = [] + + var values: [RuntimeEnvironmentLightingMode] { + lock.lock() + let values = storedValues + lock.unlock() + return values + } + + func append(_ mode: RuntimeEnvironmentLightingMode) { + lock.lock() + storedValues.append(mode) + lock.unlock() + } +} diff --git a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift index f90010ee..e252c0e4 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift @@ -1820,6 +1820,45 @@ final class StaticBatchingTest: BaseRenderSetup { "❌ Alpha mode should revert to .opaque when opacity is restored to 1.0") } + func testRecursiveOpacityUpdatesRenderableDescendants() { + let root = createStaticCubeEntity(position: .zero, markStatic: false) + let child = createStaticCubeEntity(position: simd_float3(1.0, 0.0, 0.0), markStatic: false) + let grandchild = createStaticCubeEntity(position: simd_float3(2.0, 0.0, 0.0), markStatic: false) + let sibling = createStaticCubeEntity(position: simd_float3(3.0, 0.0, 0.0), markStatic: false) + setParent(childId: child, parentId: root) + setParent(childId: grandchild, parentId: child) + + updateMaterialOpacity(entityId: root, opacity: 0.35, recursive: true) + + XCTAssertEqual(getMaterialOpacity(entityId: root), 0.35, accuracy: 0.0001) + XCTAssertEqual(getMaterialOpacity(entityId: child), 0.35, accuracy: 0.0001) + XCTAssertEqual(getMaterialOpacity(entityId: grandchild), 0.35, accuracy: 0.0001) + XCTAssertEqual(getMaterialOpacity(entityId: sibling), 1.0, accuracy: 0.0001) + XCTAssertEqual(getMaterialAlphaMode(entityId: root), .blend) + XCTAssertEqual(getMaterialAlphaMode(entityId: child), .blend) + XCTAssertEqual(getMaterialAlphaMode(entityId: grandchild), .blend) + XCTAssertEqual(getMaterialAlphaMode(entityId: sibling), .opaque) + } + + func testRecursiveEmissiveUpdatesRenderableDescendantsOnce() { + let root = createStaticCubeEntity(position: .zero, markStatic: false) + let child = createStaticCubeEntity(position: simd_float3(1.0, 0.0, 0.0), markStatic: false) + let grandchild = createStaticCubeEntity(position: simd_float3(2.0, 0.0, 0.0), markStatic: false) + setParent(childId: child, parentId: root) + setParent(childId: grandchild, parentId: child) + + if let rootScenegraph = scene.get(component: ScenegraphComponent.self, for: root) { + rootScenegraph.children.append(child) + } + + let emissive = simd_float3(0.25, 0.5, 1.0) + updateMaterialEmmisive(entityId: root, emmissive: emissive, recursive: true) + + XCTAssertEqual(getMaterialEmmissive(entityId: root), emissive) + XCTAssertEqual(getMaterialEmmissive(entityId: child), emissive) + XCTAssertEqual(getMaterialEmmissive(entityId: grandchild), emissive) + } + func testLegacyEmbeddedPseudoURLsDoNotSplitBatching() throws { // Legacy embedded pseudo-URLs were mesh-scoped: // usdz-embedded:///embedded_Basecolor_map diff --git a/Tests/UntoldEngineTests/InputSystemExtensionsTest.swift b/Tests/UntoldEngineTests/InputSystemExtensionsTest.swift index 897b8bbf..5ba3383d 100644 --- a/Tests/UntoldEngineTests/InputSystemExtensionsTest.swift +++ b/Tests/UntoldEngineTests/InputSystemExtensionsTest.swift @@ -60,6 +60,31 @@ final class InputSystemExtensionsTests: XCTestCase { // MARK: - Mouse Input Tests + func test_keyboardStates_includeHFAndTab() { + let input = InputSystem.shared + + XCTAssertFalse(input.keyState.hPressed) + XCTAssertFalse(input.keyState.tabPressed) + XCTAssertFalse(input.keyState.fPressed) + XCTAssertFalse(input.keyState.f1Pressed) + XCTAssertFalse(input.keyState.f6Pressed) + XCTAssertFalse(input.keyState.f12Pressed) + + input.keyState.hPressed = true + input.keyState.tabPressed = true + input.keyState.fPressed = true + input.keyState.f1Pressed = true + input.keyState.f6Pressed = true + input.keyState.f12Pressed = true + + XCTAssertTrue(input.keyState.hPressed) + XCTAssertTrue(input.keyState.tabPressed) + XCTAssertTrue(input.keyState.fPressed) + XCTAssertTrue(input.keyState.f1Pressed) + XCTAssertTrue(input.keyState.f6Pressed) + XCTAssertTrue(input.keyState.f12Pressed) + } + func test_mouseButtonStates_inKeyState() { let input = InputSystem.shared diff --git a/Tests/UntoldEngineTests/LightPortalSystemTests.swift b/Tests/UntoldEngineTests/LightPortalSystemTests.swift new file mode 100644 index 00000000..db06bec3 --- /dev/null +++ b/Tests/UntoldEngineTests/LightPortalSystemTests.swift @@ -0,0 +1,562 @@ +// +// LightPortalSystemTests.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 + +@MainActor +final class LightPortalSystemTests: XCTestCase { + override func setUp() async throws { + resetEngineTestState() + } + + func testDiscoveryReturnsRenderableEntitiesWithEnabledPortalChannels() { + let windowChannel = SceneChannel.userCustom(index: 0) + setSceneChannel( + windowChannel, + .lightPortal(.enabled(intensity: 2.0, range: 8.0, useRealWorldTint: true, maxActivePortals: 4, activationDistance: 12.0)) + ) + + let entityId = makeRenderableEntity(channels: windowChannel) + let candidates = discoverSceneLightPortalCandidates() + + XCTAssertEqual(candidates.count, 1) + XCTAssertEqual(candidates.first?.entityId, entityId) + XCTAssertEqual(candidates.first?.channels, windowChannel) + XCTAssertEqual(candidates.first?.mode, .enabled(intensity: 2.0, range: 8.0, useRealWorldTint: true, maxActivePortals: 4, activationDistance: 12.0)) + XCTAssertEqual(candidates.first?.localBoundsMin, simd_float3(-1.0, -1.0, -0.05)) + XCTAssertEqual(candidates.first?.localBoundsMax, simd_float3(1.0, 1.0, 0.05)) + + XCTAssertEqual( + getLightPortalDiscoveryDiagnostics(), + LightPortalDiscoveryDiagnostics( + scannedRenderableEntityCount: 1, + candidateCount: 1, + skippedHiddenCount: 0, + skippedInvisibleRenderComponentCount: 0, + skippedDisabledPortalCount: 0, + skippedInvalidGeometryCount: 0 + ) + ) + } + + func testDiscoveryUsesRegisteredPrefixFallbackChannels() { + let windowChannel = SceneChannel.userCustom(index: 1) + registerSceneChannelPrefix("WIN_", channels: windowChannel) + setSceneChannel(windowChannel, .lightPortal(.enabled())) + + let entityId = makeRenderableEntity(name: "WIN_Main") + let candidates = discoverSceneLightPortalCandidates() + + XCTAssertEqual(candidates.map(\.entityId), [entityId]) + XCTAssertEqual(candidates.first?.channels, windowChannel) + } + + func testDiscoverySkipsHiddenAndInvisibleEntities() { + let windowChannel = SceneChannel.userCustom(index: 2) + let hiddenChannel = SceneChannel.userCustom(index: 3) + setSceneChannel(windowChannel, .lightPortal(.enabled())) + setSceneChannel(hiddenChannel, .renderMode(.hidden)) + + let hiddenEntityId = makeRenderableEntity(channels: [windowChannel, hiddenChannel]) + let invisibleEntityId = makeRenderableEntity(channels: windowChannel, renderVisible: false) + let candidates = discoverSceneLightPortalCandidates() + + XCTAssertTrue(candidates.isEmpty) + XCTAssertFalse(candidates.contains { $0.entityId == hiddenEntityId }) + XCTAssertFalse(candidates.contains { $0.entityId == invisibleEntityId }) + + XCTAssertEqual( + getLightPortalDiscoveryDiagnostics(), + LightPortalDiscoveryDiagnostics( + scannedRenderableEntityCount: 2, + candidateCount: 0, + skippedHiddenCount: 1, + skippedInvisibleRenderComponentCount: 1, + skippedDisabledPortalCount: 0, + skippedInvalidGeometryCount: 0 + ) + ) + } + + func testDiscoverySkipsChannelsWithoutEnabledLightPortalMode() { + let windowChannel = SceneChannel.userCustom(index: 4) + setSceneChannel(windowChannel, .lightPortal(.enabled())) + _ = makeRenderableEntity(channels: .contextGeometry) + + let candidates = discoverSceneLightPortalCandidates() + + XCTAssertTrue(candidates.isEmpty) + XCTAssertEqual( + getLightPortalDiscoveryDiagnostics(), + LightPortalDiscoveryDiagnostics( + scannedRenderableEntityCount: 1, + candidateCount: 0, + skippedHiddenCount: 0, + skippedInvisibleRenderComponentCount: 0, + skippedDisabledPortalCount: 1, + skippedInvalidGeometryCount: 0 + ) + ) + } + + func testDiscoveryFastPathSkipsRenderableScanWhenNoPortalChannelsAreEnabled() { + _ = makeRenderableEntity(channels: .contextGeometry) + + let candidates = discoverSceneLightPortalCandidates() + let areaLights = getAreaLights() + + XCTAssertTrue(candidates.isEmpty) + XCTAssertTrue(areaLights.isEmpty) + XCTAssertEqual(getLightPortalDiscoveryDiagnostics(), .empty) + XCTAssertEqual(getLightPortalResolutionDiagnostics(), .empty) + XCTAssertEqual(getLightPortalPerformanceDiagnostics(), .empty) + } + + func testPerformanceDiagnosticsRecordDiscoveryAndResolutionCost() { + let windowChannel = SceneChannel.userCustom(index: 23) + setSceneChannel(windowChannel, .lightPortal(.enabled(maxActivePortals: 2, activationDistance: 20.0))) + _ = makePortalEntity(channels: windowChannel, position: simd_float3(1.0, 0.0, 0.0)) + _ = makePortalEntity(channels: windowChannel, position: simd_float3(3.0, 0.0, 0.0)) + + _ = resolveSceneLightPortalProxyLights(cameraPosition: .zero) + + let diagnostics = getLightPortalPerformanceDiagnostics() + XCTAssertNotNil(diagnostics.lastDiscoveryDurationMs) + XCTAssertNotNil(diagnostics.lastResolutionDurationMs) + XCTAssertGreaterThanOrEqual(diagnostics.lastDiscoveryDurationMs ?? -1.0, 0.0) + XCTAssertGreaterThanOrEqual(diagnostics.lastResolutionDurationMs ?? -1.0, 0.0) + XCTAssertEqual(diagnostics.lastScannedRenderableEntityCount, 2) + XCTAssertEqual(diagnostics.lastDiscoveredCandidateCount, 2) + XCTAssertEqual(diagnostics.lastResolvedProxyLightCount, 2) + } + + func testDiagnosticsLoggingRequiresLightPortalCategory() { + Logger.resetCategoryToggles() + XCTAssertFalse(Logger.isEnabled(category: .lightPortal)) + + LightPortalSystem.shared.logDiagnosticsNow() + + Logger.enable(category: .lightPortal) + XCTAssertTrue(Logger.isEnabled(category: .lightPortal)) + LightPortalSystem.shared.logDiagnosticsNow() + } + + func testResolveProxyLightsDerivesShapeFromPortalTransform() { + let windowChannel = SceneChannel.userCustom(index: 4) + setSceneChannel(windowChannel, .lightPortal(.enabled(intensity: 3.0, range: 9.0, useRealWorldTint: false, maxActivePortals: 2, activationDistance: 20.0))) + + let entityId = makeRenderableEntity(channels: windowChannel) + scene.get(component: LocalTransformComponent.self, for: entityId)?.boundingBox = ( + min: simd_float3(-1.0, -0.5, -0.1), + max: simd_float3(1.0, 0.5, 0.1) + ) + scene.get(component: WorldTransformComponent.self, for: entityId)?.space = matrix4x4Translation(2.0, 3.0, 4.0) * matrix4x4Scale(2.0, 3.0, 1.0) + + let proxyLights = resolveSceneLightPortalProxyLights(cameraPosition: simd_float3(2.0, 3.0, 0.0)) + + XCTAssertEqual(proxyLights.count, 1) + let proxyLight = tryUnwrap(proxyLights.first) + XCTAssertEqual(proxyLight.sourceEntityId, entityId) + XCTAssertEqual(proxyLight.channels, windowChannel) + XCTAssertEqual(proxyLight.position, simd_float3(2.0, 3.0, 4.0)) + XCTAssertEqual(proxyLight.forward, simd_float3(0.0, 0.0, 1.0)) + XCTAssertEqual(proxyLight.right, simd_float3(1.0, 0.0, 0.0)) + XCTAssertEqual(proxyLight.up, simd_float3(0.0, 1.0, 0.0)) + XCTAssertEqual(proxyLight.bounds, simd_float2(4.0, 3.0)) + XCTAssertEqual(proxyLight.color, simd_float3(1.0, 1.0, 1.0)) + XCTAssertEqual(proxyLight.intensity, 3.0) + XCTAssertEqual(proxyLight.range, 9.0) + XCTAssertEqual(proxyLight.distanceToCamera, 4.0) + XCTAssertFalse(proxyLight.useRealWorldTint) + } + + func testResolveProxyLightsDerivesPlaneFromLargestBoundsAxes() { + let windowChannel = SceneChannel.userCustom(index: 10) + setSceneChannel(windowChannel, .lightPortal(.enabled(maxActivePortals: 3, activationDistance: 20.0))) + + let xyWindow = makePortalEntity(channels: windowChannel, position: simd_float3(0.0, 0.0, 1.0)) + scene.get(component: LocalTransformComponent.self, for: xyWindow)?.boundingBox = ( + min: simd_float3(-2.0, -1.0, -0.05), + max: simd_float3(2.0, 1.0, 0.05) + ) + + let xzWindow = makePortalEntity(channels: windowChannel, position: simd_float3(0.0, 0.0, 2.0)) + scene.get(component: LocalTransformComponent.self, for: xzWindow)?.boundingBox = ( + min: simd_float3(-2.0, -0.05, -1.0), + max: simd_float3(2.0, 0.05, 1.0) + ) + + let yzWindow = makePortalEntity(channels: windowChannel, position: simd_float3(0.0, 0.0, 3.0)) + scene.get(component: LocalTransformComponent.self, for: yzWindow)?.boundingBox = ( + min: simd_float3(-0.05, -2.0, -1.0), + max: simd_float3(0.05, 2.0, 1.0) + ) + + let proxyLights = resolveSceneLightPortalProxyLights(cameraPosition: .zero) + + let xyProxy = tryUnwrap(proxyLights.first { $0.sourceEntityId == xyWindow }) + XCTAssertEqual(xyProxy.bounds, simd_float2(4.0, 2.0)) + XCTAssertEqual(xyProxy.right, simd_float3(1.0, 0.0, 0.0)) + XCTAssertEqual(xyProxy.up, simd_float3(0.0, 1.0, 0.0)) + XCTAssertEqual(xyProxy.forward, simd_float3(0.0, 0.0, 1.0)) + + let xzProxy = tryUnwrap(proxyLights.first { $0.sourceEntityId == xzWindow }) + XCTAssertEqual(xzProxy.bounds, simd_float2(4.0, 2.0)) + XCTAssertEqual(xzProxy.right, simd_float3(1.0, 0.0, 0.0)) + XCTAssertEqual(xzProxy.up, simd_float3(0.0, 0.0, 1.0)) + XCTAssertEqual(xzProxy.forward, simd_float3(0.0, 1.0, 0.0)) + + let yzProxy = tryUnwrap(proxyLights.first { $0.sourceEntityId == yzWindow }) + XCTAssertEqual(yzProxy.bounds, simd_float2(4.0, 2.0)) + XCTAssertEqual(yzProxy.right, simd_float3(0.0, 1.0, 0.0)) + XCTAssertEqual(yzProxy.up, simd_float3(0.0, 0.0, 1.0)) + XCTAssertEqual(yzProxy.forward, simd_float3(1.0, 0.0, 0.0)) + } + + func testResolveProxyLightsSelectsNearestActivePortalsWithinCap() { + let windowChannel = SceneChannel.userCustom(index: 5) + setSceneChannel(windowChannel, .lightPortal(.enabled(maxActivePortals: 2, activationDistance: 100.0))) + + let far = makePortalEntity(channels: windowChannel, position: simd_float3(20.0, 0.0, 0.0)) + let near = makePortalEntity(channels: windowChannel, position: simd_float3(2.0, 0.0, 0.0)) + let middle = makePortalEntity(channels: windowChannel, position: simd_float3(8.0, 0.0, 0.0)) + + let proxyLights = resolveSceneLightPortalProxyLights(cameraPosition: .zero) + + XCTAssertEqual(proxyLights.map(\.sourceEntityId), [near, middle]) + XCTAssertFalse(proxyLights.contains { $0.sourceEntityId == far }) + XCTAssertEqual( + getLightPortalResolutionDiagnostics(), + LightPortalResolutionDiagnostics( + discoveredCandidateCount: 3, + activePortalCount: 2, + skippedByActivationDistanceCount: 0, + maxActivePortals: 2 + ) + ) + } + + func testResolveProxyLightsSkipsPortalsBeyondActivationDistance() { + let windowChannel = SceneChannel.userCustom(index: 6) + setSceneChannel(windowChannel, .lightPortal(.enabled(maxActivePortals: 4, activationDistance: 5.0))) + + let active = makePortalEntity(channels: windowChannel, position: simd_float3(3.0, 0.0, 0.0)) + let skipped = makePortalEntity(channels: windowChannel, position: simd_float3(10.0, 0.0, 0.0)) + + let proxyLights = resolveSceneLightPortalProxyLights(cameraPosition: .zero) + + XCTAssertEqual(proxyLights.map(\.sourceEntityId), [active]) + XCTAssertFalse(proxyLights.contains { $0.sourceEntityId == skipped }) + XCTAssertEqual( + getLightPortalResolutionDiagnostics(), + LightPortalResolutionDiagnostics( + discoveredCandidateCount: 2, + activePortalCount: 1, + skippedByActivationDistanceCount: 1, + maxActivePortals: 4 + ) + ) + } + + func testResolveProxyLightsUsesDistanceToPortalSurfaceForActivation() { + let windowChannel = SceneChannel.userCustom(index: 14) + setSceneChannel(windowChannel, .lightPortal(.enabled(maxActivePortals: 1, activationDistance: 2.0))) + + let entityId = makePortalEntity(channels: windowChannel, position: .zero) + scene.get(component: LocalTransformComponent.self, for: entityId)?.boundingBox = ( + min: simd_float3(-10.0, -1.0, -0.05), + max: simd_float3(10.0, 1.0, 0.05) + ) + + let proxyLights = resolveSceneLightPortalProxyLights(cameraPosition: simd_float3(9.5, 0.0, 1.0)) + + XCTAssertEqual(proxyLights.map(\.sourceEntityId), [entityId]) + XCTAssertEqual(proxyLights.first?.distanceToCamera ?? -1.0, 1.0, accuracy: 0.0001) + } + + func testResolveProxyLightsAppliesCapsPerPortalChannel() { + let westWindows = SceneChannel.userCustom(index: 15) + let eastWindows = SceneChannel.userCustom(index: 16) + setSceneChannel(westWindows, .lightPortal(.enabled(maxActivePortals: 1, activationDistance: 100.0))) + setSceneChannel(eastWindows, .lightPortal(.enabled(maxActivePortals: 1, activationDistance: 100.0))) + + let westNear = makePortalEntity(channels: westWindows, position: simd_float3(1.0, 0.0, 0.0)) + let westFar = makePortalEntity(channels: westWindows, position: simd_float3(2.0, 0.0, 0.0)) + let eastNear = makePortalEntity(channels: eastWindows, position: simd_float3(3.0, 0.0, 0.0)) + let eastFar = makePortalEntity(channels: eastWindows, position: simd_float3(4.0, 0.0, 0.0)) + + let proxyLights = resolveSceneLightPortalProxyLights(cameraPosition: .zero) + + XCTAssertEqual(proxyLights.map(\.sourceEntityId), [westNear, eastNear]) + XCTAssertFalse(proxyLights.contains { $0.sourceEntityId == westFar }) + XCTAssertFalse(proxyLights.contains { $0.sourceEntityId == eastFar }) + } + + func testResolveProxyLightsConsumesCapacityForEachPortalChannelMembership() { + let broadWindows = SceneChannel.userCustom(index: 20) + let specificWindows = SceneChannel.userCustom(index: 21) + setSceneChannel(broadWindows, .lightPortal(.enabled(maxActivePortals: 1, activationDistance: 100.0))) + setSceneChannel(specificWindows, .lightPortal(.enabled(maxActivePortals: 2, activationDistance: 100.0))) + + let shared = makePortalEntity(channels: [broadWindows, specificWindows], position: simd_float3(1.0, 0.0, 0.0)) + let specificOnly = makePortalEntity(channels: specificWindows, position: simd_float3(2.0, 0.0, 0.0)) + let broadOnly = makePortalEntity(channels: broadWindows, position: simd_float3(3.0, 0.0, 0.0)) + + let proxyLights = resolveSceneLightPortalProxyLights(cameraPosition: .zero) + + XCTAssertEqual(proxyLights.map(\.sourceEntityId), [shared, specificOnly]) + XCTAssertFalse(proxyLights.contains { $0.sourceEntityId == broadOnly }) + } + + func testDiscoverySkipsPortalChannelsWithNonThinGeometry() { + let windowChannel = SceneChannel.userCustom(index: 17) + setSceneChannel(windowChannel, .lightPortal(.enabled())) + + _ = makeRenderableEntity(channels: windowChannel, portalBounds: ( + min: simd_float3(-1.0, -1.0, -1.0), + max: simd_float3(1.0, 1.0, 1.0) + )) + + XCTAssertTrue(discoverSceneLightPortalCandidates().isEmpty) + XCTAssertEqual(getLightPortalDiscoveryDiagnostics().skippedInvalidGeometryCount, 1) + } + + func testAreaLightsIncludeResolvedLightPortalProxies() { + let windowChannel = SceneChannel.userCustom(index: 7) + setSceneChannel(windowChannel, .lightPortal(.enabled(intensity: 2.0, range: 6.0, useRealWorldTint: false, maxActivePortals: 2, activationDistance: 20.0))) + _ = makePortalEntity(channels: windowChannel, position: simd_float3(1.0, 2.0, 3.0)) + + let areaLights = getAreaLights() + + XCTAssertEqual(areaLights.count, 1) + XCTAssertEqual(areaLights[0].position, simd_float3(1.0, 2.0, 3.0)) + XCTAssertEqual(areaLights[0].intensity, 2.0) + XCTAssertEqual(areaLights[0].range, 6.0) + XCTAssertEqual(areaLights[0].nearSourceSuppressionRadius, 0.4, accuracy: 0.0001) + XCTAssertEqual(areaLights[0].bounds, simd_float2(2.0, 2.0)) + XCTAssertTrue(areaLights[0].twoSided) + } + + func testAreaLightsReuseLightPortalCacheWithinFrame() { + let windowChannel = SceneChannel.userCustom(index: 12) + setSceneChannel(windowChannel, .lightPortal(.enabled(maxActivePortals: 1, activationDistance: 20.0))) + let entityId = makePortalEntity(channels: windowChannel, position: simd_float3(1.0, 0.0, 0.0)) + + let firstFrameLights = getAreaLights() + scene.get(component: RenderComponent.self, for: entityId)?.isVisible = false + let cachedSameFrameLights = getAreaLights() + + XCTAssertEqual(firstFrameLights.count, 1) + XCTAssertEqual(cachedSameFrameLights.count, 1) + XCTAssertEqual(cachedSameFrameLights[0].position, simd_float3(1.0, 0.0, 0.0)) + XCTAssertEqual(getLightPortalRenderDiagnostics().portalAreaLightCount, 1) + + cullFrameIndex &+= 1 + let nextFrameLights = getAreaLights() + + XCTAssertTrue(nextFrameLights.isEmpty) + XCTAssertEqual(getLightPortalRenderDiagnostics().portalAreaLightCount, 0) + XCTAssertEqual(getLightPortalRenderDiagnostics().fallbackReason, "No active portal proxy lights") + } + + func testSceneChannelLightPortalChangesInvalidateAreaLightCacheWithinFrame() { + let windowChannel = SceneChannel.userCustom(index: 13) + setSceneChannel(windowChannel, .lightPortal(.enabled(intensity: 1.0, useRealWorldTint: false, maxActivePortals: 1, activationDistance: 20.0))) + _ = makePortalEntity(channels: windowChannel, position: simd_float3(1.0, 0.0, 0.0)) + + let initialLights = getAreaLights() + setSceneChannel(windowChannel, .lightPortal(.enabled(intensity: 2.5, useRealWorldTint: false, maxActivePortals: 1, activationDistance: 20.0))) + let updatedLights = getAreaLights() + + XCTAssertEqual(initialLights.count, 1) + XCTAssertEqual(initialLights[0].intensity, 1.0, accuracy: 0.0001) + XCTAssertEqual(updatedLights.count, 1) + XCTAssertEqual(updatedLights[0].intensity, 2.5, accuracy: 0.0001) + XCTAssertEqual(getLightPortalRenderDiagnostics().maxEffectivePortalIntensity, 2.5) + } + + func testTransformChangesInvalidateAreaLightCacheWithinFrame() { + let windowChannel = SceneChannel.userCustom(index: 18) + setSceneChannel(windowChannel, .lightPortal(.enabled(maxActivePortals: 1, activationDistance: 20.0))) + let entityId = makePortalEntity(channels: windowChannel, position: simd_float3(1.0, 0.0, 0.0)) + + let initialLights = getAreaLights() + translateTo(entityId: entityId, position: simd_float3(4.0, 0.0, 0.0)) + let updatedLights = getAreaLights() + + XCTAssertEqual(initialLights.first?.position, simd_float3(1.0, 0.0, 0.0)) + XCTAssertEqual(updatedLights.first?.position, simd_float3(4.0, 0.0, 0.0)) + } + + func testActiveCameraMovementInvalidatesAreaLightCacheWithinFrame() { + let windowChannel = SceneChannel.userCustom(index: 22) + setSceneChannel(windowChannel, .lightPortal(.enabled(maxActivePortals: 1, activationDistance: 5.0))) + _ = makePortalEntity(channels: windowChannel, position: .zero) + + let camera = createEntity() + createGameCamera(entityId: camera) + translateTo(entityId: camera, position: simd_float3(100.0, 0.0, 0.0)) + + let inactiveLights = getAreaLights() + translateTo(entityId: camera, position: simd_float3(0.0, 0.0, 1.0)) + let activeLights = getAreaLights() + + XCTAssertTrue(inactiveLights.isEmpty) + XCTAssertEqual(activeLights.count, 1) + XCTAssertEqual(getLightPortalRenderDiagnostics().portalAreaLightCount, 1) + } + + func testAreaLightPortalProxyUsesXRContributionScaleWhenRequested() { + let windowChannel = SceneChannel.userCustom(index: 8) + setSceneChannel(windowChannel, .lightPortal(.enabled(intensity: 2.0, useRealWorldTint: true, maxActivePortals: 1, activationDistance: 20.0))) + _ = makePortalEntity(channels: windowChannel, position: simd_float3(1.0, 0.0, 0.0)) + RuntimeEnvironmentLightingStore.shared.mode = .realWorldEstimate + RuntimeEnvironmentLightingStore.shared.realWorldLightingContribution = 0.5 + RuntimeEnvironmentLightingStore.shared.publishXRLighting( + RuntimeEnvironmentLighting( + irradianceMap: nil, + specularMap: nil, + brdfMap: nil, + intensityScale: 3.0, + tintColor: simd_float3(1.2, 0.9, 0.6), + isValid: true + ) + ) + + let areaLights = getAreaLights() + + XCTAssertEqual(areaLights.count, 1) + XCTAssertEqual(areaLights[0].intensity, 3.0, accuracy: 0.0001) + XCTAssertEqual(areaLights[0].color, simd_float3(1.2, 0.9, 0.6)) + + let diagnostics = getLightPortalRenderDiagnostics() + XCTAssertEqual(diagnostics.portalAreaLightCount, 1) + XCTAssertEqual(diagnostics.environmentIntensityScale, 1.5, accuracy: 0.0001) + XCTAssertEqual(diagnostics.xrIntensityScale, 3.0) + XCTAssertEqual(diagnostics.environmentTintColor, simd_float3(1.2, 0.9, 0.6)) + XCTAssertEqual(diagnostics.realWorldLightingContribution, 0.5) + XCTAssertEqual(diagnostics.maxEffectivePortalIntensity, 3.0) + XCTAssertNil(diagnostics.fallbackReason) + } + + func testXRLightingChangesInvalidateAreaLightCacheWithinFrame() { + let windowChannel = SceneChannel.userCustom(index: 19) + setSceneChannel(windowChannel, .lightPortal(.enabled(intensity: 2.0, useRealWorldTint: true, maxActivePortals: 1, activationDistance: 20.0))) + _ = makePortalEntity(channels: windowChannel, position: simd_float3(1.0, 0.0, 0.0)) + RuntimeEnvironmentLightingStore.shared.mode = .realWorldEstimate + RuntimeEnvironmentLightingStore.shared.realWorldLightingContribution = 1.0 + RuntimeEnvironmentLightingStore.shared.publishXRLighting( + RuntimeEnvironmentLighting( + irradianceMap: nil, + specularMap: nil, + brdfMap: nil, + intensityScale: 1.0, + tintColor: simd_float3(1.0, 1.0, 1.0), + isValid: true + ) + ) + + let initialLights = getAreaLights() + RuntimeEnvironmentLightingStore.shared.publishXRLighting( + RuntimeEnvironmentLighting( + irradianceMap: nil, + specularMap: nil, + brdfMap: nil, + intensityScale: 3.0, + tintColor: simd_float3(1.3, 0.8, 0.6), + isValid: true + ) + ) + let updatedLights = getAreaLights() + + XCTAssertEqual(initialLights.first?.intensity ?? -1.0, 2.0, accuracy: 0.0001) + XCTAssertEqual(updatedLights.first?.intensity ?? -1.0, 6.0, accuracy: 0.0001) + XCTAssertEqual(updatedLights.first?.color, simd_float3(1.3, 0.8, 0.6)) + } + + func testAreaLightPortalProxyUsesZeroIntensityWhenXRContributionIsUnavailable() { + let windowChannel = SceneChannel.userCustom(index: 11) + setSceneChannel(windowChannel, .lightPortal(.enabled(intensity: 2.0, useRealWorldTint: true, maxActivePortals: 1, activationDistance: 20.0))) + _ = makePortalEntity(channels: windowChannel, position: simd_float3(1.0, 0.0, 0.0)) + RuntimeEnvironmentLightingStore.shared.mode = .realWorldEstimate + RuntimeEnvironmentLightingStore.shared.publishXRLighting(nil) + + let areaLights = getAreaLights() + + XCTAssertEqual(areaLights.count, 1) + XCTAssertEqual(areaLights[0].intensity, 0.0, accuracy: 0.0001) + + let diagnostics = getLightPortalRenderDiagnostics() + XCTAssertEqual(diagnostics.portalAreaLightCount, 1) + XCTAssertEqual(diagnostics.environmentIntensityScale, 0.0, accuracy: 0.0001) + XCTAssertFalse(diagnostics.xrLightingValid) + XCTAssertEqual(diagnostics.maxEffectivePortalIntensity, 0.0) + XCTAssertEqual(diagnostics.fallbackReason, "XR lighting unavailable for real-world portal tint") + } + + func testAuthoredAreaLightsTakeCapacityBeforePortalProxyLights() { + for _ in 0 ..< maxAreaLights { + makeAuthoredAreaLight() + } + + let windowChannel = SceneChannel.userCustom(index: 9) + setSceneChannel(windowChannel, .lightPortal(.enabled(maxActivePortals: 1, activationDistance: 20.0))) + _ = makePortalEntity(channels: windowChannel, position: simd_float3(1.0, 0.0, 0.0)) + + let areaLights = getAreaLights() + + XCTAssertEqual(areaLights.count, maxAreaLights) + XCTAssertFalse(areaLights.contains { $0.position == simd_float3(1.0, 0.0, 0.0) }) + } + + private func makeRenderableEntity( + name: String? = nil, + channels: SceneChannel? = nil, + renderVisible: Bool = true, + portalBounds: (min: simd_float3, max: simd_float3) = ( + min: simd_float3(-1.0, -1.0, -0.05), + max: simd_float3(1.0, 1.0, 0.05) + ) + ) -> EntityID { + let entityId = createEntity() + if let name { + setEntityName(entityId: entityId, name: name) + } + registerComponent(entityId: entityId, componentType: RenderComponent.self) + scene.get(component: LocalTransformComponent.self, for: entityId)?.boundingBox = portalBounds + if let channels { + setEntitySceneChannels(entityId: entityId, channels: channels) + } + scene.get(component: RenderComponent.self, for: entityId)?.isVisible = renderVisible + return entityId + } + + private func makePortalEntity(channels: SceneChannel, position: simd_float3) -> EntityID { + let entityId = makeRenderableEntity(channels: channels) + scene.get(component: WorldTransformComponent.self, for: entityId)?.space = matrix4x4Translation(position.x, position.y, position.z) + return entityId + } + + private func makeAuthoredAreaLight() { + let entityId = createEntity() + registerComponent(entityId: entityId, componentType: LightComponent.self) + registerComponent(entityId: entityId, componentType: AreaLightComponent.self) + } + + private func tryUnwrap(_ value: T?, file: StaticString = #filePath, line: UInt = #line) -> T { + guard let value else { + XCTFail("Expected non-nil value", file: file, line: line) + fatalError("Expected non-nil value") + } + return value + } +} diff --git a/Tests/UntoldEngineTests/LoggerCategoryTests.swift b/Tests/UntoldEngineTests/LoggerCategoryTests.swift index 100c2a24..e87a7f61 100644 --- a/Tests/UntoldEngineTests/LoggerCategoryTests.swift +++ b/Tests/UntoldEngineTests/LoggerCategoryTests.swift @@ -34,6 +34,7 @@ final class LoggerCategoryTests: XCTestCase { XCTAssertFalse(Logger.isEnabled(category: .streamingHeartbeat)) XCTAssertFalse(Logger.isEnabled(category: .textureStreaming)) XCTAssertFalse(Logger.isEnabled(category: .textureLoading)) + XCTAssertFalse(Logger.isEnabled(category: .lightPortal)) } func testStreamingCategoriesCanBeEnabledIndividually() { @@ -45,6 +46,7 @@ final class LoggerCategoryTests: XCTestCase { XCTAssertFalse(Logger.isEnabled(category: .textureStreaming)) XCTAssertFalse(Logger.isEnabled(category: .textureLoading)) XCTAssertFalse(Logger.isEnabled(category: .streamingHeartbeat)) + XCTAssertFalse(Logger.isEnabled(category: .lightPortal)) } func testWarningsRespectCategoryToggles() { diff --git a/Tests/UntoldEngineTests/SceneContextVisibilityTests.swift b/Tests/UntoldEngineTests/SceneContextVisibilityTests.swift index 1265a6e5..0ecdf60c 100644 --- a/Tests/UntoldEngineTests/SceneContextVisibilityTests.swift +++ b/Tests/UntoldEngineTests/SceneContextVisibilityTests.swift @@ -130,6 +130,97 @@ final class SceneContextVisibilityTests: XCTestCase { XCTAssertEqual(getSceneChannelRenderMode([.contextGeometry, .selectableGeometry, .preserveIdentity]), .hidden) } + func testSceneChannelLightPortalDefaultsToDisabled() { + XCTAssertEqual(getSceneChannelLightPortalMode(.contextGeometry), .disabled) + XCTAssertFalse(shouldUseSceneChannelsAsLightPortals(.contextGeometry)) + XCTAssertFalse(hasSceneChannelLightPortalsEnabled()) + } + + func testSceneChannelLightPortalCanBeEnabledAndDisabled() { + let windowChannel = SceneChannel.userCustom(index: 1) + + setSceneChannel(windowChannel, .lightPortal(.enabled( + intensity: 1.25, + range: 7.5, + useRealWorldTint: true, + maxActivePortals: 6, + activationDistance: 18.0 + ))) + + XCTAssertEqual( + getSceneChannelLightPortalMode(windowChannel), + .enabled( + intensity: 1.25, + range: 7.5, + useRealWorldTint: true, + maxActivePortals: 6, + activationDistance: 18.0 + ) + ) + XCTAssertTrue(shouldUseSceneChannelsAsLightPortals(windowChannel)) + XCTAssertTrue(hasSceneChannelLightPortalsEnabled()) + + setSceneChannel(windowChannel, .lightPortal(.disabled)) + + XCTAssertEqual(getSceneChannelLightPortalMode(windowChannel), .disabled) + XCTAssertFalse(shouldUseSceneChannelsAsLightPortals(windowChannel)) + XCTAssertFalse(hasSceneChannelLightPortalsEnabled()) + } + + func testSceneChannelLightPortalValuesAreClamped() { + let windowChannel = SceneChannel.userCustom(index: 1) + + setSceneChannel(windowChannel, .lightPortal(.enabled( + intensity: -1.0, + range: -5.0, + useRealWorldTint: false, + maxActivePortals: -4, + activationDistance: -.infinity + ))) + + XCTAssertEqual( + getSceneChannelLightPortalMode(windowChannel), + .enabled( + intensity: 0.0, + range: 0.001, + useRealWorldTint: false, + maxActivePortals: 0, + activationDistance: 15.0 + ) + ) + } + + func testCombinedSceneChannelLightPortalUsesMostPermissiveValues() { + let broadChannel = SceneChannel.userCustom(index: 1) + let specificChannel = SceneChannel.userCustom(index: 2) + + setSceneChannel(broadChannel, .lightPortal(.enabled( + intensity: 0.75, + range: 4.0, + useRealWorldTint: false, + maxActivePortals: 4, + activationDistance: 10.0 + ))) + setSceneChannel(specificChannel, .lightPortal(.enabled( + intensity: 1.5, + range: 8.0, + useRealWorldTint: true, + maxActivePortals: 2, + activationDistance: 20.0 + ))) + + XCTAssertEqual( + sceneChannelLightPortalMode(for: [broadChannel, specificChannel]), + .enabled( + intensity: 1.5, + range: 8.0, + useRealWorldTint: true, + maxActivePortals: 4, + activationDistance: 20.0 + ) + ) + } + func testNMNamedEntityIsSelectableSceneEntity() { let entityId = createEntity() setEntityName(entityId: entityId, name: "NM_Pipe_001") diff --git a/Tests/UntoldEngineTests/SpatialManipulationSystemTests.swift b/Tests/UntoldEngineTests/SpatialManipulationSystemTests.swift index 24688691..2c1c4128 100644 --- a/Tests/UntoldEngineTests/SpatialManipulationSystemTests.swift +++ b/Tests/UntoldEngineTests/SpatialManipulationSystemTests.swift @@ -684,6 +684,72 @@ import XCTest XCTAssertEqual(rotatedForward.z, 0.0, accuracy: 0.0001) } + func test_processAnchoredPinchDragLifecycle_appliesPositionTransformContinuously() { + translateTo(entityId: standaloneEntity, position: .zero) + + let startState = makeAnchoredPinchDragState( + pickedEntityId: standaloneEntity, + inputDevicePositionWorld: .zero, + spatialDragActive: false + ) + SpatialManipulationSystem.shared.processAnchoredPinchDragLifecycle( + from: startState, + entityId: standaloneEntity, + dragPlane: .xz, + positionTransform: { position in + simd_float3(position.x.rounded(), position.y, position.z.rounded()) + } + ) + + let dragState = makeAnchoredPinchDragState( + pickedEntityId: standaloneEntity, + inputDevicePositionWorld: simd_float3(0.76, 0.25, 1.1), + spatialDragActive: true + ) + SpatialManipulationSystem.shared.processAnchoredPinchDragLifecycle( + from: dragState, + entityId: standaloneEntity, + dragPlane: .xz, + positionTransform: { position in + simd_float3(position.x.rounded(), position.y, position.z.rounded()) + } + ) + + let position = getPosition(entityId: standaloneEntity) + XCTAssertEqual(position.x, 1.0, accuracy: 0.0001) + XCTAssertEqual(position.y, 0.0, accuracy: 0.0001) + XCTAssertEqual(position.z, 1.0, accuracy: 0.0001) + } + + func test_processAnchoredPinchDragLifecycle_ignoresNonFinitePositionTransformOutput() { + translateTo(entityId: standaloneEntity, position: .zero) + + let startState = makeAnchoredPinchDragState( + pickedEntityId: standaloneEntity, + inputDevicePositionWorld: .zero, + spatialDragActive: false + ) + SpatialManipulationSystem.shared.processAnchoredPinchDragLifecycle(from: startState, entityId: standaloneEntity) + + let dragState = makeAnchoredPinchDragState( + pickedEntityId: standaloneEntity, + inputDevicePositionWorld: simd_float3(1.0, 0.0, 1.0), + spatialDragActive: true + ) + SpatialManipulationSystem.shared.processAnchoredPinchDragLifecycle( + from: dragState, + entityId: standaloneEntity, + positionTransform: { _ in + simd_float3(.nan, 0.0, 0.0) + } + ) + + let position = getPosition(entityId: standaloneEntity) + XCTAssertEqual(position.x, 0.0, accuracy: 0.0001) + XCTAssertEqual(position.y, 0.0, accuracy: 0.0001) + XCTAssertEqual(position.z, 0.0, accuracy: 0.0001) + } + private func registerManipulationEntity(_ entityId: EntityID) { registerComponent(entityId: entityId, componentType: LocalTransformComponent.self) registerComponent(entityId: entityId, componentType: WorldTransformComponent.self) @@ -764,6 +830,21 @@ import XCTest return state } + private func makeAnchoredPinchDragState( + pickedEntityId: EntityID, + inputDevicePositionWorld: simd_float3, + spatialPinchActive: Bool = true, + spatialDragActive: Bool = true + ) -> XRSpatialInputState { + var state = XRSpatialInputState() + state.currentPhase = .changed + state.pickedEntityId = pickedEntityId + state.inputDevicePositionWorld = inputDevicePositionWorld + state.spatialPinchActive = spatialPinchActive + state.spatialDragActive = spatialDragActive + return state + } + private func assertMatrixApproximatelyEqual(_ lhs: simd_float3x3, _ rhs: simd_float3x3, accuracy: Float) { XCTAssertEqual(lhs.columns.0.x, rhs.columns.0.x, accuracy: accuracy) XCTAssertEqual(lhs.columns.0.y, rhs.columns.0.y, accuracy: accuracy) diff --git a/Tests/UntoldEngineTests/TestEngineReset.swift b/Tests/UntoldEngineTests/TestEngineReset.swift index 69536f39..b899cfc6 100644 --- a/Tests/UntoldEngineTests/TestEngineReset.swift +++ b/Tests/UntoldEngineTests/TestEngineReset.swift @@ -35,6 +35,10 @@ scenePickingGPUAvailable = false activeEntity = .invalid OctreeSystem.shared.clear() + LightPortalSystem.shared.resetDiagnostics() + resetLightPortalRenderDiagnostics() + resetLightPortalAreaLightCache() + RuntimeEnvironmentLightingStore.shared.reset() resetSceneChannelVisibility() resetSceneChannelPrefixes() setSceneReady(true) diff --git a/Tests/UntoldEngineTests/USCScriptingAPITest.swift b/Tests/UntoldEngineTests/USCScriptingAPITest.swift index c43de390..fedb0977 100644 --- a/Tests/UntoldEngineTests/USCScriptingAPITest.swift +++ b/Tests/UntoldEngineTests/USCScriptingAPITest.swift @@ -1483,6 +1483,12 @@ final class USCScriptingAPITest: XCTestCase { s.onUpdate() .getKeyState("w", as: "wPressed") .getKeyState("a", as: "aPressed") + .getKeyState("h", as: "hPressed") + .getKeyState("tab", as: "tabPressed") + .getKeyState("f", as: "fPressed") + .getKeyState("f1", as: "f1Pressed") + .getKeyState("f6", as: "f6Pressed") + .getKeyState("f12", as: "f12Pressed") } let entityId = createEntity() @@ -1492,6 +1498,12 @@ final class USCScriptingAPITest: XCTestCase { // Simulate W key pressed InputSystem.shared.keyState.wPressed = true InputSystem.shared.keyState.aPressed = false + InputSystem.shared.keyState.hPressed = true + InputSystem.shared.keyState.tabPressed = true + InputSystem.shared.keyState.fPressed = false + InputSystem.shared.keyState.f1Pressed = true + InputSystem.shared.keyState.f6Pressed = false + InputSystem.shared.keyState.f12Pressed = true interpreter.execute(script: script, context: context, forEvent: "OnUpdate") @@ -1501,12 +1513,40 @@ final class USCScriptingAPITest: XCTestCase { guard case let .bool(aPressed) = context.variables["aPressed"] else { return XCTFail("aPressed should be a bool") } + guard case let .bool(hPressed) = context.variables["hPressed"] else { + return XCTFail("hPressed should be a bool") + } + guard case let .bool(tabPressed) = context.variables["tabPressed"] else { + return XCTFail("tabPressed should be a bool") + } + guard case let .bool(fPressed) = context.variables["fPressed"] else { + return XCTFail("fPressed should be a bool") + } + guard case let .bool(f1Pressed) = context.variables["f1Pressed"] else { + return XCTFail("f1Pressed should be a bool") + } + guard case let .bool(f6Pressed) = context.variables["f6Pressed"] else { + return XCTFail("f6Pressed should be a bool") + } + guard case let .bool(f12Pressed) = context.variables["f12Pressed"] else { + return XCTFail("f12Pressed should be a bool") + } XCTAssertEqual(wPressed, true, "W key should be pressed") XCTAssertEqual(aPressed, false, "A key should not be pressed") + XCTAssertEqual(hPressed, true, "H key should be pressed") + XCTAssertEqual(tabPressed, true, "Tab key should be pressed") + XCTAssertEqual(fPressed, false, "F key should not be pressed") + XCTAssertEqual(f1Pressed, true, "F1 key should be pressed") + XCTAssertEqual(f6Pressed, false, "F6 key should not be pressed") + XCTAssertEqual(f12Pressed, true, "F12 key should be pressed") // Reset InputSystem.shared.keyState.wPressed = false + InputSystem.shared.keyState.hPressed = false + InputSystem.shared.keyState.tabPressed = false + InputSystem.shared.keyState.f1Pressed = false + InputSystem.shared.keyState.f12Pressed = false } func testOrBool_Scripted() { diff --git a/docs/API/UsingEngineSettings.md b/docs/API/UsingEngineSettings.md index d1f62a50..d2750cc7 100644 --- a/docs/API/UsingEngineSettings.md +++ b/docs/API/UsingEngineSettings.md @@ -186,6 +186,127 @@ setCamera(.defaultFOV(70.0)) setCamera(.clipPlanes(near: 0.1, far: 1000.0)) ``` +## Input (XR) + +```swift +// Register / unregister XR spatial event handling +registerXREvents() +unregisterXREvents() + +// Config +setInput(.xr(.pickingBackend(.octreeGPUPreferred))) +setInput(.xr(.twoHandRotateAxisMode(.dynamicSnapped))) +setInput(.xr(.sceneReady(true))) + +// Query +let state = getXRSpatialInputState() +let ready = isXRSceneReady() +``` + +## Spatial Manipulation (visionOS) + +Use `setSpatialManipulation` for tuning thresholds and behaviour: + +```swift +setSpatialManipulation(.intentTranslationThreshold(0.01)) +setSpatialManipulation(.intentRotationThreshold(0.08)) +setSpatialManipulation(.intentDominanceRatio(1.15)) +setSpatialManipulation(.zoomScale(min: 0.05, max: 20.0)) +setSpatialManipulation(.rotationDeltaLimit(perFrame: 0.12, twoHand: 0.35)) +setSpatialManipulation(.twoHandRotationDeadzone(0.001)) +setSpatialManipulation(.rotationSmoothing(factor: 0.25, deadzone: 0.002)) +setSpatialManipulation(.classificationFrames(3)) +setSpatialManipulation(.inputEpsilon(0.0001)) +``` + +Per-frame lifecycle calls use free functions so callers do not need the shared instance: + +```swift +let state = getXRSpatialInputState() + +// Full pinch-drag + rotate arbitration for a single entity +processPinchTransformLifecycle(from: state) + +// Simpler per-delta drag (call each frame while pinch is active) +applyPinchDragIfNeeded(from: state, entityId: myEntity, sensitivity: 1.0) + +// Anchored drag / rotate for individual entities +// dragPlane filters world-axis displacement; it is not ray-plane picking. +processAnchoredPinchDragLifecycle(from: state, entityId: myEntity, dragPlane: .xz) +processAnchoredPinchDragLifecycle( + from: state, + entityId: myEntity, + dragPlane: .xz, + positionTransform: { worldPosition in + simd_float3(worldPosition.x.rounded(), worldPosition.y, worldPosition.z.rounded()) + } +) + +// Anchored drag / rotate for the entire scene root +processAnchoredSceneDragLifecycle(from: state) +processAnchoredSceneRotateLifecycle(from: state) + +// Unified scene-root manipulation with drag/rotate arbitration +processAnchoredSceneManipulationLifecycle(from: state, dragSensitivity: 1.0, rotateSensitivity: 1.0) + +// Two-hand zoom and rotate for a single entity +applyTwoHandZoomIfNeeded(from: state, entityId: myEntity) +applyTwoHandRotateIfNeeded(from: state, entityId: myEntity) +``` + +Session control: + +```swift +resetSpatialManipulation() +endSpatialManipulation() +endAnchoredPinchDrag() +endAnchoredSceneDrag() +endAnchoredSceneManipulation() +endAnchoredSceneRotate() +``` + +## Lighting + +Use `setLight(entityId:, _:)` for all per-entity light configuration. Light-type-specific properties are grouped under sub-domains that follow the `setDomain(.group(.property(value)))` shape: + +```swift +// Shared across all light types +setLight(entityId: light, .color(simd_float3(1.0, 0.85, 0.7))) +setLight(entityId: light, .intensity(2.5)) + +// Directional light +setLight(entityId: light, .directional(.active)) + +// Point light +setLight(entityId: light, .point(.radius(5.0))) +setLight(entityId: light, .point(.falloff(0.7))) +setLight(entityId: light, .point(.attenuation(simd_float3(1, 0.5, 0.1)))) + +// Spot light +setLight(entityId: light, .spot(.coneAngle(30.0))) +setLight(entityId: light, .spot(.falloff(0.5))) +setLight(entityId: light, .spot(.radius(3.0))) +setLight(entityId: light, .spot(.attenuation(simd_float3(1, 0.5, 0.1)))) + +// Area light +setLight(entityId: light, .area(.twoSided(true))) +``` + +Light creation and query functions remain explicit: + +```swift +createDirLight(entityId: entity) +createPointLight(entityId: entity) +createSpotLight(entityId: entity) +createAreaLight(entityId: entity) + +getLightColor(entityId: entity) +getLightIntensity(entityId: entity) +getLightRadius(entityId: entity) +getLightFalloff(entityId: entity) +getLightConeAngle(entityId: entity) +``` + ## Style Rule For Contributors, when adding new public settings, prefer one of these forms: @@ -200,6 +321,9 @@ setBatching(.newProperty(value)) setSpatialDebug(.newProperty(value)) setLogger(.newProperty(value)) setCamera(.newProperty(value)) +setInput(.xr(.newProperty(value))) +setSpatialManipulation(.newProperty(value)) +setLight(entityId: entity, .lightType(.newProperty(value))) setSceneChannel(.contextGeometry, .renderMode(.wireframe)) ``` diff --git a/docs/API/UsingInputSystem.md b/docs/API/UsingInputSystem.md index eef2416e..2c2f0bd9 100644 --- a/docs/API/UsingInputSystem.md +++ b/docs/API/UsingInputSystem.md @@ -51,7 +51,7 @@ if inputSystem.keyState.dPressed == true { ###Step 2: Using Input to Control Entities -Here’s an example function that moves a car entity based on keyboard inputs: +Here's an example function that moves a car entity based on keyboard inputs: ```swift func moveCar(entityId: EntityID, dt: Float) { @@ -118,8 +118,62 @@ func handleInput() { --- +## XR Input Configuration (visionOS) + +When developing for visionOS, use the `setInput` facade and free functions to configure XR input without touching the shared singleton directly. + +### Registering XR events + +Before any spatial input is received, register the XR event pipeline in your init: + +```swift +func gameInit() { + registerXREvents() +} +``` + +Call `unregisterXREvents()` to stop receiving spatial events when leaving XR mode. + +### Configuring XR behaviour + +```swift +// Choose the spatial picking backend +setInput(.xr(.pickingBackend(.octreeGPUPreferred))) + +// Set how the two-hand rotate axis is derived +setInput(.xr(.twoHandRotateAxisMode(.dynamicSnapped))) + +// Signal that the XR scene is ready to receive input +setInput(.xr(.sceneReady(true))) +``` + +Available two-hand rotate axis modes: + +- `.cameraForward` — rotates around the camera-forward axis (screen-style twist) +- `.dynamic` — derives the axis from actual two-hand motion +- `.dynamicSnapped` — dynamic axis snapped to the dominant world axis (`x`, `y`, or `z`) + +### Reading XR input state + +```swift +func handleInput() { + let state = getXRSpatialInputState() + + if state.spatialTapActive, let entityId = state.pickedEntityId { + Logger.log(message: "Tapped entity: \(entityId)") + } +} +``` + +### Querying scene readiness + +```swift +let ready = isXRSceneReady() +``` + +--- + ## Tips and Best Practices - Debouncing: If you want to execute an action only once per key press, track the key's previous state to avoid repeated triggers. - Game Mode Check: Always ensure the game is in the appropriate mode (e.g., Game Mode) before processing inputs. - Smooth Movement: Use dt (delta time) to ensure frame-rate-independent movement. - diff --git a/docs/API/UsingLightPortals.md b/docs/API/UsingLightPortals.md new file mode 100644 index 00000000..950ae5ba --- /dev/null +++ b/docs/API/UsingLightPortals.md @@ -0,0 +1,191 @@ +# Light Portals + +Light portals let selected scene-channel geometry act as a proxy source for real-world window light. This is intended for spatial twin scenes where windows, doors, or openings should let ambient real-world light appear to enter the virtual model. + +The feature uses the existing area-light shader path. Each active portal surface becomes a temporary two-sided area light derived from that entity's transform and local bounds. The portal plane is inferred from the two largest local bounding-box axes, so XY, XZ, and YZ window meshes can all be used as portal surfaces. The renderer does not create persistent light entities. + +## Basic Setup + +Define a project-specific channel for windows: + +```swift +extension SceneChannel { + static let windowGeometry = SceneChannel.userCustom(index: 1) +} +``` + +Assign entities to the channel directly: + +```swift +setEntitySceneChannels(entityId: windowEntity, channels: .windowGeometry) +``` + +Or map exported names by prefix: + +```swift +registerSceneChannelPrefix("WIN_", channels: .windowGeometry) +``` + +Enable the channel as a light portal: + +```swift +setSceneChannel( + .windowGeometry, + .lightPortal(.enabled( + intensity: 1.0, + range: 6.0, + useRealWorldTint: true, + maxActivePortals: 8, + activationDistance: 15.0 + )) +) +``` + +Disable it again: + +```swift +setSceneChannel(.windowGeometry, .lightPortal(.disabled)) +``` + +## Parameters + +| Parameter | Meaning | +|---|---| +| `intensity` | Base area-light intensity for each portal. Non-finite values fall back to `1.0`; negative values clamp to `0.0`. | +| `range` | Maximum portal area-light influence distance in scene units, measured from the closest point on the portal rectangle. Portal contribution fades smoothly over the last 25% of the range. | +| `useRealWorldTint` | When real-world XR lighting is active and valid, scales portal intensity by the current XR probe intensity and real-world contribution factor. If XR lighting is unavailable while the runtime mode requests real-world lighting, the portal emits at `0.0` instead of using the configured base intensity. | +| `maxActivePortals` | Maximum active portal proxy lights for the channel. Nearby portals are selected first within each portal-enabled channel. | +| `activationDistance` | Maximum camera distance from the portal rectangle for a portal to become active. | + +If an entity belongs to multiple portal-enabled channels, the engine combines the channel settings using the most permissive intensity, range, and activation distance. `useRealWorldTint` is enabled if any channel requests it. Active portal limits are enforced per portal-enabled channel. + +## Real-World Tint + +`useRealWorldTint: true` applies the XR probe's normalized intensity scale and the engine's real-world lighting contribution multiplier: + +```text +portal intensity = portal intensity * xr intensity scale * real-world contribution +``` + +If no valid XR probe is available while XR real-world lighting is enabled, real-world-tinted portals emit no light. When XR lighting is valid, changing `setRendering(.environment(.realWorldLightingContribution(...)))` affects portal strength immediately. + +Light portals do not enable XR lighting by themselves. Configure XR real-world probe lighting separately with `setRendering(.environment(.lightingMode(.realWorldEstimate)))`. See [XR Lighting](UsingXRLighting.md) for probe setup, provider lifecycle, diagnostics, and passthrough behavior. + +## Diagnostics + +Discovery diagnostics report which scene entities are eligible portal surfaces: + +```swift +let candidates = discoverSceneLightPortalCandidates() +let discovery = getLightPortalDiscoveryDiagnostics() +print(candidates) +print(discovery) +``` + +Resolution diagnostics report which portals become active proxy lights: + +```swift +let proxies = resolveSceneLightPortalProxyLightsForActiveCamera() +let resolution = getLightPortalResolutionDiagnostics() +let performance = getLightPortalPerformanceDiagnostics() +let render = getLightPortalRenderDiagnostics() +print(proxies) +print(resolution) +print(performance) +print(render) +``` + +Important fields: + +| Field | Meaning | +|---|---| +| `scannedRenderableEntityCount` | Renderable entities scanned for portal eligibility. | +| `candidateCount` | Renderable, visible entities on portal-enabled channels. | +| `skippedHiddenCount` | Entities skipped because their scene-channel render mode is hidden. | +| `skippedInvisibleRenderComponentCount` | Entities skipped because their render component is invisible. | +| `skippedDisabledPortalCount` | Renderable entities whose channels are not portal-enabled. | +| `skippedInvalidGeometryCount` | Portal-channel entities skipped because their local bounds are non-finite, too small, or not thin enough to infer a portal plane. | +| `discoveredCandidateCount` | Portal candidates considered for proxy-light resolution. | +| `activePortalCount` | Portal proxy lights emitted after distance filtering and active-count capping. | +| `skippedByActivationDistanceCount` | Candidates outside `activationDistance`. | +| `maxActivePortals` | Active portal cap used for the current resolved list. | +| `lastDiscoveryDurationMs` | Time spent scanning renderable entities and building portal candidates during the latest discovery pass. | +| `lastResolutionDurationMs` | Time spent distance-filtering, sorting, and selecting active portal proxy lights during the latest resolution pass. | +| `lastScannedRenderableEntityCount` | Renderable entity count from the latest discovery pass, useful for spotting broad scans in large scenes. | +| `lastResolvedProxyLightCount` | Active proxy-light count from the latest resolution pass. | +| `environmentIntensityScale` | Final XR/environment multiplier used for real-world-tinted portal proxy lights. | +| `xrIntensityScale` | XR probe intensity scale before the user contribution multiplier. | +| `environmentTintColor` | RGB tint applied to real-world-tinted portal proxy lights. | +| `realWorldLightingContribution` | User contribution multiplier from the rendering settings API. | +| `maxEffectivePortalIntensity` | Brightest portal intensity emitted into the area-light buffer for the latest frame. | + +## Performance + +The feature is designed to be bounded: + +- When no scene channel has light portals enabled, the renderer takes a fast path and skips portal discovery entirely. +- When at least one portal channel is enabled, discovery scans renderable entities with transform and render components. +- Resolution filters by distance to the portal rectangle and sorts candidates by nearest first. +- Portal discovery and resolution are cached per render frame and reused by the engine's render passes. +- Only `maxActivePortals` are emitted as proxy area lights. +- Authored area lights keep priority; portal lights only fill remaining `maxAreaLights` capacity. +- Portal lights fade down near their own source plane to avoid making the window frame itself look like an emissive object. + +For large spatial twins, keep the portal channel narrow. Assign only actual window/opening surfaces to the portal channel, not full walls or entire room shells. + +### Collecting Performance Data + +Light portal diagnostics follow the same category-log pattern described in [Profiler](UsingProfiler.md). Enable the `.lightPortal` category once, and the engine logs a throttled diagnostics summary automatically from the frame monitor path. No code is needed inside your app or game `update()` function. + +```swift +setLogger(.category(.lightPortal, true)) +// Reproduce the issue. The engine logs light portal diagnostics about once per second. +setLogger(.category(.lightPortal, false)) +``` + +The automatic log emits the latest discovery, resolution, performance, and render snapshots. The snapshots are updated by the normal portal discovery/resolution/render paths; the one-second interval only controls log emission. + +For a one-shot snapshot: + +```swift +setLogger(.category(.lightPortal, true)) +LightPortalSystem.shared.logDiagnosticsNow() +setLogger(.category(.lightPortal, false)) +``` + +For scale checks, focus on: + +| Field | Why it matters | +|---|---| +| `lastScannedRenderableEntityCount` | How broad the portal discovery scan is. | +| `lastDiscoveryDurationMs` | CPU time spent finding portal candidates. | +| `lastResolutionDurationMs` | CPU time spent selecting active proxy lights. | +| `activePortalCount` | Number of portal candidates selected after distance and cap filtering. | +| `portalAreaLightCount` | Number of portal proxy lights uploaded into the area-light path. | + +Recommended starting values: + +```swift +setSceneChannel( + .windowGeometry, + .lightPortal(.enabled( + intensity: 0.5, + range: 4.0, + useRealWorldTint: true, + maxActivePortals: 4, + activationDistance: 10.0 + )) +) +``` + +Increase `maxActivePortals` only if the visual benefit is clear on device. + +## Limitations + +Light portals are an approximation. They do not ray trace sunlight through openings, clip light to the portal shape, or compute physical bounce lighting. They emit proxy area lights from the selected window surfaces. + +Portal brightness follows accepted XR probe updates, not every rendered frame. If the real room lights change, use XR lighting diagnostics to confirm that `acceptedProbeUpdateCount`, `latestProbeTimestamp`, and `latestIntensityScale` changed before judging the visual result. + +The feature does not make windows transparent by itself. Use scene-channel render modes separately if a window or wall should be hidden, wireframed, or rendered as a passthrough ghost. + +Portal proxy lights do not create persistent ECS light entities, so they are not serialized as scene-authored lights. Configure them through scene channels at runtime. diff --git a/docs/API/UsingLightingSystem.md b/docs/API/UsingLightingSystem.md index d936ab1b..22403ebb 100644 --- a/docs/API/UsingLightingSystem.md +++ b/docs/API/UsingLightingSystem.md @@ -11,6 +11,21 @@ Directional shader uniforms use the opposite vector because the BRDF expects the Area-light shader uniforms keep a separate `forward` value for the LTC rectangle polygon/front normal used to choose winding. Use `getLightEmissionDirection(entityId:)` for editor handles and authored light travel direction; do not treat `AreaLight.forward` as the semantic travel vector. +## Runtime Environment Lighting + +Use rendering environment settings to choose how indirect/environment lighting is resolved: + +```swift +setRendering(.environment(.lightingMode(.authoredOnly))) +setRendering(.environment(.lightingMode(.staticIBL))) +setRendering(.environment(.lightingMode(.realWorldEstimate))) +setRendering(.environment(.realWorldLightingContribution(0.75))) +``` + +`realWorldEstimate` uses Vision Pro environment light probes when an `UntoldEngineXR` instance is active. The XR layer observes the runtime lighting mode, starts or stops the ARKit environment-light provider as needed, and feeds prefiltered probe textures into the normal PBR lighting path. + +See [XR Lighting](UsingXRLighting.md) for Vision Pro probe setup and diagnostics. See [Light Portals](UsingLightPortals.md) for proxy area lights emitted from selected window/opening geometry. + ## Creating Each Light Type ### Directional Light @@ -47,3 +62,108 @@ let panel = createEntity() createAreaLight(entityId: panel) ``` +--- + +## Configuring Light Properties + +Use `setLight(entityId:, _:)` to configure any light after creation. The call shape follows the standard engine pattern — shared properties sit at the top level, and type-specific properties are grouped under a sub-domain: + +```swift +setLight(entityId: light, .property(value)) // shared +setLight(entityId: light, .lightType(.property(value))) // type-specific +``` + +### Shared properties (all light types) + +Color and intensity apply to every light type: + +```swift +setLight(entityId: light, .color(simd_float3(1.0, 0.85, 0.7))) +setLight(entityId: light, .intensity(2.5)) +``` + +### Directional light + +The `.directional(.active)` case designates the entity as the scene's active directional light — the one the renderer uses for shadows and directional shading. Only one entity can be active at a time; calling this again on a different entity replaces the previous one. + +```swift +let sun = createEntity() +createDirLight(entityId: sun) +rotateTo(entityId: sun, angle: -45.0, axis: simd_float3(1, 0, 0)) + +setLight(entityId: sun, .color(simd_float3(1.0, 0.95, 0.8))) +setLight(entityId: sun, .intensity(1.2)) +setLight(entityId: sun, .directional(.active)) +``` + +### Point light + +Point lights have a `radius` (effective influence range) and a `falloff` (0 = linear, 1 = physically-based quadratic). You can also override the raw attenuation coefficients if you need exact control: + +```swift +let bulb = createEntity() +createPointLight(entityId: bulb) +translateTo(entityId: bulb, position: simd_float3(0, 2, 0)) + +setLight(entityId: bulb, .color(simd_float3(1, 0.6, 0.2))) +setLight(entityId: bulb, .intensity(3.0)) +setLight(entityId: bulb, .point(.radius(8.0))) +setLight(entityId: bulb, .point(.falloff(0.7))) +setLight(entityId: bulb, .point(.attenuation(simd_float3(1, 0.5, 0.1)))) +``` + +### Spot light + +Spot lights add a cone. `coneAngle` controls the outer cone in degrees; `falloff` softens the inner edge: + +```swift +let spot = createEntity() +createSpotLight(entityId: spot) + +setLight(entityId: spot, .color(simd_float3(1, 1, 0.9))) +setLight(entityId: spot, .intensity(4.0)) +setLight(entityId: spot, .spot(.coneAngle(25.0))) +setLight(entityId: spot, .spot(.falloff(0.6))) +setLight(entityId: spot, .spot(.radius(6.0))) +setLight(entityId: spot, .spot(.attenuation(simd_float3(1, 0.4, 0.08)))) +``` + +### Area light + +Area lights derive their bounds from the entity's scale and orientation. The one configurable flag is `twoSided`, which controls whether the light emits from both faces of the rectangle: + +```swift +let panel = createEntity() +createAreaLight(entityId: panel) + +setLight(entityId: panel, .color(simd_float3(0.9, 0.95, 1.0))) +setLight(entityId: panel, .intensity(5.0)) +setLight(entityId: panel, .area(.twoSided(true))) +``` + +--- + +## Querying Light Properties + +```swift +let color = getLightColor(entityId: light) +let intensity = getLightIntensity(entityId: light) +let radius = getLightRadius(entityId: light) +let falloff = getLightFalloff(entityId: light) +let coneAngle = getLightConeAngle(entityId: light) +``` + +--- + +## Light Direction Queries + +```swift +// World-space semantic emission/travel direction (away from the light) +let emission = getLightEmissionDirection(entityId: light) + +// Local +Z in world space (transform axis, not emission) +let forward = getLightTransformForwardAxis(entityId: light) + +// Direction from shaded point toward the light (BRDF input convention) +let shader = getDirectionalLightShaderDirection(entityId: light) +``` diff --git a/docs/API/UsingMaterials.md b/docs/API/UsingMaterials.md index f34fe501..fe9064c3 100644 --- a/docs/API/UsingMaterials.md +++ b/docs/API/UsingMaterials.md @@ -85,6 +85,12 @@ let emissive = getMaterialEmmissive(entityId: entity) updateMaterialEmmisive(entityId: entity, emmissive: simd_float3(1.0, 0.5, 0.0)) ``` +To apply the same emissive value to an entity and all of its scenegraph descendants: + +```swift +updateMaterialEmmisive(entityId: rootEntity, emmissive: simd_float3(1.0, 0.5, 0.0), recursive: true) +``` + > **Spelling note:** The API currently uses `getMaterialEmmissive` / `updateMaterialEmmisive` (with double-m). Use these exact names when calling the functions. --- @@ -153,6 +159,12 @@ By default this applies to **every submesh** on the entity. To target a single s updateMaterialOpacity(entityId: entity, opacity: 0.5, applyToAllSubmeshes: false) ``` +To apply opacity to an entity and all of its scenegraph descendants: + +```swift +updateMaterialOpacity(entityId: rootEntity, opacity: 0.5, recursive: true) +``` + Or specify exact indices: ```swift @@ -170,11 +182,11 @@ updateMaterialOpacity(entityId: entity, opacity: 0.5, meshIndex: 0, submeshIndex - `getMaterialMetallic(entityId:meshIndex:submeshIndex:)` → `Float` - `updateMaterialMetallic(entityId:metallic:meshIndex:submeshIndex:)` - `getMaterialEmmissive(entityId:meshIndex:submeshIndex:)` → `simd_float3` -- `updateMaterialEmmisive(entityId:emmissive:meshIndex:submeshIndex:)` +- `updateMaterialEmmisive(entityId:emmissive:recursive:meshIndex:submeshIndex:)` - `getMaterialAlphaMode(entityId:meshIndex:submeshIndex:)` → `MaterialAlphaMode` - `updateMaterialAlphaMode(entityId:mode:meshIndex:submeshIndex:)` - `getMaterialAlphaCutoff(entityId:meshIndex:submeshIndex:)` → `Float` - `updateMaterialAlphaCutoff(entityId:cutoff:meshIndex:submeshIndex:)` - `getMaterialOpacity(entityId:meshIndex:submeshIndex:)` → `Float` -- `updateMaterialOpacity(entityId:opacity:applyToAllSubmeshes:)` +- `updateMaterialOpacity(entityId:opacity:applyToAllSubmeshes:recursive:)` - `updateMaterialOpacity(entityId:opacity:meshIndex:submeshIndex:)` diff --git a/docs/API/UsingProfiler.md b/docs/API/UsingProfiler.md index 3a69ab3e..c9506610 100644 --- a/docs/API/UsingProfiler.md +++ b/docs/API/UsingProfiler.md @@ -384,3 +384,4 @@ Profiler hooks are already integrated into: - `UntoldEngineXR.swift` (`executeXRSystemPass`) - `UntoldEngineAR.swift` (`draw`) - `BatchingSystem.swift` (`logMaterialDiagnosticsIfDue` — fires automatically every 30 s when the `.batching` category is enabled) +- `LightPortalSystem.swift` (`logDiagnosticsIfDue` — fires automatically every 1 s when the `.lightPortal` category is enabled) diff --git a/docs/API/UsingSceneChannels.md b/docs/API/UsingSceneChannels.md index 3dd6f3d4..76126a71 100644 --- a/docs/API/UsingSceneChannels.md +++ b/docs/API/UsingSceneChannels.md @@ -62,6 +62,14 @@ setSceneChannel(.ceilingGeometry, .renderMode(.wireframe)) New properties can be added to `SceneChannelProperty` in the future without introducing new top-level functions. +Scene channels can also mark selected geometry as light portals for spatial twin window lighting: + +```swift +setSceneChannel(.windowGeometry, .lightPortal(.enabled())) +``` + +See [Light Portals](UsingLightPortals.md) for the full workflow. + ## Render Modes The `.renderMode` property accepts a `SceneChannelRenderMode`: diff --git a/docs/API/UsingSpatialInput.md b/docs/API/UsingSpatialInput.md index 390766be..e7007c72 100644 --- a/docs/API/UsingSpatialInput.md +++ b/docs/API/UsingSpatialInput.md @@ -1,4 +1,4 @@ -# Spatial Input (vision Pro) +# Spatial Input (Vision Pro) Spatial input in Untold Engine follows a simple pipeline: @@ -8,8 +8,7 @@ Spatial input in Untold Engine follows a simple pipeline: 4. XRSpatialGestureRecognizer processes snapshots each frame. 5. The engine publishes a single XRSpatialInputState your game reads in handleInput(). -That separation keeps the system flexible: the OS-facing code stays in UntoldEngineXR, while gesture classification stays in -the recognizer. +That separation keeps the system flexible: the OS-facing code stays in UntoldEngineXR, while gesture classification stays in the recognizer. ## What You Get in Game Code @@ -27,39 +26,67 @@ So your game logic can stay focused on behavior (select, move, rotate, scale), n ## Important Setup Step -You must enable XR event ingestion: +You must enable XR event ingestion in your init: -InputSystem.shared.registerXREvents() +```swift +func gameInit() { + registerXREvents() +} +``` If you skip this, the callback still receives OS events, but the engine ignores them. +## XR Input Configuration + +Configure XR input behaviour with `setInput` before the scene starts: + +```swift +// Spatial picking backend +setInput(.xr(.pickingBackend(.octreeGPUPreferred))) + +// Two-hand rotate axis derivation +setInput(.xr(.twoHandRotateAxisMode(.dynamicSnapped))) + +// Signal scene readiness +setInput(.xr(.sceneReady(true))) +``` + +Tune spatial manipulation thresholds with `setSpatialManipulation`: + +```swift +setSpatialManipulation(.intentTranslationThreshold(0.01)) +setSpatialManipulation(.intentRotationThreshold(0.08)) +setSpatialManipulation(.classificationFrames(3)) +setSpatialManipulation(.rotationSmoothing(factor: 0.25, deadzone: 0.002)) +setSpatialManipulation(.zoomScale(min: 0.05, max: 20.0)) +``` + ## Typical Frame Usage In your handleInput(): -- Poll InputSystem.shared.xrSpatialInputState. +- Poll `getXRSpatialInputState()` to get the current frame's input. - React to edge-triggered gestures like tap. - Apply continuous updates for drag/zoom/rotate while active. -For object manipulation, use SpatialManipulationSystem for robust pinch-driven transforms, then layer custom behavior on top -when needed. - +For object manipulation, use the spatial manipulation free functions for robust pinch-driven transforms, then layer custom behaviour on top when needed. + ## Quick Example This example shows how to drag and rotate a mesh using the engine: -``` swift +```swift func handleInput() { if gameMode == false { return } - let state = InputSystem.shared.xrSpatialInputState + let state = getXRSpatialInputState() if state.spatialTapActive, let entityId = state.pickedEntityId { Logger.log(message: "Tapped entity: \(entityId)") } // Handles drag-based translate + twist rotation on picked entity - SpatialManipulationSystem.shared.processPinchTransformLifecycle(from: state) + processPinchTransformLifecycle(from: state) } ``` @@ -82,38 +109,33 @@ This lifecycle model prevents stuck manipulation sessions. ### Manipulate Parent Instead Of Picked Child -If ray picking hits a child mesh and you want to manipulate the parent -actor: +If ray picking hits a child mesh and you want to manipulate the parent actor: -``` swift -var state = InputSystem.shared.xrSpatialInputState +```swift +var state = getXRSpatialInputState() if let picked = state.pickedEntityId, let parent = getEntityParent(entityId: picked) { state.pickedEntityId = parent } -SpatialManipulationSystem.shared.processPinchTransformLifecycle(from: state) +processPinchTransformLifecycle(from: state) ``` This is useful when: - A character has multiple meshes - A building has sub-meshes -- You want to move the root actor instead of individual geometry - pieces +- You want to move the root actor instead of individual geometry pieces ------------------------------------------------------------------------ ### Important Note -Do not early-return only because `pickedEntityId == nil` before calling -lifecycle processing. +Do not early-return only because `pickedEntityId == nil` before calling lifecycle processing. -End/cancel phases must still propagate to properly close manipulation -sessions.\ -Failing to do so can leave the engine in an inconsistent transform -state. +End/cancel phases must still propagate to properly close manipulation sessions. +Failing to do so can leave the engine in an inconsistent transform state. ------------------------------------------------------------------------ @@ -121,7 +143,7 @@ state. Use these APIs to control whether an entity can be selected by spatial tap/ray picking and what hit representation it uses. -``` swift +```swift setEntityPickParticipation(entityId: entityId, enabled: false) // visible, not pickable setEntityPickHitRepresentationMode(entityId: entityId, mode: .bounds) // pick using bounds setEntityPickHitRepresentationMode(entityId: entityId, mode: .mesh) // pick using mesh (default) @@ -136,12 +158,9 @@ Available APIs: Hit representation modes: -- `.none`\ - Never pickable. -- `.bounds`\ - Pick using bounds intersection. -- `.mesh`\ - Pick using mesh-capable path (default behavior). +- `.none` — Never pickable. +- `.bounds` — Pick using bounds intersection. +- `.mesh` — Pick using mesh-capable path (default behavior). Behavior rules: @@ -154,8 +173,7 @@ Behavior rules: ## Raw Gesture Examples -It is strongly recommended to use the Spatial Helper functions instead -of raw gesture access. +It is strongly recommended to use the spatial free functions instead of raw gesture access. Raw access is useful when: @@ -169,8 +187,8 @@ Raw access is useful when: Vision Pro air-tap gesture. -``` swift -let state = InputSystem.shared.xrSpatialInputState +```swift +let state = getXRSpatialInputState() if state.spatialTapActive, let entityId = state.pickedEntityId { // selectEntity(entityId) } @@ -188,14 +206,13 @@ Use this to: Single-hand pinch detected. -``` swift +```swift if InputSystem.shared.hasSpatialPinch() { // pinch is active } ``` -This does **not** imply dragging yet --- only that a pinch is currently -held. +This does **not** imply dragging yet — only that a pinch is currently held. ------------------------------------------------------------------------ @@ -203,7 +220,7 @@ held. World-space position of pinch. -``` swift +```swift if let pinchPosition = InputSystem.shared.getPinchPosition() { // use pinchPosition } @@ -221,8 +238,8 @@ Useful for: Drag delta while pinch is active. -``` swift -let state = InputSystem.shared.xrSpatialInputState +```swift +let state = getXRSpatialInputState() if state.spatialPinchActive { let dragDelta = InputSystem.shared.getPinchDragDelta() // app-defined translation/scaling response @@ -239,16 +256,16 @@ Common use cases: ## Anchored Pinch Drag Helper -For stable translation (no per-frame delta accumulation), use the -anchored lifecycle helper: +For stable translation (no per-frame delta accumulation), use the anchored lifecycle helper: -``` swift +```swift func handleInput() { - let state = InputSystem.shared.xrSpatialInputState + let state = getXRSpatialInputState() - SpatialManipulationSystem.shared.processAnchoredPinchDragLifecycle( + processAnchoredPinchDragLifecycle( from: state, - entityId: sceneRootEntity + entityId: sceneRootEntity, + dragPlane: .xz ) } ``` @@ -257,10 +274,34 @@ This helper: - Captures initial hand + entity world positions - Applies absolute displacement from gesture start +- Optionally constrains world-axis movement to `.xy`, `.xz`, or `.yz` +- Optionally transforms the final world position before it is written - Cleans up session state on end/cancel -Use this when moving large roots (buildings/scenes) where incremental -delta jitter can become visible. +Use this when moving large roots (buildings/scenes) where incremental delta jitter can become visible. +Use `.xz` to preserve height while dragging across the floor axes, `.xy` to preserve depth for wall-style movement, and `.unconstrained` for free 3D movement. + +`dragPlane` filters the hand displacement in world axes. It does not raycast the input ray against a mathematical plane. For ray-plane picking, use `pickGroundPosition` or `pickPlanePosition`. + +Use `positionTransform` for continuous snapping, clamping, or custom placement rules: + +```swift +processAnchoredPinchDragLifecycle( + from: state, + entityId: sceneRootEntity, + dragPlane: .xz, + positionTransform: { worldPosition in + let gridSize: Float = 0.25 + return simd_float3( + (worldPosition.x / gridSize).rounded() * gridSize, + worldPosition.y, + (worldPosition.z / gridSize).rounded() * gridSize + ) + } +) +``` + +The closure receives and returns **world-space** position after sensitivity and `dragPlane` have been applied. Its return value is the final position, so it can intentionally override the constrained axis. If it returns a non-finite value, the engine skips that frame's position write. ------------------------------------------------------------------------ @@ -268,11 +309,11 @@ delta jitter can become visible. For translating the **entire scene root** (rather than a single entity), use the anchored scene drag lifecycle: -``` swift +```swift func handleInput() { - let state = InputSystem.shared.xrSpatialInputState + let state = getXRSpatialInputState() - SpatialManipulationSystem.shared.processAnchoredSceneDragLifecycle(from: state) + processAnchoredSceneDragLifecycle(from: state) } ``` @@ -284,14 +325,14 @@ This helper: You can adjust movement speed with the `sensitivity` parameter (defaults to `1.0`): -``` swift -SpatialManipulationSystem.shared.processAnchoredSceneDragLifecycle(from: state, sensitivity: 0.5) +```swift +processAnchoredSceneDragLifecycle(from: state, sensitivity: 0.5) ``` To manually end the drag (e.g. on a mode change), call: -``` swift -SpatialManipulationSystem.shared.endAnchoredSceneDrag() +```swift +endAnchoredSceneDrag() ``` Use this when panning an entire scene — for example, sliding a map, architectural model, or level layout in world space. @@ -302,11 +343,11 @@ Use this when panning an entire scene — for example, sliding a map, architectu For rotating the **entire scene root** around world up (`+Y`) while preserving static batching, use the anchored scene rotate lifecycle. This requires a **two-hand pinch + twist** gesture (`spatialRotateActive` with both hands pinching): -``` swift +```swift func handleInput() { - let state = InputSystem.shared.xrSpatialInputState + let state = getXRSpatialInputState() - SpatialManipulationSystem.shared.processAnchoredSceneRotateLifecycle(from: state) + processAnchoredSceneRotateLifecycle(from: state) } ``` @@ -319,14 +360,14 @@ This helper: You can adjust rotation speed with the `sensitivity` parameter (defaults to `1.0`): -``` swift -SpatialManipulationSystem.shared.processAnchoredSceneRotateLifecycle(from: state, sensitivity: 0.5) +```swift +processAnchoredSceneRotateLifecycle(from: state, sensitivity: 0.5) ``` To manually end rotation (e.g. on a mode change), call: -``` swift -SpatialManipulationSystem.shared.endAnchoredSceneRotate() +```swift +endAnchoredSceneRotate() ``` Use this when aligning or calibrating an already-loaded large scene in place without rebatching. @@ -337,11 +378,11 @@ Use this when aligning or calibrating an already-loaded large scene in place wit To avoid drag/rotate gesture fighting, use the unified scene-root manipulation lifecycle: -``` swift +```swift func handleInput() { - let state = InputSystem.shared.xrSpatialInputState + let state = getXRSpatialInputState() - SpatialManipulationSystem.shared.processAnchoredSceneManipulationLifecycle( + processAnchoredSceneManipulationLifecycle( from: state, dragSensitivity: 1.0, rotateSensitivity: 0.5 @@ -351,22 +392,22 @@ func handleInput() { Arbitration rules: -- When a pinch is first detected, classification is deferred for a few frames (`manipulationClassificationFrames`, default 3) so the second hand has time to arrive +- When a pinch is first detected, classification is deferred for a few frames so the second hand has time to arrive - Two-hand pinch + twist (`spatialRotateActive` + both hands pinching) routes to scene rotate - Otherwise, after the deferral window expires, pinch drag routes to scene drag - The non-winning session is ended automatically -- Once a mode is chosen, it stays latched (`drag` or `rotate`) until the gesture ends/release happens +- Once a mode is chosen, it stays latched (`drag` or `rotate`) until the gesture ends -You can tune the deferral window (set to 0 to commit immediately): +You can tune the deferral window: -``` swift -SpatialManipulationSystem.shared.manipulationClassificationFrames = 4 // ~44ms at 90 Hz +```swift +setSpatialManipulation(.classificationFrames(4)) // ~44ms at 90 Hz ``` To manually end the unified lifecycle (e.g. on a mode change), call: -``` swift -SpatialManipulationSystem.shared.endAnchoredSceneManipulation() +```swift +endAnchoredSceneManipulation() ``` Use this as the default scene-root helper when your app supports both panning and rotation. @@ -377,39 +418,39 @@ Use this as the default scene-root helper when your app supports both panning an All three scene-level gestures can live in the same input loop — they gate on different input conditions so they don't conflict: -``` swift +```swift func handleInput() { - let state = InputSystem.shared.xrSpatialInputState + let state = getXRSpatialInputState() // Single-hand pinch + drag → pan the scene - SpatialManipulationSystem.shared.processAnchoredSceneDragLifecycle(from: state) + processAnchoredSceneDragLifecycle(from: state) // Two-hand pinch + twist → rotate the scene (yaw) - SpatialManipulationSystem.shared.processAnchoredSceneRotateLifecycle(from: state) + processAnchoredSceneRotateLifecycle(from: state) // Two-hand pinch + spread/pinch → zoom an entity - SpatialManipulationSystem.shared.applyTwoHandZoomIfNeeded(from: state) + applyTwoHandZoomIfNeeded(from: state) } ``` For context-based entity vs. scene rotation — route two-hand twist to entity rotate when something is picked, and to scene rotate otherwise: -``` swift +```swift func handleInput() { - let state = InputSystem.shared.xrSpatialInputState + let state = getXRSpatialInputState() // Scene-level drag (always active) - SpatialManipulationSystem.shared.processAnchoredSceneDragLifecycle(from: state) + processAnchoredSceneDragLifecycle(from: state) if state.pickedEntityId != nil { // Entity is picked → two-hand twist rotates the entity - SpatialManipulationSystem.shared.applyTwoHandRotateIfNeeded(from: state) + applyTwoHandRotateIfNeeded(from: state) } else { // Nothing picked → two-hand twist rotates the scene - SpatialManipulationSystem.shared.processAnchoredSceneRotateLifecycle(from: state) + processAnchoredSceneRotateLifecycle(from: state) } - SpatialManipulationSystem.shared.applyTwoHandZoomIfNeeded(from: state) + applyTwoHandZoomIfNeeded(from: state) } ``` @@ -420,35 +461,23 @@ func handleInput() { Apply the built-in zoom response: ```swift -let state = InputSystem.shared.xrSpatialInputState +let state = getXRSpatialInputState() -SpatialManipulationSystem.shared.applyTwoHandZoomIfNeeded( - from: state, - sensitivity: 1.0 -) +applyTwoHandZoomIfNeeded(from: state, sensitivity: 1.0) ``` -By default, the helper scales the parent of the picked entity when available. -If you want to choose the exact target, pass `entityId`: +By default, the helper scales the parent of the picked entity when available. If you want to choose the exact target, pass `entityId`: ```swift -let state = InputSystem.shared.xrSpatialInputState +let state = getXRSpatialInputState() if let picked = state.pickedEntityId { // Scale exactly what was hit - SpatialManipulationSystem.shared.applyTwoHandZoomIfNeeded( - from: state, - entityId: picked, - sensitivity: 1.0 - ) + applyTwoHandZoomIfNeeded(from: state, entityId: picked, sensitivity: 1.0) // Or scale its parent explicitly if let parent = getEntityParent(entityId: picked) { - SpatialManipulationSystem.shared.applyTwoHandZoomIfNeeded( - from: state, - entityId: parent, - sensitivity: 1.0 - ) + applyTwoHandZoomIfNeeded(from: state, entityId: parent, sensitivity: 1.0) } } ``` @@ -457,50 +486,38 @@ if let picked = state.pickedEntityId { ## Two-Hand Rotate -Use `setXRTwoHandRotateAxisMode` to control how the rotation axis is derived: +Configure how the rotation axis is derived: ```swift -InputSystem.shared.setXRTwoHandRotateAxisMode(.dynamicSnapped) +setInput(.xr(.twoHandRotateAxisMode(.dynamicSnapped))) ``` Available modes: -- `.cameraForward`: rotates around camera-forward axis (screen-style twist) -- `.dynamic`: derives axis from actual two-hand motion -- `.dynamicSnapped`: dynamic axis snapped to dominant world axis (`x`, `y`, or `z`) +- `.cameraForward` — rotates around camera-forward axis (screen-style twist) +- `.dynamic` — derives axis from actual two-hand motion +- `.dynamicSnapped` — dynamic axis snapped to dominant world axis (`x`, `y`, or `z`) Apply the built-in rotate response: ```swift -let state = InputSystem.shared.xrSpatialInputState +let state = getXRSpatialInputState() -SpatialManipulationSystem.shared.applyTwoHandRotateIfNeeded( - from: state, - sensitivity: 1.5 -) +applyTwoHandRotateIfNeeded(from: state, sensitivity: 1.5) ``` -By default, the helper rotates the parent of the picked entity when available. -If you want to choose the exact target, pass `entityId`: +By default, the helper rotates the parent of the picked entity when available. If you want to choose the exact target, pass `entityId`: ```swift -let state = InputSystem.shared.xrSpatialInputState +let state = getXRSpatialInputState() if let picked = state.pickedEntityId { // Rotate exactly what was hit - SpatialManipulationSystem.shared.applyTwoHandRotateIfNeeded( - from: state, - entityId: picked, - sensitivity: 1.5 - ) + applyTwoHandRotateIfNeeded(from: state, entityId: picked, sensitivity: 1.5) // Or rotate its parent explicitly if let parent = getEntityParent(entityId: picked) { - SpatialManipulationSystem.shared.applyTwoHandRotateIfNeeded( - from: state, - entityId: parent, - sensitivity: 1.5 - ) + applyTwoHandRotateIfNeeded(from: state, entityId: parent, sensitivity: 1.5) } } ``` @@ -510,10 +527,8 @@ if let picked = state.pickedEntityId { To get the distance to an entity use the following: ```swift -// Get distance to hit-entity -let state = InputSystem.shared.xrSpatialInputState +let state = getXRSpatialInputState() if state.spatialTapActive, let entityId = state.pickedEntityId { - // get distance let distance = state.pickedEntityDistance print("Object distance: \(distance) meters") } @@ -544,7 +559,7 @@ The `filter` parameter controls which planes are considered by **alignment** and When your app needs to respond to floor or table (whichever the user taps), use a **single call** with a multi-kind filter and inspect `surfaceKind` in the result. Because the function returns the closest qualifying hit, this correctly returns the table when pointing at the table and the floor when pointing at the floor. ```swift -let state = InputSystem.shared.xrSpatialInputState +let state = getXRSpatialInputState() if state.spatialTapActive { let filter = RealSurfaceFilter(alignment: .horizontal, kinds: [.floor, .table]) @@ -571,7 +586,7 @@ if state.spatialTapActive { ### Other filter examples ```swift -let state = InputSystem.shared.xrSpatialInputState +let state = getXRSpatialInputState() if state.spatialTapActive { // Floor only — always ignores tables, seats, and ceilings @@ -639,7 +654,7 @@ When ARKit does not classify a desk or table correctly, use the `hitYRange` para Floor is always near Y≈0. A standard desk or table is typically between 0.5m and 1.1m: ```swift -let state = InputSystem.shared.xrSpatialInputState +let state = getXRSpatialInputState() if state.spatialTapActive { // Floor — accept hits within ±20 cm of ground level @@ -681,39 +696,107 @@ ARKit can initially report a newly-detected horizontal plane as `.unknown` befor ------------------------------------------------------------------------ +## Get Virtual Plane Hit Position + +Virtual planes are purely mathematical — no ARKit scanning required. Use them when you want to cast a ray against a plane you define in code rather than one detected from the real environment. Common cases: snapping objects to the engine's ground level (`Y = 0`), placing content on a wall you defined, or constraining drag to an arbitrary surface. + +Two functions are available, both returning a `PlanePickHit` with `worldPosition` and `distance`: + +### Horizontal ground plane + +`pickGroundPosition` casts against a horizontal plane at a given Y height. `planeY` defaults to `0`. + +```swift +let state = getXRSpatialInputState() + +if state.spatialTapActive { + if let hit = pickGroundPosition( + rayOrigin: state.rayOriginWorld, + rayDirection: state.rayDirectionWorld, + planeY: 0.0 + ) { + Logger.log(message: "Virtual ground hit", vector: hit.worldPosition) + } +} +``` + +Use `planeY` to match a raised or sunken surface — for example `planeY: 0.75` for a table-height virtual plane. + +### Arbitrary virtual plane + +`pickPlanePosition` casts against any plane defined by a world-space point and normal. + +```swift +let state = getXRSpatialInputState() + +if state.spatialTapActive { + // Vertical plane facing +Z, passing through the origin + if let hit = pickPlanePosition( + rayOrigin: state.rayOriginWorld, + rayDirection: state.rayDirectionWorld, + planePoint: simd_float3(0, 0, 0), + planeNormal: simd_float3(0, 0, 1) + ) { + Logger.log(message: "Virtual wall hit", vector: hit.worldPosition) + } +} +``` + +`planePoint` can be any point that lies on the plane — the normal does not need to be pre-normalized. + +### Choosing between virtual and real + +| Goal | Function to use | +|---|---| +| Snap to engine ground (`Y = 0`) | `pickGroundPosition` | +| Snap to a raised virtual surface | `pickGroundPosition(planeY:)` | +| Cast against a wall or angled surface you defined | `pickPlanePosition` | +| Cast against an ARKit-detected physical surface | `pickRealSurfacePosition` | + +Both `pickGroundPosition` and `pickPlanePosition` automatically account for scene root transforms, so the math stays correct even when the scene has been translated or rotated. + +------------------------------------------------------------------------ + ## Spatial Helper Functions -Use these helpers from `SpatialManipulationSystem.shared`: +Use these free functions for spatial manipulation. They all delegate to `SpatialManipulationSystem` internally so you never need to reference the shared singleton directly. -- `processPinchTransformLifecycle(from:)`\ - Recommended default. Handles translation + twist rotation lifecycle - safely. +- `processPinchTransformLifecycle(from:)` + Recommended default. Handles translation + twist rotation lifecycle safely. -- `applyPinchDragIfNeeded(from:entityId:sensitivity:)`\ +- `applyPinchDragIfNeeded(from:entityId:sensitivity:)` Lower-level translation helper if you want full control. -- `processAnchoredSceneDragLifecycle(from:sensitivity:)`\ - Anchored drag for the entire scene root. Applies absolute - displacement via `translateSceneTo`. +- `processAnchoredPinchDragLifecycle(from:entityId:sensitivity:dragPlane:positionTransform:)` + Anchored drag for a single entity. Applies absolute displacement from gesture start, optionally constrained by world-axis displacement filtering. + +- `processAnchoredSceneDragLifecycle(from:sensitivity:)` + Anchored drag for the entire scene root. Applies absolute displacement via `translateSceneTo`. -- `endAnchoredSceneDrag()`\ +- `endAnchoredSceneDrag()` Manually ends an in-progress anchored scene drag session. -- `processAnchoredSceneRotateLifecycle(from:sensitivity:)`\ +- `processAnchoredSceneRotateLifecycle(from:sensitivity:)` Anchored rotate for the entire scene root using two-hand pinch + twist. Applies absolute yaw via `rotateSceneToYaw`. -- `endAnchoredSceneRotate()`\ +- `endAnchoredSceneRotate()` Manually ends an in-progress anchored scene rotate session. -- `processAnchoredSceneManipulationLifecycle(from:dragSensitivity:rotateSensitivity:)`\ - Unified scene-root helper with drag/rotate arbitration to prevent - gesture-fighting. Uses a deferral window (`manipulationClassificationFrames`) before - committing to drag so the second hand has time to arrive for rotate. +- `processAnchoredSceneManipulationLifecycle(from:dragSensitivity:rotateSensitivity:)` + Unified scene-root helper with drag/rotate arbitration to prevent gesture-fighting. -- `endAnchoredSceneManipulation()`\ +- `endAnchoredSceneManipulation()` Ends any in-progress unified scene manipulation (drag, rotate, or pending classification). -- `applyTwoHandZoomIfNeeded(from:sensitivity:)`\ - Provides zoom delta signal. You must define what zoom means in your - app. +- `applyTwoHandZoomIfNeeded(from:entityId:sensitivity:)` + Scales the picked entity (or its parent) using the two-hand spread/pinch gesture. + +- `applyTwoHandRotateIfNeeded(from:entityId:sensitivity:axisOverrideWorld:)` + Rotates the picked entity (or its parent) using the two-hand twist gesture. + +- `endSpatialManipulation()` + Ends the current pinch-transform manipulation session. + +- `resetSpatialManipulation()` + Resets all manipulation session state (use when changing modes or reloading scenes). diff --git a/docs/API/UsingTheLogger.md b/docs/API/UsingTheLogger.md index 2a805766..a47885ed 100644 --- a/docs/API/UsingTheLogger.md +++ b/docs/API/UsingTheLogger.md @@ -79,6 +79,7 @@ Categories let you silence or focus specific subsystems without changing the glo | `.engineStats` | `"EngineStats"` | enabled | | `.integration` | `"Integration"` | enabled | | `.xrCamera` | `"XRCamera"` | disabled | +| `.lightPortal` | `"LightPortal"` | disabled | | `.oocTiming` | `"OOCTiming"` | disabled | | `.oocStatus` | `"OOCStatus"` | disabled | | `.assetLoader` | `"AssetLoader"` | disabled | diff --git a/docs/API/UsingXRLighting.md b/docs/API/UsingXRLighting.md new file mode 100644 index 00000000..594378ca --- /dev/null +++ b/docs/API/UsingXRLighting.md @@ -0,0 +1,130 @@ +# XR Lighting + +XR lighting lets a visionOS app shade virtual content with the Vision Pro's real-world environment light estimate. The engine receives environment probe updates from ARKit, prefilters the probe into IBL textures, and uses those textures in the normal PBR lighting path. + +This is independent of passthrough visibility. A scene can use real-world lighting in mixed passthrough or while rendering only virtual content. + +## Startup Setup + +Enable XR lighting through the normal rendering settings API. This can be done during scene setup, such as from `GameScene`, after the `UntoldEngineXR` instance exists: + +```swift +setRendering(.environment(.lightingMode(.realWorldEstimate))) +setRendering(.environment(.realWorldLightingContribution(1.0))) +``` + +When an `UntoldEngineXR` instance is active, changing the rendering lighting mode owns the Vision Pro provider lifecycle. The XR layer observes the runtime lighting mode, enables or disables ARKit environment light estimation, and restarts the ARKit provider set when needed. + +Practical rule: + +```swift +// Scene or renderer setup +setRendering(.environment(.lightingMode(.realWorldEstimate))) + +// Runtime tuning +setRendering(.environment(.realWorldLightingContribution(0.75))) +``` + +Change the contribution factor whenever the app needs to tune the strength of real-world lighting. + +## Lighting Modes + +```swift +setRendering(.environment(.lightingMode(.authoredOnly))) +setRendering(.environment(.lightingMode(.staticIBL))) +setRendering(.environment(.lightingMode(.realWorldEstimate))) +``` + +| Mode | Effect | +|---|---| +| `.authoredOnly` | Disables IBL contribution and uses authored lights only. | +| `.staticIBL` | Uses the engine's loaded/static HDR IBL path. | +| `.realWorldEstimate` | Uses Vision Pro environment light probes when available. | + +If real-world lighting is enabled but no valid probe is available yet, the renderer falls back to the static IBL path. + +## Contribution Factor + +Use the contribution factor to tune how strongly the Vision Pro lighting probe affects the scene: + +```swift +setRendering(.environment(.realWorldLightingContribution(0.75))) +``` + +The value is a non-negative multiplier: + +| Value | Meaning | +|---|---| +| `0.0` | Real-world IBL contributes no ambient/specular lighting. | +| `0.5` | Half-strength real-world IBL. | +| `1.0` | Default full-strength real-world IBL. | +| `> 1.0` | Boosted real-world IBL. | + +Negative values are clamped to `0.0`. Non-finite values reset to `1.0`. + +The factor applies immediately to the latest cached probe; the app does not need to wait for ARKit to publish another probe update. + +The same multiplier can be set through the rendering settings API: + +```swift +setRendering(.environment(.realWorldLightingContribution(0.75))) +``` + +This only changes the contribution factor. Use `setRendering(.environment(.lightingMode(...)))` to enable or disable the Vision Pro provider through the runtime lighting mode. + +Unlike the lighting mode, the contribution factor can be changed at runtime without starting, stopping, or restarting ARKit providers. + +## Diagnostics + +Use diagnostics while testing on Vision Pro: + +```swift +print("XR Lighting:", xr.xrEnvironmentLightingDiagnostics()) +``` + +Important fields: + +| Field | Meaning | +|---|---| +| `enabled` | Whether the engine requested XR environment lighting. | +| `providerSupported` | Whether ARKit environment light estimation is supported. | +| `providerRunning` | Whether the ARKit provider is currently running. | +| `latestProbeTimestamp` | Timestamp of the latest accepted probe update. | +| `latestProbeTextureValid` | Whether the latest accepted probe contained a usable texture. | +| `latestCameraScaleReference` | Raw camera exposure reference reported with the latest accepted probe, when available. | +| `latestIntensityScale` | Engine-normalized brightness scale derived from the probe camera scale reference. This is applied before `realWorldLightingContribution`. | +| `latestTintColor` | Normalized RGB tint sampled from the latest accepted environment probe. Light portals use this for warm/cool real-world color. | +| `prefilterInFlight` | Whether the engine is currently converting a probe into runtime IBL textures. | +| `lastPrefilterDurationMs` | GPU command duration for the most recent prefilter pass. | +| `realWorldLightingContribution` | Current real-world lighting contribution multiplier. | +| `acceptedProbeUpdateCount` | Number of probe updates accepted by the engine. | +| `skippedProbeUpdateCount` | Number of probe updates skipped because of throttling or in-flight work. | +| `fallbackReason` | Reason XR lighting is unavailable, if the renderer is falling back. | + +Probe updates are not expected every frame. ARKit publishes updates opportunistically as the real-world estimate changes. The engine throttles accepted probe work to avoid unnecessary GPU prefiltering. + +When testing room-light changes, watch `acceptedProbeUpdateCount`, `latestProbeTimestamp`, and `latestIntensityScale`. The visual result only changes after the engine accepts and prefilters a new probe update. + +## Passthrough + +XR lighting and passthrough are separate controls: + +```swift +xr.setImmersionMode(xrImmersionMode: .mixed) +setRendering(.environment(.lightingMode(.realWorldEstimate))) +``` + +Mixed passthrough controls whether the real camera view is visible. XR lighting controls how virtual content is shaded. They can be used together or independently. + +## Light Portals + +For spatial twin scenes, selected window geometry can be configured as light portals. A portal emits a bounded proxy area light from the window surface, and can scale its intensity using the current XR lighting estimate: + +```swift +setSceneChannel( + .windowGeometry, + .lightPortal(.enabled(useRealWorldTint: true)) +) +``` + +See [Light Portals](UsingLightPortals.md) for setup details, diagnostics, performance notes, and limitations.