diff --git a/Sources/CShaderTypes/ShaderTypes.h b/Sources/CShaderTypes/ShaderTypes.h index 50bba695..4c513c49 100644 --- a/Sources/CShaderTypes/ShaderTypes.h +++ b/Sources/CShaderTypes/ShaderTypes.h @@ -55,6 +55,7 @@ typedef struct{ typedef struct{ simd_float4 attenuation; + // Emission direction from the spot light into the scene. simd_float3 direction; simd_float3 position; simd_float3 color; @@ -66,6 +67,7 @@ typedef struct{ typedef struct{ simd_float3 position; simd_float3 color; + // LTC polygon/front normal used to choose rectangle winding in the area-light shader. simd_float3 forward; simd_float3 right; simd_float3 up; @@ -299,6 +301,7 @@ typedef enum RenderTargets{ typedef struct{ simd_float4 baseColor; simd_int4 hasTexture; //x=hasbasecolor,y=hasroughmap, z=hasmetalmap + simd_int4 textureChannels; //x=roughness channel, y=metallic channel; 0=r,1=g,2=b,3=a simd_float4 edgeTint; simd_float3 emmissive; float roughness; diff --git a/Sources/DemoGame/AppDelegate.swift b/Sources/DemoGame/AppDelegate.swift index 6fef673f..9f77c54e 100644 --- a/Sources/DemoGame/AppDelegate.swift +++ b/Sources/DemoGame/AppDelegate.swift @@ -147,6 +147,12 @@ demoState.onLoadTiledScene = { [weak self] sceneID, url, completion in self?.gameScene.loadTileScene(sceneID: sceneID, url: url, completion: completion) } + demoState.onLoadSceneAuthoredFile = { [weak self] path, completion in + self?.gameScene.loadSceneAuthoredFile(path: path, completion: completion) + } + demoState.onLoadSceneAuthoredURL = { [weak self] url, completion in + self?.gameScene.loadSceneAuthoredURL(url: url, completion: completion) + } demoState.onBatchingChanged = { [weak self] enabled in self?.gameScene.setBatching(enabled) } diff --git a/Sources/DemoGame/DemoHUD.swift b/Sources/DemoGame/DemoHUD.swift index 501c656e..c42a4c9e 100644 --- a/Sources/DemoGame/DemoHUD.swift +++ b/Sources/DemoGame/DemoHUD.swift @@ -208,6 +208,11 @@ Spacer(minLength: 0) } + Toggle("Scene Authored", isOn: $state.localSceneAuthoredEnabled) + .toggleStyle(.checkbox) + .padding(.leading, 100) + .disabled(state.isLoading) + Divider() sectionLabel("CONTROLS") @@ -539,6 +544,9 @@ if success { state.selectedPostFXPreset = .neutral state.applySelectedPostFXPreset() + if state.localSceneAuthoredEnabled { + state.onLoadSceneAuthoredFile?(path) { _ in } + } } finishLocalImport(url: url, accessing: accessing, success: success, streamingEnabled: false) } @@ -558,6 +566,9 @@ if success { state.selectedPostFXPreset = .neutral state.applySelectedPostFXPreset() + if state.localSceneAuthoredEnabled { + state.onLoadSceneAuthoredURL?(url) { _ in } + } } finishLocalImport(url: url, accessing: accessing, success: success, streamingEnabled: success) } diff --git a/Sources/DemoGame/DemoState.swift b/Sources/DemoGame/DemoState.swift index d01e1326..40cc2519 100644 --- a/Sources/DemoGame/DemoState.swift +++ b/Sources/DemoGame/DemoState.swift @@ -103,6 +103,8 @@ remoteScenes.first { $0.id == selectedRemoteSceneID } } + var localSceneAuthoredEnabled: Bool = false + // MARK: - Features var batchingEnabled: Bool = false { @@ -229,6 +231,8 @@ var onLoadFile: ((String, @escaping @Sendable (Bool) -> Void) -> Void)? var onLoadTiledScene: ((String, URL, @escaping @Sendable (Bool) -> Void) -> Void)? + var onLoadSceneAuthoredFile: ((String, @escaping @Sendable (Bool) -> Void) -> Void)? + var onLoadSceneAuthoredURL: ((URL, @escaping @Sendable (Bool) -> Void) -> Void)? var onBatchingChanged: ((Bool) -> Void)? var onStreamingChanged: ((Bool, Double, Double) -> Void)? var onLodDebugChanged: ((Bool) -> Void)? diff --git a/Sources/DemoGame/GameScene.swift b/Sources/DemoGame/GameScene.swift index 4254043f..88b4dd2d 100644 --- a/Sources/DemoGame/GameScene.swift +++ b/Sources/DemoGame/GameScene.swift @@ -71,6 +71,8 @@ let light = createEntity() setEntityName(entityId: light, name: "Directional Light") createDirLight(entityId: light) + + setDirectionalLight(.active(light)) setCamera(.active(gameCamera)) setRendering(.environment(.ibl(true))) @@ -84,14 +86,21 @@ extension GameScene { /// Loads a USDZ file as an always-resident asset, replacing whatever was previously loaded. /// The previously loaded entity is destroyed; the scene camera and light are preserved. - func loadFile(path: String, completion: @escaping @Sendable (Bool) -> Void) { + func loadFile( + path: String, + completion: @escaping @Sendable (Bool) -> Void + ) { prepareForMeshLoad { [weak self] in guard let self else { return } let entity = createEntity() setEntityName(entityId: entity, name: path) - setEntityMeshAsync(entityId: entity, filename: path, withExtension: "untold") { [weak self] success in + setEntityMeshAsync( + entityId: entity, + filename: path, + withExtension: "untold" + ) { [weak self] success in guard let self else { return } loadedEntity = success ? entity : nil loadedContent = success ? .mesh(entity) : .none @@ -106,7 +115,11 @@ } /// Loads a tiled scene from a local or remote manifest URL. - func loadTileScene(sceneID: String, url: URL, completion: @escaping @Sendable (Bool) -> Void) { + func loadTileScene( + sceneID: String, + url: URL, + completion: @escaping @Sendable (Bool) -> Void + ) { // Destroy any previously loaded tiled scene before registering a new one. if case let .tiledScene(oldRoot) = loadedContent { destroyEntity(entityId: oldRoot) @@ -132,6 +145,14 @@ } } + func loadSceneAuthoredFile(path: String, completion: @escaping @Sendable (Bool) -> Void) { + loadSceneAuthored(filename: path, withExtension: "untold", completion: completion) + } + + func loadSceneAuthoredURL(url: URL, completion: @escaping @Sendable (Bool) -> Void) { + loadSceneAuthored(url: url, completion: completion) + } + private func prepareForMeshLoad(completion: @escaping () -> Void) { clearSceneBatches() setGeometryStreaming(.enabled(false)) diff --git a/Sources/Sandbox/GameScene.swift b/Sources/Sandbox/GameScene.swift index 9690f23c..8f54c4fa 100644 --- a/Sources/Sandbox/GameScene.swift +++ b/Sources/Sandbox/GameScene.swift @@ -27,19 +27,26 @@ // Uncomment to render a simple mesh. /* let entity = createEntity() - setEntityMeshAsync(entityId: entity, filename: "/path/to/mesh", withExtension: "untold") { success in - + setEntityMeshAsync(entityId: entity, filename: "/path/to/file", withExtension: "untold") { success in setEntityName(entityId: entity, name: "redplayer") - - //load animation - setEntityAnimations(entityId: entity, filename: "/path/to/animation", withExtension: "untold", name: "running") - - changeAnimation(entityId: entity, name: "running") - - setSceneReady(true) - + if success { + loadSceneAuthored(filename: "/path/to/file", withExtension: "untold") + } + setSceneReady(success) } - */ + */ + /* + let sceneRoot = createEntity() + setEntityStreamScene( + entityId: sceneRoot, + url: URL(fileURLWithPath: "/path/to/local/json") + ) { success in + if success { + loadSceneAuthored(url: URL(fileURLWithPath: "/path/to/local/json")) + } + setSceneReady(success) + } + */ // Uncomment to render a streamed scene /* @@ -62,12 +69,12 @@ let camera = createEntity() setEntityName(entityId: camera, name: "Main Camera") createGameCamera(entityId: camera) - CameraSystem.shared.activeCamera = camera + setCamera(.active(camera)) setOrbitOffset(entityId: camera, uTargetOffset: Constants.orbitTargetOffset) - let light = createEntity() - setEntityName(entityId: light, name: "Directional Light") - createDirLight(entityId: light) +// let light = createEntity() +// setEntityName(entityId: light, name: "Directional Light") + // createDirLight(entityId: light) } func update(deltaTime _: Float) { diff --git a/Sources/UntoldEngine/AssetFormat/UntoldBinaryCodable.swift b/Sources/UntoldEngine/AssetFormat/UntoldBinaryCodable.swift index 8ede274d..6106a543 100644 --- a/Sources/UntoldEngine/AssetFormat/UntoldBinaryCodable.swift +++ b/Sources/UntoldEngine/AssetFormat/UntoldBinaryCodable.swift @@ -331,6 +331,158 @@ extension UntoldTextureRefRecordV1: UntoldBinaryEncodable, UntoldBinaryDecodable } } +extension UntoldLightRecordV1: UntoldBinaryEncodable, UntoldBinaryDecodable { + public func encode(to writer: UntoldBinaryWriter) { + writer.writeUInt32LE(entityId) + writer.writeUInt32LE(nameOffset) + writer.writeUInt32LE(lightType.rawValue) + writer.writeUInt32LE(flags) + writer.writeFloat32LE(color.x) + writer.writeFloat32LE(color.y) + writer.writeFloat32LE(color.z) + writer.writeFloat32LE(intensity) + writer.writeFloat32LE(position.x) + writer.writeFloat32LE(position.y) + writer.writeFloat32LE(position.z) + writer.writeFloat32LE(radius) + writer.writeFloat32LE(direction.x) + writer.writeFloat32LE(direction.y) + writer.writeFloat32LE(direction.z) + writer.writeFloat32LE(falloff) + writer.writeFloat32LE(right.x) + writer.writeFloat32LE(right.y) + writer.writeFloat32LE(right.z) + writer.writeFloat32LE(innerCone) + writer.writeFloat32LE(up.x) + writer.writeFloat32LE(up.y) + writer.writeFloat32LE(up.z) + writer.writeFloat32LE(outerCone) + writer.writeFloat32LE(areaSize.x) + writer.writeFloat32LE(areaSize.y) + writer.writeFloat32LE(sourcePower) + writer.writeFloat32LE(sourceExposure) + writer.writeMatrix4x4LE(localTransform) + } + + public static func decode(from reader: UntoldBinaryReader) throws -> UntoldLightRecordV1 { + let entityId = try reader.readUInt32LE() + let nameOffset = try reader.readUInt32LE() + let lightTypeRaw = try reader.readUInt32LE() + guard let lightType = UntoldLightType(rawValue: lightTypeRaw) else { + throw UntoldValidationError.unsupportedEnumValue + } + + return try UntoldLightRecordV1( + entityId: entityId, + nameOffset: nameOffset, + lightType: lightType, + flags: reader.readUInt32LE(), + color: SIMD3( + reader.readFloat32LE(), + reader.readFloat32LE(), + reader.readFloat32LE() + ), + intensity: reader.readFloat32LE(), + position: SIMD3( + reader.readFloat32LE(), + reader.readFloat32LE(), + reader.readFloat32LE() + ), + radius: reader.readFloat32LE(), + direction: SIMD3( + reader.readFloat32LE(), + reader.readFloat32LE(), + reader.readFloat32LE() + ), + falloff: reader.readFloat32LE(), + right: SIMD3( + reader.readFloat32LE(), + reader.readFloat32LE(), + reader.readFloat32LE() + ), + innerCone: reader.readFloat32LE(), + up: SIMD3( + reader.readFloat32LE(), + reader.readFloat32LE(), + reader.readFloat32LE() + ), + outerCone: reader.readFloat32LE(), + areaSize: SIMD2( + reader.readFloat32LE(), + reader.readFloat32LE() + ), + sourcePower: reader.readFloat32LE(), + sourceExposure: reader.readFloat32LE(), + localTransform: reader.readMatrix4x4LE() + ) + } +} + +extension UntoldCameraRecordV1: UntoldBinaryEncodable, UntoldBinaryDecodable { + public func encode(to writer: UntoldBinaryWriter) { + writer.writeUInt32LE(entityId) + writer.writeUInt32LE(nameOffset) + writer.writeUInt32LE(flags) + writer.writeUInt32LE(reserved0) + writer.writeFloat32LE(position.x) + writer.writeFloat32LE(position.y) + writer.writeFloat32LE(position.z) + writer.writeFloat32LE(fovYDegrees) + writer.writeFloat32LE(forward.x) + writer.writeFloat32LE(forward.y) + writer.writeFloat32LE(forward.z) + writer.writeFloat32LE(nearClip) + writer.writeFloat32LE(up.x) + writer.writeFloat32LE(up.y) + writer.writeFloat32LE(up.z) + writer.writeFloat32LE(farClip) + writer.writeFloat32LE(right.x) + writer.writeFloat32LE(right.y) + writer.writeFloat32LE(right.z) + writer.writeFloat32LE(aspectRatio) + writer.writeMatrix4x4LE(localTransform) + } + + public static func decode(from reader: UntoldBinaryReader) throws -> UntoldCameraRecordV1 { + let entityId = try reader.readUInt32LE() + let nameOffset = try reader.readUInt32LE() + let flags = try reader.readUInt32LE() + let reserved0 = try reader.readUInt32LE() + var record = try UntoldCameraRecordV1( + entityId: entityId, + nameOffset: nameOffset, + flags: flags, + position: SIMD3( + reader.readFloat32LE(), + reader.readFloat32LE(), + reader.readFloat32LE() + ), + fovYDegrees: reader.readFloat32LE(), + forward: SIMD3( + reader.readFloat32LE(), + reader.readFloat32LE(), + reader.readFloat32LE() + ), + nearClip: reader.readFloat32LE(), + up: SIMD3( + reader.readFloat32LE(), + reader.readFloat32LE(), + reader.readFloat32LE() + ), + farClip: reader.readFloat32LE(), + right: SIMD3( + reader.readFloat32LE(), + reader.readFloat32LE(), + reader.readFloat32LE() + ), + aspectRatio: reader.readFloat32LE(), + localTransform: reader.readMatrix4x4LE() + ) + record.reserved0 = reserved0 + return record + } +} + extension UntoldSkeletonRecordV1: UntoldBinaryEncodable, UntoldBinaryDecodable { public func encode(to writer: UntoldBinaryWriter) { writer.writeUInt32LE(entityId) diff --git a/Sources/UntoldEngine/AssetFormat/UntoldFormat.swift b/Sources/UntoldEngine/AssetFormat/UntoldFormat.swift index 19e54767..16a5289a 100644 --- a/Sources/UntoldEngine/AssetFormat/UntoldFormat.swift +++ b/Sources/UntoldEngine/AssetFormat/UntoldFormat.swift @@ -51,6 +51,8 @@ public enum UntoldChunkType: UInt32, Sendable { case jointIndexData = 16 case jointWeightData = 17 case edgeIndexData = 18 + case lightTable = 19 + case cameraTable = 20 } public enum UntoldCompressionType: UInt32, Sendable { @@ -88,6 +90,24 @@ public enum UntoldTextureFormat: UInt32, Sendable { } } +public enum UntoldTextureChannel: UInt32, Sendable, Equatable { + case r = 0 + case g = 1 + case b = 2 + case a = 3 + + public static func decoded(from rawValue: UInt32) -> UntoldTextureChannel { + UntoldTextureChannel(rawValue: rawValue & 0b11) ?? .r + } +} + +public enum UntoldLightType: UInt32, Sendable { + case directional = 1 + case point = 2 + case spot = 3 + case area = 4 +} + public struct UntoldAABB: Sendable, Equatable { public var min: SIMD3 public var max: SIMD3 @@ -270,6 +290,9 @@ public struct UntoldMeshRecordV1: Sendable, Equatable { } public struct UntoldMaterialRecordV1: Sendable, Equatable { + private static let roughnessChannelShift: UInt32 = 0 + private static let metallicChannelShift: UInt32 = 2 + private static let textureChannelMask: UInt32 = 0b11 public var nameOffset: UInt32 public var flags: UInt32 public var baseColorFactor: SIMD4 @@ -287,6 +310,23 @@ public struct UntoldMaterialRecordV1: Sendable, Equatable { public var occlusionTextureIndex: UInt32 /// Reserved fixed-length 2-word field for forward compatibility. public var reserved0: [UInt32] + public var roughnessTextureChannel: UntoldTextureChannel { + let word = reserved0.first ?? 0 + return UntoldTextureChannel.decoded(from: word >> Self.roughnessChannelShift) + } + + public var metallicTextureChannel: UntoldTextureChannel { + let word = reserved0.first ?? 0 + return UntoldTextureChannel.decoded(from: word >> Self.metallicChannelShift) + } + + public static func packTextureChannels( + roughness: UntoldTextureChannel = .r, + metallic: UntoldTextureChannel = .r + ) -> UInt32 { + ((roughness.rawValue & textureChannelMask) << roughnessChannelShift) + | ((metallic.rawValue & textureChannelMask) << metallicChannelShift) + } public init( nameOffset: UInt32 = UntoldFormat.invalidIndex, @@ -303,7 +343,9 @@ public struct UntoldMaterialRecordV1: Sendable, Equatable { metallicTextureIndex: UInt32 = UntoldFormat.invalidIndex, roughnessTextureIndex: UInt32 = UntoldFormat.invalidIndex, emissiveTextureIndex: UInt32 = UntoldFormat.invalidIndex, - occlusionTextureIndex: UInt32 = UntoldFormat.invalidIndex + occlusionTextureIndex: UInt32 = UntoldFormat.invalidIndex, + roughnessTextureChannel: UntoldTextureChannel = .r, + metallicTextureChannel: UntoldTextureChannel = .r ) { self.nameOffset = nameOffset self.flags = flags @@ -320,7 +362,13 @@ public struct UntoldMaterialRecordV1: Sendable, Equatable { self.roughnessTextureIndex = roughnessTextureIndex self.emissiveTextureIndex = emissiveTextureIndex self.occlusionTextureIndex = occlusionTextureIndex - reserved0 = [0, 0] + reserved0 = [ + Self.packTextureChannels( + roughness: roughnessTextureChannel, + metallic: metallicTextureChannel + ), + 0, + ] } } @@ -354,6 +402,112 @@ public struct UntoldTextureRefRecordV1: Sendable, Equatable { } } +public struct UntoldLightRecordV1: Sendable, Equatable { + public var entityId: UInt32 + public var nameOffset: UInt32 + public var lightType: UntoldLightType + public var flags: UInt32 + public var color: SIMD3 + public var intensity: Float + public var position: SIMD3 + public var radius: Float + public var direction: SIMD3 + public var falloff: Float + public var right: SIMD3 + public var innerCone: Float + public var up: SIMD3 + public var outerCone: Float + public var areaSize: SIMD2 + public var sourcePower: Float + public var sourceExposure: Float + public var localTransform: simd_float4x4 + + public init( + entityId: UInt32, + nameOffset: UInt32 = UntoldFormat.invalidIndex, + lightType: UntoldLightType, + flags: UInt32 = 0, + color: SIMD3 = SIMD3(1, 1, 1), + intensity: Float = 1.0, + position: SIMD3 = .zero, + radius: Float = 1.0, + direction: SIMD3 = SIMD3(0, -1, 0), + falloff: Float = 0.5, + right: SIMD3 = SIMD3(1, 0, 0), + innerCone: Float = 5.0, + up: SIMD3 = SIMD3(0, 1, 0), + outerCone: Float = 10.0, + areaSize: SIMD2 = SIMD2(1, 1), + sourcePower: Float = 1.0, + sourceExposure: Float = 0.0, + localTransform: simd_float4x4 = matrix_identity_float4x4 + ) { + self.entityId = entityId + self.nameOffset = nameOffset + self.lightType = lightType + self.flags = flags + self.color = color + self.intensity = intensity + self.position = position + self.radius = radius + self.direction = direction + self.falloff = falloff + self.right = right + self.innerCone = innerCone + self.up = up + self.outerCone = outerCone + self.areaSize = areaSize + self.sourcePower = sourcePower + self.sourceExposure = sourceExposure + self.localTransform = localTransform + } +} + +public struct UntoldCameraRecordV1: Sendable, Equatable { + public var entityId: UInt32 + public var nameOffset: UInt32 + public var flags: UInt32 + public var reserved0: UInt32 + public var position: SIMD3 + public var fovYDegrees: Float + public var forward: SIMD3 + public var nearClip: Float + public var up: SIMD3 + public var farClip: Float + public var right: SIMD3 + public var aspectRatio: Float + public var localTransform: simd_float4x4 + + public init( + entityId: UInt32, + nameOffset: UInt32 = UntoldFormat.invalidIndex, + flags: UInt32 = 0, + position: SIMD3 = .zero, + fovYDegrees: Float = 50.0, + forward: SIMD3 = SIMD3(0, 0, 1), + nearClip: Float = 0.1, + up: SIMD3 = SIMD3(0, 1, 0), + farClip: Float = 1000.0, + right: SIMD3 = SIMD3(1, 0, 0), + aspectRatio: Float = 1.5, + localTransform: simd_float4x4 = matrix_identity_float4x4 + ) { + self.entityId = entityId + self.nameOffset = nameOffset + self.flags = flags + reserved0 = 0 + self.position = position + self.fovYDegrees = fovYDegrees + self.forward = forward + self.nearClip = nearClip + self.up = up + self.farClip = farClip + self.right = right + self.aspectRatio = aspectRatio + self.localTransform = localTransform + } +} + public struct UntoldSkeletonRecordV1: Sendable, Equatable { public var entityId: UInt32 public var nameOffset: UInt32 diff --git a/Sources/UntoldEngine/AssetFormat/UntoldReader.swift b/Sources/UntoldEngine/AssetFormat/UntoldReader.swift index 2f08a5af..c3c1e7ac 100644 --- a/Sources/UntoldEngine/AssetFormat/UntoldReader.swift +++ b/Sources/UntoldEngine/AssetFormat/UntoldReader.swift @@ -58,6 +58,18 @@ public final class UntoldReader: @unchecked Sendable { from: data, entries: chunks ) + let lights = try decodeTableIfPresent( + UntoldLightRecordV1.self, + chunkType: .lightTable, + from: data, + entries: chunks + ) + let cameras = try decodeTableIfPresent( + UntoldCameraRecordV1.self, + chunkType: .cameraTable, + from: data, + entries: chunks + ) let skeletons = try decodeTableIfPresent( UntoldSkeletonRecordV1.self, chunkType: .skeletonTable, @@ -115,6 +127,8 @@ public final class UntoldReader: @unchecked Sendable { meshes: meshes, materials: materials, textures: textures, + lights: lights, + cameras: cameras, skeletons: skeletons, skeletonJoints: skeletonJoints, skins: skins, @@ -437,6 +451,8 @@ public struct UntoldDecodedAsset: Sendable { public let meshes: [UntoldMeshRecordV1] public let materials: [UntoldMaterialRecordV1] public let textures: [UntoldTextureRefRecordV1] + public let lights: [UntoldLightRecordV1] + public let cameras: [UntoldCameraRecordV1] public let skeletons: [UntoldSkeletonRecordV1] public let skeletonJoints: [UntoldSkeletonJointRecordV1] public let skins: [UntoldSkinRecordV1] @@ -454,6 +470,8 @@ public struct UntoldDecodedAsset: Sendable { meshes: [UntoldMeshRecordV1], materials: [UntoldMaterialRecordV1], textures: [UntoldTextureRefRecordV1], + lights: [UntoldLightRecordV1] = [], + cameras: [UntoldCameraRecordV1] = [], skeletons: [UntoldSkeletonRecordV1], skeletonJoints: [UntoldSkeletonJointRecordV1], skins: [UntoldSkinRecordV1], @@ -470,6 +488,8 @@ public struct UntoldDecodedAsset: Sendable { self.meshes = meshes self.materials = materials self.textures = textures + self.lights = lights + self.cameras = cameras self.skeletons = skeletons self.skeletonJoints = skeletonJoints self.skins = skins diff --git a/Sources/UntoldEngine/Mesh/Mesh.swift b/Sources/UntoldEngine/Mesh/Mesh.swift index 8956ce70..50109ef2 100644 --- a/Sources/UntoldEngine/Mesh/Mesh.swift +++ b/Sources/UntoldEngine/Mesh/Mesh.swift @@ -624,6 +624,8 @@ public struct Material { public var emissiveValue: simd_float3 = .zero public var roughnessValue: Float = 1.0 public var metallicValue: Float = 0.0 + public var roughnessChannel: UntoldTextureChannel = .r + public var metallicChannel: UntoldTextureChannel = .r // Disney material properties public var specular: Float = 0.0 @@ -788,6 +790,8 @@ public struct Material { emissiveValue = runtimeMaterial.emissiveFactor roughnessValue = runtimeMaterial.roughnessFactor metallicValue = runtimeMaterial.metallicFactor + roughnessChannel = runtimeMaterial.roughnessTextureChannel + metallicChannel = runtimeMaterial.metallicTextureChannel alphaCutoff = runtimeMaterial.alphaCutoff let alphaModeBits = runtimeMaterial.flags & 0b11 diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 282c03db..ae404324 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -666,6 +666,25 @@ public enum RenderPasses { return computed } + @inline(__always) + private static func applyMaterialTextureState( + material: Material, + materialParameters: inout MaterialParametersUniform + ) { + materialParameters.hasTexture = simd_int4( + Int32(material.hasBaseMap ? 1 : 0), + Int32(material.hasRoughMap ? 1 : 0), + Int32(material.hasMetalMap ? 1 : 0), + 0 + ) + materialParameters.textureChannels = simd_int4( + Int32(material.roughnessChannel.rawValue), + Int32(material.metallicChannel.rawValue), + 0, + 0 + ) + } + @inline(__always) private static func applyLODDebugColorOverride( entityId: EntityID? = nil, @@ -973,7 +992,7 @@ public enum RenderPasses { renderEncoder.setRenderPipelineState(shadowPipeline.pipelineState!) renderEncoder.setDepthStencilState(shadowPipeline.depthState!) renderEncoder.waitForFence(renderInfo.fence, before: .vertex) - renderEncoder.setDepthBias(0.005, slopeScale: 1.0, clamp: 1.0) + renderEncoder.setDepthBias(0.0015, slopeScale: 0.75, clamp: 0.02) renderEncoder.setViewport( MTLViewport(originX: 0, originY: 0, width: Double(shadowResolution.x), height: Double(shadowResolution.y), @@ -1106,7 +1125,7 @@ public enum RenderPasses { renderEncoder.setRenderPipelineState(shadowPipeline.pipelineState!) renderEncoder.setDepthStencilState(shadowPipeline.depthState!) renderEncoder.waitForFence(renderInfo.fence, before: .vertex) - renderEncoder.setDepthBias(0.005, slopeScale: 1.0, clamp: 1.0) + renderEncoder.setDepthBias(0.0015, slopeScale: 0.75, clamp: 0.02) renderEncoder.setViewport( MTLViewport(originX: 0, originY: 0, width: Double(shadowResolution.x), height: Double(shadowResolution.y), @@ -1404,12 +1423,7 @@ public enum RenderPasses { materialParameters.interactWithLight = material.interactWithLight materialParameters.emmissive = material.emissiveValue - materialParameters.hasTexture = simd_int4( - Int32(material.hasBaseMap ? 1 : 0), - Int32(material.hasRoughMap ? 1 : 0), - Int32(material.hasMetalMap ? 1 : 0), - 0 - ) + applyMaterialTextureState(material: material, materialParameters: &materialParameters) applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) applyStreamingTierDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) applyLODDither(draw: lodDraw, materialParameters: &materialParameters) @@ -1629,12 +1643,7 @@ public enum RenderPasses { materialParameters.interactWithLight = material.interactWithLight materialParameters.emmissive = material.emissiveValue - materialParameters.hasTexture = simd_int4( - Int32(material.hasBaseMap ? 1 : 0), - Int32(material.hasRoughMap ? 1 : 0), - Int32(material.hasMetalMap ? 1 : 0), - 0 - ) + applyMaterialTextureState(material: material, materialParameters: &materialParameters) applyLODDebugColorOverride( batchGroup: batchGroup, materialParameters: &materialParameters @@ -1816,12 +1825,7 @@ public enum RenderPasses { materialParameters.alphaMode = Int32(material.alphaMode.rawValue) materialParameters.interactWithLight = material.interactWithLight materialParameters.emmissive = material.emissiveValue - materialParameters.hasTexture = simd_int4( - Int32(material.hasBaseMap ? 1 : 0), - Int32(material.hasRoughMap ? 1 : 0), - Int32(material.hasMetalMap ? 1 : 0), - 0 - ) + applyMaterialTextureState(material: material, materialParameters: &materialParameters) applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) applyStreamingTierDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) applyLODDither(draw: lodDraw, materialParameters: &materialParameters) @@ -1914,12 +1918,7 @@ public enum RenderPasses { materialParameters.alphaMode = Int32(material.alphaMode.rawValue) materialParameters.interactWithLight = material.interactWithLight materialParameters.emmissive = material.emissiveValue - materialParameters.hasTexture = simd_int4( - Int32(material.hasBaseMap ? 1 : 0), - Int32(material.hasRoughMap ? 1 : 0), - Int32(material.hasMetalMap ? 1 : 0), - 0 - ) + applyMaterialTextureState(material: material, materialParameters: &materialParameters) applyLODDebugColorOverride(batchGroup: batchGroup, materialParameters: &materialParameters) applyStreamingTierDebugColorOverride(batchMaterial: material, materialParameters: &materialParameters) @@ -3217,12 +3216,7 @@ public enum RenderPasses { materialParameters.alphaMode = Int32(material.alphaMode.rawValue) materialParameters.interactWithLight = material.interactWithLight materialParameters.emmissive = material.emissiveValue - materialParameters.hasTexture = simd_int4( - Int32(material.hasBaseMap ? 1 : 0), - Int32(material.hasRoughMap ? 1 : 0), - Int32(material.hasMetalMap ? 1 : 0), - 0 - ) + applyMaterialTextureState(material: material, materialParameters: &materialParameters) applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) applyStreamingTierDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 77050f1e..53ebbb3a 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -173,7 +173,8 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { setEntityName(entityId: light, name: "Directional Light") createDirLight(entityId: light) - CameraSystem.shared.activeCamera = gameCamera + setCamera(.active(gameCamera)) + setDirectionalLight(.active(light)) #if os(visionOS) BatchingSystem.shared.applyRuntimeBatchingTuning(.visionOSBalanced) diff --git a/Sources/UntoldEngine/RuntimeAssets/NativeFormatLoader.swift b/Sources/UntoldEngine/RuntimeAssets/NativeFormatLoader.swift index e6c3d4d8..052cc143 100644 --- a/Sources/UntoldEngine/RuntimeAssets/NativeFormatLoader.swift +++ b/Sources/UntoldEngine/RuntimeAssets/NativeFormatLoader.swift @@ -59,13 +59,15 @@ public struct NativeFormatLoader: NamedRuntimeAssetLoading { jointWeightChunkData: jointWeightChunkData ) - return RuntimeAsset( + return try RuntimeAsset( sourceURL: url, sourceKind: .untold, assetName: url.deletingPathExtension().lastPathComponent, rootTransform: decoded.header.rootTransform, worldBounds: decoded.header.worldBounds, nodes: nodes, + lights: makeRuntimeLights(decoded: decoded), + cameras: makeRuntimeCameras(decoded: decoded), animationClips: animationClips ) } @@ -136,6 +138,55 @@ public struct NativeFormatLoader: NamedRuntimeAssetLoading { } } + private func makeRuntimeLights(decoded: UntoldDecodedAsset) throws -> [RuntimeLightSource] { + try decoded.lights.map { record in + try RuntimeLightSource( + name: decoded.string(at: record.nameOffset), + kind: runtimeLightKind(from: record.lightType), + color: record.color, + intensity: record.intensity, + position: record.position, + radius: record.radius, + direction: record.direction, + falloff: record.falloff, + right: record.right, + innerCone: record.innerCone, + up: record.up, + outerCone: record.outerCone, + areaSize: record.areaSize, + sourcePower: record.sourcePower, + sourceExposure: record.sourceExposure, + localTransform: record.localTransform + ) + } + } + + private func makeRuntimeCameras(decoded: UntoldDecodedAsset) throws -> [RuntimeCameraSource] { + try decoded.cameras.map { record in + try RuntimeCameraSource( + name: decoded.string(at: record.nameOffset), + position: record.position, + forward: record.forward, + up: record.up, + right: record.right, + fovYDegrees: record.fovYDegrees, + nearClip: record.nearClip, + farClip: record.farClip, + aspectRatio: record.aspectRatio, + localTransform: record.localTransform + ) + } + } + + private func runtimeLightKind(from lightType: UntoldLightType) -> RuntimeLightSourceKind { + switch lightType { + case .directional: .directional + case .point: .point + case .spot: .spot + case .area: .area + } + } + private func makeRuntimeNode( from entity: UntoldEntityRecordV1, decoded: UntoldDecodedAsset, @@ -349,6 +400,8 @@ public struct NativeFormatLoader: NamedRuntimeAssetLoading { normalScale: material.normalScale, metallicFactor: material.metallicFactor, roughnessFactor: material.roughnessFactor, + metallicTextureChannel: material.metallicTextureChannel, + roughnessTextureChannel: material.roughnessTextureChannel, occlusionStrength: material.occlusionStrength, alphaCutoff: material.alphaCutoff, flags: material.flags, diff --git a/Sources/UntoldEngine/RuntimeAssets/RuntimeAsset.swift b/Sources/UntoldEngine/RuntimeAssets/RuntimeAsset.swift index 0e1474a9..41cb3487 100644 --- a/Sources/UntoldEngine/RuntimeAssets/RuntimeAsset.swift +++ b/Sources/UntoldEngine/RuntimeAssets/RuntimeAsset.swift @@ -24,6 +24,105 @@ public enum RuntimeAssetSourceKind: String, Sendable { case procedural } +public enum RuntimeLightSourceKind: String, Sendable, Equatable { + case directional + case point + case spot + case area +} + +public struct RuntimeLightSource: Sendable, Equatable { + public var name: String? + public var kind: RuntimeLightSourceKind + public var color: SIMD3 + public var intensity: Float + public var position: SIMD3 + public var radius: Float + public var direction: SIMD3 + public var falloff: Float + public var right: SIMD3 + public var innerCone: Float + public var up: SIMD3 + public var outerCone: Float + public var areaSize: SIMD2 + public var sourcePower: Float + public var sourceExposure: Float + public var localTransform: simd_float4x4 + + public init( + name: String? = nil, + kind: RuntimeLightSourceKind, + color: SIMD3 = SIMD3(1, 1, 1), + intensity: Float = 1.0, + position: SIMD3 = .zero, + radius: Float = 1.0, + direction: SIMD3 = SIMD3(0, -1, 0), + falloff: Float = 0.5, + right: SIMD3 = SIMD3(1, 0, 0), + innerCone: Float = 5.0, + up: SIMD3 = SIMD3(0, 1, 0), + outerCone: Float = 10.0, + areaSize: SIMD2 = SIMD2(1, 1), + sourcePower: Float = 1.0, + sourceExposure: Float = 0.0, + localTransform: simd_float4x4 = matrix_identity_float4x4 + ) { + self.name = name + self.kind = kind + self.color = color + self.intensity = intensity + self.position = position + self.radius = radius + self.direction = direction + self.falloff = falloff + self.right = right + self.innerCone = innerCone + self.up = up + self.outerCone = outerCone + self.areaSize = areaSize + self.sourcePower = sourcePower + self.sourceExposure = sourceExposure + self.localTransform = localTransform + } +} + +public struct RuntimeCameraSource: Sendable, Equatable { + public var name: String? + public var position: SIMD3 + public var forward: SIMD3 + public var up: SIMD3 + public var right: SIMD3 + public var fovYDegrees: Float + public var nearClip: Float + public var farClip: Float + public var aspectRatio: Float + public var localTransform: simd_float4x4 + + public init( + name: String? = nil, + position: SIMD3 = .zero, + forward: SIMD3 = SIMD3(0, 0, 1), + up: SIMD3 = SIMD3(0, 1, 0), + right: SIMD3 = SIMD3(1, 0, 0), + fovYDegrees: Float = 50.0, + nearClip: Float = 0.1, + farClip: Float = 1000.0, + aspectRatio: Float = 1.5, + localTransform: simd_float4x4 = matrix_identity_float4x4 + ) { + self.name = name + self.position = position + self.forward = forward + self.up = up + self.right = right + self.fovYDegrees = fovYDegrees + self.nearClip = nearClip + self.farClip = farClip + self.aspectRatio = aspectRatio + self.localTransform = localTransform + } +} + public struct RuntimeTextureReference: Sendable, Equatable { public var name: String? public var sourceURL: URL? @@ -64,6 +163,8 @@ public struct RuntimeMaterialSource: Sendable, Equatable { public var normalScale: Float public var metallicFactor: Float public var roughnessFactor: Float + public var metallicTextureChannel: UntoldTextureChannel + public var roughnessTextureChannel: UntoldTextureChannel public var occlusionStrength: Float public var alphaCutoff: Float public var flags: UInt32 @@ -81,6 +182,8 @@ public struct RuntimeMaterialSource: Sendable, Equatable { normalScale: Float = 1.0, metallicFactor: Float = 1.0, roughnessFactor: Float = 1.0, + metallicTextureChannel: UntoldTextureChannel = .r, + roughnessTextureChannel: UntoldTextureChannel = .r, occlusionStrength: Float = 1.0, alphaCutoff: Float = 0.5, flags: UInt32 = 0, @@ -97,6 +200,8 @@ public struct RuntimeMaterialSource: Sendable, Equatable { self.normalScale = normalScale self.metallicFactor = metallicFactor self.roughnessFactor = roughnessFactor + self.metallicTextureChannel = metallicTextureChannel + self.roughnessTextureChannel = roughnessTextureChannel self.occlusionStrength = occlusionStrength self.alphaCutoff = alphaCutoff self.flags = flags @@ -341,6 +446,8 @@ public struct RuntimeAsset: Sendable, Equatable { public var rootTransform: simd_float4x4 public var worldBounds: RuntimeAABB public var nodes: [RuntimeAssetNode] + public var lights: [RuntimeLightSource] + public var cameras: [RuntimeCameraSource] public var animationClips: [RuntimeAnimationClip] public var meshGroups: [RuntimeMeshGroup] @@ -351,6 +458,8 @@ public struct RuntimeAsset: Sendable, Equatable { rootTransform: simd_float4x4 = matrix_identity_float4x4, worldBounds: RuntimeAABB, nodes: [RuntimeAssetNode] = [], + lights: [RuntimeLightSource] = [], + cameras: [RuntimeCameraSource] = [], animationClips: [RuntimeAnimationClip] = [], meshGroups: [RuntimeMeshGroup] ) { @@ -360,6 +469,8 @@ public struct RuntimeAsset: Sendable, Equatable { self.rootTransform = rootTransform self.worldBounds = worldBounds self.nodes = nodes + self.lights = lights + self.cameras = cameras self.animationClips = animationClips self.meshGroups = meshGroups } @@ -371,6 +482,8 @@ public struct RuntimeAsset: Sendable, Equatable { rootTransform: simd_float4x4 = matrix_identity_float4x4, worldBounds: RuntimeAABB, nodes: [RuntimeAssetNode], + lights: [RuntimeLightSource] = [], + cameras: [RuntimeCameraSource] = [], animationClips: [RuntimeAnimationClip] = [] ) { self.sourceURL = sourceURL @@ -379,6 +492,8 @@ public struct RuntimeAsset: Sendable, Equatable { self.rootTransform = rootTransform self.worldBounds = worldBounds self.nodes = nodes + self.lights = lights + self.cameras = cameras self.animationClips = animationClips meshGroups = nodes.compactMap { node in guard !node.primitives.isEmpty else { return nil } diff --git a/Sources/UntoldEngine/Scenes/SceneSerializer.swift b/Sources/UntoldEngine/Scenes/SceneSerializer.swift index ad17e686..f1f33d7b 100644 --- a/Sources/UntoldEngine/Scenes/SceneSerializer.swift +++ b/Sources/UntoldEngine/Scenes/SceneSerializer.swift @@ -53,7 +53,7 @@ struct ColorGradingData: Codable { var brightness: Float = 0.0 var contrast: Float = 1.0 var saturation: Float = 1.0 - var exposure: Float = 1.0 + var exposure: Float = 0.0 var temperature: Float = 0.0 var tint: Float = 0.0 var enabled: Bool? = false @@ -65,6 +65,7 @@ struct ColorCorrectionData: Codable { var lift: simd_float3 = .zero // RGB adjustment for shadows (0 - 2) var gamma: simd_float3 = .one // RGB adjustment for midtones (0.5 - 2.5) var gain: simd_float3 = .one // RGB adjustment for highlights (0 - 2) + var enabled: Bool? = false } struct BloomThresholdData: Codable { @@ -632,7 +633,6 @@ public func serializeScene() -> SceneData { entityData.lightData?.radius = getLightRadius(entityId: entityId) entityData.lightData?.intensity = getLightIntensity(entityId: entityId) - entityData.lightData?.falloff = getLightFalloff(entityId: entityId) } @@ -666,7 +666,6 @@ public func serializeScene() -> SceneData { entityData.lightData?.color = getLightColor(entityId: entityId) entityData.lightData?.intensity = getLightIntensity(entityId: entityId) - entityData.lightData?.forward = getForwardAxisVector(entityId: entityId) entityData.lightData?.right = getRightAxisVector(entityId: entityId) @@ -898,7 +897,8 @@ public func serializeScene() -> SceneData { sceneData.colorCorrection = ColorCorrectionData( lift: ColorCorrectionParams.shared.lift, gamma: ColorCorrectionParams.shared.gamma, - gain: ColorCorrectionParams.shared.gain + gain: ColorCorrectionParams.shared.gain, + enabled: ColorCorrectionParams.shared.enabled ) sceneData.colorGrading = ColorGradingData( @@ -1097,6 +1097,15 @@ public func deserializeScene( } } + if let colorCorrection = sceneData.colorCorrection { + ColorCorrectionParams.shared.lift = colorCorrection.lift + ColorCorrectionParams.shared.gamma = colorCorrection.gamma + ColorCorrectionParams.shared.gain = colorCorrection.gain + if let enabled = colorCorrection.enabled { + ColorCorrectionParams.shared.enabled = enabled + } + } + if let bloomThreshold = sceneData.bloom { BloomThresholdParams.shared.intensity = bloomThreshold.intensity BloomThresholdParams.shared.threshold = bloomThreshold.threshold diff --git a/Sources/UntoldEngine/Shaders/ColorGradingShader.metal b/Sources/UntoldEngine/Shaders/ColorGradingShader.metal index 2edf3b86..4c973edd 100644 --- a/Sources/UntoldEngine/Shaders/ColorGradingShader.metal +++ b/Sources/UntoldEngine/Shaders/ColorGradingShader.metal @@ -106,15 +106,21 @@ fragment float4 fragmentColorGradingShader(VertexCompositeOutput vertexOut [[sta constant float &contrast [[buffer(colorGradingPassContrastIndex)]], constant float &saturation [[buffer(colorGradingPassSaturationIndex)]], constant float &exposure [[buffer(colorGradingPassExposureIndex)]], - constant float3 &whiteBalanceCoeffs[[buffer(colorGradingWhiteBalanceCoeffsIndex)]]) + constant float3 &whiteBalanceCoeffs[[buffer(colorGradingWhiteBalanceCoeffsIndex)]], + constant bool &enabled [[buffer(colorGradingPassEnabledIndex)]]) { constexpr sampler s(min_filter::linear, mag_filter::linear, address::clamp_to_edge); - float3 color = finalTexture.sample(s, vertexOut.uvCoords).rgb; + float4 sample = finalTexture.sample(s, vertexOut.uvCoords); + float3 color = sample.rgb; + + if (!enabled) { + return sample; + } color = colorExposure(color, exposure); color = whiteBalance(color, whiteBalanceCoeffs); color = colorContrast(color, contrast); color *= (1.0+brightness); color = colorSaturation(color, saturation); - return float4(max(color,0.0), 1.0); + return float4(max(color,0.0), sample.a); } diff --git a/Sources/UntoldEngine/Shaders/LightShader.metal b/Sources/UntoldEngine/Shaders/LightShader.metal index 07dd9189..d75f35b2 100644 --- a/Sources/UntoldEngine/Shaders/LightShader.metal +++ b/Sources/UntoldEngine/Shaders/LightShader.metal @@ -14,23 +14,6 @@ #include "ShadersUtils.h" using namespace metal; -constant uint MAX_POINT_LIGHTS = 1024; - -struct PointLightBlock{ - uint4 count; - PointLightUniform lights[MAX_POINT_LIGHTS]; -}; - -struct SpotLightBlock{ - uint4 count; - SpotLightUniform lights[MAX_POINT_LIGHTS]; -}; - -struct AreaLightBlock{ - uint4 count; - AreaLightUniform lights[MAX_POINT_LIGHTS]; -}; - // Cascaded shadow map sampling. // Selects the cascade whose far-split encloses the fragment's camera view-depth, // then performs a 16-tap Poisson-disk PCF on that cascade's depth slice. @@ -65,14 +48,15 @@ float computeCSMShadow( constexpr sampler shadowSampler(coord::normalized, filter::linear, address::clamp_to_edge); float2 texelSize = 1.0 / float2(shadowArray.get_width(), shadowArray.get_height()); - float bias = max(0.002 * (1.0 - dot(normalize(normal), normalize(lightDir))), 0.001); + float NoL = clamp(dot(normalize(normal), normalize(lightDir)), 0.0, 1.0); + float bias = max(0.0011 * (1.0 - NoL), 0.0003); float currentDepth = proj.z; float shadow = 0.0; for (int i = 0; i < 16; ++i) { - float2 offset = poissonDisk[i] * texelSize * 1.5; + float2 offset = poissonDisk[i] * texelSize; float sampledDepth = shadowArray.sample(shadowSampler, proj.xy + offset, cascade); - shadow += (currentDepth - bias) > sampledDepth ? 0.3 : 1.0; + shadow += (currentDepth - bias) > sampledDepth ? 0.0 : 1.0; } return shadow / 16.0; } @@ -118,8 +102,9 @@ LightContribution computePointLightContribution(constant PointLightUniform &ligh float roughness, float metallic ){ - float3 lightDirection=normalize(light.position.xyz-verticesInWorldSpace.xyz); - float lightDistance=length(light.position.xyz-verticesInWorldSpace.xyz); + float3 lightDelta = light.position.xyz - verticesInWorldSpace.xyz; + float lightDistance = length(lightDelta); + float3 lightDirection = lightDelta * rsqrt(max(dot(lightDelta, lightDelta), 1.0e-8)); LightContribution br=computeBRDF(lightDirection, viewVector, normalMap.xyz, inBaseColor, float3(1.0), roughness,metallic); @@ -141,17 +126,22 @@ LightContribution computeSpotLightContribution(constant SpotLightUniform &light, float metallic ){ - float3 lightDirection=normalize(light.position.xyz-verticesInWorldSpace.xyz); - float3 spotDirection = normalize(light.direction.xyz); - float lightDistance=length(light.position.xyz-verticesInWorldSpace.xyz); + float3 lightDelta = light.position.xyz - verticesInWorldSpace.xyz; + float lightDistance = length(lightDelta); + float3 lightDirection = lightDelta * rsqrt(max(dot(lightDelta, lightDelta), 1.0e-8)); + float directionLen2 = dot(light.direction.xyz, light.direction.xyz); + float3 spotDirection = directionLen2 > 1.0e-8 ? light.direction.xyz * rsqrt(directionLen2) : float3(0.0, -1.0, 0.0); float attenuation=calculateAttenuation(lightDistance, light.attenuation); LightContribution br=computeBRDF(lightDirection, viewVector, normalMap.xyz, inBaseColor, float3(1.0), roughness,metallic); float theta = dot(-lightDirection, spotDirection); // cosine of angle between light dir and spot dir - float epsilon = cos(light.innerCone) - cos(light.outerCone); - float coneFalloff = clamp((theta-cos(light.outerCone))/epsilon, 0.0, 1.0); + float innerCone = clamp(light.innerCone, 0.0, M_PI_F - 1.0e-4); + float outerCone = clamp(light.outerCone, innerCone + 1.0e-4, M_PI_F); + float outerCos = cos(outerCone); + float epsilon = max(cos(innerCone) - outerCos, 1.0e-4); + float coneFalloff = clamp((theta - outerCos) / epsilon, 0.0, 1.0); LightContribution outC; outC.diff = br.diff * (half)attenuation * (half)coneFalloff * (half)light.intensity * half3(light.color); @@ -182,13 +172,19 @@ LightContribution evaluateAreaLight(constant AreaLightUniform &light, //float NoV = max(dot(normalMap, viewVector), 0.001); - float theta = acos(clamp(dot(normalMap, viewVector), 0.0, 1.0)); - float2 uv = float2(roughness, theta / (0.5 * M_PI_F)); + float NoV = clamp(dot(normalMap, viewVector), 0.0, 1.0); + float theta = acos(NoV); + float2 uv = float2(clamp(roughness, 0.0, 1.0), theta / (0.5 * M_PI_F)); uv = uv * LUT_SCALE + LUT_BIAS; float4 t = ltcMat.sample(s, uv); float4 t2 = ltcMag.sample(s,uv); + + if (light.bounds.x <= 0.0 || light.bounds.y <= 0.0) { + LightContribution outC; + return outC; + } float3x3 Minv= float3x3(float3(t.x,0,t.y), float3(0,1.0,0), @@ -198,38 +194,45 @@ LightContribution evaluateAreaLight(constant AreaLightUniform &light, float3 P = verticesInWorldSpace.xyz; // Compute corners - float3 u = light.right * light.bounds.x; - float3 v = light.up * light.bounds.y; + float3 u = normalize(light.right) * light.bounds.x; + float3 v = normalize(light.up) * light.bounds.y; float3 p0 = light.position - 0.5 * u - 0.5 * v; float3 p1 = light.position + 0.5 * u - 0.5 * v; float3 p2 = light.position + 0.5 * u + 0.5 * v; float3 p3 = light.position - 0.5 * u + 0.5 * v; float3 points[4]; - points[0]=p0; - points[1]=p1; - points[2]=p2; - points[3]=p3; + float3 areaNormalRaw = cross(u, v); + float areaNormalLen2 = dot(areaNormalRaw, areaNormalRaw); + float3 areaNormal = areaNormalLen2 > 1.0e-8 ? areaNormalRaw * rsqrt(areaNormalLen2) : float3(0.0, 0.0, 1.0); + float forwardLen2 = dot(light.forward, light.forward); + float3 emittingNormal = forwardLen2 > 1.0e-8 ? light.forward * rsqrt(forwardLen2) : areaNormal; + if (dot(areaNormal, emittingNormal) >= 0.0) { + points[0]=p0; + points[1]=p1; + points[2]=p2; + points[3]=p3; + } else { + points[0]=p0; + points[1]=p3; + points[2]=p2; + points[3]=p1; + } float3x3 identity=float3x3(float3(1.0,0.0,0.0), float3(0.0,1.0,0.0), float3(0.0,0.0,1.0)); float3 Lo_spec=LTC_Evaluate(normalMap.xyz, viewVector, P,Minv, points, light.twoSided); - - Lo_spec *= t2.x; float3 Lo_diffuse = LTC_Evaluate(normalMap.xyz, viewVector, P,identity, points, light.twoSided); - float3 lightDirection=normalize(light.position.xyz-verticesInWorldSpace.xyz); - - half3 diffuseBRDF = computeDiffuseBRDF(lightDirection, viewVector, normalMap, inBaseColor.rgb, float3(1.0), roughness, metallic); - - float3 specBRDF = computeSpecBRDF(lightDirection, viewVector, normalMap, inBaseColor.rgb, float3(1.0), roughness, metallic); - + 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); LightContribution outC; - outC.diff = (half)light.intensity * (half3)Lo_diffuse * diffuseBRDF * (half3)light.color; - outC.spec = light.intensity * Lo_spec * specBRDF * light.color; + outC.diff = (half3)(light.intensity * light.color * Lo_diffuse * diffuseBRDF); + outC.spec = light.intensity * light.color * Lo_spec * fresnelScale; return outC; diff --git a/Sources/UntoldEngine/Shaders/ShadersUtils.h b/Sources/UntoldEngine/Shaders/ShadersUtils.h index 8626e038..b99dbbbe 100644 --- a/Sources/UntoldEngine/Shaders/ShadersUtils.h +++ b/Sources/UntoldEngine/Shaders/ShadersUtils.h @@ -36,6 +36,28 @@ constant float LUT_SIZE = 64.0; constant float LUT_SCALE = (LUT_SIZE - 1.0)/LUT_SIZE; constant float LUT_BIAS = 0.5/LUT_SIZE; +struct LightContribution { + half3 diff = half3(0.0); + float3 spec = float3(0.0); +}; + +constant uint MAX_POINT_LIGHTS = 1024; + +struct PointLightBlock{ + uint4 count; + PointLightUniform lights[MAX_POINT_LIGHTS]; +}; + +struct SpotLightBlock{ + uint4 count; + SpotLightUniform lights[MAX_POINT_LIGHTS]; +}; + +struct AreaLightBlock{ + uint4 count; + AreaLightUniform lights[MAX_POINT_LIGHTS]; +}; + float degreesToRadians(float degrees); float3 rotateDirection(float3 dir, float3 axis, float angle); @@ -46,10 +68,12 @@ float3x3 rotation_matrix(float3 axis, float angle); float4x4 rotationmatrix4x4(float3 axis, float angle); -float calculateAttenuation(float distance, simd_float4 attenuation, float radius); +float calculateAttenuation(float distance, simd_float4 attenuation); float mod(float x, float y); +float selectTextureChannel(float4 sample, int channel); + void transformToLogDepth(thread simd_float4 &position, float far); //BRDF - Great intro: https://boksajak.github.io/files/CrashCourseBRDF.pdf @@ -63,11 +87,11 @@ float g1GGXSchlick(float NoV, float roughness); float geometricSmith(float NoV, float NoL,float roughness); // Cook-Torrance BRDF function - Refer to https://graphicscompendium.com/gamedev/15-pbr -LightContribution computeBRDF(float3 incomingLightDir, float3 viewDir, float3 surfaceNormal, float3 diffuseColor, float3 specularColor, MaterialParametersUniform materialParam,float roughnessMap, float metallicMap); +LightContribution computeBRDF(float3 incomingLightDir, float3 viewDir, float3 surfaceNormal, float3 diffuseColor, float3 specularColor, float roughnessMap, float metallicMap); -half3 computeDiffuseBRDF(float3 incomingLightDir, float3 viewDir, float3 surfaceNormal, float3 diffuseColor, float3 specularColor, MaterialParametersUniform materialParam,float roughnessMap, float metallicMap); +half3 computeDiffuseBRDF(float3 incomingLightDir, float3 viewDir, float3 surfaceNormal, float3 diffuseColor, float3 specularColor, float roughnessMap, float metallicMap); -float3 computeSpecBRDF(float3 incomingLightDir, float3 viewDir, float3 surfaceNormal, float3 diffuseColor, float3 specularColor, MaterialParametersUniform materialParam,float roughnessMap, float metallicMap); +float3 computeSpecBRDF(float3 incomingLightDir, float3 viewDir, float3 surfaceNormal, float3 diffuseColor, float3 specularColor, float roughnessMap, float metallicMap); @@ -119,6 +143,50 @@ float3 specularIBL(float3 F0 , float roughness, float3 N, float3 V, texture2d irradianceMap, float3 rotationAxis, float rotationAngle); +float computeCSMShadow(depth2d_array shadowArray, + constant CSMUniforms &csm, + float3 worldPos, + float3 cameraPos, + float3 normal, + float3 lightDir); + +float3 computeIBLContribution(texture2d irradianceTexture, + texture2d specularTexture, + texture2d iblBRDFTexture, + constant float &iblRotationAngle, + constant IBLParamsUniform &iblParam, + float4 inBaseColor, + float3 normalMap, + float3 viewVector, + float roughness, + float metallic); + +LightContribution computePointLightContribution(constant PointLightUniform &light, + float4 verticesInWorldSpace, + float3 viewVector, + float3 normalMap, + float3 inBaseColor, + float roughness, + float metallic); + +LightContribution computeSpotLightContribution(constant SpotLightUniform &light, + float4 verticesInWorldSpace, + float3 viewVector, + float3 normalMap, + float3 inBaseColor, + float roughness, + float metallic); + +LightContribution evaluateAreaLight(constant AreaLightUniform &light, + float4 verticesInWorldSpace, + float3 viewVector, + float3 normalMap, + texture2d ltcMat, + texture2d ltcMag, + float3 inBaseColor, + float roughness, + float metallic); + float3 ACESFilmicToneMapping(float3 x); // Filmic/Uncharted 2 Tone Mapping Function diff --git a/Sources/UntoldEngine/Shaders/ShadersUtils.metal b/Sources/UntoldEngine/Shaders/ShadersUtils.metal index b850d2bc..751a45b6 100644 --- a/Sources/UntoldEngine/Shaders/ShadersUtils.metal +++ b/Sources/UntoldEngine/Shaders/ShadersUtils.metal @@ -10,13 +10,10 @@ #include #include "../../CShaderTypes/ShaderTypes.h" +#include "ShaderStructs.h" +#include "ShadersUtils.h" using namespace metal; -struct LightContribution { - half3 diff = half3(0.0); // low-freq, FP16-friendly - float3 spec = float3(0.0); // high-freq, keep precision -}; - float3x3 rotation_matrix(float3 axis, float angle) { float c = cos(angle); @@ -61,6 +58,19 @@ float mod(float x, float y){ return x-y*floor(x/y); } +float selectTextureChannel(float4 sample, int channel){ + switch (channel) { + case 1: + return sample.g; + case 2: + return sample.b; + case 3: + return sample.a; + default: + return sample.r; + } +} + void transformToLogDepth(thread simd_float4 &position, float far){ float logarithmicDepthScale=100.0; @@ -228,13 +238,13 @@ float3 computeSpecBRDF(float3 incomingLightDir, float3 viewDir, float3 surfaceNo float VoH = max(dot(viewDir, halfVector), 0.001); //float LoH = max(dot(incomingLightDir, halfVector), 0.001); - //float NoH = max(dot(surfaceNormal, halfVector), 0.001); + float NoH = max(dot(surfaceNormal, halfVector), 0.001); float3 f0 = mix(0.04, diffuseColor.rgb, (half)metallic); float3 F=fresnelSchlick(VoH,f0); - float D=g1GGXSchlick(NoV,roughness); + float D=distributionGGX(NoH,roughness); float G=geometricSmith(NoV,NoL,roughness); @@ -592,7 +602,13 @@ float3 LTC_Evaluate(float3 N, float3 V, float3 P, float3x3 Minv, float3 points[4 // constuct orthonormal basis around N float3 T1, T2; - T1 = normalize(V-N*dot(V,N)); + T1 = V - N * dot(V, N); + if (dot(T1, T1) < 1.0e-6) { + T1 = abs(N.z) < 0.999 ? normalize(cross(float3(0.0, 0.0, 1.0), N)) + : normalize(cross(float3(0.0, 1.0, 0.0), N)); + } else { + T1 = normalize(T1); + } T2 = cross(N, T1); //rotate area light in (T1,T2, N) basis @@ -622,8 +638,9 @@ float3 LTC_Evaluate(float3 N, float3 V, float3 P, float3x3 Minv, float3 points[4 float3 Lo_i = float3(sum, sum, sum); - //return Lo_i; - return Lo_i*2.0/M_PI_F; + // Normalize the edge integral so a light covering the full visible hemisphere + // evaluates to 1.0 for a Lambertian surface. + return Lo_i * (1.0 / (2.0 * M_PI_F)); } float getLuminance(float3 color){ diff --git a/Sources/UntoldEngine/Shaders/TransparencyShader.metal b/Sources/UntoldEngine/Shaders/TransparencyShader.metal index 7a209519..5c3ecab0 100644 --- a/Sources/UntoldEngine/Shaders/TransparencyShader.metal +++ b/Sources/UntoldEngine/Shaders/TransparencyShader.metal @@ -72,12 +72,14 @@ fragment float4 fragmentTransparencyShader( : normalize(uniforms.normalMatrix * in.normal); float roughness = (materialParameter.hasTexture.y == 1) - ? roughnessTexture.sample(materialSampler, st).r * materialParameter.roughness + ? selectTextureChannel(roughnessTexture.sample(materialSampler, st), materialParameter.textureChannels.x) * materialParameter.roughness : materialParameter.roughness; + roughness = clamp(roughness, 0.045, 1.0); float metallic = (materialParameter.hasTexture.z == 1) - ? metallicTexture.sample(materialSampler, st).r * materialParameter.metallic + ? selectTextureChannel(metallicTexture.sample(materialSampler, st), materialParameter.textureChannels.y) * materialParameter.metallic : materialParameter.metallic; + metallic = clamp(metallic, 0.0, 1.0); float4 verticesInWorldSpace = uniforms.modelMatrix * in.vPosition; float3 viewVector = normalize(cameraPosition - verticesInWorldSpace.xyz); diff --git a/Sources/UntoldEngine/Shaders/modelShader.metal b/Sources/UntoldEngine/Shaders/modelShader.metal index 1d0a82e6..dfb36df9 100644 --- a/Sources/UntoldEngine/Shaders/modelShader.metal +++ b/Sources/UntoldEngine/Shaders/modelShader.metal @@ -173,12 +173,14 @@ fragment GBufferOut fragmentModelShader(VertexOutModel in [[stage_in]], normalMap=(hasNormal==false)?normalize(normalVectorInWorldSpace):normalize(TBN*normalMap); float roughness=(materialParameter.hasTexture.y==1) - ? roughnessTexture.sample(materialSampler, st, bias(0.25f)).r * materialParameter.roughness + ? selectTextureChannel(roughnessTexture.sample(materialSampler, st, bias(0.25f)), materialParameter.textureChannels.x) * materialParameter.roughness : materialParameter.roughness; + roughness=clamp(roughness, 0.045, 1.0); float metallic=(materialParameter.hasTexture.z==1) - ? metallicTexture.sample(materialSampler, st, bias(0.25f)).r * materialParameter.metallic + ? selectTextureChannel(metallicTexture.sample(materialSampler, st, bias(0.25f)), materialParameter.textureChannels.y) * materialParameter.metallic : materialParameter.metallic; + metallic=clamp(metallic, 0.0, 1.0); float4 color=inBaseColor; diff --git a/Sources/UntoldEngine/Shaders/tonemapShader.metal b/Sources/UntoldEngine/Shaders/tonemapShader.metal index 4c4a8f7f..18314bb6 100644 --- a/Sources/UntoldEngine/Shaders/tonemapShader.metal +++ b/Sources/UntoldEngine/Shaders/tonemapShader.metal @@ -39,14 +39,12 @@ fragment float4 fragmentTonemappingShader(VertexCompositeOutput vertexOut [[stag // Apply Uncharted2 Tone Mapping color.rgb = filmicToneMapping(color.rgb); - } - - if(toneMapOperator==2){ + } else if(toneMapOperator==2){ // Apply Reinhard Tone Mapping color.rgb = reinhardToneMapping(color.rgb); - }else{ + } else{ // Apply ACES Filmic Tone Mapping color.rgb = ACESFilmicToneMapping(color.rgb); diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index 880b5aab..0de1a895 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -2796,6 +2796,8 @@ public class BatchingSystem: @unchecked Sendable { // Material values (rounded to avoid tiny differences) components.append(String(format: "%.2f", material.roughnessValue)) components.append(String(format: "%.2f", material.metallicValue)) + components.append("\(material.roughnessChannel.rawValue)") + components.append("\(material.metallicChannel.rawValue)") components.append(String(format: "%.2f", material.specular)) components.append(String(format: "%.2f", material.ior)) components.append(String(format: "%.2f", material.stScale)) diff --git a/Sources/UntoldEngine/Systems/CameraSystem.swift b/Sources/UntoldEngine/Systems/CameraSystem.swift index bccd8393..ffa82f61 100644 --- a/Sources/UntoldEngine/Systems/CameraSystem.swift +++ b/Sources/UntoldEngine/Systems/CameraSystem.swift @@ -58,10 +58,12 @@ public enum CameraMoveSpace { } public func findGameCamera() -> EntityID { - for entityId in scene.getAllEntities() { - if hasComponent(entityId: entityId, componentType: CameraComponent.self), !hasComponent(entityId: entityId, componentType: SceneCameraComponent.self) { - return entityId - } + if let activeCamera = CameraSystem.shared.activeCamera, + scene.exists(activeCamera), + hasComponent(entityId: activeCamera, componentType: CameraComponent.self), + !hasComponent(entityId: activeCamera, componentType: SceneCameraComponent.self) + { + return activeCamera } // if scene camera was not found, then create one @@ -78,6 +80,7 @@ public func createGameCamera(entityId: EntityID) { cameraLookAt(entityId: entityId, eye: cameraDefaultEye, target: cameraTargetDefault, up: cameraUpDefault) + setCamera(.active(entityId)) } public func resetCameraToDefaultTransform(entityId: EntityID) { diff --git a/Sources/UntoldEngine/Systems/LightingSystem.swift b/Sources/UntoldEngine/Systems/LightingSystem.swift index 5bc8f1a0..3b0564ca 100644 --- a/Sources/UntoldEngine/Systems/LightingSystem.swift +++ b/Sources/UntoldEngine/Systems/LightingSystem.swift @@ -13,6 +13,28 @@ import CShaderTypes import Foundation import simd +public final class LightingSystem: @unchecked Sendable { + public static let shared: LightingSystem = .init() + + private let activeDirectionalLightLock = NSLock() + private var _activeDirectionalLight: EntityID? + + private init() {} + + public var activeDirectionalLight: EntityID? { + get { + activeDirectionalLightLock.lock() + defer { activeDirectionalLightLock.unlock() } + return _activeDirectionalLight + } + set { + activeDirectionalLightLock.lock() + _activeDirectionalLight = newValue + activeDirectionalLightLock.unlock() + } + } +} + public struct DirectionalLight { var direction: simd_float3 = .init(1.0, 1.0, 1.0) var color: simd_float3 = .init(1.0, 1.0, 1.0) @@ -40,7 +62,7 @@ public struct SpotLight { public struct AreaLight { var position: simd_float3 = .init(0.0, 0.0, 0.0) // Center position of the area light var color: simd_float3 = .init(1.0, 1.0, 1.0) // Light color - var forward: simd_float3 = .init(0.0, 0.0, -1.0) // Normal vector of the light's surface + var forward: simd_float3 = .init(0.0, 0.0, 1.0) // LTC polygon/front normal used by the shader var right: simd_float3 = .init(1.0, 0.0, 0.0) // Right vector defining the surface orientation var up: simd_float3 = .init(0.0, 1.0, 0.0) // Up vector defining the surface orientation var bounds: simd_float2 = .one @@ -49,8 +71,8 @@ public struct AreaLight { } private func applyDefaultLightOrientation(entityId: EntityID) { - // Light shaders/systems assume local forward points along +Y by default. - // Rotate +Z-forward entities (identity rotation) into +Y-forward. + // Engine light emission is defined as local -Z transformed into world space. + // Rotate identity lights so the default emission direction points along -Y. rotateTo(entityId: entityId, angle: -90.0, axis: simd_float3(1.0, 0.0, 0.0)) } @@ -59,6 +81,81 @@ private func assignDefaultProceduralLightMesh(entityId: EntityID) { setEntityMeshDirect(entityId: entityId, meshes: meshes, assetName: "default_cube") } +private let minimumLightRadius: Float = 0.001 +private let minimumSpotConeAngle: Float = 0.1 +private let maximumSpotConeAngle: Float = 89.0 +private let minimumSpotConeSeparation: Float = 0.05 + +private func sanitizedLightRadius(_ radius: Float) -> Float { + max(radius, minimumLightRadius) +} + +private func sanitizedLightFalloff(_ falloff: Float) -> Float { + simd_clamp(falloff, 0.0, 1.0) +} + +private func sanitizedSpotConeAngle(_ coneAngle: Float) -> Float { + simd_clamp(coneAngle, minimumSpotConeAngle, maximumSpotConeAngle) +} + +private func derivedSpotConeAngles(coneAngle: Float, falloff: Float) -> (inner: Float, outer: Float) { + let outerCone = sanitizedSpotConeAngle(coneAngle) + let edgeSoftness = min( + simd_mix(1.0, 10.0, sanitizedLightFalloff(falloff)), + max(minimumSpotConeSeparation, outerCone - minimumSpotConeSeparation) + ) + return (max(minimumSpotConeAngle, outerCone - edgeSoftness), outerCone) +} + +private func syncDerivedSpotConeAngles(_ spotLightComponent: SpotLightComponent) { + let cones = derivedSpotConeAngles( + coneAngle: spotLightComponent.coneAngle, + falloff: spotLightComponent.falloff + ) + spotLightComponent.innerCone = cones.inner + spotLightComponent.outerCone = cones.outer +} + +private func normalizedLightDirection(_ direction: simd_float3, fallback: simd_float3) -> simd_float3 { + let lengthSquared = simd_length_squared(direction) + guard lengthSquared.isFinite, lengthSquared > 1.0e-8 else { + return fallback + } + return direction / sqrt(lengthSquared) +} + +/// Returns the entity transform's local +Z axis in world space. +/// +/// This is a pure transform concept. Use `getLightEmissionDirection(entityId:)` +/// when asking where a non-point light emits. +public func getLightTransformForwardAxis(entityId: EntityID) -> simd_float3 { + normalizedLightDirection( + getForwardAxisVector(entityId: entityId), + fallback: simd_float3(0.0, 0.0, 1.0) + ) +} + +/// Returns the semantic light emission/travel direction. +/// +/// Untold lights emit along local -Z transformed into world space. Point lights do +/// not have a physical emission axis, but returning the transform-derived value +/// keeps editor/debug handles deterministic if they request one. +public func getLightEmissionDirection(entityId: EntityID) -> simd_float3 { + normalizedLightDirection( + -getLightTransformForwardAxis(entityId: entityId), + fallback: simd_float3(0.0, -1.0, 0.0) + ) +} + +/// Directional lighting shaders currently consume the BRDF light vector from the +/// shaded point toward the light source, which is opposite the light's emission. +public func getDirectionalLightShaderDirection(entityId: EntityID) -> simd_float3 { + normalizedLightDirection( + -getLightEmissionDirection(entityId: entityId), + fallback: simd_float3(0.0, 1.0, 0.0) + ) +} + public func createDirLight(entityId: EntityID) { registerComponent(entityId: entityId, componentType: LightComponent.self) registerComponent(entityId: entityId, componentType: DirectionalLightComponent.self) @@ -74,6 +171,9 @@ public func createDirLight(entityId: EntityID) { } lightComponent.lightType = .directional + if LightingSystem.shared.activeDirectionalLight == nil { + LightingSystem.shared.activeDirectionalLight = entityId + } do { let texture = try loadTexture(device: renderInfo.device, textureName: "directional_light_icon_256x256", withExtension: "png") @@ -126,6 +226,9 @@ public func createSpotLight(entityId: EntityID) { } lightComponent.lightType = .spotlight + if let spotLightComponent = scene.get(component: SpotLightComponent.self, for: entityId) { + syncDerivedSpotConeAngles(spotLightComponent) + } updateMaterialEmmisive(entityId: entityId, emmissive: simd_float3(1.0, 1.0, 1.0)) do { @@ -170,29 +273,30 @@ func getDirectionalLightParameters() -> LightParameters { var lightIntensity: Float = 0.0 var lightColor = simd_float3(0.0, 0.0, 0.0) - let lightComponentID = getComponentId(for: LightComponent.self) - let dirLightComponentID = getComponentId(for: DirectionalLightComponent.self) - let localTransformComponentID = getComponentId(for: LocalTransformComponent.self) - - let lightEntities = queryEntitiesWithComponentIds([lightComponentID, dirLightComponentID, localTransformComponentID], in: scene) - - for entity in lightEntities { - guard let lightComponent = scene.get(component: LightComponent.self, for: entity) else { - handleError(.noLightComponent) - continue - } - - guard scene.get(component: DirectionalLightComponent.self, for: entity) != nil else { - handleError(.noDirLightComponent) - continue - } + guard let entity = LightingSystem.shared.activeDirectionalLight else { + var lightParameter = LightParameters() + lightParameter.direction = lightDirection + lightParameter.intensity = lightIntensity + lightParameter.color = lightColor + return lightParameter + } - let forward = getForwardAxisVector(entityId: entity) - lightDirection = simd_float3(forward.x, forward.y, forward.z) - lightIntensity = lightComponent.intensity - lightColor = lightComponent.color + guard let lightComponent = scene.get(component: LightComponent.self, for: entity), + scene.get(component: DirectionalLightComponent.self, for: entity) != nil, + scene.get(component: LocalTransformComponent.self, for: entity) != nil + else { + LightingSystem.shared.activeDirectionalLight = nil + var lightParameter = LightParameters() + lightParameter.direction = lightDirection + lightParameter.intensity = lightIntensity + lightParameter.color = lightColor + return lightParameter } + lightDirection = getDirectionalLightShaderDirection(entityId: entity) + lightIntensity = lightComponent.intensity + lightColor = lightComponent.color + var lightParameter = LightParameters() lightParameter.direction = lightDirection lightParameter.intensity = lightIntensity @@ -299,7 +403,7 @@ public func updateLightRadius(entityId: EntityID, radius: Float) { return } - pointLightComponent.radius = radius + pointLightComponent.radius = sanitizedLightRadius(radius) } else if lightComponent.lightType == .spotlight { guard let spotLightComponent = scene.get(component: SpotLightComponent.self, for: entityId) else { @@ -307,7 +411,7 @@ public func updateLightRadius(entityId: EntityID, radius: Float) { return } - spotLightComponent.radius = radius + spotLightComponent.radius = sanitizedLightRadius(radius) } } @@ -375,7 +479,7 @@ public func updateLightFalloff(entityId: EntityID, falloff: Float) { return } - pointLightComponent.falloff = falloff + pointLightComponent.falloff = sanitizedLightFalloff(falloff) } else if lightComponent.lightType == .spotlight { guard let spotLightComponent = scene.get(component: SpotLightComponent.self, for: entityId) else { @@ -383,7 +487,8 @@ public func updateLightFalloff(entityId: EntityID, falloff: Float) { return } - spotLightComponent.falloff = falloff + spotLightComponent.falloff = sanitizedLightFalloff(falloff) + syncDerivedSpotConeAngles(spotLightComponent) } } @@ -454,13 +559,15 @@ func getPointLights() -> [PointLight] { pointLight.position = getLocalPosition(entityId: entity) pointLight.color = lightComponent.color - let linear: Float = simd_mix(0.1, 0.0, pointLightComponent.falloff) - let quadratic: Float = simd_mix(0.0, 1.0 / (pointLightComponent.radius * pointLightComponent.radius), pointLightComponent.falloff) + let falloff = sanitizedLightFalloff(pointLightComponent.falloff) + let radius = sanitizedLightRadius(pointLightComponent.radius) + let linear: Float = simd_mix(0.1, 0.0, falloff) + let quadratic: Float = simd_mix(0.0, 1.0 / (radius * radius), falloff) let constant: Float = 1.0 pointLight.attenuation = simd_float4(constant, linear, quadratic, 0.0) pointLight.intensity = lightComponent.intensity - pointLight.radius = pointLightComponent.radius + pointLight.radius = radius pointLights.append(pointLight) } @@ -517,23 +624,41 @@ func getSpotLights() -> [SpotLight] { continue } - // get orientation - let forward = getForwardAxisVector(entityId: entity) * -1.0 var spotLight = SpotLight() - spotLight.direction = simd_float3(forward.x, forward.y, forward.z) + spotLight.direction = getLightEmissionDirection(entityId: entity) spotLight.position = getLocalPosition(entityId: entity) spotLight.color = lightComponent.color - let linear: Float = simd_mix(0.1, 0.0, spotLightComponent.falloff) - let quadratic: Float = simd_mix(0.0, 1.0 / (spotLightComponent.radius * spotLightComponent.radius), spotLightComponent.falloff) + let falloff = sanitizedLightFalloff(spotLightComponent.falloff) + let radius = sanitizedLightRadius(spotLightComponent.radius) + let linear: Float = simd_mix(0.1, 0.0, falloff) + let quadratic: Float = simd_mix(0.0, 1.0 / (radius * radius), falloff) let constant: Float = 1.0 spotLight.attenuation = simd_float4(constant, linear, quadratic, 0.0) spotLight.intensity = lightComponent.intensity - spotLight.outerCone = degreesToRadians(degrees: spotLightComponent.coneAngle) - let edgeSoftness = simd_mix(1.0, 10.0, spotLightComponent.falloff) // values 1 and 10 are emperically chosen. You can tweek these values - spotLight.innerCone = spotLight.outerCone - degreesToRadians(degrees: edgeSoftness) + let innerCone = simd_clamp( + spotLightComponent.innerCone, + minimumSpotConeAngle, + maximumSpotConeAngle + ) + let outerCone = simd_clamp( + spotLightComponent.outerCone, + minimumSpotConeAngle, + maximumSpotConeAngle + ) + if innerCone < outerCone { + spotLight.innerCone = degreesToRadians(degrees: innerCone) + spotLight.outerCone = degreesToRadians(degrees: outerCone) + } else { + let cones = derivedSpotConeAngles( + coneAngle: spotLightComponent.coneAngle, + falloff: falloff + ) + spotLight.innerCone = degreesToRadians(degrees: cones.inner) + spotLight.outerCone = degreesToRadians(degrees: cones.outer) + } spotLights.append(spotLight) } @@ -592,7 +717,8 @@ public func updateLightConeAngle(entityId: EntityID, coneAngle: Float) { return } - spotLightComponent.coneAngle = coneAngle + spotLightComponent.coneAngle = sanitizedSpotConeAngle(coneAngle) + syncDerivedSpotConeAngles(spotLightComponent) } func getAreaLights() -> [AreaLight] { @@ -624,7 +750,7 @@ func getAreaLights() -> [AreaLight] { areaLight.position = getLocalPosition(entityId: entity) areaLight.color = lightComponent.color areaLight.intensity = lightComponent.intensity - areaLight.forward = getForwardAxisVector(entityId: entity) + areaLight.forward = getLightTransformForwardAxis(entityId: entity) areaLight.right = getRightAxisVector(entityId: entity) areaLight.up = getUpAxisVector(entityId: entity) let (width, height, _) = getDimension(entityId: entity) @@ -657,11 +783,11 @@ func getAreaLightCount() -> Int { public func handleLightScaleInput(projectedAmount: Float, axis: simd_float3) { if let pointLightComponent = scene.get(component: PointLightComponent.self, for: activeEntity) { - pointLightComponent.radius += projectedAmount + pointLightComponent.radius = sanitizedLightRadius(pointLightComponent.radius + projectedAmount) } if let spotLightComponent = scene.get(component: SpotLightComponent.self, for: activeEntity) { - spotLightComponent.coneAngle += projectedAmount * 10.0 + spotLightComponent.coneAngle = sanitizedSpotConeAngle(spotLightComponent.coneAngle + projectedAmount * 10.0) } if scene.get(component: AreaLightComponent.self, for: activeEntity) != nil { diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index 48204713..9817210f 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -474,6 +474,7 @@ private func applyDecomposedTransform(_ transform: simd_float4x4, to entityId: E localTransform.rotationX = eulerAngles.pitch localTransform.rotationY = eulerAngles.yaw localTransform.rotationZ = eulerAngles.roll + syncWorldTransformAndMarkOctreeDirty(entityId: entityId) } } @@ -1002,6 +1003,141 @@ public enum MeshStreamingPolicy: Sendable { case immediate } +private let untoldImportedMinimumLightRadius: Float = 0.001 +private let untoldImportedMinimumSpotConeAngle: Float = 0.1 +private let untoldImportedMaximumSpotConeAngle: Float = 89.0 +private let untoldImportedMinimumSpotConeSeparation: Float = 0.05 + +private func normalizedImportedDirection(_ direction: simd_float3, fallback: simd_float3) -> simd_float3 { + simd_length_squared(direction) > 1.0e-8 ? simd_normalize(direction) : fallback +} + +private func importedTransformAxis(_ transform: simd_float4x4, column: Int, fallback: simd_float3) -> simd_float3 { + let vector = switch column { + case 0: + simd_float3(transform.columns.0.x, transform.columns.0.y, transform.columns.0.z) + case 1: + simd_float3(transform.columns.1.x, transform.columns.1.y, transform.columns.1.z) + default: + simd_float3(transform.columns.2.x, transform.columns.2.y, transform.columns.2.z) + } + return normalizedImportedDirection(vector, fallback: fallback) +} + +private func importedTransformPosition(_ transform: simd_float4x4) -> simd_float3 { + simd_float3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z) +} + +private func scaleImportedAreaLight(_ areaSize: simd_float2, entityId: EntityID) { + guard let localTransform = scene.get(component: LocalTransformComponent.self, for: entityId) else { + handleError(.noLocalTransformComponent, entityId) + return + } + + let currentScale = localTransform.scale + let dimensions = getDimension(entityId: entityId) + let baseWidth = abs(currentScale.x) > 1.0e-6 ? dimensions.width / currentScale.x : dimensions.width + let baseHeight = abs(currentScale.y) > 1.0e-6 ? dimensions.height / currentScale.y : dimensions.height + + let targetWidth = max(areaSize.x, untoldImportedMinimumLightRadius) + let targetHeight = max(areaSize.y, untoldImportedMinimumLightRadius) + let nextScaleX = abs(baseWidth) > 1.0e-6 ? currentScale.x * (targetWidth / baseWidth) : currentScale.x + let nextScaleY = abs(baseHeight) > 1.0e-6 ? currentScale.y * (targetHeight / baseHeight) : currentScale.y + + scaleTo(entityId: entityId, scale: simd_float3(nextScaleX, nextScaleY, currentScale.z)) +} + +private func registerUntoldScenePayload(from runtimeAsset: RuntimeAsset) { + for light in runtimeAsset.lights { + registerUntoldLight(light) + } + for camera in runtimeAsset.cameras { + registerUntoldCamera(camera) + } +} + +private func registerUntoldLight(_ light: RuntimeLightSource) { + let lightEntityId = createEntity() + + switch light.kind { + case .directional: + createDirLight(entityId: lightEntityId) + case .point: + createPointLight(entityId: lightEntityId) + case .spot: + createSpotLight(entityId: lightEntityId) + case .area: + createAreaLight(entityId: lightEntityId) + } + + setEntityName(entityId: lightEntityId, name: light.name ?? "Imported Light") + applyLocalTransform(light.localTransform, to: lightEntityId) + + if let lightComponent = scene.get(component: LightComponent.self, for: lightEntityId) { + lightComponent.color = light.color + lightComponent.intensity = light.intensity + updateMaterialEmmisive(entityId: lightEntityId, emmissive: light.color) + } + + switch light.kind { + case .directional: + setDirectionalLight(.active(lightEntityId)) + + case .point: + if let pointLight = scene.get(component: PointLightComponent.self, for: lightEntityId) { + pointLight.radius = max(light.radius, untoldImportedMinimumLightRadius) + pointLight.falloff = simd_clamp(light.falloff, 0.0, 1.0) + } + + case .spot: + if let spotLight = scene.get(component: SpotLightComponent.self, for: lightEntityId) { + spotLight.radius = max(light.radius, untoldImportedMinimumLightRadius) + spotLight.falloff = simd_clamp(light.falloff, 0.0, 1.0) + spotLight.innerCone = simd_clamp(light.innerCone, untoldImportedMinimumSpotConeAngle, untoldImportedMaximumSpotConeAngle) + spotLight.outerCone = simd_clamp(light.outerCone, untoldImportedMinimumSpotConeAngle, untoldImportedMaximumSpotConeAngle) + if spotLight.innerCone >= spotLight.outerCone { + spotLight.innerCone = max(untoldImportedMinimumSpotConeAngle, spotLight.outerCone - untoldImportedMinimumSpotConeSeparation) + } + spotLight.coneAngle = spotLight.outerCone + } + + case .area: + if let areaLight = scene.get(component: AreaLightComponent.self, for: lightEntityId) { + scaleImportedAreaLight(light.areaSize, entityId: lightEntityId) + areaLight.bounds = simd_float2( + max(light.areaSize.x, untoldImportedMinimumLightRadius), + max(light.areaSize.y, untoldImportedMinimumLightRadius) + ) + } + } +} + +private func registerUntoldCamera(_ camera: RuntimeCameraSource) { + let gameCamera = createEntity() + createGameCamera(entityId: gameCamera) + setCamera(.active(gameCamera)) + setCamera(.defaultFOV(camera.fovYDegrees)) + setCamera(.clipPlanes(near: camera.nearClip, far: camera.farClip)) + setEntityName(entityId: gameCamera, name: camera.name ?? "Imported Camera") + + if hasComponent(entityId: gameCamera, componentType: LocalTransformComponent.self) == false { + registerTransformComponent(entityId: gameCamera) + } + if hasComponent(entityId: gameCamera, componentType: ScenegraphComponent.self) == false { + registerSceneGraphComponent(entityId: gameCamera) + } + + let position = importedTransformPosition(camera.localTransform) + let forward = importedTransformAxis(camera.localTransform, column: 2, fallback: simd_float3(0.0, 0.0, 1.0)) + let up = importedTransformAxis(camera.localTransform, column: 1, fallback: simd_float3(0.0, 1.0, 0.0)) + cameraLookAt( + entityId: gameCamera, + eye: position, + target: position + forward, + up: up + ) +} + /// Synchronously load a .untold mesh onto an entity. /// /// Blocks the calling thread until the asset is fully registered and GPU-resident. @@ -1194,6 +1330,83 @@ public func setEntityMeshAsync( } } +/// Loads scene-authored lights and cameras from a `.untold` asset as independent +/// top-level entities, separate from any mesh load. +/// +/// Call this alongside `setEntityMeshAsync` when you want to bring scene-authored +/// lights and cameras from an exported asset into the current scene without coupling +/// them to the mesh entity's transform. +public func loadSceneAuthored( + filename: String, + withExtension ext: String, + completion: (@Sendable (Bool) -> Void)? = nil +) { + Task { + guard let url = LoadingSystem.shared.resourceURL( + forResource: filename, withExtension: ext, subResource: nil + ) else { + handleError(.filenameNotFound, filename) + completion?(false) + return + } + + guard RuntimeAssetSource.infer(from: url).kind == .untold else { + Logger.logWarning(message: "[RegistrationSystem] loadSceneAuthored only supports .untold assets.") + completion?(false) + return + } + + guard let runtimeAsset = loadUntoldRuntimeAsset(url: url) else { + completion?(false) + return + } + + withWorldMutationGate { + registerUntoldScenePayload(from: runtimeAsset) + } + completion?(true) + } +} + +/// Loads scene-authored lights and cameras from a `.json` tile manifest as independent +/// top-level entities, separate from any tile scene load. +/// +/// Call this alongside `setEntityStreamScene` when the manifest contains +/// `scene_lights` / `scene_cameras` you want imported into the current scene. +public func loadSceneAuthored( + url manifestURL: URL, + completion: (@Sendable (Bool) -> Void)? = nil +) { + Task { + do { + let localURL: URL + if manifestURL.scheme?.lowercased() == "https" { + localURL = try await RemoteAssetDownloader.shared.localURL(for: manifestURL) + } else if manifestURL.scheme?.lowercased() == "http" { + throw RemoteAssetDownloader.DownloadError.insecureScheme("http") + } else { + localURL = manifestURL + } + + guard let data = try? Data(contentsOf: localURL), + let tileManifest = try? JSONDecoder().decode(TileManifest.self, from: data) + else { + handleError(.manifestDecodeFailed, manifestURL.lastPathComponent) + completion?(false) + return + } + + withWorldMutationGate { + registerManifestScenePayload(tileManifest) + } + completion?(true) + } catch { + handleError(.manifestNotFound, error.localizedDescription, manifestURL.lastPathComponent) + completion?(false) + } + } +} + /// Load a fallback cube mesh when async loading fails private func loadFallbackMesh(entityId: EntityID, filename: String) { Logger.logWarning(message: "Failed to load mesh '\(filename)'. Rendering fallback cube instead.") @@ -1309,6 +1522,10 @@ private struct TileManifest: Decodable { /// The streaming system only loads interior tiles when the camera is inside /// this volume. Nil for uniform_grid manifests — interior gate is disabled. let interiorZone: TileBounds? + /// Scene-authored lights/cameras exported alongside a tile manifest. + /// Decoded only by explicit `loadSceneAuthored(url:)` calls; not tied to tile residency. + let sceneLights: [ManifestLightEntry]? + let sceneCameras: [ManifestCameraEntry]? enum CodingKeys: String, CodingKey { case version @@ -1318,6 +1535,165 @@ private struct TileManifest: Decodable { case sharedBucket = "shared_bucket" case tileSize = "tile_size" case interiorZone = "interior_zone" + case sceneLights = "scene_lights" + case sceneCameras = "scene_cameras" + } +} + +private struct ManifestLightEntry: Decodable { + let name: String? + let kind: RuntimeLightSourceKind + let color: simd_float3 + let intensity: Float + let position: simd_float3 + let radius: Float + let direction: simd_float3 + let falloff: Float + let right: simd_float3 + let innerCone: Float + let up: simd_float3 + let outerCone: Float + let areaSize: simd_float2 + let sourcePower: Float + let sourceExposure: Float + let localTransform: simd_float4x4 + + enum CodingKeys: String, CodingKey { + case name + case entityName = "entity_name" + case kind + case type + case lightType = "light_type" + case color + case intensity + case position + case radius + case direction + case falloff + case right + case innerCone = "inner_cone" + case up + case outerCone = "outer_cone" + case areaSize = "area_size" + case sourcePower = "source_power" + case sourceExposure = "source_exposure" + case localTransform = "local_transform" + case localTransformRows = "local_transform_rows" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decodeIfPresent(String.self, forKey: .name) + ?? container.decodeIfPresent(String.self, forKey: .entityName) + kind = try Self.decodeKind(from: container) + color = try decodeFloat3(container.decodeIfPresent([Float].self, forKey: .color), default: simd_float3(1, 1, 1)) + intensity = try container.decodeIfPresent(Float.self, forKey: .intensity) ?? 1.0 + position = try decodeFloat3(container.decodeIfPresent([Float].self, forKey: .position), default: .zero) + radius = try container.decodeIfPresent(Float.self, forKey: .radius) ?? 1.0 + direction = try decodeFloat3(container.decodeIfPresent([Float].self, forKey: .direction), default: simd_float3(0, -1, 0)) + falloff = try container.decodeIfPresent(Float.self, forKey: .falloff) ?? 0.5 + right = try decodeFloat3(container.decodeIfPresent([Float].self, forKey: .right), default: simd_float3(1, 0, 0)) + innerCone = try container.decodeIfPresent(Float.self, forKey: .innerCone) ?? 5.0 + up = try decodeFloat3(container.decodeIfPresent([Float].self, forKey: .up), default: simd_float3(0, 1, 0)) + outerCone = try container.decodeIfPresent(Float.self, forKey: .outerCone) ?? 10.0 + areaSize = try decodeFloat2(container.decodeIfPresent([Float].self, forKey: .areaSize), default: simd_float2(1, 1)) + sourcePower = try container.decodeIfPresent(Float.self, forKey: .sourcePower) ?? intensity + sourceExposure = try container.decodeIfPresent(Float.self, forKey: .sourceExposure) ?? 0.0 + let transformRows = try container.decodeIfPresent([[Float]].self, forKey: .localTransformRows) + ?? container.decodeIfPresent([[Float]].self, forKey: .localTransform) + localTransform = decodeMatrix4x4Rows( + transformRows, + fallbackPosition: position, + right: right, + up: up, + forward: -normalizedImportedDirection(direction, fallback: simd_float3(0.0, -1.0, 0.0)) + ) + } + + private static func decodeKind(from container: KeyedDecodingContainer) throws -> RuntimeLightSourceKind { + if let rawString = (try? container.decode(String.self, forKey: .kind)) + ?? (try? container.decode(String.self, forKey: .type)) + ?? (try? container.decode(String.self, forKey: .lightType)) + { + switch rawString.lowercased() { + case "directional", "sun", "dir": + return .directional + case "point": + return .point + case "spot": + return .spot + case "area": + return .area + default: + return .point + } + } + + let rawInt = try? container.decode(Int.self, forKey: .lightType) + let rawType = (try? container.decode(UInt32.self, forKey: .lightType)) + ?? rawInt.flatMap { UInt32(exactly: $0) } + ?? UntoldLightType.point.rawValue + switch rawType { + case UInt32(UntoldLightType.directional.rawValue): + return .directional + case UInt32(UntoldLightType.spot.rawValue): + return .spot + case UInt32(UntoldLightType.area.rawValue): + return .area + default: + return .point + } + } +} + +private struct ManifestCameraEntry: Decodable { + let name: String? + let position: simd_float3 + let forward: simd_float3 + let up: simd_float3 + let right: simd_float3 + let fovYDegrees: Float + let nearClip: Float + let farClip: Float + let aspectRatio: Float + let localTransform: simd_float4x4 + + enum CodingKeys: String, CodingKey { + case name + case entityName = "entity_name" + case position + case forward + case up + case right + case fovYDegrees = "fov_y_degrees" + case nearClip = "near_clip" + case farClip = "far_clip" + case aspectRatio = "aspect_ratio" + case localTransform = "local_transform" + case localTransformRows = "local_transform_rows" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decodeIfPresent(String.self, forKey: .name) + ?? container.decodeIfPresent(String.self, forKey: .entityName) + position = try decodeFloat3(container.decodeIfPresent([Float].self, forKey: .position), default: .zero) + forward = try decodeFloat3(container.decodeIfPresent([Float].self, forKey: .forward), default: simd_float3(0, 0, 1)) + up = try decodeFloat3(container.decodeIfPresent([Float].self, forKey: .up), default: simd_float3(0, 1, 0)) + right = try decodeFloat3(container.decodeIfPresent([Float].self, forKey: .right), default: simd_float3(1, 0, 0)) + fovYDegrees = try container.decodeIfPresent(Float.self, forKey: .fovYDegrees) ?? 50.0 + nearClip = try max(container.decodeIfPresent(Float.self, forKey: .nearClip) ?? 0.1, 0.001) + farClip = try max(container.decodeIfPresent(Float.self, forKey: .farClip) ?? 1000.0, 0.001) + aspectRatio = try max(container.decodeIfPresent(Float.self, forKey: .aspectRatio) ?? 1.5, 0.001) + let transformRows = try container.decodeIfPresent([[Float]].self, forKey: .localTransformRows) + ?? container.decodeIfPresent([[Float]].self, forKey: .localTransform) + localTransform = decodeMatrix4x4Rows( + transformRows, + fallbackPosition: position, + right: right, + up: up, + forward: forward + ) } } @@ -1432,6 +1808,83 @@ private struct TileBounds: Decodable { let max: [Float] } +private func decodeFloat2(_ values: [Float]?, default defaultValue: simd_float2) -> simd_float2 { + guard let values, values.count >= 2 else { return defaultValue } + return simd_float2(values[0], values[1]) +} + +private func decodeFloat3(_ values: [Float]?, default defaultValue: simd_float3) -> simd_float3 { + guard let values, values.count >= 3 else { return defaultValue } + return simd_float3(values[0], values[1], values[2]) +} + +private func decodeMatrix4x4Rows( + _ rows: [[Float]]?, + fallbackPosition: simd_float3 = .zero, + right: simd_float3 = simd_float3(1, 0, 0), + up: simd_float3 = simd_float3(0, 1, 0), + forward: simd_float3 = simd_float3(0, 0, 1) +) -> simd_float4x4 { + guard let rows, rows.count >= 4, rows[0].count >= 4, rows[1].count >= 4, + rows[2].count >= 4, rows[3].count >= 4 + else { + return simd_float4x4( + simd_float4(right, 0), + simd_float4(up, 0), + simd_float4(forward, 0), + simd_float4(fallbackPosition, 1) + ) + } + + return simd_float4x4( + simd_float4(rows[0][0], rows[1][0], rows[2][0], rows[3][0]), + simd_float4(rows[0][1], rows[1][1], rows[2][1], rows[3][1]), + simd_float4(rows[0][2], rows[1][2], rows[2][2], rows[3][2]), + simd_float4(rows[0][3], rows[1][3], rows[2][3], rows[3][3]) + ) +} + +private func registerManifestScenePayload(_ manifest: TileManifest) { + for light in manifest.sceneLights ?? [] { + registerUntoldLight( + RuntimeLightSource( + name: light.name, + kind: light.kind, + color: light.color, + intensity: light.intensity, + position: light.position, + radius: light.radius, + direction: light.direction, + falloff: light.falloff, + right: light.right, + innerCone: light.innerCone, + up: light.up, + outerCone: light.outerCone, + areaSize: light.areaSize, + sourcePower: light.sourcePower, + sourceExposure: light.sourceExposure, + localTransform: light.localTransform + ) + ) + } + for camera in manifest.sceneCameras ?? [] { + registerUntoldCamera( + RuntimeCameraSource( + name: camera.name, + position: camera.position, + forward: camera.forward, + up: camera.up, + right: camera.right, + fovYDegrees: camera.fovYDegrees, + nearClip: camera.nearClip, + farClip: camera.farClip, + aspectRatio: camera.aspectRatio, + localTransform: camera.localTransform + ) + ) + } +} + // MARK: - setEntityStreamScene / loadTiledScene /// Attaches a distance-streamed tile scene to `rootEntityId`. @@ -1445,7 +1898,9 @@ private struct TileBounds: Decodable { /// The caller is responsible for creating `rootEntityId` via `createEntity()` before /// calling this function, and for managing its lifetime. To replace a streamed scene, /// destroy the old root (cascades to all tile stubs), then call this with a new root. -/// Camera and light entities are also the caller's responsibility. +/// Manifests may include scene-authored lights and cameras in `scene_lights` / +/// `scene_cameras`; this call does not register them. Call `loadSceneAuthored(url:)` +/// explicitly when you want those entities in the current scene. /// /// - Parameters: /// - rootEntityId: Entity that becomes the parent of all tile stubs. @@ -1496,8 +1951,9 @@ public func setEntityStreamScene( /// Backwards-compatible overload. Prefer `setEntityStreamScene(entityId:manifest:)` /// when you need a stable handle to the loaded scene. /// -/// The caller is responsible for creating any camera or light entities the -/// scene requires. +/// Manifests may include scene-authored lights and cameras in `scene_lights` / +/// `scene_cameras`; this call does not register them. Call `loadSceneAuthored(url:)` +/// explicitly when you want those entities in the current scene. public func loadTiledScene( manifest: String, withExtension ext: String = "json", @@ -1547,8 +2003,10 @@ public func loadTiledScene( /// streaming system as the camera approaches each tile. /// /// The caller is responsible for creating `rootEntityId` via `createEntity()` before -/// calling this function, and for managing its lifetime. -/// Camera and light entities are also the caller's responsibility. +/// calling this function, and for managing its lifetime. Manifests may include +/// scene-authored lights and cameras in `scene_lights` / `scene_cameras`; this call +/// does not register them. Call `loadSceneAuthored(url:)` explicitly when you want +/// those entities in the current scene. /// /// - Parameters: /// - rootEntityId: Entity that becomes the parent of all tile stubs. @@ -1604,8 +2062,9 @@ public func setEntityStreamScene( /// Backwards-compatible overload. Prefer `setEntityStreamScene(entityId:url:)` /// when you need a stable handle to the loaded scene. /// -/// The caller is responsible for creating any camera or light entities the -/// scene requires. +/// Manifests may include scene-authored lights and cameras in `scene_lights` / +/// `scene_cameras`; this call does not register them. Call `loadSceneAuthored(url:)` +/// explicitly when you want those entities in the current scene. /// /// - Parameters: /// - url: Full URL to the manifest JSON (local or remote). @@ -1666,8 +2125,8 @@ public func loadTiledScene( /// scene contract; tile payloads are runtime implementation details (for example /// `.untold`, with legacy USD/USDZ support still present during migration). /// -/// The caller is responsible for creating any camera or light entities the scene -/// requires, and for managing the lifetime of `rootEntityId`. +/// Scene-authored manifest lights/cameras are registered once under `rootEntityId`; +/// otherwise camera/light ownership remains with the caller. /// /// - Parameters: /// - rootEntityId: Entity that becomes the parent of all tile stubs. @@ -2095,6 +2554,9 @@ func removeEntityLight(entityId: EntityID) { } if scene.get(component: DirectionalLightComponent.self, for: entityId) != nil { + if LightingSystem.shared.activeDirectionalLight == entityId { + LightingSystem.shared.activeDirectionalLight = nil + } scene.remove(component: DirectionalLightComponent.self, from: entityId) } diff --git a/Sources/UntoldEngine/Systems/ShadowSystem.swift b/Sources/UntoldEngine/Systems/ShadowSystem.swift index ad243e5e..987c21de 100644 --- a/Sources/UntoldEngine/Systems/ShadowSystem.swift +++ b/Sources/UntoldEngine/Systems/ShadowSystem.swift @@ -66,20 +66,15 @@ struct ShadowSystem { isActive = false - // Find directional light entity - let lightComponentID = getComponentId(for: DirectionalLightComponent.self) - let localTransformComponentID = getComponentId(for: LocalTransformComponent.self) - let entities = queryEntitiesWithComponentIds([lightComponentID, localTransformComponentID], in: scene) - - var lightForward = simd_float3(0, -1, 0) - var foundLight = false - for entity in entities { - guard scene.get(component: DirectionalLightComponent.self, for: entity) != nil else { continue } - let fwd = getForwardAxisVector(entityId: entity) - lightForward = -normalize(simd_float3(fwd.x, fwd.y, fwd.z)) - foundLight = true + guard let lightEntity = LightingSystem.shared.activeDirectionalLight, + scene.get(component: DirectionalLightComponent.self, for: lightEntity) != nil, + scene.get(component: LocalTransformComponent.self, for: lightEntity) != nil + else { + LightingSystem.shared.activeDirectionalLight = nil + return } - guard foundLight else { return } + + let lightForward = getLightEmissionDirection(entityId: lightEntity) // Get camera guard let camEntity = CameraSystem.shared.activeCamera, @@ -100,8 +95,10 @@ struct ShadowSystem { // typical max IPD so the cascade AABB envelopes both eye frustums. let xrExpansion: Float = renderInfo.isXRStereoMode ? 0.04 : 0.0 - // Use a tighter far for XR — room-scale scenes rarely exceed 50 m. - let cameraFar: Float = renderInfo.isXRStereoMode ? 50.0 : far + // Keep cascades within the same effective distance used to cull shadow casters. + // This gives small/editor scenes more texel density in the near cascade. + let shadowFar = min(far, RenderPasses.maxShadowCastingDistance) + let cameraFar: Float = renderInfo.isXRStereoMode ? min(50.0, shadowFar) : shadowFar // Practical split scheme (blend of log and uniform, λ=0.5). let splits = computeCascadeSplits(cameraNear: near, cameraFar: cameraFar) @@ -166,8 +163,9 @@ struct ShadowSystem { minZ = min(minZ, lc.z); maxZ = max(maxZ, lc.z) } - // Extend the near (minZ) so objects behind the camera can still cast shadows. - let depthMargin: Float = 50.0 + // Extend the near (minZ) so nearby off-camera casters can still contribute, + // without destroying depth precision for small scenes. + let depthMargin: Float = min(10.0, cameraFar * 0.25) minZ -= depthMargin // Convert light-space Z extents to positive near/far distances for the ortho matrix. diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.air index 791b2468..b875e1c6 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 e00e451b..317a013e 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 7c90e3b8..7edd29d6 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 d79c06ba..00b7a77a 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 62d52efc..abcba155 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 9aecfe59..3aabb006 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 4dee1b95..a74f74a4 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 9df020af..046dd04e 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 85330803..1d5ced19 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 c55c4273..9066ac7c 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 c7a4047d..851da72f 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 987d6ebb..42f13b85 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 1276e2ff..d2ac5968 100644 --- a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift +++ b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift @@ -430,6 +430,10 @@ public enum CameraProperty: Sendable { case clipPlanes(near: Float, far: Float) } +public enum DirectionalLightProperty: Sendable { + case active(EntityID?) +} + public func setCamera(_ property: CameraProperty) { switch property { case let .active(entityId): @@ -442,6 +446,23 @@ public func setCamera(_ property: CameraProperty) { } } +public func setDirectionalLight(_ property: DirectionalLightProperty) { + switch property { + case let .active(entityId): + guard let entityId else { + LightingSystem.shared.activeDirectionalLight = nil + return + } + + 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 applyColorGradingProperty(_ property: ColorGradingProperty) { switch property { case let .enabled(value): diff --git a/Sources/UntoldEngine/Utils/SceneContextVisibility.swift b/Sources/UntoldEngine/Utils/SceneContextVisibility.swift index 23b5d983..1789242c 100644 --- a/Sources/UntoldEngine/Utils/SceneContextVisibility.swift +++ b/Sources/UntoldEngine/Utils/SceneContextVisibility.swift @@ -17,10 +17,18 @@ public struct SceneChannel: OptionSet, Sendable { self.rawValue = rawValue } + public static let engineReservedMask = SceneChannel(rawValue: 0x0000_0000_FFFF_FFFF) + public static let userCustomMask = SceneChannel(rawValue: 0xFFFF_FFFF_0000_0000) + public static let contextGeometry = SceneChannel(rawValue: 1 << 0) public static let selectableGeometry = SceneChannel(rawValue: 1 << 1) public static let preserveIdentity = SceneChannel(rawValue: 1 << 2) public static let ghostGeometry = SceneChannel(rawValue: 1 << 3) + + public static func userCustom(index: Int) -> SceneChannel { + precondition((0 ..< 32).contains(index), "User custom scene channel index must be 0...31") + return SceneChannel(rawValue: UInt64(1) << UInt64(32 + index)) + } } public enum SceneChannelRenderMode: Equatable, Sendable { @@ -153,11 +161,66 @@ private final class SceneChannelInteractionState: @unchecked Sendable { public let selectableSceneEntityNamePrefix = "NM_" +private final class SceneChannelPrefixRegistry: @unchecked Sendable { + static let shared = SceneChannelPrefixRegistry() + + private let lock = NSLock() + private var entriesByPrefix: [String: SceneChannel] = [:] + + func register(prefix: String, channels: SceneChannel) { + precondition(prefix.isEmpty == false, "Scene channel prefix cannot be empty") + precondition(channels.isEmpty == false, "Scene channel prefix must map to at least one channel") + + lock.lock() + entriesByPrefix[prefix] = channels + lock.unlock() + } + + func unregister(prefix: String) { + lock.lock() + entriesByPrefix.removeValue(forKey: prefix) + lock.unlock() + } + + func channels(forName name: String) -> SceneChannel? { + lock.lock() + let entries = entriesByPrefix + lock.unlock() + + return entries + .filter { name.hasPrefix($0.key) } + .max { lhs, rhs in lhs.key.count < rhs.key.count }? + .value + } + + func reset() { + lock.lock() + entriesByPrefix = [:] + lock.unlock() + } +} + +public func registerSceneChannelPrefix(_ prefix: String, channels: SceneChannel) { + SceneChannelPrefixRegistry.shared.register(prefix: prefix, channels: channels) +} + +public func unregisterSceneChannelPrefix(_ prefix: String) { + SceneChannelPrefixRegistry.shared.unregister(prefix: prefix) +} + +public func resetSceneChannelPrefixes() { + SceneChannelPrefixRegistry.shared.reset() +} + public func defaultSceneChannels(forName name: String, isRenderable: Bool = true) -> SceneChannel { if name.hasPrefix(selectableSceneEntityNamePrefix) { return [.selectableGeometry, .preserveIdentity] } + if let channels = SceneChannelPrefixRegistry.shared.channels(forName: name) { + return channels + } + return isRenderable ? .contextGeometry : [] } diff --git a/Tests/UntoldEngineRenderTests/LightSystemTest.swift b/Tests/UntoldEngineRenderTests/LightSystemTest.swift index 768f7bd4..45c24f4f 100644 --- a/Tests/UntoldEngineRenderTests/LightSystemTest.swift +++ b/Tests/UntoldEngineRenderTests/LightSystemTest.swift @@ -27,6 +27,18 @@ final class LightSystemTest: BaseRenderSetup { // MARK: - Light Tests + private func assertVector( + _ value: simd_float3, + equals expected: simd_float3, + accuracy: Float = 0.001, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(value.x, expected.x, accuracy: accuracy, file: file, line: line) + XCTAssertEqual(value.y, expected.y, accuracy: accuracy, file: file, line: line) + XCTAssertEqual(value.z, expected.z, accuracy: accuracy, file: file, line: line) + } + func testDirectionalLight() { let entityId: EntityID = createEntity() @@ -63,6 +75,40 @@ final class LightSystemTest: BaseRenderSetup { destroyEntity(entityId: entityId) } + func testAreaLight() { + let entityId: EntityID = createEntity() + + createAreaLight(entityId: entityId) + + XCTAssertTrue(hasComponent(entityId: entityId, componentType: LightComponent.self), "Should have a Light component") + + XCTAssertTrue(hasComponent(entityId: entityId, componentType: AreaLightComponent.self), "Should have an Area Light component") + + destroyEntity(entityId: entityId) + } + + func testDefaultLightsUseLocalNegativeZEmissionConvention() { + destroyAllEntities() + + let directional = createEntity() + createDirLight(entityId: directional) + assertVector(getLightTransformForwardAxis(entityId: directional), equals: simd_float3(0.0, 1.0, 0.0)) + assertVector(getLightEmissionDirection(entityId: directional), equals: simd_float3(0.0, -1.0, 0.0)) + assertVector(getDirectionalLightShaderDirection(entityId: directional), equals: simd_float3(0.0, 1.0, 0.0)) + + let spot = createEntity() + createSpotLight(entityId: spot) + assertVector(getLightTransformForwardAxis(entityId: spot), equals: simd_float3(0.0, 1.0, 0.0)) + assertVector(getLightEmissionDirection(entityId: spot), equals: simd_float3(0.0, -1.0, 0.0)) + assertVector(getSpotLights().first?.direction ?? .zero, equals: simd_float3(0.0, -1.0, 0.0)) + + let area = createEntity() + createAreaLight(entityId: area) + 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)) + } + func testGetDirLightParameters() { let entityId: EntityID = createEntity() @@ -146,6 +192,31 @@ final class LightSystemTest: BaseRenderSetup { destroyEntity(entityId: entityId) } + func testSpotLightParametersUseAuthoredInnerAndOuterCones() { + destroyAllEntities() + + let entityId: EntityID = createEntity() + createSpotLight(entityId: entityId) + + guard let spotLightComponent = scene.get(component: SpotLightComponent.self, for: entityId) else { + handleError(.noSpotLightComponent, entityId) + return + } + + spotLightComponent.innerCone = 12.0 + spotLightComponent.outerCone = 34.0 + spotLightComponent.coneAngle = 45.0 + spotLightComponent.falloff = 0.5 + + let spotLightParameters = getSpotLights() + + XCTAssertEqual(spotLightParameters.count, 1) + XCTAssertEqual(spotLightParameters[0].innerCone, degreesToRadians(degrees: 12.0), accuracy: 0.001) + XCTAssertEqual(spotLightParameters[0].outerCone, degreesToRadians(degrees: 34.0), accuracy: 0.001) + + destroyEntity(entityId: entityId) + } + func testGetPointLightCount() { destroyAllEntities() let entityId0: EntityID = createEntity() diff --git a/Tests/UntoldEngineRenderTests/NativeFormatRegistrationTests.swift b/Tests/UntoldEngineRenderTests/NativeFormatRegistrationTests.swift index b1cdabbf..9eab2126 100644 --- a/Tests/UntoldEngineRenderTests/NativeFormatRegistrationTests.swift +++ b/Tests/UntoldEngineRenderTests/NativeFormatRegistrationTests.swift @@ -13,6 +13,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import Foundation +import simd @preconcurrency @testable import UntoldEngine import XCTest @@ -95,6 +96,55 @@ final class NativeFormatRegistrationTests: BaseRenderSetup { XCTAssertTrue(renderComponent.isVisible, "Async .untold load should leave the entity visible") } + func testSetEntityMeshLoadsOnlyMeshAndLoadSceneAuthoredLoadsMeshAndScene() throws { + let fixture = try makeSceneAuthoredUntoldFixture() + let originalResourceURLFn = LoadingSystem.shared.resourceURLFn + LoadingSystem.shared.resourceURLFn = { name, ext, subName in + if name == fixture.stem, ext == "untold" { + return fixture.url + } + return getResourceURL(resourceName: name, ext: ext, subName: subName) + } + defer { LoadingSystem.shared.resourceURLFn = originalResourceURLFn } + + // Mesh-only load — no lights or camera should appear. + let meshRoot = createEntity() + setEntityMesh(entityId: meshRoot, filename: fixture.stem, withExtension: "untold") + + XCTAssertNil(findEntity(named: fixture.sunName)) + XCTAssertNil(findEntity(named: fixture.spotName)) + XCTAssertNil(findEntity(named: fixture.cameraName)) + + // Separate scene-authored load — lights and camera registered as top-level entities. + let sceneExpectation = expectation(description: "scene authored loaded") + loadSceneAuthored(filename: fixture.stem, withExtension: "untold") { _ in + sceneExpectation.fulfill() + } + wait(for: [sceneExpectation], timeout: 5.0) + + let sunEntity = try XCTUnwrap(findEntity(named: fixture.sunName)) + XCTAssertEqual(LightingSystem.shared.activeDirectionalLight, sunEntity) + XCTAssertNotNil(scene.get(component: DirectionalLightComponent.self, for: sunEntity)) + + let spotEntity = try XCTUnwrap(findEntity(named: fixture.spotName)) + let spotComponent = try XCTUnwrap(scene.get(component: SpotLightComponent.self, for: spotEntity)) + XCTAssertEqual(spotComponent.innerCone, 14.0, accuracy: 0.001) + XCTAssertEqual(spotComponent.outerCone, 36.0, accuracy: 0.001) + + let spotParameters = getSpotLights() + let importedSpot = try XCTUnwrap(spotParameters.first(where: { abs($0.outerCone - degreesToRadians(degrees: 36.0)) < 0.001 })) + XCTAssertEqual(importedSpot.innerCone, degreesToRadians(degrees: 14.0), accuracy: 0.001) + XCTAssertEqual(importedSpot.direction.x, 0.0, accuracy: 0.001) + XCTAssertEqual(importedSpot.direction.y, 0.0, accuracy: 0.001) + XCTAssertEqual(importedSpot.direction.z, -1.0, accuracy: 0.001) + + let cameraEntity = try XCTUnwrap(findEntity(named: fixture.cameraName)) + XCTAssertEqual(CameraSystem.shared.activeCamera, cameraEntity) + XCTAssertEqual(fov, 58.0, accuracy: 0.001) + XCTAssertEqual(near, 0.05, accuracy: 0.001) + XCTAssertEqual(far, 650.0, accuracy: 0.001) + } + func testSetEntityMesh_loadsNamedNodeFromUntold() async throws { guard let untoldURL = Bundle.module.url(forResource: "redplayer", withExtension: "untold") else { XCTFail("Failed to locate redplayer.untold") @@ -196,4 +246,207 @@ final class NativeFormatRegistrationTests: BaseRenderSetup { XCTAssertNotNil(animationComponent.currentAnimation) } + + private func findEntity(named name: String) -> EntityID? { + reverseEntityNameMap[name]?.first(where: { scene.exists($0) && getEntityName(entityId: $0) == name }) + } +} + +private struct SceneAuthoredUntoldFixture { + var url: URL + var stem: String + var sunName: String + var spotName: String + var cameraName: String +} + +private func makeSceneAuthoredUntoldFixture() throws -> SceneAuthoredUntoldFixture { + let stem = "scene-authored-\(UUID().uuidString)" + let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(stem).untold") + let sunName = "Imported Sun" + let spotName = "Imported Spot" + let cameraName = "Imported Camera" + + let strings = makeNativeStringTable([ + "root_entity", + "mesh_0", + "mat_0", + "albedo.ktx2", + sunName, + spotName, + cameraName, + ]) + let bounds = UntoldAABB(min: SIMD3(-1, -1, -1), max: SIMD3(1, 1, 1)) + let entity = UntoldEntityRecordV1( + entityId: 0, + nameOffset: strings.offsets["root_entity"]!, + firstMeshRecordIndex: 0, + meshRecordCount: 1, + localBounds: bounds, + worldBounds: bounds + ) + let material = UntoldMaterialRecordV1( + nameOffset: strings.offsets["mat_0"]!, + baseColorTextureIndex: UntoldFormat.invalidIndex + ) + let texture = UntoldTextureRefRecordV1( + nameOffset: strings.offsets["albedo.ktx2"]!, + uriOffset: strings.offsets["albedo.ktx2"]!, + textureFormat: .rgba8, + width: 16, + height: 16, + mipCount: 1 + ) + let vertex = UntoldPBRStaticVertexV1( + position: SIMD3(0, 0, 0), + normalPacked: UntoldVertexPacking.packNormal(SIMD3(0, 1, 0)), + tangentPacked: UntoldVertexPacking.packTangent(SIMD3(1, 0, 0), handedness: 1) + ) + let vertexWriter = UntoldBinaryWriter() + vertex.encode(to: vertexWriter) + let vertexData = vertexWriter.data + let indexWriter = UntoldBinaryWriter() + indexWriter.writeUInt16LE(0) + indexWriter.writeUInt16LE(0) + indexWriter.writeUInt16LE(0) + let indexData = indexWriter.data + let mesh = UntoldMeshRecordV1( + entityId: 0, + meshNameOffset: strings.offsets["mesh_0"]!, + materialIndex: 0, + indexType: .uint16, + vertexCount: 1, + indexCount: 3, + vertexStrideBytes: UInt32(vertexData.count), + vertexDataOffset: 0, + indexDataOffset: 0, + vertexDataSizeBytes: UInt64(vertexData.count), + indexDataSizeBytes: UInt64(indexData.count), + estimatedGPUBytes: UInt64(vertexData.count + indexData.count), + localBounds: bounds + ) + + var sunTransform = matrix_identity_float4x4 + sunTransform.columns.3 = SIMD4(0, 4, 0, 1) + let sun = UntoldLightRecordV1( + entityId: 1, + nameOffset: strings.offsets[sunName]!, + lightType: .directional, + color: SIMD3(1.0, 0.95, 0.8), + intensity: 2.0, + localTransform: sunTransform + ) + var spotTransform = matrix_identity_float4x4 + spotTransform.columns.3 = SIMD4(2, 3, 4, 1) + let spot = UntoldLightRecordV1( + entityId: 2, + nameOffset: strings.offsets[spotName]!, + lightType: .spot, + color: SIMD3(0.2, 0.4, 1.0), + intensity: 5.0, + position: SIMD3(2, 3, 4), + radius: 8.0, + falloff: 0.25, + innerCone: 14.0, + outerCone: 36.0, + localTransform: spotTransform + ) + var cameraTransform = matrix_identity_float4x4 + cameraTransform.columns.3 = SIMD4(0, 1, 6, 1) + let camera = UntoldCameraRecordV1( + entityId: 3, + nameOffset: strings.offsets[cameraName]!, + position: SIMD3(0, 1, 6), + fovYDegrees: 58.0, + nearClip: 0.05, + farClip: 650.0, + aspectRatio: 1.6, + localTransform: cameraTransform + ) + + var header = UntoldFileHeaderV1( + fileType: .tile, + chunkCount: 0, + meshCount: 1, + materialCount: 1, + textureRefCount: 1, + entityCount: 1, + vertexLayout: .pbrStaticV1, + worldBounds: bounds + ) + let payloads: [(UntoldChunkType, Data, UInt32)] = [ + (.stringTable, strings.data, 0), + (.entityTable, encodeNativeRecords([entity]), 1), + (.meshTable, encodeNativeRecords([mesh]), 1), + (.materialTable, encodeNativeRecords([material]), 1), + (.textureTable, encodeNativeRecords([texture]), 1), + (.vertexData, vertexData, 0), + (.indexData, indexData, 0), + (.lightTable, encodeNativeRecords([sun, spot]), 2), + (.cameraTable, encodeNativeRecords([camera]), 1), + ] + header.chunkCount = UInt32(payloads.count) + let fileData = buildNativeFileData(header: header, payloads: payloads) + try fileData.write(to: url, options: .atomic) + + return SceneAuthoredUntoldFixture(url: url, stem: stem, sunName: sunName, spotName: spotName, cameraName: cameraName) +} + +private func encodeNativeRecords(_ records: [some UntoldBinaryEncodable]) -> Data { + let writer = UntoldBinaryWriter() + for record in records { + record.encode(to: writer) + } + return writer.data +} + +private func makeNativeStringTable(_ strings: [String]) -> (data: Data, offsets: [String: UInt32]) { + let writer = UntoldBinaryWriter() + var offsets: [String: UInt32] = [:] + for string in strings { + offsets[string] = UInt32(writer.count) + writer.writeNullTerminatedUTF8(string) + } + return (writer.data, offsets) +} + +private func buildNativeFileData( + header: UntoldFileHeaderV1, + payloads: [(UntoldChunkType, Data, UInt32)] +) -> Data { + let headerWriter = UntoldBinaryWriter() + header.encode(to: headerWriter) + + let chunkTableBytes = 40 * payloads.count + var runningOffset = headerWriter.count + chunkTableBytes + var entries: [UntoldChunkEntryV1] = [] + for payload in payloads { + runningOffset = alignNativeOffset(runningOffset, to: Int(UntoldFormat.fileAlignment)) + entries.append( + UntoldChunkEntryV1( + chunkType: payload.0, + fileOffset: UInt64(runningOffset), + compressedSize: UInt64(payload.1.count), + uncompressedSize: UInt64(payload.1.count), + elementCount: payload.2 + ) + ) + runningOffset += payload.1.count + } + + let writer = UntoldBinaryWriter() + header.encode(to: writer) + for entry in entries { + entry.encode(to: writer) + } + for payload in payloads { + writer.align(to: Int(UntoldFormat.fileAlignment)) + writer.writeData(payload.1) + } + return writer.data +} + +private func alignNativeOffset(_ value: Int, to alignment: Int) -> Int { + let remainder = value % alignment + return remainder == 0 ? value : value + (alignment - remainder) } diff --git a/Tests/UntoldEngineRenderTests/NativeFormatTileStreamingTests.swift b/Tests/UntoldEngineRenderTests/NativeFormatTileStreamingTests.swift index 9cdeca6c..f51af80b 100644 --- a/Tests/UntoldEngineRenderTests/NativeFormatTileStreamingTests.swift +++ b/Tests/UntoldEngineRenderTests/NativeFormatTileStreamingTests.swift @@ -41,6 +41,18 @@ final class NativeFormatTileStreamingTests: BaseRenderSetup { override func initializeAssets() {} + private func assertVector( + _ value: simd_float3, + equals expected: simd_float3, + accuracy: Float = 0.001, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(value.x, expected.x, accuracy: accuracy, file: file, line: line) + XCTAssertEqual(value.y, expected.y, accuracy: accuracy, file: file, line: line) + XCTAssertEqual(value.z, expected.z, accuracy: accuracy, file: file, line: line) + } + func testLoadTileAndReloadUntoldManifestPayload() async throws { let fixture = try makeUntoldTileSceneFixture(includeHLOD: false, includeLOD: false) try loadSceneManifest(at: fixture.manifestURL) @@ -154,6 +166,89 @@ final class NativeFormatTileStreamingTests: BaseRenderSetup { XCTAssertEqual(tileComp.state, .unloaded) } + // MARK: - Scene-authored manifest payload + + func testLoadSceneAuthoredFromURLRegistersManifestLightsAndCameras() async throws { + let fixture = try makeUntoldTileSceneFixture( + includeHLOD: false, + includeLOD: false, + includeScenePayload: true + ) + + let didSucceed = await loadSceneManifestFromURL(fixture.manifestURL) + XCTAssertTrue(didSucceed, "loadSceneManifestFromURL should succeed for a local manifest URL") + + let sceneAuthoredExpectation = expectation(description: "scene authored loaded") + loadSceneAuthored(url: fixture.manifestURL) { _ in sceneAuthoredExpectation.fulfill() } + await fulfillment(of: [sceneAuthoredExpectation], timeout: 5.0) + + XCTAssertNotNil(findEntity(named: "Manifest Key Light")) + XCTAssertNotNil(findEntity(named: "Manifest Spot Fallback")) + XCTAssertNotNil(findEntity(named: "Manifest Area Fallback")) + let cameraEntityId = try XCTUnwrap(findEntity(named: "Manifest Camera")) + XCTAssertNotNil(scene.get(component: CameraComponent.self, for: cameraEntityId)) + XCTAssertEqual(CameraSystem.shared.activeCamera, cameraEntityId) + XCTAssertEqual(fov, 55.0, accuracy: 0.001) + XCTAssertEqual(near, 0.05, accuracy: 0.001) + XCTAssertEqual(far, 750.0, accuracy: 0.001) + } + + func testTileManifestSkipsSceneAuthoredLightsAndCamerasByDefault() throws { + let fixture = try makeUntoldTileSceneFixture( + includeHLOD: false, + includeLOD: false, + includeScenePayload: true + ) + try loadSceneManifest(at: fixture.manifestURL) + + XCTAssertNil(findEntity(named: "Manifest Key Light")) + XCTAssertNil(findEntity(named: "Manifest Camera")) + } + + func testTileManifestRegistersSceneAuthoredLightsAndCamerasWhenRequested() throws { + let fixture = try makeUntoldTileSceneFixture( + includeHLOD: false, + includeLOD: false, + includeScenePayload: true + ) + try loadSceneManifest(at: fixture.manifestURL) + let sceneAuthoredExpectation = expectation(description: "scene authored loaded") + loadSceneAuthored(url: fixture.manifestURL) { _ in sceneAuthoredExpectation.fulfill() } + wait(for: [sceneAuthoredExpectation], timeout: 5.0) + + let lightEntityId = try XCTUnwrap(findEntity(named: "Manifest Key Light")) + let light = try XCTUnwrap(scene.get(component: LightComponent.self, for: lightEntityId)) + let point = try XCTUnwrap(scene.get(component: PointLightComponent.self, for: lightEntityId)) + + XCTAssertEqual(light.color.x, 0.8, accuracy: 0.001) + XCTAssertEqual(light.color.y, 0.9, accuracy: 0.001) + XCTAssertEqual(light.color.z, 1.0, accuracy: 0.001) + XCTAssertEqual(light.intensity, 3.5, accuracy: 0.001) + XCTAssertEqual(point.radius, 12.0, accuracy: 0.001) + let spotEntityId = try XCTUnwrap(findEntity(named: "Manifest Spot Fallback")) + XCTAssertNotNil(scene.get(component: SpotLightComponent.self, for: spotEntityId)) + assertVector(getLightEmissionDirection(entityId: spotEntityId), equals: simd_float3(0.0, -1.0, 0.0)) + assertVector( + getSpotLights().first(where: { simd_length($0.position - simd_float3(-2.0, 3.0, 4.0)) < 0.001 })?.direction ?? .zero, + equals: simd_float3(0.0, -1.0, 0.0) + ) + + let areaEntityId = try XCTUnwrap(findEntity(named: "Manifest Area Fallback")) + XCTAssertNotNil(scene.get(component: AreaLightComponent.self, for: areaEntityId)) + assertVector(getLightEmissionDirection(entityId: areaEntityId), equals: simd_float3(0.0, 0.0, -1.0)) + assertVector( + getAreaLights().first(where: { simd_length($0.position - simd_float3(0.0, 5.0, 0.0)) < 0.001 })?.forward ?? .zero, + equals: simd_float3(0.0, 0.0, 1.0) + ) + + let cameraEntityId = try XCTUnwrap(findEntity(named: "Manifest Camera")) + XCTAssertNotNil(scene.get(component: CameraComponent.self, for: cameraEntityId)) + XCTAssertEqual(CameraSystem.shared.activeCamera, cameraEntityId) + XCTAssertEqual(fov, 55.0, accuracy: 0.001) + XCTAssertEqual(near, 0.05, accuracy: 0.001) + XCTAssertEqual(far, 750.0, accuracy: 0.001) + } + // MARK: - Parse timeout — clock starts after download, not before /// Verifies the parse-timeout fix: `parseStartTime` is 0 immediately after @@ -685,7 +780,10 @@ final class NativeFormatTileStreamingTests: BaseRenderSetup { let manifestStem = manifestURL.deletingPathExtension().path var didSucceed = false - loadTiledScene(manifest: manifestStem, withExtension: manifestURL.pathExtension) { success in + loadTiledScene( + manifest: manifestStem, + withExtension: manifestURL.pathExtension + ) { success in didSucceed = success expectation.fulfill() } @@ -694,9 +792,6 @@ final class NativeFormatTileStreamingTests: BaseRenderSetup { XCTAssertTrue(didSucceed, "Tile manifest should load successfully") } - /// Async variant that wraps `loadTiledScene(url:)` in a `CheckedContinuation` - /// so it can be awaited from `async throws` test methods without using - /// `wait(for:)` which is unavailable in async contexts. private func loadSceneManifestFromURL(_ url: URL) async -> Bool { await withCheckedContinuation { continuation in loadTiledScene(url: url) { @Sendable success in @@ -727,7 +822,11 @@ private struct UntoldTileSceneFixture { let tileFileName: String } -private func makeUntoldTileSceneFixture(includeHLOD: Bool, includeLOD: Bool) throws -> UntoldTileSceneFixture { +private func makeUntoldTileSceneFixture( + includeHLOD: Bool, + includeLOD: Bool, + includeScenePayload: Bool = false +) throws -> UntoldTileSceneFixture { guard let sourceUntoldURL = Bundle.module.url(forResource: "redplayer", withExtension: "untold") else { throw NSError(domain: "NativeFormatTileStreamingTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to locate redplayer.untold in test resources"]) } @@ -784,7 +883,7 @@ private func makeUntoldTileSceneFixture(includeHLOD: Bool, includeLOD: Bool) thr ] } - let manifest: [String: Any] = [ + var manifest: [String: Any] = [ "version": 1, "streaming_defaults": [ "streaming_radius": 50.0, @@ -795,6 +894,88 @@ private func makeUntoldTileSceneFixture(includeHLOD: Bool, includeLOD: Bool) thr "tiles": [tileEntry], ] + if includeScenePayload { + let keyLightRows: [[Float]] = [ + [1.0, 0.0, 0.0, 2.0], + [0.0, 1.0, 0.0, 3.0], + [0.0, 0.0, 1.0, 4.0], + [0.0, 0.0, 0.0, 1.0], + ] + let keyLight: [String: Any] = [ + "entity_name": "Manifest Key Light", + "kind": "point", + "color": [0.8, 0.9, 1.0], + "intensity": 3.5, + "position": [2.0, 3.0, 4.0], + "radius": 12.0, + "direction": [0.0, -1.0, 0.0], + "falloff": 0.4, + "right": [1.0, 0.0, 0.0], + "inner_cone": 10.0, + "up": [0.0, 1.0, 0.0], + "outer_cone": 25.0, + "area_size": [1.0, 1.0], + "source_power": 3.5, + "source_exposure": 0.0, + "local_transform_rows": keyLightRows, + ] + let spotFallback: [String: Any] = [ + "entity_name": "Manifest Spot Fallback", + "kind": "spot", + "color": [1.0, 0.6, 0.2], + "intensity": 2.0, + "position": [-2.0, 3.0, 4.0], + "radius": 9.0, + "direction": [0.0, -1.0, 0.0], + "falloff": 0.2, + "right": [1.0, 0.0, 0.0], + "inner_cone": 8.0, + "up": [0.0, 0.0, -1.0], + "outer_cone": 20.0, + "area_size": [1.0, 1.0], + "source_power": 2.0, + "source_exposure": 0.0, + ] + let areaFallback: [String: Any] = [ + "entity_name": "Manifest Area Fallback", + "kind": "area", + "color": [0.4, 1.0, 0.6], + "intensity": 4.0, + "position": [0.0, 5.0, 0.0], + "radius": 1.0, + "direction": [0.0, 0.0, -1.0], + "falloff": 0.5, + "right": [1.0, 0.0, 0.0], + "inner_cone": 5.0, + "up": [0.0, 1.0, 0.0], + "outer_cone": 10.0, + "area_size": [3.0, 2.0], + "source_power": 4.0, + "source_exposure": 0.0, + ] + manifest["scene_lights"] = [keyLight, spotFallback, areaFallback] + + let cameraRows: [[Float]] = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 6.0], + [0.0, 0.0, 0.0, 1.0], + ] + let camera: [String: Any] = [ + "entity_name": "Manifest Camera", + "position": [0.0, 1.0, 6.0], + "forward": [0.0, 0.0, 1.0], + "up": [0.0, 1.0, 0.0], + "right": [1.0, 0.0, 0.0], + "fov_y_degrees": 55.0, + "near_clip": 0.05, + "far_clip": 750.0, + "aspect_ratio": 1.6, + "local_transform_rows": cameraRows, + ] + manifest["scene_cameras"] = [camera] + } + let manifestData = try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]) let manifestURL = fixtureRoot.appendingPathComponent("scene.json") try manifestData.write(to: manifestURL) diff --git a/Tests/UntoldEngineRenderTests/RendererTest.swift b/Tests/UntoldEngineRenderTests/RendererTest.swift index 80034d70..ba292de5 100644 --- a/Tests/UntoldEngineRenderTests/RendererTest.swift +++ b/Tests/UntoldEngineRenderTests/RendererTest.swift @@ -273,12 +273,13 @@ final class RendererTests: BaseRenderSetup { ) } - let expectedLastSplit = renderInfo.isXRStereoMode ? Float(50.0) : far + let shadowFar = min(far, RenderPasses.maxShadowCastingDistance) + let expectedLastSplit = renderInfo.isXRStereoMode ? min(Float(50.0), shadowFar) : shadowFar XCTAssertEqual( shadowSystem.cascadeSplitDistances.last ?? -1, expectedLastSplit, accuracy: 0.001, - "Last cascade split should reach the configured CSM far distance" + "Last cascade split should reach the effective CSM shadow distance" ) for matrix in shadowSystem.cascadeLightSpaceMatrices { diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png index ed0108ee..40b68a82 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ChromaticAberrationReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ChromaticAberrationReference.png index cf72dfb4..1d73b078 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ChromaticAberrationReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ChromaticAberrationReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorGradingReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorGradingReference.png index 8280bf6a..7da3c398 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorGradingReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorGradingReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/CompositeColorTargetReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/CompositeColorTargetReference.png index bb925e04..d3c52c4f 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/CompositeColorTargetReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/CompositeColorTargetReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthOfFieldReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthOfFieldReference.png index 68279fbc..a1006ec2 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthOfFieldReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthOfFieldReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FXAAReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FXAAReference.png index 4eb9f646..488b941f 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FXAAReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FXAAReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint1Reference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint1Reference.png index e65b8361..b6df27f1 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint1Reference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint1Reference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint2Reference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint2Reference.png index 402dc1d4..2278fbc6 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint2Reference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint2Reference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint3Reference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint3Reference.png index 5452d997..1384fa27 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint3Reference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint3Reference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png index 8c4e48ab..710ce2e3 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SMAAReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SMAAReference.png index 42f64e11..693917d7 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SMAAReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SMAAReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png index 8c4e48ab..710ce2e3 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/VignetteReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/VignetteReference.png index fad5f451..179732df 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/VignetteReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/VignetteReference.png differ diff --git a/Tests/UntoldEngineTests/CameraTest.swift b/Tests/UntoldEngineTests/CameraTest.swift index c38df6ed..465daa05 100644 --- a/Tests/UntoldEngineTests/CameraTest.swift +++ b/Tests/UntoldEngineTests/CameraTest.swift @@ -172,6 +172,27 @@ final class CameraTests: XCTestCase { XCTAssertEqual(findGameCamera(), camera, "Could not find Main camera") } + func testCreateGameCameraSetsActiveCamera() { + CameraSystem.shared.activeCamera = nil + let newCamera = createEntity() + + createGameCamera(entityId: newCamera) + + XCTAssertEqual(CameraSystem.shared.activeCamera, newCamera) + destroyEntity(entityId: newCamera) + } + + func testFindGameCameraReturnsActiveCameraWhenMultipleCamerasExist() { + let secondCamera = createEntity() + createGameCamera(entityId: secondCamera) + setCamera(.active(secondCamera)) + + XCTAssertEqual(findGameCamera(), secondCamera) + XCTAssertNotEqual(findGameCamera(), camera) + + destroyEntity(entityId: secondCamera) + } + func testCameraMoveByWorld() { moveCameraTo(entityId: camera, 0, 0, 0) cameraMoveBy(entityId: camera, delta: simd_float3(1, 2, 3), space: .world) diff --git a/Tests/UntoldEngineTests/NativeFormatTests.swift b/Tests/UntoldEngineTests/NativeFormatTests.swift index 80ed2725..be24985b 100644 --- a/Tests/UntoldEngineTests/NativeFormatTests.swift +++ b/Tests/UntoldEngineTests/NativeFormatTests.swift @@ -176,6 +176,67 @@ final class NativeFormatTests: XCTestCase { XCTAssertEqual(try decoded.string(at: decoded.entities[1].nameOffset), "entity_b") } + func testLightAndCameraTablesRoundtripThroughRuntimeLoader() throws { + let fixture = makeScenePayloadFixture() + let decoded = try UntoldReader().readAsset(from: fixture.fileData) + + XCTAssertEqual(decoded.lights, [fixture.light]) + XCTAssertEqual(decoded.cameras, [fixture.camera]) + XCTAssertEqual(try decoded.string(at: fixture.light.nameOffset), "Authored Spot") + XCTAssertEqual(try decoded.string(at: fixture.camera.nameOffset), "Authored Camera") + + let loaded = try NativeFormatLoader().loadAssetSync(from: writeFixtureToTemporaryFile(fixture.fileData)) + let runtimeLight = try XCTUnwrap(loaded.lights.first) + XCTAssertEqual(runtimeLight.name, "Authored Spot") + XCTAssertEqual(runtimeLight.kind, .spot) + XCTAssertEqual(runtimeLight.color, SIMD3(0.25, 0.5, 1.0)) + XCTAssertEqual(runtimeLight.intensity, 7.0, accuracy: 0.0001) + XCTAssertEqual(runtimeLight.radius, 9.0, accuracy: 0.0001) + XCTAssertEqual(runtimeLight.innerCone, 12.0, accuracy: 0.0001) + XCTAssertEqual(runtimeLight.outerCone, 34.0, accuracy: 0.0001) + + let runtimeCamera = try XCTUnwrap(loaded.cameras.first) + XCTAssertEqual(runtimeCamera.name, "Authored Camera") + XCTAssertEqual(runtimeCamera.fovYDegrees, 58.0, accuracy: 0.0001) + XCTAssertEqual(runtimeCamera.nearClip, 0.05, accuracy: 0.0001) + XCTAssertEqual(runtimeCamera.farClip, 650.0, accuracy: 0.0001) + XCTAssertEqual(runtimeCamera.aspectRatio, 1.6, accuracy: 0.0001) + } + + func testMaterialTextureChannelsRoundtripThroughRuntimeLoader() throws { + let fixture = makeTinyFixture(mutator: { _, _, _, material, _, _, _ in + material = UntoldMaterialRecordV1( + nameOffset: material.nameOffset, + flags: material.flags, + baseColorFactor: material.baseColorFactor, + emissiveFactor: material.emissiveFactor, + normalScale: material.normalScale, + metallicFactor: material.metallicFactor, + roughnessFactor: material.roughnessFactor, + occlusionStrength: material.occlusionStrength, + alphaCutoff: material.alphaCutoff, + baseColorTextureIndex: material.baseColorTextureIndex, + normalTextureIndex: material.normalTextureIndex, + metallicTextureIndex: material.metallicTextureIndex, + roughnessTextureIndex: material.roughnessTextureIndex, + emissiveTextureIndex: material.emissiveTextureIndex, + occlusionTextureIndex: material.occlusionTextureIndex, + roughnessTextureChannel: .g, + metallicTextureChannel: .b + ) + }) + + let decoded = try UntoldReader().readAsset(from: fixture.fileData) + XCTAssertEqual(decoded.materials[0].reserved0[0], UntoldMaterialRecordV1.packTextureChannels(roughness: .g, metallic: .b)) + XCTAssertEqual(decoded.materials[0].roughnessTextureChannel, .g) + XCTAssertEqual(decoded.materials[0].metallicTextureChannel, .b) + + let loaded = try NativeFormatLoader().loadAssetSync(from: writeFixtureToTemporaryFile(fixture.fileData)) + let material = try XCTUnwrap(loaded.nodes.first?.primitives.first?.material) + XCTAssertEqual(material.roughnessTextureChannel, .g) + XCTAssertEqual(material.metallicTextureChannel, .b) + } + private func encodeChunk(_ records: [some UntoldBinaryEncodable]) -> Data { let writer = UntoldBinaryWriter() for record in records { @@ -211,6 +272,12 @@ final class NativeFormatTests: XCTestCase { var vertex: UntoldPBRStaticVertexV1 } + private struct ScenePayloadFixture { + var fileData: Data + var light: UntoldLightRecordV1 + var camera: UntoldCameraRecordV1 + } + private func makeTinyFixture( removedChunkTypes: Set = [], computeHash: Bool = false, @@ -445,6 +512,64 @@ final class NativeFormatTests: XCTestCase { return TinyFixture(fileData: fileData, chunkPayloads: chunkPayloads, chunkEntries: chunkEntries, entity: entityA, mesh: meshA, material: material, texture: texture, vertex: vertexA) } + private func makeScenePayloadFixture() -> ScenePayloadFixture { + let fixture = makeTinyFixture() + let stringTable = makeStringTable(["root_entity", "mesh_0", "mat_0", "albedo.ktx2", "Authored Spot", "Authored Camera"]) + var lightTransform = matrix_identity_float4x4 + lightTransform.columns.3 = SIMD4(2.0, 3.0, 4.0, 1.0) + let light = UntoldLightRecordV1( + entityId: 10, + nameOffset: stringTable.offsets["Authored Spot"]!, + lightType: .spot, + color: SIMD3(0.25, 0.5, 1.0), + intensity: 7.0, + position: SIMD3(2.0, 3.0, 4.0), + radius: 9.0, + direction: SIMD3(0.0, -1.0, 0.0), + falloff: 0.25, + innerCone: 12.0, + outerCone: 34.0, + localTransform: lightTransform + ) + var cameraTransform = matrix_identity_float4x4 + cameraTransform.columns.3 = SIMD4(0.0, 1.0, 6.0, 1.0) + let camera = UntoldCameraRecordV1( + entityId: 11, + nameOffset: stringTable.offsets["Authored Camera"]!, + position: SIMD3(0.0, 1.0, 6.0), + fovYDegrees: 58.0, + nearClip: 0.05, + farClip: 650.0, + aspectRatio: 1.6, + localTransform: cameraTransform + ) + + var header = UntoldFileHeaderV1( + fileType: .tile, + chunkCount: 0, + meshCount: 1, + materialCount: 1, + textureRefCount: 1, + entityCount: 1, + vertexLayout: .pbrStaticV1, + worldBounds: fixture.entity.worldBounds + ) + let chunkPayloads = buildChunkPayloads( + stringTableData: stringTable.data, + entities: [fixture.entity], + meshes: [fixture.mesh], + materials: [fixture.material], + textures: [fixture.texture], + vertexData: fixture.chunkPayloads.first(where: { $0.type == .vertexData })!.data, + indexData: fixture.chunkPayloads.first(where: { $0.type == .indexData })!.data, + lights: [light], + cameras: [camera] + ) + header.chunkCount = UInt32(chunkPayloads.count) + let (fileData, _) = buildFileData(header: header, chunkPayloads: chunkPayloads) + return ScenePayloadFixture(fileData: fileData, light: light, camera: camera) + } + private func buildChunkPayloads( stringTableData: Data, entities: [UntoldEntityRecordV1], @@ -454,6 +579,8 @@ final class NativeFormatTests: XCTestCase { vertexData: Data, indexData: Data, edgeIndexData: Data = Data(), + lights: [UntoldLightRecordV1] = [], + cameras: [UntoldCameraRecordV1] = [], removedChunkTypes: Set = [] ) -> [(type: UntoldChunkType, data: Data, elementCount: UInt32)] { var all: [(type: UntoldChunkType, data: Data, elementCount: UInt32)] = [ @@ -468,6 +595,12 @@ final class NativeFormatTests: XCTestCase { if !edgeIndexData.isEmpty { all.append((.edgeIndexData, edgeIndexData, 0)) } + if !lights.isEmpty { + all.append((.lightTable, encodeChunk(lights), UInt32(lights.count))) + } + if !cameras.isEmpty { + all.append((.cameraTable, encodeChunk(cameras), UInt32(cameras.count))) + } return all.filter { !removedChunkTypes.contains($0.type) } } diff --git a/Tests/UntoldEngineTests/SceneContextVisibilityTests.swift b/Tests/UntoldEngineTests/SceneContextVisibilityTests.swift index bb521720..1265a6e5 100644 --- a/Tests/UntoldEngineTests/SceneContextVisibilityTests.swift +++ b/Tests/UntoldEngineTests/SceneContextVisibilityTests.swift @@ -21,6 +21,13 @@ final class SceneContextVisibilityTests: XCTestCase { XCTAssertTrue(getSceneChannelVisible(.contextGeometry)) } + func testUserCustomSceneChannelsUseReservedUpperBits() { + XCTAssertEqual(SceneChannel.userCustom(index: 0).rawValue, UInt64(1) << 32) + XCTAssertEqual(SceneChannel.userCustom(index: 31).rawValue, UInt64(1) << 63) + XCTAssertTrue(SceneChannel.engineReservedMask.intersection(.userCustom(index: 0)).isEmpty) + XCTAssertTrue(SceneChannel.userCustomMask.intersection(.contextGeometry).isEmpty) + } + func testContextSceneChannelVisibilityCanToggle() { setSceneChannel(.contextGeometry, .renderMode(.hidden)) XCTAssertFalse(getSceneChannelVisible(.contextGeometry)) @@ -134,6 +141,47 @@ final class SceneContextVisibilityTests: XCTestCase { XCTAssertFalse(shouldHideSceneEntity(entityId: entityId)) } + func testRegisteredPrefixAssignsUserCustomDefaultSceneChannel() { + let ceilingChannel = SceneChannel.userCustom(index: 0) + registerSceneChannelPrefix("CEIL_", channels: ceilingChannel) + + let entityId = createEntity() + setEntityName(entityId: entityId, name: "CEIL_Level01_A") + _ = scene.assign(to: entityId, component: RenderComponent.self) + + XCTAssertEqual(getEntitySceneChannels(entityId: entityId), ceilingChannel) + + setSceneChannel(ceilingChannel, .renderMode(.wireframe)) + + XCTAssertTrue(shouldRenderSceneEntityAsWireframe(entityId: entityId)) + XCTAssertFalse(shouldRenderSceneChannelsOpaque(ceilingChannel)) + } + + func testRegisteredPrefixUsesLongestMatchingPrefix() { + let broadChannel = SceneChannel.userCustom(index: 0) + let specificChannel = SceneChannel.userCustom(index: 1) + registerSceneChannelPrefix("WIN_", channels: broadChannel) + registerSceneChannelPrefix("WIN_GLASS_", channels: specificChannel) + + XCTAssertEqual(defaultSceneChannels(forName: "WIN_Frame_01"), broadChannel) + XCTAssertEqual(defaultSceneChannels(forName: "WIN_GLASS_Main_01"), specificChannel) + } + + func testSelectablePrefixTakesPrecedenceOverRegisteredPrefix() { + let customChannel = SceneChannel.userCustom(index: 0) + registerSceneChannelPrefix("NM_", channels: customChannel) + + XCTAssertEqual(defaultSceneChannels(forName: "NM_Pipe_001"), [.selectableGeometry, .preserveIdentity]) + } + + func testUnregisteredPrefixFallsBackToContextGeometry() { + let customChannel = SceneChannel.userCustom(index: 0) + registerSceneChannelPrefix("CEIL_", channels: customChannel) + unregisterSceneChannelPrefix("CEIL_") + + XCTAssertEqual(defaultSceneChannels(forName: "CEIL_Level01_A"), .contextGeometry) + } + func testNonNMEntityIsHiddenWhenNonSelectableSceneIsHidden() { let entityId = createEntity() setEntityName(entityId: entityId, name: "Wall_North") diff --git a/Tests/UntoldEngineTests/TestEngineReset.swift b/Tests/UntoldEngineTests/TestEngineReset.swift index cd61a3a8..69536f39 100644 --- a/Tests/UntoldEngineTests/TestEngineReset.swift +++ b/Tests/UntoldEngineTests/TestEngineReset.swift @@ -36,5 +36,6 @@ activeEntity = .invalid OctreeSystem.shared.clear() resetSceneChannelVisibility() + resetSceneChannelPrefixes() setSceneReady(true) } diff --git a/docs/API/UsingBlenderAddon.md b/docs/API/UsingBlenderAddon.md index 4dc8e6f8..47c16dfd 100644 --- a/docs/API/UsingBlenderAddon.md +++ b/docs/API/UsingBlenderAddon.md @@ -7,7 +7,40 @@ Use it when you want to import or open a model in Blender, inspect or edit it, then export it without caring whether the original source was `.usdz`, `.fbx`, `.glb`, `.obj`, or another Blender-supported format. -## Install +## Install From A Release + +If you are new to Untold Engine or only want to use the engine/editor, you do +not need to clone the repository to install the Blender add-on. + +Download the packaged add-on from the GitHub release page: + +```text +https://github.com/untoldengine/UntoldEngine/releases +``` + +Each release includes `untold_exporter.zip` as a downloadable asset. Download +that file directly and keep it as a `.zip`; do not unzip it before installing. + +In Blender: + +1. Open `Edit > Preferences > Add-ons`. +2. Click `Install...`. +3. Select the downloaded `untold_exporter.zip`. +4. Enable `Untold Engine Exporter`. + +After enabling the add-on, Blender adds: + +- `File > Export > Untold (.untold)` +- `File > Export > Untold Animation (.untold)` +- `File > Export > Untold Tiled Scene` + +For best results, install the add-on from the same release version as the +engine/editor you are using. + +## Install From A Repository Checkout + +Use this path if you are developing the engine or want to build the add-on zip +from source. Build the plugin zip from the repo root: @@ -22,7 +55,7 @@ In Blender: 3. Select `scripts/untold-blender-addon/build/untold_exporter.zip`. 4. Enable `Untold Engine Exporter`. -The plugin adds: +After installing, the plugin adds the same export menu entries: - `File > Export > Untold (.untold)` - `File > Export > Untold Animation (.untold)` diff --git a/docs/API/UsingLightingSystem.md b/docs/API/UsingLightingSystem.md index c8bb8f07..d936ab1b 100644 --- a/docs/API/UsingLightingSystem.md +++ b/docs/API/UsingLightingSystem.md @@ -3,6 +3,13 @@ The Lighting System lets you add illumination to your scenes using common real-time light types. Under the hood it wires up the required ECS components, provides an editor-friendly visual handle, and tags the light so the renderer can pick it up. --- +## Direction Convention + +Transform forward still means local `+Z`, but non-point lights emit along local `-Z` transformed into world space. Use `getLightEmissionDirection(entityId:)` when you need the semantic emission/travel direction instead of deriving signs from `getForwardAxisVector(entityId:)` directly. + +Directional shader uniforms use the opposite vector because the BRDF expects the vector from the shaded point toward the light source. + +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. ## Creating Each Light Type ### Directional Light diff --git a/docs/API/UsingSceneChannels.md b/docs/API/UsingSceneChannels.md index 6d77ac1e..3dd6f3d4 100644 --- a/docs/API/UsingSceneChannels.md +++ b/docs/API/UsingSceneChannels.md @@ -11,6 +11,15 @@ The current built-in channels are: | `.preserveIdentity` | Entities that should not be merged into static batches because their identity matters at runtime | | `.ghostGeometry` | Specific walls or structures selected for passthrough ghost rendering | +Built-in channels use the lower 32 bits of the `SceneChannel` mask. App/project-specific channels should use `SceneChannel.userCustom(index:)`, which uses the upper 32 bits and avoids collisions with future engine channels: + +```swift +extension SceneChannel { + static let ceilingGeometry = SceneChannel.userCustom(index: 0) + static let windowGeometry = SceneChannel.userCustom(index: 1) +} +``` + ## Default Behavior For exported tiled scenes, the engine assigns channels automatically: @@ -24,6 +33,15 @@ For exported tiled scenes, the engine assigns channels automatically: This preserves the existing `NM_` workflow. Objects whose names start with `NM_` remain selectable by default; you do not need to call a channel visibility function to make them selectable. +Apps can register additional name prefixes for project-specific channels: + +```swift +registerSceneChannelPrefix("CEIL_", channels: .ceilingGeometry) +registerSceneChannelPrefix("WIN_", channels: .windowGeometry) +``` + +The built-in `NM_` selectable prefix takes precedence over registered prefixes. If more than one registered prefix matches a name, the longest prefix wins. + ## Setting Channel Properties All channel properties are set through a single function: @@ -39,6 +57,7 @@ setSceneChannel(.contextGeometry, .renderMode(.wireframe)) setSceneChannel(.contextGeometry, .renderMode(.hidden)) setSceneChannel(.contextGeometry, .renderMode(.normal)) setSceneChannel(.ghostGeometry, .renderMode(.passthroughGhost(opacity: 0.35))) +setSceneChannel(.ceilingGeometry, .renderMode(.wireframe)) ``` New properties can be added to `SceneChannelProperty` in the future without introducing new top-level functions. @@ -199,6 +218,28 @@ let context = isNonSelectableSceneContextEntity(entityId: entity) let preserveIdentity = shouldPreserveSceneEntityIdentity(entityId: entity) ``` +## User Custom Channels + +Use `SceneChannel.userCustom(index:)` for app-specific groups such as ceilings, windows, annotations, work zones, or discipline-specific layers. The engine reserves bits `0...31`; `userCustom(index:)` maps indexes `0...31` to bits `32...63`. + +```swift +extension SceneChannel { + static let ceilingGeometry = SceneChannel.userCustom(index: 0) + static let windowGeometry = SceneChannel.userCustom(index: 1) +} + +registerSceneChannelPrefix("CEIL_", channels: .ceilingGeometry) +registerSceneChannelPrefix("WIN_", channels: .windowGeometry) + +setSceneChannel(.ceilingGeometry, .renderMode(.hidden)) +setSceneChannel(.ceilingGeometry, .renderMode(.wireframe)) +setSceneChannel(.ceilingGeometry, .renderMode(.normal)) + +setSceneChannel(.windowGeometry, .renderMode(.passthroughGhost(opacity: 0.0))) +``` + +These names are defined by the app, not by the engine. The engine only provides the collision-free custom channel range and the prefix registration API. + ## Recommended Construction Workflow For large construction-site or architectural scenes: diff --git a/docs/Architecture/sceneChannels.md b/docs/Architecture/sceneChannels.md index cdb39f01..5605e882 100644 --- a/docs/Architecture/sceneChannels.md +++ b/docs/Architecture/sceneChannels.md @@ -15,7 +15,13 @@ Current built-in channels: | `.preserveIdentity` | Entities that should remain separate and should not be static-batched | | `.ghostGeometry` | Specific walls/structures selected for passthrough ghost rendering | -The bitmask design allows future channels to be added without changing the storage model. +The bitmask design allows future channels to be added without changing the storage model. Bits `0...31` are reserved for built-in engine channels. Bits `32...63` are reserved for app/project-specific channels created through: + +```swift +SceneChannel.userCustom(index: 0) +``` + +This lets apps define domain names such as `.ceilingGeometry` or `.windowGeometry` in their own code without hardcoding those semantics into the engine and without colliding with future engine channels. ## Default Assignment @@ -24,10 +30,20 @@ Runtime and streamed entities receive `EntitySceneChannelsComponent` during regi | Source | Channels | |---|---| | entity name starts with `NM_` | `.selectableGeometry`, `.preserveIdentity` | +| entity name matches a registered app prefix | registered channels | | renderable/streamed entity without `NM_` | `.contextGeometry` | Explicit calls to `setEntitySceneChannels(entityId:channels:)` override the default mapping. The component tracks whether its value came from engine defaults so later name updates can refresh default channels without overwriting app-defined channels. +Apps can register additional prefix mappings: + +```swift +registerSceneChannelPrefix("CEIL_", channels: .userCustom(index: 0)) +registerSceneChannelPrefix("WIN_", channels: .userCustom(index: 1)) +``` + +The built-in `NM_` prefix is evaluated first to preserve selectable-object compatibility. Registered prefixes use longest-prefix matching so broad and specific project prefixes can coexist. + `fallbackSceneChannels` exists only as a temporary compatibility path for entities created outside the normal registration flow. It should be removed once all entity creation paths assign scene channels explicitly. ## Rendering @@ -95,4 +111,4 @@ The engine does not require object names to implement channels. However, the cur - At runtime they default to `.selectableGeometry` and `.preserveIdentity`. - Regular merged geometry defaults to `.contextGeometry`. -This keeps the existing selectable-object workflow intact while moving the engine feature itself toward generic channels. +This keeps the existing selectable-object workflow intact while moving the engine feature itself toward generic channels. Additional app-specific exporter prefixes should be registered at runtime rather than added as built-in engine semantics. diff --git a/docs/Architecture/tilebasedstreaming.md b/docs/Architecture/tilebasedstreaming.md index da6e489f..cf5a5c15 100644 --- a/docs/Architecture/tilebasedstreaming.md +++ b/docs/Architecture/tilebasedstreaming.md @@ -52,8 +52,10 @@ A scene is described by a manifest file listing tiles. | `shared_bucket` | *(optional)* A single always-resident tile for geometry that spans many tiles | | `tile_size` | *(optional)* Tile footprint in world units, used to align batch cell size with tile boundaries | | `interior_zone` | *(v4 only)* Union AABB of all `ExteriorShell` tiles. Interior tiles are only loaded while the camera is inside this volume | +| `scene_lights` | *(optional)* Scene-authored lights exported from Blender. Registered only by an explicit `loadSceneAuthored(url:)` call; not tied to tile residency | +| `scene_cameras` | *(optional)* Scene-authored cameras exported from Blender. Registered only by an explicit `loadSceneAuthored(url:)` call; not tied to tile residency | -The `streaming_defaults` block sets scene-wide fallback values for all per-tile fields. An optional `shared_bucket` entry holds geometry that spans many tiles and should always be resident (loaded as soon as the camera enters the scene). +The `streaming_defaults` block sets scene-wide fallback values for all per-tile fields. An optional `shared_bucket` entry holds geometry that spans many tiles and should always be resident (loaded as soon as the camera enters the scene). `scene_lights` and `scene_cameras` are persistent scene payload arrays available to `loadSceneAuthored(url:)`; regular tile scene loading does not register them automatically. #### Per-tile entry fields diff --git a/scripts/tests/test_tilestreamingpartition.py b/scripts/tests/test_tilestreamingpartition.py index ce41e384..ea46eb78 100644 --- a/scripts/tests/test_tilestreamingpartition.py +++ b/scripts/tests/test_tilestreamingpartition.py @@ -15,6 +15,7 @@ import sys import unittest +from types import SimpleNamespace from unittest.mock import MagicMock from pathlib import Path @@ -182,6 +183,71 @@ def test_distance_to_aabb_uses_closest_point(self) -> None: self.assertAlmostEqual(t.distance_to_aabb((13.0, 14.0, 10.0), bounds), 5.0) + def test_collect_manifest_scene_payload_serializes_lights_and_cameras(self) -> None: + original_extract = t.extract_scene_payload_from_objects + original_objects = t.bpy.data.objects + t.bpy.data.objects = [FakeObject("Sun"), FakeObject("Camera")] + t.extract_scene_payload_from_objects = MagicMock(return_value=( + [ + SimpleNamespace( + entity_name="Sun", + light_type=1, + color=(1.0, 0.9, 0.8), + intensity=3.0, + position=(1.0, 2.0, 3.0), + radius=0.001, + direction=(0.0, -1.0, 0.0), + falloff=0.5, + right=(1.0, 0.0, 0.0), + inner_cone=10.0, + up=(0.0, 1.0, 0.0), + outer_cone=25.0, + area_size=(1.0, 1.0), + source_power=3.0, + source_exposure=0.0, + local_transform_rows=[ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 2.0], + [0.0, 0.0, 1.0, 3.0], + [0.0, 0.0, 0.0, 1.0], + ], + ) + ], + [ + SimpleNamespace( + entity_name="Camera", + position=(0.0, 1.0, 6.0), + forward=(0.0, 0.0, 1.0), + up=(0.0, 1.0, 0.0), + right=(1.0, 0.0, 0.0), + fov_y_degrees=55.0, + near_clip=0.05, + far_clip=750.0, + aspect_ratio=1.6, + local_transform_rows=[ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 6.0], + [0.0, 0.0, 0.0, 1.0], + ], + ) + ], + )) + try: + lights, cameras = t.collect_manifest_scene_payload() + finally: + t.extract_scene_payload_from_objects = original_extract + t.bpy.data.objects = original_objects + + self.assertEqual(lights[0]["entity_name"], "Sun") + self.assertEqual(lights[0]["kind"], "directional") + self.assertEqual(lights[0]["light_type"], 1) + self.assertEqual(lights[0]["local_transform_rows"][0][3], 1.0) + self.assertEqual(cameras[0]["entity_name"], "Camera") + self.assertEqual(cameras[0]["fov_y_degrees"], 55.0) + self.assertEqual(cameras[0]["near_clip"], 0.05) + self.assertEqual(cameras[0]["far_clip"], 750.0) + def test_object_union_aabb(self) -> None: objs = [FakeObject("A"), FakeObject("B")] bounds = { diff --git a/scripts/tests/test_untoldexplorer.py b/scripts/tests/test_untoldexplorer.py index 61ec430c..00d3892f 100644 --- a/scripts/tests/test_untoldexplorer.py +++ b/scripts/tests/test_untoldexplorer.py @@ -1,4 +1,5 @@ import json +import math import struct import sys import tempfile @@ -13,6 +14,63 @@ import untoldexplorer as u +class FakeVector: + def __init__(self, values) -> None: + self.x = float(values[0]) + self.y = float(values[1]) + self.z = float(values[2]) + + def __getitem__(self, index: int) -> float: + return (self.x, self.y, self.z)[index] + + +class FakeMatrix: + def __init__(self, translation=(0.0, 0.0, 0.0)) -> None: + self.translation = translation + + def to_3x3(self): + return self + + def __matmul__(self, vector): + return FakeVector(vector) + + +class FakeData: + def __init__(self, **values) -> None: + self.__dict__.update(values) + + +class FakeSceneObject: + def __init__(self, name: str, object_type: str, data: FakeData, translation=(0.0, 0.0, 0.0)) -> None: + self.name = name + self.type = object_type + self.data = data + self.matrix_world = FakeMatrix(translation) + +class FakeSocket: + def __init__(self, name: str = "") -> None: + self.name = name + self.is_linked = False + self.links = [] + + def link_from(self, from_node: object, from_socket_name: str) -> None: + self.is_linked = True + self.links = [FakeLink(from_node, FakeSocket(from_socket_name))] + + +class FakeLink: + def __init__(self, from_node: object, from_socket: FakeSocket) -> None: + self.from_node = from_node + self.from_socket = from_socket + + +class FakeNode: + def __init__(self, bl_idname: str, *, image: object = None, inputs: dict[str, FakeSocket] | None = None) -> None: + self.bl_idname = bl_idname + self.image = image + self.inputs = inputs or {} + + class UntoldExplorerTests(unittest.TestCase): def test_align_and_clamp_helpers(self) -> None: self.assertEqual(u.align(16, 16), 16) @@ -20,6 +78,49 @@ def test_align_and_clamp_helpers(self) -> None: self.assertEqual(u.clamp(-1.0, 0.0, 1.0), 0.0) self.assertEqual(u.clamp(0.5, 0.0, 1.0), 0.5) self.assertEqual(u.clamp(2.0, 0.0, 1.0), 1.0) + def test_material_texture_channel_helpers(self) -> None: + self.assertEqual( + u.pack_material_texture_channels(u.TEXTURE_CHANNEL_R, u.TEXTURE_CHANNEL_G), + 0b0100, + ) + self.assertEqual( + u.pack_material_texture_channels(u.TEXTURE_CHANNEL_B, u.TEXTURE_CHANNEL_A), + 0b1110, + ) + self.assertEqual(u.pack_material_texture_channels(99, -1), 0) + + self.assertEqual(u.texture_channel_from_socket_name("Red", u.TEXTURE_CHANNEL_B), u.TEXTURE_CHANNEL_R) + self.assertEqual(u.texture_channel_from_socket_name("G", u.TEXTURE_CHANNEL_R), u.TEXTURE_CHANNEL_G) + self.assertEqual(u.texture_channel_from_socket_name("Alpha", u.TEXTURE_CHANNEL_R), u.TEXTURE_CHANNEL_A) + self.assertEqual(u.texture_channel_from_socket_name("Color", u.TEXTURE_CHANNEL_B), u.TEXTURE_CHANNEL_B) + + def test_resolve_texture_from_socket_preserves_separate_rgb_channel(self) -> None: + image = FakeData(filepath="textures/packed.png", library=None, size=(256, 128), name="packed") + image_node = FakeNode("ShaderNodeTexImage", image=image) + separate_input = FakeSocket("Image") + separate_input.link_from(image_node, "Color") + separate_node = FakeNode("ShaderNodeSeparateRGB", inputs={"Image": separate_input}) + metallic_input = FakeSocket("Metallic") + metallic_input.link_from(separate_node, "G") + + with tempfile.TemporaryDirectory() as tmpdir: + texture = u.resolve_texture_from_socket(metallic_input, Path(tmpdir) / "asset.untold") + + self.assertIsNotNone(texture) + self.assertEqual(texture.channel, u.TEXTURE_CHANNEL_G, "SeparateRGB G output should map to green") + self.assertTrue(texture.uri.endswith("textures/packed.png")) + + def test_resolve_texture_from_socket_preserves_image_alpha_channel(self) -> None: + image = FakeData(filepath="textures/mask.png", library=None, size=(64, 64), name="mask") + image_node = FakeNode("ShaderNodeTexImage", image=image) + alpha_input = FakeSocket("Alpha") + alpha_input.link_from(image_node, "Alpha") + + with tempfile.TemporaryDirectory() as tmpdir: + texture = u.resolve_texture_from_socket(alpha_input, Path(tmpdir) / "asset.untold") + + self.assertIsNotNone(texture) + self.assertEqual(texture.channel, u.TEXTURE_CHANNEL_A) def test_normalize_and_pack_helpers_use_fallbacks_and_clamping(self) -> None: self.assertEqual(u.normalize3((0.0, 0.0, 0.0), (1.0, 2.0, 3.0)), (1.0, 2.0, 3.0)) @@ -187,6 +288,100 @@ def test_parse_args_handles_blender_style_separator(self) -> None: self.assertTrue(args.validate) self.assertTrue(args.animation) + def test_extract_scene_payload_exports_sun_spot_area_and_camera_fields(self) -> None: + original_bpy = u.bpy + original_vector = u.Vector + u.bpy = object() + u.Vector = FakeVector + try: + sun = FakeSceneObject( + "Sun", + "LIGHT", + FakeData(type="SUN", color=(1.0, 0.8, 0.6), energy=4.0, exposure=1.0), + translation=(1.0, 2.0, 3.0), + ) + spot = FakeSceneObject( + "Spot", + "LIGHT", + FakeData( + type="SPOT", + color=(0.2, 0.4, 1.0), + energy=5.0, + spot_size=math.radians(40.0), + spot_blend=0.25, + shadow_soft_size=6.0, + ), + translation=(2.0, 3.0, 4.0), + ) + area = FakeSceneObject( + "Area", + "LIGHT", + FakeData( + type="AREA", + color=(1.0, 1.0, 1.0), + energy=7.0, + shape="RECTANGLE", + size=3.0, + size_y=2.0, + ), + translation=(3.0, 4.0, 5.0), + ) + camera = FakeSceneObject( + "Camera", + "CAMERA", + FakeData( + angle_y=math.radians(55.0), + clip_start=0.05, + clip_end=750.0, + sensor_width=32.0, + sensor_height=20.0, + sensor_fit="AUTO", + ), + translation=(0.0, 1.0, 6.0), + ) + + lights, cameras = u.extract_scene_payload_from_objects([sun, spot, area, camera]) + finally: + u.bpy = original_bpy + u.Vector = original_vector + + self.assertEqual(len(lights), 3) + self.assertEqual(len(cameras), 1) + + self.assertEqual(lights[0].entity_name, "Sun") + self.assertEqual(lights[0].light_type, u.LIGHT_TYPE_DIRECTIONAL) + self.assertEqual(lights[0].position, (1.0, 2.0, 3.0)) + self.assertAlmostEqual(lights[0].intensity, 8.0) + + self.assertEqual(lights[1].entity_name, "Spot") + self.assertEqual(lights[1].light_type, u.LIGHT_TYPE_SPOT) + self.assertAlmostEqual(lights[1].outer_cone, 40.0) + self.assertAlmostEqual(lights[1].inner_cone, 30.0) + self.assertAlmostEqual(lights[1].radius, 6.0) + + self.assertEqual(lights[2].entity_name, "Area") + self.assertEqual(lights[2].light_type, u.LIGHT_TYPE_AREA) + self.assertEqual(lights[2].position, (3.0, 4.0, 5.0)) + self.assertEqual(lights[2].direction, (0.0, 0.0, -1.0)) + self.assertEqual(lights[2].right, (1.0, 0.0, 0.0)) + self.assertEqual(lights[2].up, (0.0, 1.0, 0.0)) + self.assertEqual(lights[2].area_size, (3.0, 2.0)) + self.assertEqual( + lights[2].local_transform_rows, + [ + [1.0, 0.0, 0.0, 3.0], + [0.0, 1.0, 0.0, 4.0], + [0.0, 0.0, 1.0, 5.0], + [0.0, 0.0, 0.0, 1.0], + ], + ) + + self.assertEqual(cameras[0].entity_name, "Camera") + self.assertAlmostEqual(cameras[0].fov_y_degrees, 55.0) + self.assertAlmostEqual(cameras[0].near_clip, 0.05) + self.assertAlmostEqual(cameras[0].far_clip, 750.0) + self.assertAlmostEqual(cameras[0].aspect_ratio, 1.6) + def test_normalize_blender_path_and_blender_required(self) -> None: resolved = u.normalize_blender_path("./scripts/../scripts/untoldexplorer.py") self.assertEqual(resolved, (Path.cwd() / "scripts/untoldexplorer.py").resolve()) @@ -311,9 +506,12 @@ def test_write_chunk_entry_and_record_sizes(self) -> None: roughness_texture_index=u.INVALID_INDEX, emissive_texture_index=u.INVALID_INDEX, occlusion_texture_index=u.INVALID_INDEX, + roughness_texture_channel=u.TEXTURE_CHANNEL_G, + metallic_texture_channel=u.TEXTURE_CHANNEL_B, ) u.write_material_record(wm, mat) self.assertEqual(wm.count, 88) + self.assertEqual(struct.unpack_from(" list[object]: ] +def scene_payload_candidates(context: Any, scope: str) -> list[object]: + if scope == "SELECTED": + return [ + obj + for obj in context.selected_objects + if getattr(obj, "type", None) in {"LIGHT", "CAMERA"} + ] + + view_layer_object_ids = {obj.as_pointer() for obj in context.view_layer.objects} + return [ + obj + for obj in context.scene.objects + if obj.as_pointer() in view_layer_object_ids + and not getattr(obj, "hide_get", lambda: False)() + and getattr(obj, "type", None) in {"LIGHT", "CAMERA"} + ] + + +def append_unique_objects(objects: list[object], additions: list[object]) -> list[object]: + seen = {obj.as_pointer() for obj in objects} + merged = list(objects) + for obj in additions: + pointer = obj.as_pointer() + if pointer in seen: + continue + seen.add(pointer) + merged.append(obj) + return merged + + def export_asset( *, context: Any, @@ -103,6 +133,7 @@ def export_asset( raise RuntimeError("No exportable objects were found for the selected scope") export_objects = module.prepare_export_objects_from_blender_objects(objects) + export_objects = append_unique_objects(export_objects, scene_payload_candidates(context, scope)) result = module.export_objects_to_untold( export_objects, source_asset_path=source_asset_path_for_export(output_path), diff --git a/scripts/untoldexplorer.py b/scripts/untoldexplorer.py index e61b5fc6..07c0dc28 100644 --- a/scripts/untoldexplorer.py +++ b/scripts/untoldexplorer.py @@ -78,11 +78,17 @@ "joint_index_data": 16, "joint_weight_data": 17, "edge_index_data": 18, + "light_table": 19, + "camera_table": 20, } VERTEX_LAYOUT_PBR_STATIC_V1 = 1 INDEX_TYPE_UINT16 = 1 INDEX_TYPE_UINT32 = 2 +LIGHT_TYPE_DIRECTIONAL = 1 +LIGHT_TYPE_POINT = 2 +LIGHT_TYPE_SPOT = 3 +LIGHT_TYPE_AREA = 4 ARCHITECTURAL_EDGE_ANGLE_DEGREES = 30.0 ARCHITECTURAL_EDGE_POSITION_EPSILON = 1.0e-5 TEXTURE_FORMAT_UNKNOWN = 0 @@ -90,6 +96,10 @@ TEXTURE_FLAG_NORMAL_MAP = 1 << 1 TEXTURE_FLAG_EMISSIVE = 1 << 6 TEXTURE_FLAG_OCCLUSION = 1 << 7 +TEXTURE_CHANNEL_R = 0 +TEXTURE_CHANNEL_G = 1 +TEXTURE_CHANNEL_B = 2 +TEXTURE_CHANNEL_A = 3 ProgressCallback = Callable[[str, int, int, str], None] @@ -129,6 +139,39 @@ def clamp(value: float, minimum: float, maximum: float) -> float: return max(minimum, min(maximum, value)) +def clamp_texture_channel(channel: int) -> int: + channel = int(channel) + if channel in (TEXTURE_CHANNEL_R, TEXTURE_CHANNEL_G, TEXTURE_CHANNEL_B, TEXTURE_CHANNEL_A): + return channel + return TEXTURE_CHANNEL_R + + +def pack_material_texture_channels( + roughness: int = TEXTURE_CHANNEL_R, + metallic: int = TEXTURE_CHANNEL_R, +) -> int: + return (clamp_texture_channel(roughness) & 0b11) | ((clamp_texture_channel(metallic) & 0b11) << 2) + + +def texture_channel_from_socket_name(name: str, default: int = TEXTURE_CHANNEL_R) -> int: + normalized = str(name or "").strip().lower().replace(" ", "").replace("_", "") + channel_by_name = { + "r": TEXTURE_CHANNEL_R, + "red": TEXTURE_CHANNEL_R, + "x": TEXTURE_CHANNEL_R, + "g": TEXTURE_CHANNEL_G, + "green": TEXTURE_CHANNEL_G, + "y": TEXTURE_CHANNEL_G, + "b": TEXTURE_CHANNEL_B, + "blue": TEXTURE_CHANNEL_B, + "z": TEXTURE_CHANNEL_B, + "a": TEXTURE_CHANNEL_A, + "alpha": TEXTURE_CHANNEL_A, + "w": TEXTURE_CHANNEL_A, + } + return channel_by_name.get(normalized, default) + + def normalize3(vector: tuple[float, float, float], fallback: tuple[float, float, float]) -> tuple[float, float, float]: x, y, z = vector length = math.sqrt((x * x) + (y * y) + (z * z)) @@ -388,6 +431,44 @@ class TextureRecord: mip_count: int = 0 +@dataclass(frozen=True) +class LightRecord: + entity_id: int + name_offset: int + light_type: int + flags: int + color: tuple[float, float, float] + intensity: float + position: tuple[float, float, float] + radius: float + direction: tuple[float, float, float] + falloff: float + right: tuple[float, float, float] + inner_cone: float + up: tuple[float, float, float] + outer_cone: float + area_size: tuple[float, float] + source_power: float + source_exposure: float + local_transform_rows: list[list[float]] + + +@dataclass(frozen=True) +class CameraRecord: + entity_id: int + name_offset: int + flags: int + position: tuple[float, float, float] + forward: tuple[float, float, float] + up: tuple[float, float, float] + right: tuple[float, float, float] + fov_y_degrees: float + near_clip: float + far_clip: float + aspect_ratio: float + local_transform_rows: list[list[float]] + + @dataclass(frozen=True) class MaterialRecord: name_offset: int @@ -405,6 +486,8 @@ class MaterialRecord: roughness_texture_index: int = INVALID_INDEX emissive_texture_index: int = INVALID_INDEX occlusion_texture_index: int = INVALID_INDEX + roughness_texture_channel: int = TEXTURE_CHANNEL_R + metallic_texture_channel: int = TEXTURE_CHANNEL_R @dataclass(frozen=True) @@ -514,6 +597,7 @@ class ExportedTexture: mip_count: int source_path: Optional[Path] = None source_image_name: Optional[str] = None + channel: int = TEXTURE_CHANNEL_R @dataclass(frozen=True) @@ -532,6 +616,8 @@ class ExportedMaterial: roughness_texture: Optional[ExportedTexture] = None emissive_texture: Optional[ExportedTexture] = None occlusion_texture: Optional[ExportedTexture] = None + roughness_texture_channel: int = TEXTURE_CHANNEL_R + metallic_texture_channel: int = TEXTURE_CHANNEL_R @dataclass(frozen=True) @@ -584,6 +670,40 @@ class ExportedNode: mesh: Optional[ExportedMesh] = None +@dataclass(frozen=True) +class ExportedLight: + entity_name: str + light_type: int + color: tuple[float, float, float] + intensity: float + position: tuple[float, float, float] + radius: float + direction: tuple[float, float, float] + falloff: float + right: tuple[float, float, float] + inner_cone: float + up: tuple[float, float, float] + outer_cone: float + area_size: tuple[float, float] + source_power: float + source_exposure: float + local_transform_rows: list[list[float]] + + +@dataclass(frozen=True) +class ExportedCamera: + entity_name: str + position: tuple[float, float, float] + forward: tuple[float, float, float] + up: tuple[float, float, float] + right: tuple[float, float, float] + fov_y_degrees: float + near_clip: float + far_clip: float + aspect_ratio: float + local_transform_rows: list[list[float]] + + @dataclass(frozen=True) class ExportedSkeletonJoint: name: str @@ -1003,7 +1123,7 @@ def write_material_record(writer: BinaryWriter, material: MaterialRecord) -> Non writer.write_u32(material.roughness_texture_index) writer.write_u32(material.emissive_texture_index) writer.write_u32(material.occlusion_texture_index) - writer.write_u32(0) + writer.write_u32(pack_material_texture_channels(material.roughness_texture_channel, material.metallic_texture_channel)) writer.write_u32(0) @@ -1018,6 +1138,53 @@ def write_texture_record(writer: BinaryWriter, texture: TextureRecord) -> None: writer.write_u32(0) +def write_light_record(writer: BinaryWriter, light: LightRecord) -> None: + writer.write_u32(light.entity_id) + writer.write_u32(light.name_offset) + writer.write_u32(light.light_type) + writer.write_u32(light.flags) + for value in light.color: + writer.write_f32(value) + writer.write_f32(light.intensity) + for value in light.position: + writer.write_f32(value) + writer.write_f32(light.radius) + for value in light.direction: + writer.write_f32(value) + writer.write_f32(light.falloff) + for value in light.right: + writer.write_f32(value) + writer.write_f32(light.inner_cone) + for value in light.up: + writer.write_f32(value) + writer.write_f32(light.outer_cone) + writer.write_f32(light.area_size[0]) + writer.write_f32(light.area_size[1]) + writer.write_f32(light.source_power) + writer.write_f32(light.source_exposure) + writer.write_matrix4x4_column_major(light.local_transform_rows) + + +def write_camera_record(writer: BinaryWriter, camera: CameraRecord) -> None: + writer.write_u32(camera.entity_id) + writer.write_u32(camera.name_offset) + writer.write_u32(camera.flags) + writer.write_u32(0) + for value in camera.position: + writer.write_f32(value) + writer.write_f32(camera.fov_y_degrees) + for value in camera.forward: + writer.write_f32(value) + writer.write_f32(camera.near_clip) + for value in camera.up: + writer.write_f32(value) + writer.write_f32(camera.far_clip) + for value in camera.right: + writer.write_f32(value) + writer.write_f32(camera.aspect_ratio) + writer.write_matrix4x4_column_major(camera.local_transform_rows) + + def write_skeleton_record(writer: BinaryWriter, skeleton: SkeletonRecord) -> None: writer.write_u32(skeleton.entity_id) writer.write_u32(skeleton.name_offset) @@ -1466,6 +1633,212 @@ def prepare_export_objects_from_blender_objects( return export_objects +def _object_transform_rows( + obj: object, + conversion_matrix: Optional[object], + *, + world: bool = True, +) -> list[list[float]]: + matrix = obj.matrix_world if world else obj.matrix_local + rows = matrix_rows_from_blender(matrix) + if conversion_matrix is not None: + rows = transform_matrix_rows(rows, conversion_matrix) + return rows + + +def _semantic_camera_transform_rows(obj: object, conversion_matrix: Optional[object]) -> list[list[float]]: + matrix = obj.matrix_world + position = vector3(matrix.translation) + right = transform_direction(matrix, (1.0, 0.0, 0.0), (1.0, 0.0, 0.0)) + up = transform_direction(matrix, (0.0, 1.0, 0.0), (0.0, 1.0, 0.0)) + forward = transform_direction(matrix, (0.0, 0.0, -1.0), (0.0, 0.0, 1.0)) + + if conversion_matrix is not None: + position = transform_point(conversion_matrix, position) + right = transform_direction(conversion_matrix, right, (1.0, 0.0, 0.0)) + up = transform_direction(conversion_matrix, up, (0.0, 1.0, 0.0)) + forward = transform_direction(conversion_matrix, forward, (0.0, 0.0, 1.0)) + + return [ + [right[0], up[0], forward[0], position[0]], + [right[1], up[1], forward[1], position[1]], + [right[2], up[2], forward[2], position[2]], + [0.0, 0.0, 0.0, 1.0], + ] + + +def _semantic_light_transform_rows( + obj: object, + conversion_matrix: Optional[object], + light_type: int, +) -> list[list[float]]: + matrix = obj.matrix_world + position = vector3(matrix.translation) + right = transform_direction(matrix, (1.0, 0.0, 0.0), (1.0, 0.0, 0.0)) + up = transform_direction(matrix, (0.0, 1.0, 0.0), (0.0, 1.0, 0.0)) + + if light_type == LIGHT_TYPE_DIRECTIONAL: + forward = transform_direction(matrix, (0.0, 0.0, 1.0), (0.0, 0.0, 1.0)) + elif light_type == LIGHT_TYPE_AREA: + forward = transform_direction(matrix, (0.0, 0.0, 1.0), (0.0, 0.0, 1.0)) + elif light_type == LIGHT_TYPE_SPOT: + forward = transform_direction(matrix, (0.0, 0.0, 1.0), (0.0, 0.0, 1.0)) + else: + forward = transform_direction(matrix, (0.0, 0.0, 1.0), (0.0, 0.0, 1.0)) + + if conversion_matrix is not None: + position = transform_point(conversion_matrix, position) + right = transform_direction(conversion_matrix, right, (1.0, 0.0, 0.0)) + up = transform_direction(conversion_matrix, up, (0.0, 1.0, 0.0)) + forward = transform_direction(conversion_matrix, forward, (0.0, 0.0, 1.0)) + + return [ + [right[0], up[0], forward[0], position[0]], + [right[1], up[1], forward[1], position[1]], + [right[2], up[2], forward[2], position[2]], + [0.0, 0.0, 0.0, 1.0], + ] + + +def _position_from_matrix_rows(matrix_rows: list[list[float]]) -> tuple[float, float, float]: + return (matrix_rows[0][3], matrix_rows[1][3], matrix_rows[2][3]) + + +def _direction_from_matrix_rows( + matrix_rows: list[list[float]], + direction: tuple[float, float, float], + fallback: tuple[float, float, float], +) -> tuple[float, float, float]: + return transform_direction_rows(matrix_rows, direction, fallback) + + +def _blender_light_type(light_data: object) -> int: + light_type = getattr(light_data, "type", "") + if light_type == "SUN": + return LIGHT_TYPE_DIRECTIONAL + if light_type == "SPOT": + return LIGHT_TYPE_SPOT + if light_type == "AREA": + return LIGHT_TYPE_AREA + return LIGHT_TYPE_POINT + + +def _blender_light_radius(light_data: object, light_type: int) -> float: + if light_type == LIGHT_TYPE_AREA: + shape = getattr(light_data, "shape", "SQUARE") + if shape == "RECTANGLE": + return max(float(getattr(light_data, "size", 1.0)), float(getattr(light_data, "size_y", 1.0)), 0.001) + return max(float(getattr(light_data, "size", 1.0)), 0.001) + return max(float(getattr(light_data, "shadow_soft_size", 1.0)), 0.001) + + +def _blender_light_area_size(light_data: object) -> tuple[float, float]: + shape = getattr(light_data, "shape", "SQUARE") + width = max(float(getattr(light_data, "size", 1.0)), 0.001) + height = max(float(getattr(light_data, "size_y", width if shape == "RECTANGLE" else width)), 0.001) + return (width, height) + + +def _blender_light_source_exposure(light_data: object) -> float: + value = getattr(light_data, "exposure", 0.0) + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _blender_light_engine_intensity(light_data: object) -> float: + power = max(float(getattr(light_data, "energy", 1.0)), 0.0) + exposure = _blender_light_source_exposure(light_data) + return power * math.pow(2.0, exposure) + + +def _blender_light_color(light_data: object) -> tuple[float, float, float]: + color = getattr(light_data, "color", None) + if color is None: + return (1.0, 1.0, 1.0) + try: + return ( + clamp(float(color[0]), 0.0, 1.0), + clamp(float(color[1]), 0.0, 1.0), + clamp(float(color[2]), 0.0, 1.0), + ) + except (TypeError, ValueError, IndexError): + return (1.0, 1.0, 1.0) + + +def extract_scene_payload_from_objects( + objects: list[object], + *, + convert_orientation: bool = False, + source_orientation: str = "blender-native", + include_scene_payload: bool = True, +) -> tuple[list[ExportedLight], list[ExportedCamera]]: + blender_required() + if not include_scene_payload: + return [], [] + + conversion_matrix = make_export_orientation_matrix(source_orientation) if convert_orientation else None + lights: list[ExportedLight] = [] + cameras: list[ExportedCamera] = [] + + for obj in objects: + object_type = getattr(obj, "type", None) + if object_type == "LIGHT": + light_data = obj.data + light_type = _blender_light_type(light_data) + transform_rows = _semantic_light_transform_rows(obj, conversion_matrix, light_type) + spot_size = max(float(getattr(light_data, "spot_size", math.radians(45.0))), math.radians(0.1)) + spot_blend = clamp(float(getattr(light_data, "spot_blend", 0.15)), 0.0, 1.0) + outer_cone = math.degrees(spot_size) + inner_cone = max(0.1, outer_cone * (1.0 - spot_blend)) + lights.append( + ExportedLight( + entity_name=obj.name, + light_type=light_type, + color=_blender_light_color(light_data), + intensity=_blender_light_engine_intensity(light_data), + position=_position_from_matrix_rows(transform_rows), + radius=_blender_light_radius(light_data, light_type), + direction=_direction_from_matrix_rows(transform_rows, (0.0, 0.0, -1.0), (0.0, -1.0, 0.0)), + falloff=0.5, + right=_direction_from_matrix_rows(transform_rows, (1.0, 0.0, 0.0), (1.0, 0.0, 0.0)), + inner_cone=inner_cone, + up=_direction_from_matrix_rows(transform_rows, (0.0, 1.0, 0.0), (0.0, 1.0, 0.0)), + outer_cone=outer_cone, + area_size=_blender_light_area_size(light_data), + source_power=max(float(getattr(light_data, "energy", 1.0)), 0.0), + source_exposure=_blender_light_source_exposure(light_data), + local_transform_rows=transform_rows, + ) + ) + elif object_type == "CAMERA": + camera_data = obj.data + transform_rows = _semantic_camera_transform_rows(obj, conversion_matrix) + sensor_fit = getattr(camera_data, "sensor_fit", "AUTO") + sensor_width = max(float(getattr(camera_data, "sensor_width", 36.0)), 0.001) + sensor_height = max(float(getattr(camera_data, "sensor_height", 24.0)), 0.001) + aspect = sensor_width / sensor_height + if sensor_fit == "VERTICAL": + aspect = sensor_height / sensor_width + cameras.append( + ExportedCamera( + entity_name=obj.name, + position=_position_from_matrix_rows(transform_rows), + forward=_direction_from_matrix_rows(transform_rows, (0.0, 0.0, 1.0), (0.0, 0.0, 1.0)), + up=_direction_from_matrix_rows(transform_rows, (0.0, 1.0, 0.0), (0.0, 1.0, 0.0)), + right=_direction_from_matrix_rows(transform_rows, (1.0, 0.0, 0.0), (1.0, 0.0, 0.0)), + fov_y_degrees=math.degrees(float(getattr(camera_data, "angle_y", getattr(camera_data, "angle", math.radians(50.0))))), + near_clip=max(float(getattr(camera_data, "clip_start", 0.1)), 0.001), + far_clip=max(float(getattr(camera_data, "clip_end", 1000.0)), 0.001), + aspect_ratio=aspect, + local_transform_rows=transform_rows, + ) + ) + + return lights, cameras + + def triangulate_mesh(mesh_data: object) -> None: blender_required() bm = bmesh.new() @@ -1478,21 +1851,23 @@ def triangulate_mesh(mesh_data: object) -> None: def resolve_texture_from_socket(input_socket: object, asset_path: Path) -> Optional[ExportedTexture]: - return _resolve_texture_from_socket(input_socket, asset_path, visited_nodes=set()) + return _resolve_texture_from_socket(input_socket, asset_path, visited_nodes=set(), channel=TEXTURE_CHANNEL_R) -def _resolve_texture_from_socket(input_socket: object, asset_path: Path, visited_nodes: set[int]) -> Optional[ExportedTexture]: +def _resolve_texture_from_socket(input_socket: object, asset_path: Path, visited_nodes: set[int], channel: int) -> Optional[ExportedTexture]: if not getattr(input_socket, "is_linked", False): return None source_link = input_socket.links[0] source_node = source_link.from_node + source_socket = getattr(source_link, "from_socket", None) source_node_id = id(source_node) if source_node_id in visited_nodes: return None visited_nodes.add(source_node_id) if source_node.bl_idname == "ShaderNodeTexImage" and source_node.image is not None: + texture_channel = texture_channel_from_socket_name(getattr(source_socket, "name", ""), channel) image = source_node.image image_path = bpy.path.abspath(image.filepath, library=image.library) if bpy is not None else image.filepath texture_path = Path(image_path) @@ -1512,12 +1887,18 @@ def _resolve_texture_from_socket(input_socket: object, asset_path: Path, visited mip_count=1 if width > 0 and height > 0 else 0, source_path=texture_path, source_image_name=getattr(image, "name", None), + channel=texture_channel, ) + if source_node.bl_idname in {"ShaderNodeSeparateColor", "ShaderNodeSeparateRGB"}: + texture_channel = texture_channel_from_socket_name(getattr(source_socket, "name", ""), channel) + input_name = "Color" if source_node.bl_idname == "ShaderNodeSeparateColor" else "Image" + nested_input = source_node.inputs.get(input_name) + if nested_input is not None: + return _resolve_texture_from_socket(nested_input, asset_path, visited_nodes, texture_channel) + passthrough_input_names = { "ShaderNodeNormalMap": ["Color"], - "ShaderNodeSeparateColor": ["Color"], - "ShaderNodeSeparateRGB": ["Image"], "ShaderNodeRGBToBW": ["Color"], "NodeReroute": ["Input"], # Color-correction nodes — the texture passes through their Color input. @@ -1536,7 +1917,7 @@ def _resolve_texture_from_socket(input_socket: object, asset_path: Path, visited nested_input = source_node.inputs.get(input_name) if nested_input is None: continue - resolved = _resolve_texture_from_socket(nested_input, asset_path, visited_nodes) + resolved = _resolve_texture_from_socket(nested_input, asset_path, visited_nodes, channel) if resolved is not None: return resolved @@ -2005,6 +2386,8 @@ def extract_material(mesh_object: object, asset_path: Path) -> ExportedMaterial: roughness_texture=None, emissive_texture=None, occlusion_texture=None, + roughness_texture_channel=TEXTURE_CHANNEL_R, + metallic_texture_channel=TEXTURE_CHANNEL_R, ) principled = None @@ -2084,6 +2467,8 @@ def extract_material(mesh_object: object, asset_path: Path) -> ExportedMaterial: roughness_texture=roughness_texture, emissive_texture=emissive_texture, occlusion_texture=occlusion_texture, + roughness_texture_channel=roughness_texture.channel if roughness_texture is not None else TEXTURE_CHANNEL_R, + metallic_texture_channel=metallic_texture.channel if metallic_texture is not None else TEXTURE_CHANNEL_R, ) @@ -2639,6 +3024,22 @@ def extract_nodes( ) +def extract_scene_payload_from_current_scene( + *, + mesh_name: Optional[str], + convert_orientation: bool = False, + source_orientation: str = "blender-native", +) -> tuple[list[ExportedLight], list[ExportedCamera]]: + blender_required() + include_scene_payload = mesh_name is None + return extract_scene_payload_from_objects( + list(bpy.data.objects), + convert_orientation=convert_orientation, + source_orientation=source_orientation, + include_scene_payload=include_scene_payload, + ) + + def extract_nodes_from_objects( export_objects: list[object], asset_path: Path, @@ -2704,6 +3105,9 @@ def aggregate_world_corners(obj: object) -> list[tuple[float, float, float]]: export_object_ids = {obj.as_pointer() for obj in export_objects} nodes: list[ExportedNode] = [] for obj in export_objects: + if getattr(obj, "type", None) in {"LIGHT", "CAMERA"}: + continue + local_transform_rows = matrix_rows_from_blender(obj.matrix_local) if conversion_matrix is not None: local_transform_rows = transform_matrix_rows(local_transform_rows, conversion_matrix) @@ -2783,11 +3187,15 @@ def build_untold_file( output_path: Path, file_type_name: str, *, + exported_lights: Optional[list[ExportedLight]] = None, + exported_cameras: Optional[list[ExportedCamera]] = None, compress_geometry: bool = False, progress_callback: Optional[ProgressCallback] = None, ) -> bytes: if not exported_nodes: raise RuntimeError("No nodes were extracted for export") + exported_lights = exported_lights or [] + exported_cameras = exported_cameras or [] string_table = StringTableBuilder() textures: list[TextureRecord] = [] @@ -2795,6 +3203,8 @@ def build_untold_file( materials: list[MaterialRecord] = [] material_indices: dict[tuple[object, ...], int] = {} entities: list[EntityRecord] = [] + light_records: list[LightRecord] = [] + camera_records: list[CameraRecord] = [] meshes: list[MeshRecord] = [] skeletons: list[SkeletonRecord] = [] skeleton_joints: list[SkeletonJointRecord] = [] @@ -2860,6 +3270,8 @@ def add_material(material: ExportedMaterial) -> int: roughness_texture_index, emissive_texture_index, occlusion_texture_index, + material.roughness_texture_channel, + material.metallic_texture_channel, ) existing = material_indices.get(key) if existing is not None: @@ -2884,6 +3296,8 @@ def add_material(material: ExportedMaterial) -> int: roughness_texture_index=roughness_texture_index, emissive_texture_index=emissive_texture_index, occlusion_texture_index=occlusion_texture_index, + roughness_texture_channel=material.roughness_texture_channel, + metallic_texture_channel=material.metallic_texture_channel, ) ) return index @@ -2895,6 +3309,7 @@ def add_material(material: ExportedMaterial) -> int: ) entity_ids_by_name = {exported_node.entity_name: entity_id for entity_id, exported_node in enumerate(exported_nodes)} + next_scene_payload_entity_id = len(exported_nodes) total_nodes = len(exported_nodes) for entity_id, exported_node in enumerate(exported_nodes): @@ -2998,6 +3413,52 @@ def add_material(material: ExportedMaterial) -> int: if progress_callback is not None: progress_callback("Build records", entity_id + 1, total_nodes, exported_node.entity_name) + for exported_light in exported_lights: + entity_id = next_scene_payload_entity_id + next_scene_payload_entity_id += 1 + light_records.append( + LightRecord( + entity_id=entity_id, + name_offset=string_table.add(exported_light.entity_name), + light_type=exported_light.light_type, + flags=0, + color=exported_light.color, + intensity=exported_light.intensity, + position=exported_light.position, + radius=exported_light.radius, + direction=exported_light.direction, + falloff=exported_light.falloff, + right=exported_light.right, + inner_cone=exported_light.inner_cone, + up=exported_light.up, + outer_cone=exported_light.outer_cone, + area_size=exported_light.area_size, + source_power=exported_light.source_power, + source_exposure=exported_light.source_exposure, + local_transform_rows=exported_light.local_transform_rows, + ) + ) + + for exported_camera in exported_cameras: + entity_id = next_scene_payload_entity_id + next_scene_payload_entity_id += 1 + camera_records.append( + CameraRecord( + entity_id=entity_id, + name_offset=string_table.add(exported_camera.entity_name), + flags=0, + position=exported_camera.position, + forward=exported_camera.forward, + up=exported_camera.up, + right=exported_camera.right, + fov_y_degrees=exported_camera.fov_y_degrees, + near_clip=exported_camera.near_clip, + far_clip=exported_camera.far_clip, + aspect_ratio=exported_camera.aspect_ratio, + local_transform_rows=exported_camera.local_transform_rows, + ) + ) + if progress_callback is not None: progress_callback("Build chunks", 0, 1, output_path.name) string_chunk = string_table.data @@ -3016,6 +3477,16 @@ def add_material(material: ExportedMaterial) -> int: write_texture_record(texture_writer, texture_record) texture_chunk = texture_writer.data + light_writer = BinaryWriter() + for light_record in light_records: + write_light_record(light_writer, light_record) + light_chunk = light_writer.data + + camera_writer = BinaryWriter() + for camera_record in camera_records: + write_camera_record(camera_writer, camera_record) + camera_chunk = camera_writer.data + skeleton_writer = BinaryWriter() for skeleton in skeletons: write_skeleton_record(skeleton_writer, skeleton) @@ -3091,6 +3562,8 @@ def add_material(material: ExportedMaterial) -> int: (CHUNK_TYPES["skeleton_joint_table"], skeleton_joint_chunk, len(skeleton_joint_chunk), len(skeleton_joints), COMPRESSION_NONE), (CHUNK_TYPES["skin_table"], skin_chunk, len(skin_chunk), len(skins), COMPRESSION_NONE), (CHUNK_TYPES["skin_joint_mapping_table"], skin_mapping_chunk, len(skin_mapping_chunk), len(skin_joint_mappings), COMPRESSION_NONE), + (CHUNK_TYPES["light_table"], light_chunk, len(light_chunk), len(light_records), COMPRESSION_NONE), + (CHUNK_TYPES["camera_table"], camera_chunk, len(camera_chunk), len(camera_records), COMPRESSION_NONE), (CHUNK_TYPES["vertex_data"], vertex_payload, len(vertex_raw), 0, geo_compression), (CHUNK_TYPES["index_data"], index_payload, len(index_raw), 0, geo_compression), (CHUNK_TYPES["edge_index_data"], edge_index_raw, len(edge_index_raw), 0, COMPRESSION_NONE), @@ -3417,6 +3890,12 @@ def export_objects_to_untold( compress_geometry: bool = False, progress_callback: Optional[ProgressCallback] = None, ) -> dict[str, object]: + exported_lights, exported_cameras = extract_scene_payload_from_objects( + export_objects, + convert_orientation=convert_orientation, + source_orientation=source_orientation, + include_scene_payload=True, + ) exported_nodes = extract_nodes_from_objects( export_objects, source_asset_path, @@ -3433,6 +3912,8 @@ def export_objects_to_untold( exported_nodes, output_path, file_type_name, + exported_lights=exported_lights, + exported_cameras=exported_cameras, compress_geometry=compress_geometry, progress_callback=progress_callback, ) @@ -3460,6 +3941,8 @@ def export_objects_to_untold( "bytes_written": len(untold_bytes), "node_count": len(exported_nodes), "mesh_count": len(exported_meshes), + "light_count": len(exported_lights), + "camera_count": len(exported_cameras), "vertex_count": sum(exported_mesh.vertex_count for exported_mesh in exported_meshes), "index_count": sum(exported_mesh.index_count for exported_mesh in exported_meshes), } @@ -3544,6 +4027,11 @@ def main(argv: list[str]) -> int: ), ) progress.advance("Extract nodes", f"{len(exported_nodes)} node(s)") + exported_lights, exported_cameras = extract_scene_payload_from_current_scene( + mesh_name=args.mesh_name, + convert_orientation=args.convert_orientation, + source_orientation=args.source_orientation, + ) print(f"Staging {len(exported_nodes)} node(s) ...", flush=True) exported_nodes = stage_nodes_for_output(exported_nodes, output_path) progress.advance("Stage nodes", output_path.name) @@ -3552,6 +4040,8 @@ def main(argv: list[str]) -> int: exported_nodes, output_path, args.file_type, + exported_lights=exported_lights, + exported_cameras=exported_cameras, compress_geometry=args.compress_geometry, progress_callback=lambda stage, done, total, detail: progress.stage( stage, @@ -3564,6 +4054,7 @@ def main(argv: list[str]) -> int: exported_meshes = [exported_node.mesh for exported_node in exported_nodes if exported_node.mesh is not None] print(f"Wrote {output_path} ({len(untold_bytes)} bytes)") print(f"Nodes: {len(exported_nodes)}, Meshes: {len(exported_meshes)}") + print(f"Lights: {len(exported_lights)}, Cameras: {len(exported_cameras)}") print(f"Vertices: {sum(exported_mesh.vertex_count for exported_mesh in exported_meshes)}, indices: {sum(exported_mesh.index_count for exported_mesh in exported_meshes)}") if args.validate: # This sidecar is only for validation/debugging in engine-side tests.