diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index db206739..e53da92b 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -64,11 +64,11 @@ /* Begin PBXFileReference section */ 067C5C4E2AAD6D7700F8FBB3 /* VRMKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = VRMKit; path = ..; sourceTree = ""; }; 06E116422F277AEA00D74CA4 /* MacExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 06E116512F277D1700D74CA4 /* AliciaSolid.vrm */ = {isa = PBXFileReference; lastKnownFileType = file; name = AliciaSolid.vrm; path = ../../Tests/VRMKitTests/Assets/AliciaSolid.vrm; sourceTree = ""; }; + 06E116512F277D1700D74CA4 /* AliciaSolid.vrm */ = {isa = PBXFileReference; lastKnownFileType = file; name = AliciaSolid.vrm; path = ../../Tests/Assets/VRM/AliciaSolid.vrm; sourceTree = ""; }; 06F0BD6C2AAD81A30089488C /* WatchExample Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "WatchExample Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 245725D72146F47A003AA5D7 /* VRMExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VRMExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96FABF60E748F3EF7D574461 /* VisionExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VisionExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; - AF000001AF000001AF000001 /* VRM1_Constraint_Twist_Sample.vrm */ = {isa = PBXFileReference; lastKnownFileType = file; name = VRM1_Constraint_Twist_Sample.vrm; path = ../../Tests/VRMKitTests/Assets/VRM1_Constraint_Twist_Sample.vrm; sourceTree = ""; }; + AF000001AF000001AF000001 /* VRM1_Constraint_Twist_Sample.vrm */ = {isa = PBXFileReference; lastKnownFileType = file; name = VRM1_Constraint_Twist_Sample.vrm; path = ../../Tests/Assets/VRM/VRM1_Constraint_Twist_Sample.vrm; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ diff --git a/Package.swift b/Package.swift index 1be63f3b..3722ffb2 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,10 @@ let package = Package( .library(name: "VRMRealityKit", targets: ["VRMRealityKit"]) ], targets: [ - .target(name: "VRMKit"), + .target( + name: "VRMKit", + exclude: ["Extensions/MoreCodable/LICENSE"] + ), .target( name: "VRMKitRuntime", dependencies: ["VRMKit"] @@ -29,25 +32,26 @@ let package = Package( ), // Test-only helpers shared by the test targets. - .target(name: "VRMTestSupport", path: "Tests/VRMTestSupport"), + .target( + name: "VRMTestSupport", + path: "Tests/VRMTestSupport", + resources: [ + .copy("../Assets/GLTF"), + .copy("../Assets/VRM") + ] + ), .testTarget( name: "VRMKitTests", - dependencies: ["VRMKit", "VRMTestSupport"], - resources: [.copy("Assets/AliciaSolid.vrm"), .copy("Assets/Seed-san.vrm")] + dependencies: ["VRMKit", "VRMTestSupport"] ), .testTarget( name: "VRMSceneKitTests", - dependencies: ["VRMSceneKit"], - resources: [ - .copy("../VRMKitTests/Assets/AliciaSolid.vrm"), - .copy("../VRMKitTests/Assets/Seed-san.vrm") - ] + dependencies: ["VRMSceneKit", "VRMTestSupport"] ), .testTarget( name: "VRMRealityKitTests", - dependencies: ["VRMRealityKit", "VRMTestSupport"], - resources: [.copy("../VRMKitTests/Assets/AliciaSolid.vrm"), .copy("../VRMKitTests/Assets/Seed-san.vrm")] + dependencies: ["VRMRealityKit", "VRMTestSupport"] ), ] ) diff --git a/README.md b/README.md index 33a63354..0f49b6eb 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,8 @@ RealityKit constrains what the MToon renderer can express. Each case below logs -## Frame updates +
+Frame updates `VRMUpdateSystem` (a RealityKit `System` registered on load) calls `VRMEntity.update(deltaTime:)` on every render frame. To control the timing yourself, opt out and call it manually: @@ -215,6 +216,39 @@ vrmEntity.update(deltaTime: deltaTime) To run your own animation code in a guaranteed order relative to the VRM update (e.g. posing joints that the same frame's skinning should reflect), put it in a custom `System` declared with `SystemDependency.before(VRMUpdateSystem.self)`. +
+ +
+Render glTF / GLB + +VRMRealityKit can also render plain glTF assets (`.glb` and JSON `.gltf`, including external resources and data URIs). + +```swift +let entity: GLTFEntity = try GLTFEntityLoader(withURL: url).loadEntity() +content.add(entity) + +entity.animations // [GLTFAnimation] — index, name, duration +let controller = try entity.playAnimation(at: 0, loops: true) +controller.speed = 2 // a negative speed plays backwards +controller.seek(to: 0.5) +controller.stop() +``` + +`loadEntity()` renders the asset's default scene, and throws when the glTF names none; pick one with `loadEntity(withSceneIndex:)`. A `clone(recursive:)` copy shares the loaded meshes and materials but not the animation bindings, so load the scene again for a second animatable instance. + +### RealityKit renderer limitations + +The renderer builds RealityKit meshes and materials, so a few parts of glTF have no place to go: + +- Only triangle primitives are drawn; `POINTS` and `LINES` primitives are skipped. +- `COLOR_0` vertex colors are ignored: the `MeshResource.Part` buffers this renderer builds carry no vertex-color channel. +- One UV set per material: the first UV-accessed texture decides both the `TEXCOORD_n` set and the single `KHR_texture_transform` every texture of that material is sampled with. An asset that merely lists `KHR_texture_transform` in `extensionsUsed` renders through that approximation and logs it; one that lists it in `extensionsRequired` and gives a material's textures different transforms is rejected instead of drawn wrong. +- Tangents for a primitive without `TANGENT` are averaged from its UV gradients rather than generated with MikkTSpace, which the spec recommends, so a normal map baked against MikkTSpace can differ slightly along UV seams. +- Blend shapes morph `POSITION` only, since RealityKit blend shapes have no `NORMAL` / `TANGENT` channel. +- Skinning reads `JOINTS_0` / `WEIGHTS_0` only, so a vertex is driven by at most four joints; the further sets a glTF may carry (`JOINTS_1` and up) are ignored, which the spec allows and which can change the result of an animation. + +
+ # ToDo - [x] VRM 1.0 support @@ -225,7 +259,7 @@ To run your own animation code in a guaranteed order relative to the VRM update - [ ] Improve rendering quality - [ ] Animation support (vrma) - [ ] VRM editing function -- [ ] GLTF renderer support +- [x] glTF renderer / animation support (RealityKit) # Contributing diff --git a/Sources/VRMKit/BinaryGLTF.swift b/Sources/VRMKit/BinaryGLTF.swift index a98a6530..f6cb7fe1 100644 --- a/Sources/VRMKit/BinaryGLTF.swift +++ b/Sources/VRMKit/BinaryGLTF.swift @@ -5,8 +5,10 @@ import Foundation public struct BinaryGLTF { public let version: GLTF.Version - public let jsonData: GLTF /// chunk 0 - public let binaryBuffer: Data? /// chunk1 + /// Chunk 0. + public let jsonData: GLTF + /// Chunk 1. + public let binaryBuffer: Data? /// magic equals 0x46546C67. It is ASCII string glTF, and can be used to identify data as Binary glTF. static let magic: UInt32 = 0x46546C67 @@ -19,21 +21,16 @@ public struct BinaryGLTF { package extension BinaryGLTF { func bufferViewData(at index: Int, relativeTo rootDirectory: URL? = nil) throws -> (data: Data, stride: Int?) { - let bufferView = try jsonData.load(\.bufferViews, at: index) - let buffer = try bufferData(at: bufferView.buffer, relativeTo: rootDirectory) - let end = bufferView.byteOffset.addingReportingOverflow(bufferView.byteLength) - guard bufferView.byteOffset >= 0, bufferView.byteLength >= 0, - !end.overflow, end.partialValue <= buffer.count else { - throw VRMError._dataInconsistent( - "buffer view (offset: \(bufferView.byteOffset), length: \(bufferView.byteLength)) overruns its \(buffer.count) byte buffer" - ) - } - return (buffer.subdata(in: bufferView.byteOffset.. Data { - let gltfBuffer = try jsonData.load(\.buffers, at: index) - return try Data(buffer: gltfBuffer, relativeTo: rootDirectory, binaryBuffer: binaryBuffer) +extension BinaryGLTF { + /// Whether the data starts with the GLB magic and so should be parsed as a + /// binary glTF container rather than as JSON. + public static func isGLB(_ data: Data) -> Bool { + guard data.count >= 4 else { return false } + return data.prefix(4).withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) }.littleEndian == magic } } @@ -61,32 +58,59 @@ extension BinaryGLTF { ) } - let chunk0Length = try reader.readUInt32() - let chunk0Type = try reader.readUInt32() - guard ChunkType(rawValue: chunk0Type) == .json else { - throw VRMError.notSupportedChunkType(chunk0Type) - } - let jsonData = try reader.readData(count: Int(chunk0Length)) - let gltf = try JSONDecoder().decode(GLTF.self, from: jsonData) - // The GLB container version and the asset version are independent: a 2.x - // container can still declare an asset this parser does not implement. - guard gltf.asset.version.hasPrefix("2.") else { - throw VRMError._notSupported("glTF asset version \(gltf.asset.version) is not supported") - } - if let minVersion = gltf.asset.minVersion, minVersion != "2.0" { - throw VRMError._notSupported("glTF asset minVersion \(minVersion) is not supported") + // Chunk 0 holds the JSON, chunk 1 the optional BIN; every other chunk + // type is one this parser does not know and the spec says to skip. + var gltf: GLTF? + var binaryBuffer: Data? + var chunkIndex = 0 + while reader.bytesRead + 8 <= Int(length) { + let chunkLength = try reader.readUInt32() + let chunkType = try reader.readUInt32() + // Every chunk starts and ends on a 4 byte boundary, so with the 12 byte + // header and 8 byte chunk headers the payloads are multiples of 4 too. + guard chunkLength.isMultiple(of: 4) else { + throw VRMError._dataInconsistent( + "GLB chunk of \(chunkLength) bytes breaks the container's 4 byte alignment" + ) + } + guard reader.bytesRead + Int(chunkLength) <= Int(length) else { + throw VRMError._dataInconsistent( + "GLB chunk of \(chunkLength) bytes overruns the \(length) byte container" + ) + } + let chunkData = try reader.readData(count: Int(chunkLength)) + switch ChunkType(rawValue: chunkType) { + case .json: + guard chunkIndex == 0 else { + throw VRMError._dataInconsistent("the JSON chunk must be the first GLB chunk") + } + gltf = try JSONDecoder().decode(GLTF.self, from: chunkData) + case .bin: + guard chunkIndex == 1 else { + throw VRMError._dataInconsistent("the BIN chunk must be the second GLB chunk") + } + binaryBuffer = chunkData + case nil: + guard gltf != nil else { + throw VRMError.notSupportedChunkType(chunkType) + } + } + chunkIndex += 1 } - self.jsonData = gltf - if length > reader.bytesRead { - let chunk1Length = try reader.readUInt32() - let chunk1Type = try reader.readUInt32() - guard ChunkType(rawValue: chunk1Type) == .bin else { - throw VRMError.notSupportedChunkType(chunk1Type) - } - binaryBuffer = try reader.readData(count: Int(chunk1Length)) - } else { - binaryBuffer = nil + // Anything left inside the declared length belongs to no chunk, so the + // container does not describe its own contents. + guard reader.bytesRead == Int(length) else { + throw VRMError._dataInconsistent( + "GLB has \(Int(length) - reader.bytesRead) bytes left over after its last chunk" + ) } + + let jsonData = try gltf ??? ._dataInconsistent("GLB carries no JSON chunk") + // The GLB container version and the asset version are independent: a 2.x + // container can still declare an asset this parser does not implement. + try jsonData.validateSupportedAssetVersion() + self.jsonData = jsonData + self.binaryBuffer = binaryBuffer } } diff --git a/Sources/VRMKit/Extensions/Accessor+Data.swift b/Sources/VRMKit/Extensions/Accessor+Data.swift index 7143f8d6..9327d583 100644 --- a/Sources/VRMKit/Extensions/Accessor+Data.swift +++ b/Sources/VRMKit/Extensions/Accessor+Data.swift @@ -34,6 +34,30 @@ package extension GLTF.Accessor { return (componentsPerVector, bytesPerComponent, vectorSize) } + /// One component as a float. `normalized` accessors map integers onto [0, 1] + /// or [-1, 1], with the signed minimum clamped to -1 per spec. + func floatComponent(base: UnsafeRawPointer, offset: Int) -> Float { + switch componentType { + case .float: + return base.loadUnaligned(fromByteOffset: offset, as: Float.self) + case .unsignedByte: + let value = Float(base.load(fromByteOffset: offset, as: UInt8.self)) + return normalized ? value / Float(UInt8.max) : value + case .byte: + let value = Float(base.load(fromByteOffset: offset, as: Int8.self)) + return normalized ? Swift.max(-1, value / Float(Int8.max)) : value + case .unsignedShort: + let value = Float(base.loadUnaligned(fromByteOffset: offset, as: UInt16.self)) + return normalized ? value / Float(UInt16.max) : value + case .short: + let value = Float(base.loadUnaligned(fromByteOffset: offset, as: Int16.self)) + return normalized ? Swift.max(-1, value / Float(Int16.max)) : value + case .unsignedInt: + let value = Float(base.loadUnaligned(fromByteOffset: offset, as: UInt32.self)) + return normalized ? value / Float(UInt32.max) : value + } + } + /// The accessor's elements as tightly packed data: any buffer view stride is /// removed, an accessor without a buffer view yields the zeroes the spec /// defines for it, and a sparse substitution is applied on top. @@ -72,7 +96,6 @@ private extension GLTF.Accessor { func packedBaseData(vectorSize: Int, provider: BufferViewProvider) throws -> Data { guard let bufferView else { - // The spec defines a bufferView-less accessor as all zeroes. return try Data(zeroedElementCount: count, elementSize: vectorSize) } let source = try provider(bufferView) diff --git a/Sources/VRMKit/Extensions/Data+GLTF.swift b/Sources/VRMKit/Extensions/Data+GLTF.swift index 7a232f4e..9185572f 100644 --- a/Sources/VRMKit/Extensions/Data+GLTF.swift +++ b/Sources/VRMKit/Extensions/Data+GLTF.swift @@ -16,12 +16,48 @@ package extension Data { } init(gltfUrlString: String, relativeTo rootDirectory: URL?) throws { - if let base64Str = gltfUrlString.retrievedBase64EncodedString() { - self = try Data(base64Encoded: base64Str) ??? .dataInconsistent("failed to load base64 data") + if let data = try Data(dataURI: gltfUrlString) { + self = data } else { - let url = URL(fileURLWithPath: gltfUrlString, relativeTo: rootDirectory) - self = try Data(contentsOf: url) + self = try Data(contentsOf: URL(gltfUri: gltfUrlString, relativeTo: rootDirectory)) + } + } + + /// The bytes an RFC 2397 `data:` URI carries, or nil when the string is not + /// one. `;base64` is optional there: without it the data is percent-encoded. + init?(dataURI: String) throws { + guard dataURI.hasPrefix("data:") else { return nil } + let body = dataURI.dropFirst("data:".count) + guard let separator = body.firstIndex(of: ",") else { + throw VRMError._dataInconsistent("the data uri has no \",\" separating its media type from its data") + } + let payload = body[body.index(after: separator)...] + guard body[.. String? { - guard starts(with: "data:") else { return nil } - return components(separatedBy: ";base64,").last +private extension UInt8 { + /// The value of one ASCII hexadecimal digit, or nil for anything else. + var hexDigitValue: UInt8? { + switch self { + case UInt8(ascii: "0")...UInt8(ascii: "9"): return self - UInt8(ascii: "0") + case UInt8(ascii: "a")...UInt8(ascii: "f"): return self - UInt8(ascii: "a") + 10 + case UInt8(ascii: "A")...UInt8(ascii: "F"): return self - UInt8(ascii: "A") + 10 + default: return nil + } } } @@ -101,3 +142,36 @@ package extension Int { return end.partialValue } } + +package extension URL { + /// Resolves a glTF `uri` against the asset's directory. + /// + /// A `uri` is a URI reference, not a file path: its reserved characters are + /// percent-encoded, so `My%20Buffer.bin` names a file with a space in it. + /// Only local files are read — a glTF must not fetch resources off the + /// network on the caller's behalf. + init(gltfUri: String, relativeTo rootDirectory: URL?) throws { + // A uri that is not a valid URI reference is taken as a literal path, + // which is what exporters writing unencoded characters mean by it. + let uri = URL(string: gltfUri, relativeTo: rootDirectory) + guard let uri, uri.scheme != nil else { + // A relative uri names a file beside the glTF, so without that + // directory the only base left is the working directory of the + // process, which holds some unrelated file of the same name. + guard let rootDirectory else { + throw VRMError._dataInconsistent( + """ + the glTF uri \"\(gltfUri)\" is relative to the directory of the asset, which this document was loaded without; \ + load it from a URL, or pass the directory its resources live in as rootDirectory + """ + ) + } + self = URL(fileURLWithPath: uri?.relativePath ?? gltfUri, relativeTo: rootDirectory) + return + } + guard uri.isFileURL else { + throw VRMError._notSupported("the \(uri.scheme ?? "") scheme of the glTF uri \"\(gltfUri)\" is not loadable") + } + self = uri + } +} diff --git a/Sources/VRMKit/Extensions/PackedAccessor.swift b/Sources/VRMKit/Extensions/PackedAccessor.swift new file mode 100644 index 00000000..889173c8 --- /dev/null +++ b/Sources/VRMKit/Extensions/PackedAccessor.swift @@ -0,0 +1,144 @@ +import Foundation + +/// One glTF accessor expanded into tightly packed elements, ready to be read as +/// floats or as the unsigned integers glTF uses for indices and joint references. +/// +/// Expanding an accessor removes its buffer view's stride and applies its sparse +/// substitution, so it is done once here and every reader works off the result. +package struct PackedAccessor { + package let accessor: GLTF.Accessor + package let data: Data + package let componentsPerElement: Int + package let bytesPerComponent: Int + + package var count: Int { accessor.count } + package var componentType: GLTF.Accessor.ComponentType { accessor.componentType } + package var normalized: Bool { accessor.normalized } + + package init(accessor: GLTF.Accessor, bufferView provider: BufferViewProvider) throws { + let (componentsPerElement, bytesPerComponent, _) = accessor.components() + self.accessor = accessor + self.data = try accessor.packedData(bufferView: provider) + self.componentsPerElement = componentsPerElement + self.bytesPerComponent = bytesPerComponent + } + + /// Builds one value per accessor element. `make` receives a reader for the + /// element's components as floats, decoding normalized integer storage. + package func floatElements(_ type: GLTF.Accessor.`Type`, + make: (_ component: (Int) -> Float) -> Element) throws -> [Element] { + try validate(type) + return elements { base, elementOffset in + make { component in + accessor.floatComponent(base: base, offset: elementOffset + bytesPerComponent * component) + } + } + } + + /// The same, reading the components as the unsigned integers glTF defines for + /// indices and `JOINTS_n`. + package func unsignedElements(_ type: GLTF.Accessor.`Type`, + make: (_ component: (Int) -> UInt32) -> Element) throws -> [Element] { + try validate(type) + guard let reader = UnsignedComponentReader(componentType) else { + throw VRMError._dataInconsistent( + "expected an unsigned integer accessor, got \(componentType) components" + ) + } + return elements { base, elementOffset in + make { component in + reader.load(base: base, offset: elementOffset + bytesPerComponent * component) + } + } + } + + /// Every component of every element of a `type` accessor, in order. + package func floatComponents(_ type: GLTF.Accessor.`Type`) throws -> [Float] { + try validate(type) + var result = [Float](repeating: 0, count: count * componentsPerElement) + data.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + for index in result.indices { + result[index] = accessor.floatComponent(base: base, offset: index * bytesPerComponent) + } + } + return result + } + + private func validate(_ type: GLTF.Accessor.`Type`) throws { + guard accessor.type == type else { + throw VRMError._dataInconsistent("expected \(type) accessor, got \(accessor.type)") + } + } + + private func elements(_ make: (UnsafeRawPointer, Int) -> Element) -> [Element] { + var result: [Element] = [] + result.reserveCapacity(count) + data.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + for index in 0.. PackedAccessor { + if let cached = packedAccessors[index] { return cached } + let packed = try PackedAccessor(accessor: document.gltf.load(\.accessors, at: index), + bufferView: document.bufferViewProvider) + packedAccessors[index] = packed + return packed + } + + /// Expands a float-valued accessor of `type` element by element. `make` + /// receives a reader for the element's components. + package func floatElements(at index: Int, + type: GLTF.Accessor.`Type`, + make: (_ component: (Int) -> Float) -> Element) throws -> [Element] { + try accessor(at: index).floatElements(type, make: make) + } + + /// Every component of a `type` accessor, in order. + package func floatComponents(at index: Int, type: GLTF.Accessor.`Type`) throws -> [Float] { + try accessor(at: index).floatComponents(type) + } +} + +/// The component types glTF allows where a value is an unsigned integer. +private enum UnsignedComponentReader { + case unsignedByte + case unsignedShort + case unsignedInt + + init?(_ componentType: GLTF.Accessor.ComponentType) { + switch componentType { + case .unsignedByte: self = .unsignedByte + case .unsignedShort: self = .unsignedShort + case .unsignedInt: self = .unsignedInt + case .byte, .short, .float: return nil + } + } + + func load(base: UnsafeRawPointer, offset: Int) -> UInt32 { + switch self { + case .unsignedByte: return UInt32(base.load(fromByteOffset: offset, as: UInt8.self)) + case .unsignedShort: return UInt32(base.loadUnaligned(fromByteOffset: offset, as: UInt16.self)) + case .unsignedInt: return base.loadUnaligned(fromByteOffset: offset, as: UInt32.self) + } + } +} diff --git a/Sources/VRMKit/GLTFDocument.swift b/Sources/VRMKit/GLTFDocument.swift new file mode 100644 index 00000000..11a882af --- /dev/null +++ b/Sources/VRMKit/GLTFDocument.swift @@ -0,0 +1,71 @@ +import Foundation + +/// A parsed glTF asset together with the context needed to resolve its binary +/// resources — the GLB BIN chunk, external files and data URIs — so that they +/// stay resolvable after loading, e.g. for lazily decoded animation accessors. +public final class GLTFDocument { + public let gltf: GLTF + /// GLB BIN chunk (chunk 1), when the document came from a GLB container. + package let binaryBuffer: Data? + /// Base directory for external buffer / image URIs. + package let rootDirectory: URL? + + /// Decoded buffers, keyed by buffer index: resolving one re-reads an external + /// file or base64-decodes a whole data URI. + /// + /// Buffer *views* are deliberately not cached — they are copies out of these + /// buffers, and keeping them would hold the file's bytes a second time. + private var buffers: [Int: Data] = [:] + + package init(gltf: GLTF, binaryBuffer: Data?, rootDirectory: URL?) { + self.gltf = gltf + self.binaryBuffer = binaryBuffer + self.rootDirectory = rootDirectory + } + + /// Wraps an already-parsed GLB container. + public convenience init(binary: BinaryGLTF, rootDirectory: URL? = nil) { + self.init(gltf: binary.jsonData, binaryBuffer: binary.binaryBuffer, rootDirectory: rootDirectory) + } + + /// Wraps an already-decoded JSON glTF whose resources are external files or + /// data URIs resolved against `rootDirectory`. + public convenience init(gltf: GLTF, rootDirectory: URL? = nil) { + self.init(gltf: gltf, binaryBuffer: nil, rootDirectory: rootDirectory) + } +} + +package extension GLTFDocument { + func bufferData(at index: Int) throws -> Data { + if let cached = buffers[index] { return cached } + let gltfBuffer = try gltf.load(\.buffers, at: index) + let data = try Data(buffer: gltfBuffer, relativeTo: rootDirectory, binaryBuffer: binaryBuffer) + buffers[index] = data + return data + } + + /// ``bufferViewData(at:)`` as a ``BufferViewProvider``. + var bufferViewProvider: BufferViewProvider { + { [self] index in + let (data, stride) = try bufferViewData(at: index) + return (bufferView: data, stride: stride) + } + } + + func bufferViewData(at index: Int) throws -> (data: Data, stride: Int?) { + let bufferView = try gltf.load(\.bufferViews, at: index) + let buffer = try bufferData(at: bufferView.buffer) + // The declared `byteLength` bounds the buffer, not the size of the + // resource it came from: a longer external file and the padding of a GLB + // BIN chunk carry bytes no view may reach. + let limit = min(try gltf.load(\.buffers, at: bufferView.buffer).byteLength, buffer.count) + let end = bufferView.byteOffset.addingReportingOverflow(bufferView.byteLength) + guard bufferView.byteOffset >= 0, bufferView.byteLength >= 0, + !end.overflow, end.partialValue <= limit else { + throw VRMError._dataInconsistent( + "buffer view (offset: \(bufferView.byteOffset), length: \(bufferView.byteLength)) overruns its \(limit) byte buffer" + ) + } + return (buffer.subdata(in: bufferView.byteOffset.. GLTFDocument { + guard let url = Bundle.main.url(forResource: name, withExtension: nil) else { + throw URLError(.fileDoesNotExist) + } + return try load(withURL: url) + } + + /// Loads a `.glb` / `.gltf` file. External resources resolve relative to + /// the file's directory. + public func load(withURL url: URL) throws -> GLTFDocument { + let data = try Data(contentsOf: url) + return try load(withData: data, rootDirectory: url.deletingLastPathComponent()) + } + + /// Loads in-memory glTF data, sniffing the GLB magic to pick the container + /// format. `rootDirectory` is the base directory for external resources. + public func load(withData data: Data, rootDirectory: URL? = nil) throws -> GLTFDocument { + if BinaryGLTF.isGLB(data) { + return GLTFDocument(binary: try BinaryGLTF(data: data), rootDirectory: rootDirectory) + } + let gltf = try JSONDecoder().decode(GLTF.self, from: data) + try gltf.validateSupportedAssetVersion() + return GLTFDocument(gltf: gltf, rootDirectory: rootDirectory) + } +} diff --git a/Sources/VRMKit/VRM/Animation.swift b/Sources/VRMKit/VRM/Animation.swift index 2cfaff15..3b54d53b 100644 --- a/Sources/VRMKit/VRM/Animation.swift +++ b/Sources/VRMKit/VRM/Animation.swift @@ -11,26 +11,39 @@ extension GLTF { public let extras: CodableAny? public struct Channel: Codable { - let sampler: Int - let target: Target - let extensions: CodableAny? - let extras: CodableAny? + package let sampler: Int + package let target: Target + package let extensions: CodableAny? + package let extras: CodableAny? public struct Target: Codable { - let node: Int? - let path: String - let extensions: CodableAny? - let extras: CodableAny? + package let node: Int? + package let path: String + package let extensions: CodableAny? + package let extras: CodableAny? + + /// The animated property, typed for the runtime. Nil for paths this + /// library does not know, such as extension-defined ones. + package var targetPath: TargetPath? { + TargetPath(rawValue: path) + } + + package enum TargetPath: String { + case translation + case rotation + case scale + case weights + } } } public struct Sampler: Codable { - let input: Int + package let input: Int let _interpolation: Interpolation? - var interpolation: Interpolation { return _interpolation ?? .LINEAR } - let output: Int - let extensions: CodableAny? - let extras: CodableAny? + package var interpolation: Interpolation { return _interpolation ?? .LINEAR } + package let output: Int + package let extensions: CodableAny? + package let extras: CodableAny? private enum CodingKeys: String, CodingKey { case input case _interpolation = "interpolation" diff --git a/Sources/VRMKit/VRM/GLTF.swift b/Sources/VRMKit/VRM/GLTF.swift index dd4f4bf7..7a613724 100644 --- a/Sources/VRMKit/VRM/GLTF.swift +++ b/Sources/VRMKit/VRM/GLTF.swift @@ -14,8 +14,9 @@ public struct GLTF: Codable { public let meshes: [Mesh]? public let nodes: [Node]? public let samplers: [Sampler]? - let _scene: Int? - public var scene: Int { return _scene ?? 0 } + /// The default scene, when the asset names one. glTF leaves it out for + /// assets that are a library of nodes rather than something to render. + public let scene: Int? public let scenes: [Scene]? public let skins: [Skin]? public let textures: [Texture]? @@ -35,7 +36,7 @@ public struct GLTF: Codable { case meshes case nodes case samplers - case _scene = "scene" + case scene case scenes case skins case textures @@ -51,6 +52,16 @@ extension GLTF { } package extension GLTF { + /// Rejects assets this parser does not implement. + func validateSupportedAssetVersion() throws { + guard asset.version.hasPrefix("2.") else { + throw VRMError._notSupported("glTF asset version \(asset.version) is not supported") + } + if let minVersion = asset.minVersion, minVersion != "2.0" { + throw VRMError._notSupported("glTF asset minVersion \(minVersion) is not supported") + } + } + func load(_ keyPath: KeyPath) throws -> T { try self[keyPath: keyPath] ??? .keyNotFound(Self.description(of: keyPath)) } diff --git a/Sources/VRMKit/VRM/Mesh.swift b/Sources/VRMKit/VRM/Mesh.swift index 69881ba0..4c4bd637 100644 --- a/Sources/VRMKit/VRM/Mesh.swift +++ b/Sources/VRMKit/VRM/Mesh.swift @@ -45,12 +45,14 @@ extension GLTF { attributes = try container.decode(CodableDictionary.self, forKey: .attributes) indices = try container.decodeIfPresent(Int.self, forKey: .indices) material = try container.decodeIfPresent(Int.self, forKey: .material) - mode = (try? container.decode(Mode.self, forKey: .mode)) ?? .TRIANGLES + // The spec defaults mode to TRIANGLES only when the key is absent; + // a value outside 0...6 is malformed, not an omission. + mode = try container.decodeIfPresent(Mode.self, forKey: .mode) ?? .TRIANGLES targets = try container.decodeIfPresent([CodableDictionary].self, forKey: .targets)? .map { $0.rawValue.reduce(into: [:], { (result, value) in - // skip extra key (e.g. "extra": { "name": "..." }) - // skip -1 which means no key + // A VRM 0.x exporter writes -1 for "no accessor in this + // target", and objects where an index belongs. guard let intValue = value.value.intValue, intValue >= 0 else { return } result[value.key] = intValue }) diff --git a/Sources/VRMKit/VRM/Node.swift b/Sources/VRMKit/VRM/Node.swift index 7302b944..18288e98 100644 --- a/Sources/VRMKit/VRM/Node.swift +++ b/Sources/VRMKit/VRM/Node.swift @@ -14,7 +14,7 @@ extension GLTF { public let mesh: Int? let _rotation: Vector4? public var rotation: Vector4 { - return self._rotation ?? .zero + return self._rotation ?? .identity } let _scale: Vector3? public var scale: Vector3 { diff --git a/Sources/VRMKit/VRM/Vector4.swift b/Sources/VRMKit/VRM/Vector4.swift index ecc98d6b..4ffe6405 100644 --- a/Sources/VRMKit/VRM/Vector4.swift +++ b/Sources/VRMKit/VRM/Vector4.swift @@ -7,6 +7,11 @@ extension GLTF { public static var zero: Vector4 { return .init(x: 0, y: 0, z: 0, w: 0) } + + /// The identity rotation, which a glTF node's `rotation` defaults to. + public static var identity: Vector4 { + return .init(x: 0, y: 0, z: 0, w: 1) + } } } diff --git a/Sources/VRMKitRuntime/Extensions/SIMD+.swift b/Sources/VRMKitRuntime/Extensions/SIMD+.swift index 3bbb3754..c9a3526d 100644 --- a/Sources/VRMKitRuntime/Extensions/SIMD+.swift +++ b/Sources/VRMKitRuntime/Extensions/SIMD+.swift @@ -65,6 +65,14 @@ package extension SIMD2 where Scalar == Float { } package extension simd_quatf { + /// The unit quaternion with the same orientation, or identity when the vector + /// is degenerate. Decoding and interpolation both produce off-unit values. + var safelyNormalized: simd_quatf { + let lengthSquared = simd_dot(vector, vector) + guard lengthSquared > Float.ulpOfOne else { return quat_identity_float } + return simd_quatf(vector: vector / sqrt(lengthSquared)) + } + static func * (_ left: simd_quatf, _ right: SIMD3) -> SIMD3 { simd_act(left, right) } diff --git a/Sources/VRMKitRuntime/GLTFSampledTexture.swift b/Sources/VRMKitRuntime/GLTFSampledTexture.swift new file mode 100644 index 00000000..9fc89695 --- /dev/null +++ b/Sources/VRMKitRuntime/GLTFSampledTexture.swift @@ -0,0 +1,61 @@ +import Foundation +import simd +import VRMKit + +/// `KHR_texture_transform`-style UV transform (glTF top-left origin). +package struct GLTFUVTransform: Equatable { + package let scale: SIMD2 + package let offset: SIMD2 + package let rotation: Float + + package init(scale: SIMD2 = SIMD2(1, 1), + offset: SIMD2 = SIMD2(0, 0), + rotation: Float = 0) { + self.scale = scale + self.offset = offset + self.rotation = rotation + } +} + +/// How a material samples one texture through mesh UVs: which texture, which UV +/// set, and the UV transform, with `KHR_texture_transform` decoded. +package struct GLTFSampledTexture { + package let index: Int + /// UV set this texture samples, honoring a `KHR_texture_transform` override. + package let texCoord: Int + /// UV transform carried by the source format (`KHR_texture_transform` for + /// glTF / VRM 1.0, the Unity `_MainTex` scale/offset for VRM 0.x). + package let transform: GLTFUVTransform? + + package init(index: Int, texCoord: Int = 0, transform: GLTFUVTransform? = nil) { + self.index = index + self.texCoord = texCoord + self.transform = transform + } + + /// Builds a texture reference from a glTF texture info, decoding + /// `KHR_texture_transform` and its optional `texCoord` override. + package init(index: Int, texCoord: Int, extensions: CodableAny?) { + guard let transform = extensions?.dictionaryValue["KHR_texture_transform"] as? [String: Any] else { + self.init(index: index, texCoord: texCoord) + return + } + self.init(index: index, + texCoord: transform.index("texCoord") ?? texCoord, + transform: .init(scale: transform.simd2Value(forKey: "scale", default: SIMD2(1, 1)), + offset: transform.simd2Value(forKey: "offset", default: SIMD2(0, 0)), + rotation: transform.float("rotation") ?? 0)) + } + + package init(_ textureInfo: GLTF.TextureInfo) { + self.init(index: textureInfo.index, texCoord: textureInfo.texCoord, extensions: textureInfo.extensions) + } + + package init(_ textureInfo: GLTF.Material.NormalTextureInfo) { + self.init(index: textureInfo.index, texCoord: textureInfo.texCoord, extensions: textureInfo.extensions) + } + + package init(_ textureInfo: GLTF.Material.OcclusionTextureInfo) { + self.init(index: textureInfo.index, texCoord: textureInfo.texCoord, extensions: textureInfo.extensions) + } +} diff --git a/Sources/VRMKitRuntime/MToonMaterialDescriptor.swift b/Sources/VRMKitRuntime/MToonMaterialDescriptor.swift index cb2937ca..5f69352f 100644 --- a/Sources/VRMKitRuntime/MToonMaterialDescriptor.swift +++ b/Sources/VRMKitRuntime/MToonMaterialDescriptor.swift @@ -20,36 +20,10 @@ package struct MToonMaterialDescriptor { case screenCoordinates } - /// KHR_texture_transform-style UV transform (glTF top-left origin). - package struct UVTransform: Equatable { - package var scale: SIMD2 - package var offset: SIMD2 - package var rotation: Float - - package init(scale: SIMD2 = SIMD2(1, 1), - offset: SIMD2 = SIMD2(0, 0), - rotation: Float = 0) { - self.scale = scale - self.offset = offset - self.rotation = rotation - } - } - - package struct Texture { - package let index: Int - /// UV set this texture samples, honoring a `KHR_texture_transform` - /// `texCoord` override when present. - package let texCoord: Int - /// UV transform carried by the source format (`KHR_texture_transform` - /// for VRM 1.0, the Unity `_MainTex` scale/offset for VRM 0.x). - package let transform: UVTransform? - - package init(index: Int, texCoord: Int = 0, transform: UVTransform? = nil) { - self.index = index - self.texCoord = texCoord - self.transform = transform - } - } + /// Shared with the standard material paths; the aliases keep this + /// descriptor's vocabulary local. + package typealias UVTransform = GLTFUVTransform + package typealias Texture = GLTFSampledTexture package let baseColorFactor: SIMD4 package let emissiveFactor: SIMD3 @@ -192,28 +166,6 @@ private extension MToonMaterialDescriptor.OutlineWidthMode { } private extension MToonMaterialDescriptor.Texture { - /// Builds a texture reference from a glTF texture info, decoding - /// `KHR_texture_transform` (including its optional `texCoord` override). - init(index: Int, texCoord: Int, extensions: CodableAny?) { - guard let transform = extensions?.dictionaryValue["KHR_texture_transform"] as? [String: Any] else { - self.init(index: index, texCoord: texCoord) - return - } - self.init(index: index, - texCoord: transform.index("texCoord") ?? texCoord, - transform: .init(scale: transform.simd2Value(forKey: "scale", default: SIMD2(1, 1)), - offset: transform.simd2Value(forKey: "offset", default: SIMD2(0, 0)), - rotation: transform.float("rotation") ?? 0)) - } - - init(_ textureInfo: GLTF.TextureInfo) { - self.init(index: textureInfo.index, texCoord: textureInfo.texCoord, extensions: textureInfo.extensions) - } - - init(_ textureInfo: GLTF.Material.NormalTextureInfo) { - self.init(index: textureInfo.index, texCoord: textureInfo.texCoord, extensions: textureInfo.extensions) - } - init(_ textureInfo: GLTF.Material.MaterialExtensions.MaterialsMToon.MaterialsMToonTextureInfo) { self.init(index: textureInfo.index, texCoord: textureInfo.texCoord ?? 0, extensions: textureInfo.extensions) } diff --git a/Sources/VRMKitRuntime/VRM1NodeConstraintRuntime.swift b/Sources/VRMKitRuntime/VRM1NodeConstraintRuntime.swift index 64e37ef1..1bbfd28b 100644 --- a/Sources/VRMKitRuntime/VRM1NodeConstraintRuntime.swift +++ b/Sources/VRMKitRuntime/VRM1NodeConstraintRuntime.swift @@ -107,7 +107,7 @@ package enum VRMNodeConstraintRuntime { private static func slerpRest(_ rest: simd_quatf, _ constrained: simd_quatf, weight: Float) -> simd_quatf { - simd_slerp(normalized(rest), normalized(constrained), simd_clamp(weight, 0.0, 1.0)) + simd_slerp(rest.safelyNormalized, constrained.safelyNormalized, simd_clamp(weight, 0.0, 1.0)) } private static func fromToRotation(from rawFrom: SIMD3, @@ -133,12 +133,6 @@ package enum VRMNodeConstraintRuntime { return simd_quatf(angle: acos(dotValue), axis: axis) } - private static func normalized(_ quaternion: simd_quatf) -> simd_quatf { - let lengthSquared = simd_dot(quaternion.vector, quaternion.vector) - guard lengthSquared > Float.ulpOfOne else { return quat_identity_float } - return simd_quatf(vector: quaternion.vector / sqrt(lengthSquared)) - } - } private extension GLTF.Node.NodeExtensions.NodeConstraint.Constraint.RollConstraint.RollAxis { diff --git a/Sources/VRMRealityKit/CustomType/GLTFEntity.swift b/Sources/VRMRealityKit/CustomType/GLTFEntity.swift new file mode 100644 index 00000000..9d116f42 --- /dev/null +++ b/Sources/VRMRealityKit/CustomType/GLTFEntity.swift @@ -0,0 +1,261 @@ +#if canImport(RealityKit) +import Foundation +import RealityKit +import simd +import VRMKit + +/// Carries the loaded document on the entity, so the copies `clone(recursive:)` +/// makes still answer ``GLTFEntity/document``. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +struct GLTFComponent: Component { + let document: GLTFDocument + let sceneIndex: Int +} + +/// The glTF node a loaded entity was built from. The array index is a node's +/// canonical identity — `name` is optional and may repeat. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +public struct GLTFNodeComponent: Component { + public let nodeIndex: Int +} + +/// The glTF material a model entity renders with. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +struct GLTFMaterialIndexComponent: Component { + let materialIndex: Int +} + +/// The glTF skin a model entity is skinned by. It survives `clone(recursive:)`, +/// so a mesh built once and cloned per node still binds its own joints. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +struct GLTFSkinIndexComponent: Component { + let skinIndex: Int +} + +/// The root entity of a loaded glTF scene. +/// +/// Besides the entity graph it keeps what the animation runtime binds against: +/// the document, the node index → `Entity` mapping and the skin / morph bindings. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +public class GLTFEntity: Entity { + /// The glTF document this entity was loaded from. + /// + /// - Precondition: the entity came from a ``GLTFEntityLoader``, not from + /// ``init()``. + public var document: GLTFDocument { + guard let document = components[GLTFComponent.self]?.document else { + preconditionFailure("This GLTFEntity carries no document. Load it with GLTFEntityLoader.") + } + return document + } + + public var gltf: GLTF { + document.gltf + } + + /// Index into `gltf.scenes` this entity graph was built from. + public var sceneIndex: Int { + guard let index = components[GLTFComponent.self]?.sceneIndex else { + preconditionFailure("This GLTFEntity carries no document. Load it with GLTFEntityLoader.") + } + return index + } + + /// glTF node index → the entity built for it, for this scene. + private var nodeEntities: [Entity?] = [] + + /// Whether this entity carries the bindings the animation runtime drives. + /// + /// `clone(recursive:)` copies the entity graph and the document, but the + /// bindings point at the entities of the original, so a copy is not + /// animatable. Load the scene again to get one that is. + public private(set) var hasRuntimeBindings = false + + /// The entity built for the glTF node at `index`, or nil when the index is out + /// of range or the node is not part of this scene. + public func entity(forNodeAt index: Int) -> Entity? { + guard nodeEntities.indices.contains(index) else { return nil } + return nodeEntities[index] + } + + /// One skinned model and the joint entities that drive it. + struct SkinBinding { + let modelEntity: ModelEntity + let skeleton: MeshResource.Skeleton + let jointEntities: [Entity] + } + + private(set) var skinBindings: [SkinBinding] = [] + + /// The blend shapes one node's morph weights write to, and the number of + /// targets every weights array driving them has to carry. + struct MorphBinding { + var modelEntities: [ModelEntity] + let targetCount: Int + } + + /// glTF node index → the blend shapes a `weights` channel writes to. + private(set) var morphBindings: [Int: MorphBinding] = [:] + + // Backing store for the animation API (GLTFEntity+Animation.swift). + var animationMetadata: [GLTFAnimation]? + /// One decoder for the whole document, because samplers of different + /// animations routinely share an input accessor. + lazy var animationDecoder = GLTFAnimationDecoder(document: document) + var animationRuntimes: [Int: GLTFAnimationRuntime] = [:] + var activeAnimationControllers: [GLTFAnimationPlaybackController] = [] + + /// Registers the components this entity relies on, exactly once per process. + @MainActor private static let registerRealityKitTypes: Void = { + GLTFComponent.registerComponent() + GLTFNodeComponent.registerComponent() + GLTFMaterialIndexComponent.registerComponent() + GLTFSkinIndexComponent.registerComponent() + GLTFAnimationPlaybackComponent.registerComponent() + GLTFAnimationSystem.registerSystem() + }() + + init(document: GLTFDocument, sceneIndex: Int) { + super.init() + _ = Self.registerRealityKitTypes + components.set(GLTFComponent(document: document, sceneIndex: sceneIndex)) + } + + /// Also builds the copies `clone(recursive:)` returns, which inherit the + /// ``GLTFComponent`` but not the runtime bindings. + public required init() { + super.init() + _ = Self.registerRealityKitTypes + } + + func setNodeEntities(_ nodes: [Entity?]) { + nodeEntities = nodes + hasRuntimeBindings = true + } + + /// Records a skinned model. The pose is solved by ``updateSkinning()``, which + /// the loader calls once the entity graph the joints live in is complete. + func registerSkinBinding(modelEntity: ModelEntity, + skeleton: MeshResource.Skeleton, + jointEntities: [Entity]) { + skinBindings.append(SkinBinding(modelEntity: modelEntity, + skeleton: skeleton, + jointEntities: jointEntities)) + } + + func registerMorphBindings(forNodeAt nodeIndex: Int, modelEntities: [ModelEntity], targetCount: Int) { + let morphable = modelEntities.filter { $0.components.has(BlendShapeWeightsComponent.self) } + guard !morphable.isEmpty else { return } + morphBindings[nodeIndex, default: MorphBinding(modelEntities: [], targetCount: targetCount)] + .modelEntities.append(contentsOf: morphable) + } + + /// Registers an entity as a renderer of `materialIndex`. ``VRMEntity`` overrides + /// this to feed its MToon and expression machinery. + func registerMaterialBinding(modelEntity: ModelEntity, materialIndex: Int, loader: GLTFEntityLoader) {} + + /// Whether this entity already refreshes its skin pose once per frame on its + /// own, in which case the animation tick must not solve the same skeleton. + var refreshesSkinningPerFrame: Bool { false } + + /// Re-applies the skeletal pose of every skin binding from the current joint + /// entity transforms. + func updateSkinning() { + // Bindings sharing a skeleton and model world transform — a mesh and its + // outline twin, say — resolve to identical joint transforms. + var solved: [String: (modelWorld: simd_float4x4, transforms: JointTransforms)] = [:] + for binding in skinBindings { + let modelWorld = binding.modelEntity.transformMatrix(relativeTo: nil) + let transforms: JointTransforms + if let cached = solved[binding.skeleton.id], cached.modelWorld == modelWorld { + transforms = cached.transforms + } else { + transforms = jointTransforms(for: binding, modelWorld: modelWorld) + solved[binding.skeleton.id] = (modelWorld, transforms) + } + setSkinPose(transforms, for: binding) + } + } + + private func setSkinPose(_ transforms: JointTransforms, for binding: SkinBinding) { + let existing = binding.modelEntity.components[SkeletalPosesComponent.self] + var pose = existing?.poses[binding.skeleton.id] + ?? existing?.poses.default + ?? SkeletalPose(id: binding.skeleton.id, from: binding.skeleton) + pose.jointTransforms = transforms + + var component = existing ?? SkeletalPosesComponent(poses: [pose]) + component.poses[pose.id] = pose + component.poses.default = pose + binding.modelEntity.components.set(component) + } + + private func jointTransforms(for binding: SkinBinding, + modelWorld: simd_float4x4) -> JointTransforms { + let jointEntities = binding.jointEntities + let joints = binding.skeleton.joints + var transforms: [Transform] = [] + transforms.reserveCapacity(jointEntities.count) + + let modelWorldInverse = simd_inverse(modelWorld) + // Each joint's world matrix is also its children's parent matrix. + let jointWorlds = jointEntities.map { $0.transformMatrix(relativeTo: nil) } + + for index in 0.. Bool { + weights.enumerated().contains { targetIndex, weight in + targetIndex < set.count && set[targetIndex] != weight + } + } + guard current.contains(where: differs) else { return } + + var sets = current + for setIndex in sets.indices { + for (targetIndex, weight) in weights.enumerated() where targetIndex < sets[setIndex].count { + sets[setIndex][targetIndex] = weight + } + } + blendWeights = sets + } +} + +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +extension Entity { + /// Every `ModelEntity` in this entity's hierarchy, including itself. + var modelEntitiesInHierarchy: [ModelEntity] { + var result: [ModelEntity] = [] + var stack: [Entity] = [self] + while let entity = stack.popLast() { + if let modelEntity = entity as? ModelEntity { + result.append(modelEntity) + } + stack.append(contentsOf: entity.children) + } + return result + } +} +#endif diff --git a/Sources/VRMRealityKit/CustomType/VRMEntity.swift b/Sources/VRMRealityKit/CustomType/VRMEntity.swift index 52623bab..738739dd 100644 --- a/Sources/VRMRealityKit/CustomType/VRMEntity.swift +++ b/Sources/VRMRealityKit/CustomType/VRMEntity.swift @@ -7,11 +7,6 @@ import simd import VRMKit import VRMKitRuntime -@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) -struct VRMMaterialIndexComponent: Component { - let materialIndex: Int -} - /// Carries the loaded VRM on the entity, so the copies `clone(recursive:)` makes /// through `init()` still answer ``VRMEntity/vrm``. @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) @@ -25,7 +20,7 @@ struct VRMComponent: Component { /// the parent entity owns it, and ``VRMUpdateSystem`` animates it for as long as /// it stays in the scene. @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) -public final class VRMEntity: Entity { +public final class VRMEntity: GLTFEntity { private static let logger = Logger(subsystem: "dev.tattn.VRMKit", category: "MToon") /// The VRM this entity was loaded from. @@ -47,7 +42,6 @@ public final class VRMEntity: Entity { private var materialColorClips: [ExpressionKey: [MaterialColorBinding]] = [:] private var textureTransformClips: [ExpressionKey: [TextureTransformBinding]] = [:] private var firstPersonAnnotations: [FirstPersonAnnotation] = [] - private var skinBindings: [SkinBinding] = [] /// Everything the runtime tracks per glTF material. Keying this by material /// index — rather than copying it onto every entity — is what keeps the /// MToon parameter rows single-source. @@ -77,12 +71,6 @@ public final class VRMEntity: Entity { private var mtoonLightColor = SIMD3(1, 1, 1) private var mtoonAmbientColor = SIMD3(0, 0, 0) - struct SkinBinding { - let modelEntity: ModelEntity - let skeleton: MeshResource.Skeleton - let jointEntities: [Entity] - } - /// Registers the components and the system this entity relies on. RealityKit /// instantiates registered systems for every scene, including scenes that /// already exist, so registering on first use is enough; `static let` runs @@ -90,12 +78,11 @@ public final class VRMEntity: Entity { @MainActor private static let registerRealityKitTypes: Void = { VRMComponent.registerComponent() VRMUpdateComponent.registerComponent() - VRMMaterialIndexComponent.registerComponent() VRMUpdateSystem.registerSystem() }() - init(vrm: VRM) { - super.init() + init(vrm: VRM, document: GLTFDocument, sceneIndex: Int) { + super.init(document: document, sceneIndex: sceneIndex) _ = Self.registerRealityKitTypes components.set(VRMComponent(vrm: vrm)) components.set(VRMUpdateComponent()) @@ -123,6 +110,10 @@ public final class VRMEntity: Entity { } } + /// ``update(deltaTime:)`` ends in ``updateSkinning()``, so while the VRM + /// runtime drives this entity the animation tick must not solve it too. + override var refreshesSkinningPerFrame: Bool { isAutomaticUpdateEnabled } + func setUpHumanoid(nodes: [Entity?]) { switch vrm { case .v0: @@ -132,19 +123,18 @@ public final class VRMEntity: Entity { } } - /// Called once per entity, right after its node hierarchy is built. - func setUpBlendShapes(nodes: [Entity?], meshes: [Entity?], loader: VRMEntityLoader) throws { + /// Called once per entity, right after its node hierarchy is built. A VRM 0.x + /// bind names a mesh index, so it drives every entity `meshes` has for it. + func setUpBlendShapes(nodes: [Entity?], meshes: [Int: [Entity]], loader: VRMEntityLoader) throws { switch vrm { case .v0: blendShapeClips = vrm.blendShapeMaster.blendShapeGroups .map { group in let blendShapeBinding: [BlendShapeBinding] = group.binds? - .compactMap { - guard meshes.indices.contains($0.mesh), - let mesh = meshes[$0.mesh] else { - return nil + .flatMap { bind in + (meshes[bind.mesh] ?? []).map { + BlendShapeBinding(mesh: $0, index: bind.index, weight: bind.weight) } - return BlendShapeBinding(mesh: mesh, index: $0.index, weight: $0.weight) } ?? [] return BlendShapeClip(name: group.name, preset: BlendShapePreset(name: group.presetName), @@ -239,18 +229,18 @@ public final class VRMEntity: Entity { } } - func setUpFirstPerson(nodes: [Entity?], meshes: [Entity?]) { + func setUpFirstPerson(nodes: [Entity?], meshes: [Int: [Entity]]) { switch vrm { case .v0: - firstPersonAnnotations = vrm.firstPerson.meshAnnotations.compactMap { annotation in - guard meshes.indices.contains(annotation.mesh), - let mesh = meshes[annotation.mesh], - let type = FirstPersonAnnotationType(vrm0Flag: annotation.firstPersonFlag) else { - return nil + firstPersonAnnotations = vrm.firstPerson.meshAnnotations.flatMap { annotation in + guard let type = FirstPersonAnnotationType(vrm0Flag: annotation.firstPersonFlag) else { + return [FirstPersonAnnotation]() + } + return (meshes[annotation.mesh] ?? []).map { + FirstPersonAnnotation(entity: $0, + type: type, + hidesAutoInFirstPerson: false) } - return FirstPersonAnnotation(entity: mesh, - type: type, - hidesAutoInFirstPerson: false) } case .v1(let vrm1): let head = humanoid.node(for: .head) @@ -268,7 +258,7 @@ public final class VRMEntity: Entity { setFirstPersonRenderMode(.thirdPerson) } - func setUpNodeConstraints(gltfNodes: [GLTF.Node], loader: VRMEntityLoader) throws { + func setUpNodeConstraints(gltfNodes: [GLTF.Node], loader: GLTFEntityLoader) throws { guard case .v1 = vrm else { nodeConstraints = [] return @@ -299,7 +289,7 @@ public final class VRMEntity: Entity { nodeConstraints = try NodeConstraintBinding.ordered(bindings) } - func setUpSpringBones(loader: VRMEntityLoader) throws { + func setUpSpringBones(loader: GLTFEntityLoader) throws { var springBones: [VRMEntitySpringBone] = [] switch vrm { case .v0: @@ -355,19 +345,9 @@ public final class VRMEntity: Entity { self.springBones = springBones } - func registerSkinBinding(modelEntity: ModelEntity, - skeleton: MeshResource.Skeleton, - jointEntities: [Entity]) { - let binding = SkinBinding(modelEntity: modelEntity, - skeleton: skeleton, - jointEntities: jointEntities) - skinBindings.append(binding) - initializeSkinPose(for: binding) - } - /// Registers an entity as a renderer of `materialIndex`. The MToon parameter /// rows are resolved once per material, so every entity sharing it shares them. - func registerMaterialBinding(modelEntity: ModelEntity, materialIndex: Int, loader: VRMEntityLoader) { + override func registerMaterialBinding(modelEntity: ModelEntity, materialIndex: Int, loader: GLTFEntityLoader) { if materialStates[materialIndex] == nil { materialStates[materialIndex] = MaterialRuntimeState( mtoonParameters: try? loader.mtoonParameters(withMaterialIndex: materialIndex) @@ -430,68 +410,6 @@ public final class VRMEntity: Entity { updateMToonLightingRows() } - private func updateSkinning() { - // Bindings that share a skeleton and model world transform — a mesh and - // its MToon outline twin, or primitives of the same skinned mesh — - // resolve to identical joint transforms, so solve them once per frame. - var solved: [String: (modelWorld: simd_float4x4, transforms: JointTransforms)] = [:] - for binding in skinBindings { - let modelWorld = binding.modelEntity.transformMatrix(relativeTo: nil) - let transforms: JointTransforms - if let cached = solved[binding.skeleton.id], cached.modelWorld == modelWorld { - transforms = cached.transforms - } else { - transforms = jointTransforms(for: binding, modelWorld: modelWorld) - solved[binding.skeleton.id] = (modelWorld, transforms) - } - setSkinPose(transforms, for: binding) - } - } - - private func initializeSkinPose(for binding: SkinBinding) { - let modelWorld = binding.modelEntity.transformMatrix(relativeTo: nil) - setSkinPose(jointTransforms(for: binding, modelWorld: modelWorld), for: binding) - } - - private func setSkinPose(_ transforms: JointTransforms, for binding: SkinBinding) { - let existing = binding.modelEntity.components[SkeletalPosesComponent.self] - var pose = existing?.poses[binding.skeleton.id] - ?? existing?.poses.default - ?? SkeletalPose(id: binding.skeleton.id, from: binding.skeleton) - pose.jointTransforms = transforms - - var component = existing ?? SkeletalPosesComponent(poses: [pose]) - component.poses[pose.id] = pose - component.poses.default = pose - binding.modelEntity.components.set(component) - } - - private func jointTransforms(for binding: SkinBinding, - modelWorld: simd_float4x4) -> JointTransforms { - let jointEntities = binding.jointEntities - let joints = binding.skeleton.joints - var transforms: [Transform] = [] - transforms.reserveCapacity(jointEntities.count) - - let modelWorldInverse = simd_inverse(modelWorld) - // Each joint's world matrix is also its children's parent matrix, so - // resolve them once instead of walking the ancestor chain twice. - let jointWorlds = jointEntities.map { $0.transformMatrix(relativeTo: nil) } - - for index in 0.. Bool { diff --git a/Sources/VRMRealityKit/CustomType/VRMEntitySpringBoneColliderGroup.swift b/Sources/VRMRealityKit/CustomType/VRMEntitySpringBoneColliderGroup.swift index 2deac61f..7b270e6b 100644 --- a/Sources/VRMRealityKit/CustomType/VRMEntitySpringBoneColliderGroup.swift +++ b/Sources/VRMRealityKit/CustomType/VRMEntitySpringBoneColliderGroup.swift @@ -7,14 +7,14 @@ import VRMKit final class VRMEntitySpringBoneColliderGroup { let colliders: [Collider] - init(colliderGroup: VRM0.SecondaryAnimation.ColliderGroup, loader: VRMEntityLoader) throws { + init(colliderGroup: VRM0.SecondaryAnimation.ColliderGroup, loader: GLTFEntityLoader) throws { let node = try loader.node(withNodeIndex: colliderGroup.node) self.colliders = colliderGroup.colliders.map { Collider(node: node, collider: $0) } } init(colliderGroup: VRM1.SpringBone.ColliderGroup, springBone: VRM1.SpringBone, - loader: VRMEntityLoader) throws { + loader: GLTFEntityLoader) throws { let sourceColliders = springBone.colliders ?? [] self.colliders = try colliderGroup.colliders.compactMap { colliderIndex in guard sourceColliders.indices.contains(colliderIndex) else { return nil } @@ -42,7 +42,7 @@ final class VRMEntitySpringBoneColliderGroup { self.radius = Float(collider.radius) } - init(collider: VRM1.SpringBone.Collider, loader: VRMEntityLoader) throws { + init(collider: VRM1.SpringBone.Collider, loader: GLTFEntityLoader) throws { self.node = try loader.node(withNodeIndex: collider.node) if let sphere = collider.shape.sphere { self.offset = SIMD3(sphere.offset, default: .zero) diff --git a/Sources/VRMRealityKit/CustomType/VRMUpdateSystem.swift b/Sources/VRMRealityKit/CustomType/VRMUpdateSystem.swift index df97b1b1..6fbc32c5 100644 --- a/Sources/VRMRealityKit/CustomType/VRMUpdateSystem.swift +++ b/Sources/VRMRealityKit/CustomType/VRMUpdateSystem.swift @@ -22,13 +22,24 @@ struct VRMUpdateComponent: Component {} /// `System` with `SystemDependency.before(VRMUpdateSystem.self)`. @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) public struct VRMUpdateSystem: System { + /// Runs after the glTF animation tick, so the VRM runtime sees this frame's + /// animated pose. + public static var dependencies: [SystemDependency] { [.after(GLTFAnimationSystem.self)] } + private static let query = EntityQuery(where: .has(VRMUpdateComponent.self)) public init(scene: Scene) {} public func update(context: SceneUpdateContext) { for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) { - (entity as? VRMEntity)?.update(deltaTime: context.deltaTime) + guard let vrmEntity = entity as? VRMEntity else { continue } + // A clone inherits the marker component but not the runtime bindings + // `update(deltaTime:)` drives, so it has nothing left to tick. + guard vrmEntity.hasRuntimeBindings else { + vrmEntity.components.remove(VRMUpdateComponent.self) + continue + } + vrmEntity.update(deltaTime: context.deltaTime) } } } diff --git a/Sources/VRMRealityKit/EntityData.swift b/Sources/VRMRealityKit/EntityData.swift index 67aaa3ce..8054586b 100644 --- a/Sources/VRMRealityKit/EntityData.swift +++ b/Sources/VRMRealityKit/EntityData.swift @@ -5,39 +5,45 @@ import VRMKit @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) final class EntityData { - /// Loaded scenes, keyed by scene index. Each one owns its entity graph. - var entities: [VRMEntity?] - // `nodes` / `meshes` hold Entity *instances*, which belong to a single - // scene, so `beginScene()` clears them between loads. Every other cache - // below holds values or GPU resources that are safe to share. + // `nodes` / `sceneMeshes` hold the Entity *instances* of the scene being + // built, so `beginScene()` clears them between loads. Every other cache below + // holds values, GPU resources or clone templates, which are safe to share. var nodes: [Entity?] - var skins: [MeshResource.Skeleton?] - var skinJointRemaps: [[Int]?] - var meshes: [Entity?] - var accessors: [Any?] - var bufferViews: [Data?] = [] + /// glTF mesh index → the entities of this scene built from it, one per node. + var sceneMeshes: [Int: [Entity]] = [:] + /// One glTF mesh as rendered through one skin. A mesh used by both a skinned + /// and an unskinned node needs one template each. + struct MeshTemplateKey: Hashable { + let meshIndex: Int + let skinIndex: Int? + } + + /// Meshes built once and cloned per node. + var meshTemplates: [MeshTemplateKey: Entity] = [:] + /// One glTF skin resolved for RealityKit. + struct Skin { + let skeleton: MeshResource.Skeleton + /// glTF joint index → its index in ``skeleton``, which orders the joints + /// parents-first. + let jointIndexRemap: [Int] + } + + var skins: [Skin?] var materials: [Material?] = [] - var textures: [TextureResource?] = [] var images: [VRMImage?] = [] - init(vrm: GLTF) { - entities = Array(repeating: nil, count: vrm.scenes?.count ?? 0) - nodes = Array(repeating: nil, count: vrm.nodes?.count ?? 0) - skins = Array(repeating: nil, count: vrm.skins?.count ?? 0) - skinJointRemaps = Array(repeating: nil, count: vrm.skins?.count ?? 0) - meshes = Array(repeating: nil, count: vrm.meshes?.count ?? 0) - accessors = Array(repeating: nil, count: vrm.accessors?.count ?? 0) - bufferViews = Array(repeating: nil, count: vrm.bufferViews?.count ?? 0) - materials = Array(repeating: nil, count: vrm.materials?.count ?? 0) - textures = Array(repeating: nil, count: vrm.textures?.count ?? 0) - images = Array(repeating: nil, count: vrm.images?.count ?? 0) + init(gltf: GLTF) { + nodes = Array(repeating: nil, count: gltf.nodes?.count ?? 0) + skins = Array(repeating: nil, count: gltf.skins?.count ?? 0) + materials = Array(repeating: nil, count: gltf.materials?.count ?? 0) + images = Array(repeating: nil, count: gltf.images?.count ?? 0) } - /// Starts building a scene's entity graph, dropping the entity caches while - /// buffer views, accessors, materials, textures and skeletons stay warm. + /// Starts building a scene's entity graph, dropping the entities of the + /// previous one while every shared cache stays warm. func beginScene() { nodes = Array(repeating: nil, count: nodes.count) - meshes = Array(repeating: nil, count: meshes.count) + sceneMeshes = [:] } enum EntityDataError: Error { diff --git a/Sources/VRMRealityKit/GLTF2RealityKit/GLTF2RealityKit.swift b/Sources/VRMRealityKit/GLTF2RealityKit/GLTF2RealityKit.swift index 6e70c9e2..43f59502 100644 --- a/Sources/VRMRealityKit/GLTF2RealityKit/GLTF2RealityKit.swift +++ b/Sources/VRMRealityKit/GLTF2RealityKit/GLTF2RealityKit.swift @@ -2,6 +2,7 @@ import CoreGraphics import RealityKit import VRMKit +import VRMKitRuntime extension GLTF.Vector3 { var simd: SIMD3 { @@ -10,11 +11,10 @@ extension GLTF.Vector3 { } extension GLTF.Vector4 { + /// glTF requires a unit quaternion, so an off-unit one is renormalized and a + /// degenerate one falls back to identity instead of collapsing the node. var simdQuat: simd_quatf { - if x == 0 && y == 0 && z == 0 && w == 0 { - return simd_quatf(angle: 0, axis: SIMD3(0, 1, 0)) - } - return simd_quatf(ix: x, iy: y, iz: z, r: w) + simd_quatf(ix: x, iy: y, iz: z, r: w).safelyNormalized } } diff --git a/Sources/VRMRealityKit/GLTFAnimation/GLTFAnimationRuntime.swift b/Sources/VRMRealityKit/GLTFAnimation/GLTFAnimationRuntime.swift new file mode 100644 index 00000000..49b69176 --- /dev/null +++ b/Sources/VRMRealityKit/GLTFAnimation/GLTFAnimationRuntime.swift @@ -0,0 +1,220 @@ +#if canImport(RealityKit) +import Foundation +import RealityKit +import simd +import VRMKit + +/// Decodes animation accessors into typed keyframe values, off a cache because +/// exporters routinely share one input accessor across many samplers. +struct GLTFAnimationDecoder { + private let accessors: PackedAccessorCache + + init(document: GLTFDocument) { + accessors = PackedAccessorCache(document: document) + } + + /// A sampler input. The spec fixes it to FLOAT scalars that start at or after + /// zero and increase strictly, and ``GLTFKeyframeTrack`` reads that ordering + /// as given. + func times(at accessorIndex: Int) throws -> [Float] { + let accessor = try accessors.accessor(at: accessorIndex) + guard accessor.componentType == .float else { + throw VRMError._dataInconsistent( + "an animation sampler input must be a FLOAT accessor, got \(accessor.componentType)" + ) + } + let times = try accessor.floatComponents(.SCALAR) + guard times.first.map({ $0 >= 0 }) != false else { + throw VRMError._dataInconsistent("animation sampler input times must start at or after 0") + } + return times + } + + /// Morph target weights. Like rotations they are unit quantities, so the spec + /// also allows them in normalized integer storage. + func weights(at accessorIndex: Int) throws -> [Float] { + try output(at: accessorIndex, type: .SCALAR, allowsNormalizedIntegers: true) + } + + func vector3s(at accessorIndex: Int) throws -> [SIMD3] { + let floats = try output(at: accessorIndex, type: .VEC3, allowsNormalizedIntegers: false) + return stride(from: 0, to: floats.count - floats.count % 3, by: 3).map { + SIMD3(floats[$0], floats[$0 + 1], floats[$0 + 2]) + } + } + + /// Rotation output as quaternions. Only the keyframe *values* are unit + /// quaternions: a CUBICSPLINE output's tangents carry their slope in the length. + func quaternions(at accessorIndex: Int, + interpolation: GLTF.Animation.Sampler.Interpolation) throws -> [simd_quatf] { + let floats = try output(at: accessorIndex, type: .VEC4, allowsNormalizedIntegers: true) + let isSpline = interpolation == .CUBICSPLINE + return stride(from: 0, to: floats.count - floats.count % 4, by: 4).enumerated().map { element, offset in + let vector = SIMD4(floats[offset], floats[offset + 1], floats[offset + 2], floats[offset + 3]) + let quaternion = simd_quatf(vector: vector) + // Values only: normalized-integer storage rounds, so renormalize. + guard !isSpline || element % 3 == 1 else { return quaternion } + return quaternion.safelyNormalized + } + } + + /// A sampler output. Translations and scales are FLOAT-only; rotations and + /// weights may also arrive as the normalized bytes and shorts the spec + /// permits, which ``PackedAccessor`` decodes back to floats. UNSIGNED_INT is + /// never one of them: the spec keeps it for primitive indices. + private func output(at accessorIndex: Int, + type: GLTF.Accessor.`Type`, + allowsNormalizedIntegers: Bool) throws -> [Float] { + let accessor = try accessors.accessor(at: accessorIndex) + let isValid: Bool + switch accessor.componentType { + case .float: + isValid = true + case .byte, .unsignedByte, .short, .unsignedShort: + isValid = allowsNormalizedIntegers && accessor.normalized + case .unsignedInt: + isValid = false + } + guard isValid else { + throw VRMError._dataInconsistent( + "this animation sampler output must be a FLOAT\(allowsNormalizedIntegers ? " or normalized byte / short" : "") accessor, got \(accessor.normalized ? "normalized " : "")\(accessor.componentType)" + ) + } + return try accessor.floatComponents(type) + } + + /// Groups a weights output into one `[Float]` per keyframe element, which the + /// spec sizes by the morph target count of the mesh the channel drives. + static func weightGroups(scalars: [Float], groupCount: Int, targetCount: Int) throws -> [[Float]] { + guard targetCount > 0, scalars.count == groupCount * targetCount else { + throw VRMError._dataInconsistent( + "weights animation output holds \(scalars.count) values, not the \(groupCount) keyframe elements × \(targetCount) morph targets it drives" + ) + } + return (0..>? + var rotation: GLTFKeyframeTrack? + var scale: GLTFKeyframeTrack>? + } + + private struct WeightBinding { + let modelEntities: [ModelEntity] + let track: GLTFKeyframeTrack<[Float]> + } + + private struct TargetKey: Hashable { + let node: Int + let path: GLTF.Animation.Channel.Target.TargetPath + } + + private let transformBindings: [TransformBinding] + private let weightBindings: [WeightBinding] + + @MainActor + init(animation: GLTF.Animation, entity: GLTFEntity) throws { + let decoder = entity.animationDecoder + // Keyed by node index so all of a node's channels land in one binding. + var transforms: [Int: TransformBinding] = [:] + var weights: [WeightBinding] = [] + var drivenTargets: Set = [] + + for channel in animation.channels { + // Channels without a node target or with an unknown (extension) path + // are skipped, as the spec prescribes. + guard let nodeIndex = channel.target.node, + let path = channel.target.targetPath else { continue } + // At most one channel of an animation may drive a (node, path): + // which of two wins would be arbitrary, so reject rather than pick. + guard drivenTargets.insert(TargetKey(node: nodeIndex, path: path)).inserted else { + throw VRMError._dataInconsistent( + "two channels of this animation drive the \(path.rawValue) of node \(nodeIndex)" + ) + } + guard animation.samplers.indices.contains(channel.sampler) else { + throw VRMError._dataInconsistent("animation channel references sampler \(channel.sampler) of \(animation.samplers.count)") + } + let sampler = animation.samplers[channel.sampler] + let times = try decoder.times(at: sampler.input) + + if path == .weights { + guard let binding = entity.morphBindings[nodeIndex], !binding.modelEntities.isEmpty else { continue } + let scalars = try decoder.weights(at: sampler.output) + let perKeyframe = sampler.interpolation == .CUBICSPLINE ? 3 : 1 + let groups = try GLTFAnimationDecoder.weightGroups(scalars: scalars, + groupCount: times.count * perKeyframe, + targetCount: binding.targetCount) + weights.append(WeightBinding(modelEntities: binding.modelEntities, + track: try .init(times: times, + interpolation: sampler.interpolation, + values: groups))) + continue + } + + guard let target = entity.entity(forNodeAt: nodeIndex) else { continue } + var binding = transforms[nodeIndex] ?? TransformBinding(target: target) + switch path { + case .translation: + binding.translation = try .init(times: times, + interpolation: sampler.interpolation, + values: decoder.vector3s(at: sampler.output)) + case .rotation: + binding.rotation = try .init(times: times, + interpolation: sampler.interpolation, + values: decoder.quaternions(at: sampler.output, + interpolation: sampler.interpolation)) + case .scale: + binding.scale = try .init(times: times, + interpolation: sampler.interpolation, + values: decoder.vector3s(at: sampler.output)) + case .weights: + break // handled above + } + transforms[nodeIndex] = binding + } + + transformBindings = Array(transforms.values) + weightBindings = weights + } + + /// Poses the bound entities for `time`. + /// + /// - Returns: whether any node transform actually changed, so the caller can + /// skip re-solving skin poses for a held pose. + @MainActor + @discardableResult + func apply(at time: Float) -> Bool { + var movedTransforms = false + for binding in transformBindings { + // The current transform is the base, so paths this animation does not + // drive keep whatever else wrote them. + let current = binding.target.transform + var transform = current + if let value = binding.translation?.value(at: time) { transform.translation = value } + if let value = binding.rotation?.value(at: time) { transform.rotation = value } + if let value = binding.scale?.value(at: time) { transform.scale = value } + // Assigning `Entity.transform` invalidates the subtree's cached world + // transforms, so a held pose must not write at all. + guard transform != current else { continue } + binding.target.transform = transform + movedTransforms = true + } + for binding in weightBindings { + let weights = binding.track.value(at: time) + for modelEntity in binding.modelEntities { + modelEntity.applyMorphWeights(weights) + } + } + return movedTransforms + } +} +#endif diff --git a/Sources/VRMRealityKit/GLTFAnimation/GLTFEntity+Animation.swift b/Sources/VRMRealityKit/GLTFAnimation/GLTFEntity+Animation.swift new file mode 100644 index 00000000..fd7b3188 --- /dev/null +++ b/Sources/VRMRealityKit/GLTFAnimation/GLTFEntity+Animation.swift @@ -0,0 +1,247 @@ +#if canImport(RealityKit) +import Foundation +import RealityKit +import VRMKit + +/// Metadata of one glTF animation. `index` is its canonical identity; `name` is +/// optional and may repeat. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +public struct GLTFAnimation: Sendable { + public let index: Int + public let name: String? + public let duration: TimeInterval +} + +/// Controls one running glTF animation, in the spirit of RealityKit's +/// `AnimationPlaybackController`. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +@MainActor +public final class GLTFAnimationPlaybackController { + public let animation: GLTFAnimation + /// Playback rate multiplier. A negative rate plays backwards; starting a + /// playback with one begins at the animation's end rather than at 0. + public var speed: Float + /// While paused the pose holds and time does not advance. + public var isPaused = false + public let loops: Bool + public private(set) var time: TimeInterval + /// Set when a non-looping animation reaches its end or ``stop()`` is called. + public private(set) var isComplete = false + + /// Both are weak: the entity owns the runtime and the entity graph it poses, + /// so a controller the caller keeps past its playback holds neither alive. + private weak var runtime: GLTFAnimationRuntime? + private weak var entity: GLTFEntity? + + init(animation: GLTFAnimation, + runtime: GLTFAnimationRuntime, + entity: GLTFEntity, + loops: Bool, + speed: Float) { + self.animation = animation + self.runtime = runtime + self.entity = entity + self.loops = loops + self.speed = speed + self.time = speed < 0 ? animation.duration : 0 + } + + /// Jumps to `time` (clamped into the animation) and applies that pose + /// immediately, without resuming a completed animation. + /// + /// Animations started later still win over the seeked one, exactly as they + /// do on a render tick. + public func seek(to newTime: TimeInterval) { + time = min(max(newTime, 0), animation.duration) + entity?.applyPose(seekedBy: self) + } + + /// Ends playback, holding the current pose. + public func stop() { + markComplete() + entity?.pruneCompletedAnimations() + } + + func markComplete() { + isComplete = true + } + + @discardableResult + func advance(deltaTime: TimeInterval) -> Bool { + guard !isPaused, !isComplete else { return false } + let duration = animation.duration + // A zero-length animation holds a single pose, which looping cannot + // change: it completes rather than reapplying it for every frame to come. + guard duration > 0 else { + let moved = apply() + isComplete = true + return moved + } + time += deltaTime * TimeInterval(speed) + if loops { + time = time.truncatingRemainder(dividingBy: duration) + if time < 0 { time += duration } + } else if time >= duration || time < 0 { + time = min(max(time, 0), duration) + isComplete = true + } + return apply() + } + + /// Poses the model for the current time, reporting whether joints moved. + @discardableResult + func apply() -> Bool { + runtime?.apply(at: Float(time)) ?? false + } +} + +/// Marks a ``GLTFEntity`` with running animations, so ``GLTFAnimationSystem`` +/// only visits entities that actually need a tick. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +struct GLTFAnimationPlaybackComponent: Component {} + +/// Advances the running glTF animations of every ``GLTFEntity`` once per render +/// frame. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +public struct GLTFAnimationSystem: System { + private static let query = EntityQuery(where: .has(GLTFAnimationPlaybackComponent.self)) + + public init(scene: Scene) {} + + public func update(context: SceneUpdateContext) { + for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) { + (entity as? GLTFEntity)?.updateAnimations(deltaTime: context.deltaTime) + } + } +} + +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +extension GLTFEntity { + /// Metadata of the document's animations. Keyframe data stays untouched until + /// ``playAnimation(at:loops:speed:)``. + public var animations: [GLTFAnimation] { + if let cached = animationMetadata { return cached } + let metadata = (gltf.animations ?? []).enumerated().map { index, animation in + GLTFAnimation(index: index, name: animation.name, duration: duration(of: animation)) + } + animationMetadata = metadata + return metadata + } + + /// The animations carrying `name`. glTF names may repeat, so this can return + /// any number of them. + public func animations(named name: String) -> [GLTFAnimation] { + animations.filter { $0.name == name } + } + + /// Starts playing the animation at `index` and returns its controller. + /// + /// Multiple animations can run at once; channels targeting the same node or + /// morph target apply in playback-start order, so the one started last wins. + /// + /// - Throws: when this entity is a clone and so carries no runtime bindings, + /// when `index` is out of range, or when the animation's samplers violate + /// the glTF spec (non-increasing keyframe times, an output whose length + /// does not match its interpolation). + @discardableResult + public func playAnimation(at index: Int, loops: Bool = false, speed: Float = 1) throws -> GLTFAnimationPlaybackController { + guard hasRuntimeBindings else { + throw VRMError._notSupported( + "this entity is a copy of a loaded glTF scene and carries no animation bindings; load the scene again to animate it" + ) + } + let metadata = animations + guard metadata.indices.contains(index) else { + throw VRMError._dataInconsistent("animation index \(index) is out of range for \(metadata.count) animations") + } + let runtime = try animationRuntime(at: index) + let controller = GLTFAnimationPlaybackController(animation: metadata[index], + runtime: runtime, + entity: self, + loops: loops, + speed: speed) + activeAnimationControllers.append(controller) + components.set(GLTFAnimationPlaybackComponent()) + // The first frame is correct immediately, not one render tick later. + if controller.apply() { + flushSkinPose() + } + return controller + } + + /// Stops every running glTF animation, holding the current pose. + public func stopAnimations() { + for controller in activeAnimationControllers { + controller.markComplete() + } + pruneCompletedAnimations() + } + + func updateAnimations(deltaTime: TimeInterval) { + // A clone inherits the marker component but not the controllers. + guard !activeAnimationControllers.isEmpty else { + components.remove(GLTFAnimationPlaybackComponent.self) + return + } + var movedTransforms = false + for controller in activeAnimationControllers { + movedTransforms = controller.advance(deltaTime: deltaTime) || movedTransforms + } + // A paused or held pose leaves the skeleton where the last solve put it. + if movedTransforms, !refreshesSkinningPerFrame { + flushSkinPose() + } + pruneCompletedAnimations() + } + + /// Poses the model for a seek: the seeked animation first, then every + /// animation that outranks it, so a seek cannot break the playback order. + func applyPose(seekedBy controller: GLTFAnimationPlaybackController) { + // A stopped controller has left the list, so everything running outranks it. + let first = activeAnimationControllers.firstIndex { $0 === controller }.map { $0 + 1 } ?? 0 + var moved = controller.apply() + for outranking in activeAnimationControllers[first...] { + moved = outranking.apply() || moved + } + if moved { + flushSkinPose() + } + } + + func pruneCompletedAnimations() { + activeAnimationControllers.removeAll(where: \.isComplete) + if activeAnimationControllers.isEmpty { + components.remove(GLTFAnimationPlaybackComponent.self) + } + } + + /// Re-solves the skin pose from the current joint transforms. + func flushSkinPose() { + guard !skinBindings.isEmpty else { return } + updateSkinning() + } + + private func animationRuntime(at index: Int) throws -> GLTFAnimationRuntime { + if let cached = animationRuntimes[index] { return cached } + let animation = try gltf.load(\.animations, at: index) + let runtime = try GLTFAnimationRuntime(animation: animation, entity: self) + animationRuntimes[index] = runtime + return runtime + } + + /// The animation's length: the input accessors' spec-required `max` when + /// present, decoded input times otherwise. + private func duration(of animation: GLTF.Animation) -> TimeInterval { + var duration: Float = 0 + for sampler in animation.samplers { + guard let accessor = try? gltf.load(\.accessors, at: sampler.input) else { continue } + if let max = accessor.max?.first { + duration = Swift.max(duration, max) + } else if let last = try? animationDecoder.times(at: sampler.input).last { + duration = Swift.max(duration, last) + } + } + return TimeInterval(duration) + } +} +#endif diff --git a/Sources/VRMRealityKit/GLTFAnimation/GLTFKeyframeTrack.swift b/Sources/VRMRealityKit/GLTFAnimation/GLTFKeyframeTrack.swift new file mode 100644 index 00000000..a035a8f9 --- /dev/null +++ b/Sources/VRMRealityKit/GLTFAnimation/GLTFKeyframeTrack.swift @@ -0,0 +1,159 @@ +#if canImport(RealityKit) +import Foundation +import simd +import VRMKit +import VRMKitRuntime + +/// A value a glTF animation channel can interpolate. +protocol GLTFAnimatableValue { + static func lerp(_ from: Self, to: Self, fraction: Float) -> Self + /// glTF's cubic Hermite spline, where `duration` is the time between the + /// surrounding keyframes (the spec's `t_k+1 - t_k`). + static func cubic(value0: Self, + outTangent0: Self, + value1: Self, + inTangent1: Self, + fraction: Float, + duration: Float) -> Self +} + +/// The four scalar weights of glTF's cubic Hermite basis. +struct GLTFCubicWeights { + let value0: Float + let outTangent0: Float + let value1: Float + let inTangent1: Float + + init(fraction t: Float, duration: Float) { + let t2 = t * t + let t3 = t2 * t + value0 = 2 * t3 - 3 * t2 + 1 + outTangent0 = duration * (t3 - 2 * t2 + t) + value1 = -2 * t3 + 3 * t2 + inTangent1 = duration * (t3 - t2) + } +} + +/// Every float SIMD interpolates the same way, so one extension serves both the +/// vector channels and the quaternion's component-wise spline below. +extension SIMD where Scalar == Float { + static func lerp(_ from: Self, to: Self, fraction: Float) -> Self { + from + (to - from) * Self(repeating: fraction) + } + + static func cubic(value0: Self, outTangent0: Self, value1: Self, inTangent1: Self, + fraction: Float, duration: Float) -> Self { + let w = GLTFCubicWeights(fraction: fraction, duration: duration) + return value0 * Self(repeating: w.value0) + outTangent0 * Self(repeating: w.outTangent0) + + value1 * Self(repeating: w.value1) + inTangent1 * Self(repeating: w.inTangent1) + } +} + +extension SIMD3: GLTFAnimatableValue {} + +extension simd_quatf: GLTFAnimatableValue { + static func lerp(_ from: Self, to: Self, fraction: Float) -> Self { + // The spec asks for spherical linear interpolation, shortest path. + simd_slerp(from, to, fraction) + } + + static func cubic(value0: Self, outTangent0: Self, value1: Self, inTangent1: Self, + fraction: Float, duration: Float) -> Self { + // Per spec, the spline runs on the raw components and the result is normalized. + simd_quatf(vector: SIMD4.cubic(value0: value0.vector, + outTangent0: outTangent0.vector, + value1: value1.vector, + inTangent1: inTangent1.vector, + fraction: fraction, + duration: duration)).safelyNormalized + } +} + +/// Morph target weights: one Float per target, interpolated element-wise. +extension Array: GLTFAnimatableValue where Element == Float { + static func lerp(_ from: Self, to: Self, fraction: Float) -> Self { + zip(from, to).map { $0 + ($1 - $0) * fraction } + } + + static func cubic(value0: Self, outTangent0: Self, value1: Self, inTangent1: Self, + fraction: Float, duration: Float) -> Self { + let w = GLTFCubicWeights(fraction: fraction, duration: duration) + let count = Swift.min(value0.count, outTangent0.count, value1.count, inTangent1.count) + return (0.. { + let times: [Float] + let interpolation: GLTF.Animation.Sampler.Interpolation + /// LINEAR / STEP: one element per keyframe. CUBICSPLINE: in-tangent, value, + /// out-tangent per keyframe, in that order. + let values: [Value] + + var duration: Float { times[times.count - 1] } + + init(times: [Float], + interpolation: GLTF.Animation.Sampler.Interpolation, + values: [Value]) throws { + // CUBICSPLINE interpolates between two keyframes, so one is not enough. + let minimumKeyframes = interpolation == .CUBICSPLINE ? 2 : 1 + guard times.count >= minimumKeyframes else { + throw VRMError._dataInconsistent( + "a \(interpolation) animation sampler needs at least \(minimumKeyframes) keyframes, but has \(times.count)" + ) + } + guard zip(times, times.dropFirst()).allSatisfy({ $0 < $1 }) else { + throw VRMError._dataInconsistent("animation sampler input times must be strictly increasing") + } + let valuesPerKeyframe = interpolation == .CUBICSPLINE ? 3 : 1 + guard values.count == times.count * valuesPerKeyframe else { + throw VRMError._dataInconsistent( + "a \(interpolation) animation sampler with \(times.count) keyframes needs \(times.count * valuesPerKeyframe) output elements, but has \(values.count)" + ) + } + self.times = times + self.interpolation = interpolation + self.values = values + } + + private func keyframeValue(at index: Int) -> Value { + interpolation == .CUBICSPLINE ? values[index * 3 + 1] : values[index] + } + + func value(at time: Float) -> Value { + if times.count == 1 || time <= times[0] { return keyframeValue(at: 0) } + if time >= duration { return keyframeValue(at: times.count - 1) } + + // The largest keyframe k with times[k] <= time. + var low = 0 + var high = times.count - 1 + while low + 1 < high { + let mid = (low + high) / 2 + if times[mid] <= time { low = mid } else { high = mid } + } + let span = times[low + 1] - times[low] + let fraction = (time - times[low]) / span + + switch interpolation { + case .STEP: + return keyframeValue(at: low) + case .LINEAR: + return Value.lerp(keyframeValue(at: low), to: keyframeValue(at: low + 1), fraction: fraction) + case .CUBICSPLINE: + return Value.cubic(value0: values[low * 3 + 1], + outTangent0: values[low * 3 + 2], + value1: values[(low + 1) * 3 + 1], + inTangent1: values[(low + 1) * 3], + fraction: fraction, + duration: span) + } + } +} +#endif diff --git a/Sources/VRMRealityKit/GLTFEntityLoader.swift b/Sources/VRMRealityKit/GLTFEntityLoader.swift new file mode 100644 index 00000000..ed7dffc1 --- /dev/null +++ b/Sources/VRMRealityKit/GLTFEntityLoader.swift @@ -0,0 +1,1887 @@ +#if canImport(RealityKit) +import CoreGraphics +import Foundation +import RealityKit +import Metal +import OSLog +import VRMKit +import VRMKitRuntime + +/// Loads a plain glTF / GLB document into a RealityKit entity graph. +/// +/// ``VRMEntityLoader`` subclasses it to add the VRM-specific runtime on top. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +@MainActor +public class GLTFEntityLoader { + public let document: GLTFDocument + var gltf: GLTF { document.gltf } + let entityData: EntityData + /// Accessors expanded for this loader's meshes and skins, shared by the + /// primitives that reference the same one. + private let accessors: PackedAccessorCache + + /// Name given to the loaded root entity. Subclasses set it from model metadata. + var entityName: String? + weak var currentEntity: GLTFEntity? + static let logger = Logger(subsystem: "dev.tattn.VRMKit", category: "MToon") + static let gltfLogger = Logger(subsystem: "dev.tattn.VRMKit", category: "glTF") + + private var didValidateStructure = false + /// glTF node index → its parent, built and validated once by + /// ``validateStructure()`` and read by everything that walks upwards. + private var nodeParents: [Int: Int] = [:] + private var loggedLimitations: Set = [] + private var materialTexCoordCache: [Int: (selected: Int, isMixed: Bool)] = [:] + private var morphTargetCounts: [Int: Int] = [:] + + private func logOnce(_ key: String, _ message: @autoclosure () -> String) { + guard loggedLimitations.insert(key).inserted else { return } + let text = message() + Self.gltfLogger.warning("\(text, privacy: .public)") + } + + /// Textures decoded per semantic: RealityKit bakes the semantic into the + /// resource, so one glTF texture read as color and as a normal map is two. + private var textureCacheBySemantic: [TextureResource.Semantic: [Int: TextureResource]] = [:] + private var metallicRoughnessCache: [Int: (metal: TextureResource, rough: TextureResource)] = [:] + /// One glTF texture read through a scalar factor baked into its pixels. + private struct BakedTextureKey: Hashable { + let textureIndex: Int + let factor: Float + let semantic: TextureResource.Semantic + } + + private var bakedTextureCache: [BakedTextureKey: TextureResource] = [:] + private var samplerCache: [Int: MaterialParameters.Texture.Sampler] = [:] + private var fallbackTextureCache: [MToonTextureSlot.Fallback: TextureResource] = [:] + private var mtoonDescriptorCache: [Int: MToonMaterialDescriptor?] = [:] +#if !os(visionOS) + /// Everything derived from one MToon material. A non-nil state is what + /// "this material renders as MToon" means. + private struct MToonState { + let descriptor: MToonMaterialDescriptor + let parameters: MToonMaterialParameters + let parameterTexture: CustomMaterial.Texture + let library: MTLLibrary + } + + private var mtoonStateCache: [Int: MToonState?] = [:] + private var mtoonOutlineMaterialCache: [Int: Material?] = [:] +#endif + private var loggedMToonLibraryError = false + /// When `false`, MToon materials are not created and the loader falls back to Unlit / PBR materials. + /// visionOS always uses the fallback because `CustomMaterial` is unavailable there. + public let isMToonEnabled: Bool + /// Controls creation of MToon's inverted-hull outline entities. + /// visionOS does not create MToon outlines because `CustomMaterial` is unavailable there. + public let isOutlineEnabled: Bool + public init(document: GLTFDocument, + isMToonEnabled: Bool = true, + isOutlineEnabled: Bool = true) { + self.document = document + self.entityData = EntityData(gltf: document.gltf) + self.accessors = PackedAccessorCache(document: document) + self.isMToonEnabled = isMToonEnabled + self.isOutlineEnabled = isOutlineEnabled + } + + /// Loads a `.glb` / `.gltf` file. External resources resolve relative to + /// the file's directory. + public convenience init(withURL url: URL, + isMToonEnabled: Bool = true, + isOutlineEnabled: Bool = true) throws { + self.init(document: try GLTFLoader().load(withURL: url), + isMToonEnabled: isMToonEnabled, + isOutlineEnabled: isOutlineEnabled) + } + + /// Loads a bundled glTF resource. + public convenience init(named: String, + isMToonEnabled: Bool = true, + isOutlineEnabled: Bool = true) throws { + self.init(document: try GLTFLoader().load(named: named), + isMToonEnabled: isMToonEnabled, + isOutlineEnabled: isOutlineEnabled) + } + + /// Loads in-memory glTF data. `rootDirectory` is the base directory for + /// external resources. + public convenience init(withData data: Data, + rootDirectory: URL? = nil, + isMToonEnabled: Bool = true, + isOutlineEnabled: Bool = true) throws { + self.init(document: try GLTFLoader().load(withData: data, rootDirectory: rootDirectory), + isMToonEnabled: isMToonEnabled, + isOutlineEnabled: isOutlineEnabled) + } + + /// Loads the document's default scene. + /// + /// - Throws: when the glTF names no default `scene`; such an asset is a + /// library of nodes, so the caller picks a scene with + /// ``loadEntity(withSceneIndex:)``. + public func loadEntity() throws -> GLTFEntity { + let scene = try gltf.scene ??? ._dataInconsistent("this glTF has no default scene") + return try loadEntity(withSceneIndex: scene) + } + + /// Loads one scene of the glTF as its own entity graph. Every call builds a + /// new graph — even for the same scene — while resources are reused. + public func loadEntity(withSceneIndex index: Int) throws -> GLTFEntity { + try validateRequiredExtensions() + try validateStructure() + let gltfScene = try gltf.load(\.scenes, at: index) + entityData.beginScene() + + let entity = makeRootEntity(sceneIndex: index) + if let entityName { + entity.name = entityName + } + currentEntity = entity + defer { currentEntity = nil } + for node in gltfScene.nodes ?? [] { + // Attaching a node that already has a parent would reparent it away + // from that parent, making the graph depend on `scene.nodes` order. + if let parent = nodeParents[node] { + throw VRMError._dataInconsistent( + "scene \(index) names node \(node) as a root, but it is a child of node \(parent)" + ) + } + entity.addChild(try self.node(withNodeIndex: node)) + } + entity.setNodeEntities(entityData.nodes) + try didBuildScene(entity) + // A skin binding is registered while the graph around it is still being + // built, so the rest pose is only solvable once the graph is complete. + entity.updateSkinning() + + return entity + } + + /// The root entity a scene load fills in. + func makeRootEntity(sceneIndex: Int) -> GLTFEntity { + GLTFEntity(document: document, sceneIndex: sceneIndex) + } + + /// Called once per scene, after its node hierarchy and bindings are built. + func didBuildScene(_ entity: GLTFEntity) throws {} + + /// glTF extensions this renderer implements, to satisfy `extensionsRequired`. + /// + /// Not an override point outside this module: claiming an extension this + /// renderer cannot draw would only silence the check that rejects it. + public var supportedRequiredExtensions: Set { + var extensions: Set = ["KHR_materials_unlit", "KHR_texture_transform"] + // MToon renders through CustomMaterial and a precompiled Metal library, so + // a platform without either — visionOS, Mac Catalyst — cannot claim it, and + // neither can a loader with MToon turned off. +#if !os(visionOS) + if isMToonEnabled, MToonShaderLibraryLoader.resourceName != nil { + extensions.insert("VRMC_materials_mtoon") + } +#endif + return extensions + } + + /// Fails the load when the file requires an extension this renderer does not + /// implement, as the glTF spec demands, or leans on a required extension past + /// what this renderer implements of it. + func validateRequiredExtensions() throws { + if let unsupported = unsupportedRequiredExtensions().first { + throw VRMError._notSupported("this glTF requires the \(unsupported) extension") + } + if gltf.extensionsRequired?.contains("KHR_texture_transform") == true { + try validateTextureTransformsAreRenderable() + } + } + + /// RealityKit gives a material one UV transform, so `KHR_texture_transform` is + /// only fully implemented while a material's textures agree on theirs. + /// + /// An asset that merely *uses* the extension renders through the first + /// transform and logs the approximation; one that *requires* it is asking for + /// a result this renderer cannot draw, so it is rejected instead. + private func validateTextureTransformsAreRenderable() throws { + for (index, gltfMaterial) in (gltf.materials ?? []).enumerated() { + let transforms = sampledTextures(of: gltfMaterial).map { $0.transform ?? GLTFUVTransform() } + guard transforms.allSatisfy({ $0 == transforms.first }) else { + throw VRMError._notSupported( + "this glTF requires KHR_texture_transform, and material \(index) gives its textures different transforms, which this renderer cannot draw" + ) + } + } + } + + /// `extensionsRequired` entries outside ``supportedRequiredExtensions``. + func unsupportedRequiredExtensions() -> [String] { + (gltf.extensionsRequired ?? []).filter { !supportedRequiredExtensions.contains($0) } + } + + /// Rejects, once per document, the malformed node graphs and skins the rest + /// of the loader takes for granted: the spec guarantees the nodes form a + /// forest and that a skin names at least one joint, each of them once. + /// + /// Without this, a cyclic hierarchy would recurse forever and a repeated or + /// out-of-range joint would trap instead of throwing. + private func validateStructure() throws { + guard !didValidateStructure else { return } + let nodes = gltf.nodes ?? [] + + var parents: [Int: Int] = [:] + for (index, node) in nodes.enumerated() { + for child in node.children ?? [] { + guard nodes.indices.contains(child) else { + throw VRMError._dataInconsistent("node \(index) has a child \(child) of \(nodes.count) nodes") + } + guard parents.updateValue(index, forKey: child) == nil else { + throw VRMError._dataInconsistent("node \(child) is a child of more than one node") + } + } + } + nodeParents = parents + // With at most one parent each, the hierarchy is a forest unless walking + // up from a node returns to a node already on the way up. + var verified: Set = [] + for index in nodes.indices where !verified.contains(index) { + var chain: Set = [] + var current = index + while !verified.contains(current) { + guard chain.insert(current).inserted else { + throw VRMError._dataInconsistent("the node hierarchy is cyclic at node \(current)") + } + guard let parent = parents[current] else { break } + current = parent + } + verified.formUnion(chain) + } + + for (index, skin) in (gltf.skins ?? []).enumerated() { + guard !skin.joints.isEmpty else { + throw VRMError._dataInconsistent("skin \(index) names no joint") + } + var seen: Set = [] + for joint in skin.joints { + guard nodes.indices.contains(joint) else { + throw VRMError._dataInconsistent("skin \(index) has a joint \(joint) of \(nodes.count) nodes") + } + guard seen.insert(joint).inserted else { + throw VRMError._dataInconsistent("skin \(index) names node \(joint) as a joint twice") + } + } + } + + didValidateStructure = true + } + + func node(withNodeIndex index: Int) throws -> Entity { + if let cache = try entityData.load(\.nodes, index: index) { return cache } + + let entity = Entity() + // A skinned mesh may sit below one of its own joints, whose entity its + // skin binding then asks for mid-build. Publishing the entity before its + // subtree exists ends that recursion. + entityData.nodes[index] = entity + do { + try build(entity, forNodeAt: index) + } catch { + entityData.nodes[index] = nil + throw error + } + return entity + } + + private func build(_ entity: Entity, forNodeAt index: Int) throws { + let gltfNode = try gltf.load(\.nodes, at: index) + entity.name = gltfNode.name ?? "node_\(index)" + entity.components.set(GLTFNodeComponent(nodeIndex: index)) + entity.transform = transform(from: gltfNode) + + if let cameraIndex = gltfNode.camera { + try applyCamera(withCameraIndex: cameraIndex, to: entity) + } + + if let meshIndex = gltfNode.mesh { + let meshEntity = try mesh(withMeshIndex: meshIndex, skinIndex: gltfNode.skin) + entity.addChild(meshEntity) + let modelEntities = meshEntity.modelEntitiesInHierarchy + let targetCount = try morphTargetCount(ofMeshAt: meshIndex) + try applyInitialMorphWeights(of: gltfNode, + meshIndex: meshIndex, + targetCount: targetCount, + to: modelEntities) + currentEntity?.registerMorphBindings(forNodeAt: index, + modelEntities: modelEntities, + targetCount: targetCount) + } + + for child in gltfNode.children ?? [] { + entity.addChild(try node(withNodeIndex: child)) + } + } + + /// The number of morph targets the mesh at `index` renders with, which every + /// weights array driving it holds one weight for. + /// + /// A primitive declaring no target of its own does not take part: where a VRM + /// leaves them off, ``resolvedPrimitives(of:)`` has filled in the shared ones. + func morphTargetCount(ofMeshAt index: Int) throws -> Int { + if let cached = morphTargetCounts[index] { return cached } + var targetCount = 0 + for primitive in resolvedPrimitives(of: try gltf.load(\.meshes, at: index)) { + let count = primitive.targets?.count ?? 0 + guard count > 0 else { continue } + guard targetCount == 0 || targetCount == count else { + throw VRMError._dataInconsistent( + "the primitives of mesh \(index) declare \(targetCount) and \(count) morph targets, but a mesh's primitives must all declare the same number" + ) + } + targetCount = count + } + morphTargetCounts[index] = targetCount + return targetCount + } + + /// Applies the spec's starting morph state — `node.weights`, falling back to + /// `mesh.weights` — so the first frame renders correctly without animation. + /// + /// Both are sized by the mesh's morph target count; another length means the + /// file and this renderer disagree about what the weights stand for. + private func applyInitialMorphWeights(of gltfNode: GLTF.Node, + meshIndex: Int, + targetCount: Int, + to modelEntities: [ModelEntity]) throws { + func validated(_ weights: [Float]?, _ source: @autoclosure () -> String) throws -> [Float]? { + guard let weights else { return nil } + guard weights.count == targetCount else { + throw VRMError._dataInconsistent( + "\(source()) holds \(weights.count) weights but mesh \(meshIndex) has \(targetCount) morph targets" + ) + } + return weights + } + let meshWeights = try validated(gltf.load(\.meshes, at: meshIndex).weights, "mesh \(meshIndex)") + let nodeWeights = try validated(gltfNode.weights, "a node rendering mesh \(meshIndex)") + + guard let weights = nodeWeights ?? meshWeights, weights.contains(where: { $0 != 0 }) else { return } + for modelEntity in modelEntities where modelEntity.components.has(BlendShapeWeightsComponent.self) { + modelEntity.applyMorphWeights(weights) + } + } + + private func applyCamera(withCameraIndex index: Int, to entity: Entity) throws { + let gltfCamera = try gltf.load(\.cameras, at: index) + switch gltfCamera.type { + case .perspective: + let perspective = try gltfCamera.perspective ??? .keyNotFound("perspective") + let fovDegrees: Float + let fovOrientation: CameraFieldOfViewOrientation + if let aspectRatio = perspective.aspectRatio, aspectRatio > 0 { + let yFov = perspective.yfov + let xFov = 2 * atan(tan(yFov * 0.5) * aspectRatio) + fovDegrees = xFov * 180 / .pi + fovOrientation = .horizontal + } else { + fovDegrees = perspective.yfov * 180 / .pi + fovOrientation = .vertical + } + var component = PerspectiveCameraComponent(near: perspective.znear, + far: perspective.zfar ?? .infinity, + fieldOfViewInDegrees: fovDegrees) + component.fieldOfViewOrientation = fovOrientation + entity.components.set(component) + case .orthographic: + let orthographic = try gltfCamera.orthographic ??? .keyNotFound("orthographic") + var component = OrthographicCameraComponent() + component.near = orthographic.znear + component.far = orthographic.zfar + component.scale = orthographic.ymag + component.scaleDirection = .vertical + entity.components.set(component) + } + } + + /// The entity one node renders a glTF mesh through, one per call. + func mesh(withMeshIndex index: Int, skinIndex: Int?) throws -> Entity { + // A mesh is built once per skin it is used with and cloned per node: the + // clones share the `MeshResource` and carry their own pose and weights. + let meshEntity = try meshTemplate(withMeshIndex: index, skinIndex: skinIndex).clone(recursive: true) + try registerSkinBindings(in: meshEntity) + registerMaterialBindings(in: meshEntity) + entityData.sceneMeshes[index, default: []].append(meshEntity) + return meshEntity + } + + /// The clone source for one (mesh, skin) pair, which never joins a scene itself. + private func meshTemplate(withMeshIndex index: Int, skinIndex: Int?) throws -> Entity { + let key = EntityData.MeshTemplateKey(meshIndex: index, skinIndex: skinIndex) + if let cache = entityData.meshTemplates[key] { return cache } + let template = try makeMeshEntity(withMeshIndex: index, skinIndex: skinIndex) + entityData.meshTemplates[key] = template + return template + } + + private func makeMeshEntity(withMeshIndex index: Int, skinIndex: Int?) throws -> Entity { + let gltfMesh = try gltf.load(\.meshes, at: index) + let meshEntity = Entity() + meshEntity.name = gltfMesh.name ?? "mesh_\(index)" + + for primitive in resolvedPrimitives(of: gltfMesh) { + if let primitiveEntity = try modelEntity(withPrimitive: primitive, skinIndex: skinIndex) { + meshEntity.addChild(primitiveEntity) + } + } + return meshEntity + } + + /// The primitives a mesh is built from, as the document declares them. + /// ``VRMEntityLoader`` overrides it to reproduce VRM's morph target sharing. + func resolvedPrimitives(of mesh: GLTF.Mesh) -> [GLTF.Mesh.Primitive] { + mesh.primitives + } + + private func modelEntity(withPrimitive primitive: GLTF.Mesh.Primitive, skinIndex: Int?) throws -> Entity? { + guard supportsTriangles(primitive.mode) else { + logOnce("primitiveMode-\(primitive.mode)", """ + A \(primitive.mode) primitive was skipped; RealityKit meshes render triangles only. + """) + return nil + } + + let attributes = primitive.attributes.rawValue + guard let positionIndex = attributes[.POSITION] else { + throw VRMError._dataInconsistent("POSITION attribute is missing") + } + + let positions = try vector3s(positionIndex) + + // glTF requires every vertex attribute to hold as many elements as POSITION. + func vertexAttribute(_ key: GLTF.Mesh.Primitive.AttributeKey, + _ read: (Int) throws -> [Element]) throws -> [Element]? { + guard let accessorIndex = attributes[key] else { return nil } + let values = try read(accessorIndex) + guard values.count == positions.count else { + throw VRMError._dataInconsistent( + "\(key) has \(values.count) elements but POSITION has \(positions.count)" + ) + } + return values + } + + if attributes[.COLOR_0] != nil { + logOnce("COLOR_0", "COLOR_0 vertex colors are not applied; the mesh parts this renderer builds carry no vertex-color channel. The primitive renders without them.") + } + + let normals = try vertexAttribute(.NORMAL, vector3s) + // A primitive without NORMAL is flat shaded, and glTF has its TANGENTs + // ignored along with it: they were authored for the normals it omits. + let rawTangents = normals == nil ? nil : try vertexAttribute(.TANGENT, vector4s) + let texcoords = try vertexAttribute(texcoordAttributeKey(forMaterialIndex: primitive.material, + attributes: attributes), + vector2s) + // glTF requires every primitive of a skinned mesh to carry both skinning + // attributes, so a missing one is a malformed asset, not an unskinned mesh. + let skinning: (joints: [SIMD4], weights: [SIMD4], remap: [Int])? = try skinIndex.map { skinIndex in + guard let joints = try vertexAttribute(.JOINTS_0, jointIndices), + let weights = try vertexAttribute(.WEIGHTS_0, jointWeights) else { + throw VRMError._dataInconsistent( + "a primitive of a mesh skinned by skin \(skinIndex) has no JOINTS_0 / WEIGHTS_0 attribute" + ) + } + return (joints, weights, try skin(withSkinIndex: skinIndex).jointIndexRemap) + } + // Only POSITION morphs: RealityKit blend shapes have no NORMAL / TANGENT channel. + var targetOffsets: [[SIMD3]] = [] + if let targets = primitive.targets, !targets.isEmpty { + targetOffsets.reserveCapacity(targets.count) + for target in targets { + if let positionAccessor = target[.POSITION] { + let offsets = try vector3s(positionAccessor) + guard offsets.count == positions.count else { + throw VRMError._dataInconsistent("blend shape target count \(offsets.count) does not match vertex count \(positions.count)") + } + targetOffsets.append(offsets) + } else { + targetOffsets.append(Array(repeating: .zero, count: positions.count)) + } + } + } + + var indexData: [UInt32] + if let indicesAccessor = primitive.indices { + indexData = try indexValues(indicesAccessor) + } else { + indexData = (0..= positions.count { + throw VRMError._dataInconsistent( + "triangle index \(maxIndex) is out of range for \(positions.count) vertices" + ) + } + + var finalPositions = positions + var finalTexcoords = texcoords ?? [] + var finalJoints = skinning?.joints ?? [] + var finalWeights = skinning?.weights ?? [] + if normals == nil { + // Flat shading needs a normal per triangle corner, so every attribute + // is expanded along the triangle list and the index buffer with it. + let corners = indexData + func expanded(_ values: [Element]) -> [Element] { + values.isEmpty ? values : corners.map { values[Int($0)] } + } + finalPositions = expanded(finalPositions) + finalTexcoords = expanded(finalTexcoords) + finalJoints = expanded(finalJoints) + finalWeights = expanded(finalWeights) + targetOffsets = targetOffsets.map(expanded) + indexData = Array(0.. ModelEntity { + let entity = ModelEntity(mesh: mesh, materials: materials) + if let materialIndex = primitive.material { + entity.components.set(GLTFMaterialIndexComponent(materialIndex: materialIndex)) + } + if let blendShapeMapping { + entity.components.set(BlendShapeWeightsComponent(weightsMapping: blendShapeMapping)) + } + if let skinIndex, skinSkeleton != nil { + // The binding itself is registered per clone, once the entity is + // part of a scene and its joints exist. + entity.components.set(GLTFSkinIndexComponent(skinIndex: skinIndex)) + } + return entity + } + + let modelEntity = makeEntity(materials: [material]) + if let materialIndex = primitive.material, + let outlineMaterial = try mtoonOutlineMaterial(withMaterialIndex: materialIndex) { + let outlineEntity = makeEntity(materials: [outlineMaterial]) + outlineEntity.name = "\(modelEntity.name)_outline" + let container = Entity() + container.name = "\(modelEntity.name)_container" + container.addChild(outlineEntity) + container.addChild(modelEntity) + return container + } + return modelEntity + } + + /// The UV set this primitive's mesh feeds RealityKit. Custom meshes carry a + /// single UV channel, so the material's first UV-accessed texture decides it. + private func texcoordAttributeKey(forMaterialIndex materialIndex: Int?, + attributes: [GLTF.Mesh.Primitive.AttributeKey: Int]) -> GLTF.Mesh.Primitive.AttributeKey { + guard let materialIndex else { return .TEXCOORD_0 } + let resolved: (selected: Int, isMixed: Bool) + if let cached = materialTexCoordCache[materialIndex] { + resolved = cached + } else { + guard let gltfMaterial = try? gltf.load(\.materials, at: materialIndex) else { + return .TEXCOORD_0 + } + let textures = sampledTextures(of: gltfMaterial) + resolved = (textures.first?.texCoord ?? 0, textures.contains { $0.texCoord != textures.first?.texCoord }) + materialTexCoordCache[materialIndex] = resolved + } + let selected = resolved.selected + guard selected != 0 else { return .TEXCOORD_0 } + let isMixed = resolved.isMixed + let isAvailable = selected == 1 && attributes[.TEXCOORD_1] != nil + if isMixed || !isAvailable { + logOnce("texCoord-\(materialIndex)", """ + Material \(materialIndex) samples UV set \(selected)\(isMixed ? " among others" : ""); \ + RealityKit meshes carry one UV channel, so \ + \(isAvailable ? "that set is used for every texture" : "TEXCOORD_0 is used instead"). + """) + } + return isAvailable ? .TEXCOORD_1 : .TEXCOORD_0 + } + + private func supportsTriangles(_ mode: GLTF.Mesh.Primitive.Mode) -> Bool { + switch mode { + case .TRIANGLES, .TRIANGLE_STRIP, .TRIANGLE_FAN: + return true + case .POINTS, .LINES, .LINE_LOOP, .LINE_STRIP: + return false + } + } + + private func triangulatedIndices(for mode: GLTF.Mesh.Primitive.Mode, + indices: [UInt32]) throws -> [UInt32] { + switch mode { + case .TRIANGLES: + guard !indices.isEmpty, indices.count.isMultiple(of: 3) else { + throw VRMError._dataInconsistent( + "a TRIANGLES primitive needs a non-zero multiple of 3 indices, but has \(indices.count)" + ) + } + return indices + case .TRIANGLE_STRIP: + guard indices.count >= 3 else { + throw VRMError._dataInconsistent( + "a TRIANGLE_STRIP primitive needs at least 3 indices, but has \(indices.count)" + ) + } + var result: [UInt32] = [] + result.reserveCapacity((indices.count - 2) * 3) + for i in 0..<(indices.count - 2) { + let i0 = indices[i] + let i1 = indices[i + 1] + let i2 = indices[i + 2] + if i.isMultiple(of: 2) { + result.append(contentsOf: [i0, i1, i2]) + } else { + result.append(contentsOf: [i1, i0, i2]) + } + } + return result + case .TRIANGLE_FAN: + guard indices.count >= 3 else { + throw VRMError._dataInconsistent( + "a TRIANGLE_FAN primitive needs at least 3 indices, but has \(indices.count)" + ) + } + let base = indices[0] + var result: [UInt32] = [] + result.reserveCapacity((indices.count - 2) * 3) + for i in 1..<(indices.count - 1) { + result.append(contentsOf: [base, indices[i], indices[i + 1]]) + } + return result + case .POINTS, .LINES, .LINE_LOOP, .LINE_STRIP: + // Filtered out by supportsTriangles() before the indices are read. + throw VRMError._notSupported("\(mode) primitives have no triangles") + } + } + + /// The material a primitive renders with. ``VRMEntityLoader`` overrides it to + /// keep rendering a model whose material this renderer cannot build. + func primitiveMaterial(withMaterialIndex index: Int) throws -> Material { + try material(withMaterialIndex: index) + } + + func material(withMaterialIndex index: Int) throws -> Material { + if let cache = try entityData.load(\.materials, index: index) { return cache } + let (gltfMaterial, materialProperty) = try materialSource(withMaterialIndex: index) +#if !os(visionOS) + do { + if let state = try mtoonState(withMaterialIndex: index) { + let material = try customMToonMaterial(state) + entityData.materials[index] = material + return material + } + } catch { + // The fallback material is not MToon, so the state has to go with it. + discardMToonState(withMaterialIndex: index) + Self.logger.error("Failed to build the MToon material \(index, privacy: .public); falling back to Unlit / PBR: \(String(describing: error), privacy: .public)") + } +#endif + + let shaderName = materialProperty?.shader.lowercased() + let isMToon = try mtoonDescriptor(withMaterialIndex: index) != nil + let isUnlit = shaderName?.contains("unlit") == true || gltfMaterial.extensions?.materialsUnlit != nil + // MToon and Unlit variants are not PBR, so both render through UnlitMaterial. + let useUnlit = isMToon || isUnlit + let resolvedAlphaMode = GLTF.Material.AlphaMode(vrm0: materialProperty, + fallback: gltfMaterial.alphaMode) + let tint = gltfMaterial.pbrMetallicRoughness + .map { VRMColor(simd: SIMD4($0.baseColorFactor)) } ?? .white + + if useUnlit { + // RealityKit's tone mapping visibly darkens flat art, so opt out of it. + var material = UnlitMaterial(applyPostProcessToneMap: false) + if let pbr = gltfMaterial.pbrMetallicRoughness, + let baseTexture = pbr.baseColorTexture { + let textureParam = try materialTexture(withTextureIndex: baseTexture.index, semantic: .color) + material.color = .init(tint: tint, texture: textureParam) + } else { + material.color = .init(tint: tint) + } + applyAlphaMode(resolvedAlphaMode, alphaCutoff: gltfMaterial.alphaCutoff, to: &material) + if gltfMaterial.doubleSided { + material.faceCulling = .none + } + material.textureCoordinateTransform = standardTextureTransform(withMaterialIndex: index, of: gltfMaterial) + entityData.materials[index] = material + return material + } + + var material = PhysicallyBasedMaterial() + if let pbr = gltfMaterial.pbrMetallicRoughness { + if let baseTexture = pbr.baseColorTexture { + let textureParam = try materialTexture(withTextureIndex: baseTexture.index, semantic: .color) + material.baseColor = .init(tint: tint, texture: textureParam) + } else { + material.baseColor = .init(tint: tint) + } + + if let metallicTexture = pbr.metallicRoughnessTexture { + // glTF multiplies the sampled channel by its factor, which is + // what RealityKit's texture-plus-scale pair does. + let textures = try metallicRoughnessTextures(withTextureIndex: metallicTexture.index) + material.metallic = .init(scale: pbr.metallicFactor, texture: textures.metal) + material.roughness = .init(scale: pbr.roughnessFactor, texture: textures.rough) + } else { + material.metallic = .init(floatLiteral: pbr.metallicFactor) + material.roughness = .init(floatLiteral: pbr.roughnessFactor) + } + } else { + material.baseColor = .init(tint: tint) + material.metallic = .init(floatLiteral: 1.0) + material.roughness = .init(floatLiteral: 1.0) + } + + if let normalTexture = gltfMaterial.normalTexture { + material.normal.texture = try normalTextureParameter(normalTexture) + } + + if let occlusionTexture = gltfMaterial.occlusionTexture { + material.ambientOcclusion.texture = try occlusionTextureParameter(occlusionTexture) + } + + let emissiveFactor = gltfMaterial.emissiveFactor + let emissiveTint = VRMColor(red: CGFloat(emissiveFactor.r), + green: CGFloat(emissiveFactor.g), + blue: CGFloat(emissiveFactor.b), + alpha: 1) + let hasEmissiveTint = emissiveFactor.r != 0 || emissiveFactor.g != 0 || emissiveFactor.b != 0 + if let emissiveTexture = gltfMaterial.emissiveTexture { + let textureParam = try materialTexture(withTextureIndex: emissiveTexture.index, semantic: .color) + material.emissiveColor = .init(color: emissiveTint, + texture: textureParam) + } else if hasEmissiveTint { + material.emissiveColor = .init(color: emissiveTint) + } + + applyAlphaMode(resolvedAlphaMode, alphaCutoff: gltfMaterial.alphaCutoff, to: &material) + if gltfMaterial.doubleSided { + material.faceCulling = .none + } + material.textureCoordinateTransform = standardTextureTransform(withMaterialIndex: index, of: gltfMaterial) + + entityData.materials[index] = material + return material + } + + /// The textures a standard (non-MToon) material samples through mesh UVs, in + /// the order the glTF material declares them. + private func sampledTextures(of gltfMaterial: GLTF.Material) -> [GLTFSampledTexture] { + var textures: [GLTFSampledTexture] = [] + if let pbr = gltfMaterial.pbrMetallicRoughness { + if let info = pbr.baseColorTexture { textures.append(GLTFSampledTexture(info)) } + if let info = pbr.metallicRoughnessTexture { textures.append(GLTFSampledTexture(info)) } + } + if let info = gltfMaterial.normalTexture { textures.append(GLTFSampledTexture(info)) } + if let info = gltfMaterial.occlusionTexture { textures.append(GLTFSampledTexture(info)) } + if let info = gltfMaterial.emissiveTexture { textures.append(GLTFSampledTexture(info)) } + return textures + } + + /// The `KHR_texture_transform` a material renders with. RealityKit gives a + /// material one UV transform, so the first UV-accessed texture's wins. + private func selectedUVTransform(withMaterialIndex index: Int, + textures: [GLTFSampledTexture]) -> GLTFUVTransform { + let selected = textures.first?.transform ?? GLTFUVTransform() + if textures.contains(where: { ($0.transform ?? GLTFUVTransform()) != selected }) { + logOnce("uvTransform-\(index)", """ + Material \(index) has per-texture KHR_texture_transform values; \ + RealityKit applies the first UV-accessed transform to all of its textures. + """) + } + return selected + } + + /// Converts `KHR_texture_transform` into RealityKit's `textureCoordinateTransform`. + /// + /// Only the rotation direction mirrors — offset and scale already act from + /// the corner the extension measures from — which is what + /// `TextureTransformRenderingTests` checks against what RealityKit draws. + private func standardTextureTransform(withMaterialIndex index: Int, + of gltfMaterial: GLTF.Material) -> MaterialParameterTypes.TextureCoordinateTransform { + let transform = selectedUVTransform(withMaterialIndex: index, + textures: sampledTextures(of: gltfMaterial)) + return MaterialParameterTypes.TextureCoordinateTransform(offset: transform.offset, + scale: transform.scale, + rotation: -transform.rotation) + } + +#if !os(visionOS) + private func customMToonMaterial(_ state: MToonState) throws -> Material { + let mtoon = state.descriptor + let surface = CustomMaterial.SurfaceShader(named: "mtoonSurface", in: state.library) + var material = try CustomMaterial(surfaceShader: surface, lightingModel: .unlit) + // MToon needs more textures than CustomMaterial has semantic channels, so the + // extra slots ride on unrelated ones; MToon.metal reads them back the same way. + material.baseColor = .init(tint: .white, texture: try mtoonTexture(mtoon, slot: .base)) + material.roughness.texture = try mtoonTexture(mtoon, slot: .shade) + material.specular.texture = try mtoonTexture(mtoon, slot: .shadingShift) + material.metallic.texture = try mtoonTexture(mtoon, slot: .matcap) + material.normal.texture = try mtoonTexture(mtoon, slot: .normal) + material.emissiveColor = .init(color: .white, texture: try mtoonTexture(mtoon, slot: .emissive)) + material.clearcoatRoughness.texture = try mtoonTexture(mtoon, slot: .rim) + material.clearcoat.texture = try mtoonTexture(mtoon, slot: .outlineWidth) + material.ambientOcclusion.texture = try mtoonTexture(mtoon, slot: .uvAnimationMask) + + applyAlphaMode(mtoon.alphaMode, alphaCutoff: mtoon.alphaCutoff, to: &material) + applyDepthWrite(mtoon, to: &material) + material.faceCulling = mtoon.cullMode.faceCulling + applyMToonParameters(state, to: &material) + return material + } + + private func customMToonOutlineMaterial(_ state: MToonState) throws -> Material { + let mtoon = state.descriptor + let surface = CustomMaterial.SurfaceShader(named: "mtoonOutlineSurface", in: state.library) + let geometry = CustomMaterial.GeometryModifier(named: "mtoonOutlineGeometry", in: state.library) + var material = try CustomMaterial(surfaceShader: surface, + geometryModifier: geometry, + lightingModel: .unlit) + material.faceCulling = .front + material.baseColor = .init(tint: .white, texture: try mtoonTexture(mtoon, slot: .base)) + material.clearcoat.texture = try mtoonTexture(mtoon, slot: .outlineWidth) + material.ambientOcclusion.texture = try mtoonTexture(mtoon, slot: .uvAnimationMask) + applyAlphaMode(mtoon.alphaMode, alphaCutoff: mtoon.alphaCutoff, to: &material) + applyDepthWrite(mtoon, to: &material) + applyMToonParameters(state, to: &material) + return material + } + + /// MToon.metal applies the UV transform from the parameter rows, so + /// `textureCoordinateTransform` is deliberately left at identity here. + private func applyMToonParameters(_ state: MToonState, to material: inout CustomMaterial) { + material.custom.value = state.parameters.customValue + material.custom.texture = state.parameterTexture + } + + /// The descriptor's texture for `slot`, or the slot's neutral fallback. + private func mtoonTexture(_ descriptor: MToonMaterialDescriptor, + slot: MToonTextureSlot) throws -> CustomMaterial.Texture { + guard let texture = descriptor.texture(for: slot) else { + return CustomMaterial.Texture(try fallbackTextureResource(slot.fallback)) + } + return CustomMaterial.Texture(try self.texture(withTextureIndex: texture.index, semantic: slot.semantic)) + } +#endif + + func mtoonParameters(withMaterialIndex index: Int) throws -> MToonMaterialParameters? { +#if os(visionOS) + return nil +#else + return try mtoonState(withMaterialIndex: index)?.parameters +#endif + } + +#if !os(visionOS) + private func mtoonState(withMaterialIndex index: Int) throws -> MToonState? { + if let cached = mtoonStateCache[index] { + return cached + } + let state = try makeMToonState(withMaterialIndex: index) + mtoonStateCache[index] = state + return state + } + + /// Records that the material does not render as MToon after all. + private func discardMToonState(withMaterialIndex index: Int) { + mtoonStateCache.updateValue(nil, forKey: index) + mtoonOutlineMaterialCache.updateValue(nil, forKey: index) + } + + private func makeMToonState(withMaterialIndex index: Int) throws -> MToonState? { + guard isMToonEnabled, + let descriptor = try mtoonDescriptor(withMaterialIndex: index), + let library = mtoonShaderLibrary() else { + return nil + } + let textureTransform = mtoonTextureTransform(withMaterialIndex: index, descriptor: descriptor) + let parameters = try mtoonParameters(for: descriptor, textureTransform: textureTransform) + logMToonUnsupportedFeatures(for: descriptor, index: index) + return MToonState(descriptor: descriptor, + parameters: parameters, + parameterTexture: CustomMaterial.Texture(try parameters.textureResource()), + library: library) + } + + private func logMToonUnsupportedFeatures(for descriptor: MToonMaterialDescriptor, index: Int) { + if descriptor.renderQueueOffsetNumber != 0 { + Self.logger.warning("MToon material \(index, privacy: .public) requests renderQueueOffsetNumber \(descriptor.renderQueueOffsetNumber); RealityKit has no material-level draw-order hook, so it is ignored.") + } + } +#endif + + private func mtoonParameters(for descriptor: MToonMaterialDescriptor, + textureTransform: MaterialParameterTypes.TextureCoordinateTransform) throws -> MToonMaterialParameters { + var parameters = MToonMaterialParameters(descriptor) + parameters.setTextureTransform(scale: textureTransform.scale, + offset: textureTransform.offset, + rotation: textureTransform.rotation) + for slot in MToonTextureSlot.allCases { + try parameters.setSampler(mtoonSamplerParameters(for: descriptor.texture(for: slot)), for: slot) + } + return parameters + } + + /// MToon.metal transforms in glTF UV space, so unlike the standard path the + /// transform passes through unconverted. + private func mtoonTextureTransform(withMaterialIndex index: Int, + descriptor: MToonMaterialDescriptor) -> MaterialParameterTypes.TextureCoordinateTransform { + let textures = descriptor.uvAccessedTextures + if textures.contains(where: { $0.texCoord != 0 }) { + logOnce("mtoonTexCoord-\(index)", + "MToon material \(index) requests a nonzero texCoord; RealityKit uses TEXCOORD_0 on supported deployment targets.") + } + let selected = selectedUVTransform(withMaterialIndex: index, textures: textures) + return MaterialParameterTypes.TextureCoordinateTransform(offset: selected.offset, + scale: selected.scale, + rotation: selected.rotation) + } + + private func mtoonOutlineMaterial(withMaterialIndex index: Int) throws -> Material? { +#if os(visionOS) + return nil +#else + guard isOutlineEnabled else { + return nil + } + if let cached = mtoonOutlineMaterialCache[index] { + return cached + } + let material: Material? + if let state = try mtoonState(withMaterialIndex: index), state.descriptor.hasOutline { + material = try customMToonOutlineMaterial(state) + } else { + material = nil + } + mtoonOutlineMaterialCache[index] = material + return material +#endif + } + + private func mtoonDescriptor(withMaterialIndex index: Int) throws -> MToonMaterialDescriptor? { + if let cached = mtoonDescriptorCache[index] { + return cached + } + let descriptor = try makeMToonDescriptor(withMaterialIndex: index) + mtoonDescriptorCache[index] = descriptor + return descriptor + } + + private func makeMToonDescriptor(withMaterialIndex index: Int) throws -> MToonMaterialDescriptor? { + let (gltfMaterial, materialProperty) = try materialSource(withMaterialIndex: index) + return MToonMaterialDescriptor(material: gltfMaterial, materialProperty: materialProperty) + } + + /// The glTF material and, for VRM 0.x, the Unity material property describing it. + private func materialSource(withMaterialIndex index: Int) throws -> (GLTF.Material, VRM0.MaterialProperty?) { + let gltfMaterial = try gltf.load(\.materials, at: index) + return (gltfMaterial, vrm0MaterialProperty(for: gltfMaterial)) + } + + /// VRM 0.x compatibility hook, overridden by ``VRMEntityLoader``. + func vrm0MaterialProperty(for gltfMaterial: GLTF.Material) -> VRM0.MaterialProperty? { + nil + } + + private func mtoonShaderLibrary() -> MTLLibrary? { + do { + return try MToonShaderLibraryLoader.loadDefault() + } catch { + if !loggedMToonLibraryError { + loggedMToonLibraryError = true + Self.logger.error("Failed to load bundled MToon shader library: \(String(describing: error), privacy: .public)") + } + return nil + } + } + + func texture(withTextureIndex index: Int, semantic: TextureResource.Semantic = .color) throws -> TextureResource { + if let cache = textureCacheBySemantic[semantic]?[index] { return cache } + let gltfTexture = try gltf.load(\.textures, at: index) + let image = try image(withImageIndex: gltfTexture.source) + let cgImage = try image.cgImage ??? .dataInconsistent("failed to load cgImage") + let texture = try TextureResource(image: cgImage, options: .init(semantic: semantic)) + textureCacheBySemantic[semantic, default: [:]][index] = texture + return texture + } + + private func materialTexture(withTextureIndex index: Int, + semantic: TextureResource.Semantic = .color) throws -> MaterialParameters.Texture { + let texture = try texture(withTextureIndex: index, semantic: semantic) + let sampler = try sampler(withTextureIndex: index) + return MaterialParameters.Texture(texture, sampler: sampler) + } + + /// The neutral 1x1 texture bound when a material omits an MToon slot. + private func fallbackTextureResource(_ fallback: MToonTextureSlot.Fallback) throws -> TextureResource { + if let cached = fallbackTextureCache[fallback] { + return cached + } + let texture: TextureResource + switch fallback { + case .white: + texture = try solidColorTextureResource(rgba: [255, 255, 255, 255], semantic: .color) + case .neutralNormal: + texture = try solidColorTextureResource(rgba: [128, 128, 255, 255], semantic: .normal) + } + fallbackTextureCache[fallback] = texture + return texture + } + + private func solidColorTextureResource(rgba: [UInt8], + semantic: TextureResource.Semantic) throws -> TextureResource { + guard let provider = CGDataProvider(data: Data(rgba) as CFData), + let image = CGImage(width: 1, + height: 1, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent) else { + throw VRMError._dataInconsistent("failed to create 1x1 \(semantic) texture") + } + return try TextureResource(image: image, options: .init(semantic: semantic)) + } + + private func sampler(withTextureIndex index: Int) throws -> MaterialParameters.Texture.Sampler { + if let cache = samplerCache[index] { + return cache + } + let descriptor = MTLSamplerDescriptor() + applySampler(try gltfSampler(withTextureIndex: index), to: descriptor) + let sampler = MaterialParameters.Texture.Sampler(descriptor) + samplerCache[index] = sampler + return sampler + } + + /// The glTF sampler a texture references, or nil when it uses the defaults. + private func gltfSampler(withTextureIndex index: Int) throws -> GLTF.Sampler? { + guard let samplerIndex = try gltf.load(\.textures, at: index).sampler else { return nil } + return try gltf.load(\.samplers, at: samplerIndex) + } + + private func applySampler(_ sampler: GLTF.Sampler?, to descriptor: MTLSamplerDescriptor) { + descriptor.magFilter = metalFilter(sampler?.magFilter ?? .LINEAR) + let (min, mip) = metalFilters(sampler?.minFilter ?? .LINEAR_MIPMAP_LINEAR) + descriptor.minFilter = min + descriptor.mipFilter = mip + descriptor.sAddressMode = metalWrap(sampler?.wrapS ?? .REPEAT) + descriptor.tAddressMode = metalWrap(sampler?.wrapT ?? .REPEAT) + } + + private func mtoonSamplerParameters(for texture: MToonMaterialDescriptor.Texture?) throws -> SIMD4 { + guard let texture, + let sampler = try gltfSampler(withTextureIndex: texture.index) else { + return MToonMaterialParameters.defaultSampler + } + return mtoonSamplerParameters(sampler) + } + + /// (wrapS, wrapT, filterIndex, 0), the sampler row layout `MToon.metal` expects. + private func mtoonSamplerParameters(_ sampler: GLTF.Sampler) -> SIMD4 { + let (minFilter, mipFilter) = metalFilters(sampler.minFilter ?? .LINEAR_MIPMAP_LINEAR) + let filter = MToonSamplerFilter( + magnification: metalFilter(sampler.magFilter ?? .LINEAR) == .nearest ? .nearest : .linear, + minification: minFilter == .nearest ? .nearest : .linear, + mip: MToonSamplerFilter.MipFilter(mipFilter) + ) + return SIMD4(mtoonWrapMode(sampler.wrapS), + mtoonWrapMode(sampler.wrapT), + Float(filter.index), + 0) + } + + private func mtoonWrapMode(_ wrap: GLTF.Sampler.Wrap) -> Float { + switch wrap { + case .REPEAT: return 0 + case .CLAMP_TO_EDGE: return 1 + case .MIRRORED_REPEAT: return 2 + } + } + + private func metalFilter(_ filter: GLTF.Sampler.MagFilter) -> MTLSamplerMinMagFilter { + switch filter { + case .NEAREST: return .nearest + case .LINEAR: return .linear + } + } + + private func metalFilters(_ filter: GLTF.Sampler.MinFilter) -> (min: MTLSamplerMinMagFilter, mip: MTLSamplerMipFilter) { + switch filter { + case .NEAREST: + return (.nearest, .notMipmapped) + case .LINEAR: + return (.linear, .notMipmapped) + case .NEAREST_MIPMAP_NEAREST: + return (.nearest, .nearest) + case .LINEAR_MIPMAP_NEAREST: + return (.linear, .nearest) + case .NEAREST_MIPMAP_LINEAR: + return (.nearest, .linear) + case .LINEAR_MIPMAP_LINEAR: + return (.linear, .linear) + } + } + + private func metalWrap(_ wrap: GLTF.Sampler.Wrap) -> MTLSamplerAddressMode { + switch wrap { + case .CLAMP_TO_EDGE: return .clampToEdge + case .MIRRORED_REPEAT: return .mirrorRepeat + case .REPEAT: return .repeat + } + } + + func image(withImageIndex index: Int) throws -> VRMImage { + if let cache = try entityData.load(\.images, index: index) { return cache } + let gltfImage = try gltf.load(\.images, at: index) + let image = try VRMImage.from(gltfImage, relativeTo: document.rootDirectory) { index in + try document.bufferViewData(at: index).data + } + entityData.images[index] = image + return image + } + + /// The normal map a material samples, with `normalTexture.scale` applied. + private func normalTextureParameter(_ info: GLTF.Material.NormalTextureInfo) throws -> MaterialParameters.Texture { + guard info.scale != 1 else { + return try materialTexture(withTextureIndex: info.index, semantic: .normal) + } + return try bakedTexture(withTextureIndex: info.index, + factor: info.scale, + semantic: .normal, + bake: scaledNormalImage) + } + + /// The occlusion map a material samples, with `occlusionTexture.strength` + /// applied. Occlusion is linear data, so `.raw`: `.color` would apply an + /// sRGB-to-linear conversion. + private func occlusionTextureParameter(_ info: GLTF.Material.OcclusionTextureInfo) throws -> MaterialParameters.Texture { + guard info.strength != 1 else { + return try materialTexture(withTextureIndex: info.index, semantic: .raw) + } + return try bakedTexture(withTextureIndex: info.index, + factor: info.strength, + semantic: .raw, + bake: weakenedOcclusionImage) + } + + /// A texture with one of glTF's scalar factors baked into its pixels, built + /// once per texture and factor. + /// + /// RealityKit's normal and ambient-occlusion parameters carry a texture and + /// no scalar beside it, so a factor other than the neutral 1 has nowhere + /// else to go. + private func bakedTexture(withTextureIndex index: Int, + factor: Float, + semantic: TextureResource.Semantic, + bake: (CGImage, Float) throws -> CGImage) throws -> MaterialParameters.Texture { + let key = BakedTextureKey(textureIndex: index, factor: factor, semantic: semantic) + let resource: TextureResource + if let cached = bakedTextureCache[key] { + resource = cached + } else { + let gltfTexture = try gltf.load(\.textures, at: index) + let image = try image(withImageIndex: gltfTexture.source) + let cgImage = try image.cgImage ??? .dataInconsistent("failed to load cgImage") + resource = try TextureResource(image: try bake(cgImage, factor), + options: .init(semantic: semantic)) + bakedTextureCache[key] = resource + } + return MaterialParameters.Texture(resource, sampler: try sampler(withTextureIndex: index)) + } + + /// glTF scales a sampled normal's x and y by `normalTexture.scale` and + /// renormalizes it, which the map can carry itself. + func scaledNormalImage(_ image: CGImage, scale: Float) throws -> CGImage { + try rewritingPixels(of: image) { pixels, pixelCount in + for pixel in 0..(Float(pixels[offset]), + Float(pixels[offset + 1]), + Float(pixels[offset + 2])) / 127.5 - 1 + let scaled = SIMD3(decoded.x * scale, decoded.y * scale, decoded.z) + let length = simd_length(scaled) + // An unusable texel keeps the neutral, straight-up normal. + let normal = length > 1e-6 ? scaled / length : SIMD3(0, 0, 1) + let encoded = (normal + 1) * 127.5 + pixels[offset] = UInt8(clamping: Int(encoded.x.rounded())) + pixels[offset + 1] = UInt8(clamping: Int(encoded.y.rounded())) + pixels[offset + 2] = UInt8(clamping: Int(encoded.z.rounded())) + } + } + } + + /// glTF blends sampled occlusion toward "no occlusion" by + /// `occlusionTexture.strength`, so a strength of 0 lights the surface as if + /// the map were absent. + func weakenedOcclusionImage(_ image: CGImage, strength: Float) throws -> CGImage { + try rewritingPixels(of: image) { pixels, pixelCount in + for pixel in 0.., Int) -> Void) throws -> CGImage { + try withRGBA8Pixels(of: image) { context, pixels, pixelCount in + rewrite(pixels, pixelCount) + return try context.makeImage() ??? .dataInconsistent("failed to create CGImage") + } + } + + /// Draws `image` into a freshly allocated 8-bit RGBA buffer and hands `body` + /// that buffer, its pixel count and the context behind it. All three live + /// only for the call. + private func withRGBA8Pixels( + of image: CGImage, + _ body: (CGContext, UnsafeMutablePointer, Int) throws -> Result + ) throws -> Result { + let bytesPerPixel = 4 + let pixelCount = image.width * image.height + let pixels = UnsafeMutablePointer.allocate(capacity: pixelCount * bytesPerPixel) + defer { pixels.deallocate() } + + guard let context = CGContext( + data: UnsafeMutableRawPointer(pixels), + width: image.width, + height: image.height, + bitsPerComponent: 8, + bytesPerRow: bytesPerPixel * image.width, + space: CGColorSpaceCreateDeviceRGB(), + // The maps are data, not color: alpha must not premultiply them. + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { + throw VRMError._dataInconsistent("failed to create cgcontext") + } + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + return try body(context, pixels, pixelCount) + } + + private func metallicRoughnessTextures(withTextureIndex index: Int) throws -> (metal: MaterialParameters.Texture, rough: MaterialParameters.Texture) { + let resources: (metal: TextureResource, rough: TextureResource) + if let cache = metallicRoughnessCache[index] { + resources = cache + } else { + let gltfTexture = try gltf.load(\.textures, at: index) + let image = try image(withImageIndex: gltfTexture.source) + let textures = try createMetallicRoughnessTextures(from: image) + metallicRoughnessCache[index] = textures + resources = textures + } + let sampler = try sampler(withTextureIndex: index) + return (MaterialParameters.Texture(resources.metal, sampler: sampler), + MaterialParameters.Texture(resources.rough, sampler: sampler)) + } + + /// glTF packs roughness in the green channel and metalness in the blue one of + /// a single texture; RealityKit samples a texture of its own for each. + private func createMetallicRoughnessTextures(from uiImage: VRMImage) throws -> (metal: TextureResource, rough: TextureResource) { + guard let image = uiImage.cgImage else { + throw VRMError._dataInconsistent("failed to load cgImage") + } + + let images = try withRGBA8Pixels(of: image) { _, pixels, pixelCount in + let metalPtr = UnsafeMutablePointer.allocate(capacity: pixelCount) + let roughPtr = UnsafeMutablePointer.allocate(capacity: pixelCount) + defer { + metalPtr.deallocate() + roughPtr.deallocate() + } + for pixel in 0..) throws -> CGImage { + guard let data = CFDataCreate(nil, dataPointer, width * height), + let provider = CGDataProvider(data: data) else { + throw VRMError._dataInconsistent("failed to create image data") + } + guard let image = CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 8, + bytesPerRow: width, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + ) else { + throw VRMError._dataInconsistent("failed to create CGImage") + } + return image + } + + /// The glTF alpha-mode → RealityKit blending decision, shared by every material. + private struct AlphaModeSettings { + let isTransparent: Bool + let opacityThreshold: Float? + + init(_ mode: GLTF.Material.AlphaMode, alphaCutoff: Float) { + switch mode { + case .OPAQUE: + (isTransparent, opacityThreshold) = (false, nil) + case .MASK: + (isTransparent, opacityThreshold) = (false, alphaCutoff) + case .BLEND: + (isTransparent, opacityThreshold) = (true, nil) + } + } + } + + private func applyAlphaMode(_ mode: GLTF.Material.AlphaMode, + alphaCutoff: Float, + to material: inout UnlitMaterial) { + let settings = AlphaModeSettings(mode, alphaCutoff: alphaCutoff) + material.blending = settings.isTransparent ? .transparent(opacity: .init(scale: 1.0)) : .opaque + material.opacityThreshold = settings.opacityThreshold + } + + private func applyAlphaMode(_ mode: GLTF.Material.AlphaMode, + alphaCutoff: Float, + to material: inout PhysicallyBasedMaterial) { + let settings = AlphaModeSettings(mode, alphaCutoff: alphaCutoff) + material.blending = settings.isTransparent ? .transparent(opacity: .init(scale: 1.0)) : .opaque + material.opacityThreshold = settings.opacityThreshold + } + +#if !os(visionOS) + private func applyAlphaMode(_ mode: GLTF.Material.AlphaMode, + alphaCutoff: Float, + to material: inout CustomMaterial) { + let settings = AlphaModeSettings(mode, alphaCutoff: alphaCutoff) + material.blending = settings.isTransparent ? .transparent(opacity: .init(scale: 1.0)) : .opaque + material.opacityThreshold = settings.opacityThreshold + } + + /// MToon's `transparentWithZWrite` asks a blended material to still write depth. + private func applyDepthWrite(_ mtoon: MToonMaterialDescriptor, to material: inout CustomMaterial) { + material.writesDepth = mtoon.alphaMode != .BLEND || mtoon.transparentWithZWrite + } +#endif + + /// UVs arrive V-flipped: glTF's origin is top-left, RealityKit's is bottom-left. + private func vector2s(_ accessorIndex: Int) throws -> [SIMD2] { + try accessors.floatElements(at: accessorIndex, type: .VEC2) { + SIMD2($0(0), 1.0 - $0(1)) + } + } + + private func vector3s(_ accessorIndex: Int) throws -> [SIMD3] { + try accessors.floatElements(at: accessorIndex, type: .VEC3) { + SIMD3($0(0), $0(1), $0(2)) + } + } + + private func vector4s(_ accessorIndex: Int) throws -> [SIMD4] { + try accessors.floatElements(at: accessorIndex, type: .VEC4) { + SIMD4($0(0), $0(1), $0(2), $0(3)) + } + } + + private func indexValues(_ accessorIndex: Int) throws -> [UInt32] { + try accessors.accessor(at: accessorIndex).unsignedElements(.SCALAR) { $0(0) } + } + + /// JOINTS_n as glTF defines it: unsigned byte or short indices into the skin. + private func jointIndices(_ accessorIndex: Int) throws -> [SIMD4] { + let packed = try accessors.accessor(at: accessorIndex) + switch packed.componentType { + case .unsignedByte, .unsignedShort: + return try packed.unsignedElements(.VEC4) { + SIMD4($0(0), $0(1), $0(2), $0(3)) + } + case .byte, .short, .unsignedInt, .float: + throw VRMError._dataInconsistent( + "JOINTS_0 must use unsigned byte or short components, not \(packed.componentType)" + ) + } + } + + /// WEIGHTS_n as glTF defines it: float, or normalized unsigned byte / short. + private func jointWeights(_ accessorIndex: Int) throws -> [SIMD4] { + let packed = try accessors.accessor(at: accessorIndex) + switch packed.componentType { + case .float: + return try vector4s(accessorIndex) + case .unsignedByte, .unsignedShort: + guard packed.normalized else { + throw VRMError._dataInconsistent( + "WEIGHTS_0 with \(packed.componentType) components must be normalized" + ) + } + return try vector4s(accessorIndex) + case .byte, .short, .unsignedInt: + throw VRMError._dataInconsistent( + "WEIGHTS_0 must use float or normalized unsigned byte / short components, not \(packed.componentType)" + ) + } + } + + private func makeJointInfluences(joints: [SIMD4], + weights: [SIMD4], + vertexCount: Int, + jointIndexRemap remap: [Int]) throws -> MeshResource.JointInfluences { + guard joints.count == weights.count else { + throw VRMError._dataInconsistent("JOINTS_0 and WEIGHTS_0 counts do not match") + } + guard joints.count == vertexCount else { + throw VRMError._dataInconsistent("joint influence count \(joints.count) does not match vertex count \(vertexCount)") + } + + var influences: [MeshJointInfluence] = [] + influences.reserveCapacity(joints.count * 4) + func remapped(_ jointIndex: UInt32) throws -> Int { + guard remap.indices.contains(Int(jointIndex)) else { + throw VRMError._dataInconsistent( + "joint index \(jointIndex) is out of range for \(remap.count) skin joints" + ) + } + return remap[Int(jointIndex)] + } + for i in 0.. 0 { + w0 /= sum + w1 /= sum + w2 /= sum + w3 /= sum + } + influences.append(MeshJointInfluence(jointIndex: try remapped(joint.x), weight: w0)) + influences.append(MeshJointInfluence(jointIndex: try remapped(joint.y), weight: w1)) + influences.append(MeshJointInfluence(jointIndex: try remapped(joint.z), weight: w2)) + influences.append(MeshJointInfluence(jointIndex: try remapped(joint.w), weight: w3)) + } + + let buffer = MeshBuffer(influences) + return MeshResource.JointInfluences(influences: buffer, influencesPerVertex: 4) + } + + private func matrix4s(_ accessorIndex: Int) throws -> [simd_float4x4] { + guard try accessors.accessor(at: accessorIndex).componentType == .float else { + throw VRMError._dataInconsistent("MAT4 accessor must be float") + } + // glTF stores matrices column-major, which is also simd's layout. + return try accessors.floatElements(at: accessorIndex, type: .MAT4) { component in + simd_float4x4(columns: ( + SIMD4(component(0), component(1), component(2), component(3)), + SIMD4(component(4), component(5), component(6), component(7)), + SIMD4(component(8), component(9), component(10), component(11)), + SIMD4(component(12), component(13), component(14), component(15)) + )) + } + } + + /// The skin at `index` resolved for RealityKit. Its skeleton and its joint + /// remap come out of the same ordering pass, so they are cached together. + private func skin(withSkinIndex index: Int) throws -> EntityData.Skin { + if let cache = try entityData.load(\.skins, index: index) { return cache } + let skin = try gltf.load(\.skins, at: index) + let nodes = try gltf.load(\.nodes) + let (parentIndices, order, remap) = computeSkinJointOrdering(skin: skin) + + // glTF defines an absent inverseBindMatrices as identity per joint, but a + // present one has to cover every joint. + let inverseBindMatrices: [simd_float4x4] + if let accessorIndex = skin.inverseBindMatrices { + inverseBindMatrices = try matrix4s(accessorIndex) + guard inverseBindMatrices.count >= skin.joints.count else { + throw VRMError._dataInconsistent( + "inverseBindMatrices has \(inverseBindMatrices.count) elements for \(skin.joints.count) skin joints" + ) + } + } else { + inverseBindMatrices = Array(repeating: matrix_identity_float4x4, count: skin.joints.count) + } + + var joints: [MeshResource.Skeleton.Joint] = [] + joints.reserveCapacity(order.count) + for newIndex in 0.. (parentIndices: [Int?], order: [Int], remap: [Int]) { + let jointNodeIndices = skin.joints + let jointIndexMap = Dictionary(uniqueKeysWithValues: jointNodeIndices.enumerated().map { ($0.element, $0.offset) }) + var parentIndices: [Int?] = Array(repeating: nil, count: jointNodeIndices.count) + for (i, nodeIndex) in jointNodeIndices.enumerated() { + // `validateStructure()` has proven `nodeParents` to describe a forest, + // so walking up from a joint terminates. + var current = nodeIndex + while let parent = nodeParents[current] { + if let jointIndex = jointIndexMap[parent] { + parentIndices[i] = jointIndex + break + } + current = parent + } + } + + var children: [[Int]] = Array(repeating: [], count: jointNodeIndices.count) + for (i, parent) in parentIndices.enumerated() { + if let parent = parent { + children[parent].append(i) + } + } + + var order: [Int] = [] + order.reserveCapacity(jointNodeIndices.count) + func visit(_ index: Int) { + order.append(index) + for child in children[index] { + visit(child) + } + } + + // `validateStructure()` has proven the hierarchy to be a forest, so the + // joints form one too and visiting every root reaches all of them. + let roots = parentIndices.enumerated().compactMap { $0.element == nil ? $0.offset : nil } + for root in roots { + visit(root) + } + + var remap: [Int] = Array(repeating: 0, count: jointNodeIndices.count) + for (newIndex, oldIndex) in order.enumerated() { + remap[oldIndex] = newIndex + } + + return (parentIndices, order, remap) + } + + private func meshResource(positions: [SIMD3], + normals: [SIMD3], + tangentFrame: TangentFrame, + texcoords: [SIMD2], + indices: [UInt32], + blendShapeOffsets: [[SIMD3]], + skeleton: MeshResource.Skeleton?, + jointInfluences: MeshResource.JointInfluences?) throws -> MeshResource { + var part = MeshResource.Part(id: UUID().uuidString, materialIndex: 0) + part.positions = MeshBuffer(positions) + if !normals.isEmpty { + part.normals = MeshBuffer(normals) + } + if !tangentFrame.tangents.isEmpty { + part.tangents = MeshBuffer(tangentFrame.tangents) + part.bitangents = MeshBuffer(tangentFrame.bitangents) + } + if !texcoords.isEmpty { + part.textureCoordinates = MeshBuffer(texcoords) + } + part.triangleIndices = MeshBuffer(indices) + if !blendShapeOffsets.isEmpty { + for (targetIndex, offsets) in blendShapeOffsets.enumerated() { + let name = "blendShape_\(targetIndex)" + part.setBlendShapeOffsets(named: name, buffer: MeshBuffer(offsets)) + } + _ = part.blendShapeNames + } + if let skeleton, let jointInfluences { + part.skeletonID = skeleton.id + part.jointInfluences = jointInfluences + } + + let modelID = UUID().uuidString + let model = MeshResource.Model(id: modelID, parts: [part]) + + var models = MeshModelCollection() + _ = models.insert(model) + + var instances = MeshInstanceCollection() + _ = instances.insert(MeshResource.Instance(id: modelID, model: modelID)) + + var contents = MeshResource.Contents() + contents.models = models + contents.instances = instances + if let skeleton { + var skeletons = MeshSkeletonCollection() + _ = skeletons.insert(skeleton) + contents.skeletons = skeletons + } + + return try MeshResource.generate(from: contents) + } + + /// Binds the skinned models of a freshly cloned mesh to this scene's joints. + private func registerSkinBindings(in root: Entity) throws { + guard let currentEntity else { return } + for modelEntity in root.modelEntitiesInHierarchy { + guard let skinIndex = modelEntity.components[GLTFSkinIndexComponent.self]?.skinIndex else { + continue + } + let jointNodes = try gltf.load(\.skins, at: skinIndex).joints + let jointsInSkinOrder = try jointNodes.map { try node(withNodeIndex: $0) } + // The skeleton reorders the joints parents-first, and the pose is + // written in the skeleton's order. + let skin = try skin(withSkinIndex: skinIndex) + var jointEntities = jointsInSkinOrder + for (oldIndex, newIndex) in skin.jointIndexRemap.enumerated() { + jointEntities[newIndex] = jointsInSkinOrder[oldIndex] + } + currentEntity.registerSkinBinding(modelEntity: modelEntity, + skeleton: skin.skeleton, + jointEntities: jointEntities) + } + } + + private func registerMaterialBindings(in root: Entity) { + guard let currentEntity else { return } + for modelEntity in root.modelEntitiesInHierarchy { + guard let materialIndex = modelEntity.components[GLTFMaterialIndexComponent.self]?.materialIndex else { + continue + } + currentEntity.registerMaterialBinding(modelEntity: modelEntity, + materialIndex: materialIndex, + loader: self) + } + } + + private func transform(from node: GLTF.Node) -> Transform { + if let matrix = node._matrix { + return Transform(matrix: matrix.simdMatrix) + } + return Transform(scale: node.scale.simd, + rotation: node.rotation.simdQuat, + translation: node.translation.simd) + } + + /// A complete tangent basis. RealityKit derives neither buffer from the other, + /// so both are filled together or left empty together. + private struct TangentFrame { + static let empty = TangentFrame(tangents: [], bitangents: []) + + let tangents: [SIMD3] + let bitangents: [SIMD3] + } + + /// The tangent basis a normal map needs: glTF `TANGENT` when the primitive has + /// one, otherwise derived from the UVs. Skipped when no normal map samples it. + private func tangentFrame(rawTangents: [SIMD4]?, + positions: [SIMD3], + normals: [SIMD3], + texcoords: [SIMD2], + indices: [UInt32], + materialIndex: Int?) -> TangentFrame { + if let rawTangents { + // glTF stores handedness in w. + var tangents = [SIMD3](repeating: .zero, count: positions.count) + var bitangents = tangents + for i in 0..(raw.x, raw.y, raw.z) + tangents[i] = tangent + bitangents[i] = simd_cross(normals[i], tangent) * (raw.w < 0 ? -1 : 1) + } + return TangentFrame(tangents: tangents, bitangents: bitangents) + } + guard texcoords.count == positions.count, + let materialIndex, + materialUsesNormalTexture(withMaterialIndex: materialIndex) else { + return .empty + } + return generatedTangentFrame(positions: positions, + normals: normals, + texcoords: texcoords, + indices: indices) + } + + /// Whether the material samples a normal map. The MToon descriptor is asked + /// first because VRM 0.x carries its normal map in Unity's `_BumpMap`. + private func materialUsesNormalTexture(withMaterialIndex index: Int) -> Bool { + if let descriptor = try? mtoonDescriptor(withMaterialIndex: index), descriptor.normalTexture != nil { + return true + } + guard let (gltfMaterial, _) = try? materialSource(withMaterialIndex: index) else { return false } + return gltfMaterial.normalTexture != nil + } + + /// Per-triangle UV gradients accumulated per vertex, then orthonormalized + /// against the normal. + /// + /// The spec only *recommends* MikkTSpace for a primitive that ships no + /// `TANGENT`; this averaging is the cheaper approximation, so a mesh whose + /// baked normal map assumes MikkTSpace can differ slightly along UV seams. + private func generatedTangentFrame(positions: [SIMD3], + normals: [SIMD3], + texcoords: [SIMD2], + indices: [UInt32]) -> TangentFrame { + var tangentSums = [SIMD3](repeating: .zero, count: positions.count) + var bitangentSums = tangentSums + let triangleCount = indices.count / 3 + for i in 0.. 1e-12 else { continue } + let scale = 1 / determinant + let tangent = (edge1 * deltaUV2.y - edge2 * deltaUV1.y) * scale + let bitangent = (edge2 * deltaUV1.x - edge1 * deltaUV2.x) * scale + tangentSums[i0] += tangent + tangentSums[i1] += tangent + tangentSums[i2] += tangent + bitangentSums[i0] += bitangent + bitangentSums[i1] += bitangent + bitangentSums[i2] += bitangent + } + + var tangents = [SIMD3](repeating: .zero, count: positions.count) + var bitangents = tangents + for i in 0..) -> SIMD2 { + SIMD2(delta.x, -delta.y) + } + + /// Gram-Schmidt against the normal, falling back to any perpendicular axis. + private func orthonormalizedTangent(_ tangent: SIMD3, normal: SIMD3) -> SIMD3 { + // A degenerate triangle leaves the zero normal `flatNormals()` writes, and + // nothing is perpendicular to it — normalizing a fallback axis would only + // turn that into a NaN basis. + guard simd_length_squared(normal) > 1e-12 else { return .zero } + let projected = tangent - normal * simd_dot(normal, tangent) + if simd_length_squared(projected) > 1e-12 { + return simd_normalize(projected) + } + let axis = abs(normal.x) < 0.9 ? SIMD3(1, 0, 0) : SIMD3(0, 1, 0) + return simd_normalize(simd_cross(normal, axis)) + } + + /// The flat normals glTF asks for when a primitive ships no NORMAL: one face + /// normal shared by the triangle's three corners. + /// + /// - Precondition: the positions are expanded per triangle corner, so each + /// triangle owns the vertices it writes. + private func flatNormals(positions: [SIMD3]) -> [SIMD3] { + var normals = [SIMD3](repeating: .zero, count: positions.count) + for base in stride(from: 0, to: positions.count - positions.count % 3, by: 3) { + let faceNormal = simd_cross(positions[base + 1] - positions[base], + positions[base + 2] - positions[base]) + // A degenerate triangle keeps a zero normal rather than a NaN one. + guard simd_length_squared(faceNormal) > 1e-24 else { continue } + let normal = simd_normalize(faceNormal) + normals[base] = normal + normals[base + 1] = normal + normals[base + 2] = normal + } + return normals + } + + /// glTF's default material for a primitive that names none: lit, white, and + /// fully metallic and rough. + func defaultMaterial() -> Material { + var material = PhysicallyBasedMaterial() + material.baseColor = .init(tint: .white) + material.metallic = .init(floatLiteral: 1.0) + material.roughness = .init(floatLiteral: 1.0) + return material + } + +} + +#endif diff --git a/Sources/VRMRealityKit/VRMEntityLoader+convenience.swift b/Sources/VRMRealityKit/VRMEntityLoader+convenience.swift deleted file mode 100644 index 745c1d72..00000000 --- a/Sources/VRMRealityKit/VRMEntityLoader+convenience.swift +++ /dev/null @@ -1,49 +0,0 @@ -#if canImport(RealityKit) -import Foundation -import VRMKit - -@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) -extension VRMEntityLoader { - /// Loads a VRM from a file URL. - /// - /// - Parameters: - /// - url: VRM file location. - /// - rootDirectory: Optional base directory for external glTF resources. - /// - isMToonEnabled: When `false`, MToon is fully disabled and Unlit / PBR fallbacks are used. - /// - isOutlineEnabled: Controls creation of MToon outline entities. - public convenience init(withURL url: URL, - rootDirectory: URL? = nil, - isMToonEnabled: Bool = true, - isOutlineEnabled: Bool = true) throws { - let vrm = try VRMLoader().load(withURL: url) - self.init(vrm: vrm, - rootDirectory: rootDirectory, - isMToonEnabled: isMToonEnabled, - isOutlineEnabled: isOutlineEnabled) - } - - /// Loads a bundled VRM resource. - public convenience init(named: String, - rootDirectory: URL? = nil, - isMToonEnabled: Bool = true, - isOutlineEnabled: Bool = true) throws { - let vrm = try VRMLoader().load(named: named) - self.init(vrm: vrm, - rootDirectory: rootDirectory, - isMToonEnabled: isMToonEnabled, - isOutlineEnabled: isOutlineEnabled) - } - - /// Loads a VRM from in-memory data. - public convenience init(withData data: Data, - rootDirectory: URL? = nil, - isMToonEnabled: Bool = true, - isOutlineEnabled: Bool = true) throws { - let vrm = try VRMLoader().load(withData: data) - self.init(vrm: vrm, - rootDirectory: rootDirectory, - isMToonEnabled: isMToonEnabled, - isOutlineEnabled: isOutlineEnabled) - } -} -#endif diff --git a/Sources/VRMRealityKit/VRMEntityLoader.swift b/Sources/VRMRealityKit/VRMEntityLoader.swift index 10be070e..1ed3b604 100644 --- a/Sources/VRMRealityKit/VRMEntityLoader.swift +++ b/Sources/VRMRealityKit/VRMEntityLoader.swift @@ -1,602 +1,154 @@ #if canImport(RealityKit) -import CoreGraphics import Foundation import RealityKit -import Metal -import OSLog import VRMKit import VRMKitRuntime +/// Loads a VRM model into a ``VRMEntity``. +/// +/// The generic glTF rendering lives in ``GLTFEntityLoader``; this subclass adds +/// the VRM layers on top: VRM 0.x material properties, humanoid, expressions, +/// first person, node constraints and spring bones. @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) @MainActor -open class VRMEntityLoader { +public class VRMEntityLoader: GLTFEntityLoader { public let vrm: VRM - private let gltf: GLTF - private let entityData: EntityData - private var rootDirectory: URL? = nil - private let entityName: String? - private weak var currentEntity: VRMEntity? - private static let logger = Logger(subsystem: "dev.tattn.VRMKit", category: "MToon") - private var textureCacheBySemantic: [TextureResource.Semantic: [Int: TextureResource]] = [:] - private var metallicRoughnessCache: [Int: (metal: TextureResource, rough: TextureResource)] = [:] - private var samplerCache: [Int: MaterialParameters.Texture.Sampler] = [:] - private var fallbackTextureCache: [MToonTextureSlot.Fallback: TextureResource] = [:] - private var mtoonDescriptorCache: [Int: MToonMaterialDescriptor?] = [:] -#if !os(visionOS) - /// Everything the RealityKit adapter derives from one MToon material. A - /// non-nil state is what "this material renders as MToon" means, and it is - /// shared by the surface material, the outline material and the parameters. - private struct MToonState { - let descriptor: MToonMaterialDescriptor - let parameters: MToonMaterialParameters - let parameterTexture: CustomMaterial.Texture - let library: MTLLibrary - } - - private var mtoonStateCache: [Int: MToonState?] = [:] - private var mtoonOutlineMaterialCache: [Int: Material?] = [:] -#endif - private var loggedMToonUVLimitations: Set = [] - private var loggedMToonLibraryError = false - /// When `false`, MToon materials are not created and the loader falls back to Unlit / PBR materials. - /// visionOS always uses the fallback because `CustomMaterial` is unavailable there. - public let isMToonEnabled: Bool - /// Controls creation of MToon's inverted-hull outline entities. - /// visionOS does not create MToon outlines because `CustomMaterial` is unavailable there. - public let isOutlineEnabled: Bool public init(vrm: VRM, rootDirectory: URL? = nil, isMToonEnabled: Bool = true, isOutlineEnabled: Bool = true) { self.vrm = vrm - self.gltf = vrm.gltf.jsonData - self.rootDirectory = rootDirectory - self.entityName = vrm.meta.title - self.entityData = EntityData(vrm: gltf) - self.isMToonEnabled = isMToonEnabled - self.isOutlineEnabled = isOutlineEnabled + super.init(document: GLTFDocument(binary: vrm.gltf, rootDirectory: rootDirectory), + isMToonEnabled: isMToonEnabled, + isOutlineEnabled: isOutlineEnabled) + entityName = vrm.meta.title } - public func loadEntity() throws -> VRMEntity { - return try loadEntity(withSceneIndex: gltf.scene) - } - - /// Loads one scene of the glTF as its own entity graph. + /// Loads a VRM from a file URL. /// - /// Each scene builds its own entities, so a node shared by two scenes becomes - /// a separate `Entity` in each. Buffers, materials and textures are reused. - public func loadEntity(withSceneIndex index: Int) throws -> VRMEntity { - if let cache = try entityData.load(\.entities, index: index) { return cache } - let gltfScene = try gltf.load(\.scenes, at: index) - entityData.beginScene() - - let vrmEntity = VRMEntity(vrm: vrm) - if let entityName { - vrmEntity.name = entityName - } - currentEntity = vrmEntity - defer { currentEntity = nil } - for node in gltfScene.nodes ?? [] { - vrmEntity.addChild(try self.node(withNodeIndex: node)) - } - vrmEntity.setUpHumanoid(nodes: entityData.nodes) - try vrmEntity.setUpBlendShapes(nodes: entityData.nodes, meshes: entityData.meshes, loader: self) - vrmEntity.setUpFirstPerson(nodes: entityData.nodes, meshes: entityData.meshes) - try vrmEntity.setUpNodeConstraints(gltfNodes: try gltf.load(\.nodes), loader: self) - try vrmEntity.setUpSpringBones(loader: self) - // TODO: animations. - - entityData.entities[index] = vrmEntity - return vrmEntity + /// - Parameters: + /// - url: VRM file location. + /// - rootDirectory: Optional base directory for external glTF resources. + /// - isMToonEnabled: When `false`, MToon is fully disabled and Unlit / PBR fallbacks are used. + /// - isOutlineEnabled: Controls creation of MToon outline entities. + public convenience init(withURL url: URL, + rootDirectory: URL? = nil, + isMToonEnabled: Bool = true, + isOutlineEnabled: Bool = true) throws { + let vrm = try VRMLoader().load(withURL: url) + self.init(vrm: vrm, + rootDirectory: rootDirectory, + isMToonEnabled: isMToonEnabled, + isOutlineEnabled: isOutlineEnabled) + } + + /// Loads a bundled VRM resource. + public convenience init(named: String, + rootDirectory: URL? = nil, + isMToonEnabled: Bool = true, + isOutlineEnabled: Bool = true) throws { + let vrm = try VRMLoader().load(named: named) + self.init(vrm: vrm, + rootDirectory: rootDirectory, + isMToonEnabled: isMToonEnabled, + isOutlineEnabled: isOutlineEnabled) + } + + /// Loads a VRM from in-memory data. + public convenience init(withData data: Data, + rootDirectory: URL? = nil, + isMToonEnabled: Bool = true, + isOutlineEnabled: Bool = true) throws { + let vrm = try VRMLoader().load(withData: data) + self.init(vrm: vrm, + rootDirectory: rootDirectory, + isMToonEnabled: isMToonEnabled, + isOutlineEnabled: isOutlineEnabled) + } + + /// Unlike the generic loader, a VRM without a default scene still loads: a + /// VRM is a single avatar, so its first scene is the one to render. + override public func loadEntity() throws -> VRMEntity { + return try loadEntity(withSceneIndex: gltf.scene ?? 0) + } + + override public func loadEntity(withSceneIndex index: Int) throws -> VRMEntity { + return try (super.loadEntity(withSceneIndex: index) as? VRMEntity) + ??? ._dataInconsistent("VRMEntityLoader built a non-VRM root entity") } public func loadThumbnail() throws -> VRMImage { - let imageIndex = try vrm.thumbnailImageIndex - if let cache = try entityData.load(\.images, index: imageIndex) { return cache } - return try image(withImageIndex: imageIndex) + try image(withImageIndex: vrm.thumbnailImageIndex) } - func node(withNodeIndex index: Int) throws -> Entity { - if let cache = try entityData.load(\.nodes, index: index) { return cache } - let gltfNode = try gltf.load(\.nodes, at: index) - - let entity = Entity() - entity.name = gltfNode.name ?? "node_\(index)" - - if let cameraIndex = gltfNode.camera { - try applyCamera(withCameraIndex: cameraIndex, to: entity) - } - - if let meshIndex = gltfNode.mesh { - let meshEntity = try mesh(withMeshIndex: meshIndex, skinIndex: gltfNode.skin) - entity.addChild(meshEntity) - } - - if let matrix = gltfNode._matrix { - entity.transform = Transform(matrix: matrix.simdMatrix) - } else { - entity.transform.translation = gltfNode.translation.simd - entity.transform.rotation = gltfNode.rotation.simdQuat - entity.transform.scale = gltfNode.scale.simd - } - - for child in gltfNode.children ?? [] { - entity.addChild(try node(withNodeIndex: child)) - } - - entityData.nodes[index] = entity - return entity + override func makeRootEntity(sceneIndex: Int) -> GLTFEntity { + VRMEntity(vrm: vrm, document: document, sceneIndex: sceneIndex) } - private func applyCamera(withCameraIndex index: Int, to entity: Entity) throws { - let gltfCamera = try gltf.load(\.cameras, at: index) - switch gltfCamera.type { - case .perspective: - let perspective = try gltfCamera.perspective ??? .keyNotFound("perspective") - let fovDegrees: Float - let fovOrientation: CameraFieldOfViewOrientation - if let aspectRatio = perspective.aspectRatio, aspectRatio > 0 { - let yFov = perspective.yfov - let xFov = 2 * atan(tan(yFov * 0.5) * aspectRatio) - fovDegrees = xFov * 180 / .pi - fovOrientation = .horizontal - } else { - fovDegrees = perspective.yfov * 180 / .pi - fovOrientation = .vertical - } - var component = PerspectiveCameraComponent(near: perspective.znear, - far: perspective.zfar ?? .infinity, - fieldOfViewInDegrees: fovDegrees) - component.fieldOfViewOrientation = fovOrientation - entity.components.set(component) - case .orthographic: - let orthographic = try gltfCamera.orthographic ??? .keyNotFound("orthographic") - var component = OrthographicCameraComponent() - component.near = orthographic.znear - component.far = orthographic.zfar - component.scale = orthographic.ymag - component.scaleDirection = .vertical - entity.components.set(component) - } + override func didBuildScene(_ entity: GLTFEntity) throws { + guard let vrmEntity = entity as? VRMEntity else { return } + vrmEntity.setUpHumanoid(nodes: entityData.nodes) + try vrmEntity.setUpBlendShapes(nodes: entityData.nodes, meshes: entityData.sceneMeshes, loader: self) + vrmEntity.setUpFirstPerson(nodes: entityData.nodes, meshes: entityData.sceneMeshes) + try vrmEntity.setUpNodeConstraints(gltfNodes: try gltf.load(\.nodes), loader: self) + try vrmEntity.setUpSpringBones(loader: self) } - func mesh(withMeshIndex index: Int, skinIndex: Int?) throws -> Entity { - if skinIndex == nil, let cache = try entityData.load(\.meshes, index: index) { - let clone = cache.clone(recursive: true) - registerMaterialBindings(in: clone) - return clone - } - - let gltfMesh = try gltf.load(\.meshes, at: index) - let meshEntity = Entity() - meshEntity.name = gltfMesh.name ?? "mesh_\(index)" - - // Some VRM meshes split primitives by indices but share the same POSITION accessor. - // SceneKit reuses the morpher across such primitives, so mimic that by sharing targets. - let targetsByPositionAccessor: [Int: [[GLTF.Mesh.Primitive.AttributeKey: Int]]] = { - var result: [Int: [[GLTF.Mesh.Primitive.AttributeKey: Int]]] = [:] - for primitive in gltfMesh.primitives { - guard let targets = primitive.targets, !targets.isEmpty else { continue } - if let positionAccessor = primitive.attributes.rawValue[.POSITION], - result[positionAccessor] == nil { - result[positionAccessor] = targets - } - } - return result - }() - - for primitive in gltfMesh.primitives { - var resolvedPrimitive = primitive - if (resolvedPrimitive.targets?.isEmpty ?? true), - let positionAccessor = resolvedPrimitive.attributes.rawValue[.POSITION], - let sharedTargets = targetsByPositionAccessor[positionAccessor] { - resolvedPrimitive.targets = sharedTargets - } - if let primitiveEntity = try modelEntity(withPrimitive: resolvedPrimitive, skinIndex: skinIndex) { - meshEntity.addChild(primitiveEntity) - } - } - - if skinIndex == nil { - entityData.meshes[index] = meshEntity - let clone = meshEntity.clone(recursive: true) - registerMaterialBindings(in: clone) - return clone - } - if entityData.meshes.indices.contains(index), entityData.meshes[index] == nil { - entityData.meshes[index] = meshEntity - } - registerMaterialBindings(in: meshEntity) - return meshEntity + /// The VRM extensions this loader implements, on top of the generic glTF ones. + override public var supportedRequiredExtensions: Set { + super.supportedRequiredExtensions.union([ + "VRM", "VRMC_vrm", "VRMC_springBone", "VRMC_node_constraint" + ]) } - private func modelEntity(withPrimitive primitive: GLTF.Mesh.Primitive, skinIndex: Int?) throws -> Entity? { - guard supportsTriangles(primitive.mode) else { return nil } - - let attributes = primitive.attributes.rawValue - guard let positionIndex = attributes[.POSITION] else { - throw VRMError._dataInconsistent("POSITION attribute is missing") - } - - let positions = try vector3s(positionIndex) - - // glTF requires every vertex attribute of a primitive to hold as many - // elements as POSITION, and nothing downstream re-checks it. - func vertexAttribute(_ key: GLTF.Mesh.Primitive.AttributeKey, - _ read: (Int) throws -> [Element]) throws -> [Element]? { - guard let accessorIndex = attributes[key] else { return nil } - let values = try read(accessorIndex) - guard values.count == positions.count else { - throw VRMError._dataInconsistent( - "\(key) has \(values.count) elements but POSITION has \(positions.count)" - ) - } - return values - } - - let normals = try vertexAttribute(.NORMAL, vector3s) - let rawTangents = try vertexAttribute(.TANGENT, vector4s) - let texcoords = try vertexAttribute(.TEXCOORD_0, vector2s) - let jointRemap: [Int]? = { - guard let skinIndex else { return nil } - return try? jointIndexRemap(forSkinIndex: skinIndex) - }() - let skinJointInfluences: ([SIMD4], [SIMD4])? = try { - guard skinIndex != nil, - let joints = try vertexAttribute(.JOINTS_0, jointIndices), - let weights = try vertexAttribute(.WEIGHTS_0, jointWeights) else { - return nil - } - return (joints, weights) - }() - // Only POSITION morphs are applied: RealityKit blend shapes drive vertex - // positions, and NORMAL / TANGENT targets have no equivalent channel. - var targetOffsets: [[SIMD3]] = [] - if let targets = primitive.targets, !targets.isEmpty { - targetOffsets.reserveCapacity(targets.count) - for target in targets { - if let positionAccessor = target[.POSITION] { - let offsets = try vector3s(positionAccessor) - guard offsets.count == positions.count else { - throw VRMError._dataInconsistent("blend shape target count \(offsets.count) does not match vertex count \(positions.count)") - } - targetOffsets.append(offsets) - } else { - targetOffsets.append(Array(repeating: .zero, count: positions.count)) - } - } - } - - var indexData: [UInt32] - if let indicesAccessor = primitive.indices { - indexData = try indexValues(indicesAccessor) - } else { - indexData = (0..= positions.count { - throw VRMError._dataInconsistent( - "triangle index \(maxIndex) is out of range for \(positions.count) vertices" - ) - } - - // NORMAL is optional in glTF; everything else is used as-is. - let finalNormals = normals ?? smoothNormals(positions: positions, indices: indexData) - let finalTexcoords = texcoords ?? [] - let tangentFrame = tangentFrame(rawTangents: rawTangents, - positions: positions, - normals: finalNormals, - texcoords: finalTexcoords, - indices: indexData, - materialIndex: primitive.material) - let finalJoints = skinJointInfluences?.0 ?? [] - let finalWeights = skinJointInfluences?.1 ?? [] - - // One unbuildable material must not fail the whole model: the primitive - // renders with the default material instead. - let material = primitive.material.flatMap { materialIndex -> Material? in - do { - return try self.material(withMaterialIndex: materialIndex) - } catch { - Self.logger.error("Failed to build the material \(materialIndex, privacy: .public); falling back to the default material: \(String(describing: error), privacy: .public)") - return nil - } - } ?? defaultMaterial() - - let hasSkinning = skinIndex != nil && !finalJoints.isEmpty - let hasBlendShapes = !targetOffsets.isEmpty - let mesh: MeshResource - var boundSkeleton: MeshResource.Skeleton? - if let skinIndex, hasSkinning { - let influences = try makeJointInfluences(joints: finalJoints, - weights: finalWeights, - vertexCount: positions.count, - jointIndexRemap: jointRemap) - let skinSkeleton = try skeleton(withSkinIndex: skinIndex) - mesh = try meshResource(positions: positions, - normals: finalNormals, - tangentFrame: tangentFrame, - texcoords: finalTexcoords, - indices: indexData, - blendShapeOffsets: targetOffsets, - skeleton: skinSkeleton, - jointInfluences: influences) - boundSkeleton = skinSkeleton - } else { - mesh = try meshResource(positions: positions, - normals: finalNormals, - tangentFrame: tangentFrame, - texcoords: finalTexcoords, - indices: indexData, - blendShapeOffsets: targetOffsets, - skeleton: nil, - jointInfluences: nil) - } - - // The blend-shape mapping is derived from the mesh, so the model entity - // and its outline twin share one instance. - let blendShapeMapping = hasBlendShapes ? BlendShapeWeightsMapping(meshResource: mesh) : nil - - func makeEntity(materials: [Material]) throws -> ModelEntity { - let entity = ModelEntity(mesh: mesh, materials: materials) - if let materialIndex = primitive.material { - entity.components.set(VRMMaterialIndexComponent(materialIndex: materialIndex)) - } - if let blendShapeMapping { - entity.components.set(BlendShapeWeightsComponent(weightsMapping: blendShapeMapping)) - } - if let skinIndex, let boundSkeleton { - try registerSkinBinding(modelEntity: entity, skinIndex: skinIndex, skeleton: boundSkeleton) - } - return entity - } - - let modelEntity = try makeEntity(materials: [material]) - if let materialIndex = primitive.material, - let outlineMaterial = try mtoonOutlineMaterial(withMaterialIndex: materialIndex) { - let outlineEntity = try makeEntity(materials: [outlineMaterial]) - outlineEntity.name = "\(modelEntity.name)_outline" - let container = Entity() - container.name = "\(modelEntity.name)_container" - container.addChild(outlineEntity) - container.addChild(modelEntity) - return container + /// Unlike the generic loader, the VRM path only reports an unimplemented + /// required extension and renders anyway. + override func validateRequiredExtensions() throws { + for name in unsupportedRequiredExtensions() { + Self.gltfLogger.warning("This VRM requires the \(name, privacy: .public) glTF extension, which this renderer does not implement; rendering anyway.") } - return modelEntity } - private func supportsTriangles(_ mode: GLTF.Mesh.Primitive.Mode) -> Bool { - switch mode { - case .TRIANGLES, .TRIANGLE_STRIP, .TRIANGLE_FAN: - return true - case .POINTS, .LINES, .LINE_LOOP, .LINE_STRIP: - return false + /// Some VRM meshes split primitives by indices but share the same POSITION + /// accessor, and only one of them carries the morph targets. SceneKit reuses + /// the morpher across such primitives, so mimic that by sharing the targets. + override func resolvedPrimitives(of mesh: GLTF.Mesh) -> [GLTF.Mesh.Primitive] { + var targetsByPositionAccessor: [Int: [[GLTF.Mesh.Primitive.AttributeKey: Int]]] = [:] + for primitive in mesh.primitives { + guard let targets = primitive.targets, !targets.isEmpty, + let positionAccessor = primitive.attributes.rawValue[.POSITION] else { continue } + if targetsByPositionAccessor[positionAccessor] == nil { + targetsByPositionAccessor[positionAccessor] = targets + } } - } + guard !targetsByPositionAccessor.isEmpty else { return mesh.primitives } - /// glTF requires a TRIANGLES primitive to hold a non-zero multiple of three - /// indices and a strip / fan to hold at least three, so a primitive that does - /// not fails the load rather than being quietly trimmed into a valid one. - private func triangulatedIndices(for mode: GLTF.Mesh.Primitive.Mode, - indices: [UInt32]) throws -> [UInt32] { - switch mode { - case .TRIANGLES: - guard !indices.isEmpty, indices.count.isMultiple(of: 3) else { - throw VRMError._dataInconsistent( - "a TRIANGLES primitive needs a non-zero multiple of 3 indices, but has \(indices.count)" - ) - } - return indices - case .TRIANGLE_STRIP: - guard indices.count >= 3 else { - throw VRMError._dataInconsistent( - "a TRIANGLE_STRIP primitive needs at least 3 indices, but has \(indices.count)" - ) + return mesh.primitives.map { primitive in + guard primitive.targets?.isEmpty ?? true, + let positionAccessor = primitive.attributes.rawValue[.POSITION], + let sharedTargets = targetsByPositionAccessor[positionAccessor] else { + return primitive } - var result: [UInt32] = [] - result.reserveCapacity((indices.count - 2) * 3) - for i in 0..<(indices.count - 2) { - let i0 = indices[i] - let i1 = indices[i + 1] - let i2 = indices[i + 2] - if i.isMultiple(of: 2) { - result.append(contentsOf: [i0, i1, i2]) - } else { - result.append(contentsOf: [i1, i0, i2]) - } - } - return result - case .TRIANGLE_FAN: - guard indices.count >= 3 else { - throw VRMError._dataInconsistent( - "a TRIANGLE_FAN primitive needs at least 3 indices, but has \(indices.count)" - ) - } - let base = indices[0] - var result: [UInt32] = [] - result.reserveCapacity((indices.count - 2) * 3) - for i in 1..<(indices.count - 1) { - result.append(contentsOf: [base, indices[i], indices[i + 1]]) - } - return result - case .POINTS, .LINES, .LINE_LOOP, .LINE_STRIP: - // Filtered out by supportsTriangles() before the indices are read. - throw VRMError._notSupported("\(mode) primitives have no triangles") + var shared = primitive + shared.targets = sharedTargets + return shared } } - func material(withMaterialIndex index: Int) throws -> Material { - if let cache = try entityData.load(\.materials, index: index) { return cache } - let (gltfMaterial, materialProperty) = try materialSource(withMaterialIndex: index) -#if !os(visionOS) + /// Unlike the generic loader, a VRM whose material this renderer cannot build + /// still renders, with the default material in its place. + override func primitiveMaterial(withMaterialIndex index: Int) throws -> Material { do { - // Building the state reads the extension's textures and samplers, so - // it fails on the same malformed files the material build does. - if let state = try mtoonState(withMaterialIndex: index) { - let material = try customMToonMaterial(state) - entityData.materials[index] = material - return material - } + return try super.primitiveMaterial(withMaterialIndex: index) } catch { - // The caller falls back to a non-MToon material, so the state has - // to go with it: otherwise expression binds would keep writing - // parameter rows that nothing on screen reads. - discardMToonState(withMaterialIndex: index) - Self.logger.error("Failed to build the MToon material \(index, privacy: .public); falling back to Unlit / PBR: \(String(describing: error), privacy: .public)") - } -#endif - - let shaderName = materialProperty?.shader.lowercased() - let isMToon = try mtoonDescriptor(withMaterialIndex: index) != nil - // MToon / Unlit variants are not PBR, so use UnlitMaterial for consistent rendering - // This matches SceneKit's behavior which uses lightingModel = .constant - let isUnlit = shaderName?.contains("unlit") == true || gltfMaterial.extensions?.materialsUnlit != nil - let useUnlit = isMToon || isUnlit - let resolvedAlphaMode = GLTF.Material.AlphaMode(vrm0: materialProperty, - fallback: gltfMaterial.alphaMode) - let tint = gltfMaterial.pbrMetallicRoughness - .map { VRMColor(simd: SIMD4($0.baseColorFactor)) } ?? .white - - if useUnlit { - // RealityKit tone maps everything it draws, and that curve visibly - // darkens flat art, so the unlit path opts out of it. - var material = UnlitMaterial(applyPostProcessToneMap: false) - if let pbr = gltfMaterial.pbrMetallicRoughness, - let baseTexture = pbr.baseColorTexture { - let textureParam = try materialTexture(withTextureIndex: baseTexture.index, semantic: .color) - material.color = .init(tint: tint, texture: textureParam) - } else { - material.color = .init(tint: tint) - } - applyAlphaMode(resolvedAlphaMode, alphaCutoff: gltfMaterial.alphaCutoff, to: &material) - if gltfMaterial.doubleSided { - material.faceCulling = .none - } - entityData.materials[index] = material - return material - } - - var material = PhysicallyBasedMaterial() - if let pbr = gltfMaterial.pbrMetallicRoughness { - if let baseTexture = pbr.baseColorTexture { - let textureParam = try materialTexture(withTextureIndex: baseTexture.index, semantic: .color) - material.baseColor = .init(tint: tint, texture: textureParam) - } else { - material.baseColor = .init(tint: tint) - } - - if let metallicTexture = pbr.metallicRoughnessTexture { - let textures = try metallicRoughnessTextures(withTextureIndex: metallicTexture.index) - material.metallic.texture = textures.metal - material.roughness.texture = textures.rough - } else { - material.metallic = .init(floatLiteral: pbr.metallicFactor) - material.roughness = .init(floatLiteral: pbr.roughnessFactor) - } - } else { - material.baseColor = .init(tint: tint) - material.metallic = .init(floatLiteral: 1.0) - material.roughness = .init(floatLiteral: 1.0) - } - - if let normalTexture = gltfMaterial.normalTexture { - material.normal.texture = try materialTexture(withTextureIndex: normalTexture.index, semantic: .normal) - } - - if let occlusionTexture = gltfMaterial.occlusionTexture { - material.ambientOcclusion.texture = try materialTexture(withTextureIndex: occlusionTexture.index, semantic: .color) - } - - let emissiveFactor = gltfMaterial.emissiveFactor - let emissiveTint = VRMColor(red: CGFloat(emissiveFactor.r), - green: CGFloat(emissiveFactor.g), - blue: CGFloat(emissiveFactor.b), - alpha: 1) - let hasEmissiveTint = emissiveFactor.r != 0 || emissiveFactor.g != 0 || emissiveFactor.b != 0 - if let emissiveTexture = gltfMaterial.emissiveTexture { - let textureParam = try materialTexture(withTextureIndex: emissiveTexture.index, semantic: .color) - material.emissiveColor = .init(color: emissiveTint, - texture: textureParam) - } else if hasEmissiveTint { - material.emissiveColor = .init(color: emissiveTint) - } - - applyAlphaMode(resolvedAlphaMode, alphaCutoff: gltfMaterial.alphaCutoff, to: &material) - if gltfMaterial.doubleSided { - material.faceCulling = .none - } - - entityData.materials[index] = material - return material - } - -#if !os(visionOS) - private func customMToonMaterial(_ state: MToonState) throws -> Material { - // RealityKit has no material-level draw-order hook, so MToon's - // renderQueueOffsetNumber is not honored here. - let mtoon = state.descriptor - let surface = CustomMaterial.SurfaceShader(named: "mtoonSurface", in: state.library) - var material = try CustomMaterial(surfaceShader: surface, lightingModel: .unlit) - // MToon needs more textures than CustomMaterial has semantic channels, - // so the extra slots ride on unrelated channels; MToon.metal reads them - // back through the same mapping. - material.baseColor = .init(tint: .white, texture: try mtoonTexture(mtoon, slot: .base)) - material.roughness.texture = try mtoonTexture(mtoon, slot: .shade) - material.specular.texture = try mtoonTexture(mtoon, slot: .shadingShift) - material.metallic.texture = try mtoonTexture(mtoon, slot: .matcap) - material.normal.texture = try mtoonTexture(mtoon, slot: .normal) - material.emissiveColor = .init(color: .white, texture: try mtoonTexture(mtoon, slot: .emissive)) - material.clearcoatRoughness.texture = try mtoonTexture(mtoon, slot: .rim) - material.clearcoat.texture = try mtoonTexture(mtoon, slot: .outlineWidth) - material.ambientOcclusion.texture = try mtoonTexture(mtoon, slot: .uvAnimationMask) - - applyAlphaMode(mtoon.alphaMode, alphaCutoff: mtoon.alphaCutoff, to: &material) - applyDepthWrite(mtoon, to: &material) - material.faceCulling = mtoon.cullMode.faceCulling - applyMToonParameters(state, to: &material) - return material - } - - private func customMToonOutlineMaterial(_ state: MToonState) throws -> Material { - let mtoon = state.descriptor - let surface = CustomMaterial.SurfaceShader(named: "mtoonOutlineSurface", in: state.library) - let geometry = CustomMaterial.GeometryModifier(named: "mtoonOutlineGeometry", in: state.library) - var material = try CustomMaterial(surfaceShader: surface, - geometryModifier: geometry, - lightingModel: .unlit) - // The inverted hull is rendered with front faces culled. - material.faceCulling = .front - material.baseColor = .init(tint: .white, texture: try mtoonTexture(mtoon, slot: .base)) - material.clearcoat.texture = try mtoonTexture(mtoon, slot: .outlineWidth) - material.ambientOcclusion.texture = try mtoonTexture(mtoon, slot: .uvAnimationMask) - applyAlphaMode(mtoon.alphaMode, alphaCutoff: mtoon.alphaCutoff, to: &material) - applyDepthWrite(mtoon, to: &material) - applyMToonParameters(state, to: &material) - return material - } - - /// MToon.metal applies the UV transform from the parameter rows, so - /// `textureCoordinateTransform` is deliberately left at identity here: - /// setting it too would transform the primary UV a second time. - private func applyMToonParameters(_ state: MToonState, to material: inout CustomMaterial) { - material.custom.value = state.parameters.customValue - material.custom.texture = state.parameterTexture - } - - /// Resolves the descriptor's texture for `slot`, falling back to the slot's - /// neutral texture when the material does not provide one. - private func mtoonTexture(_ descriptor: MToonMaterialDescriptor, - slot: MToonTextureSlot) throws -> CustomMaterial.Texture { - guard let texture = descriptor.texture(for: slot) else { - return CustomMaterial.Texture(try fallbackTextureResource(slot.fallback)) + Self.gltfLogger.error("Failed to build the material \(index, privacy: .public); falling back to the default material: \(String(describing: error), privacy: .public)") + return defaultMaterial() } - return CustomMaterial.Texture(try self.texture(withTextureIndex: texture.index, semantic: slot.semantic)) } -#endif + /// The color a `materialColorBind` starts from. MToon keeps it in its + /// parameter rows, everything else in the RealityKit material. func currentMaterialColor(withMaterialIndex index: Int, type: VRM1.Expressions.Expression.MaterialColorBind.MaterialColorType) throws -> SIMD4 { if let color = try mtoonParameters(withMaterialIndex: index)?.color(for: type) { @@ -614,1203 +166,11 @@ open class VRMEntityLoader { return try material(withMaterialIndex: index).currentTextureTransform } - func mtoonParameters(withMaterialIndex index: Int) throws -> MToonMaterialParameters? { -#if os(visionOS) - return nil -#else - // A nil state means the material does not render as MToon (disabled, - // no metallib, or not an MToon material). - return try mtoonState(withMaterialIndex: index)?.parameters -#endif - } - -#if !os(visionOS) - private func mtoonState(withMaterialIndex index: Int) throws -> MToonState? { - if let cached = mtoonStateCache[index] { - return cached - } - let state = try makeMToonState(withMaterialIndex: index) - mtoonStateCache[index] = state - return state - } - - /// Records that the material does not render as MToon after all, so every - /// MToon-derived path — surface, outline, runtime parameters — agrees. - private func discardMToonState(withMaterialIndex index: Int) { - mtoonStateCache.updateValue(nil, forKey: index) - mtoonOutlineMaterialCache.updateValue(nil, forKey: index) - } - - private func makeMToonState(withMaterialIndex index: Int) throws -> MToonState? { - guard isMToonEnabled, - let descriptor = try mtoonDescriptor(withMaterialIndex: index), - let library = mtoonShaderLibrary() else { - return nil - } - let textureTransform = mtoonTextureTransform(withMaterialIndex: index, descriptor: descriptor) - let parameters = try mtoonParameters(for: descriptor, textureTransform: textureTransform) - logMToonUnsupportedFeatures(for: descriptor, index: index) - return MToonState(descriptor: descriptor, - parameters: parameters, - parameterTexture: CustomMaterial.Texture(try parameters.textureResource()), - library: library) - } - - /// MToon features that this renderer cannot express are reported once per - /// material instead of being dropped silently. - private func logMToonUnsupportedFeatures(for descriptor: MToonMaterialDescriptor, index: Int) { - if descriptor.renderQueueOffsetNumber != 0 { - Self.logger.warning("MToon material \(index, privacy: .public) requests renderQueueOffsetNumber \(descriptor.renderQueueOffsetNumber); RealityKit has no material-level draw-order hook, so it is ignored.") - } - } -#endif - - private func mtoonParameters(for descriptor: MToonMaterialDescriptor, - textureTransform: MaterialParameterTypes.TextureCoordinateTransform) throws -> MToonMaterialParameters { - var parameters = MToonMaterialParameters(descriptor) - parameters.setTextureTransform(scale: textureTransform.scale, - offset: textureTransform.offset, - rotation: textureTransform.rotation) - for slot in MToonTextureSlot.allCases { - try parameters.setSampler(mtoonSamplerParameters(for: descriptor.texture(for: slot)), for: slot) - } - return parameters - } - - /// Custom meshes expose only TEXCOORD_0 and `CustomMaterial` has a single - /// material-level UV transform, so the descriptor's first UV-accessed - /// transform is applied to every MToon texture. - private func mtoonTextureTransform(withMaterialIndex index: Int, - descriptor: MToonMaterialDescriptor) -> MaterialParameterTypes.TextureCoordinateTransform { - let textures = descriptor.uvAccessedTextures - // The first UV-accessed slot wins even when it has no transform of its - // own; letting a later slot's transform stand in would shift it. - let selectedTransform = textures.first?.transform - .map { MaterialParameterTypes.TextureCoordinateTransform(offset: $0.offset, - scale: $0.scale, - rotation: $0.rotation) } - ?? MaterialParameterTypes.TextureCoordinateTransform() - let usesUnsupportedTexCoord = textures.contains { $0.texCoord != 0 } - let hasDifferentTransforms = textures.contains { - textureTransform($0.transform ?? .init(), differsFrom: selectedTransform) - } - if (usesUnsupportedTexCoord || hasDifferentTransforms), - loggedMToonUVLimitations.insert(index).inserted { - if usesUnsupportedTexCoord { - Self.logger.warning("MToon material \(index, privacy: .public) requests a nonzero texCoord; RealityKit uses TEXCOORD_0 on supported deployment targets.") - } - if hasDifferentTransforms { - Self.logger.warning("MToon material \(index, privacy: .public) has per-texture KHR_texture_transform values; RealityKit uses the first UV-accessed transform for all MToon textures.") - } - } - return selectedTransform - } - - private func textureTransform(_ lhs: MToonMaterialDescriptor.UVTransform, - differsFrom rhs: MaterialParameterTypes.TextureCoordinateTransform) -> Bool { - lhs.scale != rhs.scale || lhs.offset != rhs.offset || abs(lhs.rotation - rhs.rotation) > 0.000_001 - } - - private func mtoonOutlineMaterial(withMaterialIndex index: Int) throws -> Material? { -#if os(visionOS) - return nil -#else - guard isOutlineEnabled else { - return nil - } - if let cached = mtoonOutlineMaterialCache[index] { - return cached - } - let material: Material? - if let state = try mtoonState(withMaterialIndex: index), state.descriptor.hasOutline { - material = try customMToonOutlineMaterial(state) - } else { - material = nil - } - mtoonOutlineMaterialCache[index] = material - return material -#endif - } - - /// Memoized because both the material-type decision and the MToon state - /// need it, and building it runs the whole VRM 0.x migration. - private func mtoonDescriptor(withMaterialIndex index: Int) throws -> MToonMaterialDescriptor? { - if let cached = mtoonDescriptorCache[index] { - return cached - } - let descriptor = try makeMToonDescriptor(withMaterialIndex: index) - mtoonDescriptorCache[index] = descriptor - return descriptor - } - - private func makeMToonDescriptor(withMaterialIndex index: Int) throws -> MToonMaterialDescriptor? { - let (gltfMaterial, materialProperty) = try materialSource(withMaterialIndex: index) - return MToonMaterialDescriptor(material: gltfMaterial, materialProperty: materialProperty) - } - - /// The glTF material and, for VRM 0.x, the Unity material property that - /// describes it. The VRM 0.x name lookup lives here only. - private func materialSource(withMaterialIndex index: Int) throws -> (GLTF.Material, VRM0.MaterialProperty?) { - let gltfMaterial = try gltf.load(\.materials, at: index) + override func vrm0MaterialProperty(for gltfMaterial: GLTF.Material) -> VRM0.MaterialProperty? { guard case .v0(let vrm0) = vrm, let name = gltfMaterial.name else { - return (gltfMaterial, nil) - } - return (gltfMaterial, vrm0.materialPropertyNameMap[name]) - } - - private func mtoonShaderLibrary() -> MTLLibrary? { - do { - return try MToonShaderLibraryLoader.loadDefault() - } catch { - if !loggedMToonLibraryError { - loggedMToonLibraryError = true - Self.logger.error("Failed to load bundled MToon shader library: \(String(describing: error), privacy: .public)") - } - return nil - } - } - - func texture(withTextureIndex index: Int, semantic: TextureResource.Semantic = .color) throws -> TextureResource { - if semantic == .color, let cache = try entityData.load(\.textures, index: index) { - return cache - } - if semantic != .color, let cache = textureCacheBySemantic[semantic]?[index] { - return cache - } - let gltfTexture = try gltf.load(\.textures, at: index) - let image = try image(withImageIndex: gltfTexture.source) - let cgImage = try image.cgImage ??? .dataInconsistent("failed to load cgImage") - let texture = try TextureResource(image: cgImage, options: .init(semantic: semantic)) - if semantic == .color { - entityData.textures[index] = texture - } else { - textureCacheBySemantic[semantic, default: [:]][index] = texture - } - return texture - } - - private func materialTexture(withTextureIndex index: Int, - semantic: TextureResource.Semantic = .color) throws -> MaterialParameters.Texture { - let texture = try texture(withTextureIndex: index, semantic: semantic) - let sampler = try sampler(withTextureIndex: index) - return MaterialParameters.Texture(texture, sampler: sampler) - } - - /// The neutral 1x1 texture bound when a material omits an MToon slot. - private func fallbackTextureResource(_ fallback: MToonTextureSlot.Fallback) throws -> TextureResource { - if let cached = fallbackTextureCache[fallback] { - return cached - } - let texture: TextureResource - switch fallback { - case .white: - texture = try solidColorTextureResource(rgba: [255, 255, 255, 255], semantic: .color) - case .neutralNormal: - texture = try solidColorTextureResource(rgba: [128, 128, 255, 255], semantic: .normal) - } - fallbackTextureCache[fallback] = texture - return texture - } - - private func solidColorTextureResource(rgba: [UInt8], - semantic: TextureResource.Semantic) throws -> TextureResource { - guard let provider = CGDataProvider(data: Data(rgba) as CFData), - let image = CGImage(width: 1, - height: 1, - bitsPerComponent: 8, - bitsPerPixel: 32, - bytesPerRow: 4, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), - provider: provider, - decode: nil, - shouldInterpolate: false, - intent: .defaultIntent) else { - throw VRMError._dataInconsistent("failed to create 1x1 \(semantic) texture") - } - return try TextureResource(image: image, options: .init(semantic: semantic)) - } - - private func sampler(withTextureIndex index: Int) throws -> MaterialParameters.Texture.Sampler { - if let cache = samplerCache[index] { - return cache - } - let descriptor = MTLSamplerDescriptor() - applySampler(try gltfSampler(withTextureIndex: index), to: descriptor) - let sampler = MaterialParameters.Texture.Sampler(descriptor) - samplerCache[index] = sampler - return sampler - } - - /// The glTF sampler a texture references, or nil when it relies on the - /// glTF default sampler. - private func gltfSampler(withTextureIndex index: Int) throws -> GLTF.Sampler? { - let textures = try gltf.load(\.textures) - guard textures.indices.contains(index) else { - throw VRMError._dataInconsistent("Texture index \(index) out of bounds") - } - guard let samplerIndex = textures[index].sampler else { - return nil - } - let samplers = try gltf.load(\.samplers) - guard samplers.indices.contains(samplerIndex) else { - throw VRMError._dataInconsistent("Sampler index \(samplerIndex) out of bounds") - } - return samplers[samplerIndex] - } - - /// A nil sampler means the texture relies on the glTF defaults. - private func applySampler(_ sampler: GLTF.Sampler?, to descriptor: MTLSamplerDescriptor) { - descriptor.magFilter = metalFilter(sampler?.magFilter ?? .LINEAR) - let (min, mip) = metalFilters(sampler?.minFilter ?? .LINEAR_MIPMAP_LINEAR) - descriptor.minFilter = min - descriptor.mipFilter = mip - descriptor.sAddressMode = metalWrap(sampler?.wrapS ?? .REPEAT) - descriptor.tAddressMode = metalWrap(sampler?.wrapT ?? .REPEAT) - } - - private func mtoonSamplerParameters(for texture: MToonMaterialDescriptor.Texture?) throws -> SIMD4 { - guard let texture, - let sampler = try gltfSampler(withTextureIndex: texture.index) else { - return MToonMaterialParameters.defaultSampler - } - return mtoonSamplerParameters(sampler) - } - - /// (wrapS, wrapT, filterIndex, 0), matching the sampler rows `MToon.metal` - /// selects its samplers with. - private func mtoonSamplerParameters(_ sampler: GLTF.Sampler) -> SIMD4 { - let (minFilter, mipFilter) = metalFilters(sampler.minFilter ?? .LINEAR_MIPMAP_LINEAR) - let filter = MToonSamplerFilter( - magnification: metalFilter(sampler.magFilter ?? .LINEAR) == .nearest ? .nearest : .linear, - minification: minFilter == .nearest ? .nearest : .linear, - mip: MToonSamplerFilter.MipFilter(mipFilter) - ) - return SIMD4(mtoonWrapMode(sampler.wrapS), - mtoonWrapMode(sampler.wrapT), - Float(filter.index), - 0) - } - - private func mtoonWrapMode(_ wrap: GLTF.Sampler.Wrap) -> Float { - switch wrap { - case .REPEAT: return 0 - case .CLAMP_TO_EDGE: return 1 - case .MIRRORED_REPEAT: return 2 - } - } - - private func metalFilter(_ filter: GLTF.Sampler.MagFilter) -> MTLSamplerMinMagFilter { - switch filter { - case .NEAREST: return .nearest - case .LINEAR: return .linear - } - } - - private func metalFilters(_ filter: GLTF.Sampler.MinFilter) -> (min: MTLSamplerMinMagFilter, mip: MTLSamplerMipFilter) { - switch filter { - case .NEAREST: - return (.nearest, .notMipmapped) - case .LINEAR: - return (.linear, .notMipmapped) - case .NEAREST_MIPMAP_NEAREST: - return (.nearest, .nearest) - case .LINEAR_MIPMAP_NEAREST: - return (.linear, .nearest) - case .NEAREST_MIPMAP_LINEAR: - return (.nearest, .linear) - case .LINEAR_MIPMAP_LINEAR: - return (.linear, .linear) - } - } - - private func metalWrap(_ wrap: GLTF.Sampler.Wrap) -> MTLSamplerAddressMode { - switch wrap { - case .CLAMP_TO_EDGE: return .clampToEdge - case .MIRRORED_REPEAT: return .mirrorRepeat - case .REPEAT: return .repeat - } - } - - func image(withImageIndex index: Int) throws -> VRMImage { - if let cache = try entityData.load(\.images, index: index) { return cache } - let gltfImage = try gltf.load(\.images, at: index) - let image = try VRMImage.from(gltfImage, relativeTo: rootDirectory) { index in - try self.bufferView(withBufferViewIndex: index).bufferView - } - entityData.images[index] = image - return image - } - - func bufferView(withBufferViewIndex index: Int) throws -> (bufferView: Data, stride: Int?) { - if let cache = try entityData.load(\.bufferViews, index: index) { - let gltfBufferView = try gltf.load(\.bufferViews, at: index) - return (cache, gltfBufferView.byteStride) - } - let result = try vrm.gltf.bufferViewData(at: index, relativeTo: rootDirectory) - entityData.bufferViews[index] = result.data - return (result.data, result.stride) - } - - private func metallicRoughnessTextures(withTextureIndex index: Int) throws -> (metal: MaterialParameters.Texture, rough: MaterialParameters.Texture) { - let resources: (metal: TextureResource, rough: TextureResource) - if let cache = metallicRoughnessCache[index] { - resources = cache - } else { - let gltfTexture = try gltf.load(\.textures, at: index) - let image = try image(withImageIndex: gltfTexture.source) - let textures = try createMetallicRoughnessTextures(from: image) - metallicRoughnessCache[index] = textures - resources = textures - } - let sampler = try sampler(withTextureIndex: index) - return (MaterialParameters.Texture(resources.metal, sampler: sampler), - MaterialParameters.Texture(resources.rough, sampler: sampler)) - } - - private func createMetallicRoughnessTextures(from uiImage: VRMImage) throws -> (metal: TextureResource, rough: TextureResource) { - guard let image = uiImage.cgImage else { - throw VRMError._dataInconsistent("failed to load cgImage") - } - - let pixelCount = image.width * image.height - let bitsPerComponent = 8 - let componentsPerPixel = 4 - let srcBytesPerPixel = bitsPerComponent * componentsPerPixel / 8 - let srcDataSize = pixelCount * srcBytesPerPixel - - let ptr = UnsafeMutablePointer.allocate(capacity: srcDataSize) - let metalPtr = UnsafeMutablePointer.allocate(capacity: pixelCount) - let roughPtr = UnsafeMutablePointer.allocate(capacity: pixelCount) - defer { - ptr.deallocate() - metalPtr.deallocate() - roughPtr.deallocate() - } - - guard let context = CGContext( - data: UnsafeMutableRawPointer(ptr), - width: image.width, - height: image.height, - bitsPerComponent: bitsPerComponent, - bytesPerRow: srcBytesPerPixel * image.width, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue - ) else { - throw VRMError._dataInconsistent("failed to create cgcontext") - } - context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) - - for dstPos in 0..) throws -> CGImage { - guard let data = CFDataCreate(nil, dataPointer, width * height), - let provider = CGDataProvider(data: data) else { - throw VRMError._dataInconsistent("failed to create image data") - } - guard let image = CGImage( - width: width, - height: height, - bitsPerComponent: 8, - bitsPerPixel: 8, - bytesPerRow: width, - space: CGColorSpaceCreateDeviceGray(), - bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue), - provider: provider, - decode: nil, - shouldInterpolate: false, - intent: .defaultIntent - ) else { - throw VRMError._dataInconsistent("failed to create CGImage") - } - return image - } - - /// The single glTF alpha-mode → RealityKit blending decision, shared by - /// every material type this loader builds. - private struct AlphaModeSettings { - let isTransparent: Bool - let opacityThreshold: Float? - - init(_ mode: GLTF.Material.AlphaMode, alphaCutoff: Float) { - switch mode { - case .OPAQUE: - (isTransparent, opacityThreshold) = (false, nil) - case .MASK: - (isTransparent, opacityThreshold) = (false, alphaCutoff) - case .BLEND: - (isTransparent, opacityThreshold) = (true, nil) - } - } - } - - private func applyAlphaMode(_ mode: GLTF.Material.AlphaMode, - alphaCutoff: Float, - to material: inout UnlitMaterial) { - let settings = AlphaModeSettings(mode, alphaCutoff: alphaCutoff) - material.blending = settings.isTransparent ? .transparent(opacity: .init(scale: 1.0)) : .opaque - material.opacityThreshold = settings.opacityThreshold - } - - private func applyAlphaMode(_ mode: GLTF.Material.AlphaMode, - alphaCutoff: Float, - to material: inout PhysicallyBasedMaterial) { - let settings = AlphaModeSettings(mode, alphaCutoff: alphaCutoff) - material.blending = settings.isTransparent ? .transparent(opacity: .init(scale: 1.0)) : .opaque - material.opacityThreshold = settings.opacityThreshold - } - -#if !os(visionOS) - private func applyAlphaMode(_ mode: GLTF.Material.AlphaMode, - alphaCutoff: Float, - to material: inout CustomMaterial) { - let settings = AlphaModeSettings(mode, alphaCutoff: alphaCutoff) - material.blending = settings.isTransparent ? .transparent(opacity: .init(scale: 1.0)) : .opaque - material.opacityThreshold = settings.opacityThreshold - } - - /// MToon's `transparentWithZWrite` asks a blended material to still write - /// depth. Only BLEND materials can turn depth writing off, so every other - /// alpha mode keeps it on. - private func applyDepthWrite(_ mtoon: MToonMaterialDescriptor, to material: inout CustomMaterial) { - material.writesDepth = mtoon.alphaMode != .BLEND || mtoon.transparentWithZWrite - } -#endif - - private struct AccessorSlice { - let data: Data - let componentsPerVector: Int - let bytesPerComponent: Int - let count: Int - let componentType: GLTF.Accessor.ComponentType - let normalized: Bool - } - - private func accessorSlice(_ index: Int) throws -> AccessorSlice { - if let cache = try entityData.load(\.accessors, index: index) as? AccessorSlice { - return cache - } - let accessors = try gltf.load(\.accessors) - guard accessors.indices.contains(index) else { - throw VRMError._dataInconsistent("accessor index \(index) is out of range for \(accessors.count) accessors") - } - let accessor = accessors[index] - let (componentsPerVector, bytesPerComponent, _) = accessor.components() - let slice = AccessorSlice( - data: try accessor.packedData(bufferView: { try self.bufferView(withBufferViewIndex: $0) }), - componentsPerVector: componentsPerVector, - bytesPerComponent: bytesPerComponent, - count: accessor.count, - componentType: accessor.componentType, - normalized: accessor.normalized - ) - entityData.accessors[index] = slice - return slice - } - - private func vector2s(_ accessorIndex: Int) throws -> [SIMD2] { - let slice = try accessorSlice(accessorIndex) - guard slice.componentsPerVector == 2 else { - throw VRMError._dataInconsistent("expected VEC2 accessor") - } - var result: [SIMD2] = [] - result.reserveCapacity(slice.count) - slice.data.withUnsafeBytes { raw in - guard let base = raw.baseAddress else { return } - for i in 0..(x, 1.0 - y)) - } - } - return result - } - - private func vector3s(_ accessorIndex: Int) throws -> [SIMD3] { - let slice = try accessorSlice(accessorIndex) - guard slice.componentsPerVector == 3 else { - throw VRMError._dataInconsistent("expected VEC3 accessor") - } - var result: [SIMD3] = [] - result.reserveCapacity(slice.count) - slice.data.withUnsafeBytes { raw in - guard let base = raw.baseAddress else { return } - for i in 0..(x, y, z)) - } - } - return result - } - - private func vector4s(_ accessorIndex: Int) throws -> [SIMD4] { - let slice = try accessorSlice(accessorIndex) - guard slice.componentsPerVector == 4 else { - throw VRMError._dataInconsistent("expected VEC4 accessor") - } - var result: [SIMD4] = [] - result.reserveCapacity(slice.count) - slice.data.withUnsafeBytes { raw in - guard let base = raw.baseAddress else { return } - for i in 0..(x, y, z, w)) - } - } - return result - } - - private func indexValues(_ accessorIndex: Int) throws -> [UInt32] { - let slice = try accessorSlice(accessorIndex) - guard slice.componentsPerVector == 1 else { - throw VRMError._dataInconsistent("indices accessor must be SCALAR") - } - - var result: [UInt32] = [] - result.reserveCapacity(slice.count) - slice.data.withUnsafeBytes { raw in - guard let base = raw.baseAddress else { return } - for i in 0.. Float { - switch componentType { - case .float: - return base.load(fromByteOffset: offset, as: Float.self) - case .unsignedByte: - let value = Float(base.load(fromByteOffset: offset, as: UInt8.self)) - return normalized ? value / Float(UInt8.max) : value - case .byte: - let value = Float(base.load(fromByteOffset: offset, as: Int8.self)) - if normalized { - return max(-1, value / Float(Int8.max)) - } - return value - case .unsignedShort: - let value = Float(base.load(fromByteOffset: offset, as: UInt16.self)) - return normalized ? value / Float(UInt16.max) : value - case .short: - let value = Float(base.load(fromByteOffset: offset, as: Int16.self)) - if normalized { - return max(-1, value / Float(Int16.max)) - } - return value - case .unsignedInt: - let value = Float(base.load(fromByteOffset: offset, as: UInt32.self)) - return normalized ? value / Float(UInt32.max) : value - } - } - - /// One element of an index-valued accessor — mesh indices and `JOINTS_n`. - /// glTF defines both as unsigned integers, so a signed or floating point - /// component type is a malformed file and yields nil for the caller to throw on. - private func readIndexComponent(base: UnsafeRawPointer, - offset: Int, - componentType: GLTF.Accessor.ComponentType) -> UInt32? { - switch componentType { - case .unsignedByte: - return UInt32(base.load(fromByteOffset: offset, as: UInt8.self)) - case .unsignedShort: - return UInt32(base.load(fromByteOffset: offset, as: UInt16.self)) - case .unsignedInt: - return base.load(fromByteOffset: offset, as: UInt32.self) - case .byte, .short, .float: return nil } + return vrm0.materialPropertyNameMap[name] } - - private func vector4UInts(_ accessorIndex: Int) throws -> [SIMD4] { - let slice = try accessorSlice(accessorIndex) - guard slice.componentsPerVector == 4 else { - throw VRMError._dataInconsistent("expected VEC4 accessor") - } - var result: [SIMD4] = [] - result.reserveCapacity(slice.count) - slice.data.withUnsafeBytes { raw in - guard let base = raw.baseAddress else { return } - for i in 0..(x, y, z, w)) - } - } - if result.count != slice.count { - throw VRMError._dataInconsistent("failed to read joint indices") - } - return result - } - - /// JOINTS_n as glTF defines it: unsigned byte or short indices into the skin. - /// A wider or signed component type means the file indexes joints in a way - /// its own skin does not describe. - private func jointIndices(_ accessorIndex: Int) throws -> [SIMD4] { - let slice = try accessorSlice(accessorIndex) - switch slice.componentType { - case .unsignedByte, .unsignedShort: - return try vector4UInts(accessorIndex) - case .byte, .short, .unsignedInt, .float: - throw VRMError._dataInconsistent( - "JOINTS_0 must use unsigned byte or short components, not \(slice.componentType)" - ) - } - } - - /// WEIGHTS_n as glTF defines it: float, or normalized unsigned byte / short. - /// An unnormalized integer accessor would arrive as raw counts rather than - /// the 0...1 weights the influences are built from. - private func jointWeights(_ accessorIndex: Int) throws -> [SIMD4] { - let slice = try accessorSlice(accessorIndex) - switch slice.componentType { - case .float: - return try vector4s(accessorIndex) - case .unsignedByte, .unsignedShort: - guard slice.normalized else { - throw VRMError._dataInconsistent( - "WEIGHTS_0 with \(slice.componentType) components must be normalized" - ) - } - return try vector4s(accessorIndex) - case .byte, .short, .unsignedInt: - throw VRMError._dataInconsistent( - "WEIGHTS_0 must use float or normalized unsigned byte / short components, not \(slice.componentType)" - ) - } - } - - private func makeJointInfluences(joints: [SIMD4], - weights: [SIMD4], - vertexCount: Int, - jointIndexRemap: [Int]?) throws -> MeshResource.JointInfluences { - guard joints.count == weights.count else { - throw VRMError._dataInconsistent("JOINTS_0 and WEIGHTS_0 counts do not match") - } - guard joints.count == vertexCount else { - throw VRMError._dataInconsistent("joint influence count \(joints.count) does not match vertex count \(vertexCount)") - } - - var influences: [MeshJointInfluence] = [] - influences.reserveCapacity(joints.count * 4) - let remap = jointIndexRemap - // JOINTS_0 comes straight from the file, so an out-of-range index has to - // fail the load rather than reach the remap table or the skeleton. - func remapped(_ jointIndex: UInt32) throws -> Int { - guard let remap else { return Int(jointIndex) } - guard remap.indices.contains(Int(jointIndex)) else { - throw VRMError._dataInconsistent( - "joint index \(jointIndex) is out of range for \(remap.count) skin joints" - ) - } - return remap[Int(jointIndex)] - } - for i in 0.. 0 { - w0 /= sum - w1 /= sum - w2 /= sum - w3 /= sum - } - influences.append(MeshJointInfluence(jointIndex: try remapped(joint.x), weight: w0)) - influences.append(MeshJointInfluence(jointIndex: try remapped(joint.y), weight: w1)) - influences.append(MeshJointInfluence(jointIndex: try remapped(joint.z), weight: w2)) - influences.append(MeshJointInfluence(jointIndex: try remapped(joint.w), weight: w3)) - } - - let buffer = MeshBuffer(influences) - return MeshResource.JointInfluences(influences: buffer, influencesPerVertex: 4) - } - - private func matrix4s(_ accessorIndex: Int) throws -> [simd_float4x4] { - let slice = try accessorSlice(accessorIndex) - guard slice.componentsPerVector == 16 else { - throw VRMError._dataInconsistent("expected MAT4 accessor") - } - guard slice.componentType == .float else { - throw VRMError._dataInconsistent("MAT4 accessor must be float") - } - var result: [simd_float4x4] = [] - result.reserveCapacity(slice.count) - slice.data.withUnsafeBytes { raw in - guard let base = raw.baseAddress else { return } - for i in 0..(values[0], values[1], values[2], values[3]), - SIMD4(values[4], values[5], values[6], values[7]), - SIMD4(values[8], values[9], values[10], values[11]), - SIMD4(values[12], values[13], values[14], values[15]) - )) - result.append(matrix) - } - } - if result.count != slice.count { - throw VRMError._dataInconsistent("failed to read inverse bind matrices") - } - return result - } - - private func skeleton(withSkinIndex index: Int) throws -> MeshResource.Skeleton { - if let cache = try entityData.load(\.skins, index: index) { return cache } - let skin = try gltf.load(\.skins, at: index) - let nodes = try gltf.load(\.nodes) - let (parentIndices, order, remap) = computeSkinJointOrdering(skin: skin, nodes: nodes) - entityData.skinJointRemaps[index] = remap - - // glTF defines an absent inverseBindMatrices as identity per joint, but a - // present one has to cover every joint: silently substituting identities - // for a broken accessor would bind the mesh to the wrong rest pose. - let inverseBindMatrices: [simd_float4x4] - if let accessorIndex = skin.inverseBindMatrices { - inverseBindMatrices = try matrix4s(accessorIndex) - guard inverseBindMatrices.count >= skin.joints.count else { - throw VRMError._dataInconsistent( - "inverseBindMatrices has \(inverseBindMatrices.count) elements for \(skin.joints.count) skin joints" - ) - } - } else { - inverseBindMatrices = Array(repeating: matrix_identity_float4x4, count: skin.joints.count) - } - - var joints: [MeshResource.Skeleton.Joint] = [] - joints.reserveCapacity(order.count) - for newIndex in 0.. [Int] { - if let cache = try entityData.load(\.skinJointRemaps, index: index) { return cache } - let skin = try gltf.load(\.skins, at: index) - let nodes = try gltf.load(\.nodes) - let (_, _, remap) = computeSkinJointOrdering(skin: skin, nodes: nodes) - entityData.skinJointRemaps[index] = remap - return remap - } - - private func computeSkinJointOrdering(skin: GLTF.Skin, - nodes: [GLTF.Node]) -> (parentIndices: [Int?], order: [Int], remap: [Int]) { - let jointNodeIndices = skin.joints - let jointIndexMap = Dictionary(uniqueKeysWithValues: jointNodeIndices.enumerated().map { ($0.element, $0.offset) }) - - var parentMap: [Int: Int] = [:] - for (nodeIndex, node) in nodes.enumerated() { - for child in node.children ?? [] { - parentMap[child] = nodeIndex - } - } - - var parentIndices: [Int?] = Array(repeating: nil, count: jointNodeIndices.count) - for (i, nodeIndex) in jointNodeIndices.enumerated() { - var current = nodeIndex - while let parent = parentMap[current] { - if let jointIndex = jointIndexMap[parent] { - parentIndices[i] = jointIndex - break - } - current = parent - } - } - - var children: [[Int]] = Array(repeating: [], count: jointNodeIndices.count) - for (i, parent) in parentIndices.enumerated() { - if let parent = parent { - children[parent].append(i) - } - } - - var order: [Int] = [] - order.reserveCapacity(jointNodeIndices.count) - func visit(_ index: Int) { - order.append(index) - for child in children[index] { - visit(child) - } - } - - let roots = parentIndices.enumerated().compactMap { $0.element == nil ? $0.offset : nil } - for root in roots { - visit(root) - } - if order.count < jointNodeIndices.count { - for i in 0..], - normals: [SIMD3], - tangentFrame: TangentFrame, - texcoords: [SIMD2], - indices: [UInt32], - blendShapeOffsets: [[SIMD3]], - skeleton: MeshResource.Skeleton?, - jointInfluences: MeshResource.JointInfluences?) throws -> MeshResource { - var part = MeshResource.Part(id: UUID().uuidString, materialIndex: 0) - part.positions = MeshBuffer(positions) - if !normals.isEmpty { - part.normals = MeshBuffer(normals) - } - if !tangentFrame.tangents.isEmpty { - part.tangents = MeshBuffer(tangentFrame.tangents) - part.bitangents = MeshBuffer(tangentFrame.bitangents) - } - if !texcoords.isEmpty { - part.textureCoordinates = MeshBuffer(texcoords) - } - part.triangleIndices = MeshBuffer(indices) - if !blendShapeOffsets.isEmpty { - for (targetIndex, offsets) in blendShapeOffsets.enumerated() { - let name = "blendShape_\(targetIndex)" - part.setBlendShapeOffsets(named: name, buffer: MeshBuffer(offsets)) - } - _ = part.blendShapeNames - } - if let skeleton, let jointInfluences { - part.skeletonID = skeleton.id - part.jointInfluences = jointInfluences - } - - let modelID = UUID().uuidString - let model = MeshResource.Model(id: modelID, parts: [part]) - - var models = MeshModelCollection() - _ = models.insert(model) - - var instances = MeshInstanceCollection() - _ = instances.insert(MeshResource.Instance(id: modelID, model: modelID)) - - var contents = MeshResource.Contents() - contents.models = models - contents.instances = instances - if let skeleton { - var skeletons = MeshSkeletonCollection() - _ = skeletons.insert(skeleton) - contents.skeletons = skeletons - } - - return try MeshResource.generate(from: contents) - } - - private func registerSkinBinding(modelEntity: ModelEntity, - skinIndex: Int, - skeleton: MeshResource.Skeleton) throws { - guard let vrmEntity = currentEntity else { return } - let skin = try gltf.load(\.skins, at: skinIndex) - var jointEntities = try skin.joints.map { try node(withNodeIndex: $0) } - if let remap = try? jointIndexRemap(forSkinIndex: skinIndex), remap.count == jointEntities.count { - var ordered: [Entity] = Array(repeating: jointEntities[0], count: jointEntities.count) - for (oldIndex, newIndex) in remap.enumerated() { - ordered[newIndex] = jointEntities[oldIndex] - } - jointEntities = ordered - } - vrmEntity.registerSkinBinding(modelEntity: modelEntity, - skeleton: skeleton, - jointEntities: jointEntities) - } - - private func registerMaterialBindings(in root: Entity) { - guard let vrmEntity = currentEntity else { return } - for modelEntity in root.modelEntitiesInHierarchy { - guard let materialIndex = modelEntity.components[VRMMaterialIndexComponent.self]?.materialIndex else { - continue - } - vrmEntity.registerMaterialBinding(modelEntity: modelEntity, - materialIndex: materialIndex, - loader: self) - } - } - - private func transform(from node: GLTF.Node) -> Transform { - if let matrix = node._matrix { - return Transform(matrix: matrix.simdMatrix) - } - return Transform(scale: node.scale.simd, - rotation: node.rotation.simdQuat, - translation: node.translation.simd) - } - - /// A complete tangent basis. RealityKit stores tangents and bitangents as two - /// independent mesh buffers and derives neither from the other, so both are - /// filled together or left empty together. - private struct TangentFrame { - static let empty = TangentFrame(tangents: [], bitangents: []) - - let tangents: [SIMD3] - let bitangents: [SIMD3] - } - - /// The tangent basis a normal map needs: taken from glTF `TANGENT` when the - /// primitive has one, and otherwise derived from the UVs. Meshes whose - /// material never samples a normal map get no basis, since nothing reads it. - private func tangentFrame(rawTangents: [SIMD4]?, - positions: [SIMD3], - normals: [SIMD3], - texcoords: [SIMD2], - indices: [UInt32], - materialIndex: Int?) -> TangentFrame { - if let rawTangents { - // glTF stores handedness in w; the bitangent it selects is what makes - // the basis match the normal map the asset was authored against. - var tangents = [SIMD3](repeating: .zero, count: positions.count) - var bitangents = tangents - for i in 0..(raw.x, raw.y, raw.z) - tangents[i] = tangent - bitangents[i] = simd_cross(normals[i], tangent) * (raw.w < 0 ? -1 : 1) - } - return TangentFrame(tangents: tangents, bitangents: bitangents) - } - guard texcoords.count == positions.count, - let materialIndex, - materialUsesNormalTexture(withMaterialIndex: materialIndex) else { - return .empty - } - return generatedTangentFrame(positions: positions, - normals: normals, - texcoords: texcoords, - indices: indices) - } - - /// Whether the material samples a normal map. The MToon descriptor is asked - /// first because VRM 0.x carries its normal map in Unity's `_BumpMap`, which - /// the migration surfaces there and not on the glTF material. - private func materialUsesNormalTexture(withMaterialIndex index: Int) -> Bool { - if let descriptor = try? mtoonDescriptor(withMaterialIndex: index), descriptor.normalTexture != nil { - return true - } - guard let (gltfMaterial, _) = try? materialSource(withMaterialIndex: index) else { return false } - return gltfMaterial.normalTexture != nil - } - - /// Per-triangle UV gradients accumulated per vertex, then orthonormalized - /// against the normal — the standard basis a normal map is authored against. - private func generatedTangentFrame(positions: [SIMD3], - normals: [SIMD3], - texcoords: [SIMD2], - indices: [UInt32]) -> TangentFrame { - var tangentSums = [SIMD3](repeating: .zero, count: positions.count) - var bitangentSums = tangentSums - let triangleCount = indices.count / 3 - for i in 0.. 1e-12 else { continue } - let scale = 1 / determinant - let tangent = (edge1 * deltaUV2.y - edge2 * deltaUV1.y) * scale - let bitangent = (edge2 * deltaUV1.x - edge1 * deltaUV2.x) * scale - tangentSums[i0] += tangent - tangentSums[i1] += tangent - tangentSums[i2] += tangent - bitangentSums[i0] += bitangent - bitangentSums[i1] += bitangent - bitangentSums[i2] += bitangent - } - - var tangents = [SIMD3](repeating: .zero, count: positions.count) - var bitangents = tangents - for i in 0..) -> SIMD2 { - SIMD2(delta.x, -delta.y) - } - - /// Gram-Schmidt against the normal, falling back to any perpendicular axis - /// for vertices no usable triangle reached. - private func orthonormalizedTangent(_ tangent: SIMD3, normal: SIMD3) -> SIMD3 { - let projected = tangent - normal * simd_dot(normal, tangent) - if simd_length_squared(projected) > 1e-12 { - return simd_normalize(projected) - } - let axis = abs(normal.x) < 0.9 ? SIMD3(1, 0, 0) : SIMD3(0, 1, 0) - return simd_normalize(simd_cross(normal, axis)) - } - - /// Vertex normals for a primitive that ships none, accumulated from the - /// unnormalized face normals: their length is twice the triangle area, which - /// is the weighting a smooth normal wants. - /// - /// This is a deliberate departure from glTF, which asks for flat normals — - /// and for TANGENT to be ignored — when NORMAL is absent. Flat normals need - /// a vertex per triangle, and splitting the vertices would break the index - /// buffer that blend-shape targets, joint influences and the outline twin all - /// address. Every VRM ships NORMAL, so this only ever runs for a plain glTF, - /// where a smooth-shaded model is the better failure mode than none at all. - private func smoothNormals(positions: [SIMD3], indices: [UInt32]) -> [SIMD3] { - var normals = [SIMD3](repeating: .zero, count: positions.count) - let triangleCount = indices.count / 3 - for i in 0.. 1e-24 else { continue } - normals[i0] += faceNormal - normals[i1] += faceNormal - normals[i2] += faceNormal - } - // Vertices no usable triangle reached keep a zero normal rather than a - // NaN one; RealityKit treats it as unlit, which is the lesser artifact. - for i in 0.. 1e-24 { - normals[i] = simd_normalize(normals[i]) - } - return normals - } - - private func defaultMaterial() -> Material { - var material = UnlitMaterial() - material.color = .init(tint: .white) - return material - } - } - #endif diff --git a/Sources/VRMSceneKit/VRMSceneLoader.swift b/Sources/VRMSceneKit/VRMSceneLoader.swift index a8a5bb09..826969cc 100644 --- a/Sources/VRMSceneKit/VRMSceneLoader.swift +++ b/Sources/VRMSceneKit/VRMSceneLoader.swift @@ -20,7 +20,7 @@ open class VRMSceneLoader { } public func loadScene() throws -> VRMScene { - return try loadScene(withSceneIndex: gltf.scene) + return try loadScene(withSceneIndex: gltf.scene ?? 0) } public func loadScene(withSceneIndex index: Int) throws -> VRMScene { diff --git a/Tests/Assets/GLTF/AnimatedMorphCube/AnimatedMorphCube.glb b/Tests/Assets/GLTF/AnimatedMorphCube/AnimatedMorphCube.glb new file mode 100644 index 00000000..219d2ac5 Binary files /dev/null and b/Tests/Assets/GLTF/AnimatedMorphCube/AnimatedMorphCube.glb differ diff --git a/Tests/Assets/GLTF/AnimatedMorphCube/LICENSE.md b/Tests/Assets/GLTF/AnimatedMorphCube/LICENSE.md new file mode 100644 index 00000000..58dfc473 --- /dev/null +++ b/Tests/Assets/GLTF/AnimatedMorphCube/LICENSE.md @@ -0,0 +1,17 @@ +# LICENSE file for the model: Animated Morph Cube + +All files in this directory tree are licensed as indicated below. + +* All files directly associated with the model including all text, image and binary files: + + * [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode) [SPDX license identifier: "CC0-1.0"] + +* This file and all other metadocumentation files including "metadata.json": + + * [Creative Commons Attribution 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +Full license text of these licenses are available at the links above. + +This license excludes logos and associated trademarks. + + diff --git a/Tests/Assets/GLTF/AnimatedTriangle/AnimatedTriangle.gltf b/Tests/Assets/GLTF/AnimatedTriangle/AnimatedTriangle.gltf new file mode 100644 index 00000000..2aa1da6c --- /dev/null +++ b/Tests/Assets/GLTF/AnimatedTriangle/AnimatedTriangle.gltf @@ -0,0 +1,118 @@ +{ + "scene" : 0, + "scenes" : [ + { + "nodes" : [ 0 ] + } + ], + + "nodes" : [ + { + "mesh" : 0, + "rotation" : [ 0.0, 0.0, 0.0, 1.0 ] + } + ], + + "meshes" : [ + { + "primitives" : [ { + "attributes" : { + "POSITION" : 1 + }, + "indices" : 0 + } ] + } + ], + + "animations": [ + { + "samplers" : [ + { + "input" : 2, + "interpolation" : "LINEAR", + "output" : 3 + } + ], + "channels" : [ { + "sampler" : 0, + "target" : { + "node" : 0, + "path" : "rotation" + } + } ] + } + ], + + "buffers" : [ + { + "uri" : "data:application/octet-stream;base64,AAABAAIAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAA=", + "byteLength" : 44 + }, + { + "uri" : "data:application/octet-stream;base64,AAAAAAAAgD4AAAA/AABAPwAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAD0/TQ/9P00PwAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAPT9ND/0/TS/AAAAAAAAAAAAAAAAAACAPw==", + "byteLength" : 100 + } + ], + "bufferViews" : [ + { + "buffer" : 0, + "byteOffset" : 0, + "byteLength" : 6, + "target" : 34963 + }, + { + "buffer" : 0, + "byteOffset" : 8, + "byteLength" : 36, + "target" : 34962 + }, + { + "buffer" : 1, + "byteOffset" : 0, + "byteLength" : 100 + } + ], + "accessors" : [ + { + "bufferView" : 0, + "byteOffset" : 0, + "componentType" : 5123, + "count" : 3, + "type" : "SCALAR", + "max" : [ 2 ], + "min" : [ 0 ] + }, + { + "bufferView" : 1, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 3, + "type" : "VEC3", + "max" : [ 1.0, 1.0, 0.0 ], + "min" : [ 0.0, 0.0, 0.0 ] + }, + { + "bufferView" : 2, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 5, + "type" : "SCALAR", + "max" : [ 1.0 ], + "min" : [ 0.0 ] + }, + { + "bufferView" : 2, + "byteOffset" : 20, + "componentType" : 5126, + "count" : 5, + "type" : "VEC4", + "max" : [ 0.0, 0.0, 1.0, 1.0 ], + "min" : [ 0.0, 0.0, 0.0, -0.707 ] + } + ], + + "asset" : { + "version" : "2.0" + } + +} diff --git a/Tests/Assets/GLTF/AnimatedTriangle/LICENSE.md b/Tests/Assets/GLTF/AnimatedTriangle/LICENSE.md new file mode 100644 index 00000000..8d2ef199 --- /dev/null +++ b/Tests/Assets/GLTF/AnimatedTriangle/LICENSE.md @@ -0,0 +1,17 @@ +# LICENSE file for the model: Animated Triangle + +All files in this directory tree are licensed as indicated below. + +* All files directly associated with the model including all text, image and binary files: + + * [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode) [SPDX license identifier: "CC0-1.0"] + +* This file and all other metadocumentation files including "metadata.json": + + * [Creative Commons Attribution 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +Full license text of these licenses are available at the links above. + +This license excludes logos and associated trademarks. + + diff --git a/Tests/Assets/GLTF/BoxVertexColors/BoxVertexColors.glb b/Tests/Assets/GLTF/BoxVertexColors/BoxVertexColors.glb new file mode 100644 index 00000000..875d95a9 Binary files /dev/null and b/Tests/Assets/GLTF/BoxVertexColors/BoxVertexColors.glb differ diff --git a/Tests/Assets/GLTF/BoxVertexColors/LICENSE.md b/Tests/Assets/GLTF/BoxVertexColors/LICENSE.md new file mode 100644 index 00000000..4f5fb26d --- /dev/null +++ b/Tests/Assets/GLTF/BoxVertexColors/LICENSE.md @@ -0,0 +1,17 @@ +# LICENSE file for the model: Box Vertex Colors + +All files in this directory tree are licensed as indicated below. + +* All files directly associated with the model including all text, image and binary files: + + * [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode) [SPDX license identifier: "CC0-1.0"] + +* This file and all other metadocumentation files including "metadata.json": + + * [Creative Commons Attribution 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +Full license text of these licenses are available at the links above. + +This license excludes logos and associated trademarks. + + diff --git a/Tests/Assets/GLTF/Cameras/Cameras.gltf b/Tests/Assets/GLTF/Cameras/Cameras.gltf new file mode 100644 index 00000000..cdeacc22 --- /dev/null +++ b/Tests/Assets/GLTF/Cameras/Cameras.gltf @@ -0,0 +1,99 @@ +{ + "scene" : 0, + "scenes" : [ + { + "nodes" : [ 0, 1, 2 ] + } + ], + "nodes" : [ + { + "rotation" : [ -0.383, 0.0, 0.0, 0.92375 ], + "mesh" : 0 + }, + { + "translation" : [ 0.5, 0.5, 3.0 ], + "camera" : 0 + }, + { + "translation" : [ 0.5, 0.5, 3.0 ], + "camera" : 1 + } + ], + + "cameras" : [ + { + "type": "perspective", + "perspective": { + "aspectRatio": 1.0, + "yfov": 0.7, + "zfar": 100, + "znear": 0.01 + } + }, + { + "type": "orthographic", + "orthographic": { + "xmag": 1.0, + "ymag": 1.0, + "zfar": 100, + "znear": 0.01 + } + } + ], + + "meshes" : [ + { + "primitives" : [ { + "attributes" : { + "POSITION" : 1 + }, + "indices" : 0 + } ] + } + ], + + "buffers" : [ + { + "uri" : "data:application/octet-stream;base64,AAABAAIAAQADAAIAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAgD8AAAAA", + "byteLength" : 60 + } + ], + "bufferViews" : [ + { + "buffer" : 0, + "byteOffset" : 0, + "byteLength" : 12, + "target" : 34963 + }, + { + "buffer" : 0, + "byteOffset" : 12, + "byteLength" : 48, + "target" : 34962 + } + ], + "accessors" : [ + { + "bufferView" : 0, + "byteOffset" : 0, + "componentType" : 5123, + "count" : 6, + "type" : "SCALAR", + "max" : [ 3 ], + "min" : [ 0 ] + }, + { + "bufferView" : 1, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 4, + "type" : "VEC3", + "max" : [ 1.0, 1.0, 0.0 ], + "min" : [ 0.0, 0.0, 0.0 ] + } + ], + + "asset" : { + "version" : "2.0" + } +} diff --git a/Tests/Assets/GLTF/Cameras/LICENSE.md b/Tests/Assets/GLTF/Cameras/LICENSE.md new file mode 100644 index 00000000..b87c1d6b --- /dev/null +++ b/Tests/Assets/GLTF/Cameras/LICENSE.md @@ -0,0 +1,17 @@ +# LICENSE file for the model: Cameras + +All files in this directory tree are licensed as indicated below. + +* All files directly associated with the model including all text, image and binary files: + + * [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode) [SPDX license identifier: "CC0-1.0"] + +* This file and all other metadocumentation files including "metadata.json": + + * [Creative Commons Attribution 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +Full license text of these licenses are available at the links above. + +This license excludes logos and associated trademarks. + + diff --git a/Tests/Assets/GLTF/InterpolationTest/InterpolationTest.glb b/Tests/Assets/GLTF/InterpolationTest/InterpolationTest.glb new file mode 100644 index 00000000..4200fccc Binary files /dev/null and b/Tests/Assets/GLTF/InterpolationTest/InterpolationTest.glb differ diff --git a/Tests/Assets/GLTF/InterpolationTest/LICENSE.md b/Tests/Assets/GLTF/InterpolationTest/LICENSE.md new file mode 100644 index 00000000..3b6807ce --- /dev/null +++ b/Tests/Assets/GLTF/InterpolationTest/LICENSE.md @@ -0,0 +1,17 @@ +# LICENSE file for the model: Interpolation Test + +All files in this directory tree are licensed as indicated below. + +* All files directly associated with the model including all text, image and binary files: + + * [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode) [SPDX license identifier: "CC0-1.0"] + +* This file and all other metadocumentation files including "metadata.json": + + * [Creative Commons Attribution 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +Full license text of these licenses are available at the links above. + +This license excludes logos and associated trademarks. + + diff --git a/Tests/Assets/GLTF/README.md b/Tests/Assets/GLTF/README.md new file mode 100644 index 00000000..44e59515 --- /dev/null +++ b/Tests/Assets/GLTF/README.md @@ -0,0 +1,33 @@ +# glTF Sample Assets (test fixtures) + +Test fixtures for the generic glTF rendering path, taken from +[KhronosGroup/glTF-Sample-Assets](https://github.com/KhronosGroup/glTF-Sample-Assets) +(`Models//`). + +**Every model here is licensed CC0-1.0**, so the fixtures carry no attribution +obligation. Models under CC-BY-4.0 were deliberately excluded to keep this +repository free of attribution requirements — cover their features with +hand-written fixtures instead (sparse accessors are covered that way in +`GLTFEntityLoaderTests.testSparseAccessorSubstitutesPositions`). Each model keeps +its upstream `LICENSE.md`; note that those metadocumentation files are +themselves CC-BY-4.0, as stated inside them. + +| Model | Variant | What it covers | +|---|---|---| +| Triangle | glTF | Minimal indexed geometry, external `.bin` | +| TriangleWithoutIndices | glTF | Non-indexed geometry (`primitive.indices` absent) | +| SimpleMeshes | glTF | Two nodes sharing one mesh | +| SimpleTexture | glTF | External PNG image + sampler | +| SimpleSkin | glTF-Embedded | Skin, joints, inverse bind matrices, data URI buffer | +| SimpleMorph | glTF-Embedded | Morph targets with `mesh.weights` | +| Cameras | glTF-Embedded | Perspective and orthographic cameras | +| AnimatedTriangle | glTF-Embedded | Animation channels (rotation, LINEAR) | +| BoxVertexColors | glTF-Binary | `COLOR_0` vertex colors, GLB container | +| AnimatedMorphCube | glTF-Binary | Morph target animation (`weights` path) | +| InterpolationTest | glTF-Binary | LINEAR / STEP / CUBICSPLINE samplers | +| TextureTransformTest | glTF | `KHR_texture_transform` | + +The animation models drive `GLTFAnimationPlaybackTests`: `InterpolationTest` +alone covers all three glTF interpolations across nine animations. + +To refresh, re-download the same paths from the upstream `main` branch. diff --git a/Tests/Assets/GLTF/SimpleMeshes/LICENSE.md b/Tests/Assets/GLTF/SimpleMeshes/LICENSE.md new file mode 100644 index 00000000..575e1b36 --- /dev/null +++ b/Tests/Assets/GLTF/SimpleMeshes/LICENSE.md @@ -0,0 +1,17 @@ +# LICENSE file for the model: Simple Meshes + +All files in this directory tree are licensed as indicated below. + +* All files directly associated with the model including all text, image and binary files: + + * [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode) [SPDX license identifier: "CC0-1.0"] + +* This file and all other metadocumentation files including "metadata.json": + + * [Creative Commons Attribution 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +Full license text of these licenses are available at the links above. + +This license excludes logos and associated trademarks. + + diff --git a/Tests/Assets/GLTF/SimpleMeshes/SimpleMeshes.bin b/Tests/Assets/GLTF/SimpleMeshes/SimpleMeshes.bin new file mode 100644 index 00000000..291b43ca Binary files /dev/null and b/Tests/Assets/GLTF/SimpleMeshes/SimpleMeshes.bin differ diff --git a/Tests/Assets/GLTF/SimpleMeshes/SimpleMeshes.gltf b/Tests/Assets/GLTF/SimpleMeshes/SimpleMeshes.gltf new file mode 100644 index 00000000..c3f3d44c --- /dev/null +++ b/Tests/Assets/GLTF/SimpleMeshes/SimpleMeshes.gltf @@ -0,0 +1,84 @@ +{ + "scene" : 0, + "scenes" : [ + { + "nodes" : [ 0, 1] + } + ], + "nodes" : [ + { + "mesh" : 0 + }, + { + "mesh" : 0, + "translation" : [ 1.0, 0.0, 0.0 ] + } + ], + + "meshes" : [ + { + "primitives" : [ { + "attributes" : { + "POSITION" : 1, + "NORMAL" : 2 + }, + "indices" : 0 + } ] + } + ], + + "buffers" : [ + { + "uri" : "SimpleMeshes.bin", + "byteLength" : 80 + } + ], + "bufferViews" : [ + { + "buffer" : 0, + "byteOffset" : 0, + "byteLength" : 6, + "target" : 34963 + }, + { + "buffer" : 0, + "byteOffset" : 8, + "byteLength" : 72, + "byteStride" : 12, + "target" : 34962 + } + ], + "accessors" : [ + { + "bufferView" : 0, + "byteOffset" : 0, + "componentType" : 5123, + "count" : 3, + "type" : "SCALAR", + "max" : [ 2 ], + "min" : [ 0 ] + }, + { + "bufferView" : 1, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 3, + "type" : "VEC3", + "max" : [ 1.0, 1.0, 0.0 ], + "min" : [ 0.0, 0.0, 0.0 ] + }, + { + "bufferView" : 1, + "byteOffset" : 36, + "componentType" : 5126, + "count" : 3, + "type" : "VEC3", + "max" : [ 0.0, 0.0, 1.0 ], + "min" : [ 0.0, 0.0, 1.0 ] + } + ], + + "asset" : { + "version" : "2.0" + } +} diff --git a/Tests/Assets/GLTF/SimpleMorph/LICENSE.md b/Tests/Assets/GLTF/SimpleMorph/LICENSE.md new file mode 100644 index 00000000..4116e7f9 --- /dev/null +++ b/Tests/Assets/GLTF/SimpleMorph/LICENSE.md @@ -0,0 +1,17 @@ +# LICENSE file for the model: Simple Morph + +All files in this directory tree are licensed as indicated below. + +* All files directly associated with the model including all text, image and binary files: + + * [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode) [SPDX license identifier: "CC0-1.0"] + +* This file and all other metadocumentation files including "metadata.json": + + * [Creative Commons Attribution 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +Full license text of these licenses are available at the links above. + +This license excludes logos and associated trademarks. + + diff --git a/Tests/Assets/GLTF/SimpleMorph/SimpleMorph.gltf b/Tests/Assets/GLTF/SimpleMorph/SimpleMorph.gltf new file mode 100644 index 00000000..3b6efd8d --- /dev/null +++ b/Tests/Assets/GLTF/SimpleMorph/SimpleMorph.gltf @@ -0,0 +1,192 @@ +{ + "scene" : 0, + "scenes":[ + { + "nodes":[ + 0 + ] + } + ], + "nodes":[ + { + "mesh":0 + } + ], + "meshes":[ + { + "primitives":[ + { + "attributes":{ + "POSITION":1 + }, + "targets":[ + { + "POSITION":2 + }, + { + "POSITION":3 + } + ], + "indices":0 + } + ], + "weights":[ + 0.5, + 0.5 + ] + } + ], + + "animations":[ + { + "samplers":[ + { + "input":4, + "interpolation":"LINEAR", + "output":5 + } + ], + "channels":[ + { + "sampler":0, + "target":{ + "node":0, + "path":"weights" + } + } + ] + } + ], + + "buffers":[ + { + "uri":"data:application/gltf-buffer;base64,AAABAAIAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAA/AAAAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIC/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAA=", + "byteLength":116 + }, + { + "uri":"data:application/gltf-buffer;base64,AAAAAAAAgD8AAABAAABAQAAAgEAAAAAAAAAAAAAAAAAAAIA/AACAPwAAgD8AAIA/AAAAAAAAAAAAAAAA", + "byteLength":60 + } + ], + "bufferViews":[ + { + "buffer":0, + "byteOffset":0, + "byteLength":6, + "target":34963 + }, + { + "buffer":0, + "byteOffset":8, + "byteLength":108, + "byteStride":12, + "target":34962 + }, + { + "buffer":1, + "byteOffset":0, + "byteLength":20 + }, + { + "buffer":1, + "byteOffset":20, + "byteLength":40 + } + ], + "accessors":[ + { + "bufferView":0, + "byteOffset":0, + "componentType":5123, + "count":3, + "type":"SCALAR", + "max":[ + 2 + ], + "min":[ + 0 + ] + }, + { + "bufferView":1, + "byteOffset":0, + "componentType":5126, + "count":3, + "type":"VEC3", + "max":[ + 1.0, + 0.5, + 0.0 + ], + "min":[ + 0.0, + 0.0, + 0.0 + ] + }, + { + "bufferView":1, + "byteOffset":36, + "componentType":5126, + "count":3, + "type":"VEC3", + "max":[ + 0.0, + 1.0, + 0.0 + ], + "min":[ + -1.0, + 0.0, + 0.0 + ] + }, + { + "bufferView":1, + "byteOffset":72, + "componentType":5126, + "count":3, + "type":"VEC3", + "max":[ + 1.0, + 1.0, + 0.0 + ], + "min":[ + 0.0, + 0.0, + 0.0 + ] + }, + { + "bufferView":2, + "byteOffset":0, + "componentType":5126, + "count":5, + "type":"SCALAR", + "max":[ + 4.0 + ], + "min":[ + 0.0 + ] + }, + { + "bufferView":3, + "byteOffset":0, + "componentType":5126, + "count":10, + "type":"SCALAR", + "max":[ + 1.0 + ], + "min":[ + 0.0 + ] + } + ], + + "asset":{ + "version":"2.0" + } +} \ No newline at end of file diff --git a/Tests/Assets/GLTF/SimpleSkin/LICENSE.md b/Tests/Assets/GLTF/SimpleSkin/LICENSE.md new file mode 100644 index 00000000..1a2b5a31 --- /dev/null +++ b/Tests/Assets/GLTF/SimpleSkin/LICENSE.md @@ -0,0 +1,17 @@ +# LICENSE file for the model: Simple Skin + +All files in this directory tree are licensed as indicated below. + +* All files directly associated with the model including all text, image and binary files: + + * [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode) [SPDX license identifier: "CC0-1.0"] + +* This file and all other metadocumentation files including "metadata.json": + + * [Creative Commons Attribution 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +Full license text of these licenses are available at the links above. + +This license excludes logos and associated trademarks. + + diff --git a/Tests/Assets/GLTF/SimpleSkin/SimpleSkin.gltf b/Tests/Assets/GLTF/SimpleSkin/SimpleSkin.gltf new file mode 100644 index 00000000..e9f4e4d8 --- /dev/null +++ b/Tests/Assets/GLTF/SimpleSkin/SimpleSkin.gltf @@ -0,0 +1,131 @@ +{ + "scene" : 0, + "scenes" : [ { + "nodes" : [ 0, 1 ] + } ], + + "nodes" : [ { + "skin" : 0, + "mesh" : 0 + }, { + "children" : [ 2 ] + }, { + "translation" : [ 0.0, 1.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0, 1.0 ] + } ], + + "meshes" : [ { + "primitives" : [ { + "attributes" : { + "POSITION" : 1, + "JOINTS_0" : 2, + "WEIGHTS_0" : 3 + }, + "indices" : 0 + } ] + } ], + + "skins" : [ { + "inverseBindMatrices" : 4, + "joints" : [ 1, 2 ] + } ], + + "animations" : [ { + "channels" : [ { + "sampler" : 0, + "target" : { + "node" : 2, + "path" : "rotation" + } + } ], + "samplers" : [ { + "input" : 5, + "interpolation" : "LINEAR", + "output" : 6 + } ] + } ], + + "buffers" : [ { + "uri" : "data:application/gltf-buffer;base64,AAABAAMAAAADAAIAAgADAAUAAgAFAAQABAAFAAcABAAHAAYABgAHAAkABgAJAAgAAAAAvwAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAvwAAAD8AAAAAAAAAPwAAAD8AAAAAAAAAvwAAgD8AAAAAAAAAPwAAgD8AAAAAAAAAvwAAwD8AAAAAAAAAPwAAwD8AAAAAAAAAvwAAAEAAAAAAAAAAPwAAAEAAAAAA", + "byteLength" : 168 + }, { + "uri" : "data:application/gltf-buffer;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAABAPwAAgD4AAAAAAAAAAAAAQD8AAIA+AAAAAAAAAAAAAAA/AAAAPwAAAAAAAAAAAAAAPwAAAD8AAAAAAAAAAAAAgD4AAEA/AAAAAAAAAAAAAIA+AABAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAA=", + "byteLength" : 320 + }, { + "uri" : "data:application/gltf-buffer;base64,AACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAgD8=", + "byteLength" : 128 + }, { + "uri" : "data:application/gltf-buffer;base64,AAAAAAAAAD8AAIA/AADAPwAAAEAAACBAAABAQAAAYEAAAIBAAACQQAAAoEAAALBAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAkxjEPkSLbD8AAAAAAAAAAPT9ND/0/TQ/AAAAAAAAAAD0/TQ/9P00PwAAAAAAAAAAkxjEPkSLbD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAkxjEvkSLbD8AAAAAAAAAAPT9NL/0/TQ/AAAAAAAAAAD0/TS/9P00PwAAAAAAAAAAkxjEvkSLbD8AAAAAAAAAAAAAAAAAAIA/", + "byteLength" : 240 + } ], + + "bufferViews" : [ { + "buffer" : 0, + "byteLength" : 48, + "target" : 34963 + }, { + "buffer" : 0, + "byteOffset" : 48, + "byteLength" : 120, + "target" : 34962 + }, { + "buffer" : 1, + "byteLength" : 320, + "byteStride" : 16 + }, { + "buffer" : 2, + "byteLength" : 128 + }, { + "buffer" : 3, + "byteLength" : 240 + } ], + + "accessors" : [ { + "bufferView" : 0, + "componentType" : 5123, + "count" : 24, + "type" : "SCALAR" + }, { + "bufferView" : 1, + "componentType" : 5126, + "count" : 10, + "type" : "VEC3", + "max" : [ 0.5, 2.0, 0.0 ], + "min" : [ -0.5, 0.0, 0.0 ] + }, { + "bufferView" : 2, + "componentType" : 5123, + "count" : 10, + "type" : "VEC4" + }, { + "bufferView" : 2, + "byteOffset" : 160, + "componentType" : 5126, + "count" : 10, + "type" : "VEC4" + }, { + "bufferView" : 3, + "componentType" : 5126, + "count" : 2, + "type" : "MAT4" + }, { + "bufferView" : 4, + "componentType" : 5126, + "count" : 12, + "type" : "SCALAR", + "max" : [ 5.5 ], + "min" : [ 0.0 ] + }, { + "bufferView" : 4, + "byteOffset" : 48, + "componentType" : 5126, + "count" : 12, + "type" : "VEC4", + "max" : [ 0.0, 0.0, 0.707, 1.0 ], + "min" : [ 0.0, 0.0, -0.707, 0.707 ] + } ], + + "asset" : { + "version" : "2.0" + } +} \ No newline at end of file diff --git a/Tests/Assets/GLTF/SimpleTexture/LICENSE.md b/Tests/Assets/GLTF/SimpleTexture/LICENSE.md new file mode 100644 index 00000000..61acf9dc --- /dev/null +++ b/Tests/Assets/GLTF/SimpleTexture/LICENSE.md @@ -0,0 +1,17 @@ +# LICENSE file for the model: Simple Texture + +All files in this directory tree are licensed as indicated below. + +* All files directly associated with the model including all text, image and binary files: + + * [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode) [SPDX license identifier: "CC0-1.0"] + +* This file and all other metadocumentation files including "metadata.json": + + * [Creative Commons Attribution 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +Full license text of these licenses are available at the links above. + +This license excludes logos and associated trademarks. + + diff --git a/Tests/Assets/GLTF/SimpleTexture/SimpleTexture.bin b/Tests/Assets/GLTF/SimpleTexture/SimpleTexture.bin new file mode 100644 index 00000000..11ef558a Binary files /dev/null and b/Tests/Assets/GLTF/SimpleTexture/SimpleTexture.bin differ diff --git a/Tests/Assets/GLTF/SimpleTexture/SimpleTexture.gltf b/Tests/Assets/GLTF/SimpleTexture/SimpleTexture.gltf new file mode 100644 index 00000000..2bc5eab3 --- /dev/null +++ b/Tests/Assets/GLTF/SimpleTexture/SimpleTexture.gltf @@ -0,0 +1,89 @@ +{ + "scene": 0, + "scenes" : [ { + "nodes" : [ 0 ] + } ], + "nodes" : [ { + "mesh" : 0 + } ], + "meshes" : [ { + "primitives" : [ { + "attributes" : { + "POSITION" : 1, + "TEXCOORD_0" : 2 + }, + "indices" : 0, + "material" : 0 + } ] + } ], + + "materials" : [ { + "pbrMetallicRoughness" : { + "baseColorTexture" : { + "index" : 0 + }, + "metallicFactor" : 0.0, + "roughnessFactor" : 1.0 + } + } ], + + "textures" : [ { + "sampler" : 0, + "source" : 0 + } ], + "images" : [ { + "uri" : "testTexture.png" + } ], + "samplers" : [ { + "magFilter" : 9729, + "minFilter" : 9987, + "wrapS" : 33648, + "wrapT" : 33648 + } ], + + "buffers" : [ { + "uri" : "SimpleTexture.bin", + "byteLength" : 108 + } ], + "bufferViews" : [ { + "buffer" : 0, + "byteOffset" : 0, + "byteLength" : 12, + "target" : 34963 + }, { + "buffer" : 0, + "byteOffset" : 12, + "byteLength" : 96, + "byteStride" : 12, + "target" : 34962 + } ], + "accessors" : [ { + "bufferView" : 0, + "byteOffset" : 0, + "componentType" : 5123, + "count" : 6, + "type" : "SCALAR", + "max" : [ 3 ], + "min" : [ 0 ] + }, { + "bufferView" : 1, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 4, + "type" : "VEC3", + "max" : [ 1.0, 1.0, 0.0 ], + "min" : [ 0.0, 0.0, 0.0 ] + }, { + "bufferView" : 1, + "byteOffset" : 48, + "componentType" : 5126, + "count" : 4, + "type" : "VEC2", + "max" : [ 1.0, 1.0 ], + "min" : [ 0.0, 0.0 ] + } ], + + "asset" : { + "version" : "2.0" + } +} \ No newline at end of file diff --git a/Tests/Assets/GLTF/SimpleTexture/testTexture.png b/Tests/Assets/GLTF/SimpleTexture/testTexture.png new file mode 100644 index 00000000..c0aeb3c2 Binary files /dev/null and b/Tests/Assets/GLTF/SimpleTexture/testTexture.png differ diff --git a/Tests/Assets/GLTF/TextureTransformTest/Arrow.png b/Tests/Assets/GLTF/TextureTransformTest/Arrow.png new file mode 100644 index 00000000..fe3405b3 Binary files /dev/null and b/Tests/Assets/GLTF/TextureTransformTest/Arrow.png differ diff --git a/Tests/Assets/GLTF/TextureTransformTest/Correct.png b/Tests/Assets/GLTF/TextureTransformTest/Correct.png new file mode 100644 index 00000000..e332824f Binary files /dev/null and b/Tests/Assets/GLTF/TextureTransformTest/Correct.png differ diff --git a/Tests/Assets/GLTF/TextureTransformTest/Error.png b/Tests/Assets/GLTF/TextureTransformTest/Error.png new file mode 100644 index 00000000..35acf6e9 Binary files /dev/null and b/Tests/Assets/GLTF/TextureTransformTest/Error.png differ diff --git a/Tests/Assets/GLTF/TextureTransformTest/LICENSE.md b/Tests/Assets/GLTF/TextureTransformTest/LICENSE.md new file mode 100644 index 00000000..bd63c36c --- /dev/null +++ b/Tests/Assets/GLTF/TextureTransformTest/LICENSE.md @@ -0,0 +1,17 @@ +# LICENSE file for the model: Texture Transform Test + +All files in this directory tree are licensed as indicated below. + +* All files directly associated with the model including all text, image and binary files: + + * [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode) [SPDX license identifier: "CC0-1.0"] + +* This file and all other metadocumentation files including "metadata.json": + + * [Creative Commons Attribution 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +Full license text of these licenses are available at the links above. + +This license excludes logos and associated trademarks. + + diff --git a/Tests/Assets/GLTF/TextureTransformTest/NotSupported.png b/Tests/Assets/GLTF/TextureTransformTest/NotSupported.png new file mode 100644 index 00000000..721e7fd7 Binary files /dev/null and b/Tests/Assets/GLTF/TextureTransformTest/NotSupported.png differ diff --git a/Tests/Assets/GLTF/TextureTransformTest/TextureTransformTest.bin b/Tests/Assets/GLTF/TextureTransformTest/TextureTransformTest.bin new file mode 100644 index 00000000..6765a130 Binary files /dev/null and b/Tests/Assets/GLTF/TextureTransformTest/TextureTransformTest.bin differ diff --git a/Tests/Assets/GLTF/TextureTransformTest/TextureTransformTest.gltf b/Tests/Assets/GLTF/TextureTransformTest/TextureTransformTest.gltf new file mode 100644 index 00000000..6dde51d9 --- /dev/null +++ b/Tests/Assets/GLTF/TextureTransformTest/TextureTransformTest.gltf @@ -0,0 +1,540 @@ +{ + "accessors": [ + { + "bufferView": 0, + "componentType": 5126, + "count": 4, + "type": "VEC3", + "max": [ + 0.5, + 0.5, + 0.0 + ], + "min": [ + -0.5, + -0.5, + 0.0 + ], + "name": "Positions" + }, + { + "bufferView": 1, + "componentType": 5126, + "count": 4, + "type": "VEC2", + "name": "UV0" + }, + { + "bufferView": 2, + "componentType": 5126, + "count": 4, + "type": "VEC2", + "name": "UV1" + }, + { + "bufferView": 3, + "componentType": 5125, + "count": 6, + "type": "SCALAR", + "name": "Indices" + } + ], + "asset": { + "version": "2.0" + }, + "buffers": [ + { + "uri": "TextureTransformTest.bin", + "byteLength": 136 + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteLength": 48, + "name": "Positions" + }, + { + "buffer": 0, + "byteOffset": 48, + "byteLength": 32, + "name": "UV0" + }, + { + "buffer": 0, + "byteOffset": 80, + "byteLength": 32, + "name": "UV1" + }, + { + "buffer": 0, + "byteOffset": 112, + "byteLength": 24, + "name": "Indices" + } + ], + "extensionsUsed": [ + "KHR_texture_transform" + ], + "images": [ + { + "uri": "UV.png" + }, + { + "uri": "Arrow.png" + }, + { + "uri": "Correct.png" + }, + { + "uri": "NotSupported.png" + }, + { + "uri": "Error.png" + } + ], + "materials": [ + { + "name": "Offset U", + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 0, + "extensions": { + "KHR_texture_transform": { + "offset": [ + 0.5, + 0.0 + ] + } + } + }, + "metallicFactor": 0 + } + }, + { + "name": "Offset V", + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 0, + "extensions": { + "KHR_texture_transform": { + "offset": [ + 0.0, + 0.5 + ] + } + } + }, + "metallicFactor": 0 + } + }, + { + "name": "Offset UV", + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 0, + "extensions": { + "KHR_texture_transform": { + "offset": [ + 0.5, + 0.5 + ] + } + } + }, + "metallicFactor": 0 + } + }, + { + "name": "Rotation", + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 1, + "extensions": { + "KHR_texture_transform": { + "rotation": 0.39269908169872415480783042290994 + } + } + }, + "metallicFactor": 0 + } + }, + { + "name": "Scale", + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 1, + "extensions": { + "KHR_texture_transform": { + "scale": [ + 1.5, + 1.5 + ] + } + } + }, + "metallicFactor": 0 + } + }, + { + "name": "All", + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 1, + "extensions": { + "KHR_texture_transform": { + "offset": [ + -0.2, + -0.1 + ], + "rotation": 0.3, + "scale": [ + 1.5, + 1.5 + ] + } + } + }, + "metallicFactor": 0 + } + }, + { + "name": "Correct", + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 2 + }, + "metallicFactor": 0 + } + }, + { + "name": "NotSupported", + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 3 + }, + "metallicFactor": 0 + } + }, + { + "name": "Error", + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 4 + }, + "metallicFactor": 0 + } + } + ], + "meshes": [ + { + "name": "Offset U", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "TEXCOORD_0": 2 + }, + "indices": 3, + "material": 0 + } + ] + }, + { + "name": "Offset V", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "TEXCOORD_0": 2 + }, + "indices": 3, + "material": 1 + } + ] + }, + { + "name": "Offset UV", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "TEXCOORD_0": 2 + }, + "indices": 3, + "material": 2 + } + ] + }, + { + "name": "Rotation", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "TEXCOORD_0": 1 + }, + "indices": 3, + "material": 3 + } + ] + }, + { + "name": "Scale", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "TEXCOORD_0": 1 + }, + "indices": 3, + "material": 4 + } + ] + }, + { + "name": "All", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "TEXCOORD_0": 1 + }, + "indices": 3, + "material": 5 + } + ] + }, + { + "name": "Correct Marker", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "TEXCOORD_0": 1 + }, + "indices": 3, + "material": 6 + } + ] + }, + { + "name": "Not Supported Marker", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "TEXCOORD_0": 1 + }, + "indices": 3, + "material": 7 + } + ] + }, + { + "name": "Error Marker", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "TEXCOORD_0": 1 + }, + "indices": 3, + "material": 8 + } + ] + } + ], + "nodes": [ + { + "name": "Offset U", + "mesh": 0, + "translation": [ + -1.1, + 0.55, + 0 + ] + }, + { + "name": "Offset V", + "mesh": 1, + "translation": [ + 0, + 0.55, + 0 + ] + }, + { + "name": "Offset UV", + "mesh": 2, + "translation": [ + 1.1, + 0.55, + 0 + ] + }, + { + "name": "Rotation", + "mesh": 3, + "translation": [ + -1.1, + -0.55, + 0 + ], + "children": [ + 4, + 5, + 6 + ] + }, + { + "name": "Rotation - Correct", + "mesh": 6, + "translation": [ + -0.07904822439840125109869401756656, + -0.51626748576241543174100150833647, + 0.01 + ], + "scale": [ + 0.15, + 0.15, + 0.15 + ] + }, + { + "name": "Rotation - Not Supported", + "mesh": 7, + "translation": [ + 0.27781745930520227684092879831533, + -0.27781745930520227684092879831533, + 0.01 + ], + "scale": [ + 0.15, + 0.15, + 0.15 + ] + }, + { + "name": "Rotation - Error", + "mesh": 8, + "translation": [ + 0.51626748576241543174100150833647, + 0.07904822439840125109869401756656, + 0.01 + ], + "scale": [ + 0.15, + 0.15, + 0.15 + ] + }, + { + "name": "Scale", + "mesh": 4, + "translation": [ + 0, + -0.55, + 0 + ], + "children": [ + 8, + 9 + ] + }, + { + "name": "Scale - Correct", + "mesh": 6, + "translation": [ + 0.01854497287013485122728586554355, + -0.01854497287013485122728586554355, + 0.01 + ], + "scale": [ + 0.1, + 0.1, + 0.1 + ] + }, + { + "name": "Scale - Not Supported", + "mesh": 7, + "translation": [ + 0.27781745930520227684092879831533, + -0.27781745930520227684092879831533, + 0.01 + ], + "scale": [ + 0.15, + 0.15, + 0.15 + ] + }, + { + "name": "All", + "mesh": 5, + "translation": [ + 1.1, + -0.55, + 0 + ], + "children": [ + 11 + ] + }, + { + "name": "All - Correct", + "mesh": 6, + "translation": [ + -0.07, + -0.25, + 0.01 + ], + "scale": [ + 0.1, + 0.1, + 0.1 + ] + } + ], + "scene": 0, + "scenes": [ + { + "nodes": [ + 0, + 1, + 2, + 3, + 7, + 10 + ] + } + ], + "textures": [ + { + "source": 0, + "sampler": 0 + }, + { + "source": 1, + "sampler": 0 + }, + { + "source": 2 + }, + { + "source": 3 + }, + { + "source": 4 + } + ], + "samplers": [ + { + "wrapS": 33071, + "wrapT": 33071, + "magFilter": 9729, + "minFilter": 9729 + } + ] +} \ No newline at end of file diff --git a/Tests/Assets/GLTF/TextureTransformTest/UV.png b/Tests/Assets/GLTF/TextureTransformTest/UV.png new file mode 100644 index 00000000..c1a6d4d3 Binary files /dev/null and b/Tests/Assets/GLTF/TextureTransformTest/UV.png differ diff --git a/Tests/Assets/GLTF/Triangle/LICENSE.md b/Tests/Assets/GLTF/Triangle/LICENSE.md new file mode 100644 index 00000000..423e1437 --- /dev/null +++ b/Tests/Assets/GLTF/Triangle/LICENSE.md @@ -0,0 +1,17 @@ +# LICENSE file for the model: Triangle + +All files in this directory tree are licensed as indicated below. + +* All files directly associated with the model including all text, image and binary files: + + * [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode) [SPDX license identifier: "CC0-1.0"] + +* This file and all other metadocumentation files including "metadata.json": + + * [Creative Commons Attribution 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +Full license text of these licenses are available at the links above. + +This license excludes logos and associated trademarks. + + diff --git a/Tests/Assets/GLTF/Triangle/Triangle.bin b/Tests/Assets/GLTF/Triangle/Triangle.bin new file mode 100644 index 00000000..d642500b Binary files /dev/null and b/Tests/Assets/GLTF/Triangle/Triangle.bin differ diff --git a/Tests/Assets/GLTF/Triangle/Triangle.gltf b/Tests/Assets/GLTF/Triangle/Triangle.gltf new file mode 100644 index 00000000..0bb222c8 --- /dev/null +++ b/Tests/Assets/GLTF/Triangle/Triangle.gltf @@ -0,0 +1,70 @@ +{ + "scene" : 0, + "scenes" : [ + { + "nodes" : [ 0 ] + } + ], + + "nodes" : [ + { + "mesh" : 0 + } + ], + + "meshes" : [ + { + "primitives" : [ { + "attributes" : { + "POSITION" : 1 + }, + "indices" : 0 + } ] + } + ], + + "buffers" : [ + { + "uri" : "Triangle.bin", + "byteLength" : 44 + } + ], + "bufferViews" : [ + { + "buffer" : 0, + "byteOffset" : 0, + "byteLength" : 6, + "target" : 34963 + }, + { + "buffer" : 0, + "byteOffset" : 8, + "byteLength" : 36, + "target" : 34962 + } + ], + "accessors" : [ + { + "bufferView" : 0, + "byteOffset" : 0, + "componentType" : 5123, + "count" : 3, + "type" : "SCALAR", + "max" : [ 2 ], + "min" : [ 0 ] + }, + { + "bufferView" : 1, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 3, + "type" : "VEC3", + "max" : [ 1.0, 1.0, 0.0 ], + "min" : [ 0.0, 0.0, 0.0 ] + } + ], + + "asset" : { + "version" : "2.0" + } +} diff --git a/Tests/Assets/GLTF/TriangleWithoutIndices/LICENSE.md b/Tests/Assets/GLTF/TriangleWithoutIndices/LICENSE.md new file mode 100644 index 00000000..c0874869 --- /dev/null +++ b/Tests/Assets/GLTF/TriangleWithoutIndices/LICENSE.md @@ -0,0 +1,17 @@ +# LICENSE file for the model: Triangle Without Indices + +All files in this directory tree are licensed as indicated below. + +* All files directly associated with the model including all text, image and binary files: + + * [Creative Commons Zero v1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/legalcode) [SPDX license identifier: "CC0-1.0"] + +* This file and all other metadocumentation files including "metadata.json": + + * [Creative Commons Attribution 4.0 International]("https://creativecommons.org/licenses/by/4.0/legalcode") [SPDX license identifier: "CC-BY-4.0"] + +Full license text of these licenses are available at the links above. + +This license excludes logos and associated trademarks. + + diff --git a/Tests/Assets/GLTF/TriangleWithoutIndices/TriangleWithoutIndices.bin b/Tests/Assets/GLTF/TriangleWithoutIndices/TriangleWithoutIndices.bin new file mode 100644 index 00000000..f9f96d86 Binary files /dev/null and b/Tests/Assets/GLTF/TriangleWithoutIndices/TriangleWithoutIndices.bin differ diff --git a/Tests/Assets/GLTF/TriangleWithoutIndices/TriangleWithoutIndices.gltf b/Tests/Assets/GLTF/TriangleWithoutIndices/TriangleWithoutIndices.gltf new file mode 100644 index 00000000..b3acc558 --- /dev/null +++ b/Tests/Assets/GLTF/TriangleWithoutIndices/TriangleWithoutIndices.gltf @@ -0,0 +1,55 @@ +{ + "scene" : 0, + "scenes" : [ + { + "nodes" : [ 0 ] + } + ], + + "nodes" : [ + { + "mesh" : 0 + } + ], + + "meshes" : [ + { + "primitives" : [ { + "attributes" : { + "POSITION" : 0 + } + } ] + } + ], + + "buffers" : [ + { + "uri" : "TriangleWithoutIndices.bin", + "byteLength" : 36 + } + ], + "bufferViews" : [ + { + "buffer" : 0, + "byteOffset" : 0, + "byteLength" : 36, + "target" : 34962 + } + ], + "accessors" : [ + { + "bufferView" : 0, + "byteOffset" : 0, + "componentType" : 5126, + "count" : 3, + "type" : "VEC3", + "max" : [ 1.0, 1.0, 0.0 ], + "min" : [ 0.0, 0.0, 0.0 ] + } + ], + + "asset" : { + "version" : "2.0" + } +} + diff --git a/Tests/Assets/README.md b/Tests/Assets/README.md new file mode 100644 index 00000000..dabf7191 --- /dev/null +++ b/Tests/Assets/README.md @@ -0,0 +1,16 @@ +# Test assets + +Fixtures shared by every test target, and by the Example apps. + +| Directory | Contents | +|---|---| +| [`GLTF/`](GLTF/README.md) | CC0-1.0 models from KhronosGroup/glTF-Sample-Assets | +| `VRM/` | `.vrm` models for the VRM 0.x / 1.0 loading paths | + +`VRMTestSupport` owns this directory as its resources (see `Package.swift`), so +the fixtures are copied into one bundle instead of one per test target. Reach +them through `GLTFSampleAsset` / `VRMSampleAsset`, never through +`Bundle.module` of a test target — the assets are not in those bundles. + +When adding a fixture, drop it in the matching directory and add a case to the +corresponding enum in `Tests/VRMTestSupport`. diff --git a/Tests/VRMKitTests/Assets/AliciaSolid.vrm b/Tests/Assets/VRM/AliciaSolid.vrm similarity index 100% rename from Tests/VRMKitTests/Assets/AliciaSolid.vrm rename to Tests/Assets/VRM/AliciaSolid.vrm diff --git a/Tests/VRMKitTests/Assets/Seed-san.vrm b/Tests/Assets/VRM/Seed-san.vrm similarity index 100% rename from Tests/VRMKitTests/Assets/Seed-san.vrm rename to Tests/Assets/VRM/Seed-san.vrm diff --git a/Tests/VRMKitTests/Assets/VRM1_Constraint_Twist_Sample.vrm b/Tests/Assets/VRM/VRM1_Constraint_Twist_Sample.vrm similarity index 100% rename from Tests/VRMKitTests/Assets/VRM1_Constraint_Twist_Sample.vrm rename to Tests/Assets/VRM/VRM1_Constraint_Twist_Sample.vrm diff --git a/Tests/VRMKitTests/BinaryGLTFTests.swift b/Tests/VRMKitTests/BinaryGLTFTests.swift index f37564da..0a587493 100644 --- a/Tests/VRMKitTests/BinaryGLTFTests.swift +++ b/Tests/VRMKitTests/BinaryGLTFTests.swift @@ -9,7 +9,7 @@ class BinaryGLTFTests: XCTestCase { } func testLoadVRM() { - let binaryGltf = try! BinaryGLTF(data: Resources.aliciaSolid.data) + let binaryGltf = try! BinaryGLTF(data: VRMSampleAsset.aliciaSolid.data) let json = binaryGltf.jsonData XCTAssertEqual(json.asset.generator, "UniGLTF") XCTAssertEqual(json.asset.version, "2.0") @@ -62,15 +62,39 @@ class BinaryGLTFTests: XCTestCase { XCTAssertThrowsError(try negative.bufferViewData(at: 0)) } + /// A buffer's declared `byteLength` is where it logically ends, so the bytes + /// a padded GLB BIN chunk holds past it are out of every view's reach. + func testBufferViewDataRejectsRangesBeyondTheDeclaredBufferLength() throws { + func binaryGLTF(bufferByteLength: Int, bufferView: [String: Any]) throws -> BinaryGLTF { + let data = try VRMSampleAsset.aliciaSolid.rewritingJSON { json in + guard var buffers = json["buffers"] as? [[String: Any]], !buffers.isEmpty, + var bufferViews = json["bufferViews"] as? [[String: Any]], !bufferViews.isEmpty else { + throw GLBRewriter.Error.invalidJSON + } + buffers[0]["byteLength"] = bufferByteLength + json["buffers"] = buffers + bufferViews[0].merge(bufferView) { _, new in new } + json["bufferViews"] = bufferViews + } + return try BinaryGLTF(data: data) + } + + let beyond = try binaryGLTF(bufferByteLength: 64, bufferView: ["byteOffset": 60, "byteLength": 16]) + XCTAssertThrowsError(try beyond.bufferViewData(at: 0)) + + let upToTheEnd = try binaryGLTF(bufferByteLength: 64, bufferView: ["byteOffset": 48, "byteLength": 16]) + XCTAssertEqual(try upToTheEnd.bufferViewData(at: 0).data.count, 16) + } + func testBufferViewDataRejectsAnIndexBeyondTheFile() throws { - let binaryGltf = try BinaryGLTF(data: Resources.aliciaSolid.data) + let binaryGltf = try BinaryGLTF(data: VRMSampleAsset.aliciaSolid.data) XCTAssertThrowsError(try binaryGltf.bufferViewData(at: binaryGltf.jsonData.bufferViews?.count ?? 0)) } /// The fixture with `byteOffset` / `byteLength` of its first buffer view /// replaced, leaving the rest of the file intact. private func binaryGLTF(withFirstBufferView fields: [String: Any]) throws -> BinaryGLTF { - let data = try Resources.aliciaSolid.rewritingJSON { json in + let data = try VRMSampleAsset.aliciaSolid.rewritingJSON { json in guard var bufferViews = json["bufferViews"] as? [[String: Any]], !bufferViews.isEmpty else { throw GLBRewriter.Error.invalidJSON } @@ -82,13 +106,13 @@ class BinaryGLTFTests: XCTestCase { /// A file whose magic is not `glTF` has to fail the load. func testRejectsAFileThatIsNotBinaryGLTF() { - var notGLB = Resources.aliciaSolid.data + var notGLB = VRMSampleAsset.aliciaSolid.data notGLB.writeUInt32LE(0x12345678, at: 0) XCTAssertThrowsError(try BinaryGLTF(data: notGLB)) } func testRejectsATruncatedHeader() { - let data = Resources.aliciaSolid.data + let data = VRMSampleAsset.aliciaSolid.data // Cut short at each header field in turn: magic, version, total length, // chunk 0 length, chunk 0 type, and mid-way through the JSON chunk. for count in [0, 4, 8, 12, 16, 20, 24] { @@ -98,11 +122,11 @@ class BinaryGLTFTests: XCTestCase { } func testRejectsChunkLengthsBeyondTheFile() { - var jsonOverrun = Resources.aliciaSolid.data + var jsonOverrun = VRMSampleAsset.aliciaSolid.data jsonOverrun.writeUInt32LE(.max, at: 12) XCTAssertThrowsError(try BinaryGLTF(data: jsonOverrun)) - var binaryOverrun = Resources.aliciaSolid.data + var binaryOverrun = VRMSampleAsset.aliciaSolid.data let binaryChunkOffset = 20 + Int(binaryOverrun.uint32LE(at: 12)) binaryOverrun.writeUInt32LE(.max, at: binaryChunkOffset) XCTAssertThrowsError(try BinaryGLTF(data: binaryOverrun)) @@ -118,7 +142,7 @@ class BinaryGLTFTests: XCTestCase { /// The header length names the size of the whole GLB, so a file shorter than /// it is truncated and its chunk offsets cannot be trusted. func testRejectsAHeaderLengthBeyondTheFile() { - var overrunLength = Resources.aliciaSolid.data + var overrunLength = VRMSampleAsset.aliciaSolid.data overrunLength.writeUInt32LE(UInt32(overrunLength.count + 4), at: 8) XCTAssertThrowsError(try BinaryGLTF(data: overrunLength)) } @@ -126,24 +150,95 @@ class BinaryGLTFTests: XCTestCase { /// Bytes past the header length are not part of any chunk, so a file that /// carries them (exporter padding, a slice of a larger container) still loads. func testLoadsAFileWithBytesPastTheHeaderLength() throws { - var trailingBytes = Resources.aliciaSolid.data + var trailingBytes = VRMSampleAsset.aliciaSolid.data trailingBytes.append(contentsOf: [0, 0, 0, 0]) let binaryGltf = try BinaryGLTF(data: trailingBytes) XCTAssertEqual(binaryGltf.jsonData.asset.version, "2.0") XCTAssertNotNil(binaryGltf.binaryBuffer) } + /// The spec has readers ignore chunk types they do not know, so a file with + /// one still loads with its JSON and BIN chunks intact. + func testSkipsChunkTypesItDoesNotKnow() throws { + let unknownChunk = appendingChunk(type: 0x4E574E55, payload: Data([1, 2, 3, 4]), + to: VRMSampleAsset.aliciaSolid.data) + let binaryGltf = try BinaryGLTF(data: unknownChunk) + XCTAssertEqual(binaryGltf.jsonData.asset.version, "2.0") + XCTAssertNotNil(binaryGltf.binaryBuffer) + } + + /// The header length bounds the chunks, so a chunk reaching past it is + /// malformed even when the bytes it names are present in the file. + func testRejectsAChunkBeyondTheHeaderLength() { + var overrun = appendingChunk(type: 0x4E574E55, payload: Data([1, 2, 3, 4]), + to: VRMSampleAsset.aliciaSolid.data) + // Leave the trailing chunk in the file but outside the declared length. + overrun.writeUInt32LE(UInt32(overrun.count - 4), at: 8) + XCTAssertThrowsError(try BinaryGLTF(data: overrun)) + } + + /// A GLB whose first chunk is not the JSON chunk is not loadable: everything + /// else in the container is described by it. + func testRejectsAFileWhoseFirstChunkIsNotJSON() { + let header = VRMSampleAsset.aliciaSolid.data.prefix(12) + let noJSON = appendingChunk(type: 0x4E574E55, payload: Data([1, 2, 3, 4]), to: Data(header)) + XCTAssertThrowsError(try BinaryGLTF(data: noJSON)) + } + + /// Bytes inside the declared length that are too few to start a chunk are + /// described by nothing, so the container does not add up and must not load. + func testRejectsBytesLeftOverInsideTheHeaderLength() { + var leftover = VRMSampleAsset.aliciaSolid.data + leftover.append(contentsOf: [0, 0, 0, 0]) + leftover.writeUInt32LE(UInt32(leftover.count), at: 8) + XCTAssertThrowsError(try BinaryGLTF(data: leftover)) + } + + /// Every chunk starts and ends on a 4 byte boundary, so a chunk length that + /// is not a multiple of 4 misaligns every chunk behind it. + func testRejectsAChunkLengthThatBreaksTheAlignment() { + var misaligned = VRMSampleAsset.aliciaSolid.data + misaligned.writeUInt32LE(misaligned.uint32LE(at: 12) - 1, at: 12) + XCTAssertThrowsError(try BinaryGLTF(data: misaligned)) + } + + /// The spec fixes the BIN chunk at index 1, so a file that pushes it behind + /// an unknown chunk is malformed even though each chunk on its own is not. + func testRejectsABINChunkThatIsNotTheSecondChunk() { + let data = VRMSampleAsset.aliciaSolid.data + let binaryChunkOffset = 20 + Int(data.uint32LE(at: 12)) + var reordered = Data(data.prefix(binaryChunkOffset)) + reordered.appendUInt32LE(4) + reordered.appendUInt32LE(0x4E574E55) + reordered.append(contentsOf: [1, 2, 3, 4]) + reordered.append(data.suffix(from: binaryChunkOffset)) + reordered.writeUInt32LE(UInt32(reordered.count), at: 8) + XCTAssertThrowsError(try BinaryGLTF(data: reordered)) + } + + /// `data` with one more chunk, its header length grown to cover it. + private func appendingChunk(type: UInt32, payload: Data, to data: Data) -> Data { + var extended = data + extended.appendUInt32LE(UInt32(payload.count)) + extended.appendUInt32LE(type) + extended.append(payload) + if extended.count >= 12 { + extended.writeUInt32LE(UInt32(extended.count), at: 8) + } + return extended + } + /// The GLB container version and the asset version are independent, so a 2.0 /// container can still declare an asset this parser does not implement. func testRejectsAnAssetVersionItDoesNotImplement() throws { - let futureVersion = try Resources.aliciaSolid.rewritingJSON { json in + let futureVersion = try VRMSampleAsset.aliciaSolid.rewritingJSON { json in var asset = json["asset"] as? [String: Any] ?? [:] asset["version"] = "3.0" json["asset"] = asset } XCTAssertThrowsError(try BinaryGLTF(data: futureVersion)) - let futureMinVersion = try Resources.aliciaSolid.rewritingJSON { json in + let futureMinVersion = try VRMSampleAsset.aliciaSolid.rewritingJSON { json in var asset = json["asset"] as? [String: Any] ?? [:] asset["minVersion"] = "2.1" json["asset"] = asset diff --git a/Tests/VRMKitTests/GLTFLoaderTests.swift b/Tests/VRMKitTests/GLTFLoaderTests.swift new file mode 100644 index 00000000..b38c648f --- /dev/null +++ b/Tests/VRMKitTests/GLTFLoaderTests.swift @@ -0,0 +1,191 @@ +import Foundation +import Testing +import VRMTestSupport +@testable import VRMKit + +@Suite +struct GLTFLoaderTests { + @Test + func testGLBDataLoadsAsBinaryDocument() throws { + let document = try GLTFLoader().load(withData: VRMSampleAsset.aliciaSolid.data) + + #expect(document.binaryBuffer != nil) + #expect(document.gltf.nodes?.isEmpty == false) + // The BIN chunk resolves through the document without a root directory. + #expect(try document.bufferData(at: 0).isEmpty == false) + } + + @Test + func testJSONDataLoadsAsDocumentWithDataURIBuffer() throws { + let json = """ + { + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [{"name": "root"}], + "buffers": [{"uri": "data:application/octet-stream;base64,AAECAw==", "byteLength": 4}] + } + """ + let document = try GLTFLoader().load(withData: Data(json.utf8)) + + #expect(document.binaryBuffer == nil) + #expect(document.gltf.nodes?.count == 1) + #expect(try document.bufferData(at: 0) == Data([0, 1, 2, 3])) + } + + /// RFC 2397 makes `;base64` optional: without it the data is percent-encoded + /// octets, which glTF allows for an image `uri`. + @Test + func testPercentEncodedDataURIResolvesToItsOctets() throws { + let json = """ + { + "asset": {"version": "2.0"}, + "buffers": [{"uri": "data:application/octet-stream,%00%01%02%03", "byteLength": 4}] + } + """ + let document = try GLTFLoader().load(withData: Data(json.utf8)) + + #expect(try document.bufferData(at: 0) == Data([0, 1, 2, 3])) + } + + /// A media type of its own is optional too, so the shortest data uri is just + /// `data:,`. + @Test + func testDataURIWithoutAMediaTypeResolves() throws { + #expect(try Data(gltfUrlString: "data:,AB", relativeTo: nil) == Data("AB".utf8)) + #expect(try Data(gltfUrlString: "data:;base64,QUI=", relativeTo: nil) == Data("AB".utf8)) + } + + @Test + func testMalformedDataURIFails() { + // No "," at all, and a truncated percent escape. + #expect(throws: VRMError.self) { try Data(gltfUrlString: "data:application/octet-stream", relativeTo: nil) } + #expect(throws: VRMError.self) { try Data(gltfUrlString: "data:,%0", relativeTo: nil) } + #expect(throws: VRMError.self) { try Data(gltfUrlString: "data:;base64,!!!", relativeTo: nil) } + } + + @Test + func testJSONDataWithUnsupportedAssetVersionFails() { + let json = """ + {"asset": {"version": "1.0"}} + """ + #expect(throws: VRMError.self) { + try GLTFLoader().load(withData: Data(json.utf8)) + } + } + + /// A glTF `uri` is a URI reference, so a resource whose name needs escaping + /// is referenced percent-encoded and has to be resolved that way. + @Test + func testPercentEncodedExternalResourceResolves() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("VRMKitTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let payload = Data([0, 1, 2, 3]) + try payload.write(to: directory.appendingPathComponent("My Buffer.bin")) + let json = """ + { + "asset": {"version": "2.0"}, + "buffers": [{"uri": "My%20Buffer.bin", "byteLength": 4}] + } + """ + let document = try GLTFLoader().load(withData: Data(json.utf8), rootDirectory: directory) + + #expect(try document.bufferData(at: 0) == payload) + } + + /// Resolving a `uri` must not put the loader on the network behind the + /// caller's back. + @Test + func testRemoteResourceURIIsRejected() throws { + let json = """ + { + "asset": {"version": "2.0"}, + "buffers": [{"uri": "https://example.com/buffer.bin", "byteLength": 4}] + } + """ + let document = try GLTFLoader().load(withData: Data(json.utf8)) + + #expect(throws: VRMError.self) { try document.bufferData(at: 0) } + } + + /// A relative `uri` is relative to the directory of the glTF, so a document + /// loaded from data alone cannot resolve one. + @Test + func testRelativeResourceURIWithoutARootDirectoryIsRejected() throws { + let json = """ + { + "asset": {"version": "2.0"}, + "buffers": [{"uri": "buffer.bin", "byteLength": 4}] + } + """ + let document = try GLTFLoader().load(withData: Data(json.utf8)) + + #expect(throws: VRMError.self) { try document.bufferData(at: 0) } + // The same document resolves it once it knows where the asset lives. + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("VRMKitTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let payload = Data([0, 1, 2, 3]) + try payload.write(to: directory.appendingPathComponent("buffer.bin")) + + let located = try GLTFLoader().load(withData: Data(json.utf8), rootDirectory: directory) + #expect(try located.bufferData(at: 0) == payload) + } + + /// glTF defaults a node's `rotation` to identity, not to the zero vector. + @Test + func testNodeRotationDefaultsToIdentity() throws { + let json = """ + { + "asset": {"version": "2.0"}, + "nodes": [{}, {"rotation": [0, 0.7071068, 0, 0.7071068]}] + } + """ + let nodes = try #require(GLTFLoader().load(withData: Data(json.utf8)).gltf.nodes) + + #expect(nodes[0].rotation.w == 1) + #expect(nodes[0].rotation.x == 0) + #expect(nodes[1].rotation.y == 0.7071068) + } + + /// glTF leaves `scene` out for assets that are a library of nodes rather than + /// something to render, which is not the same as naming scene 0. + @Test + func testDefaultSceneIsAbsentWhenTheAssetNamesNone() throws { + let json = """ + {"asset": {"version": "2.0"}, "scenes": [{"nodes": []}]} + """ + #expect(try GLTFLoader().load(withData: Data(json.utf8)).gltf.scene == nil) + #expect(try GLTFLoader().load(withData: VRMSampleAsset.seedSan.data).gltf.scene == 0) + } + + /// glTF defaults `mode` to TRIANGLES only when the primitive leaves it out; + /// a value outside 0...6 is malformed and must not silently render as one. + @Test + func testPrimitiveModeOutsideTheSpecFailsTheLoad() throws { + func json(mode: String) -> Data { + Data(""" + { + "asset": {"version": "2.0"}, + "meshes": [{"primitives": [{"attributes": {"POSITION": 0}\(mode)}]}] + } + """.utf8) + } + let defaulted = try GLTFLoader().load(withData: json(mode: "")).gltf + #expect(defaulted.meshes?.first?.primitives.first?.mode == .TRIANGLES) + #expect(try GLTFLoader().load(withData: json(mode: #", "mode": 0"#)) + .gltf.meshes?.first?.primitives.first?.mode == .POINTS) + #expect(throws: (any Error).self) { try GLTFLoader().load(withData: json(mode: #", "mode": 7"#)) } + #expect(throws: (any Error).self) { try GLTFLoader().load(withData: json(mode: #", "mode": "4""#)) } + } + + @Test + func testGLBMagicDetection() { + #expect(BinaryGLTF.isGLB(VRMSampleAsset.aliciaSolid.data)) + #expect(!BinaryGLTF.isGLB(Data("{\"asset\":{}}".utf8))) + #expect(!BinaryGLTF.isGLB(Data([0x67, 0x6C]))) + } +} diff --git a/Tests/VRMKitTests/GLTFSampleAssetTests.swift b/Tests/VRMKitTests/GLTFSampleAssetTests.swift new file mode 100644 index 00000000..7993caf6 --- /dev/null +++ b/Tests/VRMKitTests/GLTFSampleAssetTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing +import VRMTestSupport +@testable import VRMKit + +/// Parses the Khronos CC0 sample assets, covering what the VRM fixtures — all +/// GLB with an embedded BIN chunk — never reach: JSON `.gltf` files with +/// external resources and with data URI buffers. +@Suite +struct GLTFSampleAssetTests { + @Test(arguments: GLTFSampleAsset.allCases) + func testEverySampleAssetLoadsAndResolvesItsBuffers(_ asset: GLTFSampleAsset) throws { + let document = try GLTFLoader().load(withURL: asset.url) + + #expect(document.gltf.asset.version.hasPrefix("2.")) + #expect(document.gltf.nodes?.isEmpty == false) + #expect(document.gltf.meshes?.isEmpty == false) + + // Reading every buffer view proves the resource context is right: GLB + // chunk, sibling file or data URI, whichever this asset uses. + for index in (document.gltf.bufferViews ?? []).indices { + #expect(try !document.bufferViewData(at: index).data.isEmpty) + } + } + + @Test + func testExternalBinaryResolvesRelativeToTheGLTFFile() throws { + let document = try GLTFLoader().load(withURL: GLTFSampleAsset.triangle.url) + + #expect(document.binaryBuffer == nil) + #expect(document.rootDirectory != nil) + // Triangle.bin holds 3 vertices of 3 floats plus 3 shorts of indices. + #expect(try document.bufferData(at: 0).count == 44) + } + + @Test + func testExternalBinaryFailsWithoutARootDirectory() throws { + // Loaded from memory there is nowhere to resolve "Triangle.bin" from. + let document = try GLTFLoader().load(withData: GLTFSampleAsset.triangle.data) + + #expect(throws: (any Error).self) { + _ = try document.bufferData(at: 0) + } + } + + @Test + func testEmbeddedDataURIBufferNeedsNoRootDirectory() throws { + let document = try GLTFLoader().load(withData: GLTFSampleAsset.simpleSkin.data) + + #expect(document.binaryBuffer == nil) + #expect(try !document.bufferData(at: 0).isEmpty) + #expect(document.gltf.skins?.count == 1) + } + + @Test + func testGLBSampleAssetLoadsThroughTheBinaryPath() throws { + let document = try GLTFLoader().load(withData: GLTFSampleAsset.boxVertexColors.data) + + #expect(document.binaryBuffer != nil) + let primitive = try #require(document.gltf.meshes?.first?.primitives.first) + #expect(primitive.attributes.rawValue[.COLOR_0] != nil) + } + + @Test + func testAnimationModelDecodesChannelsAndSamplers() throws { + let document = try GLTFLoader().load(withData: GLTFSampleAsset.simpleMorph.data) + let animation = try #require(document.gltf.animations?.first) + let channel = try #require(animation.channels.first) + + #expect(channel.target.node == 0) + #expect(channel.target.targetPath == .weights) + #expect(animation.samplers[channel.sampler].interpolation == .LINEAR) + } + + @Test + func testInterpolationTestCoversEveryInterpolationMode() throws { + let document = try GLTFLoader().load(withData: GLTFSampleAsset.interpolationTest.data) + let animations = try #require(document.gltf.animations) + let interpolations = Set(animations.flatMap { $0.samplers.map(\.interpolation) }) + + #expect(interpolations == [.LINEAR, .STEP, .CUBICSPLINE]) + // Every channel targets a node by index, what the runtime binds against. + for animation in animations { + for channel in animation.channels { + #expect(channel.target.node != nil) + #expect(channel.target.targetPath != nil) + } + } + } +} diff --git a/Tests/VRMKitTests/Resources.swift b/Tests/VRMKitTests/Resources.swift deleted file mode 100644 index 29d41090..00000000 --- a/Tests/VRMKitTests/Resources.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -import VRMTestSupport - -enum Resources { - case aliciaSolid - case seedSan - - var data: Data { - switch self { - case .aliciaSolid: - let url = Bundle.module.url(forResource: "AliciaSolid", withExtension: "vrm")! - return try! Data(contentsOf: url) - case .seedSan: - let url = Bundle.module.url(forResource: "Seed-san", withExtension: "vrm")! - return try! Data(contentsOf: url) - } - } -} - -extension Resources { - /// The fixture with its glTF JSON rewritten, so tests can feed the loaders - /// malformed or unusual files without shipping extra assets. - func rewritingJSON(_ modify: (inout [String: Any]) throws -> Void) throws -> Data { - try GLBRewriter.rewritingJSON(of: data, modify) - } - - /// The fixture with `extensions.VRMC_vrm.specVersion` replaced. Passing nil - /// removes the key entirely. - func withVRMCSpecVersion(_ specVersion: Any?) throws -> Data { - try rewritingJSON { json in - guard var extensions = json["extensions"] as? [String: Any], - var vrm = extensions["VRMC_vrm"] as? [String: Any] else { - throw GLBRewriter.Error.invalidJSON - } - if let specVersion { - vrm["specVersion"] = specVersion - } else { - vrm.removeValue(forKey: "specVersion") - } - extensions["VRMC_vrm"] = vrm - json["extensions"] = extensions - } - } -} diff --git a/Tests/VRMKitTests/VRM0Tests.swift b/Tests/VRMKitTests/VRM0Tests.swift index dfe438c9..d506735a 100644 --- a/Tests/VRMKitTests/VRM0Tests.swift +++ b/Tests/VRMKitTests/VRM0Tests.swift @@ -1,9 +1,10 @@ import XCTest import VRMKit +import VRMTestSupport class VRM0Tests: XCTestCase { - let vrm = try! VRM(data: Resources.aliciaSolid.data) + let vrm = try! VRM(data: VRMSampleAsset.aliciaSolid.data) override func setUp() { super.setUp() diff --git a/Tests/VRMKitTests/VRM1MigrationTests.swift b/Tests/VRMKitTests/VRM1MigrationTests.swift index 85c0a5a1..5c4f92d1 100644 --- a/Tests/VRMKitTests/VRM1MigrationTests.swift +++ b/Tests/VRMKitTests/VRM1MigrationTests.swift @@ -1,12 +1,13 @@ import Testing import VRMKit import Foundation +import VRMTestSupport struct VRM1MigrationTests { @Test("Meta: VRM1 -> VRM0") func migrationMetaVRM1toVRM0() throws { - let vrm = try VRM(data: Resources.seedSan.data) + let vrm = try VRM(data: VRMSampleAsset.seedSan.data) // VRM1 data accessed via VRM0 format migration #expect(vrm.meta.title == "Seed-san") @@ -22,7 +23,7 @@ struct VRM1MigrationTests { @Test("Humanoid: VRM1 -> VRM0") func migrationHumanoidVRM1toVRM0() throws { - let vrm = try VRM(data: Resources.seedSan.data) + let vrm = try VRM(data: VRMSampleAsset.seedSan.data) // VRM1 data accessed via VRM0 format migration #expect(vrm.humanoid.humanBones.count == 51) @@ -32,7 +33,7 @@ struct VRM1MigrationTests { @Test("BlendShape: VRM1 -> VRM0") func migrationBlendShapeVRM1toVRM0() throws { - let vrm = try VRM(data: Resources.seedSan.data) + let vrm = try VRM(data: VRMSampleAsset.seedSan.data) #expect(vrm.blendShapeMaster.blendShapeGroups.count == 18) @@ -45,7 +46,7 @@ struct VRM1MigrationTests { @Test("FirstPerson: VRM1 -> VRM0") func migrationFirstPersonVRM1toVRM0() throws { - let vrm = try VRM(data: Resources.seedSan.data) + let vrm = try VRM(data: VRMSampleAsset.seedSan.data) #expect(vrm.firstPerson.meshAnnotations.count == 5) #expect(vrm.firstPerson.firstPersonBone == -1) @@ -54,7 +55,7 @@ struct VRM1MigrationTests { @Test("SpringBone: VRM1 -> VRM0") func migrationSecondaryAnimationVRM1toVRM0() throws { - let vrm = try VRM(data: Resources.seedSan.data) + let vrm = try VRM(data: VRMSampleAsset.seedSan.data) #expect(vrm.secondaryAnimation.colliderGroups.count == 6) @@ -64,7 +65,7 @@ struct VRM1MigrationTests { @Test("Material: MToon VRM1 -> VRM0") func migrationMaterialVRM1toVRM0() throws { - let vrm = try VRM(data: Resources.seedSan.data) + let vrm = try VRM(data: VRMSampleAsset.seedSan.data) #expect(vrm.materialProperties.count == 17) @@ -95,7 +96,7 @@ struct VRM1MigrationTests { @Test("VRM1 Version Detection") func versionDetection() throws { - let vrm = try VRM(data: Resources.seedSan.data) + let vrm = try VRM(data: VRMSampleAsset.seedSan.data) guard case .v1(let vrm1) = vrm else { throw VRMError.dataInconsistent("Expected VRM1") diff --git a/Tests/VRMKitTests/VRM1Tests.swift b/Tests/VRMKitTests/VRM1Tests.swift index ae57eb45..1446efd0 100644 --- a/Tests/VRMKitTests/VRM1Tests.swift +++ b/Tests/VRMKitTests/VRM1Tests.swift @@ -1,9 +1,10 @@ import XCTest import VRMKit +import VRMTestSupport class VRM1Tests: XCTestCase { - let vrm = try! VRM1(data: Resources.seedSan.data) + let vrm = try! VRM1(data: VRMSampleAsset.seedSan.data) override func setUp() { super.setUp() @@ -16,9 +17,9 @@ class VRM1Tests: XCTestCase { /// A missing or mistyped specVersion has to surface as a thrown error rather /// than trapping in the initializer. func testMalformedSpecVersionThrowsInsteadOfCrashing() throws { - XCTAssertThrowsError(try VRM1(data: try Resources.seedSan.withVRMCSpecVersion(nil))) - XCTAssertThrowsError(try VRM1(data: try Resources.seedSan.withVRMCSpecVersion(1.0))) - XCTAssertThrowsError(try VRM1(data: try Resources.seedSan.withVRMCSpecVersion(["1.0"]))) + XCTAssertThrowsError(try VRM1(data: try VRMSampleAsset.seedSan.withVRMCSpecVersion(nil))) + XCTAssertThrowsError(try VRM1(data: try VRMSampleAsset.seedSan.withVRMCSpecVersion(1.0))) + XCTAssertThrowsError(try VRM1(data: try VRMSampleAsset.seedSan.withVRMCSpecVersion(["1.0"]))) } func testUnsupportedSpecVersionIsRejected() throws { @@ -27,8 +28,8 @@ class VRM1Tests: XCTestCase { XCTAssertFalse(VRM1.supports(specVersion: "2.0")) XCTAssertFalse(VRM1.supports(specVersion: "1.0-draft")) - XCTAssertNoThrow(try VRM1(data: try Resources.seedSan.withVRMCSpecVersion("1.0-beta"))) - XCTAssertThrowsError(try VRM1(data: try Resources.seedSan.withVRMCSpecVersion("2.0"))) + XCTAssertNoThrow(try VRM1(data: try VRMSampleAsset.seedSan.withVRMCSpecVersion("1.0-beta"))) + XCTAssertThrowsError(try VRM1(data: try VRMSampleAsset.seedSan.withVRMCSpecVersion("2.0"))) } diff --git a/Tests/VRMKitTests/VRMSampleAsset+SpecVersion.swift b/Tests/VRMKitTests/VRMSampleAsset+SpecVersion.swift new file mode 100644 index 00000000..f001ef95 --- /dev/null +++ b/Tests/VRMKitTests/VRMSampleAsset+SpecVersion.swift @@ -0,0 +1,22 @@ +import Foundation +import VRMTestSupport + +extension VRMSampleAsset { + /// The fixture with `extensions.VRMC_vrm.specVersion` replaced. Passing nil + /// removes the key entirely. + func withVRMCSpecVersion(_ specVersion: Any?) throws -> Data { + try rewritingJSON { json in + guard var extensions = json["extensions"] as? [String: Any], + var vrm = extensions["VRMC_vrm"] as? [String: Any] else { + throw GLBRewriter.Error.invalidJSON + } + if let specVersion { + vrm["specVersion"] = specVersion + } else { + vrm.removeValue(forKey: "specVersion") + } + extensions["VRMC_vrm"] = vrm + json["extensions"] = extensions + } + } +} diff --git a/Tests/VRMRealityKitTests/ApproximateEquality.swift b/Tests/VRMRealityKitTests/ApproximateEquality.swift index cab1fe91..a13ce804 100644 --- a/Tests/VRMRealityKitTests/ApproximateEquality.swift +++ b/Tests/VRMRealityKitTests/ApproximateEquality.swift @@ -7,6 +7,12 @@ extension Float { } } +extension Double { + func isApproximatelyEqual(to other: Double, tolerance: Double = 0.0001) -> Bool { + abs(self - other) < tolerance + } +} + extension SIMD where Scalar == Float { func isApproximatelyEqual(to other: Self, tolerance: Float = 0.0001) -> Bool { indices.allSatisfy { abs(self[$0] - other[$0]) < tolerance } diff --git a/Tests/VRMRealityKitTests/GLTFAnimationPlaybackTests.swift b/Tests/VRMRealityKitTests/GLTFAnimationPlaybackTests.swift new file mode 100644 index 00000000..85d87336 --- /dev/null +++ b/Tests/VRMRealityKitTests/GLTFAnimationPlaybackTests.swift @@ -0,0 +1,597 @@ +#if canImport(RealityKit) +import Foundation +import RealityKit +import simd +import Testing +import VRMKit +import VRMTestSupport +@testable import VRMRealityKit + +/// Plays the Khronos CC0 animation fixtures through the glTF animation +/// runtime, driving the tick directly instead of through a rendering scene. +@Suite +@MainActor +struct GLTFAnimationPlaybackTests { + @Test + func testAnimationMetadataIsLazyAndIndexBased() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(GLTFSampleAsset.animatedTriangle) + + let animations = entity.animations + #expect(animations.count == 1) + #expect(animations[0].index == 0) + #expect(animations[0].name == nil) + #expect(animations[0].duration.isApproximatelyEqual(to: 1.0)) + // Unnamed animations are only addressable by index. + #expect(entity.animations(named: "walk").isEmpty) + } + + /// A controller outlives its playback whenever the caller keeps it, so it + /// must hold neither the entity nor the runtime posing the entity graph. + @Test + func testAKeptControllerDoesNotRetainTheAnimatedGraph() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + weak var loaded: GLTFEntity? + weak var animatedNode: Entity? + var controller: GLTFAnimationPlaybackController? + do { + let entity = try TestSupport.loadEntity(GLTFSampleAsset.animatedTriangle) + loaded = entity + animatedNode = try #require(entity.entity(forNodeAt: 0)) + controller = try entity.playAnimation(at: 0) + controller?.stop() + } + + #expect(controller != nil) + #expect(loaded == nil) + #expect(animatedNode == nil) + } + + @Test + func testRotationChannelDrivesTheNodeTransform() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(GLTFSampleAsset.animatedTriangle) + let node = try #require(entity.entity(forNodeAt: 0)) + + let controller = try entity.playAnimation(at: 0) + // Keyframe 0.25 is exactly a 90° rotation around +z. + entity.updateAnimations(deltaTime: 0.25) + #expect(controller.time.isApproximatelyEqual(to: 0.25)) + let quarter = simd_quatf(angle: .pi / 2, axis: SIMD3(0, 0, 1)) + #expect(abs(simd_dot(node.transform.rotation, quarter)) > 0.999) + + // Between keyframes 0 and 0.25 the rotation slerps: 45° at t = 0.125. + controller.seek(to: 0.125) + let eighth = simd_quatf(angle: .pi / 4, axis: SIMD3(0, 0, 1)) + #expect(abs(simd_dot(node.transform.rotation, eighth)) > 0.999) + } + + @Test + func testWeightsChannelDrivesBlendShapeWeights() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(GLTFSampleAsset.simpleMorph) + let modelEntity = try #require(entity.morphBindings[0]?.modelEntities.first) + + // SimpleMorph: 2 targets, keyframes at t = 0...4 with weights + // (0,0) (0,1) (1,1) (1,0) (0,0); mesh.weights started them at 0.5. + let controller = try entity.playAnimation(at: 0) + entity.updateAnimations(deltaTime: 1.0) + var weights = try #require(modelEntity.blendWeights.first) + #expect(weights[0].isApproximatelyEqual(to: 0)) + #expect(weights[1].isApproximatelyEqual(to: 1)) + + // Halfway between keyframes 1 and 2 both targets interpolate. + controller.seek(to: 1.5) + weights = try #require(modelEntity.blendWeights.first) + #expect(weights[0].isApproximatelyEqual(to: 0.5)) + #expect(weights[1].isApproximatelyEqual(to: 1)) + } + + @Test + func testJointAnimationRefreshesTheSkeletalPose() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(GLTFSampleAsset.simpleSkin) + let binding = try #require(entity.skinBindings.first) + + func pose() throws -> JointTransforms { + let component = try #require(binding.modelEntity.components[SkeletalPosesComponent.self]) + return try #require(component.poses.default?.jointTransforms) + } + + let initialPose = try pose() + try entity.playAnimation(at: 0) + // SimpleSkin's joint node 2 is rotated 90° around +z at t = 1. + entity.updateAnimations(deltaTime: 1.0) + let animatedPose = try pose() + let changed = zip(initialPose, animatedPose).contains { before, after in + abs(simd_dot(before.rotation, after.rotation)) < 0.999 + } + #expect(changed) + } + + @Test + func testInterpolationTestDecodesAndPlaysEveryAnimation() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(GLTFSampleAsset.interpolationTest) + + // 9 animations covering LINEAR / STEP / CUBICSPLINE for rotation and + // translation; every one must decode and evaluate without throwing. + #expect(entity.animations.count == 9) + for animation in entity.animations { + #expect(animation.duration > 0, "animation \(animation.index)") + let controller = try entity.playAnimation(at: animation.index, loops: true) + entity.updateAnimations(deltaTime: animation.duration * 0.4) + controller.stop() + } + } + + @Test + func testNonLoopingPlaybackCompletesAndHoldsTheFinalPose() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(GLTFSampleAsset.animatedTriangle) + let node = try #require(entity.entity(forNodeAt: 0)) + + let controller = try entity.playAnimation(at: 0) + #expect(entity.components.has(GLTFAnimationPlaybackComponent.self)) + entity.updateAnimations(deltaTime: 5) + + #expect(controller.isComplete) + #expect(controller.time.isApproximatelyEqual(to: 1.0)) + // The final keyframe is identity; the pose holds there. + #expect(abs(node.transform.rotation.real).isApproximatelyEqual(to: 1, tolerance: 0.001)) + // A completed entity leaves the animation system's query. + #expect(!entity.components.has(GLTFAnimationPlaybackComponent.self)) + } + + @Test + func testLoopingPlaybackWrapsAndPauseHolds() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(GLTFSampleAsset.animatedTriangle) + + let controller = try entity.playAnimation(at: 0, loops: true, speed: 2) + entity.updateAnimations(deltaTime: 0.6) // 1.2s of animation time wraps to 0.2 + #expect(!controller.isComplete) + #expect(controller.time.isApproximatelyEqual(to: 0.2, tolerance: 0.001)) + + controller.isPaused = true + entity.updateAnimations(deltaTime: 10) + #expect(controller.time.isApproximatelyEqual(to: 0.2, tolerance: 0.001)) + + controller.stop() + #expect(!entity.components.has(GLTFAnimationPlaybackComponent.self)) + } + + /// A CUBICSPLINE rotation output interleaves in-tangent / value / out-tangent, + /// and normalizing the tangents would rescale the curve's slope. + @Test + func testCubicSplineRotationKeepsItsTangentsUnnormalized() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let decoder = GLTFAnimationDecoder(document: try GLTFLoader().load(withData: splineRotationFixture())) + let quaternions = try decoder.quaternions(at: 1, interpolation: .CUBICSPLINE) + + // Keyframe 0: in-tangent (0,0,0,0), value identity, out-tangent (0,0,4,0). + #expect(quaternions.count == 6) + #expect(simd_length(quaternions[0].vector).isApproximatelyEqual(to: 0)) + #expect(simd_length(quaternions[1].vector).isApproximatelyEqual(to: 1)) + #expect(quaternions[2].vector.isApproximatelyEqual(to: SIMD4(0, 0, 4, 0))) + // The same accessor read as LINEAR normalizes every element instead. + let linearDecoder = GLTFAnimationDecoder(document: try GLTFLoader().load(withData: splineRotationFixture())) + let asLinear = try linearDecoder.quaternions(at: 1, interpolation: .LINEAR) + #expect(simd_length(asLinear[2].vector).isApproximatelyEqual(to: 1)) + } + + /// Two *different* animations driving the same morph targets: the one started + /// last wins, and stopping it lets the first one drive the targets again. + @Test + func testASecondWeightsAnimationTakesOverAndReleasesTheTargets() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try GLTFEntityLoader(withData: twoWeightAnimationsFixture()).loadEntity() + let modelEntity = try #require(entity.morphBindings[0]?.modelEntities.first) + func weight() throws -> Float { + try #require(modelEntity.blendWeights.first?.first) + } + + let first = try entity.playAnimation(at: 0, loops: true) // holds 0.25 + entity.updateAnimations(deltaTime: 0) + #expect(try weight().isApproximatelyEqual(to: 0.25)) + + let second = try entity.playAnimation(at: 1, loops: true) // holds 0.75 + entity.updateAnimations(deltaTime: 0) + #expect(try weight().isApproximatelyEqual(to: 0.75)) + + second.stop() + entity.updateAnimations(deltaTime: 0) + #expect(try weight().isApproximatelyEqual(to: 0.25)) + #expect(!first.isComplete) + } + + /// Seeking an animation another one outranks must not let it take the target + /// over until the next frame: the later-started animation still wins. + @Test + func testSeekingAnOutrankedAnimationDoesNotTakeOverTheTarget() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try GLTFEntityLoader(withData: twoWeightAnimationsFixture()).loadEntity() + let modelEntity = try #require(entity.morphBindings[0]?.modelEntities.first) + func weight() throws -> Float { + try #require(modelEntity.blendWeights.first?.first) + } + + let first = try entity.playAnimation(at: 0, loops: true) // holds 0.25 + try entity.playAnimation(at: 1, loops: true) // holds 0.75, started last + first.seek(to: 0.5) + #expect(try weight().isApproximatelyEqual(to: 0.75)) + + // The seek still poses the model once nothing outranks it any more. + entity.stopAnimations() + first.seek(to: 0.5) + #expect(try weight().isApproximatelyEqual(to: 0.25)) + } + + /// One morph target, two STEP animations holding different constant + /// weights on the same node. + private func twoWeightAnimationsFixture() -> Data { + var buffer = Data(littleEndianFloats: [0, 0, 0, 1, 0, 0, 0, 1, 0]) // POSITION + let targetOffset = buffer.count + buffer.append(Data(littleEndianFloats: [0, 1, 0, 0, 1, 0, 0, 1, 0])) // morph target offsets + let timesOffset = buffer.count + buffer.append(Data(littleEndianFloats: [0, 1])) + let weightsAOffset = buffer.count + buffer.append(Data(littleEndianFloats: [0.25, 0.25])) + let weightsBOffset = buffer.count + buffer.append(Data(littleEndianFloats: [0.75, 0.75])) + + let json = """ + { + "asset": {"version": "2.0"}, + "scene": 0, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "targets": [{"POSITION": 1}]}]}], + "animations": [ + {"channels": [{"sampler": 0, "target": {"node": 0, "path": "weights"}}], + "samplers": [{"input": 2, "interpolation": "STEP", "output": 3}]}, + {"channels": [{"sampler": 0, "target": {"node": 0, "path": "weights"}}], + "samplers": [{"input": 2, "interpolation": "STEP", "output": 4}]} + ], + "buffers": [{"uri": "data:application/octet-stream;base64,\(buffer.base64EncodedString())", "byteLength": \(buffer.count)}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": \(targetOffset), "byteLength": 36}, + {"buffer": 0, "byteOffset": \(timesOffset), "byteLength": 8}, + {"buffer": 0, "byteOffset": \(weightsAOffset), "byteLength": 8}, + {"buffer": 0, "byteOffset": \(weightsBOffset), "byteLength": 8} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", "min": [0, 0, 0], "max": [1, 1, 0]}, + {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3", "min": [0, 0, 0], "max": [0, 1, 0]}, + {"bufferView": 2, "componentType": 5126, "count": 2, "type": "SCALAR", "min": [0], "max": [1]}, + {"bufferView": 3, "componentType": 5126, "count": 2, "type": "SCALAR"}, + {"bufferView": 4, "componentType": 5126, "count": 2, "type": "SCALAR"} + ] + } + """ + return Data(json.utf8) + } + + /// A one-node, one-morph-target glTF whose only animation drives `weights` + /// with the given keyframe times and STEP output values. + private func weightAnimationFixture(times: [Float], weights: [Float]) -> Data { + var buffer = Data(littleEndianFloats: [0, 0, 0, 1, 0, 0, 0, 1, 0]) // POSITION + let targetOffset = buffer.count + buffer.append(Data(littleEndianFloats: [0, 1, 0, 0, 1, 0, 0, 1, 0])) // morph target offsets + let timesOffset = buffer.count + buffer.append(Data(littleEndianFloats: times)) + let weightsOffset = buffer.count + buffer.append(Data(littleEndianFloats: weights)) + + let json = """ + { + "asset": {"version": "2.0"}, + "scene": 0, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "targets": [{"POSITION": 1}]}]}], + "animations": [ + {"channels": [{"sampler": 0, "target": {"node": 0, "path": "weights"}}], + "samplers": [{"input": 2, "interpolation": "STEP", "output": 3}]} + ], + "buffers": [{"uri": "data:application/octet-stream;base64,\(buffer.base64EncodedString())", "byteLength": \(buffer.count)}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": \(targetOffset), "byteLength": 36}, + {"buffer": 0, "byteOffset": \(timesOffset), "byteLength": \(times.count * 4)}, + {"buffer": 0, "byteOffset": \(weightsOffset), "byteLength": \(weights.count * 4)} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", "min": [0, 0, 0], "max": [1, 1, 0]}, + {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3", "min": [0, 0, 0], "max": [0, 1, 0]}, + {"bufferView": 2, "componentType": 5126, "count": \(times.count), "type": "SCALAR", "min": [\(times.min() ?? 0)], "max": [\(times.max() ?? 0)]}, + {"bufferView": 3, "componentType": 5126, "count": \(weights.count), "type": "SCALAR"} + ] + } + """ + return Data(json.utf8) + } + + /// glTF sizes a `weights` output by keyframes × morph targets, so one that + /// merely divides evenly by the keyframe count is still malformed. + @Test + func testWeightsOutputMustMatchTheMorphTargetCount() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + // One morph target and two keyframes, but four weights. + let mismatched = try GLTFEntityLoader(withData: weightAnimationFixture(times: [0, 1], + weights: [0, 0, 1, 1])).loadEntity() + #expect(throws: VRMError.self) { try mismatched.playAnimation(at: 0) } + + let exact = try GLTFEntityLoader(withData: weightAnimationFixture(times: [0, 1], + weights: [0, 1])).loadEntity() + #expect(try exact.playAnimation(at: 0).isComplete == false) + } + + /// A zero-length animation has a single pose, so looping it completes with + /// that pose applied instead of ticking the entity forever. + @Test + func testZeroDurationLoopAppliesItsPoseAndCompletes() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try GLTFEntityLoader(withData: weightAnimationFixture(times: [0], weights: [0.5])).loadEntity() + let modelEntity = try #require(entity.morphBindings[0]?.modelEntities.first) + + let controller = try entity.playAnimation(at: 0, loops: true) + #expect(controller.animation.duration == 0) + + entity.updateAnimations(deltaTime: 1.0 / 60) + #expect(try #require(modelEntity.blendWeights.first?.first).isApproximatelyEqual(to: 0.5)) + #expect(controller.isComplete) + // With nothing left to advance, the entity leaves the animation system. + #expect(!entity.components.has(GLTFAnimationPlaybackComponent.self)) + } + + /// A controller stays usable after it finishes: seeking it still poses the + /// model, including the skinned meshes. + @Test + func testSeekingAFinishedControllerStillUpdatesTheSkinPose() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(.simpleSkin) + let binding = try #require(entity.skinBindings.first) + func pose() throws -> JointTransforms { + let component = try #require(binding.modelEntity.components[SkeletalPosesComponent.self]) + return try #require(component.poses.default?.jointTransforms) + } + + let controller = try entity.playAnimation(at: 0) + entity.updateAnimations(deltaTime: 99) + #expect(controller.isComplete) + let restPose = try pose() + + controller.seek(to: 1) // joint node 2 is rotated 90° at t = 1 + let seekedPose = try pose() + let changed = zip(restPose, seekedPose).contains { before, after in + abs(simd_dot(before.rotation, after.rotation)) < 0.999 + } + #expect(changed) + } + + /// A held pose must not re-solve the skeleton: paused playback, a zero speed + /// and a STEP track between keyframes all leave the joints in place. + @Test + func testHeldPosesDoNotReportMovedJoints() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(.animatedTriangle) + let controller = try entity.playAnimation(at: 0, loops: true) + + // A real advance moves the node... + #expect(controller.advance(deltaTime: 0.1)) + // ...but re-applying the same time does not. + #expect(!controller.advance(deltaTime: 0)) + + controller.isPaused = true + #expect(!controller.advance(deltaTime: 0.5)) + controller.isPaused = false + + controller.speed = 0 + #expect(!controller.advance(deltaTime: 0.5)) + } + + @Test + func testNegativeSpeedPlaysBackwardsFromTheEnd() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(.animatedTriangle) + + let controller = try entity.playAnimation(at: 0, speed: -1) + #expect(controller.time.isApproximatelyEqual(to: 1.0)) + entity.updateAnimations(deltaTime: 0.25) + #expect(!controller.isComplete) + #expect(controller.time.isApproximatelyEqual(to: 0.75)) + + entity.updateAnimations(deltaTime: 1) + #expect(controller.isComplete) + #expect(controller.time.isApproximatelyEqual(to: 0)) + } + + /// A rotation sampler with two CUBICSPLINE keyframes: identity at t = 0 + /// with a long out-tangent, identity at t = 1 with a zero in-tangent. + private func splineRotationFixture() -> Data { + var buffer = Data(littleEndianFloats: [0, 1]) // input times + let outputOffset = buffer.count + buffer.append(Data(littleEndianFloats: [ + 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 4, 0, // keyframe 0: a, v, b + 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 // keyframe 1: a, v, b + ])) + let json = """ + { + "asset": {"version": "2.0"}, + "buffers": [{"uri": "data:application/octet-stream;base64,\(buffer.base64EncodedString())", "byteLength": \(buffer.count)}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 8}, + {"buffer": 0, "byteOffset": \(outputOffset), "byteLength": 96} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 2, "type": "SCALAR", "min": [0], "max": [1]}, + {"bufferView": 1, "componentType": 5126, "count": 6, "type": "VEC4"} + ] + } + """ + return Data(json.utf8) + } + + /// One accessor of a hand-written animation sampler, described exactly as it + /// should reach the loader — storage the spec forbids included. + private struct SamplerStorage { + let componentType: Int + let type: String + let count: Int + let bytes: Data + var normalized = false + } + + /// A one-node glTF whose only animation drives `path` through the two given + /// sampler accessors. + private func samplerFixture(path: String, input: SamplerStorage, output: SamplerStorage) -> Data { + var buffer = input.bytes + buffer.append(contentsOf: [UInt8](repeating: 0, count: (4 - buffer.count % 4) % 4)) + let outputOffset = buffer.count + buffer.append(output.bytes) + func accessor(_ storage: SamplerStorage, bufferView: Int) -> String { + """ + {"bufferView": \(bufferView), "componentType": \(storage.componentType), "count": \(storage.count), "type": "\(storage.type)", "normalized": \(storage.normalized)} + """ + } + let json = """ + { + "asset": {"version": "2.0"}, + "scene": 0, + "scenes": [{"nodes": [0]}], + "nodes": [{}], + "buffers": [{"uri": "data:application/octet-stream;base64,\(buffer.base64EncodedString())", "byteLength": \(buffer.count)}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": \(input.bytes.count)}, + {"buffer": 0, "byteOffset": \(outputOffset), "byteLength": \(output.bytes.count)} + ], + "accessors": [\(accessor(input, bufferView: 0)), \(accessor(output, bufferView: 1))], + "animations": [{ + "samplers": [{"input": 0, "interpolation": "LINEAR", "output": 1}], + "channels": [{"sampler": 0, "target": {"node": 0, "path": "\(path)"}}] + }] + } + """ + return Data(json.utf8) + } + + /// Two identity rotation keyframes at t = 0 and t = 1, as FLOAT. + private var floatTimes: SamplerStorage { + SamplerStorage(componentType: 5126, type: "SCALAR", count: 2, bytes: Data(littleEndianFloats: [0, 1])) + } + + private var floatRotations: SamplerStorage { + SamplerStorage(componentType: 5126, type: "VEC4", count: 2, + bytes: Data(littleEndianFloats: [0, 0, 0, 1, 0, 0, 0, 1])) + } + + /// Identity rotations as normalized SHORT, which the spec allows. + private var shortRotationBytes: Data { + var bytes = Data() + bytes.appendLittleEndian([0, 0, 0, 32767, 0, 0, 0, 32767]) + return bytes + } + + @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) + @MainActor + private func loadAnimated(path: String, input: SamplerStorage, output: SamplerStorage) throws -> GLTFEntity { + try GLTFEntityLoader(withData: samplerFixture(path: path, input: input, output: output)).loadEntity() + } + + /// The spec fixes a sampler input to FLOAT scalars starting at or after zero. + /// `PackedAccessor` converts integer components to Float just as happily, so + /// only the decoder's own check keeps a malformed input out. + @Test + func testAnimationInputMustBeNonNegativeFloats() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + var integerTimes = Data() + integerTimes.appendLittleEndian([0, 1]) + let integer = try loadAnimated(path: "rotation", + input: SamplerStorage(componentType: 5123, type: "SCALAR", + count: 2, bytes: integerTimes), + output: floatRotations) + #expect(throws: VRMError.self) { try integer.playAnimation(at: 0) } + + let negative = try loadAnimated(path: "rotation", + input: SamplerStorage(componentType: 5126, type: "SCALAR", count: 2, + bytes: Data(littleEndianFloats: [-1, 1])), + output: floatRotations) + #expect(throws: VRMError.self) { try negative.playAnimation(at: 0) } + } + + /// A rotation is a unit quantity, so the spec also stores it as normalized + /// integers. A translation is not, and neither is an unnormalized rotation. + @Test + func testAnimationOutputComponentTypeFollowsTheTargetPath() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let normalized = try loadAnimated(path: "rotation", + input: floatTimes, + output: SamplerStorage(componentType: 5122, type: "VEC4", count: 2, + bytes: shortRotationBytes, normalized: true)) + let controller = try normalized.playAnimation(at: 0) + #expect(!controller.isComplete) + + let unnormalized = try loadAnimated(path: "rotation", + input: floatTimes, + output: SamplerStorage(componentType: 5122, type: "VEC4", count: 2, + bytes: shortRotationBytes)) + #expect(throws: VRMError.self) { try unnormalized.playAnimation(at: 0) } + + var shortVectors = Data() + shortVectors.appendLittleEndian([0, 0, 0, 0, 0, 0]) + let translation = try loadAnimated(path: "translation", + input: floatTimes, + output: SamplerStorage(componentType: 5122, type: "VEC3", count: 2, + bytes: shortVectors, normalized: true)) + #expect(throws: VRMError.self) { try translation.playAnimation(at: 0) } + } + + /// A rotation or a weight normalizes bytes and shorts only: UNSIGNED_INT is + /// reserved for primitive indices, and normalizing it is forbidden outright. + @Test + func testUnsignedIntAnimationOutputIsRejectedEvenWhenNormalized() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + var intRotations = Data() + intRotations.appendLittleEndian(unsignedInts: [0, 0, 0, .max, 0, 0, 0, .max]) + let rotation = try loadAnimated(path: "rotation", + input: floatTimes, + output: SamplerStorage(componentType: 5125, type: "VEC4", count: 2, + bytes: intRotations, normalized: true)) + #expect(throws: VRMError.self) { try rotation.playAnimation(at: 0) } + } + + /// At most one channel of an animation may drive a given (node, path): a + /// file with two leaves the winner to chance, so it has to be rejected. + @Test + func testDuplicateChannelTargetsAreRejected() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try TestSupport.loader(.animatedTriangle) { json in + guard var animations = json["animations"] as? [[String: Any]], !animations.isEmpty, + let channels = animations[0]["channels"] as? [[String: Any]], + let channel = channels.first else { + throw GLBRewriter.Error.invalidJSON + } + animations[0]["channels"] = channels + [channel] + json["animations"] = animations + } + let entity = try loader.loadEntity() + + // The metadata pass does not read channels, so only playback rejects it. + #expect(entity.animations.count == 1) + #expect(throws: VRMError.self) { try entity.playAnimation(at: 0) } + } + + @Test + func testVRMEntityInheritsTheAnimationAPI() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + // The VRM fixtures carry no glTF animations; the API still answers. + let vrmEntity = try VRMEntityLoader(withData: TestSupport.seedSanData).loadEntity() + #expect(vrmEntity.animations.isEmpty) + #expect(throws: VRMError.self) { + try vrmEntity.playAnimation(at: 0) + } + } +} +#endif diff --git a/Tests/VRMRealityKitTests/GLTFEntityLoaderTests.swift b/Tests/VRMRealityKitTests/GLTFEntityLoaderTests.swift new file mode 100644 index 00000000..27f19845 --- /dev/null +++ b/Tests/VRMRealityKitTests/GLTFEntityLoaderTests.swift @@ -0,0 +1,740 @@ +#if canImport(RealityKit) +import CoreGraphics +import Foundation +import RealityKit +import Testing +import VRMKit +import VRMTestSupport +@testable import VRMRealityKit + +/// Loads the VRM fixtures through the generic glTF loader: a VRM file is a GLB +/// with extensions, so it doubles as a plain glTF fixture. +@Suite +@MainActor +struct GLTFEntityLoaderTests { + @Test + func testGenericLoadBuildsEntityGraphWithNodeMapping() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try GLTFEntityLoader(withData: TestSupport.seedSanData) + let entity = try loader.loadEntity() + + #expect(!(entity is VRMEntity)) + #expect(entity.sceneIndex == entity.gltf.scene) + #expect(entity.document.gltf.nodes?.isEmpty == false) + + // Every scene node resolves through the index mapping. + let nodeCount = entity.gltf.nodes?.count ?? 0 + var mappedCount = 0 + for index in 0.. 0) + #expect(entity.entity(forNodeAt: nodeCount) == nil) + #expect(entity.entity(forNodeAt: -1) == nil) + } + + @Test + func testGenericLoadSetsUpSkinBindingsWithInitialPose() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try GLTFEntityLoader(withData: TestSupport.seedSanData).loadEntity() + + #expect(!entity.skinBindings.isEmpty) + for binding in entity.skinBindings { + #expect(binding.modelEntity.components.has(SkeletalPosesComponent.self)) + #expect(!binding.jointEntities.isEmpty) + } + } + + /// Meshes are built once and cloned per node, skinned ones included, so a + /// second scene off the same loader reuses the `MeshResource` while binding + /// its own joint entities. + @Test + func testReloadingASceneReusesItsMeshesAndBindsItsOwnJoints() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + func root(of entity: Entity) -> Entity { + var root = entity + while let parent = root.parent { root = parent } + return root + } + let loader = try GLTFEntityLoader(withData: TestSupport.seedSanData) + let first = try loader.loadEntity() + let second = try loader.loadEntity() + + #expect(!second.skinBindings.isEmpty) + #expect(first.skinBindings.count == second.skinBindings.count) + for (old, new) in zip(first.skinBindings, second.skinBindings) { + #expect(old.modelEntity !== new.modelEntity) + #expect(old.modelEntity.model?.mesh === new.modelEntity.model?.mesh) + #expect(new.jointEntities.allSatisfy { root(of: $0) === second }) + } + } + + @Test + func testGenericLoadRecordsMorphBindings() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try GLTFEntityLoader(withData: TestSupport.seedSanData).loadEntity() + + #expect(!entity.morphBindings.isEmpty) + for (nodeIndex, binding) in entity.morphBindings { + #expect(entity.entity(forNodeAt: nodeIndex) != nil) + for modelEntity in binding.modelEntities { + #expect(modelEntity.components.has(BlendShapeWeightsComponent.self)) + } + } + } + + @Test + func testInitialMorphWeightsComeFromNodeThenMesh() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + // Find a node whose mesh has morph targets, then give that node + // explicit starting weights. + let document = try GLTFLoader().load(withData: TestSupport.seedSanData) + let gltf = document.gltf + let (nodeIndex, targetCount) = try #require(gltf.nodes?.enumerated().compactMap { index, node -> (Int, Int)? in + guard let meshIndex = node.mesh, + let targets = gltf.meshes?[meshIndex].primitives.first?.targets, + !targets.isEmpty else { return nil } + return (index, targets.count) + }.first) + + var weights = [Double](repeating: 0, count: targetCount) + weights[0] = 0.5 + let modified = try TestSupport.modifiedSeedSanData(name: "node initial weights") { json in + guard var nodes = json["nodes"] as? [[String: Any]] else { + throw VRMError.dataInconsistent("missing nodes") + } + nodes[nodeIndex]["weights"] = weights + json["nodes"] = nodes + } + + let entity = try GLTFEntityLoader(withData: modified).loadEntity() + let binding = try #require(entity.morphBindings[nodeIndex]) + #expect(binding.targetCount == targetCount) + let applied = binding.modelEntities.contains { modelEntity in + modelEntity.blendWeights.contains { set in + set.first.map { abs($0 - 0.5) < 0.0001 } ?? false + } + } + #expect(applied) + } + + /// glTF sizes `node.weights` and `mesh.weights` by the mesh's morph target + /// count; a different length is not a partial pose to apply as far as it goes. + @Test + func testMorphWeightsOfTheWrongLengthFailTheLoad() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let gltf = try GLTFLoader().load(withData: TestSupport.seedSanData).gltf + let (nodeIndex, meshIndex, targetCount) = try #require( + gltf.nodes?.enumerated().compactMap { index, node -> (Int, Int, Int)? in + guard let meshIndex = node.mesh, + let targets = gltf.meshes?[meshIndex].primitives.first?.targets, + !targets.isEmpty else { return nil } + return (index, meshIndex, targets.count) + }.first) + + func weighted(_ count: Int, key: String, of collection: String, at index: Int) throws -> Data { + try TestSupport.modifiedSeedSanData(name: "\(collection) \(count) weights") { json in + guard var elements = json[collection] as? [[String: Any]] else { + throw VRMError.dataInconsistent("missing \(collection)") + } + elements[index][key] = [Double](repeating: 0.5, count: count) + json[collection] = elements + } + } + + let shortNodeWeights = try weighted(targetCount - 1, key: "weights", of: "nodes", at: nodeIndex) + #expect(throws: VRMError.self) { try GLTFEntityLoader(withData: shortNodeWeights).loadEntity() } + + let longMeshWeights = try weighted(targetCount + 1, key: "weights", of: "meshes", at: meshIndex) + #expect(throws: VRMError.self) { try GLTFEntityLoader(withData: longMeshWeights).loadEntity() } + + // The same rewrite with the right length still loads. + let exact = try weighted(targetCount, key: "weights", of: "nodes", at: nodeIndex) + #expect(try GLTFEntityLoader(withData: exact).loadEntity().morphBindings[nodeIndex] != nil) + } + + @Test + func testGenericLoadRendersMToonFromGLTFExtension() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } +#if !os(visionOS) + // Seed-san's materials carry VRMC_materials_mtoon as a plain glTF + // material extension, so the generic loader renders them as MToon too. + let loader = try GLTFEntityLoader(withData: TestSupport.seedSanData) + let material = try loader.material(withMaterialIndex: 0) + #expect(material is CustomMaterial, TestSupport.expectedCustomMaterialMessage) +#endif + } + + @Test + func testUnsupportedRequiredExtensionFailsGenericLoadButNotVRM() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let modified = try TestSupport.modifiedSeedSanData(name: "unsupported required extension") { json in + json["extensionsRequired"] = ["FAKE_required_extension"] + } + + #expect(throws: VRMError.self) { + _ = try GLTFEntityLoader(withData: modified).loadEntity() + } + // The VRM path only warns about an unimplemented required extension. + _ = try VRMEntityLoader(withData: modified).loadEntity() + } + + /// Hand-written because every glTF-Sample-Assets model with a sparse accessor + /// is CC-BY-4.0, which the test assets avoid. + @Test + func testSparseAccessorSubstitutesPositions() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + // A triangle whose third vertex is (0, 1, 0) in the base buffer and is + // replaced by (0, 5, 0) through the accessor's sparse substitution. + var buffer = Data() + let indicesOffset = buffer.count + buffer.appendLittleEndian([0, 1, 2]) + buffer.append(contentsOf: [0, 0]) // pad to a 4 byte boundary + let positionsOffset = buffer.count + buffer.append(Data(littleEndianFloats: [0, 0, 0, 1, 0, 0, 0, 1, 0])) + let sparseIndicesOffset = buffer.count + buffer.appendLittleEndian([2]) + buffer.append(contentsOf: [0, 0]) + let sparseValuesOffset = buffer.count + buffer.append(Data(littleEndianFloats: [0, 5, 0])) + + let json = """ + { + "asset": {"version": "2.0"}, + "scene": 0, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 1}, "indices": 0}]}], + "buffers": [{"uri": "data:application/octet-stream;base64,\(buffer.base64EncodedString())", "byteLength": \(buffer.count)}], + "bufferViews": [ + {"buffer": 0, "byteOffset": \(indicesOffset), "byteLength": 6}, + {"buffer": 0, "byteOffset": \(positionsOffset), "byteLength": 36}, + {"buffer": 0, "byteOffset": \(sparseIndicesOffset), "byteLength": 2}, + {"buffer": 0, "byteOffset": \(sparseValuesOffset), "byteLength": 12} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5123, "count": 3, "type": "SCALAR"}, + { + "bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3", + "min": [0, 0, 0], "max": [1, 5, 0], + "sparse": { + "count": 1, + "indices": {"bufferView": 2, "componentType": 5123}, + "values": {"bufferView": 3} + } + } + ] + } + """ + + let entity = try GLTFEntityLoader(withData: Data(json.utf8)).loadEntity() + let model = try #require(entity.modelEntitiesInHierarchy.first?.components[ModelComponent.self]) + let positions = try #require(model.mesh.contents.models.first?.parts.first?.positions.elements) + + #expect(positions.count == 3) + #expect(positions[2].isApproximatelyEqual(to: SIMD3(0, 5, 0))) + // The untouched vertices keep their base-buffer values. + #expect(positions[1].isApproximatelyEqual(to: SIMD3(1, 0, 0))) + } + + /// A material whose textures sample UV set 1 gets TEXCOORD_1 as the mesh's + /// single RealityKit UV channel. Hand-written: MultiUVTest is CC-BY-4.0. + @Test + func testMaterialSamplingUVSet1SelectsTEXCOORD1() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + var buffer = Data(littleEndianFloats: [0, 0, 0, 1, 0, 0, 0, 1, 0]) // POSITION + let uv0Offset = buffer.count + buffer.append(Data(littleEndianFloats: [0, 0, 0, 0, 0, 0])) // TEXCOORD_0 + let uv1Offset = buffer.count + buffer.append(Data(littleEndianFloats: [0.25, 0.75, 0.5, 0.75, 0.25, 0.5])) // TEXCOORD_1 + let whitePixelPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC" + + let json = """ + { + "asset": {"version": "2.0"}, + "scene": 0, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{ + "attributes": {"POSITION": 0, "TEXCOORD_0": 1, "TEXCOORD_1": 2}, + "material": 0 + }]}], + "materials": [{"pbrMetallicRoughness": {"baseColorTexture": {"index": 0, "texCoord": 1}}}], + "textures": [{"source": 0}], + "images": [{"uri": "data:image/png;base64,\(whitePixelPNG)"}], + "buffers": [{"uri": "data:application/octet-stream;base64,\(buffer.base64EncodedString())", "byteLength": \(buffer.count)}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": \(uv0Offset), "byteLength": 24}, + {"buffer": 0, "byteOffset": \(uv1Offset), "byteLength": 24} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", "min": [0, 0, 0], "max": [1, 1, 0]}, + {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"}, + {"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC2"} + ] + } + """ + + let entity = try GLTFEntityLoader(withData: Data(json.utf8)).loadEntity() + let model = try #require(entity.modelEntitiesInHierarchy.first?.components[ModelComponent.self]) + let uvs = try #require(model.mesh.contents.models.first?.parts.first?.textureCoordinates?.elements) + + #expect(uvs.count == 3) + // The loader flips V (RealityKit's UV origin is bottom-left), so the + // TEXCOORD_1 values arrive as (u, 1 - v). + #expect(uvs[0].isApproximatelyEqual(to: SIMD2(0.25, 0.25))) + #expect(uvs[1].isApproximatelyEqual(to: SIMD2(0.5, 0.25))) + #expect(uvs[2].isApproximatelyEqual(to: SIMD2(0.25, 0.5))) + } + + /// glTF's final metallic / roughness is the sampled texture channel times the + /// factor, so a texture must not drop the factors on the floor. + @Test + func testMetallicRoughnessTextureKeepsItsFactors() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try TestSupport.loader(.simpleTexture) { json in + guard var materials = json["materials"] as? [[String: Any]] else { return } + materials[0]["pbrMetallicRoughness"] = [ + "baseColorTexture": ["index": 0], + "metallicRoughnessTexture": ["index": 0], + "metallicFactor": 0.25, + "roughnessFactor": 0.75 + ] + json["materials"] = materials + } + _ = try loader.loadEntity() + + let material = try #require(try loader.material(withMaterialIndex: 0) as? PhysicallyBasedMaterial) + #expect(material.metallic.texture != nil) + #expect(material.roughness.texture != nil) + #expect(material.metallic.scale.isApproximatelyEqual(to: 0.25)) + #expect(material.roughness.scale.isApproximatelyEqual(to: 0.75)) + } + + /// A primitive without a material renders with glTF's default material: a lit + /// white PBR one, not an unlit fill. + @Test + func testPrimitiveWithoutAMaterialRendersAsLitPBR() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(.triangle) + + let model = try #require(entity.modelEntitiesInHierarchy.first?.components[ModelComponent.self]) + let material = try #require(model.materials.first as? PhysicallyBasedMaterial) + #expect(material.metallic.scale.isApproximatelyEqual(to: 1)) + #expect(material.roughness.scale.isApproximatelyEqual(to: 1)) + } + + /// RealityKit meshes render triangles only, so a POINTS or LINES primitive is + /// skipped: the node it hangs on still loads, it just draws nothing. + @Test + func testNonTriangledPrimitivesAreSkippedWithoutFailingTheLoad() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + for mode in [0, 1, 2, 3] { // POINTS, LINES, LINE_LOOP, LINE_STRIP + let loader = try TestSupport.loader(.triangle) { json in + guard var meshes = json["meshes"] as? [[String: Any]], + var primitives = meshes.first?["primitives"] as? [[String: Any]] else { + throw VRMError.dataInconsistent("Missing Triangle fixture primitives") + } + primitives[0]["mode"] = mode + meshes[0]["primitives"] = primitives + json["meshes"] = meshes + } + let entity = try loader.loadEntity() + + #expect(entity.modelEntitiesInHierarchy.isEmpty) + #expect(entity.entity(forNodeAt: 0) != nil) + } + // The same fixture with its TRIANGLES mode intact does render. + #expect(try !TestSupport.loadEntity(.triangle).modelEntitiesInHierarchy.isEmpty) + } + + /// glTF leaves `scene` out for assets that are a library of nodes, which the + /// generic loader must not silently render as scene 0. + @Test + func testLoadingAnAssetWithoutADefaultSceneNeedsAnExplicitIndex() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try TestSupport.loader(.triangle) { json in + json.removeValue(forKey: "scene") + } + + #expect(loader.document.gltf.scene == nil) + #expect(throws: VRMError.self) { try loader.loadEntity() } + #expect(throws: Never.self) { try loader.loadEntity(withSceneIndex: 0) } + } + + /// A VRM is a single avatar, so its loader still renders one without a + /// default scene. + @Test + func testVRMWithoutADefaultSceneStillLoads() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let data = try TestSupport.modifiedSeedSanData(name: "no default scene") { json in + json.removeValue(forKey: "scene") + } + let entity = try VRMEntityLoader(withData: data).loadEntity() + + #expect(entity.sceneIndex == 0) + } + + /// glTF node hierarchies are forests. A cyclic one would recurse forever, so + /// it has to fail the load instead. + @Test + func testCyclicNodeHierarchyFailsTheLoad() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try TestSupport.loader(.triangle) { json in + json["nodes"] = [["children": [1]], ["children": [0]]] + json["scenes"] = [["nodes": [0]]] + } + + #expect(throws: VRMError.self) { try loader.loadEntity() } + } + + /// A node reached from two parents is neither renderable as a tree nor valid + /// glTF. + @Test + func testNodeWithTwoParentsFailsTheLoad() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try TestSupport.loader(.triangle) { json in + json["nodes"] = [["children": [2]], ["children": [2]], ["mesh": 0]] + json["scenes"] = [["nodes": [0, 1]]] + } + + #expect(throws: VRMError.self) { try loader.loadEntity() } + } + + /// `scene.nodes` names root nodes. Attaching one that already has a parent + /// would reparent it, so the resulting hierarchy would depend on the order + /// `scene.nodes` happens to list them in. + @Test + func testSceneRootThatIsAlreadyAChildFailsTheLoad() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try TestSupport.loader(.triangle) { json in + json["nodes"] = [["children": [1]], ["mesh": 0]] + json["scenes"] = [["nodes": [0, 1]]] + } + + #expect(throws: VRMError.self) { try loader.loadEntity() } + } + + /// RealityKit gives a material one UV transform, so an asset that *requires* + /// `KHR_texture_transform` and gives a material's textures different ones is + /// asking for a render this loader cannot produce. One shared transform stays + /// within what it implements. + @Test + func testRequiredTextureTransformBeyondOnePerMaterialFailsTheLoad() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + func loader(emissiveScale: [Double]) throws -> GLTFEntityLoader { + try TestSupport.loader(.simpleTexture) { json in + let transform: (String, Any) -> [String: Any] = { key, scale in + ["index": 0, "extensions": ["KHR_texture_transform": [key: scale]]] + } + json["extensionsUsed"] = ["KHR_texture_transform"] + json["extensionsRequired"] = ["KHR_texture_transform"] + json["materials"] = [[ + "pbrMetallicRoughness": ["baseColorTexture": transform("scale", [2.0, 2.0])], + "emissiveTexture": transform("scale", emissiveScale) + ]] + } + } + + _ = try loader(emissiveScale: [2.0, 2.0]).loadEntity() + #expect(throws: VRMError.self) { try loader(emissiveScale: [3.0, 3.0]).loadEntity() } + } + + /// Skin joints index the joint arrays positionally, so a repeated, missing or + /// out-of-range joint has to throw rather than trap. + @Test + func testMalformedSkinJointsFailTheLoad() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + for joints in [[1, 1], [], [99]] { + let loader = try TestSupport.loader(.simpleSkin) { json in + guard var skins = json["skins"] as? [[String: Any]] else { return } + skins[0]["joints"] = joints + skins[0].removeValue(forKey: "inverseBindMatrices") + json["skins"] = skins + } + #expect(throws: VRMError.self, "joints \(joints) must not load") { + try loader.loadEntity() + } + } + } + + /// A clone shares the meshes and the document but not the bindings the + /// animation runtime drives, so playback has to report that instead of + /// silently doing nothing. + @Test + func testAnimatingACloneIsRejected() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(.animatedTriangle) + let clone = entity.clone(recursive: true) + + #expect(entity.hasRuntimeBindings) + #expect(!clone.hasRuntimeBindings) + // The metadata still reads off the document it carries. + #expect(clone.animations.count == entity.animations.count) + #expect(throws: VRMError.self) { try clone.playAnimation(at: 0) } + } + + /// An animation sampler that reads a shared accessor as the wrong type must + /// fail even when the accessor is already in the decoder's cache. + @Test + func testAnimationSamplerReadingAnAccessorAsTheWrongTypeFails() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + // The input accessor is SCALAR; a rotation output has to be VEC4. + let entity = try TestSupport.loader(.animatedTriangle) { json in + guard var animations = json["animations"] as? [[String: Any]], + var samplers = animations[0]["samplers"] as? [[String: Any]] else { return } + samplers[0]["output"] = samplers[0]["input"] + animations[0]["samplers"] = samplers + json["animations"] = animations + }.loadEntity() + + #expect(throws: VRMError.self) { try entity.playAnimation(at: 0) } + } + + /// VRM meshes split by indices share one POSITION accessor and put the morph + /// targets on a single primitive; the VRM loader shares them across the rest, + /// which the plain glTF loader must not do. + @Test + func testVRMSharesMorphTargetsAcrossPrimitivesButPlainGLTFDoesNot() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + func morphableModelCount(_ entity: Entity) -> Int { + entity.modelEntitiesInHierarchy.filter { $0.components.has(BlendShapeWeightsComponent.self) }.count + } + // AliciaSolid names no default scene, so the plain loader is given one. + let vrm = try VRMEntityLoader(withData: TestSupport.aliciaSolidData).loadEntity() + let plain = try GLTFEntityLoader(withData: TestSupport.aliciaSolidData).loadEntity(withSceneIndex: 0) + + #expect(morphableModelCount(vrm) > morphableModelCount(plain)) + } + + /// A degenerate triangle in a primitive without NORMAL keeps the zero normal + /// `flatNormals()` leaves it, and the tangent basis a normal map then asks + /// for must not turn that into NaN. + @Test + func testDegenerateTriangleUnderANormalMapKeepsTheTangentBasisFinite() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + // The fixture's index buffer lives in its .bin, so the degenerate + // triangle arrives through a buffer of its own. + var degenerateIndices = Data() + degenerateIndices.appendLittleEndian([0, 0, 1, 0, 1, 2]) + let loader = try TestSupport.loader(.simpleTexture) { json in + guard var buffers = json["buffers"] as? [[String: Any]], + var bufferViews = json["bufferViews"] as? [[String: Any]], + var accessors = json["accessors"] as? [[String: Any]], + var materials = json["materials"] as? [[String: Any]] else { + throw VRMError.dataInconsistent("unexpected SimpleTexture layout") + } + buffers.append([ + "uri": "data:application/octet-stream;base64,\(degenerateIndices.base64EncodedString())", + "byteLength": degenerateIndices.count + ]) + bufferViews.append(["buffer": buffers.count - 1, "byteOffset": 0, "byteLength": degenerateIndices.count]) + accessors[0]["bufferView"] = bufferViews.count - 1 + materials[0]["normalTexture"] = ["index": 0] + json["buffers"] = buffers + json["bufferViews"] = bufferViews + json["accessors"] = accessors + json["materials"] = materials + } + + let entity = try loader.loadEntity() + let model = try #require(entity.modelEntitiesInHierarchy.first?.components[ModelComponent.self]) + let part = try #require(model.mesh.contents.models.first?.parts.first) + let tangents = try #require(part.tangents?.elements) + let bitangents = try #require(part.bitangents?.elements) + + #expect(tangents.count == 6) + #expect(tangents.allSatisfy { $0.x.isFinite && $0.y.isFinite && $0.z.isFinite }) + #expect(bitangents.allSatisfy { $0.x.isFinite && $0.y.isFinite && $0.z.isFinite }) + } + + /// glTF flat shades a primitive that ships no NORMAL, so the shared vertices + /// of two folded triangles must not average into one smooth normal. + @Test + func testPrimitiveWithoutNORMALIsFlatShaded() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + // Two triangles sharing the edge (0,0,0)-(1,0,0): one in the z = 0 plane + // facing +z, one in the y = 0 plane facing +y. + var buffer = Data() + let indicesOffset = buffer.count + buffer.appendLittleEndian([0, 1, 2, 0, 3, 1]) + let positionsOffset = buffer.count + buffer.append(Data(littleEndianFloats: [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1])) + + let json = """ + { + "asset": {"version": "2.0"}, + "scene": 0, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 1}, "indices": 0}]}], + "buffers": [{"uri": "data:application/octet-stream;base64,\(buffer.base64EncodedString())", "byteLength": \(buffer.count)}], + "bufferViews": [ + {"buffer": 0, "byteOffset": \(indicesOffset), "byteLength": 12}, + {"buffer": 0, "byteOffset": \(positionsOffset), "byteLength": 48} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5123, "count": 6, "type": "SCALAR"}, + {"bufferView": 1, "componentType": 5126, "count": 4, "type": "VEC3", "min": [0, 0, 0], "max": [1, 1, 1]} + ] + } + """ + + let entity = try GLTFEntityLoader(withData: Data(json.utf8)).loadEntity() + let model = try #require(entity.modelEntitiesInHierarchy.first?.components[ModelComponent.self]) + let part = try #require(model.mesh.contents.models.first?.parts.first) + let normals = try #require(part.normals?.elements) + + // Flat shading needs a vertex per triangle corner. + #expect(part.positions.elements.count == 6) + #expect(normals.count == 6) + for normal in normals[0..<3] { + #expect(normal.isApproximatelyEqual(to: SIMD3(0, 0, 1))) + } + for normal in normals[3..<6] { + #expect(normal.isApproximatelyEqual(to: SIMD3(0, 1, 0))) + } + } + + /// RealityKit's normal parameter has no scale beside its texture, so + /// `normalTexture.scale` is baked into the map: x and y scale, then the + /// vector is renormalized. + @Test + func testNormalTextureScaleIsBakedIntoTheMap() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try GLTFEntityLoader(withData: GLTFSampleAsset.triangle.data, + rootDirectory: GLTFSampleAsset.triangle.rootDirectory) + // (0, 0, 1) straight up, and a normal tilted 45° toward +x. + let source = try Self.image(rgb: [[128, 128, 255], [218, 128, 218]]) + + let unchanged = try Self.pixels(of: loader.scaledNormalImage(source, scale: 1)) + #expect(unchanged[0] == [128, 128, 255]) + #expect(unchanged[1] == [218, 128, 218]) + + let flattened = try Self.pixels(of: loader.scaledNormalImage(source, scale: 0)) + // With nothing left of x and y, every texel is the neutral normal. + #expect(flattened[0] == [128, 128, 255]) + #expect(flattened[1] == [128, 128, 255]) + + // Half the tilt: x drops from 0.71 to 0.45 once renormalized. + let halved = try Self.pixels(of: loader.scaledNormalImage(source, scale: 0.5)) + #expect(halved[0] == [128, 128, 255]) + #expect(halved[1] == [185, 128, 242]) + } + + /// `occlusionTexture.strength` blends the sampled occlusion toward "no + /// occlusion", which RealityKit's ambient occlusion parameter cannot express + /// on its own either. + @Test + func testOcclusionTextureStrengthIsBakedIntoTheMap() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try GLTFEntityLoader(withData: GLTFSampleAsset.triangle.data, + rootDirectory: GLTFSampleAsset.triangle.rootDirectory) + // Fully occluded, half occluded and unoccluded texels. + let source = try Self.image(rgb: [[0, 0, 0], [128, 0, 0], [255, 0, 0]]) + + let unchanged = try Self.pixels(of: loader.weakenedOcclusionImage(source, strength: 1)) + #expect(unchanged.map(\.first) == [0, 128, 255]) + + let halved = try Self.pixels(of: loader.weakenedOcclusionImage(source, strength: 0.5)) + #expect(halved.map(\.first) == [128, 192, 255]) + + let disabled = try Self.pixels(of: loader.weakenedOcclusionImage(source, strength: 0)) + #expect(disabled.map(\.first) == [255, 255, 255]) + } + + /// A row of 8-bit RGB texels as a `CGImage`. + private static func image(rgb: [[UInt8]]) throws -> CGImage { + let bytes: [UInt8] = rgb.flatMap { $0 + [255] } + let provider = try #require(CGDataProvider(data: Data(bytes) as CFData)) + return try #require(CGImage(width: rgb.count, + height: 1, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: rgb.count * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent)) + } + + /// The image's texels as `[r, g, b]` rows. + private static func pixels(of image: CGImage) throws -> [[UInt8]] { + var bytes = [UInt8](repeating: 0, count: image.width * 4) + try bytes.withUnsafeMutableBytes { raw in + let context = try #require(CGContext(data: raw.baseAddress, + width: image.width, + height: 1, + bitsPerComponent: 8, + bytesPerRow: image.width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: 1)) + } + return (0.. 0 } + }) + let binding = try #require(clip.values.first { $0.weight > 0 }) + // The bind resolved to an entity of the scene, not to a clone template. + #expect(TestSupport.isDescendant(binding.mesh, of: vrmEntity)) + + vrmEntity.setBlendShape(value: 1, for: clip.key) + let targetName = "blendShape_\(binding.index)" + let applied = TestSupport.modelEntities(in: vrmEntity).contains { modelEntity in + let weights = modelEntity.blendWeights + let names = modelEntity.blendWeightNames + return names.indices.contains { setIndex in + guard setIndex < weights.count, + let nameIndex = names[setIndex].firstIndex(of: targetName), + nameIndex < weights[setIndex].count else { return false } + return weights[setIndex][nameIndex] > 0 + } + } + #expect(applied) + } + + @Test + func testVRMEntityIsAGLTFEntity() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let vrmEntity = try VRMEntityLoader(withData: TestSupport.seedSanData).loadEntity() + + // The VRM runtime sits on the generic one: document, node mapping and skin + // bindings all come from the base. + let base: GLTFEntity = vrmEntity + #expect(base.sceneIndex == base.gltf.scene) + #expect(!base.skinBindings.isEmpty) + #expect(base.entity(forNodeAt: 0) != nil) + } +} +#endif diff --git a/Tests/VRMRealityKitTests/GLTFKeyframeTrackTests.swift b/Tests/VRMRealityKitTests/GLTFKeyframeTrackTests.swift new file mode 100644 index 00000000..5357c53c --- /dev/null +++ b/Tests/VRMRealityKitTests/GLTFKeyframeTrackTests.swift @@ -0,0 +1,120 @@ +#if canImport(RealityKit) +import Foundation +import simd +import Testing +import VRMKit +@testable import VRMRealityKit + +/// Pure evaluation tests for the animation sampler, one per interpolation +/// mode, checked against hand-computed values from the glTF spec formulas. +@Suite +struct GLTFKeyframeTrackTests { + @Test + func testLinearInterpolatesAndClampsOutsideTheRange() throws { + let track = try GLTFKeyframeTrack>( + times: [1, 3], + interpolation: .LINEAR, + values: [SIMD3(0, 0, 0), SIMD3(2, 4, 0)]) + + #expect(track.duration == 3) + #expect(track.value(at: 2).isApproximatelyEqual(to: SIMD3(1, 2, 0))) + // Inputs outside the keyframe range clamp to the boundary keyframes. + #expect(track.value(at: 0).isApproximatelyEqual(to: .zero)) + #expect(track.value(at: 99).isApproximatelyEqual(to: SIMD3(2, 4, 0))) + } + + @Test + func testStepHoldsThePreviousKeyframe() throws { + let track = try GLTFKeyframeTrack<[Float]>( + times: [0, 1, 2], + interpolation: .STEP, + values: [[0], [10], [20]]) + + #expect(track.value(at: 0.99) == [0]) + #expect(track.value(at: 1.0) == [10]) + #expect(track.value(at: 1.99) == [10]) + #expect(track.value(at: 2.5) == [20]) + } + + @Test + func testLinearRotationUsesSphericalInterpolation() throws { + let quarter = simd_quatf(angle: .pi / 2, axis: SIMD3(0, 0, 1)) + let track = try GLTFKeyframeTrack( + times: [0, 1], + interpolation: .LINEAR, + values: [simd_quatf(ix: 0, iy: 0, iz: 0, r: 1), quarter]) + + let mid = track.value(at: 0.5) + let expected = simd_quatf(angle: .pi / 4, axis: SIMD3(0, 0, 1)) + #expect(abs(simd_dot(mid, expected)) > 0.9999) + // Unit length is what downstream Transform assignment relies on. + #expect(simd_length(mid.vector).isApproximatelyEqual(to: 1)) + } + + @Test + func testCubicSplineMatchesTheSpecFormula() throws { + // v0 = 0 (out-tangent 2), v1 = 1 (in-tangent 0), span 0..1. + // p(0.5) = 0.5·v0 + 0.125·b0 + 0.5·v1 − 0.125·a1 = 0.25 + 0.5 = 0.75. + let track = try GLTFKeyframeTrack<[Float]>( + times: [0, 1], + interpolation: .CUBICSPLINE, + values: [[0], [0], [2], [0], [1], [0]]) + + let value = track.value(at: 0.5) + #expect(value.count == 1) + #expect(value[0].isApproximatelyEqual(to: 0.75)) + // Boundaries hit the keyframe values exactly. + #expect(track.value(at: 0) == [0]) + #expect(track.value(at: 1) == [1]) + } + + @Test + func testCubicSplineWithZeroTangentsEasesBetweenValues() throws { + let track = try GLTFKeyframeTrack>( + times: [0, 2], + interpolation: .CUBICSPLINE, + values: [.zero, SIMD3(0, 0, 0), .zero, + .zero, SIMD3(4, 0, 0), .zero]) + + // With zero tangents the Hermite basis reduces to smoothstep: 0.5 at + // the midpoint, but steeper than linear at 1/4 of the way. + #expect(track.value(at: 1).x.isApproximatelyEqual(to: 2)) + #expect(track.value(at: 0.5).x.isApproximatelyEqual(to: 4 * 0.15625)) + } + + /// The spec's sampler invariants are checked once when the track is built, so + /// a malformed file fails the load instead of animating a prefix of itself. + @Test + func testMalformedSamplersAreRejectedAtConstruction() { + #expect(throws: VRMError.self) { + try GLTFKeyframeTrack<[Float]>(times: [], interpolation: .LINEAR, values: []) + } + // More times than values. + #expect(throws: VRMError.self) { + try GLTFKeyframeTrack<[Float]>(times: [0, 1, 2], interpolation: .LINEAR, values: [[0], [2]]) + } + // Times must be strictly increasing. + #expect(throws: VRMError.self) { + try GLTFKeyframeTrack<[Float]>(times: [0, 1, 1], interpolation: .LINEAR, values: [[0], [1], [2]]) + } + #expect(throws: VRMError.self) { + try GLTFKeyframeTrack<[Float]>(times: [1, 0], interpolation: .LINEAR, values: [[0], [1]]) + } + // CUBICSPLINE needs two keyframes to interpolate between... + #expect(throws: VRMError.self) { + try GLTFKeyframeTrack<[Float]>(times: [0], interpolation: .CUBICSPLINE, values: [[0], [1], [0]]) + } + // ...and three output elements per keyframe. + #expect(throws: VRMError.self) { + try GLTFKeyframeTrack<[Float]>(times: [0, 1], interpolation: .CUBICSPLINE, values: [[0], [1]]) + } + } + + @Test + func testSingleKeyframeHoldsItsValue() throws { + let track = try GLTFKeyframeTrack<[Float]>(times: [3], interpolation: .LINEAR, values: [[7]]) + #expect(track.value(at: 0) == [7]) + #expect(track.value(at: 9) == [7]) + } +} +#endif diff --git a/Tests/VRMRealityKitTests/GLTFSampleAssetRenderingTests.swift b/Tests/VRMRealityKitTests/GLTFSampleAssetRenderingTests.swift new file mode 100644 index 00000000..d789e8d1 --- /dev/null +++ b/Tests/VRMRealityKitTests/GLTFSampleAssetRenderingTests.swift @@ -0,0 +1,260 @@ +#if canImport(RealityKit) +import Foundation +import RealityKit +import Testing +import VRMKit +import VRMTestSupport +@testable import VRMRealityKit + +/// Renders the Khronos CC0 sample assets through ``GLTFEntityLoader``, covering +/// what the VRM fixtures cannot: JSON glTF with external resources, non-indexed +/// geometry, plain PBR materials, cameras and animations. +@Suite +@MainActor +struct GLTFSampleAssetRenderingTests { + @Test(arguments: GLTFSampleAsset.allCases) + func testEverySampleAssetLoadsIntoARenderableEntity(_ asset: GLTFSampleAsset) throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(asset) + + // Every asset must produce drawable geometry, not just an empty graph. + let modelEntities = entity.modelEntitiesInHierarchy + #expect(!modelEntities.isEmpty, "\(asset.rawValue) produced no ModelEntity") + for modelEntity in modelEntities { + let model = try #require(modelEntity.components[ModelComponent.self]) + #expect(!model.materials.isEmpty) + #expect(model.mesh.contents.models.contains { !$0.parts.isEmpty }) + } + } + + /// The loader falls back to the default material when one fails to build, and + /// only logs it, so each material is built explicitly here. + @Test(arguments: GLTFSampleAsset.allCases) + func testEveryMaterialOfEverySampleAssetBuilds(_ asset: GLTFSampleAsset) throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try GLTFEntityLoader(withURL: asset.url) + let materialCount = loader.document.gltf.materials?.count ?? 0 + + for index in 0..(1, 0, 0))) + } + + @Test + func testExternalPNGTextureLoadsIntoTheMaterial() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try GLTFEntityLoader(withURL: GLTFSampleAsset.simpleTexture.url) + let material = try #require(try loader.material(withMaterialIndex: 0) as? PhysicallyBasedMaterial) + + // The image is a sibling file of the .gltf, so a non-nil texture proves the + // root directory reached the image loader. + #expect(material.baseColor.texture != nil) + } + + @Test + func testSkinnedSampleGetsSkeletonAndInitialPose() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(GLTFSampleAsset.simpleSkin) + + let binding = try #require(entity.skinBindings.first) + // SimpleSkin's skin lists two joints, nodes 1 and 2. + #expect(binding.jointEntities.count == 2) + #expect(binding.jointEntities[0] === entity.entity(forNodeAt: 1)) + #expect(binding.jointEntities[1] === entity.entity(forNodeAt: 2)) + #expect(binding.modelEntity.components.has(SkeletalPosesComponent.self)) + } + + /// SimpleMorph declares `mesh.weights = [0.5, 0.5]` and no node weights, so + /// it is the fixture for the `node.weights` → `mesh.weights` fallback. + @Test + func testInitialMorphWeightsFallBackToMeshWeights() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(GLTFSampleAsset.simpleMorph) + + let binding = try #require(entity.morphBindings[0]) + #expect(binding.targetCount == 2) + let weights = try #require(binding.modelEntities.first?.blendWeights.first) + #expect(weights.count == 2) + #expect(weights.allSatisfy { $0.isApproximatelyEqual(to: 0.5) }) + } + + @Test + func testCamerasBecomeCameraComponents() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(GLTFSampleAsset.cameras) + + let perspectiveNode = try #require(entity.entity(forNodeAt: 1)) + let perspective = try #require(perspectiveNode.components[PerspectiveCameraComponent.self]) + #expect(perspective.near.isApproximatelyEqual(to: 0.01)) + #expect(perspective.far.isApproximatelyEqual(to: 100)) + // aspectRatio 1.0 with yfov 0.7 gives the same horizontal fov. + #expect(perspective.fieldOfViewOrientation == .horizontal) + #expect(perspective.fieldOfViewInDegrees.isApproximatelyEqual(to: 0.7 * 180 / .pi, tolerance: 0.01)) + + let orthographicNode = try #require(entity.entity(forNodeAt: 2)) + let orthographic = try #require(orthographicNode.components[OrthographicCameraComponent.self]) + #expect(orthographic.near.isApproximatelyEqual(to: 0.01)) + #expect(orthographic.far.isApproximatelyEqual(to: 100)) + #expect(orthographic.scale.isApproximatelyEqual(to: 1)) + } + + /// A loaded animated model sits in its rest pose until something plays an + /// animation. Playback itself is covered by GLTFAnimationPlaybackTests. + @Test + func testAnimatedSamplesRenderTheirRestPose() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(GLTFSampleAsset.animatedTriangle) + + let node = try #require(entity.entity(forNodeAt: 0)) + #expect(node.transform.rotation.vector.isApproximatelyEqual(to: SIMD4(0, 0, 0, 1))) + #expect(entity.gltf.animations?.isEmpty == false) + } + + @Test + func testAnimatedMorphCubeKeepsItsMorphBindings() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try TestSupport.loadEntity(GLTFSampleAsset.animatedMorphCube) + + // The weights channel targets a node, which has to resolve to the + // blend-shape model entities the animation runtime writes to. + let channel = try #require(entity.gltf.animations?.first?.channels.first { $0.target.targetPath == .weights }) + let nodeIndex = try #require(channel.target.node) + let binding = try #require(entity.morphBindings[nodeIndex]) + #expect(!binding.modelEntities.isEmpty) + #expect(binding.modelEntities.allSatisfy { !$0.blendWeights.isEmpty }) + } + + /// glTF puts no restriction on where a skinned mesh sits, so it may hang + /// below one of its own joints — a joint whose entity is then asked for while + /// its own node is still under construction. + @Test + func testSkinnedMeshBelowOneOfItsJointsResolvesToTheSameJointEntities() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + // SimpleSkin's node 0 is the skinned mesh and its joints are nodes 1 / 2. + let loader = try TestSupport.loader(.simpleSkin) { json in + guard var nodes = json["nodes"] as? [[String: Any]], nodes.count == 3 else { + throw VRMError.dataInconsistent("Missing SimpleSkin node fixture data") + } + nodes[1]["children"] = [2, 0] + json["nodes"] = nodes + json["scenes"] = [["nodes": [1]]] + } + let entity = try loader.loadEntity() + + let jointRoot = try #require(entity.entity(forNodeAt: 1)) + let meshNode = try #require(entity.entity(forNodeAt: 0)) + #expect(meshNode.parent === jointRoot) + #expect(jointRoot.parent === entity) + + // The binding must drive this graph's joints, not a second copy of them. + let binding = try #require(entity.skinBindings.first) + #expect(binding.jointEntities.count == 2) + #expect(binding.jointEntities[0] === jointRoot) + #expect(binding.jointEntities[1] === entity.entity(forNodeAt: 2)) + #expect(TestSupport.isDescendant(binding.modelEntity, of: meshNode)) + } + + /// glTF requires every primitive of a skinned mesh to carry both skinning + /// attributes, so a file missing one is malformed rather than unskinned. + @Test + func testSkinnedPrimitiveWithoutSkinningAttributesFailsTheLoad() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + for dropped in ["JOINTS_0", "WEIGHTS_0"] { + let loader = try TestSupport.loader(.simpleSkin) { json in + guard var meshes = json["meshes"] as? [[String: Any]], + var primitives = meshes.first?["primitives"] as? [[String: Any]], + var attributes = primitives.first?["attributes"] as? [String: Any] else { + throw VRMError.dataInconsistent("Missing SimpleSkin mesh fixture data") + } + attributes[dropped] = nil + primitives[0]["attributes"] = attributes + meshes[0]["primitives"] = primitives + json["meshes"] = meshes + } + #expect(throws: VRMError.self, "a skinned primitive without \(dropped)") { + try loader.loadEntity() + } + } + } + + /// The loader keeps no scene cache, so one loader can hand out several + /// independently animatable copies of the same scene. + @Test + func testLoadingOneSceneTwiceBuildsIndependentEntities() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try GLTFEntityLoader(withURL: GLTFSampleAsset.simpleSkin.url) + let first = try loader.loadEntity() + let second = try loader.loadEntity() + + #expect(first !== second) + #expect(first.entity(forNodeAt: 1) !== second.entity(forNodeAt: 1)) + #expect(second.hasRuntimeBindings) + // Each entity's bindings stay within its own graph. + for (entity, other) in [(first, second), (second, first)] { + let binding = try #require(entity.skinBindings.first) + #expect(binding.jointEntities.allSatisfy { TestSupport.isDescendant($0, of: entity) }) + #expect(binding.jointEntities.allSatisfy { !TestSupport.isDescendant($0, of: other) }) + } + } + + @Test + func testKHRTextureTransformReachesPBRMaterials() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try GLTFEntityLoader(withURL: GLTFSampleAsset.textureTransformTest.url) + + func transform(_ index: Int) throws -> MaterialParameterTypes.TextureCoordinateTransform { + let material = try #require(try loader.material(withMaterialIndex: index) as? PhysicallyBasedMaterial) + return material.textureCoordinateTransform + } + + // Offsets and scales carry over as-is; only the rotation direction + // mirrors. `TextureTransformRenderingTests` is what proves that mapping + // right; this pins it down for the Khronos fixture's own materials. + // Material 2 "Offset UV", 3 "Rotation" (π/8), 4 "Scale", 5 "All": + #expect(try transform(2).offset.isApproximatelyEqual(to: SIMD2(0.5, 0.5))) + #expect(try transform(3).rotation.isApproximatelyEqual(to: -0.39269908)) + #expect(try transform(4).scale.isApproximatelyEqual(to: SIMD2(1.5, 1.5))) + + let all = try transform(5) + #expect(all.offset.isApproximatelyEqual(to: SIMD2(-0.2, -0.1))) + #expect(all.rotation.isApproximatelyEqual(to: -0.3)) + #expect(all.scale.isApproximatelyEqual(to: SIMD2(1.5, 1.5))) + + // Material 6 "Correct" (no extension) stays identity. + let identity = try transform(6) + #expect(identity.offset.isApproximatelyEqual(to: SIMD2(0, 0))) + #expect(identity.rotation.isApproximatelyEqual(to: 0)) + #expect(identity.scale.isApproximatelyEqual(to: SIMD2(1, 1))) + } + +} +#endif diff --git a/Tests/VRMRealityKitTests/OffscreenRenderer.swift b/Tests/VRMRealityKitTests/OffscreenRenderer.swift new file mode 100644 index 00000000..35c18913 --- /dev/null +++ b/Tests/VRMRealityKitTests/OffscreenRenderer.swift @@ -0,0 +1,125 @@ +#if canImport(RealityKit) +import CoreGraphics +import Foundation +import ImageIO +import Metal +import RealityKit +import UniformTypeIdentifiers +import simd + +/// Renders an entity into a Metal texture, so a test can assert on what +/// RealityKit actually draws rather than on the parameters handed to it. +@MainActor +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +enum OffscreenRenderer { + /// Whether this machine can render at all. A test environment without a + /// Metal device skips the rendering tests instead of failing them. + static var isAvailable: Bool { MTLCreateSystemDefaultDevice() != nil } + + /// Renders `entity` head-on through an orthographic camera framing + /// x, y in [-1, 1], and returns the pixels as `[row][column]` RGB, row 0 at + /// the top of the image. + static func render(_ entity: Entity, size: Int) throws -> [[SIMD3]] { + guard let device = MTLCreateSystemDefaultDevice() else { + throw RenderError.noMetalDevice + } + + let camera = Entity() + var cameraComponent = OrthographicCameraComponent() + cameraComponent.near = 0.1 + cameraComponent.far = 10 + // The vertical scale is the half-height of the framed area. + cameraComponent.scale = 1 + cameraComponent.scaleDirection = .vertical + camera.components.set(cameraComponent) + camera.position = SIMD3(0, 0, 2) + + let renderer = try RealityRenderer() + renderer.entities.append(entity) + renderer.entities.append(camera) + renderer.activeCamera = camera + renderer.cameraSettings.isToneMappingEnabled = false + renderer.cameraSettings.antialiasing = .none + renderer.cameraSettings.colorBackground = .color(CGColor(gray: 0, alpha: 1)) + + let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm, + width: size, + height: size, + mipmapped: false) + descriptor.usage = [.renderTarget, .shaderRead, .shaderWrite] + descriptor.storageMode = .shared + guard let target = device.makeTexture(descriptor: descriptor) else { + throw RenderError.noMetalDevice + } + + let finished = DispatchSemaphore(value: 0) + try renderer.updateAndRender(deltaTime: 0.01, + cameraOutput: try RealityRenderer.CameraOutput(.singleProjection(colorTexture: target)), + onComplete: { _ in finished.signal() }) + guard finished.wait(timeout: .now() + 30) == .success else { + throw RenderError.timedOut + } + + var bytes = [UInt8](repeating: 0, count: size * size * 4) + bytes.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress else { return } + target.getBytes(base, + bytesPerRow: size * 4, + from: MTLRegionMake2D(0, 0, size, size), + mipmapLevel: 0) + } + return (0..(Float(bytes[offset]), + Float(bytes[offset + 1]), + Float(bytes[offset + 2])) + } + } + } + + enum RenderError: Error { + case noMetalDevice + case timedOut + case encodingFailed + } + + /// A `size` x `size` PNG whose texel (row, column) carries a colour unique to + /// it, so a rendered pixel names the texel it sampled. + static func makeProbeTexturePNG(size: Int) throws -> Data { + let step = 256 / size + var bytes = [UInt8](repeating: 255, count: size * size * 4) + for row in 0.. GLTFEntity { + try GLTFEntityLoader(withURL: asset.url).loadEntity() + } - /// The bundled AliciaSolid VRM 0.x fixture, read once per test process. - /// The VRM 0.x loading paths need a 0.x model; Seed-san is 1.0. - static let aliciaSolidData: Data = { - guard let url = Bundle.module.url(forResource: "AliciaSolid", withExtension: "vrm"), - let data = try? Data(contentsOf: url) else { - fatalError("Failed to load AliciaSolid.vrm resource from test bundle.") - } - return data - }() + /// Loads a bundled glTF sample asset with its JSON rewritten in memory, so a + /// test can feed the loader an unusual or malformed variant of a fixture + /// without shipping one. + @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) + @MainActor + static func loader(_ asset: GLTFSampleAsset, + rewritingJSON modify: (inout [String: Any]) throws -> Void) throws -> GLTFEntityLoader { + try GLTFEntityLoader(withData: asset.rewritingJSON(modify), rootDirectory: asset.rootDirectory) + } + + /// The bundled Seed-san VRM 1.0 fixture. + static var seedSanData: Data { VRMSampleAsset.seedSan.data } + + /// The bundled AliciaSolid VRM 0.x fixture. The VRM 0.x loading paths need + /// a 0.x model; Seed-san is 1.0. + static var aliciaSolidData: Data { VRMSampleAsset.aliciaSolid.data } /// Rewrites the fixture's glTF JSON in memory. Loaders accept the returned /// data directly, so no temporary files are written. `name` identifies the @@ -33,7 +39,7 @@ enum TestSupport { static func modifiedSeedSanData(name: String, modify: (inout [String: Any]) throws -> Void) throws -> Data { do { - return try GLBRewriter.rewritingJSON(of: seedSanData, modify) + return try VRMSampleAsset.seedSan.rewritingJSON(modify) } catch let error as GLBRewriter.Error { throw VRMError.dataInconsistent("Invalid Seed-san fixture data for '\(name)': \(error)") } @@ -43,7 +49,7 @@ enum TestSupport { static func modifiedAliciaSolidData(name: String, modify: (inout [String: Any]) throws -> Void) throws -> Data { do { - return try GLBRewriter.rewritingJSON(of: aliciaSolidData, modify) + return try VRMSampleAsset.aliciaSolid.rewritingJSON(modify) } catch let error as GLBRewriter.Error { throw VRMError.dataInconsistent("Invalid AliciaSolid fixture data for '\(name)': \(error)") } @@ -126,6 +132,18 @@ enum TestSupport { root.modelEntitiesInHierarchy } + /// Whether `entity` hangs under `ancestor`, i.e. whether it is part of that + /// entity graph at all. + @MainActor + static func isDescendant(_ entity: Entity, of ancestor: Entity) -> Bool { + var current: Entity? = entity + while let entity = current { + if entity === ancestor { return true } + current = entity.parent + } + return false + } + #if !os(visionOS) /// Whether any model entity in the hierarchy renders with a CustomMaterial. /// visionOS has no `CustomMaterial`, so this only exists where MToon does. @@ -149,7 +167,7 @@ enum TestSupport { @MainActor @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) static func materialIndexes(in root: Entity) -> [Int] { - Set(modelEntities(in: root).compactMap { $0.components[VRMMaterialIndexComponent.self]?.materialIndex }) + Set(modelEntities(in: root).compactMap { $0.components[GLTFMaterialIndexComponent.self]?.materialIndex }) .sorted() } diff --git a/Tests/VRMRealityKitTests/TextureTransformRenderingTests.swift b/Tests/VRMRealityKitTests/TextureTransformRenderingTests.swift new file mode 100644 index 00000000..9e2fc4bf --- /dev/null +++ b/Tests/VRMRealityKitTests/TextureTransformRenderingTests.swift @@ -0,0 +1,185 @@ +#if canImport(RealityKit) +import Foundation +import RealityKit +import Testing +import VRMKit +import simd +@testable import VRMRealityKit + +/// Renders `KHR_texture_transform` through the loader and checks which texels end +/// up on screen. +/// +/// Reading the converted parameters back proves nothing: the conversion mirrors +/// the rotation because the loader also flips V, and only the drawn result says +/// whether the two cancel out. Every expectation here comes from the extension's +/// own `translation * rotation * scale` applied to the glTF UV. +@Suite +@MainActor +struct TextureTransformRenderingTests { + /// The probe texture is 8x8 and each texel is rendered 8 pixels wide. + private static let textureSize = 8 + private static let renderSize = 64 + + /// One `KHR_texture_transform`, with the matrix the extension defines. + private struct UVTransform { + var offset: SIMD2 = .zero + var scale: SIMD2 = .one + var rotation: Float = 0 + + var json: [String: Any] { + ["offset": [offset.x, offset.y], "scale": [scale.x, scale.y], "rotation": rotation] + } + + /// `translation * rotation * scale` applied to a glTF UV. + func applied(to uv: SIMD2) -> SIMD2 { + let cosine = cos(rotation), sine = sin(rotation) + return SIMD2(scale.x * cosine * uv.x + scale.y * sine * uv.y + offset.x, + -scale.x * sine * uv.x + scale.y * cosine * uv.y + offset.y) + } + } + + @Test + func testTransformedUVsSampleTheTexelsTheExtensionNames() throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + // Nothing to assert on a machine that cannot render. + guard OffscreenRenderer.isAvailable else { return } + + // With no transform a rendered pixel shows the texel its own UV names, + // which both checks the V flip and calibrates colour → texel below. + let identity = try render(UVTransform()) + var texelOfColour: [SIMD3: SIMD2] = [:] + let pixelsPerTexel = Self.renderSize / Self.textureSize + for row in 0..(column, row) + } + } + // The identity render doubles as the machine's capability check. The + // visionOS simulator builds RealityKit's compositing pipelines on the + // host GPU and hands back a blank frame wherever they fail to compile, + // and a pixel test that cannot see its own subject has nothing to say. + let rendersEveryTexel = texelOfColour.count == Self.textureSize * Self.textureSize +#if os(visionOS) + guard rendersEveryTexel else { return } +#endif + #expect(rendersEveryTexel, "the identity render must show every texel exactly once") + + let transforms: [(name: String, transform: UVTransform)] = [ + ("offset", UVTransform(offset: SIMD2(0.25, 0.5))), + ("scale", UVTransform(scale: SIMD2(2, 0.5))), + ("rotation", UVTransform(rotation: .pi / 2)), + ("offset and scale", UVTransform(offset: SIMD2(0.125, -0.375), + scale: SIMD2(0.5, 2))), + ("all", UVTransform(offset: SIMD2(-0.2, -0.1), + scale: SIMD2(1.5, 1.5), + rotation: 0.3)), + ] + + for (name, transform) in transforms { + let rendered = try render(transform) + var checked = 0 + for row in stride(from: 2, to: Self.renderSize, by: 5) { + for column in stride(from: 2, to: Self.renderSize, by: 5) { + let uv = SIMD2((Float(column) + 0.5) / Float(Self.renderSize), + (Float(row) + 0.5) / Float(Self.renderSize)) + let texel = transform.applied(to: uv) * Float(Self.textureSize) + // A sample landing near a texel edge is one rounding apart + // from either neighbour, so it proves nothing. + guard texel.x.truncatingRemainder(dividingBy: 1).magnitude > 0.2, + texel.y.truncatingRemainder(dividingBy: 1).magnitude > 0.2, + (1 - texel.x.truncatingRemainder(dividingBy: 1).magnitude) > 0.2, + (1 - texel.y.truncatingRemainder(dividingBy: 1).magnitude) > 0.2 else { continue } + checked += 1 + let expected = SIMD2(wrapped(texel.x), wrapped(texel.y)) + #expect(texelOfColour[rendered[row][column]] == expected, + "\(name): pixel (\(row), \(column)) must sample texel \(expected)") + } + } + #expect(checked > 20, "\(name): too few usable samples") + } + } + + /// The texel index a transformed UV names, with the sampler's repeat wrap. + private func wrapped(_ scaledUV: Float) -> Int { + let index = Int(scaledUV.rounded(.down)) % Self.textureSize + return index < 0 ? index + Self.textureSize : index + } + + /// Loads a one-quad glTF whose only material carries `transform`, and renders + /// it filling the viewport. + @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) + private func render(_ transform: UVTransform) throws -> [[SIMD3]] { + let entity = try GLTFEntityLoader(withData: Self.quadGLTF(transform)).loadEntity() + return try OffscreenRenderer.render(entity, size: Self.renderSize) + } + + /// A glTF of a single unlit quad covering x, y in [-1, 1], textured with the + /// probe image through `transform`. Both resources are data URIs, so the + /// document needs no directory of its own. + @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) + private static func quadGLTF(_ transform: UVTransform) throws -> Data { + var buffer = Data() + // POSITION, then TEXCOORD_0 in glTF's V-down convention, then indices. + for position in [SIMD3(-1, -1, 0), .init(1, -1, 0), .init(1, 1, 0), .init(-1, 1, 0)] { + for component in [position.x, position.y, position.z] { + withUnsafeBytes(of: component.bitPattern.littleEndian) { buffer.append(contentsOf: $0) } + } + } + for uv in [SIMD2(0, 1), .init(1, 1), .init(1, 0), .init(0, 0)] { + for component in [uv.x, uv.y] { + withUnsafeBytes(of: component.bitPattern.littleEndian) { buffer.append(contentsOf: $0) } + } + } + let indexOffset = buffer.count + for index in [0, 1, 2, 0, 2, 3] as [UInt16] { + withUnsafeBytes(of: index.littleEndian) { buffer.append(contentsOf: $0) } + } + + let png = try OffscreenRenderer.makeProbeTexturePNG(size: textureSize) + let json: [String: Any] = [ + "asset": ["version": "2.0"], + "scene": 0, + "scenes": [["nodes": [0]]], + "nodes": [["mesh": 0]], + "meshes": [["primitives": [[ + "attributes": ["POSITION": 0, "TEXCOORD_0": 1], + "indices": 2, + "material": 0, + ]]]], + "materials": [[ + "pbrMetallicRoughness": [ + "baseColorTexture": [ + "index": 0, + "extensions": ["KHR_texture_transform": transform.json], + ], + ], + // Unlit keeps the rendered colour equal to the sampled texel. + "extensions": ["KHR_materials_unlit": [String: Any]()], + ]], + "extensionsUsed": ["KHR_materials_unlit", "KHR_texture_transform"], + "textures": [["sampler": 0, "source": 0]], + // Nearest filtering and repeat wrapping keep every rendered pixel one + // whole texel of the probe image. + "samplers": [["magFilter": 9728, "minFilter": 9728, "wrapS": 10497, "wrapT": 10497]], + "images": [["uri": "data:image/png;base64,\(png.base64EncodedString())"]], + "buffers": [[ + "uri": "data:application/octet-stream;base64,\(buffer.base64EncodedString())", + "byteLength": buffer.count, + ]], + "bufferViews": [ + ["buffer": 0, "byteOffset": 0, "byteLength": 48], + ["buffer": 0, "byteOffset": 48, "byteLength": 32], + ["buffer": 0, "byteOffset": indexOffset, "byteLength": 12], + ], + "accessors": [ + ["bufferView": 0, "componentType": 5126, "count": 4, "type": "VEC3", + "min": [-1, -1, 0], "max": [1, 1, 0]], + ["bufferView": 1, "componentType": 5126, "count": 4, "type": "VEC2"], + ["bufferView": 2, "componentType": 5123, "count": 6, "type": "SCALAR"], + ], + ] + return try JSONSerialization.data(withJSONObject: json) + } +} +#endif diff --git a/Tests/VRMRealityKitTests/VRM1RealityKitTests.swift b/Tests/VRMRealityKitTests/VRM1RealityKitTests.swift index 94b2968f..e82cfff9 100644 --- a/Tests/VRMRealityKitTests/VRM1RealityKitTests.swift +++ b/Tests/VRMRealityKitTests/VRM1RealityKitTests.swift @@ -558,7 +558,7 @@ struct VRM1RealityKitTests { var checkedParts = 0 for modelEntity in TestSupport.modelEntities(in: vrmEntity) - where modelEntity.components[VRMMaterialIndexComponent.self]?.materialIndex == materialIndex { + where modelEntity.components[GLTFMaterialIndexComponent.self]?.materialIndex == materialIndex { guard let mesh = modelEntity.components[ModelComponent.self]?.mesh else { continue } for part in mesh.contents.models.flatMap(\.parts) { let tangents = try #require(part.tangents?.elements) @@ -1111,8 +1111,15 @@ struct VRM1RealityKitTests { #expect(morphWeight(in: firstScene, targetIndex: 33) == 1) #expect(morphWeight(in: secondScene, targetIndex: 33) == 0) - // Re-requesting a scene returns the same instance rather than rebuilding. - #expect(try loader.loadEntity(withSceneIndex: 0) === firstScene) + // Loading a scene again builds another independent entity, so one loader + // can hand out several animatable copies of the same scene. + let reloaded = try loader.loadEntity(withSceneIndex: 0) + #expect(reloaded !== firstScene) + #expect(reloaded.hasRuntimeBindings) + #expect(Set(TestSupport.modelEntities(in: reloaded).map(ObjectIdentifier.init)) + .isDisjoint(with: firstModels)) + #expect(morphWeight(in: reloaded, targetIndex: 33) == 0) + #expect(morphWeight(in: firstScene, targetIndex: 33) == 1) } @Test @@ -1356,7 +1363,7 @@ struct VRM1RealityKitTests { materialIndex: Int, faceCulling: CustomMaterial.FaceCulling? = nil) throws -> CustomMaterial { for modelEntity in TestSupport.modelEntities(in: root) { - guard modelEntity.components[VRMMaterialIndexComponent.self]?.materialIndex == materialIndex, + guard modelEntity.components[GLTFMaterialIndexComponent.self]?.materialIndex == materialIndex, let model = modelEntity.components[ModelComponent.self], let material = model.materials.first as? CustomMaterial else { continue diff --git a/Tests/VRMSceneKitTests/VRM1SceneKitTests.swift b/Tests/VRMSceneKitTests/VRM1SceneKitTests.swift index dedc24cf..4ae8eb49 100644 --- a/Tests/VRMSceneKitTests/VRM1SceneKitTests.swift +++ b/Tests/VRMSceneKitTests/VRM1SceneKitTests.swift @@ -1,4 +1,5 @@ import VRMKit +import VRMTestSupport @testable import VRMSceneKit import SceneKit import simd @@ -8,8 +9,7 @@ import Testing struct VRM1SceneLoaderTests { func vrmLoader() throws -> VRMSceneLoader { - let url = try #require(Bundle.module.url(forResource: "Seed-san", withExtension: "vrm"), "Failed to load Seed-san.vrm resource from test bundle.") - return try VRMSceneLoader(withURL: url) + try VRMSceneLoader(withURL: VRMSampleAsset.seedSan.url) } @Test diff --git a/Tests/VRMSceneKitTests/VRMSceneKitTests.swift b/Tests/VRMSceneKitTests/VRMSceneKitTests.swift index b93d73a9..0caaf653 100644 --- a/Tests/VRMSceneKitTests/VRMSceneKitTests.swift +++ b/Tests/VRMSceneKitTests/VRMSceneKitTests.swift @@ -1,4 +1,5 @@ import XCTest +import VRMTestSupport @testable import VRMSceneKit import SceneKit @@ -61,8 +62,6 @@ class VRMSceneKitTests: XCTestCase { } func loadVRMLoader() -> VRMSceneLoader { - let url = Bundle.module.url(forResource: "AliciaSolid", withExtension: "vrm")! - let data = try! Data(contentsOf: url) - return try! VRMSceneLoader(withData: data) + try! VRMSceneLoader(withData: VRMSampleAsset.aliciaSolid.data) } } diff --git a/Tests/VRMTestSupport/Data+LittleEndian.swift b/Tests/VRMTestSupport/Data+LittleEndian.swift new file mode 100644 index 00000000..3e4d0bce --- /dev/null +++ b/Tests/VRMTestSupport/Data+LittleEndian.swift @@ -0,0 +1,25 @@ +import Foundation + +public extension Data { + /// Little-endian float / uint16 payloads for the hand-written glTF fixtures. + init(littleEndianFloats values: [Float]) { + self.init() + for value in values { + Swift.withUnsafeBytes(of: value.bitPattern.littleEndian) { append(contentsOf: $0) } + } + } + + mutating func appendLittleEndian(_ values: [UInt16]) { + for value in values { + Swift.withUnsafeBytes(of: value.littleEndian) { append(contentsOf: $0) } + } + } + + /// Labelled, so the untyped array literals of the UInt16 callers stay + /// unambiguous. + mutating func appendLittleEndian(unsignedInts values: [UInt32]) { + for value in values { + Swift.withUnsafeBytes(of: value.littleEndian) { append(contentsOf: $0) } + } + } +} diff --git a/Tests/VRMTestSupport/GLBRewriter.swift b/Tests/VRMTestSupport/GLBRewriter.swift index 88b1d0ac..caa9657e 100644 --- a/Tests/VRMTestSupport/GLBRewriter.swift +++ b/Tests/VRMTestSupport/GLBRewriter.swift @@ -16,6 +16,11 @@ public enum GLBRewriter { private static let magic: [UInt8] = [0x67, 0x6c, 0x54, 0x46] // "glTF" private static let jsonChunkType: UInt32 = 0x4e4f534a // "JSON" + /// Whether the data is a GLB container rather than a JSON glTF. + public static func isGLB(_ data: Data) -> Bool { + data.count >= 4 && Array(data.prefix(4)) == magic + } + /// Returns `data` with its glTF JSON replaced by whatever `modify` produces. public static func rewritingJSON(of data: Data, _ modify: (inout [String: Any]) throws -> Void) throws -> Data { diff --git a/Tests/VRMTestSupport/GLTFSampleAsset.swift b/Tests/VRMTestSupport/GLTFSampleAsset.swift new file mode 100644 index 00000000..a87b2870 --- /dev/null +++ b/Tests/VRMTestSupport/GLTFSampleAsset.swift @@ -0,0 +1,49 @@ +import Foundation + +/// The CC0-1.0 fixtures from KhronosGroup/glTF-Sample-Assets that ship with the +/// test bundle. See `Tests/Assets/GLTF/README.md` for provenance. +public enum GLTFSampleAsset: String, CaseIterable, Sendable { + case triangle = "Triangle/Triangle.gltf" + case triangleWithoutIndices = "TriangleWithoutIndices/TriangleWithoutIndices.gltf" + case simpleMeshes = "SimpleMeshes/SimpleMeshes.gltf" + case simpleTexture = "SimpleTexture/SimpleTexture.gltf" + case simpleSkin = "SimpleSkin/SimpleSkin.gltf" + case simpleMorph = "SimpleMorph/SimpleMorph.gltf" + case cameras = "Cameras/Cameras.gltf" + case animatedTriangle = "AnimatedTriangle/AnimatedTriangle.gltf" + case boxVertexColors = "BoxVertexColors/BoxVertexColors.glb" + case animatedMorphCube = "AnimatedMorphCube/AnimatedMorphCube.glb" + case interpolationTest = "InterpolationTest/InterpolationTest.glb" + case textureTransformTest = "TextureTransformTest/TextureTransformTest.gltf" + + /// The fixture's location in the bundle. `GLTFLoader` derives the root + /// directory from it, so sibling `.bin` and `.png` files resolve. + public var url: URL { + TestAssetBundle.url(forFixture: "GLTF/\(rawValue)") + } + + public var data: Data { + TestAssetBundle.data(forFixture: "GLTF/\(rawValue)") + } + + /// The directory holding the fixture, i.e. the base its external `.bin` and + /// `.png` resources resolve against. + public var rootDirectory: URL { + url.deletingLastPathComponent() + } + + /// The fixture with its glTF JSON rewritten, so tests can feed the loaders + /// malformed or unusual files without shipping extra assets. The result has + /// no directory of its own, so load it with ``rootDirectory``. + public func rewritingJSON(_ modify: (inout [String: Any]) throws -> Void) throws -> Data { + let data = data + guard !GLBRewriter.isGLB(data) else { + return try GLBRewriter.rewritingJSON(of: data, modify) + } + guard var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw GLBRewriter.Error.invalidJSON + } + try modify(&json) + return try JSONSerialization.data(withJSONObject: json) + } +} diff --git a/Tests/VRMTestSupport/TestAssetBundle.swift b/Tests/VRMTestSupport/TestAssetBundle.swift new file mode 100644 index 00000000..92430255 --- /dev/null +++ b/Tests/VRMTestSupport/TestAssetBundle.swift @@ -0,0 +1,47 @@ +import Foundation + +/// The fixtures under `Tests/Assets`, which this target owns as its resources. +/// +/// Every test target reaches them through `VRMTestSupport`, so they are copied +/// into one bundle instead of one per test target, and the lookups need no +/// bundle from the caller. Paths are relative to `Tests/Assets`, e.g. +/// `"GLTF/Triangle/Triangle.gltf"`. +enum TestAssetBundle { + static func url(forFixture path: String) -> URL { + let directory = (path as NSString).deletingLastPathComponent + let file = (path as NSString).lastPathComponent as NSString + guard let url = Bundle.module.url(forResource: file.deletingPathExtension, + withExtension: file.pathExtension, + subdirectory: directory) else { + fatalError("Failed to locate the \(path) fixture in \(Bundle.module.bundlePath).") + } + return url + } + + /// The fixture's bytes, read once per test process. The VRM fixtures are + /// ~10 MB each and the tests read them over and over. + static func data(forFixture path: String) -> Data { + cache.data(forFixture: path) + } + + private static let cache = FixtureCache() + + /// Swift Testing runs tests in parallel, so the cache takes a lock. + private final class FixtureCache: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String: Data] = [:] + + func data(forFixture path: String) -> Data { + lock.lock() + defer { lock.unlock() } + if let cached = storage[path] { + return cached + } + guard let data = try? Data(contentsOf: url(forFixture: path)) else { + fatalError("Failed to read the \(path) fixture.") + } + storage[path] = data + return data + } + } +} diff --git a/Tests/VRMTestSupport/VRMSampleAsset.swift b/Tests/VRMTestSupport/VRMSampleAsset.swift new file mode 100644 index 00000000..cd6f6c76 --- /dev/null +++ b/Tests/VRMTestSupport/VRMSampleAsset.swift @@ -0,0 +1,25 @@ +import Foundation + +/// The `.vrm` fixtures that ship with the test bundle. +public enum VRMSampleAsset: String, CaseIterable, Sendable { + /// A VRM 0.x model. The 0.x loading paths need one, as the others are 1.0. + case aliciaSolid = "AliciaSolid.vrm" + /// A VRM 1.0 model with MToon materials, expressions and spring bones. + case seedSan = "Seed-san.vrm" + /// A VRM 1.0 model exercising `VRMC_node_constraint`. + case vrm1ConstraintTwist = "VRM1_Constraint_Twist_Sample.vrm" + + public var url: URL { + TestAssetBundle.url(forFixture: "VRM/\(rawValue)") + } + + public var data: Data { + TestAssetBundle.data(forFixture: "VRM/\(rawValue)") + } + + /// The fixture with its glTF JSON rewritten, so tests can feed the loaders + /// malformed or unusual files without shipping extra assets. + public func rewritingJSON(_ modify: (inout [String: Any]) throws -> Void) throws -> Data { + try GLBRewriter.rewritingJSON(of: data, modify) + } +}