diff --git a/.gitignore b/.gitignore index 5c205132..58616729 100644 --- a/.gitignore +++ b/.gitignore @@ -1,31 +1,31 @@ ### https://raw.github.com/github/gitignore/e6dd3a81e6037cc923e503e372c80326f65ccb25/Global/macos.gitignore -*.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk ### https://raw.github.com/github/gitignore/e6dd3a81e6037cc923e503e372c80326f65ccb25/Global/xcode.gitignore @@ -57,4 +57,6 @@ UserInterfaceState.xcuserstate docs .build +argus1_VRMKit_VRM1_development_plan.md +scripts/capture-reference-output/three-vrm/node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..9cc68718 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## Unreleased + +### Added +- Fork governance and release-hygiene documentation for the VRM 1.0 hardening effort. +- Compatibility and platform support expectations for Swift, Apple SDKs, and RealityKit usage. +- Provenance guidance for behavior or code informed by UniVRM and three-vrm. + +### Changed +- Documented the upstream synchronization policy and the project baseline used for the VRM 1.0 work. + +### Security +- No security-sensitive changes in this initial governance update. diff --git a/Example/Example/ExampleModel.swift b/Example/Example/ExampleModel.swift index 39fe694c..c822804c 100644 --- a/Example/Example/ExampleModel.swift +++ b/Example/Example/ExampleModel.swift @@ -11,6 +11,7 @@ enum VRMExampleModel: String, CaseIterable, Identifiable { } } + /// VRM 0.x and 1.0 models face opposite ways, so one of them is turned to face the viewer. var initialRotation: Float { switch self { case .alicia: return 0 diff --git a/Example/MacExample/MacExampleModel.swift b/Example/MacExample/MacExampleModel.swift index 2eba9168..eb424ee5 100644 --- a/Example/MacExample/MacExampleModel.swift +++ b/Example/MacExample/MacExampleModel.swift @@ -13,6 +13,7 @@ enum MacExampleModel: String, CaseIterable, Identifiable { } } + /// VRM 0.x and 1.0 models face opposite ways, so one of them is turned to face the viewer. var initialRotation: Float { switch self { case .alicia: return 0 diff --git a/Example/VisionExample/ContentView.swift b/Example/VisionExample/ContentView.swift index 707e5094..65cf0419 100644 --- a/Example/VisionExample/ContentView.swift +++ b/Example/VisionExample/ContentView.swift @@ -98,7 +98,6 @@ final class ImmersiveViewModel { let vrmEntity = try await loader.loadEntity() vrmEntity.transform.translation = SIMD3(0, 0, -1.5) - // Alicia (VRM0) needs 180 degree rotation to face camera, VRM1 samples often don't vrmEntity.transform.rotation = simd_quatf(angle: model.initialRotation, axis: SIMD3(0, 1, 0)) rootEntity.addChild(vrmEntity) diff --git a/Example/VisionExample/VisionExampleApp.swift b/Example/VisionExample/VisionExampleApp.swift index 70256f20..e1d6fc30 100644 --- a/Example/VisionExample/VisionExampleApp.swift +++ b/Example/VisionExample/VisionExampleApp.swift @@ -46,6 +46,7 @@ final class AppModel { } } + /// VRM 0.x and 1.0 models face opposite ways, so one of them is turned to face the viewer. var initialRotation: Float { switch self { case .alicia: return .pi diff --git a/Package.swift b/Package.swift index 76552d21..daf9f65e 100644 --- a/Package.swift +++ b/Package.swift @@ -34,7 +34,8 @@ let package = Package( resources: [ .copy("../Assets/GLTF"), .copy("../Assets/VRM"), - .copy("../Assets/VRMA") + .copy("../Assets/VRMA"), + .copy("../Assets/ReferenceOutputs") ] ), diff --git a/README.md b/README.md index 61321269..74cc052c 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ For "VRM", please refer to [this page](https://dwango.github.io/en/vrm/). .package(url: "https://github.com/tattn/VRMKit.git", from: "0.9.0") ``` -For SceneKit, use [0.10.0](https://github.com/tattn/VRMKit/releases/tag/0.10.0), the last version that shipped it. +For SceneKit rendering, use [0.10.0](https://github.com/tattn/VRMKit/releases/tag/0.10.0). # Usage @@ -194,6 +194,8 @@ let custom = try VRMEntityLoader(withData: data, shaders: [MyShader(), MToonShad `GLTFShadedMaterial` also carries extra render passes, MToon's outline being one, and a `makeAnimatableState` closure that lets VRM expressions animate a custom material. See the `GLTFMaterialShader` documentation comments. +A blended material also carries a `renderQueue`, the Unity-scale draw order that VRM 0.x's `renderQueue` and MToon's `renderQueueOffsetNumber` express; `context.renderQueue(alphaMode:transparentWithZWrite:offset:)` derives it. RealityKit orders the blended parts of one model entity by distance, which flips parts an author stacked on purpose as the view moves, so a mesh whose blended materials sit at different queues draws them from model entities of their own, sorted in queue order. + A pass can be built hidden and shown later with `entity.setPassEnabled(_:named:)`, which is how MToon outlines double as a selection highlight. An override outranks the authored values, and releasing it puts them back. ```swift @@ -231,7 +233,9 @@ let controller = try entity.playAnimation(at: 0, loops: true) // same controlle `loadEntity()` renders the asset's default scene and throws when the glTF names none; pick one with `loadEntity(withSceneIndex:)`. It reads the model's vertex data off the main thread, a primitive at a time in parallel. -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. It also draws with the original's material parameters; `cloneWithOwnMaterialParameters()` gives the copy its own, a few hundred bytes per material, so it can be lit or recolored by itself. +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. It moves and is lit as the original is; `cloneWithOwnMaterialParameters()` gives the copy meshes and material parameters of its own, posed as the original is at the call, so it holds still and can be lit or recolored by itself. + +Skinning and morphing run in the loader's own compute kernel into a `LowLevelMesh` per model entity, and only when a pose or weight moved. The rest-pose vertex data behind a model entity is readable through `gltfMeshGeometry`.
Renderer limitations @@ -242,9 +246,8 @@ RealityKit meshes and materials cannot express every part of glTF and MToon. Eac - `COLOR_0` vertex colors are ignored: the mesh buffers this renderer builds carry no vertex-color channel. - One UV set and one `KHR_texture_transform` per material: the first UV-accessed texture decides both. A glTF load requiring more is rejected rather than drawn wrong; a VRM load renders the approximation. - Tangents for a primitive without `TANGENT` are averaged from its UV gradients, not generated with MikkTSpace, so a normal map baked against MikkTSpace can differ along UV seams. -- Blend shapes morph `POSITION` only, since RealityKit blend shapes have no `NORMAL` / `TANGENT` channel. +- Blend shapes morph `POSITION` only; a target's `NORMAL` / `TANGENT` deltas are not read. - Skinning reads `JOINTS_0` / `WEIGHTS_0` only, so a vertex is driven by at most four joints. -- MToon's `renderQueueOffsetNumber` is ignored, because RealityKit has no material-level draw-order hook; `transparentWithZWrite` works through `CustomMaterial.writesDepth`. - MToon's outline is clamped to a culling margin of the mesh's radius, so an outline asking for more caps out there. - MToon's outline takes its lit color from the runtime light color, not from the surface's fully evaluated shading, which RealityKit does not expose to a `CustomMaterial`. diff --git a/Sources/VRMKit/VRM/VRM1.swift b/Sources/VRMKit/VRM/VRM1.swift index ab925a7b..a8d91bd3 100644 --- a/Sources/VRMKit/VRM/VRM1.swift +++ b/Sources/VRMKit/VRM/VRM1.swift @@ -1,6 +1,18 @@ import Foundation public struct VRM1: Sendable { + public struct HumanoidValidationReport: Hashable, Sendable { + public let missingRequiredBones: [HumanoidBone] + public let invalidNodeIndices: [HumanoidBone: Int] + public let duplicateNodeAssignments: [Int: [HumanoidBone]] + + public var isValid: Bool { + missingRequiredBones.isEmpty + && invalidNodeIndices.isEmpty + && duplicateNodeAssignments.isEmpty + } + } + /// The `VRMC_vrm` spec versions this type models. public static func supports(specVersion: String) -> Bool { specVersion == "1.0" || specVersion == "1.0-beta" @@ -23,6 +35,38 @@ public struct VRM1: Sendable { try self.init(document: GLTFDocument(data: data, rootDirectory: rootDirectory)) } + public static func validateHumanoid(data: Data, rootDirectory: URL? = nil) throws -> HumanoidValidationReport { + try validateHumanoid(document: GLTFDocument(data: data, rootDirectory: rootDirectory)) + } + + public static func validateHumanoid(document: GLTFDocument) throws -> HumanoidValidationReport { + let extensions = try document.rawJSON().object("extensions") + ??? .keyNotFound("extensions") + let vrm = try extensions.object(GLTFExtension.vrm1.rawValue) + ??? .keyNotFound(GLTFExtension.vrm1.rawValue) + let bones = vrm.object("humanoid")?.object("humanBones") ?? [:] + var present: Set = [] + var nodeAssignments: [Int: [HumanoidBone]] = [:] + var invalid: [HumanoidBone: Int] = [:] + + for bone in HumanoidBone.allCases { + guard let node = bones.object(bone.rawValue)?.int("node") else { continue } + present.insert(bone) + if !document.gltf.nodes.indices.contains(node) { + invalid[bone] = node + } else { + nodeAssignments[node, default: []].append(bone) + } + } + + let duplicates = nodeAssignments.filter { $0.value.count > 1 } + return HumanoidValidationReport( + missingRequiredBones: Humanoid.HumanBones.required.filter { !present.contains($0) }, + invalidNodeIndices: invalid, + duplicateNodeAssignments: duplicates + ) + } + /// Initialize from an already-loaded document, so deciding a file's version does /// not mean parsing it twice. public init(document: GLTFDocument) throws { @@ -113,7 +157,7 @@ public extension VRM1 { public subscript(bone: HumanoidBone) -> HumanBone? { bones[bone] } /// The bones VRM 1.0 requires of every humanoid. - static let required: [HumanoidBone] = [ + public static let required: [HumanoidBone] = [ .hips, .spine, .head, .leftUpperLeg, .leftLowerLeg, .leftFoot, .rightUpperLeg, .rightLowerLeg, .rightFoot, diff --git a/Sources/VRMKit/VRM/VRMAnimation.swift b/Sources/VRMKit/VRM/VRMAnimation.swift index 1d3c7534..fae7a3a2 100644 --- a/Sources/VRMKit/VRM/VRMAnimation.swift +++ b/Sources/VRMKit/VRM/VRMAnimation.swift @@ -21,7 +21,7 @@ public struct VRMAnimation: Sendable { /// The pre-release version older exporters wrote. package static let draftSpecVersion = "1.0-draft" - private static let logger = Logger(subsystem: "dev.tattn.VRMKit", category: "VRMAnimation") + private static let logger = Logger(subsystem: "com.github.tattn.VRMKit", category: "VRMAnimation") /// The underlying glTF document, which carries the animation samplers and /// their binary buffers. diff --git a/Sources/VRMKitRuntime/Humanoid.swift b/Sources/VRMKitRuntime/Humanoid.swift index fa45cc22..efbda811 100644 --- a/Sources/VRMKitRuntime/Humanoid.swift +++ b/Sources/VRMKitRuntime/Humanoid.swift @@ -7,9 +7,14 @@ public final class Humanoid { /// Binds the rig to the nodes it was built from, both VRM versions reading /// as the same `boneNodes` mapping. - package func setUp(boneNodes: [HumanoidBone: Int], nodes: [Node?]) { - bones = boneNodes.reduce(into: [:]) { result, entry in - guard nodes.indices.contains(entry.value), let node = nodes[entry.value] else { return } + package func setUp(boneNodes: [HumanoidBone: Int], nodes: [Node?]) throws { + bones = try boneNodes.reduce(into: [:]) { result, entry in + guard nodes.indices.contains(entry.value) else { + throw VRMError._dataInconsistent( + "humanoid bone \(entry.key.rawValue) names node \(entry.value), which does not exist" + ) + } + guard let node = nodes[entry.value] else { return } result[entry.key] = node } } diff --git a/Sources/VRMRealityKit/CustomType/GLTFEntity+MToon.swift b/Sources/VRMRealityKit/CustomType/GLTFEntity+MToon.swift index caac2c5f..784822d4 100644 --- a/Sources/VRMRealityKit/CustomType/GLTFEntity+MToon.swift +++ b/Sources/VRMRealityKit/CustomType/GLTFEntity+MToon.swift @@ -22,32 +22,69 @@ extension GLTFEntity { // MARK: - Lighting + /// Sets the light direction, light color and ambient color together, pushing the + /// rows to the GPU once. A caller tracking a light per frame (a device that moves, + /// a background whose light is re-estimated) goes through here rather than the + /// three setters, which would flush every material three times a frame. + /// /// The vector points from the surface toward the light, so a `DirectionalLight` - /// matching it sits at `direction` and aims at the model. It rides in the parameter - /// texture, so tracking a light per frame is one small blit per material. - public func setMToonLightDirection(_ direction: SIMD3) { - let length = simd_length(direction) - let normalized = length > 0.001 ? direction / length : MToonMaterialParameters.defaultLightDirection - guard simd_distance(normalized, mtoonLightDirection) > 0.0001 else { return } + /// matching it sits at `direction` and aims at the model. The default color is + /// white and the default ambient color, feeding the MToon GI approximation, black. + public func setMToonLighting(direction: SIMD3, color: SIMD3, ambient: SIMD3) { + let normalized = Self.normalizedMToonLightDirection(direction) + let directionChanged = simd_distance(normalized, mtoonLightDirection) > 0.0001 + let colorsChanged = color != mtoonLightColor || ambient != mtoonAmbientColor + guard directionChanged || colorsChanged else { return } mtoonLightDirection = normalized + mtoonLightColor = color + mtoonAmbientColor = ambient mutateMToonStates { state in - state.setLightDirection(normalized) + if directionChanged { + state.setLightDirection(normalized) + } + if colorsChanged { + state.setLighting(color: color, ambient: ambient) + } return true } } + /// The vector points from the surface toward the light, so a `DirectionalLight` + /// matching it sits at `direction` and aims at the model. It rides in the parameter + /// texture, so tracking a light per frame is one small blit per material. + public func setMToonLightDirection(_ direction: SIMD3) { + setMToonLighting(direction: direction, color: mtoonLightColor, ambient: mtoonAmbientColor) + } + /// The default is white. public func setMToonLightColor(_ color: SIMD3) { - guard color != mtoonLightColor else { return } - mtoonLightColor = color - updateMToonLightingRows() + setMToonLighting(direction: mtoonLightDirection, color: color, ambient: mtoonAmbientColor) } /// Feeds the MToon GI approximation. The default is black. public func setMToonAmbientColor(_ color: SIMD3) { - guard color != mtoonAmbientColor else { return } - mtoonAmbientColor = color - updateMToonLightingRows() + setMToonLighting(direction: mtoonLightDirection, color: mtoonLightColor, ambient: color) + } + + /// Adds a rim light to every MToon material, or removes it with nil. Like the + /// light direction it can track a light per frame; an unchanged value writes nothing. + public func setMToonRimLight(_ rim: MToonRimLight?) { + setMToonRimLight(rim, forMaterials: Set(materialStates.keys)) + } + + /// ``setMToonRimLight(_:)`` restricted to `materials`, which + /// ``GLTFEntity/materialIndices(under:)`` supplies for a node's subtree. The inside + /// of a mouth is the usual exclusion: its walls meet the view at a grazing angle, + /// like a silhouette, so a backlight would light them up. + public func setMToonRimLight(_ rim: MToonRimLight?, forMaterials materials: Set) { + mutateMToonStates(forMaterials: materials) { state in + state.setRimLight(rim) + } + } + + private static func normalizedMToonLightDirection(_ direction: SIMD3) -> SIMD3 { + let length = simd_length(direction) + return length > 0.001 ? direction / length : MToonMaterialParameters.defaultLightDirection } /// Blocks until every MToon parameter write has reached the GPU. The rows are @@ -59,12 +96,6 @@ extension GLTFEntity { } } - private func updateMToonLightingRows() { - mutateMToonStates { state in - state.setLighting(color: mtoonLightColor, ambient: mtoonAmbientColor) - return true - } - } // MARK: - Outline diff --git a/Sources/VRMRealityKit/CustomType/GLTFEntity.swift b/Sources/VRMRealityKit/CustomType/GLTFEntity.swift index e80d90d5..9ead7da5 100644 --- a/Sources/VRMRealityKit/CustomType/GLTFEntity.swift +++ b/Sources/VRMRealityKit/CustomType/GLTFEntity.swift @@ -1,5 +1,7 @@ #if canImport(RealityKit) import Foundation +import Metal +import OSLog import RealityKit import simd import VRMKit @@ -85,6 +87,16 @@ public class GLTFEntity: Entity { let jointEntities: [Entity] /// Stands in for `skeleton.id`, so the per-frame solve dedups without hashing strings. let skeletonKey: Int + /// The mesh the solved pose is written to. + let deformedMesh: GLTFDeformedMesh? + } + + /// Every mesh this entity's own document deforms, whose pending skinning and + /// morphing ``flushDeformation()`` submits. + private(set) var deformedMeshes: [GLTFDeformedMesh] = [] + + func registerDeformedMesh(_ mesh: GLTFDeformedMesh) { + deformedMeshes.append(mesh) } private(set) var skinBindings: [SkinBinding] = [] @@ -126,17 +138,6 @@ public class GLTFEntity: Entity { /// glTF node index → the blend shapes a `weights` channel writes to. private(set) var morphBindings: [Int: MorphBinding] = [:] - /// Counts the mesh variants swapped in under this model. A variant re-lays the - /// weight sets and the parts, so whatever was resolved against a mesh is stale - /// once this moves. - private(set) var meshVariantGeneration = 0 - - /// Called by the entity that swapped a variant in, so resolutions made against - /// the mesh it replaced are not reused. - func didSwapMeshVariant() { - meshVariantGeneration &+= 1 - } - /// One place a glTF material is rendered: which slot of which entity's /// materials array holds it. struct MaterialBinding { @@ -182,6 +183,7 @@ public class GLTFEntity: Entity { GLTFNodeComponent.registerComponent() GLTFMaterialSlotsComponent.registerComponent() GLTFMergedMeshComponent.registerComponent() + GLTFDeformedMeshComponent.registerComponent() GLTFMaterialPassComponent.registerComponent() GLTFSkinIndexComponent.registerComponent() GLTFAnimationPlaybackComponent.registerComponent() @@ -213,7 +215,7 @@ public class GLTFEntity: Entity { nodeEntities = nodes } - /// The pose is solved by ``flushSkinPose()`` once the entity graph is complete. + /// The pose is solved by ``flushDeformation()`` once the entity graph is complete. func registerSkinBinding(modelEntity: ModelEntity, skeleton: MeshResource.Skeleton, jointEntities: [Entity]) { @@ -232,11 +234,12 @@ public class GLTFEntity: Entity { skinBindings.append(SkinBinding(modelEntity: modelEntity, skeleton: skeleton, jointEntities: jointEntities, - skeletonKey: key)) + skeletonKey: key, + deformedMesh: modelEntity.deformedMesh)) } func registerMorphBindings(forNodeAt nodeIndex: Int, modelEntities: [ModelEntity], targetCount: Int) { - let morphable = modelEntities.filter { $0.components.has(BlendShapeWeightsComponent.self) } + let morphable = modelEntities.filter { $0.deformedMesh?.geometry.hasBlendShapes == true } guard !morphable.isEmpty else { return } morphBindings[nodeIndex, default: MorphBinding(modelEntities: [], targetCount: targetCount)] .modelEntities.append(contentsOf: morphable) @@ -253,36 +256,91 @@ public class GLTFEntity: Entity { materialStates[materialIndex]?.bindings.append(MaterialBinding(modelEntity: modelEntity, slot: slot)) } - /// A `clone(recursive:)` copy with material parameters of its own. + /// A `clone(recursive:)` copy with material parameters and meshes of its own. /// - /// A plain clone samples this entity's parameters, so it stays lit and colored as this - /// entity is and the material setters do nothing on it. This copy takes the values this - /// entity draws with now, a few hundred bytes per material, and moves on its own from - /// there. Like any clone it carries no animation bindings. Before a one-off render of - /// the copy, call ``waitForMToonParameterWrites()`` on it. + /// A plain clone shares this entity's parameters and meshes, so it is lit, colored + /// and posed as this entity is, and the material setters do nothing on it. This copy + /// takes both as they are now and holds them on its own from there. Like any clone it + /// carries no animation bindings. Before a one-off render of the copy, call + /// ``waitForMToonParameterWrites()`` on it. public func cloneWithOwnMaterialParameters() -> Self { // The copy carries the meshes as they are skinned now, so they are solved against // the joints it is about to copy. updateSkinPose() let copy = clone(recursive: true) - for modelEntity in copy.modelEntitiesInHierarchy { + copy.freezeDeformedMeshes() + // A glTF entity attached under this one (an accessory) is a document of its own, + // whose material indices mean nothing in this entity's states; each takes its + // parameters from its own original. A clone keeps the children in order, so the + // two hierarchies pair up entity by entity. + for (original, cloned) in zip(gltfEntitiesInHierarchy, copy.gltfEntitiesInHierarchy) { + cloned.adoptMaterialParameters(of: original) + } + return copy + } + + /// Gives every model entity under this clone a still copy of the mesh it shares with + /// its original, so the original's next pose does not reach it. + private func freezeDeformedMeshes() { + for modelEntity in modelEntitiesInHierarchy { + guard let shared = modelEntity.deformedMesh, !shared.isFrozen else { continue } + do { + modelEntity.setDeformedMesh(try shared.makeFrozenCopy()) + } catch { + // The clone keeps drawing the shared mesh, which at least draws. + GLTFResourceCache.gltfLogger.error("Failed to copy a mesh for a clone: \(String(describing: error), privacy: .public)") + } + } + } + + /// Gives this clone rows of its own copied from `original`'s, bound to the model + /// entities of this entity's own document (not those of a glTF entity nested under it). + private func adoptMaterialParameters(of original: GLTFEntity) { + for modelEntity in ownModelEntities { guard let indices = modelEntity.components[GLTFMaterialSlotsComponent.self]?.materialIndices else { continue } for case let (slot, materialIndex?) in indices.enumerated() { - if copy.materialStates[materialIndex] == nil { - let detached = materialStates[materialIndex]?.animatable?.detached() - copy.materialStates[materialIndex] = MaterialRuntimeState(animatable: detached, - needsFlush: detached != nil) + if materialStates[materialIndex] == nil { + let detached = original.materialStates[materialIndex]?.animatable?.detached() + materialStates[materialIndex] = MaterialRuntimeState(animatable: detached, + needsFlush: detached != nil) } - copy.materialStates[materialIndex]?.bindings.append(MaterialBinding(modelEntity: modelEntity, slot: slot)) + materialStates[materialIndex]?.bindings.append(MaterialBinding(modelEntity: modelEntity, slot: slot)) } } - copy.mtoonLightDirection = mtoonLightDirection - copy.mtoonLightColor = mtoonLightColor - copy.mtoonAmbientColor = mtoonAmbientColor - copy.flushDirtyMaterialStates() - return copy + mtoonLightDirection = original.mtoonLightDirection + mtoonLightColor = original.mtoonLightColor + mtoonAmbientColor = original.mtoonAmbientColor + flushDirtyMaterialStates() + } + + /// This entity and every glTF entity nested under it, in a fixed traversal order. + private var gltfEntitiesInHierarchy: [GLTFEntity] { + var result: [GLTFEntity] = [] + var stack: [Entity] = [self] + while let entity = stack.popLast() { + if let gltfEntity = entity as? GLTFEntity { + result.append(gltfEntity) + } + stack.append(contentsOf: entity.children) + } + return result + } + + /// The model entities of this entity's own document: its hierarchy minus the subtrees + /// of glTF entities nested under it. + private var ownModelEntities: [ModelEntity] { + var result: [ModelEntity] = [] + var stack: [Entity] = [self] + while let entity = stack.popLast() { + if entity !== self, entity is GLTFEntity { continue } + if let modelEntity = entity as? ModelEntity { + result.append(modelEntity) + } + stack.append(contentsOf: entity.children) + } + return result } /// The glTF material indices any model entity under `root` renders with, additional @@ -400,6 +458,10 @@ public class GLTFEntity: Entity { /// and reports whether every one of them landed. @discardableResult func flushDirtyMaterialStates() -> Bool { + MToonParameterTexture.batchWrites { flushDirtyMaterialStatesNow() } + } + + private func flushDirtyMaterialStatesNow() -> Bool { var didFlushAll = true for (materialIndex, state) in materialStates where state.needsFlush { guard let animatable = state.animatable, animatable.prepareFlush() else { @@ -462,20 +524,64 @@ public class GLTFEntity: Entity { } /// Solves the skinned meshes against the joints as they stand, where they have moved - /// since the last solve. + /// since the last solve, and submits their deformation. /// /// The update loop does this once a frame, so this is for a caller reading or copying /// the model between updates: a `clone(recursive: true)` taken while a joint has moved /// carries the mesh as the last solve left it, which is a pose the joints no longer /// describe. ``cloneWithOwnMaterialParameters()`` does it for you. public func updateSkinPose() { - flushSkinPoseIfNeeded() + flushDeformation() } - /// Re-solves the skin pose of every skin binding from the current joint transforms. - func flushSkinPose() { - isSkinPoseDirty = true - solveSkinPose() + private static let signposter = OSSignposter(subsystem: "com.github.tattn.VRMKit", category: "Deformation") + + /// Solves the skeletons whose joints moved, then submits the skinning and morphing + /// of every mesh whose pose or weights moved since the last submit, in one command + /// buffer. Nothing is submitted for a held pose. + /// + /// The entities drawing one glTF mesh (its render passes and its render-queue + /// groups) hold the same pose and weights, so the first of them deforms the + /// vertices and the rest copy its result. Every dispatch shares one compute encoder + /// and every copy one blit encoder: an encoder costs the GPU more than a copy does. + func flushDeformation() { + flushSkinPoseIfNeeded() + guard let pending = deformedMeshes.first(where: \.isDeformationPending) else { return } + let context = pending.source.context + guard let commandBuffer = context.commandQueue.makeCommandBuffer() else { return } + commandBuffer.label = "VRMKit deformation" + var deformations: [(mesh: GLTFDeformedMesh, output: MTLBuffer)] = [] + var copies: [(from: MTLBuffer, to: MTLBuffer)] = [] + var deformedBySource: [ObjectIdentifier: MTLBuffer] = [:] + // The interval is the CPU side of a submit, for a trace to set against the GPU's. + let interval = Self.signposter.beginInterval("VRMKit deformation") + defer { Self.signposter.endInterval("VRMKit deformation", interval) } + for mesh in deformedMeshes { + guard let output = mesh.beginDeformation(using: commandBuffer) else { continue } + let source = ObjectIdentifier(mesh.source) + if let deformed = deformedBySource[source] { + copies.append((deformed, output)) + } else { + deformedBySource[source] = output + deformations.append((mesh, output)) + } + } + if let encoder = commandBuffer.makeComputeCommandEncoder() { + encoder.label = "VRMKit deformation" + encoder.setComputePipelineState(context.pipeline) + for (mesh, output) in deformations { + mesh.encodeDeformation(into: output, with: encoder) + } + encoder.endEncoding() + } + if !copies.isEmpty, let blit = commandBuffer.makeBlitCommandEncoder() { + blit.label = "VRMKit deformation copy" + for (from, to) in copies { + GLTFDeformedMesh.encodeCopy(from: from, to: to, with: blit) + } + blit.endEncoding() + } + commandBuffer.commit() } /// Re-solves the skin pose only where a joint has moved since the last solve, so a @@ -585,16 +691,7 @@ public class GLTFEntity: Entity { } 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) + binding.deformedMesh?.setJointTransforms(transforms) } /// A skeleton pose is each joint read in the space of the joint above it, which a @@ -634,32 +731,6 @@ public class GLTFEntity: Entity { } } -@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) -extension ModelEntity { - /// Writes `weights` onto the blend-shape targets positionally, as glTF defines for - /// `mesh.weights`, `node.weights` and `weights` channels alike. - func applyMorphWeights(_ weights: [Float]) { - let current = blendWeights - guard !current.isEmpty else { return } - // Compared before any copy: a held pose is the common case, and writing the sets - // would re-upload the weights. - func differs(_ set: [Float]) -> 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. diff --git a/Sources/VRMRealityKit/CustomType/GLTFMergedMesh.swift b/Sources/VRMRealityKit/CustomType/GLTFMergedMesh.swift index 76a5fe27..d9865850 100644 --- a/Sources/VRMRealityKit/CustomType/GLTFMergedMesh.swift +++ b/Sources/VRMRealityKit/CustomType/GLTFMergedMesh.swift @@ -11,147 +11,21 @@ struct GLTFMaterialSlotsComponent: Component { let materialIndices: [Int?] } -/// Everything a merged model entity redraws itself from: which part each material -/// slot owns, what a first-person camera cuts of it, and the mesh variants already -/// generated. Hidden slots and first-person cuts are baked into a variant rather -/// than drawn by entities of their own, which is what keeps one mesh at one entity. -/// -/// One catalog is shared by every clone of its template, so each variant is -/// generated once per document however many entity graphs show it. -@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) -@MainActor -final class GLTFMergedMeshCatalog { - /// One material slot of the merged mesh, built from one glTF primitive. - struct Slot { - /// The part's id inside the full mesh's single model. - let partID: String - /// What a first-person camera draws of the part. - let firstPersonMask: FirstPersonPrimitiveMask - /// The visibility the slot was built with, which a release restores. - let isInitiallyVisible: Bool - } - - /// Every slot visible, in third person. - let fullMesh: MeshResource - let slots: [Slot] - private let slotIndicesByPartID: [String: Int] - - private struct VariantKey: Hashable { - let visibleSlots: [Bool] - let isFirstPerson: Bool - } - - /// Nil is a variant with nothing left to draw. - private var variants: [VariantKey: MeshResource?] = [:] - /// The regular flows use a handful of combinations; a caller walking many - /// resets the cache rather than growing it for the document's lifetime. - private static let maxCachedVariants = 16 - - init(fullMesh: MeshResource, slots: [Slot]) { - self.fullMesh = fullMesh - self.slots = slots - self.slotIndicesByPartID = Dictionary(uniqueKeysWithValues: - slots.enumerated().map { ($0.element.partID, $0.offset) }) - } - - var initiallyVisibleSlots: [Bool] { slots.map(\.isInitiallyVisible) } - - /// Whether a first-person camera draws this mesh differently at all. - var hasFirstPersonCut: Bool { - slots.contains { $0.firstPersonMask != .whole } - } - - /// The mesh drawing exactly `visibleSlots` of the parts, cut for a - /// first-person camera when asked, or nil when nothing is left to draw. - func mesh(visibleSlots: [Bool], isFirstPerson: Bool) throws -> MeshResource? { - // The first-person flag only matters where a cut exists. - let key = VariantKey(visibleSlots: visibleSlots, - isFirstPerson: isFirstPerson && hasFirstPersonCut) - if !key.isFirstPerson, !key.visibleSlots.contains(false) { return fullMesh } - if let cached = variants[key] { return cached } - - let variant = try variantContents(for: key).map { try MeshResource.generate(from: $0) } - cache(variant, for: key) - return variant - } - - /// Generates the mesh a first-person camera draws with every slot at its initial - /// visibility, off the actor. Loads call it once they have returned, so the first - /// camera switch finds the variant ready without the load having paid for it; a - /// switch landing first generates the same mesh on the spot. - func prewarmFirstPersonVariant() async throws { - let key = VariantKey(visibleSlots: initiallyVisibleSlots, isFirstPerson: true) - guard key.isFirstPerson, variants[key] == nil else { return } - guard let contents = variantContents(for: key) else { - cache(nil, for: key) - return - } - let variant = try await Self.generate(VariantContents(contents: contents)) - guard variants[key] == nil else { return } - cache(variant, for: key) - } - - /// The contents are a value copy of the full mesh's, so nothing shared leaves the actor. - private struct VariantContents: @unchecked Sendable { - let contents: MeshResource.Contents - } - - private nonisolated static func generate(_ variant: VariantContents) async throws -> MeshResource { - try await MeshResource(from: variant.contents) - } - - private func cache(_ variant: MeshResource?, for key: VariantKey) { - if variants.count >= Self.maxCachedVariants { - variants.removeAll(keepingCapacity: true) - } - variants[key] = variant - } - - /// The full mesh's contents with the hidden and cut parts taken out, for a - /// resource of their own. Part and model ids survive, so the materials and - /// the skeletal pose keep addressing the result. Nil is nothing left to draw. - private func variantContents(for key: VariantKey) -> MeshResource.Contents? { - var contents = fullMesh.contents - var models = MeshModelCollection() - for model in contents.models { - var parts: [MeshResource.Part] = [] - for part in model.parts { - guard let slotIndex = slotIndicesByPartID[part.id] else { - // A part no slot owns is kept whole: silently losing geometry - // would be worse than drawing it in every variant. - parts.append(part) - continue - } - guard key.visibleSlots[safe: slotIndex] ?? true else { continue } - var part = part - if key.isFirstPerson { - switch slots[slotIndex].firstPersonMask { - case .whole: - break - case .nothing: - continue - case .triangles(let indices): - part.triangleIndices = MeshBuffer(indices) - } - } - parts.append(part) - } - guard !parts.isEmpty else { continue } - _ = models.insert(MeshResource.Model(id: model.id, parts: parts)) - } - guard !models.isEmpty else { return nil } - contents.models = models - return contents - } -} - -/// The merged-mesh state one model entity currently draws: its catalog, which -/// slots are visible, and whether a first-person camera looks at it. +/// The merged-mesh state one model entity currently draws: which of its material +/// slots are visible, and whether a first-person camera looks at it. Hidden slots +/// and first-person cuts are a choice of parts over the one mesh rather than +/// entities of their own, which is what keeps one mesh at one entity. @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) struct GLTFMergedMeshComponent: Component { - let catalog: GLTFMergedMeshCatalog + /// The visibility each slot was built with, which a release restores. + let initiallyVisibleSlots: [Bool] var visibleSlots: [Bool] var isFirstPerson = false + + init(initiallyVisibleSlots: [Bool]) { + self.initiallyVisibleSlots = initiallyVisibleSlots + visibleSlots = initiallyVisibleSlots + } } @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) @@ -186,15 +60,14 @@ extension ModelEntity { /// Puts every slot back to the visibility it was built with. func resetMergedVisibility() { guard var merged = mergedMesh else { return } - let initial = merged.catalog.initiallyVisibleSlots - guard merged.visibleSlots != initial else { return } - merged.visibleSlots = initial + guard merged.visibleSlots != merged.initiallyVisibleSlots else { return } + merged.visibleSlots = merged.initiallyVisibleSlots applyMergedMesh(merged) } /// The visibility one slot was built with, which a released override falls back to. func initialMergedSlotVisibility(at slot: Int) -> Bool { - mergedMesh?.catalog.slots[safe: slot]?.isInitiallyVisible ?? true + mergedMesh?.initiallyVisibleSlots[safe: slot] ?? true } /// Draws the mesh as a first- or third-person camera sees it, cutting the parts the head draws. @@ -204,51 +77,18 @@ extension ModelEntity { applyMergedMesh(merged) } - /// Regenerates the mesh for the state the component holds, for a template - /// whose slots do not all start visible. + /// Draws the parts the state the component holds describes. func applyMergedMesh() { guard let merged = mergedMesh else { return } applyMergedMesh(merged) } private func applyMergedMesh(_ merged: GLTFMergedMeshComponent) { - let mesh: MeshResource? - do { - mesh = try merged.catalog.mesh(visibleSlots: merged.visibleSlots, - isFirstPerson: merged.isFirstPerson) - } catch { - // The component keeps describing what is drawn, so the caller's next - // attempt is not swallowed by the did-anything-change guards. - GLTFResourceCache.gltfLogger.error("Failed to generate a mesh variant: \(String(describing: error), privacy: .public)") - return - } components.set(merged) - // A variant with nothing to draw hides the entity instead of drawing an - // empty mesh; the last mesh stays for the state that shows it again. - isEnabled = mesh != nil - guard let mesh, var model = components[ModelComponent.self], model.mesh !== mesh else { return } - model.mesh = mesh - components.set(model) - refreshBlendShapes(for: mesh) - owningGLTFEntity?.didSwapMeshVariant() - } - - /// The model this entity is drawn as part of, which counts the mesh variants - /// swapped in under it. - private var owningGLTFEntity: GLTFEntity? { - sequence(first: self, next: \.parent).lazy.compactMap { $0 as? GLTFEntity }.first - } - - /// A fresh weights component for the swapped-in mesh, carrying the weights the - /// old one held: variants drop parts, so the weight sets are laid out anew. - private func refreshBlendShapes(for mesh: MeshResource) { - guard components.has(BlendShapeWeightsComponent.self) else { return } - let weights = blendWeights.first - let mapping = BlendShapeWeightsMapping(meshResource: mesh) - components.set(BlendShapeWeightsComponent(weightsMapping: mapping)) - if let weights, weights.contains(where: { $0 != 0 }) { - applyMorphWeights(weights) - } + guard let deformedMesh else { return } + // A state with nothing to draw hides the entity instead of drawing an empty + // mesh; the last parts stay for the state that shows it again. + isEnabled = deformedMesh.setParts(visibleSlots: merged.visibleSlots, isFirstPerson: merged.isFirstPerson) } } #endif diff --git a/Sources/VRMRealityKit/CustomType/VRMEntity.swift b/Sources/VRMRealityKit/CustomType/VRMEntity.swift index 7aa36ab7..b0340506 100644 --- a/Sources/VRMRealityKit/CustomType/VRMEntity.swift +++ b/Sources/VRMRealityKit/CustomType/VRMEntity.swift @@ -20,7 +20,7 @@ struct VRMComponent: Component { /// owns it, and ``VRMUpdateSystem`` animates it while it stays in the scene. @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) public final class VRMEntity: GLTFEntity { - private static let logger = Logger(subsystem: "dev.tattn.VRMKit", category: "Expression") + private static let logger = Logger(subsystem: "com.github.tattn.VRMKit", category: "Expression") /// The VRM this entity was loaded from. /// @@ -43,11 +43,10 @@ public final class VRMEntity: GLTFEntity { private var springBones = SpringBoneRig() private var nodeConstraints = NodeConstraintRig() private var lookAt = LookAtRig() - // Blend-shape target -> weight-set positions, resolved on first write. - private var blendShapeSlotCache: [MorphBindingKey: BlendShapeSlots] = [:] - // Scratch for one apply: the weight sets each mesh is written back with, so a - // frame's morph writes cost one read and one write per mesh, not per target. - private var stagedBlendWeights: [StagedBlendWeights] = [] + private var lipSyncOverrides: [ExpressionKey: CGFloat] = [:] + private var animationExpressionWeights: [ExpressionKey: CGFloat] = [:] + /// Mesh entity → the deformed meshes under it, resolved on the first write to each. + private var deformedMeshesByMeshEntity: [Entity.ID: [GLTFDeformedMesh]] = [:] private lazy var expressionApplier = ExpressionApplier( setMorphWeight: { [unowned self] weight, targetIndex, mesh in @@ -60,7 +59,6 @@ public final class VRMEntity: GLTFEntity { applyTextureTransform(scale: scale, offset: offset, rotation: rotation, materialIndex: materialIndex) }, didApply: { [unowned self] in - flushStagedBlendWeights() flushDirtyMaterialStates() } ) @@ -121,8 +119,8 @@ public final class VRMEntity: GLTFEntity { /// coordinates, so the entity faces whichever way its VRM does. public override var frontDirection: SIMD3 { vrm.forwardDirection } - func setUpHumanoid(nodes: [Entity?]) { - humanoid.setUp(boneNodes: vrm.boneNodes, nodes: nodes) + func setUpHumanoid(nodes: [Entity?]) throws { + try humanoid.setUp(boneNodes: vrm.boneNodes, nodes: nodes) } /// Called once per entity, right after its node hierarchy is built. @@ -276,7 +274,7 @@ public final class VRMEntity: GLTFEntity { if springBones.update(deltaTime: deltaTime) { invalidateSkinPose(for: springBones.posedNodes) } - flushSkinPoseIfNeeded() + flushDeformation() } public func setExpression(value: CGFloat, for key: ExpressionKey) { @@ -291,6 +289,32 @@ public final class VRMEntity: GLTFEntity { expressions.apply(with: expressionApplier) } + /// Applies application-owned lip-sync weights after VRMA expression tracks. + public func setLipSyncExpressions(_ weights: [ExpressionKey: CGFloat]) { + lipSyncOverrides.merge(weights) { _, newest in newest } + applyExpressionWeights() + } + + /// Releases application ownership for `keys`; released expressions resume their VRMA values. + public func clearLipSyncExpressions(for keys: some Sequence) { + for key in keys { + lipSyncOverrides.removeValue(forKey: key) + } + applyExpressionWeights() + } + + func applyAnimationExpressions(_ weights: [ExpressionKey: CGFloat]) { + animationExpressionWeights = weights + applyExpressionWeights() + } + + private func applyExpressionWeights() { + var weights = animationExpressionWeights + weights.merge(lipSyncOverrides) { _, newest in newest } + guard expressions.storeWeights(weights) else { return } + expressions.apply(with: expressionApplier) + } + public func expression(for key: ExpressionKey) -> CGFloat { CGFloat(expressions.weight(for: key)) } @@ -319,7 +343,7 @@ public final class VRMEntity: GLTFEntity { private func applyFirstPersonCut(_ mode: FirstPersonRenderMode, to entity: Entity) -> Bool { var cut = false for modelEntity in entity.modelEntitiesInHierarchy { - guard let merged = modelEntity.mergedMesh, merged.catalog.hasFirstPersonCut else { continue } + guard modelEntity.deformedMesh?.hasFirstPersonCut == true else { continue } cut = true modelEntity.setMergedFirstPerson(mode == .firstPerson) } @@ -354,116 +378,19 @@ public final class VRMEntity: GLTFEntity { } } - /// Where one blend-shape target lives in a model entity's weight sets. - private struct BlendShapeSlot { - let modelEntity: ModelEntity - /// (weight set, index within it) pairs the target writes to. - let positions: [(set: Int, index: Int)] - } - - /// One mesh's weight sets, read once and written back once per apply. - private struct StagedBlendWeights { - let modelEntity: ModelEntity - var sets: [[Float]] - } - - /// One resolved target, remembering the mesh variant it was resolved against: a - /// variant re-lays the weight sets, so a resolution outliving one is made again. - private struct BlendShapeSlots { - let slots: [BlendShapeSlot] - let generation: Int - } - - /// Stages the write rather than making it: `blendWeights` copies every set both - /// read and written, so writing straight through would pay for that copy once per - /// target. The staged sets are mutated where they sit, for the same reason. private func applyBlendShapeWeight(_ weight: Float, targetIndex: Int, on mesh: Entity) { - for slot in blendShapeSlots(targetIndex: targetIndex, on: mesh) { - let index = stagedIndex(of: slot.modelEntity) - for position in slot.positions { - guard position.set < stagedBlendWeights[index].sets.count, - position.index < stagedBlendWeights[index].sets[position.set].count else { continue } - stagedBlendWeights[index].sets[position.set][position.index] = weight - } - } - } - - /// Where one mesh's staged sets sit, read out of the mesh on first use. A model - /// has a handful of them, so they are searched rather than hashed. - private func stagedIndex(of modelEntity: ModelEntity) -> Int { - if let index = stagedBlendWeights.firstIndex(where: { $0.modelEntity === modelEntity }) { - return index - } - stagedBlendWeights.append(StagedBlendWeights(modelEntity: modelEntity, - sets: modelEntity.blendWeights)) - return stagedBlendWeights.count - 1 - } - - /// Writes back what one apply staged, once per mesh. - private func flushStagedBlendWeights() { - for staged in stagedBlendWeights { - staged.modelEntity.blendWeights = staged.sets - } - stagedBlendWeights.removeAll(keepingCapacity: true) - } - - /// Resolves the target's weight-set positions once per mesh, rather than looking up - /// a blend-shape name on every write. - private func blendShapeSlots(targetIndex: Int, on mesh: Entity) -> [BlendShapeSlot] { - let key = MorphBindingKey(mesh: mesh, targetIndex: targetIndex) - if let cached = blendShapeSlotCache[key], cached.generation == meshVariantGeneration { - return cached.slots - } - - let targetName = "blendShape_\(targetIndex)" - var slots: [BlendShapeSlot] = [] - for modelEntity in mesh.modelEntitiesInHierarchy { - ensureBlendShapeComponent(on: modelEntity) - let weights = modelEntity.blendWeights - guard !weights.isEmpty else { continue } - let names = modelEntity.blendWeightNames - var positions: [(set: Int, index: Int)] = [] - if !names.isEmpty { - for setIndex in names.indices { - if let nameIndex = names[setIndex].firstIndex(of: targetName), - nameIndex < weights[setIndex].count { - positions.append((setIndex, nameIndex)) - } - } - } - if positions.isEmpty { - // Meshes without blend-shape names address targets positionally. - for setIndex in weights.indices where targetIndex < weights[setIndex].count { - positions.append((setIndex, targetIndex)) - } - } - guard !positions.isEmpty else { continue } - slots.append(BlendShapeSlot(modelEntity: modelEntity, positions: positions)) - } - blendShapeSlotCache[key] = BlendShapeSlots(slots: slots, generation: meshVariantGeneration) - return slots - } - - private func ensureBlendShapeComponent(on modelEntity: ModelEntity) { - if modelEntity.components[BlendShapeWeightsComponent.self] != nil { - return + for deformedMesh in deformedMeshes(under: mesh) { + deformedMesh.setBlendShapeWeight(weight, forTarget: targetIndex) } - guard let model = modelEntity.components[ModelComponent.self] else { return } - let mapping = BlendShapeWeightsMapping(meshResource: model.mesh) - modelEntity.components.set(BlendShapeWeightsComponent(weightsMapping: mapping)) } -} -@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) -/// Identifies one morph target on one mesh entity, both to accumulate expression -/// weights and to cache where the target lives in the blend-shape weight sets. -private struct MorphBindingKey: Hashable { - let mesh: ObjectIdentifier - let targetIndex: Int - - init(mesh: Entity, targetIndex: Int) { - self.mesh = ObjectIdentifier(mesh) - self.targetIndex = targetIndex + /// The deformed meshes a mesh entity draws, found once rather than by walking the + /// entity's hierarchy on every write. + private func deformedMeshes(under mesh: Entity) -> [GLTFDeformedMesh] { + if let cached = deformedMeshesByMeshEntity[mesh.id] { return cached } + let meshes = mesh.modelEntitiesInHierarchy.compactMap(\.deformedMesh) + deformedMeshesByMeshEntity[mesh.id] = meshes + return meshes } } diff --git a/Sources/VRMRealityKit/Deformation/GLTFDeformationContext.swift b/Sources/VRMRealityKit/Deformation/GLTFDeformationContext.swift new file mode 100644 index 00000000..bf65274e --- /dev/null +++ b/Sources/VRMRealityKit/Deformation/GLTFDeformationContext.swift @@ -0,0 +1,88 @@ +#if canImport(RealityKit) +import Foundation +import Metal +import VRMKit + +/// One deformed vertex as the kernel writes it and the `LowLevelMesh` reads it. +struct GLTFDeformedVertex { + static let stride = 48 + static let positionOffset = 0 + static let normalOffset = 12 + static let tangentOffset = 24 + static let bitangentOffset = 36 +} + +/// Matches `Uniforms` in ``GLTFDeformationKernel``. +struct GLTFDeformationUniforms { + var vertexCount: UInt32 + var activeTargetCount: UInt32 + var hasNormals: UInt32 + var hasTangents: UInt32 + var isSkinned: UInt32 +} + +/// What every deformed mesh in the process shares: the device, the queue the +/// deformation is submitted on, and the compiled kernel. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +@MainActor +final class GLTFDeformationContext { + let device: MTLDevice + let commandQueue: MTLCommandQueue + let pipeline: MTLComputePipelineState + /// Bound where a mesh has no data for an input, so every buffer slot is filled. + let emptyBuffer: MTLBuffer + + private static var cached: Result? + + /// The shared context, made on first use. Failing to make one fails the load that + /// asked, and every load after it, the same way. + static func shared() throws -> GLTFDeformationContext { + if let cached { return try cached.get() } + let result = Result { try GLTFDeformationContext() } + cached = result + return try result.get() + } + + private init() throws { + guard let device = MTLCreateSystemDefaultDevice() else { + throw VRMError._notSupported("mesh deformation needs a Metal device") + } + guard let commandQueue = device.makeCommandQueue(), + let emptyBuffer = device.makeBuffer(length: 16, options: .storageModeShared) else { + throw VRMError._notSupported("mesh deformation could not make a Metal command queue") + } + let library = try device.makeLibrary(source: GLTFDeformationKernel.source, options: nil) + guard let function = library.makeFunction(name: GLTFDeformationKernel.functionName) else { + throw VRMError._notSupported("the mesh deformation kernel is missing from its library") + } + self.device = device + self.commandQueue = commandQueue + self.pipeline = try device.makeComputePipelineState(function: function) + self.emptyBuffer = emptyBuffer + } + + /// A shared-storage buffer of `vectors` packed as the kernel's `packed_float3`. + func makePackedBuffer(_ vectors: [SIMD3]) -> MTLBuffer? { + guard !vectors.isEmpty else { return nil } + guard let buffer = device.makeBuffer(length: vectors.count * 12, options: .storageModeShared) else { + return nil + } + Self.pack(vectors, into: buffer.contents()) + return buffer + } + + /// Writes `vectors` at `destination` as consecutive `packed_float3`. + static func pack(_ vectors: [SIMD3], into destination: UnsafeMutableRawPointer) { + for (index, vector) in vectors.enumerated() { + pack(vector, into: destination.advanced(by: index * 12)) + } + } + + static func pack(_ vector: SIMD3, into destination: UnsafeMutableRawPointer) { + let floats = destination.assumingMemoryBound(to: Float.self) + floats[0] = vector.x + floats[1] = vector.y + floats[2] = vector.z + } +} +#endif diff --git a/Sources/VRMRealityKit/Deformation/GLTFDeformationKernel.swift b/Sources/VRMRealityKit/Deformation/GLTFDeformationKernel.swift new file mode 100644 index 00000000..16e958c9 --- /dev/null +++ b/Sources/VRMRealityKit/Deformation/GLTFDeformationKernel.swift @@ -0,0 +1,84 @@ +#if canImport(RealityKit) +/// The compute kernel that skins and morphs a mesh into its `LowLevelMesh` vertex buffer. +/// +/// Compiled at runtime rather than shipped in the MToon metallibs: it is plain Metal +/// with no RealityKit shader dependency, so it compiles on every platform the package +/// renders on, visionOS included, and stays one source with the Swift side that lays +/// out its buffers (``GLTFDeformationContext``). +enum GLTFDeformationKernel { + static let functionName = "gltfDeformVertices" + + static let source = """ + #include + using namespace metal; + + // Matches GLTFDeformedVertex and GLTFDeformationUniforms in Swift. + struct DeformedVertex { + packed_float3 position; + packed_float3 normal; + packed_float3 tangent; + packed_float3 bitangent; + }; + + struct Uniforms { + uint vertexCount; + uint activeTargetCount; + uint hasNormals; + uint hasTangents; + uint isSkinned; + }; + + kernel void gltfDeformVertices(device const packed_float3 *basePositions [[buffer(0)]], + device const packed_float3 *baseNormals [[buffer(1)]], + device const packed_float3 *baseTangents [[buffer(2)]], + device const packed_float3 *baseBitangents [[buffer(3)]], + device const uint4 *joints [[buffer(4)]], + device const float4 *jointWeights [[buffer(5)]], + device const float4x4 *jointMatrices [[buffer(6)]], + device const packed_float3 *morphDeltas [[buffer(7)]], + device const float *morphWeights [[buffer(8)]], + device const uint *activeTargets [[buffer(9)]], + constant Uniforms &uniforms [[buffer(10)]], + device DeformedVertex *out [[buffer(11)]], + uint id [[thread_position_in_grid]]) + { + if (id >= uniforms.vertexCount) return; + float3 position = float3(basePositions[id]); + // Only the targets with a weight are summed: a face carries dozens and an + // expression moves a few. + for (uint i = 0; i < uniforms.activeTargetCount; i++) { + uint target = activeTargets[i]; + position += morphWeights[target] * float3(morphDeltas[target * uniforms.vertexCount + id]); + } + float3 normal = uniforms.hasNormals ? float3(baseNormals[id]) : float3(0.0); + float3 tangent = uniforms.hasTangents ? float3(baseTangents[id]) : float3(0.0); + float3 bitangent = uniforms.hasTangents ? float3(baseBitangents[id]) : float3(0.0); + + if (uniforms.isSkinned) { + uint4 joint = joints[id]; + float4 weight = jointWeights[id]; + // A vertex no joint drives stays where it was authored. + if (weight.x + weight.y + weight.z + weight.w > 0.0) { + float4x4 skin = jointMatrices[joint.x] * weight.x + + jointMatrices[joint.y] * weight.y + + jointMatrices[joint.z] * weight.z + + jointMatrices[joint.w] * weight.w; + position = (skin * float4(position, 1.0)).xyz; + float3x3 rotation = float3x3(skin[0].xyz, skin[1].xyz, skin[2].xyz); + normal = normalize(rotation * normal); + tangent = normalize(rotation * tangent); + bitangent = normalize(rotation * bitangent); + } + } + + // `vertex` is a Metal keyword. + DeformedVertex deformed; + deformed.position = position; + deformed.normal = normal; + deformed.tangent = tangent; + deformed.bitangent = bitangent; + out[id] = deformed; + } + """ +} +#endif diff --git a/Sources/VRMRealityKit/Deformation/GLTFDeformedMesh.swift b/Sources/VRMRealityKit/Deformation/GLTFDeformedMesh.swift new file mode 100644 index 00000000..0c145d3e --- /dev/null +++ b/Sources/VRMRealityKit/Deformation/GLTFDeformedMesh.swift @@ -0,0 +1,295 @@ +#if canImport(RealityKit) +import Foundation +import Metal +import RealityKit +import simd +import VRMKit + +/// One drawn copy of a merged mesh: the `LowLevelMesh` it renders from, and the +/// pose and morph weights the vertices in it were last deformed with. +/// +/// The loader skins and morphs on the GPU itself rather than through RealityKit's +/// deformation, which on OS 27 allocates and frees GPU buffers every frame for +/// every deformed mesh. A mesh whose joints and weights did not move is not dispatched. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +@MainActor +final class GLTFDeformedMesh { + let source: GLTFMeshSource + /// The slots of the source this mesh draws, in the order of the entity's materials. + /// Every entity drawing one mesh shares its vertex array and draws its own slots. + let drawnSlots: [Int] + let lowLevelMesh: LowLevelMesh + let meshResource: MeshResource + + /// One weight per morph target, positionally as glTF defines them. + private(set) var blendShapeWeights: [Float] + /// The skeleton pose the mesh was last solved against, each joint in the space of + /// the joint above it, as ``GLTFEntity`` solves it. + private(set) var jointTransforms: JointTransforms? + + /// A copy taken off a live mesh draws what the mesh drew when it was taken, and + /// never deforms again. + let isFrozen: Bool + + private var needsDeformation = false + /// Whether the parts drawn now cover anything. A mesh drawing nothing, such as a + /// hidden render pass, is not deformed until something of it shows again. + private var drawsAnything = true + /// Whether a dispatch would change what the mesh draws. + var isDeformationPending: Bool { needsDeformation && drawsAnything && !isFrozen && source.isDeformable } + private let jointMatricesBuffer: MTLBuffer? + private let morphWeightsBuffer: MTLBuffer? + private let activeTargetsBuffer: MTLBuffer? + + var geometry: GLTFMeshGeometry { source.geometry } + + /// Whether a first-person camera draws this mesh differently at all. + var hasFirstPersonCut: Bool { + drawnSlots.contains { geometry.slots[$0].firstPersonIndexRange != nil } + } + + init(source: GLTFMeshSource, drawnSlots: [Int], frozen: Bool = false) throws { + self.source = source + self.drawnSlots = drawnSlots + self.isFrozen = frozen + let geometry = source.geometry + let device = source.context.device + lowLevelMesh = try LowLevelMesh(descriptor: source.descriptor) + lowLevelMesh.withUnsafeMutableIndices { raw in + geometry.indices.withUnsafeBytes { raw.copyMemory(from: $0) } + } + if !geometry.texcoords.isEmpty { + lowLevelMesh.withUnsafeMutableBytes(bufferIndex: GLTFMeshSource.texcoordBufferIndex) { raw in + geometry.texcoords.withUnsafeBytes { raw.copyMemory(from: $0) } + } + } + // The rest pose is what the mesh draws until a solve arrives; a static mesh + // keeps it for good. + Self.writeRestPose(of: geometry, into: lowLevelMesh) + blendShapeWeights = Array(repeating: 0, count: geometry.blendShapeTargetCount) + if let skin = source.skin { + let identity = Array(repeating: matrix_identity_float4x4, count: max(skin.skeleton.joints.count, 1)) + jointMatricesBuffer = device.makeBuffer(identity) + } else { + jointMatricesBuffer = nil + } + if geometry.hasBlendShapes { + morphWeightsBuffer = device.makeBuffer(length: geometry.blendShapeTargetCount * 4, options: .storageModeShared) + activeTargetsBuffer = device.makeBuffer(length: geometry.blendShapeTargetCount * 4, options: .storageModeShared) + } else { + morphWeightsBuffer = nil + activeTargetsBuffer = nil + } + meshResource = try MeshResource(from: lowLevelMesh) + } + + private static func writeRestPose(of geometry: GLTFMeshGeometry, into mesh: LowLevelMesh) { + mesh.withUnsafeMutableBytes(bufferIndex: 0) { raw in + guard let base = raw.baseAddress else { return } + memset(base, 0, raw.count) + for index in 0.. Bool { + var parts: [LowLevelMesh.Part] = [] + for (materialIndex, slot) in drawnSlots.enumerated() where visibleSlots[safe: materialIndex] ?? true { + let slot = geometry.slots[slot] + let range = slot.indexRange(isFirstPerson: isFirstPerson) + guard !range.isEmpty else { continue } + parts.append(LowLevelMesh.Part(indexOffset: range.lowerBound * MemoryLayout.stride, + indexCount: range.count, + topology: .triangle, + materialIndex: materialIndex, + bounds: slot.bounds)) + } + // A mesh shown again after a hidden stretch has missed the poses written meanwhile. + if !drawsAnything, !parts.isEmpty { needsDeformation = true } + drawsAnything = !parts.isEmpty + guard drawsAnything else { return false } + lowLevelMesh.parts.replaceAll(parts) + return true + } + + /// The number of indices the parts drawn now cover. + var drawnIndexCount: Int { + lowLevelMesh.parts.reduce(0) { $0 + $1.indexCount } + } + + // MARK: - Pose and weights + + /// Writes `weights` onto the morph targets positionally, as glTF defines for + /// `mesh.weights`, `node.weights` and `weights` channels alike. + func setBlendShapeWeights(_ weights: [Float]) { + for (target, weight) in weights.enumerated() { + setBlendShapeWeight(weight, forTarget: target) + } + } + + func setBlendShapeWeight(_ weight: Float, forTarget target: Int) { + guard blendShapeWeights.indices.contains(target), blendShapeWeights[target] != weight else { return } + blendShapeWeights[target] = weight + needsDeformation = true + } + + /// Takes the solved skeleton pose, each joint in its skeleton parent's space. The + /// skinning matrices are made from it when the mesh is dispatched, so a mesh that + /// copies a sibling's result never makes them. + func setJointTransforms(_ transforms: JointTransforms) { + guard source.skin != nil else { return } + jointTransforms = transforms + needsDeformation = true + } + + // MARK: - Deformation + + /// The buffer this submit's vertices go to, taken from RealityKit before anything is + /// encoded on `commandBuffer`. Nil when the mesh has nothing to deform. + func beginDeformation(using commandBuffer: MTLCommandBuffer) -> MTLBuffer? { + guard isDeformationPending else { return nil } + needsDeformation = false + return lowLevelMesh.replace(bufferIndex: 0, using: commandBuffer) + } + + /// Encodes this mesh's skinning and morphing into `output`, which + /// ``beginDeformation(using:)`` handed out, on an encoder that already holds the + /// context's pipeline. + func encodeDeformation(into output: MTLBuffer, with encoder: MTLComputeCommandEncoder) { + let context = source.context + let geometry = source.geometry + + var activeTargetCount = 0 + if let morphWeightsBuffer, let activeTargetsBuffer { + let weights = morphWeightsBuffer.contents().assumingMemoryBound(to: Float.self) + let active = activeTargetsBuffer.contents().assumingMemoryBound(to: UInt32.self) + for (target, weight) in blendShapeWeights.enumerated() { + weights[target] = weight + if weight != 0 { + active[activeTargetCount] = UInt32(target) + activeTargetCount += 1 + } + } + } + if let skin = source.skin, let jointMatricesBuffer, let jointTransforms { + Self.writeJointMatrices(of: jointTransforms, for: skin.skeleton, into: jointMatricesBuffer) + } + + var uniforms = GLTFDeformationUniforms(vertexCount: UInt32(geometry.vertexCount), + activeTargetCount: UInt32(activeTargetCount), + hasNormals: geometry.normals.isEmpty ? 0 : 1, + hasTangents: geometry.tangents.isEmpty ? 0 : 1, + isSkinned: source.skin == nil ? 0 : 1) + let empty = context.emptyBuffer + encoder.setBuffer(source.basePositions, offset: 0, index: 0) + encoder.setBuffer(source.baseNormals ?? empty, offset: 0, index: 1) + encoder.setBuffer(source.baseTangents ?? empty, offset: 0, index: 2) + encoder.setBuffer(source.baseBitangents ?? empty, offset: 0, index: 3) + encoder.setBuffer(source.skin?.joints ?? empty, offset: 0, index: 4) + encoder.setBuffer(source.skin?.weights ?? empty, offset: 0, index: 5) + encoder.setBuffer(jointMatricesBuffer ?? empty, offset: 0, index: 6) + encoder.setBuffer(geometry.blendShapeOffsetStorage ?? empty, offset: 0, index: 7) + encoder.setBuffer(morphWeightsBuffer ?? empty, offset: 0, index: 8) + encoder.setBuffer(activeTargetsBuffer ?? empty, offset: 0, index: 9) + encoder.setBytes(&uniforms, length: MemoryLayout.stride, index: 10) + encoder.setBuffer(output, offset: 0, index: 11) + let width = min(context.pipeline.maxTotalThreadsPerThreadgroup, 64) + // Whole threadgroups rather than `dispatchThreads`: the simulator's Metal device rejects + // non-uniform threadgroup sizes, and the kernel already ignores threads past the last vertex. + let groups = (geometry.vertexCount + width - 1) / width + encoder.dispatchThreadgroups(MTLSize(width: groups, height: 1, depth: 1), + threadsPerThreadgroup: MTLSize(width: width, height: 1, depth: 1)) + } + + /// Writes the matrices the kernel skins with, each joint's model-space pose times + /// its inverse bind matrix, into `buffer`. + private static func writeJointMatrices(of transforms: JointTransforms, + for skeleton: MeshResource.Skeleton, + into buffer: MTLBuffer) { + let joints = skeleton.joints + let matrices = buffer.contents().assumingMemoryBound(to: simd_float4x4.self) + // The skeleton lists parents first, so a parent's model-space pose is settled + // by the time its child reads it. + for index in joints.indices { + let local = index < transforms.count ? transforms[index].matrix : matrix_identity_float4x4 + if let parent = joints[index].parentIndex, parent < index { + matrices[index] = matrices[parent] * local + } else { + matrices[index] = local + } + } + for index in joints.indices { + matrices[index] = matrices[index] * joints[index].inverseBindPoseMatrix + } + } + + static func encodeCopy(from: MTLBuffer, to: MTLBuffer, with blit: MTLBlitCommandEncoder) { + blit.copy(from: from, sourceOffset: 0, to: to, destinationOffset: 0, size: min(from.length, to.length)) + } + + /// A copy drawing what this mesh draws now, which never deforms again. Any + /// deformation still pending on this mesh must have been submitted first: the + /// copy is taken on the GPU, behind it. + func makeFrozenCopy() throws -> GLTFDeformedMesh { + let copy = try GLTFDeformedMesh(source: source, drawnSlots: drawnSlots, frozen: true) + copy.blendShapeWeights = blendShapeWeights + copy.jointTransforms = jointTransforms + if source.isDeformable { + guard let commandBuffer = source.context.commandQueue.makeCommandBuffer(), + let blit = commandBuffer.makeBlitCommandEncoder() else { + throw VRMError._notSupported("could not copy a deformed mesh") + } + Self.encodeCopy(from: lowLevelMesh.read(bufferIndex: 0, using: commandBuffer), + to: copy.lowLevelMesh.replace(bufferIndex: 0, using: commandBuffer), + with: blit) + blit.endEncoding() + commandBuffer.commit() + } + return copy + } +} + +/// The deformed mesh a model entity draws. Shared by a plain `clone(recursive:)`, +/// which therefore keeps drawing what its original does; a copy of its own comes from +/// ``GLTFEntity/cloneWithOwnMaterialParameters()``. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +struct GLTFDeformedMeshComponent: Component { + let mesh: GLTFDeformedMesh +} + +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +extension ModelEntity { + var deformedMesh: GLTFDeformedMesh? { + components[GLTFDeformedMeshComponent.self]?.mesh + } + + /// The vertex data this entity was built from, at rest, for an entity a + /// ``GLTFEntityLoader`` built. Nil on any other model entity. + public var gltfMeshGeometry: GLTFMeshGeometry? { + deformedMesh?.geometry + } + + /// Draws `mesh` from here on, with the parts the merged-mesh state describes. + func setDeformedMesh(_ mesh: GLTFDeformedMesh) { + guard var model = components[ModelComponent.self] else { return } + model.mesh = mesh.meshResource + components.set(model) + components.set(GLTFDeformedMeshComponent(mesh: mesh)) + applyMergedMesh() + } +} +#endif diff --git a/Sources/VRMRealityKit/Deformation/GLTFMeshGeometry.swift b/Sources/VRMRealityKit/Deformation/GLTFMeshGeometry.swift new file mode 100644 index 00000000..2245246d --- /dev/null +++ b/Sources/VRMRealityKit/Deformation/GLTFMeshGeometry.swift @@ -0,0 +1,218 @@ +#if canImport(RealityKit) +import Foundation +import Metal +import RealityKit +import simd +import VRMKit + +/// The vertex data behind one model entity of a loaded glTF, as authored: the rest +/// pose, before any skinning or morphing. +/// +/// The mesh is drawn from a `LowLevelMesh` the loader deforms itself, which has no +/// readable contents, so anything that needs to know where a vertex sits reads it +/// here, through ``RealityKit/ModelEntity/gltfMeshGeometry``. Every slot of the +/// merged mesh shares the one vertex array and draws its own range of `indices`. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +public struct GLTFMeshGeometry: @unchecked Sendable { + /// One material slot of the merged mesh, built from one glTF primitive. + public struct Slot: Sendable { + /// The vertices the slot's triangles reference. + public let vertexRange: Range + /// Where the slot's triangles sit in ``GLTFMeshGeometry/indices``. + public let indexRange: Range + /// Where the triangles a first-person camera draws sit in + /// ``GLTFMeshGeometry/indices``. Nil draws the slot whole; an empty range + /// draws nothing of it. + public let firstPersonIndexRange: Range? + /// The slot's vertices at rest. + public let bounds: BoundingBox + /// Whether the primitive the slot came from declares morph targets. The + /// others' vertices hold zero deltas for every target. + public let hasBlendShapes: Bool + + /// Where the triangles drawn for the camera sit in ``GLTFMeshGeometry/indices``. + public func indexRange(isFirstPerson: Bool) -> Range { + isFirstPerson ? firstPersonIndexRange ?? indexRange : indexRange + } + } + + public let positions: [SIMD3] + /// Empty when no primitive carries normals. + public let normals: [SIMD3] + /// Empty when no material samples a normal map. + public let tangents: [SIMD3] + public let bitangents: [SIMD3] + /// Empty when no primitive carries texture coordinates. + public let texcoords: [SIMD2] + /// Every slot's triangles in slot order, followed by the first-person cuts. + public let indices: [UInt32] + public let slots: [Slot] + /// Four joints per vertex in the skeleton's joint order, empty for an unskinned mesh. + public let jointIndices: [SIMD4] + /// The four weights of ``jointIndices``, renormalized to sum to one. + public let jointWeights: [SIMD4] + public let blendShapeTargetCount: Int + /// The whole mesh at rest. + public let bounds: BoundingBox + + /// The POSITION deltas of every target over every vertex, target-major, packed as + /// the kernel reads them. Kept once, in the buffer the GPU deforms from. + let blendShapeOffsetStorage: MTLBuffer? + + public var vertexCount: Int { positions.count } + public var isSkinned: Bool { !jointIndices.isEmpty } + public var hasBlendShapes: Bool { blendShapeTargetCount > 0 } + + /// The triangles a slot draws, as `indices` into ``positions``. + /// The POSITION deltas of one morph target, one per vertex. Zero for the vertices + /// of a primitive that declares no targets, and empty for a target the mesh has not. + public func blendShapeOffsets(forTarget target: Int) -> [SIMD3] { + guard let blendShapeOffsetStorage, (0.. ArraySlice { + indices[slots[slot].indexRange(isFirstPerson: isFirstPerson)] + } +} + +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +extension GLTFMeshGeometry { + /// Lays `primitives` out as one vertex array and one index array, one slot each. + /// A primitive missing an attribute another carries gets zeros for it, so every + /// vertex reads the same layout. + @MainActor + init(primitives: [GLTFPreparedPrimitive], + isSkinned: Bool, + blendShapeTargetCount: Int, + context: GLTFDeformationContext) throws { + let vertexCount = primitives.reduce(0) { $0 + $1.geometry.positions.count } + let wholeIndexCount = primitives.reduce(0) { $0 + $1.geometry.indices.count } + let hasNormals = primitives.contains { !$0.geometry.normals.isEmpty } + let hasTangents = primitives.contains { !$0.geometry.tangents.isEmpty } + let hasTexcoords = primitives.contains { !$0.geometry.texcoords.isEmpty } + + var positions: [SIMD3] = [] + var normals: [SIMD3] = [] + var tangents: [SIMD3] = [] + var bitangents: [SIMD3] = [] + var texcoords: [SIMD2] = [] + var jointIndices: [SIMD4] = [] + var jointWeights: [SIMD4] = [] + var indices: [UInt32] = [] + var cuts: [UInt32] = [] + var slots: [Slot] = [] + positions.reserveCapacity(vertexCount) + if hasNormals { normals.reserveCapacity(vertexCount) } + if hasTangents { + tangents.reserveCapacity(vertexCount) + bitangents.reserveCapacity(vertexCount) + } + if hasTexcoords { texcoords.reserveCapacity(vertexCount) } + if isSkinned { + jointIndices.reserveCapacity(vertexCount) + jointWeights.reserveCapacity(vertexCount) + } + indices.reserveCapacity(wholeIndexCount) + slots.reserveCapacity(primitives.count) + + func append(_ values: [Element], paddedTo count: Int, with filler: Element, + to array: inout [Element]) { + if values.isEmpty { + array.append(contentsOf: repeatElement(filler, count: count)) + } else { + array.append(contentsOf: values) + } + } + + // The cuts go after every slot's whole triangles, so a slot's range is + // contiguous whichever way it is drawn. + var cutStart = wholeIndexCount + for primitive in primitives { + let geometry = primitive.geometry + let base = positions.count + let count = geometry.positions.count + positions.append(contentsOf: geometry.positions) + if hasNormals { append(geometry.normals, paddedTo: count, with: .zero, to: &normals) } + if hasTangents { + append(geometry.tangents, paddedTo: count, with: .zero, to: &tangents) + append(geometry.bitangents, paddedTo: count, with: .zero, to: &bitangents) + } + if hasTexcoords { append(geometry.texcoords, paddedTo: count, with: .zero, to: &texcoords) } + if isSkinned { + append(primitive.jointInfluences?.joints ?? [], paddedTo: count, with: .zero, to: &jointIndices) + append(primitive.jointInfluences?.weights ?? [], paddedTo: count, with: .zero, to: &jointWeights) + } + let indexStart = indices.count + for index in geometry.indices { + indices.append(index + UInt32(base)) + } + var firstPersonIndexRange: Range? + switch primitive.firstPersonMask { + case .whole: + break + case .nothing: + firstPersonIndexRange = cutStart.. 0, vertexCount > 0 { + guard let storage = context.device.makeBuffer(length: blendShapeTargetCount * vertexCount * 12, + options: .storageModeShared) else { + throw VRMError._notSupported("could not allocate the blend shape offsets of a mesh") + } + // A primitive without targets leaves its vertices' deltas at the zero the + // buffer starts with. + memset(storage.contents(), 0, storage.length) + for (primitive, slot) in zip(primitives, slots) { + for (target, offsets) in primitive.geometry.blendShapeOffsets.enumerated() where target < blendShapeTargetCount { + let destination = storage.contents().advanced(by: (target * vertexCount + slot.vertexRange.lowerBound) * 12) + GLTFDeformationContext.pack(offsets, into: destination) + } + } + blendShapeOffsetStorage = storage + } + + self.positions = positions + self.normals = normals + self.tangents = tangents + self.bitangents = bitangents + self.texcoords = texcoords + self.indices = indices + self.slots = slots + self.jointIndices = jointIndices + self.jointWeights = jointWeights + self.blendShapeTargetCount = blendShapeTargetCount + self.bounds = slots.dropFirst().reduce(slots.first?.bounds ?? BoundingBox(min: .zero, max: .zero)) { + $0.union($1.bounds) + } + self.blendShapeOffsetStorage = blendShapeOffsetStorage + } + + private static func bounds(of positions: ArraySlice>) -> BoundingBox { + guard var minimum = positions.first else { return BoundingBox(min: .zero, max: .zero) } + var maximum = minimum + for position in positions { + minimum = simd_min(minimum, position) + maximum = simd_max(maximum, position) + } + return BoundingBox(min: minimum, max: maximum) + } +} +#endif diff --git a/Sources/VRMRealityKit/Deformation/GLTFMeshSource.swift b/Sources/VRMRealityKit/Deformation/GLTFMeshSource.swift new file mode 100644 index 00000000..2559c5c2 --- /dev/null +++ b/Sources/VRMRealityKit/Deformation/GLTFMeshSource.swift @@ -0,0 +1,82 @@ +#if canImport(RealityKit) +import Foundation +import Metal +import RealityKit +import VRMKit + +/// What every instance of one merged mesh deforms from: the rest-pose attributes, +/// the joint influences and the morph deltas, once on the GPU per document, and +/// the layout of the `LowLevelMesh` each instance draws. +/// +/// Built once per mesh template and shared by every entity cloned from it, across +/// loads of the document. +@available(iOS 18.0, macOS 15.0, visionOS 2.0, *) +@MainActor +final class GLTFMeshSource { + let geometry: GLTFMeshGeometry + let context: GLTFDeformationContext + let descriptor: LowLevelMesh.Descriptor + + let basePositions: MTLBuffer + let baseNormals: MTLBuffer? + let baseTangents: MTLBuffer? + let baseBitangents: MTLBuffer? + + struct Skin { + let joints: MTLBuffer + let weights: MTLBuffer + /// The joints in the order the influences index them, parents first. + let skeleton: MeshResource.Skeleton + } + + let skin: Skin? + + /// Whether anything moves the vertices: a static mesh is written once and never + /// dispatched. + var isDeformable: Bool { skin != nil || geometry.hasBlendShapes } + + /// The layout index of the buffer holding the texture coordinates, when the mesh has any. + static let texcoordBufferIndex = 1 + + init(geometry: GLTFMeshGeometry, skeleton: MeshResource.Skeleton?, context: GLTFDeformationContext) throws { + self.geometry = geometry + self.context = context + guard let basePositions = context.makePackedBuffer(geometry.positions) else { + throw VRMError._dataInconsistent("a mesh with no vertices cannot be drawn") + } + self.basePositions = basePositions + baseNormals = context.makePackedBuffer(geometry.normals) + baseTangents = context.makePackedBuffer(geometry.tangents) + baseBitangents = context.makePackedBuffer(geometry.bitangents) + if let skeleton, geometry.isSkinned, + let joints = context.device.makeBuffer(geometry.jointIndices), + let weights = context.device.makeBuffer(geometry.jointWeights) { + skin = Skin(joints: joints, weights: weights, skeleton: skeleton) + } else { + skin = nil + } + + var attributes: [LowLevelMesh.Attribute] = [ + .init(semantic: .position, format: .float3, layoutIndex: 0, offset: GLTFDeformedVertex.positionOffset), + ] + if !geometry.normals.isEmpty { + attributes.append(.init(semantic: .normal, format: .float3, layoutIndex: 0, offset: GLTFDeformedVertex.normalOffset)) + } + if !geometry.tangents.isEmpty { + attributes.append(.init(semantic: .tangent, format: .float3, layoutIndex: 0, offset: GLTFDeformedVertex.tangentOffset)) + attributes.append(.init(semantic: .bitangent, format: .float3, layoutIndex: 0, offset: GLTFDeformedVertex.bitangentOffset)) + } + var layouts = [LowLevelMesh.Layout(bufferIndex: 0, bufferStride: GLTFDeformedVertex.stride)] + if !geometry.texcoords.isEmpty { + attributes.append(.init(semantic: .uv0, format: .float2, layoutIndex: Self.texcoordBufferIndex, offset: 0)) + layouts.append(LowLevelMesh.Layout(bufferIndex: Self.texcoordBufferIndex, + bufferStride: MemoryLayout>.stride)) + } + descriptor = LowLevelMesh.Descriptor(vertexCapacity: geometry.vertexCount, + vertexAttributes: attributes, + vertexLayouts: layouts, + indexCapacity: geometry.indices.count, + indexType: .uint32) + } +} +#endif diff --git a/Sources/VRMRealityKit/Extensions/MTLDevice+.swift b/Sources/VRMRealityKit/Extensions/MTLDevice+.swift new file mode 100644 index 00000000..7dea6e10 --- /dev/null +++ b/Sources/VRMRealityKit/Extensions/MTLDevice+.swift @@ -0,0 +1,13 @@ +#if canImport(RealityKit) +import Metal + +extension MTLDevice { + /// A shared-storage buffer holding `values`, or nil for an empty array. + func makeBuffer(_ values: [Element]) -> MTLBuffer? { + guard !values.isEmpty else { return nil } + return values.withUnsafeBytes { bytes in + makeBuffer(bytes: bytes.baseAddress!, length: bytes.count, options: .storageModeShared) + } + } +} +#endif diff --git a/Sources/VRMRealityKit/GLTFAnimation/GLTFAnimationRuntime.swift b/Sources/VRMRealityKit/GLTFAnimation/GLTFAnimationRuntime.swift index 8f14cac8..a0166064 100644 --- a/Sources/VRMRealityKit/GLTFAnimation/GLTFAnimationRuntime.swift +++ b/Sources/VRMRealityKit/GLTFAnimation/GLTFAnimationRuntime.swift @@ -246,7 +246,7 @@ final class GLTFAnimationRuntime: GLTFAnimationApplying { for binding in weightBindings { let weights = binding.track.value(at: time) for modelEntity in binding.modelEntities { - modelEntity.applyMorphWeights(weights) + modelEntity.deformedMesh?.setBlendShapeWeights(weights) } } } diff --git a/Sources/VRMRealityKit/GLTFAnimation/GLTFEntity+Animation.swift b/Sources/VRMRealityKit/GLTFAnimation/GLTFEntity+Animation.swift index 2d243ffd..6c0d4112 100644 --- a/Sources/VRMRealityKit/GLTFAnimation/GLTFEntity+Animation.swift +++ b/Sources/VRMRealityKit/GLTFAnimation/GLTFEntity+Animation.swift @@ -196,9 +196,8 @@ extension GLTFEntity { components.set(GLTFAnimationPlaybackComponent()) var movedNodes: [Entity] = [] controller.apply(movedNodes: &movedNodes) - if !movedNodes.isEmpty { - flushSkinPose() - } + invalidateSkinPose(for: movedNodes) + flushDeformation() return controller } @@ -222,13 +221,11 @@ extension GLTFEntity { } // A paused or held pose leaves the skeleton where the last solve put it, and a // looping animation that rests moves only the joints something else has taken. - if !movedAnimationNodes.isEmpty { - invalidateSkinPose(for: movedAnimationNodes) - // A model driving its own per-frame update solves the pose at the end of it, - // after this frame's constraints and spring bones. - if !refreshesSkinningPerFrame { - flushSkinPoseIfNeeded() - } + invalidateSkinPose(for: movedAnimationNodes) + // A model driving its own per-frame update solves the pose and submits the + // deformation at the end of it, after this frame's constraints and spring bones. + if !refreshesSkinningPerFrame { + flushDeformation() } pruneCompletedAnimations() } @@ -243,9 +240,8 @@ extension GLTFEntity { for outranking in activeAnimationControllers[first...] { outranking.apply(movedNodes: &movedNodes) } - if !movedNodes.isEmpty { - flushSkinPose() - } + invalidateSkinPose(for: movedNodes) + flushDeformation() } func pruneCompletedAnimations() { diff --git a/Sources/VRMRealityKit/GLTFEntityLoader.swift b/Sources/VRMRealityKit/GLTFEntityLoader.swift index 86a7ff2d..0700db73 100644 --- a/Sources/VRMRealityKit/GLTFEntityLoader.swift +++ b/Sources/VRMRealityKit/GLTFEntityLoader.swift @@ -90,7 +90,8 @@ public final class GLTFEntityLoader { try await queue.run { let root = GLTFEntity(document: document, sceneIndex: index) _ = try await resources.build(into: root) - root.flushSkinPose() + root.invalidateSkinPose() + root.flushDeformation() return root } } @@ -118,7 +119,6 @@ extension GLTFResourceCache { meshes \(timings.meshResources, privacy: .public) x\(timings.meshResourceCount, privacy: .public), \ late textures \(timings.textureResources, privacy: .public) x\(timings.textureResourceCount, privacy: .public)) """) - builder.prewarmFirstPersonMeshesInBackground() return (builder, built) } } diff --git a/Sources/VRMRealityKit/Geometry/GLTFGeometryDecoder.swift b/Sources/VRMRealityKit/Geometry/GLTFGeometryDecoder.swift index b73ea6bc..d4a31e2f 100644 --- a/Sources/VRMRealityKit/Geometry/GLTFGeometryDecoder.swift +++ b/Sources/VRMRealityKit/Geometry/GLTFGeometryDecoder.swift @@ -31,6 +31,37 @@ struct GLTFPrimitiveGeometry: Sendable { var warnings: [GLTFGeometryWarning] = [] var isSkinned: Bool { !joints.isEmpty } + + /// Removes the vertices no triangle references and renumbers the indices, keeping + /// the vertices in first-use order. Orphan vertices are common: a VRM 0.x export + /// shares one vertex buffer across the primitives of a mesh, so each primitive + /// decodes every vertex of the mesh and draws its own slice of them (the + /// `AliciaSolid` fixture decodes 80,169 vertices that way for 21,995 drawn), and + /// merged or baked exports leave hidden parts behind as vertices no triangle uses. + /// RealityKit skins and morphs every vertex in the buffer whether or not a + /// triangle draws it. Call it before the tangent frame is built. + mutating func dropUnreferencedVertices() { + var renumbered = [UInt32](repeating: .max, count: positions.count) + var kept: [Int] = [] + kept.reserveCapacity(positions.count) + for index in indices where renumbered[Int(index)] == .max { + renumbered[Int(index)] = UInt32(kept.count) + kept.append(Int(index)) + } + guard kept.count < positions.count else { return } + func gathered(_ values: [Element]) -> [Element] { + values.isEmpty ? values : kept.map { values[$0] } + } + positions = gathered(positions) + normals = gathered(normals) + tangents = gathered(tangents) + bitangents = gathered(bitangents) + texcoords = gathered(texcoords) + joints = gathered(joints) + weights = gathered(weights) + blendShapeOffsets = blendShapeOffsets.map(gathered) + indices = indices.map { renumbered[Int($0)] } + } } /// Turns a glTF primitive into the vertex buffers a renderer draws. @@ -163,6 +194,7 @@ struct GLTFGeometryDecoder: Sendable { geometry.indices = indexData geometry.blendShapeOffsets = targetOffsets geometry.normals = normals ?? Self.flatNormals(positions: geometry.positions) + geometry.dropUnreferencedVertices() let frame = tangentFrame(rawTangents: rawTangents, positions: geometry.positions, diff --git a/Sources/VRMRealityKit/Geometry/GLTFSceneBuilder+Geometry.swift b/Sources/VRMRealityKit/Geometry/GLTFSceneBuilder+Geometry.swift index ef7f6ec5..ec2b3ae3 100644 --- a/Sources/VRMRealityKit/Geometry/GLTFSceneBuilder+Geometry.swift +++ b/Sources/VRMRealityKit/Geometry/GLTFSceneBuilder+Geometry.swift @@ -74,7 +74,7 @@ extension GLTFSceneBuilder { /// A skinned primitive's vertex influences in the skin's joint order, four per vertex /// with the weights renormalized. - private nonisolated static func jointInfluences(of geometry: GLTFPrimitiveGeometry) throws -> [MeshJointInfluence] { + private nonisolated static func jointInfluences(of geometry: GLTFPrimitiveGeometry) throws -> GLTFJointInfluences { let joints = geometry.joints let weights = geometry.weights let remap = geometry.jointIndexRemap @@ -85,35 +85,30 @@ extension GLTFSceneBuilder { throw VRMError._dataInconsistent("joint influence count \(joints.count) does not match vertex count \(geometry.positions.count)") } - var influences: [MeshJointInfluence] = [] - influences.reserveCapacity(joints.count * 4) - func remapped(_ jointIndex: UInt32) throws -> Int { + var remappedJoints: [SIMD4] = [] + var normalizedWeights: [SIMD4] = [] + remappedJoints.reserveCapacity(joints.count) + normalizedWeights.reserveCapacity(joints.count) + func remapped(_ jointIndex: UInt32) throws -> UInt32 { 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)] + return UInt32(remap[Int(jointIndex)]) } for i in 0.. 0 { - w0 /= sum - w1 /= sum - w2 /= sum - w3 /= sum + weight /= 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)) + remappedJoints.append(SIMD4(try remapped(joint.x), try remapped(joint.y), + try remapped(joint.z), try remapped(joint.w))) + normalizedWeights.append(weight) } - return influences + return GLTFJointInfluences(joints: remappedJoints, weights: normalizedWeights) } private func geometryDecoder() throws -> GLTFGeometryDecoder { diff --git a/Sources/VRMRealityKit/Loading/GLTFPreparedResources.swift b/Sources/VRMRealityKit/Loading/GLTFPreparedResources.swift index 69c7d473..62630424 100644 --- a/Sources/VRMRealityKit/Loading/GLTFPreparedResources.swift +++ b/Sources/VRMRealityKit/Loading/GLTFPreparedResources.swift @@ -20,8 +20,15 @@ struct GLTFPreparedPrimitive: @unchecked Sendable { /// with the other cut derives its own mask. let cutsHead: Bool let firstPersonMask: FirstPersonPrimitiveMask - /// Four per vertex in the skin's joint order, or nil for an unskinned primitive. - let jointInfluences: [MeshJointInfluence]? + /// Nil for an unskinned primitive. + let jointInfluences: GLTFJointInfluences? +} + +/// A skinned primitive's influences: four joints per vertex in the skin's joint order, +/// with the weights renormalized to sum to one. +struct GLTFJointInfluences: Sendable { + let joints: [SIMD4] + let weights: [SIMD4] } /// What the prepare passes decoded off the actor the entity graph is built on, for the diff --git a/Sources/VRMRealityKit/Loading/GLTFResourceCache.swift b/Sources/VRMRealityKit/Loading/GLTFResourceCache.swift index 9b903582..04b9902a 100644 --- a/Sources/VRMRealityKit/Loading/GLTFResourceCache.swift +++ b/Sources/VRMRealityKit/Loading/GLTFResourceCache.swift @@ -20,7 +20,7 @@ final class GLTFResourceCache { /// See ``GLTFEntityLoader/maxTextureDimension``. let maxTextureDimension: Int? - static let gltfLogger = Logger(subsystem: "dev.tattn.VRMKit", category: "glTF") + static let gltfLogger = Logger(subsystem: "com.github.tattn.VRMKit", category: "glTF") init(document: GLTFDocument, shaders: [any GLTFMaterialShader], diff --git a/Sources/VRMRealityKit/Loading/GLTFSceneBuilder.swift b/Sources/VRMRealityKit/Loading/GLTFSceneBuilder.swift index 38796fe3..6dcbe26f 100644 --- a/Sources/VRMRealityKit/Loading/GLTFSceneBuilder.swift +++ b/Sources/VRMRealityKit/Loading/GLTFSceneBuilder.swift @@ -49,10 +49,6 @@ final class GLTFSceneBuilder { } private(set) var timings = Timings() - /// The merged meshes a first-person camera cuts, whose cut variant is generated - /// after the load rather than during it. - private(set) var firstPersonCatalogs: [GLTFMergedMeshCatalog] = [] - /// The scene's entities, by glTF node index. private(set) var nodes: [Entity?] @@ -116,23 +112,6 @@ final class GLTFSceneBuilder { timings.prepareTextures = ContinuousClock.now - started } - /// Generates the first-person variant of every cut mesh once the load has returned, - /// off the actor, so the first camera switch finds it ready without the load paying - /// for a mesh most sessions never draw. A switch landing first generates it on the spot. - func prewarmFirstPersonMeshesInBackground() { - let catalogs = firstPersonCatalogs - guard !catalogs.isEmpty else { return } - Task(priority: .utility) { @MainActor in - for catalog in catalogs { - do { - try await catalog.prewarmFirstPersonVariant() - } catch { - GLTFResourceCache.gltfLogger.error("Failed to generate a first-person mesh: \(String(describing: error), privacy: .public)") - } - } - } - } - func build() throws -> BuiltScene { let gltfScene = try gltf.load(\.scenes, at: sceneIndex) for node in gltfScene.nodes ?? [] { @@ -259,8 +238,8 @@ final class GLTFSceneBuilder { 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) + for modelEntity in modelEntities { + modelEntity.deformedMesh?.setBlendShapeWeights(weights) } } @@ -299,11 +278,12 @@ final class GLTFSceneBuilder { /// The entity one node renders a glTF mesh through, one per call. func mesh(withMeshIndex index: Int, skinIndex: Int?, nodeIndex: Int) throws -> Entity { let headJoints = try headJoints(ofNodeAt: nodeIndex, meshIndex: index, skinIndex: skinIndex) - // Built once per template and cloned per node: the clones share the `MeshResource` - // and carry their own pose and weights. + // Built once per template and cloned per node: the clones share the vertex data + // and each deform a mesh of their own with their own pose and weights. let meshEntity = try meshTemplate(withMeshIndex: index, skinIndex: skinIndex, headJoints: headJoints).clone(recursive: true) + try instantiateDeformedMeshes(in: meshEntity) try registerSkinBindings(in: meshEntity) registerMaterialBindings(in: meshEntity) sceneMeshes[index, default: []].append(.init(nodeIndex: nodeIndex, entity: meshEntity)) @@ -343,45 +323,79 @@ final class GLTFSceneBuilder { guard !primitives.isEmpty else { return meshEntity } // A skinned mesh's primitives all bind the one skin the node names, so the - // merged parts share its skeleton. + // merged slots share its skeleton. let skeleton = primitives.contains(where: \.isSkinned) ? try skinIndex.map { try skin(withSkinIndex: $0).skeleton } : nil + // One vertex array for the whole mesh: every entity drawing it (its render + // passes and its render-queue groups) deforms from the same source and draws + // its own slots of it, so the vertices are skinned once per frame, not once + // per entity. + let started = ContinuousClock.now + let context = try GLTFDeformationContext.shared() + let geometry = try GLTFMeshGeometry(primitives: primitives.map(\.prepared), + isSkinned: skeleton != nil, + blendShapeTargetCount: try morphTargetCount(ofMeshAt: index), + context: context) + let source = try GLTFMeshSource(geometry: geometry, skeleton: skeleton, context: context) + timings.meshResources += ContinuousClock.now - started + timings.meshResourceCount += 1 // Each distinct pass of the mesh's materials becomes one sibling model entity - // bundling every primitive whose material draws it, in first-appearance order. + // drawing every slot whose material has the pass, in first-appearance order. var passNames: [String] = [] - var passSlots: [String: [(primitive: MergedPrimitive, pass: GLTFShadedMaterial.Pass)]] = [:] for primitive in primitives { - for pass in primitive.shaded.additionalPasses { - if passSlots[pass.name] == nil { passNames.append(pass.name) } - passSlots[pass.name, default: []].append((primitive, pass)) + for pass in primitive.shaded.additionalPasses where !passNames.contains(pass.name) { + passNames.append(pass.name) } } for name in passNames { - guard let slots = passSlots[name] else { continue } - let passEntity = try makeMergedModelEntity( - name: "\(meshEntity.name)_\(name)", - modelID: "mesh_\(index)_\(name)", - primitives: slots.map(\.primitive), - materials: slots.map(\.pass.material), - initiallyVisibleSlots: slots.map(\.pass.isInitiallyEnabled), - skeleton: skeleton, - skinIndex: skinIndex) + var drawing: [SlotDrawing] = [] + var passes: [GLTFShadedMaterial.Pass] = [] + for (slot, primitive) in primitives.enumerated() { + guard let pass = primitive.shaded.additionalPasses.first(where: { $0.name == name }) else { continue } + drawing.append(SlotDrawing(slot: slot, material: pass.material, isInitiallyVisible: pass.isInitiallyEnabled)) + passes.append(pass) + } + let passEntity = try makeModelEntity(name: "\(meshEntity.name)_\(name)", + source: source, + primitives: primitives, + drawing: drawing, + skinIndex: skinIndex) passEntity.components.set(GLTFMaterialPassComponent(name: name)) - grantBoundsBudget(to: passEntity, passes: slots.map(\.pass)) + grantBoundsBudget(to: passEntity, passes: passes) // The passes go on before the main model entity, keeping their draw order. meshEntity.addChild(passEntity) } - meshEntity.addChild(try makeMergedModelEntity( - name: "\(meshEntity.name)_model", - modelID: "mesh_\(index)", - primitives: primitives, - materials: primitives.map(\.shaded.material), - initiallyVisibleSlots: primitives.map { _ in true }, - skeleton: skeleton, - skinIndex: skinIndex)) + // RealityKit orders the blended parts of one model entity back to front by + // their bounds, which flips parts an author stacked on purpose (an eye + // highlight a few millimetres over its iris) once the view tilts. Parts whose + // materials ask for different render queues draw from entities of their own, + // in a sort group that keeps the queue order. + let renderQueues = Set(primitives.compactMap(\.shaded.renderQueue)).sorted() + var groups: [(queue: Int?, slots: [Int])] = [(nil, Array(primitives.indices))] + var sortGroup: ModelSortGroup? + if renderQueues.count > 1 { + groups = (renderQueues.map(Optional.some) + [nil]).map { queue in + (queue, primitives.indices.filter { primitives[$0].shaded.renderQueue == queue }) + } + sortGroup = ModelSortGroup() + } + for (queue, slots) in groups where !slots.isEmpty { + let modelEntity = try makeModelEntity( + name: "\(meshEntity.name)_model\(queue.map { "_\($0)" } ?? "")", + source: source, + primitives: primitives, + drawing: slots.map { + SlotDrawing(slot: $0, material: primitives[$0].shaded.material, isInitiallyVisible: true) + }, + skinIndex: skinIndex) + if let queue, let sortGroup { + modelEntity.components.set(ModelSortGroupComponent(group: sortGroup, order: Int32(queue))) + } + meshEntity.addChild(modelEntity) + } return meshEntity } @@ -400,15 +414,12 @@ final class GLTFSceneBuilder { } /// One primitive of a mesh, decoded and shaded, ready to merge into the mesh's - /// model entities as one part each. + /// model entities as one slot each. private struct MergedPrimitive { let materialIndex: Int? let shaded: GLTFShadedMaterial - /// The part's `materialIndex` is set to its slot by the entity taking it. - let part: MeshResource.Part - /// What a first-person camera draws of the part. - let firstPersonMask: FirstPersonPrimitiveMask - let hasBlendShapes: Bool + let prepared: GLTFPreparedPrimitive + /// Whether the node draws the primitive through a skin. let isSkinned: Bool } @@ -424,76 +435,61 @@ final class GLTFSceneBuilder { """) return nil } - let geometry = prepared.geometry - for warning in geometry.warnings { + for warning in prepared.geometry.warnings { resources.logOnce(warning.key, warning.message) } let shaded = try primitive.material.map { try shadedMaterial(withMaterialIndex: $0) } ?? GLTFShadedMaterial(material: Self.defaultMaterial()) - var jointInfluences: MeshResource.JointInfluences? - var skeletonID: String? - if let skinIndex, let influences = prepared.jointInfluences { - jointInfluences = MeshResource.JointInfluences(influences: MeshBuffer(influences), influencesPerVertex: 4) - skeletonID = try skin(withSkinIndex: skinIndex).skeleton.id - } return MergedPrimitive(materialIndex: primitive.material, shaded: shaded, - part: makePart(id: "primitive_\(primitiveIndex)", - geometry: geometry, - skeletonID: skeletonID, - jointInfluences: jointInfluences), - firstPersonMask: prepared.firstPersonMask, - hasBlendShapes: !geometry.blendShapeOffsets.isEmpty, - isSkinned: geometry.isSkinned) - } - - /// One model entity drawing `primitives` as the parts of one mesh, each - /// addressing its material by slot. - private func makeMergedModelEntity(name: String, - modelID: String, - primitives: [MergedPrimitive], - materials: [Material], - initiallyVisibleSlots: [Bool], - skeleton: MeshResource.Skeleton?, - skinIndex: Int?) throws -> ModelEntity { - var parts: [MeshResource.Part] = [] - parts.reserveCapacity(primitives.count) - for (slot, primitive) in primitives.enumerated() { - var part = primitive.part - part.materialIndex = slot - parts.append(part) - } - let mesh = try meshResource(modelID: modelID, parts: parts, skeleton: skeleton) - - let entity = ModelEntity(mesh: mesh, materials: materials) + prepared: prepared, + isSkinned: skinIndex != nil && prepared.jointInfluences != nil) + } + + /// How one model entity draws one slot of its mesh. + private struct SlotDrawing { + let slot: Int + let material: Material + let isInitiallyVisible: Bool + } + + /// One model entity drawing the slots of `source` that `drawing` names, each + /// addressing its material by its position in `drawing`. + private func makeModelEntity(name: String, + source: GLTFMeshSource, + primitives: [MergedPrimitive], + drawing: [SlotDrawing], + skinIndex: Int?) throws -> ModelEntity { + let deformedMesh = try GLTFDeformedMesh(source: source, drawnSlots: drawing.map(\.slot)) + let entity = ModelEntity(mesh: deformedMesh.meshResource, materials: drawing.map(\.material)) entity.name = name - entity.components.set(GLTFMaterialSlotsComponent(materialIndices: primitives.map(\.materialIndex))) - if primitives.contains(where: \.hasBlendShapes) { - entity.components.set(BlendShapeWeightsComponent(weightsMapping: BlendShapeWeightsMapping(meshResource: mesh))) - } - if let skinIndex, skeleton != nil { + entity.components.set(GLTFMaterialSlotsComponent(materialIndices: drawing.map { primitives[$0.slot].materialIndex })) + if let skinIndex, source.skin != nil { // The binding is registered per clone, once its joints exist. entity.components.set(GLTFSkinIndexComponent(skinIndex: skinIndex)) + // The parts carry the rest bounds, and a posed joint can carry its vertices + // outside them; the mesh's own radius covers a pose of the body. + if var component = entity.components[ModelComponent.self] { + component.boundsMargin = source.geometry.bounds.boundingRadius + entity.components.set(component) + } } + entity.components.set(GLTFMergedMeshComponent(initiallyVisibleSlots: drawing.map(\.isInitiallyVisible))) + entity.setDeformedMesh(deformedMesh) + return entity + } - let catalog = GLTFMergedMeshCatalog( - fullMesh: mesh, - slots: zip(primitives, initiallyVisibleSlots).map { primitive, isVisible in - .init(partID: primitive.part.id, - firstPersonMask: primitive.firstPersonMask, - isInitiallyVisible: isVisible) - }) - entity.components.set(GLTFMergedMeshComponent(catalog: catalog, - visibleSlots: initiallyVisibleSlots)) - if initiallyVisibleSlots.contains(false) { - entity.applyMergedMesh() - } - if catalog.hasFirstPersonCut { - firstPersonCatalogs.append(catalog) + /// Gives every model entity cloned off a template a deformed mesh of its own, drawn + /// from the template's vertex data, so each node and each load poses its own. + private func instantiateDeformedMeshes(in meshRoot: Entity) throws { + for modelEntity in meshRoot.modelEntitiesInHierarchy { + guard let template = modelEntity.deformedMesh else { continue } + let own = try GLTFDeformedMesh(source: template.source, drawnSlots: template.drawnSlots) + modelEntity.setDeformedMesh(own) + root.registerDeformedMesh(own) } - return entity } /// Widens the bounding box RealityKit culls `passEntity` by, so a geometry modifier @@ -502,11 +498,12 @@ final class GLTFSceneBuilder { private func grantBoundsBudget(to passEntity: ModelEntity, passes: [GLTFShadedMaterial.Pass]) { guard passes.contains(where: { $0.applyBoundsBudget != nil }), + let geometry = passEntity.gltfMeshGeometry, var component = passEntity.components[ModelComponent.self] else { return } // The budget covers the whole merged mesh, hidden slots included, so a slot // shown later still fits it. - let budget = (passEntity.mergedMesh?.catalog.fullMesh ?? component.mesh).bounds.boundingRadius - component.boundsMargin = budget + let budget = geometry.bounds.boundingRadius + component.boundsMargin = max(component.boundsMargin, budget) for (slot, pass) in passes.enumerated() { guard let applyBudget = pass.applyBoundsBudget, component.materials.indices.contains(slot) else { continue } @@ -579,7 +576,8 @@ final class GLTFSceneBuilder { for shader in resources.shaders { if let shaded = try shader.makeMaterial(for: context) { return shaded } } - return GLTFShadedMaterial(material: try standardMaterial(for: context)) + return GLTFShadedMaterial(material: try standardMaterial(for: context), + renderQueue: try context.resolvedRenderQueue()) } func makeMaterialShaderContext(withMaterialIndex index: Int) throws -> GLTFMaterialShaderContext { @@ -610,8 +608,7 @@ final class GLTFSceneBuilder { 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 resolvedAlphaMode = context.resolvedAlphaMode let tint = gltfMaterial.pbrMetallicRoughness .map { VRMColor(simd: $0.baseColorFactor) } ?? .white @@ -1300,63 +1297,6 @@ final class GLTFSceneBuilder { return (parentIndices, order, remap) } - private func makePart(id: String, - geometry: GLTFPrimitiveGeometry, - skeletonID: String?, - jointInfluences: MeshResource.JointInfluences?) -> MeshResource.Part { - var part = MeshResource.Part(id: id, materialIndex: 0) - part.positions = MeshBuffer(geometry.positions) - if !geometry.normals.isEmpty { - part.normals = MeshBuffer(geometry.normals) - } - if !geometry.tangents.isEmpty { - part.tangents = MeshBuffer(geometry.tangents) - part.bitangents = MeshBuffer(geometry.bitangents) - } - if !geometry.texcoords.isEmpty { - part.textureCoordinates = MeshBuffer(geometry.texcoords) - } - part.triangleIndices = MeshBuffer(geometry.indices) - if !geometry.blendShapeOffsets.isEmpty { - for (targetIndex, offsets) in geometry.blendShapeOffsets.enumerated() { - let name = "blendShape_\(targetIndex)" - part.setBlendShapeOffsets(named: name, buffer: MeshBuffer(offsets)) - } - _ = part.blendShapeNames - } - if let skeletonID, let jointInfluences { - part.skeletonID = skeletonID - part.jointInfluences = jointInfluences - } - return part - } - - private func meshResource(modelID: String, - parts: [MeshResource.Part], - skeleton: MeshResource.Skeleton?) throws -> MeshResource { - var models = MeshModelCollection() - _ = models.insert(MeshResource.Model(id: modelID, parts: parts)) - - 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 - } - - let started = ContinuousClock.now - defer { - timings.meshResources += ContinuousClock.now - started - timings.meshResourceCount += 1 - } - return try MeshResource.generate(from: contents) - } - /// Binds the skinned models of a freshly cloned mesh to this scene's joints. private func registerSkinBindings(in meshRoot: Entity) throws { for modelEntity in meshRoot.modelEntitiesInHierarchy { diff --git a/Sources/VRMRealityKit/MToon/MToonMaterialParameters.swift b/Sources/VRMRealityKit/MToon/MToonMaterialParameters.swift index 7458f310..2f175ce3 100644 --- a/Sources/VRMRealityKit/MToon/MToonMaterialParameters.swift +++ b/Sources/VRMRealityKit/MToon/MToonMaterialParameters.swift @@ -28,6 +28,9 @@ enum MToonParameterRow: Int, CaseIterable { case uvTransformRotation case normalParameters case lightDirection + case rimLightColor + case rimLightDirection + case rimLightShape } @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) @@ -60,6 +63,12 @@ struct MToonMaterialParameters { var samplers = Array(repeating: MToonMaterialParameters.defaultSampler, count: MToonMaterialParameters.samplerRowCount) var lightDirection: SIMD3 = MToonMaterialParameters.defaultLightDirection + /// rgb is the rim light's color and w its blend (``MToonRimLight/blend``). Black, + /// the default, is the runtime rim light off; the shader skips the term on it. + var rimLightColor = SIMD4(0, 0, 0, 0) + var rimLightDirection = SIMD4(0, 0, 1, 0) + /// x width, y softness, z wrap, w view bend (``MToonRimLight``). + var rimLightShape = SIMD4(0, 0, 0, 0) init(_ mtoon: MToonMaterialDescriptor) { baseColor = mtoon.baseColorFactor @@ -152,6 +161,21 @@ struct MToonMaterialParameters { outlineParams.y = mode.mtoonRawValue } + /// Nil turns the runtime rim light off. + mutating func setRimLight(_ rim: MToonRimLight?) { + guard let rim else { + rimLightColor = SIMD4(0, 0, 0, 0) + rimLightDirection = SIMD4(0, 0, 1, 0) + rimLightShape = SIMD4(0, 0, 0, 0) + return + } + rimLightColor = SIMD4(rim.color, simd_clamp(rim.blend, 0, 1)) + rimLightDirection = SIMD4(rim.normalizedDirection, 0) + rimLightShape = simd_clamp(SIMD4(rim.width, rim.softness, rim.wrap, rim.viewBend), + SIMD4(repeating: 0), + SIMD4(repeating: 1)) + } + mutating func setTextureTransform(scale: SIMD2, offset: SIMD2, rotation: Float) { @@ -193,6 +217,9 @@ struct MToonMaterialParameters { case .uvTransformRotation: return uvTransformRotation case .normalParameters: return normalParameters case .lightDirection: return SIMD4(lightDirection, 0) + case .rimLightColor: return rimLightColor + case .rimLightDirection: return rimLightDirection + case .rimLightShape: return rimLightShape } } @@ -242,7 +269,7 @@ final class MToonParameterTexture { /// The last write's command buffer, kept so a caller about to render on /// another queue can wait for the rows to reach the GPU. - private var lastWrite: MTLCommandBuffer? + private(set) var lastWrite: MTLCommandBuffer? /// Blocks until every committed write has executed. func waitForWrites() { @@ -254,6 +281,51 @@ final class MToonParameterTexture { /// all main-actor. private static var sharedCommandQueue: MTLCommandQueue? + /// The command buffer every write inside ``batchWrites(_:)`` encodes into, opened by + /// the first write of the batch. Nil outside a batch, and nil inside one until a + /// texture writes. + private static var openBatch: WriteBatch? + private static var isBatching = false + + private struct WriteBatch { + let commandBuffer: MTLCommandBuffer + let blit: MTLBlitCommandEncoder + let device: MTLDevice + } + + /// Runs `body` with every parameter texture write it makes encoded into one command + /// buffer, committed when `body` returns. A flush that relights a model touches every + /// material, and committing a command buffer per material costs more than the rows + /// themselves. A nested call joins the batch already open. + static func batchWrites(_ body: () throws -> T) rethrows -> T { + guard !isBatching else { return try body() } + isBatching = true + defer { + isBatching = false + if let batch = openBatch { + openBatch = nil + batch.blit.endEncoding() + batch.commandBuffer.commit() + } + } + return try body() + } + + /// The batch's blit encoder when a batch is open and shares this texture's device, + /// opening the batch's command buffer on the first call. A texture on another device + /// (a multi-GPU machine) writes on its own. + private func batchBlit() -> WriteBatch? { + guard Self.isBatching else { return nil } + if let batch = Self.openBatch { + return batch.device === device ? batch : nil + } + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let blit = commandBuffer.makeBlitCommandEncoder() else { return nil } + let batch = WriteBatch(commandBuffer: commandBuffer, blit: blit, device: device) + Self.openBatch = batch + return batch + } + init(rows: [SIMD4]) throws { var descriptor = LowLevelTexture.Descriptor() descriptor.textureType = .type2D @@ -277,32 +349,39 @@ final class MToonParameterTexture { precondition(rows.count == MToonMaterialParameters.textureRowCount) // A buffer of its own per write: the GPU reads it whenever it runs the // blit, and a few hundred bytes cost less than synchronizing a shared one. - guard let buffer = rows.withUnsafeBytes({ - device.makeBuffer(bytes: $0.baseAddress!, length: $0.count, options: .storageModeShared) - }) else { + guard let buffer = device.makeBuffer(rows) else { throw VRMError._dataInconsistent("failed to allocate the MToon parameter staging buffer") } - guard let commandBuffer = commandQueue.makeCommandBuffer() else { - throw VRMError._dataInconsistent("failed to make a command buffer for the MToon parameter texture") - } - let destination = texture.replace(using: commandBuffer) - guard let blit = commandBuffer.makeBlitCommandEncoder() else { - throw VRMError._dataInconsistent("failed to encode the MToon parameter texture write") + if let batch = batchBlit() { + encodeCopy(of: buffer, rowCount: rows.count, using: batch.blit, on: batch.commandBuffer) + lastWrite = batch.commandBuffer + } else { + guard let commandBuffer = commandQueue.makeCommandBuffer() else { + throw VRMError._dataInconsistent("failed to make a command buffer for the MToon parameter texture") + } + guard let blit = commandBuffer.makeBlitCommandEncoder() else { + throw VRMError._dataInconsistent("failed to encode the MToon parameter texture write") + } + encodeCopy(of: buffer, rowCount: rows.count, using: blit, on: commandBuffer) + blit.endEncoding() + commandBuffer.commit() + lastWrite = commandBuffer } + writeCount += 1 + } + + private func encodeCopy(of buffer: MTLBuffer, rowCount: Int, + using blit: MTLBlitCommandEncoder, on commandBuffer: MTLCommandBuffer) { blit.copy(from: buffer, sourceOffset: 0, sourceBytesPerRow: Self.bytesPerRow, sourceBytesPerImage: Self.bytesPerRow, - sourceSize: MTLSize(width: rows.count, height: 1, depth: 1), - to: destination, + sourceSize: MTLSize(width: rowCount, height: 1, depth: 1), + to: texture.replace(using: commandBuffer), destinationSlice: 0, destinationLevel: 0, destinationOrigin: MTLOrigin(x: 0, y: 0, z: 0)) - blit.endEncoding() - commandBuffer.commit() - lastWrite = commandBuffer - writeCount += 1 } private static func commandQueue(for device: MTLDevice) throws -> MTLCommandQueue { diff --git a/Sources/VRMRealityKit/MToon/MToonRimLight.swift b/Sources/VRMRealityKit/MToon/MToonRimLight.swift new file mode 100644 index 00000000..a61a31fd --- /dev/null +++ b/Sources/VRMRealityKit/MToon/MToonRimLight.swift @@ -0,0 +1,53 @@ +import simd + +/// A rim light the runtime adds on top of a model's authored MToon: light from +/// `direction` that lines the silhouette facing it, like a backlight. +/// +/// MToon's own parametric rim depends on the view alone, so it cannot say which +/// side the light comes from. This term is independent of it; the rim-multiply +/// texture masks both. The shape follows the backlight of anime-style shaders: a +/// band of set width along the silhouette rather than a Fresnel gradient. +public struct MToonRimLight: Sendable, Equatable { + /// Linear RGB. Values above 1 are allowed, for a renderer with bloom. + public var color: SIMD3 + /// From the surface toward the light, in the space the model's light + /// direction is given in. A backlight has it pointing away from the viewer. + public var direction: SIMD3 + /// How far in from the silhouette the band reaches, 0 to 1. 1 covers every + /// surface the light reaches; 0.3 is a thin outline. + public var width: Float + /// How soft the band's inner edge is, 0 to 1. 0 is a hard anime edge. + public var softness: Float + /// How far around the shadowed side the band continues, 0 to 1. At 0 it stops + /// where the surface turns away from the light; at 1 it lines the whole + /// silhouette regardless. + public var wrap: Float + /// How much the light is bent away from the viewer, 0 to 1. A backlight + /// aimed at the model still lines the near edges with this above 0. + public var viewBend: Float + /// How the band meets the surface, 0 to 1. At 0 the light adds to the lit + /// color, which a bright surface swallows; at 1 the band is painted in the + /// rim's color over it, the way an illustration draws a rim on white cloth. + public var blend: Float + + public init(color: SIMD3, + direction: SIMD3, + width: Float = 0.3, + softness: Float = 0.1, + wrap: Float = 0.5, + viewBend: Float = 0.5, + blend: Float = 0) { + self.color = color + self.direction = direction + self.width = width + self.softness = softness + self.wrap = wrap + self.viewBend = viewBend + self.blend = blend + } + + var normalizedDirection: SIMD3 { + let length = simd_length(direction) + return length > 0.001 ? direction / length : SIMD3(0, 0, 1) + } +} diff --git a/Sources/VRMRealityKit/MToon/MToonShader.swift b/Sources/VRMRealityKit/MToon/MToonShader.swift index c96e5fbb..ecf35005 100644 --- a/Sources/VRMRealityKit/MToon/MToonShader.swift +++ b/Sources/VRMRealityKit/MToon/MToonShader.swift @@ -18,7 +18,7 @@ import VRMKitRuntime @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) @MainActor public final class MToonShader: GLTFMaterialShader { - static let logger = Logger(subsystem: "dev.tattn.VRMKit", category: "MToon") + static let logger = Logger(subsystem: "com.github.tattn.VRMKit", category: "MToon") static let extensionName = GLTFExtension.materialsMToon.rawValue /// Which materials this shader renders as MToon. @@ -143,9 +143,13 @@ public final class MToonShader: GLTFMaterialShader { // The rows the expression runtime animates are built here; the state factory // only hands every entity graph its own copy. let parameters = state.parameters + let descriptor = state.descriptor var shaded = GLTFShadedMaterial(material: try customMToonMaterial(state, context: context), + renderQueue: context.renderQueue(alphaMode: descriptor.alphaMode, + transparentWithZWrite: descriptor.transparentWithZWrite, + offset: descriptor.renderQueueOffsetNumber), makeAnimatableState: { MToonAnimatableMaterialState(parameters: parameters) }) - let hasOutline = state.descriptor.hasOutline + let hasOutline = descriptor.hasOutline if outlinePass.buildsPass(hasAuthoredOutline: hasOutline) { // A pass created only for runtime outlines draws nothing yet, so it // starts disabled rather than spending a draw call per frame. @@ -163,7 +167,6 @@ public final class MToonShader: GLTFMaterialShader { let library = try MToonShaderLibraryLoader.loadDefault() let textureTransform = try textureTransform(for: context, descriptor: descriptor) let parameters = try parameters(for: descriptor, textureTransform: textureTransform, context: context) - logUnsupportedFeatures(of: descriptor, index: context.materialIndex) return MToonState(descriptor: descriptor, parameters: parameters, parameterTexture: CustomMaterial.Texture(try parameters.textureResource()), @@ -197,12 +200,6 @@ public final class MToonShader: GLTFMaterialShader { } } - private func logUnsupportedFeatures(of 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.") - } - } - private func customMToonMaterial(_ state: MToonState, context: GLTFMaterialShaderContext) throws -> Material { let mtoon = state.descriptor @@ -469,6 +466,13 @@ final class MToonAnimatableMaterialState: VRMAnimatableMaterialState { parameters.ambientColor = SIMD4(ambient, 1) } + /// Returns whether the rows changed. + func setRimLight(_ rim: MToonRimLight?) -> Bool { + let before = (parameters.rimLightColor, parameters.rimLightDirection, parameters.rimLightShape) + parameters.setRimLight(rim) + return before != (parameters.rimLightColor, parameters.rimLightDirection, parameters.rimLightShape) + } + /// Blocks until this material's committed parameter writes reach the GPU, /// for a caller about to render on another queue: the snapshot. func waitForParameterWrites() { diff --git a/Sources/VRMRealityKit/MToon/MToonShaderLibraryLoader.swift b/Sources/VRMRealityKit/MToon/MToonShaderLibraryLoader.swift index a15ba2c7..3a68ce40 100644 --- a/Sources/VRMRealityKit/MToon/MToonShaderLibraryLoader.swift +++ b/Sources/VRMRealityKit/MToon/MToonShaderLibraryLoader.swift @@ -50,7 +50,6 @@ enum MToonShaderLibraryLoader { return try cachedResult.get() } let result = Result { () throws -> MTLLibrary in - // Check the statically-known failure before creating a device. guard resourceName != nil else { throw MToonShaderLibraryLoaderError.unsupportedPlatform } diff --git a/Sources/VRMRealityKit/Materials/GLTFMaterialShader.swift b/Sources/VRMRealityKit/Materials/GLTFMaterialShader.swift index 01899c73..321359ca 100644 --- a/Sources/VRMRealityKit/Materials/GLTFMaterialShader.swift +++ b/Sources/VRMRealityKit/Materials/GLTFMaterialShader.swift @@ -70,6 +70,13 @@ public struct GLTFShadedMaterial { public var material: any Material /// Extra passes, added to the scene before the main model entity. public var additionalPasses: [Pass] + /// Where a blended material draws among the other blended materials of its + /// mesh, on Unity's render-queue scale (lower draws first): what VRM 0.x + /// records per material, or MToon's base queue plus `renderQueueOffsetNumber`. + /// Nil for a material that is not blended, which the depth buffer orders. + /// ``GLTFMaterialShaderContext/renderQueue(alphaMode:transparentWithZWrite:offset:)`` + /// derives it. + public var renderQueue: Int? /// Lets VRM expressions (`materialColorBind` / `textureTransformBind`) drive /// this material the way MToon does. Called once per material per loaded /// entity graph. Anything the state does not claim falls back to mutating @@ -78,9 +85,11 @@ public struct GLTFShadedMaterial { public init(material: any Material, additionalPasses: [Pass] = [], + renderQueue: Int? = nil, makeAnimatableState: (@MainActor () -> any VRMAnimatableMaterialState)? = nil) { self.material = material self.additionalPasses = additionalPasses + self.renderQueue = renderQueue self.makeAnimatableState = makeAnimatableState } } @@ -104,6 +113,34 @@ public struct GLTFMaterialShaderContext { /// below do not cover. public var document: GLTFDocument { builder.document } + /// How ``material`` cuts out or blends, with the VRM 0.x shader metadata + /// taking priority over the glTF alpha mode as UniVRM reads it. + public var resolvedAlphaMode: GLTF.Material.AlphaMode { + GLTF.Material.AlphaMode(vrm0: vrm0MaterialProperty, fallback: material.alphaMode) + } + + /// The ``GLTFShadedMaterial/renderQueue`` of ``material`` drawn as `alphaMode`: + /// the queue VRM 0.x recorded, or the mode's base queue (a depth-writing blend + /// draws ahead of the rest) moved by MToon's `offset`. Nil unless blended. + public func renderQueue(alphaMode: GLTF.Material.AlphaMode, + transparentWithZWrite: Bool = false, + offset: Int = 0) -> Int? { + guard alphaMode == .BLEND else { return nil } + if let vrm0MaterialProperty { return vrm0MaterialProperty.renderQueue } + return alphaMode.vrm0RenderQueue(transparentWithZWrite: transparentWithZWrite) + offset + } + + /// The ``GLTFShadedMaterial/renderQueue`` of ``material`` drawn as authored, + /// whichever shader draws it: the queue its MToon data places it at, or the + /// plain one of ``resolvedAlphaMode``. A platform that renders MToon through + /// the Unlit approximation still draws the model in its authored order. + public func resolvedRenderQueue() throws -> Int? { + let descriptor = try mtoonResolution().descriptor + return renderQueue(alphaMode: resolvedAlphaMode, + transparentWithZWrite: descriptor?.transparentWithZWrite ?? false, + offset: descriptor?.renderQueueOffsetNumber ?? 0) + } + /// Whether the document declares itself undrawable without `name`, so a shader /// that degrades gracefully should still throw. public func enforcesRequiredExtension(_ name: String) -> Bool { diff --git a/Sources/VRMRealityKit/Resources/MToon-ios.metallib b/Sources/VRMRealityKit/Resources/MToon-ios.metallib index 8bb36466..74b781a3 100644 Binary files a/Sources/VRMRealityKit/Resources/MToon-ios.metallib and b/Sources/VRMRealityKit/Resources/MToon-ios.metallib differ diff --git a/Sources/VRMRealityKit/Resources/MToon-iossim.metallib b/Sources/VRMRealityKit/Resources/MToon-iossim.metallib index c85814ed..79a08e03 100644 Binary files a/Sources/VRMRealityKit/Resources/MToon-iossim.metallib and b/Sources/VRMRealityKit/Resources/MToon-iossim.metallib differ diff --git a/Sources/VRMRealityKit/Resources/MToon-macos.metallib b/Sources/VRMRealityKit/Resources/MToon-macos.metallib index 516ccdc9..fc0d4185 100644 Binary files a/Sources/VRMRealityKit/Resources/MToon-macos.metallib and b/Sources/VRMRealityKit/Resources/MToon-macos.metallib differ diff --git a/Sources/VRMRealityKit/Shaders/MToon.metal b/Sources/VRMRealityKit/Shaders/MToon.metal index 199c56fc..515a8ad0 100644 --- a/Sources/VRMRealityKit/Shaders/MToon.metal +++ b/Sources/VRMRealityKit/Shaders/MToon.metal @@ -26,7 +26,7 @@ constexpr sampler mtoonTextureSampler(coord::normalized, address::repeat, mag_filter::linear, min_filter::linear, mip_filter::linear); -constant float mtoonParameterTextureWidth = 27.0; +constant float mtoonParameterTextureWidth = 30.0; // Parameter rows, mirroring MToonParameterRow on the Swift side. constant float mtoonRowBaseColor = 0.0; @@ -47,7 +47,10 @@ constant float mtoonRowUvTransform = 14.0; constant float mtoonRowUvTransformRotation = 15.0; constant float mtoonRowNormalParameters = 16.0; constant float mtoonRowLightDirection = 17.0; -constant float mtoonSamplerParameterStart = 18.0; +constant float mtoonRowRimLightColor = 18.0; +constant float mtoonRowRimLightDirection = 19.0; +constant float mtoonRowRimLightShape = 20.0; +constant float mtoonSamplerParameterStart = 21.0; // Sampler parameter slots, mirroring MToonTextureSlot on the Swift side. constant float mtoonSamplerSlotBase = 0.0; @@ -416,6 +419,7 @@ void mtoonSurface(realitykit::surface_parameters params) half4 emissiveFactor = mtoonParameter(textures, mtoonRowEmissiveFactor); half4 lightColorParameter = mtoonParameter(textures, mtoonRowLightColor); half4 giColorParameter = mtoonParameter(textures, mtoonRowAmbientColor); + half4 rimLightColor = mtoonParameter(textures, mtoonRowRimLightColor); half4 uvTransform = mtoonParameter(textures, mtoonRowUvTransform); half4 uvTransformRotation = mtoonParameter(textures, mtoonRowUvTransformRotation); half4 normalParameters = mtoonParameter(textures, mtoonRowNormalParameters); @@ -494,6 +498,21 @@ void mtoonSurface(realitykit::surface_parameters params) color += rim; } + // The runtime rim light (MToonRimLight), a term of its own because the model's + // rim above is view-only. Black, the default, is off. + if (any(rimLightColor.rgb > 0.0h)) { + float3 viewDirection = normalize(params.geometry().view_direction()); + float3 rimLightDirection = float3(mtoonParameter(textures, mtoonRowRimLightDirection).xyz); + float4 rimLightShape = float4(mtoonParameter(textures, mtoonRowRimLightShape)); + float3 rimBand = float3(mtoonRimLightTerm(normal, viewDirection, rimLightDirection, rimLightShape)); + if (featureFlags.y > 0.5h) { + rimBand *= float3(mtoonSample(textures.clearcoat_roughness(), uv, rimSampler).rgb); + } + // blend (w) fades the surface under the band out, so that at 1 the band is + // the rim's color itself rather than light added to an already bright surface. + color = color * (1.0 - rimBand * float(rimLightColor.w)) + float3(rimLightColor.rgb) * rimBand; + } + float3 emissiveTexture = extraFlags.z > 0.5h ? float3(mtoonSample(textures.emissive_color(), uv, emissiveSampler).rgb) : float3(1.0); diff --git a/Sources/VRMRealityKit/Shaders/MToonCore.h b/Sources/VRMRealityKit/Shaders/MToonCore.h index 541de018..084f2892 100644 --- a/Sources/VRMRealityKit/Shaders/MToonCore.h +++ b/Sources/VRMRealityKit/Shaders/MToonCore.h @@ -61,4 +61,31 @@ inline float mtoonParametricRim(float3 normal, float3 viewDirection, float rimFr return metal::pow(rimBase, metal::max(rimFresnelPower, mtoonEpsilon)); } +// A rim light from a direction, not part of the specification. The shape follows +// the backlight of anime-style shaders rather than a Fresnel gradient: +// - the light is bent away from the viewer (shape.w) so a backlight lines the +// near edges of the silhouette too, not only the surfaces facing it; +// - a half-Lambert with a wrap range (shape.z) carries the band around the +// shadowed side instead of cutting it off at the terminator; +// - the Fresnel edge times that facing is cut into a band of set width (shape.x) +// with a soft inner edge (shape.y). +// shape = (width, softness, wrap, viewBend), all in 0...1. +// +// A back face of a double-sided material carries a normal pointing away from the +// viewer, which would read as the sharpest edge there is; it is flipped to face +// the viewer so that only real silhouettes light up. +inline float mtoonRimLightTerm(float3 normal, float3 viewDirection, float3 lightDirection, float4 shape) +{ + if (metal::dot(normal, viewDirection) < 0.0) { + normal = -normal; + } + const float3 bentLight = metal::normalize(lightDirection - viewDirection * shape.w); + const float halfLambert = metal::dot(normal, bentLight) * 0.5 + 0.5; + const float facing = metal::saturate((halfLambert + shape.z) / (1.0 + shape.z)); + const float edge = 1.0 - metal::saturate(metal::dot(normal, viewDirection)); + const float band = edge * facing; + const float border = 1.0 - shape.x; + return mtoonLinearstep(border - shape.y * 0.5, border + shape.y * 0.5, band); +} + #endif diff --git a/Sources/VRMRealityKit/Shaders/MToonMetallibInputs.txt b/Sources/VRMRealityKit/Shaders/MToonMetallibInputs.txt index 9cdafc74..8c035030 100644 --- a/Sources/VRMRealityKit/Shaders/MToonMetallibInputs.txt +++ b/Sources/VRMRealityKit/Shaders/MToonMetallibInputs.txt @@ -3,6 +3,6 @@ flags=-Wall -Wextra -Werror target=macosx|macos-|-mmacosx-version-min=12.0|MToon-macos.metallib target=iphoneos|ios-|-mios-version-min=15.0|MToon-ios.metallib target=iphonesimulator|ios-|-miphonesimulator-version-min=15.0|MToon-iossim.metallib -9e46cfb871a2ea6c223bb08f345b3d8b4bd328f0992afb781b7fe345943fae1a Sources/VRMRealityKit/Shaders/MToonCore.h -ab49d7e66cde99ccce0eb1955f46bafd4c614c7826b8ecd561208e00fd526729 Sources/VRMRealityKit/Shaders/MToon.metal +4bdf83be234ee21184940dd66d6101f3f2d9f15d4217eacfcdf9af606b71d2cb Sources/VRMRealityKit/Shaders/MToonCore.h +4cd056a364a4e43e08bf60e7796674c1e74490b93c72260bb089da003e826fe6 Sources/VRMRealityKit/Shaders/MToon.metal 83dc27b262e43b5c83b6ee8b4c8b5702acc533eb3a7deda108ac548786d7bce9 scripts/build-mtoon-metallibs.sh diff --git a/Sources/VRMRealityKit/VRMAnimation/VRMAnimationRuntime.swift b/Sources/VRMRealityKit/VRMAnimation/VRMAnimationRuntime.swift index 2e3746e3..8b7adf29 100644 --- a/Sources/VRMRealityKit/VRMAnimation/VRMAnimationRuntime.swift +++ b/Sources/VRMRealityKit/VRMAnimation/VRMAnimationRuntime.swift @@ -311,7 +311,7 @@ extension VRMAnimationRuntime: GLTFAnimationApplying { } } // Sent together so the entity re-accumulates its bindings once. - entity.setExpressions(weights) + entity.applyAnimationExpressions(weights) } /// Aims the model's gaze where the animation states it. diff --git a/Sources/VRMRealityKit/VRMEntityLoader.swift b/Sources/VRMRealityKit/VRMEntityLoader.swift index e3f157fd..b1bf7d44 100644 --- a/Sources/VRMRealityKit/VRMEntityLoader.swift +++ b/Sources/VRMRealityKit/VRMEntityLoader.swift @@ -91,7 +91,8 @@ public final class VRMEntityLoader { try setUpVRM(root, built: built, builder: builder) // Skin bindings are registered mid-build, so the rest pose is only solvable // once the graph is complete. - root.flushSkinPose() + root.invalidateSkinPose() + root.flushDeformation() return root } } @@ -101,7 +102,7 @@ public final class VRMEntityLoader { built: GLTFSceneBuilder.BuiltScene, builder: GLTFSceneBuilder) throws { let hierarchy = try resources.nodeHierarchy() - entity.setUpHumanoid(nodes: built.nodes) + try entity.setUpHumanoid(nodes: built.nodes) entity.setUpBlendShapes(nodes: built.nodes, meshes: built.meshes, builder: builder) entity.setUpFirstPerson(plan: profile.firstPerson(hierarchy: hierarchy), nodes: built.nodes, diff --git a/Tests/Assets/README.md b/Tests/Assets/README.md index 90dda644..28a592f1 100644 --- a/Tests/Assets/README.md +++ b/Tests/Assets/README.md @@ -3,9 +3,9 @@ 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 | +| `VRM/` | `.vrm` models for the VRM 0.x / 1.0 loading paths, including Seed-san, the VRM specification constraint sample, and AvatarSample_M | | [`VRMA/`](VRMA/README.md) | The MIT-licensed `.vrma` sample from pixiv/three-vrm | `VRMTestSupport` owns this directory as its resources (see `Package.swift`), so diff --git a/Tests/Assets/ReferenceOutputs/schema.json b/Tests/Assets/ReferenceOutputs/schema.json new file mode 100644 index 00000000..341a5ff4 --- /dev/null +++ b/Tests/Assets/ReferenceOutputs/schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/argus1/VRMKit/blob/main/Tests/Assets/ReferenceOutputs/schema.json", + "title": "VRMKit conformance reference output", + "type": "object", + "required": ["schemaVersion", "source", "fixture", "coordinateSystem", "tolerances", "samples"], + "properties": { + "schemaVersion": { + "type": "string", + "const": "1" + }, + "source": { + "type": "object", + "required": ["name", "version", "commit"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "commit": { "type": "string", "minLength": 1 }, + "notes": { "type": "string" } + }, + "additionalProperties": false + }, + "fixture": { + "type": "object", + "required": ["path", "sha256"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$" + } + }, + "additionalProperties": false + }, + "coordinateSystem": { + "type": "object", + "required": ["handedness", "upAxis", "forwardAxis", "units"], + "properties": { + "handedness": { "enum": ["right", "left"] }, + "upAxis": { "enum": ["+X", "-X", "+Y", "-Y", "+Z", "-Z"] }, + "forwardAxis": { "enum": ["+X", "-X", "+Y", "-Y", "+Z", "-Z"] }, + "units": { "type": "string", "minLength": 1 }, + "notes": { "type": "string" } + }, + "additionalProperties": false + }, + "tolerances": { + "type": "object", + "required": ["translation", "rotation", "scalar"], + "properties": { + "translation": { "type": "number", "exclusiveMinimum": 0 }, + "rotation": { "type": "number", "exclusiveMinimum": 0 }, + "scalar": { "type": "number", "exclusiveMinimum": 0 } + }, + "additionalProperties": false + }, + "samples": { + "type": "object", + "required": ["bones", "expressions", "lookAt", "constraints", "springPositions", "vrma"], + "properties": { + "bones": { "$ref": "#/$defs/sampleList" }, + "expressions": { "$ref": "#/$defs/sampleList" }, + "lookAt": { "$ref": "#/$defs/sampleList" }, + "constraints": { "$ref": "#/$defs/sampleList" }, + "springPositions": { "$ref": "#/$defs/sampleList" }, + "vrma": { "$ref": "#/$defs/sampleList" } + }, + "additionalProperties": false + } + }, + "$defs": { + "sampleList": { + "type": "array", + "items": { + "type": "object", + "required": ["time", "values"], + "properties": { + "time": { "type": "number", "minimum": 0 }, + "values": { "type": "object" }, + "notes": { "type": "string" } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/Tests/Assets/ReferenceOutputs/three-vrm/AvatarSample_M.json b/Tests/Assets/ReferenceOutputs/three-vrm/AvatarSample_M.json new file mode 100644 index 00000000..30e3a88c --- /dev/null +++ b/Tests/Assets/ReferenceOutputs/three-vrm/AvatarSample_M.json @@ -0,0 +1,2123 @@ +{ + "schemaVersion": "1", + "source": { + "name": "three-vrm", + "version": "v3.5.5", + "commit": "1b4fc0cc7ef39a49d62bb7a66dcfeca8f65316f7", + "notes": "Captured headlessly with three.js + @pixiv/three-vrm on Node.js." + }, + "fixture": { + "path": "Tests/Assets/VRM/AvatarSample_M.vrm", + "sha256": "75954b19b6cc148ee4cdba88e9833db0dd321318f85098b834b3de36275b4bb1" + }, + "coordinateSystem": { + "handedness": "right", + "upAxis": "+Y", + "forwardAxis": "-Z", + "units": "meters", + "notes": "three.js world coordinates; rotations are three.js quaternions." + }, + "tolerances": { + "translation": 0.002, + "rotation": 0.001, + "scalar": 0.001 + }, + "samples": { + "bones": [ + { + "time": 0, + "values": { + "names": [ + "hips", + "spine", + "chest", + "upperChest", + "neck", + "head", + "leftEye", + "rightEye", + "leftUpperLeg", + "leftLowerLeg", + "leftFoot", + "leftToes", + "rightUpperLeg", + "rightLowerLeg", + "rightFoot", + "rightToes", + "leftShoulder", + "leftUpperArm", + "leftLowerArm", + "leftHand", + "rightShoulder", + "rightUpperArm", + "rightLowerArm", + "rightHand", + "leftThumbMetacarpal", + "leftThumbProximal", + "leftThumbDistal", + "leftIndexProximal", + "leftIndexIntermediate", + "leftIndexDistal", + "leftMiddleProximal", + "leftMiddleIntermediate", + "leftMiddleDistal", + "leftRingProximal", + "leftRingIntermediate", + "leftRingDistal", + "leftLittleProximal", + "leftLittleIntermediate", + "leftLittleDistal", + "rightThumbMetacarpal", + "rightThumbProximal", + "rightThumbDistal", + "rightIndexProximal", + "rightIndexIntermediate", + "rightIndexDistal", + "rightMiddleProximal", + "rightMiddleIntermediate", + "rightMiddleDistal", + "rightRingProximal", + "rightRingIntermediate", + "rightRingDistal", + "rightLittleProximal", + "rightLittleIntermediate", + "rightLittleDistal" + ], + "types": [], + "sources": [], + "scalars": [], + "positions": [ + { + "x": 0, + "y": 0.757685363, + "z": 0.00381013332 + }, + { + "x": -5.845327e-32, + "y": 0.8041406565780059, + "z": 0.015106671718121557 + }, + { + "x": 1.9799062599999942e-17, + "y": 0.903696121714195, + "z": 0.017324667069052793 + }, + { + "x": -8.778872800000056e-18, + "y": 1.00118158309131, + "z": 0.004048040863534712 + }, + { + "x": -9.846644280000006e-17, + "y": 1.1220832395428906, + "z": -0.031006743538186873 + }, + { + "x": 1.477351695335572e-10, + "y": 1.1792174663297623, + "z": -0.023806990923331273 + }, + { + "x": 0.013359913382298595, + "y": 1.2342657628223623, + "z": -0.005242163005876144 + }, + { + "x": -0.013359907317697142, + "y": 1.2342657613759511, + "z": -0.005242147505045855 + }, + { + "x": 0.07044158, + "y": 0.7216353998591813, + "z": 0.0005060365904474722 + }, + { + "x": 0.07044156509883881, + "y": 0.43038739549466654, + "z": -0.005588904452323859 + }, + { + "x": 0.07044156509883881, + "y": 0.08535409921109588, + "z": -0.026574970471669505 + }, + { + "x": 0.07044156509883881, + "y": 0.03945818591154627, + "z": 0.05399122838856497 + }, + { + "x": -0.07044158, + "y": 0.7216353998591813, + "z": 0.0005060365904474722 + }, + { + "x": -0.07044156509883881, + "y": 0.43038739549466654, + "z": -0.005588904452323859 + }, + { + "x": -0.07044156509883881, + "y": 0.08535409921109588, + "z": -0.026574970471669505 + }, + { + "x": -0.07044156509883881, + "y": 0.03945818591154627, + "z": 0.05399122838856497 + }, + { + "x": 0.02044639179999999, + "y": 1.0977709905019934, + "z": -0.02334133754082897 + }, + { + "x": 0.093577008417953, + "y": 1.0873539105296832, + "z": -0.023341282479467103 + }, + { + "x": 0.28450597408816963, + "y": 1.087353902739309, + "z": -0.023341282995573084 + }, + { + "x": 0.4691875737691166, + "y": 1.0873561615553937, + "z": -0.023329813584413838 + }, + { + "x": -0.020446391800000012, + "y": 1.0977709905019934, + "z": -0.02334133754082897 + }, + { + "x": -0.09357700841795302, + "y": 1.0873539105296832, + "z": -0.023341282479467103 + }, + { + "x": -0.2845059740881697, + "y": 1.087353902739309, + "z": -0.023341282995573084 + }, + { + "x": -0.4691875737691167, + "y": 1.0873561615553937, + "z": -0.023329813584413838 + }, + { + "x": 0.47210998966008283, + "y": 1.0790475135965452, + "z": -0.01007746544070864 + }, + { + "x": 0.5014460275800118, + "y": 1.077335045778128, + "z": 0.01504568087366465 + }, + { + "x": 0.520158450060655, + "y": 1.0765297873270336, + "z": 0.029698240138332 + }, + { + "x": 0.5234929183266197, + "y": 1.0934830384753582, + "z": -0.00701130910493996 + }, + { + "x": 0.5510777981736434, + "y": 1.0934830389648234, + "z": -0.0075477784590048756 + }, + { + "x": 0.5680447354532421, + "y": 1.0929921647034253, + "z": -0.008202740442412659 + }, + { + "x": 0.5242327312400629, + "y": 1.093483158405364, + "z": -0.0216304643653359 + }, + { + "x": 0.5549646475869835, + "y": 1.093483038171131, + "z": -0.02163047535738026 + }, + { + "x": 0.5739237525542337, + "y": 1.093483156748075, + "z": -0.02163048083191159 + }, + { + "x": 0.5236058095542182, + "y": 1.0934831589631684, + "z": -0.034610300011875386 + }, + { + "x": 0.5521132806049784, + "y": 1.0934831580123907, + "z": -0.0346103110172267 + }, + { + "x": 0.5685651757765617, + "y": 1.0934831574636887, + "z": -0.03461031091881828 + }, + { + "x": 0.5198587635737256, + "y": 1.093483040273428, + "z": -0.04755069596606573 + }, + { + "x": 0.5459349015286854, + "y": 1.0934830394037394, + "z": -0.04755069581008908 + }, + { + "x": 0.5609633782027248, + "y": 1.093483158111777, + "z": -0.047550695720196205 + }, + { + "x": -0.47210998966008294, + "y": 1.0790475135965452, + "z": -0.01007746544070864 + }, + { + "x": -0.5014460275800119, + "y": 1.077335045778128, + "z": 0.01504568087366465 + }, + { + "x": -0.5201584500606551, + "y": 1.0765297873270336, + "z": 0.029698240138332 + }, + { + "x": -0.5234929183266198, + "y": 1.0934830384753582, + "z": -0.00701130910493996 + }, + { + "x": -0.5510777981736436, + "y": 1.0934830389648234, + "z": -0.0075477784590048756 + }, + { + "x": -0.5680447354532422, + "y": 1.0929921647034253, + "z": -0.008202740442412659 + }, + { + "x": -0.524232731240063, + "y": 1.093483158405364, + "z": -0.0216304643653359 + }, + { + "x": -0.5549646475869836, + "y": 1.093483038171131, + "z": -0.02163047535738026 + }, + { + "x": -0.5739237525542338, + "y": 1.093483156748075, + "z": -0.02163048083191159 + }, + { + "x": -0.5236058095542183, + "y": 1.0934831589631684, + "z": -0.034610300011875386 + }, + { + "x": -0.5521132806049786, + "y": 1.0934831580123907, + "z": -0.0346103110172267 + }, + { + "x": -0.5685651757765618, + "y": 1.0934831574636887, + "z": -0.03461031091881828 + }, + { + "x": -0.5198587635737257, + "y": 1.093483040273428, + "z": -0.04755069596606573 + }, + { + "x": -0.5459349015286855, + "y": 1.0934830394037394, + "z": -0.04755069581008908 + }, + { + "x": -0.5609633782027249, + "y": 1.093483158111777, + "z": -0.047550695720196205 + } + ], + "rotations": [ + { + "x": 0.0628984300361247, + "y": 0, + "z": 0, + "w": 0.9980199334176603 + }, + { + "x": 0.07566474980394804, + "y": 0, + "z": 0, + "w": 0.9971333138738803 + }, + { + "x": -0.060871022678788175, + "y": 0, + "z": 0, + "w": 0.9981456399734652 + }, + { + "x": -0.1682905138153223, + "y": 0, + "z": 0, + "w": 0.9857374411879535 + }, + { + "x": 0.05973147343981242, + "y": 0, + "z": 0, + "w": 0.9982144815022014 + }, + { + "x": 0.005404276931358667, + "y": 2.1909725139848319e-7, + "z": 2.5114000160203877e-8, + "w": 0.9999853967887732 + }, + { + "x": 0.00834234834748586, + "y": 0.18904124080621174, + "z": -0.001842933975985261, + "w": 0.9819319824010295 + }, + { + "x": 0.008342346682125095, + "y": -0.18904061816810513, + "z": 0.001842979654152041, + "w": 0.9819321021992553 + }, + { + "x": 0.010460935925093291, + "y": 0, + "z": 0, + "w": 0.9999452829128058 + }, + { + "x": 0.03326846820976296, + "y": 0, + "z": 0, + "w": 0.9994464513034081 + }, + { + "x": 0.002740466434822799, + "y": 0, + "z": 0, + "w": 0.9999962449148094 + }, + { + "x": 0.004907154608454107, + "y": 0, + "z": 0, + "w": 0.9999879598443416 + }, + { + "x": 0.010460935925093291, + "y": 0, + "z": 0, + "w": 0.9999452829128058 + }, + { + "x": 0.03326846820976296, + "y": 0, + "z": 0, + "w": 0.9994464513034081 + }, + { + "x": 0.002740466434822799, + "y": 0, + "z": 0, + "w": 0.9999962449148094 + }, + { + "x": 0.004907154608454107, + "y": 0, + "z": 0, + "w": 0.9999879598443416 + }, + { + "x": -1.4398225932785714e-8, + "y": -3.900336381932066e-9, + "z": -0.07061426678777337, + "w": 0.9975036968984247 + }, + { + "x": 7.688782510502086e-9, + "y": -2.2826715994447348e-9, + "z": -6.666448730736264e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739805693990317e-9, + "y": -1.7471811495139457e-9, + "z": -2.9411570896354196e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739806138079522e-9, + "y": -1.7471811495139455e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": -1.4398225932785714e-8, + "y": 3.900336381932066e-9, + "z": 0.07061426678777337, + "w": 0.9975036968984247 + }, + { + "x": 7.688782510502086e-9, + "y": 2.2826715994447348e-9, + "z": 6.666448730736264e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739805693990317e-9, + "y": 1.7471811495139457e-9, + "z": 2.9411570896354196e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739806138079522e-9, + "y": 1.7471811495139455e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 0.7031657042416328, + "y": -0.24679931042956194, + "z": 0.24397961307558874, + "w": 0.6205820082693404 + }, + { + "x": 0.7077860417617068, + "y": -0.22929749692788873, + "z": 0.2319946578212436, + "w": 0.6266099603423588 + }, + { + "x": 0.7000756824410366, + "y": -0.26246532928015504, + "z": 0.25139792828045965, + "w": 0.6146584947419494 + }, + { + "x": -6.27493280162014e-9, + "y": 0.009704281133206556, + "z": 2.2541215123981717e-8, + "w": 0.9999529123552202 + }, + { + "x": -0.000002323085382228829, + "y": 0.019265677887168035, + "z": -0.014442429235732974, + "w": 0.9997100829142818 + }, + { + "x": -0.00009604699545115089, + "y": 0.016676430424690015, + "z": -0.017367714383805823, + "w": 0.9997100829403496 + }, + { + "x": 7.73980658216872e-9, + "y": -1.7471811495139405e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807026257922e-9, + "y": -1.7471811495139358e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807470347122e-9, + "y": -1.747181149513931e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.73980658216872e-9, + "y": -1.7471811495139405e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807026257922e-9, + "y": -1.7471811495139358e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": -2.8932114364779858e-8, + "y": 0.01143835821565482, + "z": -0.006218711291538919, + "w": 0.9999152422027179 + }, + { + "x": 7.73980658216872e-9, + "y": -1.7471811495139405e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807026257922e-9, + "y": -1.7471811495139358e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": -5.4999708701382365e-8, + "y": -0.00960975770559671, + "z": 0.007768771857816397, + "w": 0.9999236464544501 + }, + { + "x": 0.7031657042416328, + "y": 0.24679931042956194, + "z": -0.24397961307558874, + "w": 0.6205820082693404 + }, + { + "x": 0.7077860417617068, + "y": 0.22929749692788873, + "z": -0.2319946578212436, + "w": 0.6266099603423588 + }, + { + "x": 0.7000756824410366, + "y": 0.26246532928015504, + "z": -0.25139792828045965, + "w": 0.6146584947419494 + }, + { + "x": -6.27493280162014e-9, + "y": -0.009704281133206556, + "z": -2.2541215123981717e-8, + "w": 0.9999529123552202 + }, + { + "x": -0.000002323085382228829, + "y": -0.019265677887168035, + "z": 0.014442429235732974, + "w": 0.9997100829142818 + }, + { + "x": -0.00009604699545115089, + "y": -0.016676430424690015, + "z": 0.017367714383805823, + "w": 0.9997100829403496 + }, + { + "x": 7.73980658216872e-9, + "y": 1.7471811495139405e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807026257922e-9, + "y": 1.7471811495139358e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807470347122e-9, + "y": 1.747181149513931e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.73980658216872e-9, + "y": 1.7471811495139405e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807026257922e-9, + "y": 1.7471811495139358e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": -2.8932114364779858e-8, + "y": -0.01143835821565482, + "z": 0.006218711291538919, + "w": 0.9999152422027179 + }, + { + "x": 7.73980658216872e-9, + "y": 1.7471811495139405e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807026257922e-9, + "y": 1.7471811495139358e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": -5.4999708701382365e-8, + "y": 0.00960975770559671, + "z": -0.007768771857816397, + "w": 0.9999236464544501 + } + ] + }, + "notes": "" + } + ], + "expressions": [ + { + "time": 0, + "values": { + "names": [ + "aa", + "ih", + "ou", + "ee", + "oh", + "blink", + "happy", + "angry", + "sad", + "relaxed", + "surprised", + "blinkLeft", + "blinkRight", + "neutral" + ], + "types": [], + "sources": [], + "scalars": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "positions": [], + "rotations": [] + }, + "notes": "" + } + ], + "lookAt": [ + { + "time": 0, + "values": { + "names": [ + "yaw", + "pitch" + ], + "types": [], + "sources": [], + "scalars": [ + 0, + 0 + ], + "positions": [], + "rotations": [] + }, + "notes": "" + } + ], + "constraints": [ + { + "time": 0, + "values": { + "names": [], + "types": [], + "sources": [], + "scalars": [], + "positions": [], + "rotations": [] + }, + "notes": "" + } + ], + "springPositions": [ + { + "time": 0, + "values": { + "names": [ + "J_Sec_L_Bust1", + "J_Sec_L_Bust2", + "J_Sec_R_Bust1", + "J_Sec_R_Bust2", + "J_Sec_L_SkirtBack1_01", + "J_Sec_L_SkirtBack2_01", + "J_Sec_L_SkirtBack2_end_01", + "J_Sec_L_SkirtFront1_01", + "J_Sec_L_SkirtFront2_01", + "J_Sec_L_SkirtFront2_end_01", + "J_Sec_L_SkirtSide1_01", + "J_Sec_L_SkirtSide2_01", + "J_Sec_L_SkirtSide2_end_01", + "J_Sec_R_SkirtBack1_01", + "J_Sec_R_SkirtBack2_01", + "J_Sec_R_SkirtBack2_end_01", + "J_Sec_R_SkirtFront1_01", + "J_Sec_R_SkirtFront2_01", + "J_Sec_R_SkirtFront2_end_01", + "J_Sec_R_SkirtSide1_01", + "J_Sec_R_SkirtSide2_01", + "J_Sec_R_SkirtSide2_end_01", + "J_Sec_L_SkirtBack1_02", + "J_Sec_L_SkirtBack2_02", + "J_Sec_L_SkirtBack2_end_02", + "J_Sec_L_SkirtFront1_02", + "J_Sec_L_SkirtFront2_02", + "J_Sec_L_SkirtFront2_end_02", + "J_Sec_L_SkirtSide1_02", + "J_Sec_L_SkirtSide2_02", + "J_Sec_L_SkirtSide2_end_02", + "J_Sec_R_SkirtBack1_02", + "J_Sec_R_SkirtBack2_02", + "J_Sec_R_SkirtBack2_end_02", + "J_Sec_R_SkirtFront1_02", + "J_Sec_R_SkirtFront2_02", + "J_Sec_R_SkirtFront2_end_02", + "J_Sec_R_SkirtSide1_02", + "J_Sec_R_SkirtSide2_02", + "J_Sec_R_SkirtSide2_end_02", + "J_Sec_L_SkirtBack1_03", + "J_Sec_L_SkirtBack2_03", + "J_Sec_L_SkirtBack2_end_03", + "J_Sec_L_SkirtFront1_03", + "J_Sec_L_SkirtFront2_03", + "J_Sec_L_SkirtFront2_end_03", + "J_Sec_L_SkirtSide1_03", + "J_Sec_L_SkirtSide2_03", + "J_Sec_L_SkirtSide2_end_03", + "J_Sec_R_SkirtBack1_03", + "J_Sec_R_SkirtBack2_03", + "J_Sec_R_SkirtBack2_end_03", + "J_Sec_R_SkirtFront1_03", + "J_Sec_R_SkirtFront2_03", + "J_Sec_R_SkirtFront2_end_03", + "J_Sec_R_SkirtSide1_03", + "J_Sec_R_SkirtSide2_03", + "J_Sec_R_SkirtSide2_end_03", + "J_Sec_L_SkirtBack1_04", + "J_Sec_L_SkirtBack2_04", + "J_Sec_L_SkirtBack2_end_04", + "J_Sec_L_SkirtFront1_04", + "J_Sec_L_SkirtFront2_04", + "J_Sec_L_SkirtFront2_end_04", + "J_Sec_L_SkirtSide1_04", + "J_Sec_L_SkirtSide2_04", + "J_Sec_L_SkirtSide2_end_04", + "J_Sec_R_SkirtBack1_04", + "J_Sec_R_SkirtBack2_04", + "J_Sec_R_SkirtBack2_end_04", + "J_Sec_R_SkirtFront1_04", + "J_Sec_R_SkirtFront2_04", + "J_Sec_R_SkirtFront2_end_04", + "J_Sec_R_SkirtSide1_04", + "J_Sec_R_SkirtSide2_04", + "J_Sec_R_SkirtSide2_end_04", + "J_Sec_Hair1_04", + "J_Sec_Hair2_04", + "J_Sec_Hair3_04", + "J_Sec_Hair1_03", + "J_Sec_Hair2_03", + "J_Sec_Hair3_03", + "J_Sec_Hair1_02", + "J_Sec_Hair2_02", + "J_Sec_Hair3_02", + "J_Sec_Hair1_01", + "J_Sec_Hair2_01", + "J_Sec_Hair3_01", + "J_Sec_Hair1_05", + "J_Sec_Hair2_05", + "J_Sec_Hair3_05", + "J_Sec_Hair1_06", + "J_Sec_Hair2_06", + "J_Sec_Hair3_06", + "J_Sec_Hair4_06", + "J_Sec_Hair5_06", + "J_Sec_Hair1_07", + "J_Sec_Hair2_07", + "J_Sec_Hair3_07", + "J_Sec_Hair4_07", + "J_Sec_Hair5_07", + "J_Sec_Hair1_08", + "J_Sec_Hair2_08", + "J_Sec_Hair3_08", + "J_Sec_Hair4_08", + "J_Sec_Hair5_08", + "J_Sec_Hair1_09", + "J_Sec_Hair2_09", + "J_Sec_Hair3_09", + "J_Sec_Hair1_10", + "J_Sec_Hair2_10", + "J_Sec_Hair3_10" + ], + "types": [], + "sources": [], + "scalars": [], + "positions": [ + { + "x": 0.048148397399999994, + "y": 0.9945499345403213, + "z": 0.06709030418550116 + }, + { + "x": 0.06757662177822786, + "y": 0.999293071665878, + "z": 0.1056371987745834 + }, + { + "x": -0.04814839740000001, + "y": 0.9945499345403213, + "z": 0.06709030418550116 + }, + { + "x": -0.06757662177822787, + "y": 0.999293071665878, + "z": 0.1056371987745834 + }, + { + "x": 0.0708954010756205, + "y": 0.7500656600669661, + "z": -0.11796292043738542 + }, + { + "x": 0.0993257901633079, + "y": 0.6501378667800073, + "z": -0.16674408708623098 + }, + { + "x": 0.12712682832072822, + "y": 0.5489388564873061, + "z": -0.2109690902609462 + }, + { + "x": 0.06675606835, + "y": 0.7245396310197586, + "z": 0.13191013775041668 + }, + { + "x": 0.08672393643910994, + "y": 0.6227568250347314, + "z": 0.1503594711108329 + }, + { + "x": 0.11108507733853991, + "y": 0.49847516131513664, + "z": 0.17291886287037508 + }, + { + "x": 0.17587489329853842, + "y": 0.7419734587099379, + "z": 0.011391235450953004 + }, + { + "x": 0.22334867854846852, + "y": 0.6273974294541649, + "z": 0.004376259265554172 + }, + { + "x": 0.2617038617931969, + "y": 0.518060483557632, + "z": -0.001438313055514926 + }, + { + "x": -0.07089540095318601, + "y": 0.7500655035852268, + "z": -0.11796326427893938 + }, + { + "x": -0.09932578179448441, + "y": 0.6501376090498888, + "z": -0.16674427928601512 + }, + { + "x": -0.12712725631210187, + "y": 0.5489390419696683, + "z": -0.21096903666724082 + }, + { + "x": -0.066756082893908, + "y": 0.7245395118075588, + "z": 0.13191039436787094 + }, + { + "x": -0.08672394211028094, + "y": 0.6227569894528253, + "z": 0.15035959587242148 + }, + { + "x": -0.11108466124262453, + "y": 0.4984754479700141, + "z": 0.17291908574862386 + }, + { + "x": -0.17587468429854153, + "y": 0.7419732807916624, + "z": 0.011391216683192516 + }, + { + "x": -0.22334826543551842, + "y": 0.6273978144636333, + "z": 0.004376223880674878 + }, + { + "x": -0.26170347577152675, + "y": 0.5180604980776081, + "z": -0.001438315583548737 + }, + { + "x": 0.0708954010756205, + "y": 0.7500656600669661, + "z": -0.11796292043738542 + }, + { + "x": 0.0993257901633079, + "y": 0.6501378667800073, + "z": -0.16674408708623098 + }, + { + "x": 0.12712682832072822, + "y": 0.5489388564873061, + "z": -0.2109690902609462 + }, + { + "x": 0.06675606835, + "y": 0.7245396310197586, + "z": 0.13191013775041668 + }, + { + "x": 0.08672393643910994, + "y": 0.6227568250347314, + "z": 0.1503594711108329 + }, + { + "x": 0.11108507733853991, + "y": 0.49847516131513664, + "z": 0.17291886287037508 + }, + { + "x": 0.17587489329853842, + "y": 0.7419734587099379, + "z": 0.011391235450953004 + }, + { + "x": 0.22334867854846852, + "y": 0.6273974294541649, + "z": 0.004376259265554172 + }, + { + "x": 0.2617038617931969, + "y": 0.518060483557632, + "z": -0.001438313055514926 + }, + { + "x": -0.07089540095318601, + "y": 0.7500655035852268, + "z": -0.11796326427893938 + }, + { + "x": -0.09932578179448441, + "y": 0.6501376090498888, + "z": -0.16674427928601512 + }, + { + "x": -0.12712725631210187, + "y": 0.5489390419696683, + "z": -0.21096903666724082 + }, + { + "x": -0.066756082893908, + "y": 0.7245395118075588, + "z": 0.13191039436787094 + }, + { + "x": -0.08672394211028094, + "y": 0.6227569894528253, + "z": 0.15035959587242148 + }, + { + "x": -0.11108466124262453, + "y": 0.4984754479700141, + "z": 0.17291908574862386 + }, + { + "x": -0.17587468429854153, + "y": 0.7419732807916624, + "z": 0.011391216683192516 + }, + { + "x": -0.22334826543551842, + "y": 0.6273978144636333, + "z": 0.004376223880674878 + }, + { + "x": -0.26170347577152675, + "y": 0.5180604980776081, + "z": -0.001438315583548737 + }, + { + "x": 0.0708954010756205, + "y": 0.7500656600669661, + "z": -0.11796292043738542 + }, + { + "x": 0.0993257901633079, + "y": 0.6501378667800073, + "z": -0.16674408708623098 + }, + { + "x": 0.12712682832072822, + "y": 0.5489388564873061, + "z": -0.2109690902609462 + }, + { + "x": 0.06675606835, + "y": 0.7245396310197586, + "z": 0.13191013775041668 + }, + { + "x": 0.08672393643910994, + "y": 0.6227568250347314, + "z": 0.1503594711108329 + }, + { + "x": 0.11108507733853991, + "y": 0.49847516131513664, + "z": 0.17291886287037508 + }, + { + "x": 0.17587489329853842, + "y": 0.7419734587099379, + "z": 0.011391235450953004 + }, + { + "x": 0.22334867854846852, + "y": 0.6273974294541649, + "z": 0.004376259265554172 + }, + { + "x": 0.2617038617931969, + "y": 0.518060483557632, + "z": -0.001438313055514926 + }, + { + "x": -0.07089540095318601, + "y": 0.7500655035852268, + "z": -0.11796326427893938 + }, + { + "x": -0.09932578179448441, + "y": 0.6501376090498888, + "z": -0.16674427928601512 + }, + { + "x": -0.12712725631210187, + "y": 0.5489390419696683, + "z": -0.21096903666724082 + }, + { + "x": -0.066756082893908, + "y": 0.7245395118075588, + "z": 0.13191039436787094 + }, + { + "x": -0.08672394211028094, + "y": 0.6227569894528253, + "z": 0.15035959587242148 + }, + { + "x": -0.11108466124262453, + "y": 0.4984754479700141, + "z": 0.17291908574862386 + }, + { + "x": -0.17587468429854153, + "y": 0.7419732807916624, + "z": 0.011391216683192516 + }, + { + "x": -0.22334826543551842, + "y": 0.6273978144636333, + "z": 0.004376223880674878 + }, + { + "x": -0.26170347577152675, + "y": 0.5180604980776081, + "z": -0.001438315583548737 + }, + { + "x": 0.0708954010756205, + "y": 0.7500656600669661, + "z": -0.11796292043738542 + }, + { + "x": 0.0993257901633079, + "y": 0.6501378667800073, + "z": -0.16674408708623098 + }, + { + "x": 0.12712682832072822, + "y": 0.5489388564873061, + "z": -0.2109690902609462 + }, + { + "x": 0.06675606835, + "y": 0.7245396310197586, + "z": 0.13191013775041668 + }, + { + "x": 0.08672393643910994, + "y": 0.6227568250347314, + "z": 0.1503594711108329 + }, + { + "x": 0.11108507733853991, + "y": 0.49847516131513664, + "z": 0.17291886287037508 + }, + { + "x": 0.17587489329853842, + "y": 0.7419734587099379, + "z": 0.011391235450953004 + }, + { + "x": 0.22334867854846852, + "y": 0.6273974294541649, + "z": 0.004376259265554172 + }, + { + "x": 0.2617038617931969, + "y": 0.518060483557632, + "z": -0.001438313055514926 + }, + { + "x": -0.07089540095318601, + "y": 0.7500655035852268, + "z": -0.11796326427893938 + }, + { + "x": -0.09932578179448441, + "y": 0.6501376090498888, + "z": -0.16674427928601512 + }, + { + "x": -0.12712725631210187, + "y": 0.5489390419696683, + "z": -0.21096903666724082 + }, + { + "x": -0.066756082893908, + "y": 0.7245395118075588, + "z": 0.13191039436787094 + }, + { + "x": -0.08672394211028094, + "y": 0.6227569894528253, + "z": 0.15035959587242148 + }, + { + "x": -0.11108466124262453, + "y": 0.4984754479700141, + "z": 0.17291908574862386 + }, + { + "x": -0.17587468429854153, + "y": 0.7419732807916624, + "z": 0.011391216683192516 + }, + { + "x": -0.22334826543551842, + "y": 0.6273978144636333, + "z": 0.004376223880674878 + }, + { + "x": -0.26170347577152675, + "y": 0.5180604980776081, + "z": -0.001438315583548737 + }, + { + "x": -0.002106240295225794, + "y": 1.271728828359608, + "z": 0.0724732104179388 + }, + { + "x": 0.00320555443637297, + "y": 1.2453421800124949, + "z": 0.07085598957220007 + }, + { + "x": 0.015547375639862865, + "y": 1.2232511406841584, + "z": 0.06299065340333326 + }, + { + "x": -0.03993716392379628, + "y": 1.2751070560774, + "z": 0.05696595235534056 + }, + { + "x": -0.039809236015471795, + "y": 1.2484888273054804, + "z": 0.05843672102744471 + }, + { + "x": -0.03946668438634222, + "y": 1.2241830252610948, + "z": 0.05393453296627538 + }, + { + "x": 0.06930165791348601, + "y": 1.225823614824912, + "z": 0.033777045646673186 + }, + { + "x": 0.05974736361252103, + "y": 1.2016454754819301, + "z": 0.038926246819642935 + }, + { + "x": 0.04323569475910028, + "y": 1.184569989208838, + "z": 0.04796289136489791 + }, + { + "x": 0.0807902020915653, + "y": 1.207162560859767, + "z": 0.006823502649944586 + }, + { + "x": 0.07171743213277562, + "y": 1.1535502257752428, + "z": 0.011183158680811135 + }, + { + "x": 0.06577481629302945, + "y": 1.110634310380413, + "z": 0.011939799999609847 + }, + { + "x": -0.08112847308309751, + "y": 1.2021051284620714, + "z": 0.006959042858556856 + }, + { + "x": -0.07083275676445737, + "y": 1.151340595659768, + "z": 0.01163295317943961 + }, + { + "x": -0.06577473890424286, + "y": 1.1106343214990484, + "z": 0.011939842468941687 + }, + { + "x": 0.06832551550563236, + "y": 1.2083232091242893, + "z": -0.09941790053676997 + }, + { + "x": 0.08213266156910792, + "y": 1.1279448273110466, + "z": -0.1250434247170204 + }, + { + "x": 0.11347654938192983, + "y": 1.0555658292618604, + "z": -0.15805106710224875 + }, + { + "x": 0.13665328695141793, + "y": 0.9754928640629705, + "z": -0.16750226615470754 + }, + { + "x": 0.11319878117302759, + "y": 0.901084143244067, + "z": -0.16916228102832423 + }, + { + "x": -0.0005053504487613126, + "y": 1.200264044940149, + "z": -0.12364684936785168 + }, + { + "x": -0.0007707360945443176, + "y": 1.1163659172911509, + "z": -0.1451867373755156 + }, + { + "x": -0.006893301448521719, + "y": 1.0418455005281262, + "z": -0.18895501367031187 + }, + { + "x": -0.01360721454632193, + "y": 0.95845336952185, + "z": -0.20054249462586754 + }, + { + "x": 0.0006361374484205578, + "y": 0.879929933732027, + "z": -0.18816863687261923 + }, + { + "x": -0.06634147195472072, + "y": 1.211280834416604, + "z": -0.09326326581549106 + }, + { + "x": -0.08029892803271148, + "y": 1.1274266023543853, + "z": -0.11922315825636605 + }, + { + "x": -0.11265725726052461, + "y": 1.0499968783861298, + "z": -0.14738197679031292 + }, + { + "x": -0.1253076492937132, + "y": 0.9660740142910689, + "z": -0.15833181460881862 + }, + { + "x": -0.10458597486668988, + "y": 0.8845511865953058, + "z": -0.15451908518048788 + }, + { + "x": 0.031091442098466107, + "y": 1.283064916099815, + "z": 0.061626204192284276 + }, + { + "x": 0.04007637010408158, + "y": 1.2563807651673176, + "z": 0.058701863795495086 + }, + { + "x": 0.04146031893085605, + "y": 1.2287424931089865, + "z": 0.05394330950218197 + }, + { + "x": -0.06968508730320817, + "y": 1.2277697734710118, + "z": 0.03351598757307591 + }, + { + "x": -0.06023164049759379, + "y": 1.202478984265913, + "z": 0.03871464247934741 + }, + { + "x": -0.04323563816183256, + "y": 1.1845700820981901, + "z": 0.04796288850357895 + } + ], + "rotations": [] + }, + "notes": "" + } + ], + "vrma": [ + { + "time": 0, + "values": { + "names": [ + "hips", + "spine", + "chest", + "upperChest", + "neck", + "head", + "leftEye", + "rightEye", + "leftUpperLeg", + "leftLowerLeg", + "leftFoot", + "leftToes", + "rightUpperLeg", + "rightLowerLeg", + "rightFoot", + "rightToes", + "leftShoulder", + "leftUpperArm", + "leftLowerArm", + "leftHand", + "rightShoulder", + "rightUpperArm", + "rightLowerArm", + "rightHand", + "leftThumbMetacarpal", + "leftThumbProximal", + "leftThumbDistal", + "leftIndexProximal", + "leftIndexIntermediate", + "leftIndexDistal", + "leftMiddleProximal", + "leftMiddleIntermediate", + "leftMiddleDistal", + "leftRingProximal", + "leftRingIntermediate", + "leftRingDistal", + "leftLittleProximal", + "leftLittleIntermediate", + "leftLittleDistal", + "rightThumbMetacarpal", + "rightThumbProximal", + "rightThumbDistal", + "rightIndexProximal", + "rightIndexIntermediate", + "rightIndexDistal", + "rightMiddleProximal", + "rightMiddleIntermediate", + "rightMiddleDistal", + "rightRingProximal", + "rightRingIntermediate", + "rightRingDistal", + "rightLittleProximal", + "rightLittleIntermediate", + "rightLittleDistal" + ], + "types": [], + "sources": [], + "scalars": [], + "positions": [ + { + "x": 0, + "y": 0.757685363, + "z": 0.00381013332 + }, + { + "x": -5.845327e-32, + "y": 0.8041406565780059, + "z": 0.015106671718121557 + }, + { + "x": 1.9799062599999942e-17, + "y": 0.903696121714195, + "z": 0.017324667069052793 + }, + { + "x": -8.778872800000056e-18, + "y": 1.00118158309131, + "z": 0.004048040863534712 + }, + { + "x": -9.846644280000006e-17, + "y": 1.1220832395428906, + "z": -0.031006743538186873 + }, + { + "x": 1.477351695335572e-10, + "y": 1.1792174663297623, + "z": -0.023806990923331273 + }, + { + "x": 0.013359913382298595, + "y": 1.2342657628223623, + "z": -0.005242163005876144 + }, + { + "x": -0.013359907317697142, + "y": 1.2342657613759511, + "z": -0.005242147505045855 + }, + { + "x": 0.07044158, + "y": 0.7216353998591813, + "z": 0.0005060365904474722 + }, + { + "x": 0.07044156509883881, + "y": 0.43038739549466654, + "z": -0.005588904452323859 + }, + { + "x": 0.07044156509883881, + "y": 0.08535409921109588, + "z": -0.026574970471669505 + }, + { + "x": 0.07044156509883881, + "y": 0.03945818591154627, + "z": 0.05399122838856497 + }, + { + "x": -0.07044158, + "y": 0.7216353998591813, + "z": 0.0005060365904474722 + }, + { + "x": -0.07044156509883881, + "y": 0.43038739549466654, + "z": -0.005588904452323859 + }, + { + "x": -0.07044156509883881, + "y": 0.08535409921109588, + "z": -0.026574970471669505 + }, + { + "x": -0.07044156509883881, + "y": 0.03945818591154627, + "z": 0.05399122838856497 + }, + { + "x": 0.02044639179999999, + "y": 1.0977709905019934, + "z": -0.02334133754082897 + }, + { + "x": 0.093577008417953, + "y": 1.0873539105296832, + "z": -0.023341282479467103 + }, + { + "x": 0.28450597408816963, + "y": 1.087353902739309, + "z": -0.023341282995573084 + }, + { + "x": 0.4691875737691166, + "y": 1.0873561615553937, + "z": -0.023329813584413838 + }, + { + "x": -0.020446391800000012, + "y": 1.0977709905019934, + "z": -0.02334133754082897 + }, + { + "x": -0.09357700841795302, + "y": 1.0873539105296832, + "z": -0.023341282479467103 + }, + { + "x": -0.2845059740881697, + "y": 1.087353902739309, + "z": -0.023341282995573084 + }, + { + "x": -0.4691875737691167, + "y": 1.0873561615553937, + "z": -0.023329813584413838 + }, + { + "x": 0.47210998966008283, + "y": 1.0790475135965452, + "z": -0.01007746544070864 + }, + { + "x": 0.5014460275800118, + "y": 1.077335045778128, + "z": 0.01504568087366465 + }, + { + "x": 0.520158450060655, + "y": 1.0765297873270336, + "z": 0.029698240138332 + }, + { + "x": 0.5234929183266197, + "y": 1.0934830384753582, + "z": -0.00701130910493996 + }, + { + "x": 0.5510777981736434, + "y": 1.0934830389648234, + "z": -0.0075477784590048756 + }, + { + "x": 0.5680447354532421, + "y": 1.0929921647034253, + "z": -0.008202740442412659 + }, + { + "x": 0.5242327312400629, + "y": 1.093483158405364, + "z": -0.0216304643653359 + }, + { + "x": 0.5549646475869835, + "y": 1.093483038171131, + "z": -0.02163047535738026 + }, + { + "x": 0.5739237525542337, + "y": 1.093483156748075, + "z": -0.02163048083191159 + }, + { + "x": 0.5236058095542182, + "y": 1.0934831589631684, + "z": -0.034610300011875386 + }, + { + "x": 0.5521132806049784, + "y": 1.0934831580123907, + "z": -0.0346103110172267 + }, + { + "x": 0.5685651757765617, + "y": 1.0934831574636887, + "z": -0.03461031091881828 + }, + { + "x": 0.5198587635737256, + "y": 1.093483040273428, + "z": -0.04755069596606573 + }, + { + "x": 0.5459349015286854, + "y": 1.0934830394037394, + "z": -0.04755069581008908 + }, + { + "x": 0.5609633782027248, + "y": 1.093483158111777, + "z": -0.047550695720196205 + }, + { + "x": -0.47210998966008294, + "y": 1.0790475135965452, + "z": -0.01007746544070864 + }, + { + "x": -0.5014460275800119, + "y": 1.077335045778128, + "z": 0.01504568087366465 + }, + { + "x": -0.5201584500606551, + "y": 1.0765297873270336, + "z": 0.029698240138332 + }, + { + "x": -0.5234929183266198, + "y": 1.0934830384753582, + "z": -0.00701130910493996 + }, + { + "x": -0.5510777981736436, + "y": 1.0934830389648234, + "z": -0.0075477784590048756 + }, + { + "x": -0.5680447354532422, + "y": 1.0929921647034253, + "z": -0.008202740442412659 + }, + { + "x": -0.524232731240063, + "y": 1.093483158405364, + "z": -0.0216304643653359 + }, + { + "x": -0.5549646475869836, + "y": 1.093483038171131, + "z": -0.02163047535738026 + }, + { + "x": -0.5739237525542338, + "y": 1.093483156748075, + "z": -0.02163048083191159 + }, + { + "x": -0.5236058095542183, + "y": 1.0934831589631684, + "z": -0.034610300011875386 + }, + { + "x": -0.5521132806049786, + "y": 1.0934831580123907, + "z": -0.0346103110172267 + }, + { + "x": -0.5685651757765618, + "y": 1.0934831574636887, + "z": -0.03461031091881828 + }, + { + "x": -0.5198587635737257, + "y": 1.093483040273428, + "z": -0.04755069596606573 + }, + { + "x": -0.5459349015286855, + "y": 1.0934830394037394, + "z": -0.04755069581008908 + }, + { + "x": -0.5609633782027249, + "y": 1.093483158111777, + "z": -0.047550695720196205 + } + ], + "rotations": [ + { + "x": 0.0628984300361247, + "y": 0, + "z": 0, + "w": 0.9980199334176603 + }, + { + "x": 0.07566474980394804, + "y": 0, + "z": 0, + "w": 0.9971333138738803 + }, + { + "x": -0.060871022678788175, + "y": 0, + "z": 0, + "w": 0.9981456399734652 + }, + { + "x": -0.1682905138153223, + "y": 0, + "z": 0, + "w": 0.9857374411879535 + }, + { + "x": 0.05973147343981242, + "y": 0, + "z": 0, + "w": 0.9982144815022014 + }, + { + "x": 0.005404276931358667, + "y": 2.1909725139848319e-7, + "z": 2.5114000160203877e-8, + "w": 0.9999853967887732 + }, + { + "x": 0.00834234834748586, + "y": 0.18904124080621174, + "z": -0.001842933975985261, + "w": 0.9819319824010295 + }, + { + "x": 0.008342346682125095, + "y": -0.18904061816810513, + "z": 0.001842979654152041, + "w": 0.9819321021992553 + }, + { + "x": 0.010460935925093291, + "y": 0, + "z": 0, + "w": 0.9999452829128058 + }, + { + "x": 0.03326846820976296, + "y": 0, + "z": 0, + "w": 0.9994464513034081 + }, + { + "x": 0.002740466434822799, + "y": 0, + "z": 0, + "w": 0.9999962449148094 + }, + { + "x": 0.004907154608454107, + "y": 0, + "z": 0, + "w": 0.9999879598443416 + }, + { + "x": 0.010460935925093291, + "y": 0, + "z": 0, + "w": 0.9999452829128058 + }, + { + "x": 0.03326846820976296, + "y": 0, + "z": 0, + "w": 0.9994464513034081 + }, + { + "x": 0.002740466434822799, + "y": 0, + "z": 0, + "w": 0.9999962449148094 + }, + { + "x": 0.004907154608454107, + "y": 0, + "z": 0, + "w": 0.9999879598443416 + }, + { + "x": -1.4398225932785714e-8, + "y": -3.900336381932066e-9, + "z": -0.07061426678777337, + "w": 0.9975036968984247 + }, + { + "x": 7.688782510502086e-9, + "y": -2.2826715994447348e-9, + "z": -6.666448730736264e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739805693990317e-9, + "y": -1.7471811495139457e-9, + "z": -2.9411570896354196e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739806138079522e-9, + "y": -1.7471811495139455e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": -1.4398225932785714e-8, + "y": 3.900336381932066e-9, + "z": 0.07061426678777337, + "w": 0.9975036968984247 + }, + { + "x": 7.688782510502086e-9, + "y": 2.2826715994447348e-9, + "z": 6.666448730736264e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739805693990317e-9, + "y": 1.7471811495139457e-9, + "z": 2.9411570896354196e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739806138079522e-9, + "y": 1.7471811495139455e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 0.7031657042416328, + "y": -0.24679931042956194, + "z": 0.24397961307558874, + "w": 0.6205820082693404 + }, + { + "x": 0.7077860417617068, + "y": -0.22929749692788873, + "z": 0.2319946578212436, + "w": 0.6266099603423588 + }, + { + "x": 0.7000756824410366, + "y": -0.26246532928015504, + "z": 0.25139792828045965, + "w": 0.6146584947419494 + }, + { + "x": -6.27493280162014e-9, + "y": 0.009704281133206556, + "z": 2.2541215123981717e-8, + "w": 0.9999529123552202 + }, + { + "x": -0.000002323085382228829, + "y": 0.019265677887168035, + "z": -0.014442429235732974, + "w": 0.9997100829142818 + }, + { + "x": -0.00009604699545115089, + "y": 0.016676430424690015, + "z": -0.017367714383805823, + "w": 0.9997100829403496 + }, + { + "x": 7.73980658216872e-9, + "y": -1.7471811495139405e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807026257922e-9, + "y": -1.7471811495139358e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807470347122e-9, + "y": -1.747181149513931e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.73980658216872e-9, + "y": -1.7471811495139405e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807026257922e-9, + "y": -1.7471811495139358e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": -2.8932114364779858e-8, + "y": 0.01143835821565482, + "z": -0.006218711291538919, + "w": 0.9999152422027179 + }, + { + "x": 7.73980658216872e-9, + "y": -1.7471811495139405e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807026257922e-9, + "y": -1.7471811495139358e-9, + "z": -2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": -5.4999708701382365e-8, + "y": -0.00960975770559671, + "z": 0.007768771857816397, + "w": 0.9999236464544501 + }, + { + "x": 0.7031657042416328, + "y": 0.24679931042956194, + "z": -0.24397961307558874, + "w": 0.6205820082693404 + }, + { + "x": 0.7077860417617068, + "y": 0.22929749692788873, + "z": -0.2319946578212436, + "w": 0.6266099603423588 + }, + { + "x": 0.7000756824410366, + "y": 0.26246532928015504, + "z": -0.25139792828045965, + "w": 0.6146584947419494 + }, + { + "x": -6.27493280162014e-9, + "y": -0.009704281133206556, + "z": -2.2541215123981717e-8, + "w": 0.9999529123552202 + }, + { + "x": -0.000002323085382228829, + "y": -0.019265677887168035, + "z": 0.014442429235732974, + "w": 0.9997100829142818 + }, + { + "x": -0.00009604699545115089, + "y": -0.016676430424690015, + "z": 0.017367714383805823, + "w": 0.9997100829403496 + }, + { + "x": 7.73980658216872e-9, + "y": 1.7471811495139405e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807026257922e-9, + "y": 1.7471811495139358e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807470347122e-9, + "y": 1.747181149513931e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.73980658216872e-9, + "y": 1.7471811495139405e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807026257922e-9, + "y": 1.7471811495139358e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": -2.8932114364779858e-8, + "y": -0.01143835821565482, + "z": 0.006218711291538919, + "w": 0.9999152422027179 + }, + { + "x": 7.73980658216872e-9, + "y": 1.7471811495139405e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": 7.739807026257922e-9, + "y": 1.7471811495139358e-9, + "z": 2.9411579778138194e-9, + "w": 0.9999999999999998 + }, + { + "x": -5.4999708701382365e-8, + "y": 0.00960975770559671, + "z": -0.007768771857816397, + "w": 0.9999236464544501 + } + ] + }, + "notes": "No three-vrm VRMA controller is attached; this is the current runtime pose." + } + ] + } +} \ No newline at end of file diff --git a/Tests/Assets/ReferenceOutputs/univrm/AvatarSample_M.json b/Tests/Assets/ReferenceOutputs/univrm/AvatarSample_M.json new file mode 100644 index 00000000..d14af04c --- /dev/null +++ b/Tests/Assets/ReferenceOutputs/univrm/AvatarSample_M.json @@ -0,0 +1,2347 @@ +{ + "schemaVersion": "1", + "source": { + "name": "UniVRM", + "version": "v0.131.2", + "commit": "928b30e96439c1c11a49b172efb72eb96d2cad8b", + "notes": "Captured from the Unity runtime scene on macOS ARM64." + }, + "fixture": { + "path": "Assets/AvatarSample_M.vrm", + "sha256": "75954b19b6cc148ee4cdba88e9833db0dd321318f85098b834b3de36275b4bb1" + }, + "coordinateSystem": { + "handedness": "right", + "upAxis": "+Y", + "forwardAxis": "+Z", + "units": "meters", + "notes": "Unity world coordinates; rotations are Unity quaternions." + }, + "tolerances": { + "translation": 0.0020000000949949028, + "rotation": 0.0010000000474974514, + "scalar": 0.0010000000474974514 + }, + "samples": { + "bones": [ + { + "time": 0.0, + "values": { + "names": [ + "Hips", + "LeftUpperLeg", + "RightUpperLeg", + "LeftLowerLeg", + "RightLowerLeg", + "LeftFoot", + "RightFoot", + "Spine", + "Chest", + "Neck", + "Head", + "LeftShoulder", + "RightShoulder", + "LeftUpperArm", + "RightUpperArm", + "LeftLowerArm", + "RightLowerArm", + "LeftHand", + "RightHand", + "LeftToes", + "RightToes", + "LeftEye", + "RightEye", + "LeftThumbProximal", + "LeftThumbIntermediate", + "LeftThumbDistal", + "LeftIndexProximal", + "LeftIndexIntermediate", + "LeftIndexDistal", + "LeftMiddleProximal", + "LeftMiddleIntermediate", + "LeftMiddleDistal", + "LeftRingProximal", + "LeftRingIntermediate", + "LeftRingDistal", + "LeftLittleProximal", + "LeftLittleIntermediate", + "LeftLittleDistal", + "RightThumbProximal", + "RightThumbIntermediate", + "RightThumbDistal", + "RightIndexProximal", + "RightIndexIntermediate", + "RightIndexDistal", + "RightMiddleProximal", + "RightMiddleIntermediate", + "RightMiddleDistal", + "RightRingProximal", + "RightRingIntermediate", + "RightRingDistal", + "RightLittleProximal", + "RightLittleIntermediate", + "RightLittleDistal", + "UpperChest" + ], + "types": [], + "sources": [], + "scalars": [], + "positions": [ + { + "x": 0.0, + "y": 0.7576853632926941, + "z": 0.003810133319348097 + }, + { + "x": -0.0704415813088417, + "y": 0.7216354012489319, + "z": 0.0005060369148850441 + }, + { + "x": 0.0704415813088417, + "y": 0.7216354012489319, + "z": 0.0005060369148850441 + }, + { + "x": -0.07044156640768051, + "y": 0.4303874373435974, + "z": -0.005588908214122057 + }, + { + "x": 0.07044156640768051, + "y": 0.4303874373435974, + "z": -0.005588908214122057 + }, + { + "x": -0.07044156640768051, + "y": 0.08535408973693848, + "z": -0.02657494693994522 + }, + { + "x": 0.07044156640768051, + "y": 0.08535408973693848, + "z": -0.02657494693994522 + }, + { + "x": 5.845327273341027e-32, + "y": 0.8041406869888306, + "z": 0.015106672421097756 + }, + { + "x": -1.9799062615463025e-17, + "y": 0.9036961197853088, + "z": 0.017324669286608697 + }, + { + "x": 9.846644191778954e-17, + "y": 1.1220831871032715, + "z": -0.031006742268800737 + }, + { + "x": -1.4773517131860815e-10, + "y": 1.1792173385620118, + "z": -0.02380697801709175 + }, + { + "x": -0.02044639177620411, + "y": 1.0977709293365479, + "z": -0.023341331630945207 + }, + { + "x": 0.02044639177620411, + "y": 1.0977709293365479, + "z": -0.023341331630945207 + }, + { + "x": -0.09357699751853943, + "y": 1.0873539447784424, + "z": -0.023341268301010133 + }, + { + "x": 0.09357699751853943, + "y": 1.0873539447784424, + "z": -0.023341268301010133 + }, + { + "x": -0.2845059931278229, + "y": 1.0873539447784424, + "z": -0.023341264575719835 + }, + { + "x": 0.2845059931278229, + "y": 1.0873539447784424, + "z": -0.023341264575719835 + }, + { + "x": -0.46918749809265139, + "y": 1.0873562097549439, + "z": -0.023329801857471467 + }, + { + "x": 0.46918749809265139, + "y": 1.0873562097549439, + "z": -0.023329801857471467 + }, + { + "x": -0.07044156640768051, + "y": 0.03945821523666382, + "z": 0.05399119481444359 + }, + { + "x": 0.07044156640768051, + "y": 0.03945821523666382, + "z": 0.05399119481444359 + }, + { + "x": -0.013359913602471352, + "y": 1.2342658042907715, + "z": -0.0052421544678509239 + }, + { + "x": 0.01335990708321333, + "y": 1.2342658042907715, + "z": -0.005242135841399431 + }, + { + "x": -0.47210997343063357, + "y": 1.079047441482544, + "z": -0.010077446699142456 + }, + { + "x": -0.5014460682868958, + "y": 1.077335000038147, + "z": 0.015045687556266785 + }, + { + "x": -0.5201584696769714, + "y": 1.0765297412872315, + "z": 0.029698237776756288 + }, + { + "x": -0.5234929323196411, + "y": 1.0934830904006959, + "z": -0.00701130973175168 + }, + { + "x": -0.5510778427124023, + "y": 1.0934829711914063, + "z": -0.007547762710601091 + }, + { + "x": -0.5680448412895203, + "y": 1.0929920673370362, + "z": -0.008202727884054184 + }, + { + "x": -0.5242327451705933, + "y": 1.0934832096099854, + "z": -0.021630454808473588 + }, + { + "x": -0.5549647212028503, + "y": 1.0934829711914063, + "z": -0.021630462259054185 + }, + { + "x": -0.5739237666130066, + "y": 1.0934832096099854, + "z": -0.021630480885505678 + }, + { + "x": -0.5236058831214905, + "y": 1.0934832096099854, + "z": -0.034610290080308917 + }, + { + "x": -0.5521131753921509, + "y": 1.0934830904006959, + "z": -0.034610308706760409 + }, + { + "x": -0.5685652494430542, + "y": 1.0934832096099854, + "z": -0.03461030125617981 + }, + { + "x": -0.519858717918396, + "y": 1.0934830904006959, + "z": -0.047550689429044726 + }, + { + "x": -0.5459350347518921, + "y": 1.0934829711914063, + "z": -0.047550689429044726 + }, + { + "x": -0.5609633326530457, + "y": 1.0934832096099854, + "z": -0.047550689429044726 + }, + { + "x": 0.47210997343063357, + "y": 1.079047441482544, + "z": -0.010077446699142456 + }, + { + "x": 0.5014460682868958, + "y": 1.077335000038147, + "z": 0.015045687556266785 + }, + { + "x": 0.5201584696769714, + "y": 1.0765297412872315, + "z": 0.029698237776756288 + }, + { + "x": 0.5234929323196411, + "y": 1.0934830904006959, + "z": -0.00701130973175168 + }, + { + "x": 0.5510778427124023, + "y": 1.0934829711914063, + "z": -0.007547762710601091 + }, + { + "x": 0.5680448412895203, + "y": 1.0929920673370362, + "z": -0.008202727884054184 + }, + { + "x": 0.5242327451705933, + "y": 1.0934832096099854, + "z": -0.021630454808473588 + }, + { + "x": 0.5549647212028503, + "y": 1.0934829711914063, + "z": -0.021630462259054185 + }, + { + "x": 0.5739237666130066, + "y": 1.0934832096099854, + "z": -0.021630480885505678 + }, + { + "x": 0.5236058831214905, + "y": 1.0934832096099854, + "z": -0.034610290080308917 + }, + { + "x": 0.5521131753921509, + "y": 1.0934830904006959, + "z": -0.034610308706760409 + }, + { + "x": 0.5685652494430542, + "y": 1.0934832096099854, + "z": -0.03461030125617981 + }, + { + "x": 0.519858717918396, + "y": 1.0934830904006959, + "z": -0.047550689429044726 + }, + { + "x": 0.5459350347518921, + "y": 1.0934829711914063, + "z": -0.047550689429044726 + }, + { + "x": 0.5609633326530457, + "y": 1.0934832096099854, + "z": -0.047550689429044726 + }, + { + "x": 8.778872804108958e-18, + "y": 1.0011816024780274, + "z": 0.0040480452589690689 + } + ], + "rotations": [ + { + "x": 0.0628984272480011, + "y": 0.0, + "z": 0.0, + "w": 0.9980199337005615 + }, + { + "x": 0.010460935533046723, + "y": 0.0, + "z": 0.0, + "w": 0.9999452829360962 + }, + { + "x": 0.010460935533046723, + "y": 0.0, + "z": 0.0, + "w": 0.9999452829360962 + }, + { + "x": 0.03326667100191116, + "y": 0.0, + "z": 0.0, + "w": 0.9994465708732605 + }, + { + "x": 0.03326667100191116, + "y": 0.0, + "z": 0.0, + "w": 0.9994465708732605 + }, + { + "x": 0.0027404725551605226, + "y": 0.0, + "z": 0.0, + "w": 0.9999963045120239 + }, + { + "x": 0.0027404725551605226, + "y": 0.0, + "z": 0.0, + "w": 0.9999963045120239 + }, + { + "x": 0.0756639689207077, + "y": 0.0, + "z": 0.0, + "w": 0.9971334338188171 + }, + { + "x": -0.06087004765868187, + "y": 0.0, + "z": 0.0, + "w": 0.9981458187103272 + }, + { + "x": 0.05973046272993088, + "y": 0.0, + "z": 0.0, + "w": 0.998214602470398 + }, + { + "x": 0.005404282361268997, + "y": -2.190971741811154e-7, + "z": -2.5113974899682036e-8, + "w": 0.9999856352806091 + }, + { + "x": -7.450580596923828e-9, + "y": 2.3283064365386964e-9, + "z": 0.0706142708659172, + "w": 0.9975038766860962 + }, + { + "x": -7.450580596923828e-9, + "y": -2.3283064365386964e-9, + "z": -0.0706142708659172, + "w": 0.9975038766860962 + }, + { + "x": 0.0, + "y": 8.391076722347179e-10, + "z": -6.190666312910764e-10, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": -8.391076722347179e-10, + "z": 6.190666312910764e-10, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": 2.568880108633209e-10, + "z": 7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": -2.568880108633209e-10, + "z": -7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": 2.568880108633209e-10, + "z": 7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": -2.568880108633209e-10, + "z": -7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.004894983023405075, + "y": 0.0, + "z": 0.0, + "w": 0.9999880194664002 + }, + { + "x": 0.004894983023405075, + "y": 0.0, + "z": 0.0, + "w": 0.9999880194664002 + }, + { + "x": 0.008342351764440537, + "y": -0.18904095888137818, + "z": 0.001842934638261795, + "w": 0.9819321036338806 + }, + { + "x": 0.008342362940311432, + "y": 0.18904033303260804, + "z": -0.0018429867923259736, + "w": 0.9819323420524597 + }, + { + "x": 0.7031658291816711, + "y": 0.24679939448833466, + "z": -0.24397972226142884, + "w": 0.6205821633338928 + }, + { + "x": 0.707785964012146, + "y": 0.22929760813713075, + "z": -0.2319946587085724, + "w": 0.6266101598739624 + }, + { + "x": 0.7000758051872253, + "y": 0.2624654173851013, + "z": -0.2513979971408844, + "w": 0.6146586537361145 + }, + { + "x": -1.4901161193847657e-8, + "y": -0.009698144160211087, + "z": -2.0780134946107866e-8, + "w": 0.9999532699584961 + }, + { + "x": -0.000002332031726837158, + "y": -0.01926567777991295, + "z": 0.014442428946495057, + "w": 0.999710202217102 + }, + { + "x": -0.00009603053331375122, + "y": -0.016674719750881196, + "z": 0.017365923151373864, + "w": 0.9997105002403259 + }, + { + "x": 0.0, + "y": 2.568880108633209e-10, + "z": 7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": 2.568880108633209e-10, + "z": 7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": 2.568880108633209e-10, + "z": 7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": 2.568880108633209e-10, + "z": 7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": 2.568880108633209e-10, + "z": 7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": -1.4901161193847657e-8, + "y": -0.011438355781137944, + "z": 0.006218722090125084, + "w": 0.9999155402183533 + }, + { + "x": 0.0, + "y": 2.568880108633209e-10, + "z": 7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": 2.568880108633209e-10, + "z": 7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": -5.960464477539063e-8, + "y": 0.009609760716557503, + "z": -0.007768773473799229, + "w": 0.9999237656593323 + }, + { + "x": 0.7031658291816711, + "y": -0.24679939448833466, + "z": 0.24397972226142884, + "w": 0.6205821633338928 + }, + { + "x": 0.707785964012146, + "y": -0.22929760813713075, + "z": 0.2319946587085724, + "w": 0.6266101598739624 + }, + { + "x": 0.7000758051872253, + "y": -0.2624654173851013, + "z": 0.2513979971408844, + "w": 0.6146586537361145 + }, + { + "x": -1.4901161193847657e-8, + "y": 0.009698144160211087, + "z": 2.0780134946107866e-8, + "w": 0.9999532699584961 + }, + { + "x": -0.000002332031726837158, + "y": 0.01926567777991295, + "z": -0.014442428946495057, + "w": 0.999710202217102 + }, + { + "x": -0.00009603053331375122, + "y": 0.016674719750881196, + "z": -0.017365923151373864, + "w": 0.9997105002403259 + }, + { + "x": 0.0, + "y": -2.568880108633209e-10, + "z": -7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": -2.568880108633209e-10, + "z": -7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": -2.568880108633209e-10, + "z": -7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": -2.568880108633209e-10, + "z": -7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": -2.568880108633209e-10, + "z": -7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": -1.4901161193847657e-8, + "y": 0.011438355781137944, + "z": -0.006218722090125084, + "w": 0.9999155402183533 + }, + { + "x": 0.0, + "y": -2.568880108633209e-10, + "z": -7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": 0.0, + "y": -2.568880108633209e-10, + "z": -7.0387162764973258e-9, + "w": 1.000000238418579 + }, + { + "x": -5.960464477539063e-8, + "y": -0.009609760716557503, + "z": 0.007768773473799229, + "w": 0.9999237656593323 + }, + { + "x": -0.16829019784927369, + "y": 0.0, + "z": 0.0, + "w": 0.9857375621795654 + } + ] + }, + "notes": "" + } + ], + "expressions": [ + { + "time": 0.0, + "values": { + "names": [ + "happy", + "angry", + "sad", + "relaxed", + "surprised", + "aa", + "ih", + "ou", + "ee", + "oh", + "blink", + "blinkLeft", + "blinkRight", + "lookUp", + "lookDown", + "lookLeft", + "lookRight", + "neutral" + ], + "types": [], + "sources": [], + "scalars": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "positions": [], + "rotations": [] + }, + "notes": "" + } + ], + "lookAt": [ + { + "time": 0.0, + "values": { + "names": [ + "yaw", + "pitch" + ], + "types": [], + "sources": [], + "scalars": [ + 0.0, + 0.0 + ], + "positions": [], + "rotations": [] + }, + "notes": "" + } + ], + "constraints": [ + { + "time": 0.0, + "values": { + "names": [], + "types": [], + "sources": [], + "scalars": [], + "positions": [], + "rotations": [] + }, + "notes": "" + } + ], + "springPositions": [ + { + "time": 0.0, + "values": { + "names": [ + "J_Sec_L_Bust1", + "J_Sec_L_Bust2", + "J_Sec_L_Bust2_end", + "J_Sec_R_Bust1", + "J_Sec_R_Bust2", + "J_Sec_R_Bust2_end", + "J_Sec_L_SkirtBack1_01", + "J_Sec_L_SkirtBack2_01", + "J_Sec_L_SkirtBack2_end_01", + "J_Sec_L_SkirtBack2_end_01_end", + "J_Sec_L_SkirtFront1_01", + "J_Sec_L_SkirtFront2_01", + "J_Sec_L_SkirtFront2_end_01", + "J_Sec_L_SkirtFront2_end_01_end", + "J_Sec_L_SkirtSide1_01", + "J_Sec_L_SkirtSide2_01", + "J_Sec_L_SkirtSide2_end_01", + "J_Sec_L_SkirtSide2_end_01_end", + "J_Sec_R_SkirtBack1_01", + "J_Sec_R_SkirtBack2_01", + "J_Sec_R_SkirtBack2_end_01", + "J_Sec_R_SkirtBack2_end_01_end", + "J_Sec_R_SkirtFront1_01", + "J_Sec_R_SkirtFront2_01", + "J_Sec_R_SkirtFront2_end_01", + "J_Sec_R_SkirtFront2_end_01_end", + "J_Sec_R_SkirtSide1_01", + "J_Sec_R_SkirtSide2_01", + "J_Sec_R_SkirtSide2_end_01", + "J_Sec_R_SkirtSide2_end_01_end", + "J_Sec_L_SkirtBack1_02", + "J_Sec_L_SkirtBack2_02", + "J_Sec_L_SkirtBack2_end_02", + "J_Sec_L_SkirtBack2_end_02_end", + "J_Sec_L_SkirtFront1_02", + "J_Sec_L_SkirtFront2_02", + "J_Sec_L_SkirtFront2_end_02", + "J_Sec_L_SkirtFront2_end_02_end", + "J_Sec_L_SkirtSide1_02", + "J_Sec_L_SkirtSide2_02", + "J_Sec_L_SkirtSide2_end_02", + "J_Sec_L_SkirtSide2_end_02_end", + "J_Sec_R_SkirtBack1_02", + "J_Sec_R_SkirtBack2_02", + "J_Sec_R_SkirtBack2_end_02", + "J_Sec_R_SkirtBack2_end_02_end", + "J_Sec_R_SkirtFront1_02", + "J_Sec_R_SkirtFront2_02", + "J_Sec_R_SkirtFront2_end_02", + "J_Sec_R_SkirtFront2_end_02_end", + "J_Sec_R_SkirtSide1_02", + "J_Sec_R_SkirtSide2_02", + "J_Sec_R_SkirtSide2_end_02", + "J_Sec_R_SkirtSide2_end_02_end", + "J_Sec_L_SkirtBack1_03", + "J_Sec_L_SkirtBack2_03", + "J_Sec_L_SkirtBack2_end_03", + "J_Sec_L_SkirtBack2_end_03_end", + "J_Sec_L_SkirtFront1_03", + "J_Sec_L_SkirtFront2_03", + "J_Sec_L_SkirtFront2_end_03", + "J_Sec_L_SkirtFront2_end_03_end", + "J_Sec_L_SkirtSide1_03", + "J_Sec_L_SkirtSide2_03", + "J_Sec_L_SkirtSide2_end_03", + "J_Sec_L_SkirtSide2_end_03_end", + "J_Sec_R_SkirtBack1_03", + "J_Sec_R_SkirtBack2_03", + "J_Sec_R_SkirtBack2_end_03", + "J_Sec_R_SkirtBack2_end_03_end", + "J_Sec_R_SkirtFront1_03", + "J_Sec_R_SkirtFront2_03", + "J_Sec_R_SkirtFront2_end_03", + "J_Sec_R_SkirtFront2_end_03_end", + "J_Sec_R_SkirtSide1_03", + "J_Sec_R_SkirtSide2_03", + "J_Sec_R_SkirtSide2_end_03", + "J_Sec_R_SkirtSide2_end_03_end", + "J_Sec_L_SkirtBack1_04", + "J_Sec_L_SkirtBack2_04", + "J_Sec_L_SkirtBack2_end_04", + "J_Sec_L_SkirtBack2_end_04_end", + "J_Sec_L_SkirtFront1_04", + "J_Sec_L_SkirtFront2_04", + "J_Sec_L_SkirtFront2_end_04", + "J_Sec_L_SkirtFront2_end_04_end", + "J_Sec_L_SkirtSide1_04", + "J_Sec_L_SkirtSide2_04", + "J_Sec_L_SkirtSide2_end_04", + "J_Sec_L_SkirtSide2_end_04_end", + "J_Sec_R_SkirtBack1_04", + "J_Sec_R_SkirtBack2_04", + "J_Sec_R_SkirtBack2_end_04", + "J_Sec_R_SkirtBack2_end_04_end", + "J_Sec_R_SkirtFront1_04", + "J_Sec_R_SkirtFront2_04", + "J_Sec_R_SkirtFront2_end_04", + "J_Sec_R_SkirtFront2_end_04_end", + "J_Sec_R_SkirtSide1_04", + "J_Sec_R_SkirtSide2_04", + "J_Sec_R_SkirtSide2_end_04", + "J_Sec_R_SkirtSide2_end_04_end", + "J_Sec_Hair1_04", + "J_Sec_Hair2_04", + "J_Sec_Hair3_04", + "J_Sec_Hair3_04_end", + "J_Sec_Hair1_03", + "J_Sec_Hair2_03", + "J_Sec_Hair3_03", + "J_Sec_Hair3_03_end", + "J_Sec_Hair1_02", + "J_Sec_Hair2_02", + "J_Sec_Hair3_02", + "J_Sec_Hair3_02_end", + "J_Sec_Hair1_01", + "J_Sec_Hair2_01", + "J_Sec_Hair3_01", + "J_Sec_Hair3_01_end", + "J_Sec_Hair1_05", + "J_Sec_Hair2_05", + "J_Sec_Hair3_05", + "J_Sec_Hair3_05_end", + "J_Sec_Hair1_06", + "J_Sec_Hair2_06", + "J_Sec_Hair3_06", + "J_Sec_Hair4_06", + "J_Sec_Hair5_06", + "J_Sec_Hair5_06_end", + "J_Sec_Hair1_07", + "J_Sec_Hair2_07", + "J_Sec_Hair3_07", + "J_Sec_Hair4_07", + "J_Sec_Hair5_07", + "J_Sec_Hair5_07_end", + "J_Sec_Hair1_08", + "J_Sec_Hair2_08", + "J_Sec_Hair3_08", + "J_Sec_Hair4_08", + "J_Sec_Hair5_08", + "J_Sec_Hair5_08_end", + "J_Sec_Hair1_09", + "J_Sec_Hair2_09", + "J_Sec_Hair3_09", + "J_Sec_Hair3_09_end", + "J_Sec_Hair1_10", + "J_Sec_Hair2_10", + "J_Sec_Hair3_10", + "J_Sec_Hair3_10_end" + ], + "types": [], + "sources": [], + "scalars": [], + "positions": [ + { + "x": -0.048148397356271747, + "y": 0.9945499300956726, + "z": 0.06709028780460358 + }, + { + "x": -0.06757662445306778, + "y": 0.9992930889129639, + "z": 0.10563720762729645 + }, + { + "x": -0.09889371693134308, + "y": 1.0069386959075928, + "z": 0.16777239739894868 + }, + { + "x": 0.048148397356271747, + "y": 0.9945499300956726, + "z": 0.06709028780460358 + }, + { + "x": 0.06757662445306778, + "y": 0.9992930889129639, + "z": 0.10563720762729645 + }, + { + "x": 0.09889371693134308, + "y": 1.0069386959075928, + "z": 0.16777239739894868 + }, + { + "x": -0.07089540362358093, + "y": 0.7500656843185425, + "z": -0.11796291172504425 + }, + { + "x": -0.09932579100131989, + "y": 0.6501379013061523, + "z": -0.16674405336380006 + }, + { + "x": -0.12712682783603669, + "y": 0.5489388704299927, + "z": -0.21096903085708619 + }, + { + "x": -0.14421473443508149, + "y": 0.48673683404922488, + "z": -0.23815196752548219 + }, + { + "x": -0.06675606966018677, + "y": 0.7245396375656128, + "z": 0.13191013038158418 + }, + { + "x": -0.0867239385843277, + "y": 0.622756838798523, + "z": 0.15035948157310487 + }, + { + "x": -0.11108507215976715, + "y": 0.49847522377967837, + "z": 0.17291884124279023 + }, + { + "x": -0.12186066061258316, + "y": 0.4299764037132263, + "z": 0.1824994683265686 + }, + { + "x": -0.17587488889694215, + "y": 0.7419734597206116, + "z": 0.011391235515475273 + }, + { + "x": -0.2233486771583557, + "y": 0.6273974180221558, + "z": 0.004376260098069906 + }, + { + "x": -0.26170384883880618, + "y": 0.5180605053901672, + "z": -0.0014383126981556416 + }, + { + "x": -0.2823503315448761, + "y": 0.45130133628845217, + "z": -0.0055542499758303169 + }, + { + "x": 0.07089540362358093, + "y": 0.7500655055046082, + "z": -0.1179632619023323 + }, + { + "x": 0.09932577610015869, + "y": 0.6501376032829285, + "z": -0.16674424707889558 + }, + { + "x": 0.1271272599697113, + "y": 0.548939049243927, + "z": -0.210969015955925 + }, + { + "x": 0.14421549439430238, + "y": 0.48673713207244875, + "z": -0.23815186321735383 + }, + { + "x": 0.06675608456134796, + "y": 0.7245395183563232, + "z": 0.13191038370132447 + }, + { + "x": 0.0867239385843277, + "y": 0.6227569580078125, + "z": 0.15035958588123322 + }, + { + "x": 0.11108465492725373, + "y": 0.4984755218029022, + "z": 0.17291904985904694 + }, + { + "x": 0.12434062361717224, + "y": 0.43084728717803957, + "z": 0.18519486486911775 + }, + { + "x": 0.17587468028068543, + "y": 0.7419732809066773, + "z": 0.01139121688902378 + }, + { + "x": 0.22334825992584229, + "y": 0.6273978352546692, + "z": 0.004376220982521772 + }, + { + "x": 0.2617034316062927, + "y": 0.518060564994812, + "z": -0.0014383126981556416 + }, + { + "x": 0.284845769405365, + "y": 0.45208993554115298, + "z": -0.004946617875248194 + }, + { + "x": -0.07089540362358093, + "y": 0.7500656843185425, + "z": -0.11796291172504425 + }, + { + "x": -0.09932579100131989, + "y": 0.6501379013061523, + "z": -0.16674405336380006 + }, + { + "x": -0.12712682783603669, + "y": 0.5489388704299927, + "z": -0.21096903085708619 + }, + { + "x": -0.14421473443508149, + "y": 0.48673683404922488, + "z": -0.23815196752548219 + }, + { + "x": -0.06675606966018677, + "y": 0.7245396375656128, + "z": 0.13191013038158418 + }, + { + "x": -0.0867239385843277, + "y": 0.622756838798523, + "z": 0.15035948157310487 + }, + { + "x": -0.11108507215976715, + "y": 0.49847522377967837, + "z": 0.17291884124279023 + }, + { + "x": -0.12186066061258316, + "y": 0.4299764037132263, + "z": 0.1824994683265686 + }, + { + "x": -0.17587488889694215, + "y": 0.7419734597206116, + "z": 0.011391235515475273 + }, + { + "x": -0.2233486771583557, + "y": 0.6273974180221558, + "z": 0.004376260098069906 + }, + { + "x": -0.26170384883880618, + "y": 0.5180605053901672, + "z": -0.0014383126981556416 + }, + { + "x": -0.2823503315448761, + "y": 0.45130133628845217, + "z": -0.0055542499758303169 + }, + { + "x": 0.07089540362358093, + "y": 0.7500655055046082, + "z": -0.1179632619023323 + }, + { + "x": 0.09932577610015869, + "y": 0.6501376032829285, + "z": -0.16674424707889558 + }, + { + "x": 0.1271272599697113, + "y": 0.548939049243927, + "z": -0.210969015955925 + }, + { + "x": 0.14421549439430238, + "y": 0.48673713207244875, + "z": -0.23815186321735383 + }, + { + "x": 0.06675608456134796, + "y": 0.7245395183563232, + "z": 0.13191038370132447 + }, + { + "x": 0.0867239385843277, + "y": 0.6227569580078125, + "z": 0.15035958588123322 + }, + { + "x": 0.11108465492725373, + "y": 0.4984755218029022, + "z": 0.17291904985904694 + }, + { + "x": 0.12434062361717224, + "y": 0.43084728717803957, + "z": 0.18519486486911775 + }, + { + "x": 0.17587468028068543, + "y": 0.7419732809066773, + "z": 0.01139121688902378 + }, + { + "x": 0.22334825992584229, + "y": 0.6273978352546692, + "z": 0.004376220982521772 + }, + { + "x": 0.2617034316062927, + "y": 0.518060564994812, + "z": -0.0014383126981556416 + }, + { + "x": 0.284845769405365, + "y": 0.45208993554115298, + "z": -0.004946617875248194 + }, + { + "x": -0.07089540362358093, + "y": 0.7500656843185425, + "z": -0.11796291172504425 + }, + { + "x": -0.09932579100131989, + "y": 0.6501379013061523, + "z": -0.16674405336380006 + }, + { + "x": -0.12712682783603669, + "y": 0.5489388704299927, + "z": -0.21096903085708619 + }, + { + "x": -0.14421473443508149, + "y": 0.48673683404922488, + "z": -0.23815196752548219 + }, + { + "x": -0.06675606966018677, + "y": 0.7245396375656128, + "z": 0.13191013038158418 + }, + { + "x": -0.0867239385843277, + "y": 0.622756838798523, + "z": 0.15035948157310487 + }, + { + "x": -0.11108507215976715, + "y": 0.49847522377967837, + "z": 0.17291884124279023 + }, + { + "x": -0.12186066061258316, + "y": 0.4299764037132263, + "z": 0.1824994683265686 + }, + { + "x": -0.17587488889694215, + "y": 0.7419734597206116, + "z": 0.011391235515475273 + }, + { + "x": -0.2233486771583557, + "y": 0.6273974180221558, + "z": 0.004376260098069906 + }, + { + "x": -0.26170384883880618, + "y": 0.5180605053901672, + "z": -0.0014383126981556416 + }, + { + "x": -0.2823503315448761, + "y": 0.45130133628845217, + "z": -0.0055542499758303169 + }, + { + "x": 0.07089540362358093, + "y": 0.7500655055046082, + "z": -0.1179632619023323 + }, + { + "x": 0.09932577610015869, + "y": 0.6501376032829285, + "z": -0.16674424707889558 + }, + { + "x": 0.1271272599697113, + "y": 0.548939049243927, + "z": -0.210969015955925 + }, + { + "x": 0.14421549439430238, + "y": 0.48673713207244875, + "z": -0.23815186321735383 + }, + { + "x": 0.06675608456134796, + "y": 0.7245395183563232, + "z": 0.13191038370132447 + }, + { + "x": 0.0867239385843277, + "y": 0.6227569580078125, + "z": 0.15035958588123322 + }, + { + "x": 0.11108465492725373, + "y": 0.4984755218029022, + "z": 0.17291904985904694 + }, + { + "x": 0.12434062361717224, + "y": 0.43084728717803957, + "z": 0.18519486486911775 + }, + { + "x": 0.17587468028068543, + "y": 0.7419732809066773, + "z": 0.01139121688902378 + }, + { + "x": 0.22334825992584229, + "y": 0.6273978352546692, + "z": 0.004376220982521772 + }, + { + "x": 0.2617034316062927, + "y": 0.518060564994812, + "z": -0.0014383126981556416 + }, + { + "x": 0.284845769405365, + "y": 0.45208993554115298, + "z": -0.004946617875248194 + }, + { + "x": -0.07089540362358093, + "y": 0.7500656843185425, + "z": -0.11796291172504425 + }, + { + "x": -0.09932579100131989, + "y": 0.6501379013061523, + "z": -0.16674405336380006 + }, + { + "x": -0.12712682783603669, + "y": 0.5489388704299927, + "z": -0.21096903085708619 + }, + { + "x": -0.14421473443508149, + "y": 0.48673683404922488, + "z": -0.23815196752548219 + }, + { + "x": -0.06675606966018677, + "y": 0.7245396375656128, + "z": 0.13191013038158418 + }, + { + "x": -0.0867239385843277, + "y": 0.622756838798523, + "z": 0.15035948157310487 + }, + { + "x": -0.11108507215976715, + "y": 0.49847522377967837, + "z": 0.17291884124279023 + }, + { + "x": -0.12186066061258316, + "y": 0.4299764037132263, + "z": 0.1824994683265686 + }, + { + "x": -0.17587488889694215, + "y": 0.7419734597206116, + "z": 0.011391235515475273 + }, + { + "x": -0.2233486771583557, + "y": 0.6273974180221558, + "z": 0.004376260098069906 + }, + { + "x": -0.26170384883880618, + "y": 0.5180605053901672, + "z": -0.0014383126981556416 + }, + { + "x": -0.2823503315448761, + "y": 0.45130133628845217, + "z": -0.0055542499758303169 + }, + { + "x": 0.07089540362358093, + "y": 0.7500655055046082, + "z": -0.1179632619023323 + }, + { + "x": 0.09932577610015869, + "y": 0.6501376032829285, + "z": -0.16674424707889558 + }, + { + "x": 0.1271272599697113, + "y": 0.548939049243927, + "z": -0.210969015955925 + }, + { + "x": 0.14421549439430238, + "y": 0.48673713207244875, + "z": -0.23815186321735383 + }, + { + "x": 0.06675608456134796, + "y": 0.7245395183563232, + "z": 0.13191038370132447 + }, + { + "x": 0.0867239385843277, + "y": 0.6227569580078125, + "z": 0.15035958588123322 + }, + { + "x": 0.11108465492725373, + "y": 0.4984755218029022, + "z": 0.17291904985904694 + }, + { + "x": 0.12434062361717224, + "y": 0.43084728717803957, + "z": 0.18519486486911775 + }, + { + "x": 0.17587468028068543, + "y": 0.7419732809066773, + "z": 0.01139121688902378 + }, + { + "x": 0.22334825992584229, + "y": 0.6273978352546692, + "z": 0.004376220982521772 + }, + { + "x": 0.2617034316062927, + "y": 0.518060564994812, + "z": -0.0014383126981556416 + }, + { + "x": 0.284845769405365, + "y": 0.45208993554115298, + "z": -0.004946617875248194 + }, + { + "x": 0.002106240252032876, + "y": 1.271728754043579, + "z": 0.07247321307659149 + }, + { + "x": -0.003205553162842989, + "y": 1.2453420162200928, + "z": 0.0708560049533844 + }, + { + "x": -0.015547368675470353, + "y": 1.2232511043548585, + "z": 0.06299068033695221 + }, + { + "x": -0.04814962297677994, + "y": 1.1648952960968018, + "z": 0.042213521897792819 + }, + { + "x": 0.03993716463446617, + "y": 1.2751071453094483, + "z": 0.05696595087647438 + }, + { + "x": 0.03980923444032669, + "y": 1.2484886646270753, + "z": 0.058436695486307147 + }, + { + "x": 0.03946668282151222, + "y": 1.2241829633712769, + "z": 0.05393451824784279 + }, + { + "x": 0.03849674016237259, + "y": 1.155360460281372, + "z": 0.04118645563721657 + }, + { + "x": -0.06930165737867356, + "y": 1.2258236408233643, + "z": 0.03377702087163925 + }, + { + "x": -0.0597473606467247, + "y": 1.2016453742980958, + "z": 0.03892624378204346 + }, + { + "x": -0.04323567450046539, + "y": 1.184570074081421, + "z": 0.04796287417411804 + }, + { + "x": 0.0022439882159233095, + "y": 1.1375372409820557, + "z": 0.07285338640213013 + }, + { + "x": -0.0807902067899704, + "y": 1.207162618637085, + "z": 0.006823513191193342 + }, + { + "x": -0.07171743363142014, + "y": 1.153550148010254, + "z": 0.011183176189661026 + }, + { + "x": -0.0657748207449913, + "y": 1.1106343269348145, + "z": 0.01193980872631073 + }, + { + "x": -0.05617490038275719, + "y": 1.041306495666504, + "z": 0.013162106275558472 + }, + { + "x": 0.08112847059965134, + "y": 1.202105164527893, + "z": 0.006959061603993177 + }, + { + "x": 0.070832759141922, + "y": 1.1513406038284302, + "z": 0.011632952839136124 + }, + { + "x": 0.06577473133802414, + "y": 1.1106343269348145, + "z": 0.011939838528633118 + }, + { + "x": 0.05714340880513191, + "y": 1.0411704778671265, + "z": 0.012463558465242386 + }, + { + "x": -0.06832551211118698, + "y": 1.2083232402801514, + "z": -0.09941787272691727 + }, + { + "x": -0.08213266730308533, + "y": 1.1279447078704835, + "z": -0.12504342198371888 + }, + { + "x": -0.11347656697034836, + "y": 1.0555658340454102, + "z": -0.15805105865001679 + }, + { + "x": -0.13665327429771424, + "y": 0.9754927754402161, + "z": -0.1675022542476654 + }, + { + "x": -0.11319878697395325, + "y": 0.9010841846466065, + "z": -0.16916227340698243 + }, + { + "x": -0.09215942025184632, + "y": 0.8343373537063599, + "z": -0.1706513613462448 + }, + { + "x": 0.0005053504137322307, + "y": 1.2002639770507813, + "z": -0.12364683300256729 + }, + { + "x": 0.0007707360200583935, + "y": 1.116365909576416, + "z": -0.14518669247627259 + }, + { + "x": 0.006893301382660866, + "y": 1.0418455600738526, + "z": -0.18895500898361207 + }, + { + "x": 0.013607216067612172, + "y": 0.958453357219696, + "z": -0.20054247975349427 + }, + { + "x": -0.0006361373234540224, + "y": 0.8799299597740173, + "z": -0.18816860020160676 + }, + { + "x": -0.012982039712369442, + "y": 0.8118671178817749, + "z": -0.17744314670562745 + }, + { + "x": 0.06634146720170975, + "y": 1.2112808227539063, + "z": -0.09326324611902237 + }, + { + "x": 0.08029892295598984, + "y": 1.1274266242980958, + "z": -0.11922312527894974 + }, + { + "x": 0.11265724897384644, + "y": 1.0499968528747559, + "z": -0.147381991147995 + }, + { + "x": 0.12530763447284699, + "y": 0.9660741090774536, + "z": -0.15833178162574769 + }, + { + "x": 0.10458599030971527, + "y": 0.8845513463020325, + "z": -0.15451912581920625 + }, + { + "x": 0.0873592272400856, + "y": 0.8167782425880432, + "z": -0.15134936571121217 + }, + { + "x": -0.031091442331671716, + "y": 1.2830647230148316, + "z": 0.06162622198462486 + }, + { + "x": -0.040076371282339099, + "y": 1.2563807964324952, + "z": 0.05870186537504196 + }, + { + "x": -0.041460320353507999, + "y": 1.2287423610687256, + "z": 0.053943317383527759 + }, + { + "x": -0.04491044580936432, + "y": 1.1598412990570069, + "z": 0.04208042100071907 + }, + { + "x": 0.06968508660793305, + "y": 1.2277696132659913, + "z": 0.03351595997810364 + }, + { + "x": 0.06023162975907326, + "y": 1.2024788856506348, + "z": 0.03871463984251022 + }, + { + "x": 0.043235622346401218, + "y": 1.184570074081421, + "z": 0.04796287789940834 + }, + { + "x": -0.0018890646751970053, + "y": 1.137021541595459, + "z": 0.07251714169979096 + } + ], + "rotations": [] + }, + "notes": "" + } + ], + "vrma": [ + { + "time": 0.0, + "values": { + "names": [ + "Hips", + "LeftUpperLeg", + "RightUpperLeg", + "LeftLowerLeg", + "RightLowerLeg", + "LeftFoot", + "RightFoot", + "Spine", + "Chest", + "Neck", + "Head", + "LeftShoulder", + "RightShoulder", + "LeftUpperArm", + "RightUpperArm", + "LeftLowerArm", + "RightLowerArm", + "LeftHand", + "RightHand", + "LeftToes", + "RightToes", + "LeftEye", + "RightEye", + "LeftThumbProximal", + "LeftThumbIntermediate", + "LeftThumbDistal", + "LeftIndexProximal", + "LeftIndexIntermediate", + "LeftIndexDistal", + "LeftMiddleProximal", + "LeftMiddleIntermediate", + "LeftMiddleDistal", + "LeftRingProximal", + "LeftRingIntermediate", + "LeftRingDistal", + "LeftLittleProximal", + "LeftLittleIntermediate", + "LeftLittleDistal", + "RightThumbProximal", + "RightThumbIntermediate", + "RightThumbDistal", + "RightIndexProximal", + "RightIndexIntermediate", + "RightIndexDistal", + "RightMiddleProximal", + "RightMiddleIntermediate", + "RightMiddleDistal", + "RightRingProximal", + "RightRingIntermediate", + "RightRingDistal", + "RightLittleProximal", + "RightLittleIntermediate", + "RightLittleDistal", + "UpperChest" + ], + "types": [], + "sources": [], + "scalars": [], + "positions": [ + { + "x": 0.0, + "y": 0.7576853632926941, + "z": 0.003810133319348097 + }, + { + "x": -0.0704415813088417, + "y": 0.7216354012489319, + "z": 0.0005060369148850441 + }, + { + "x": 0.0704415813088417, + "y": 0.7216354012489319, + "z": 0.0005060369148850441 + }, + { + "x": -0.07044156640768051, + "y": 0.4303874373435974, + "z": -0.005588908214122057 + }, + { + "x": 0.07044156640768051, + "y": 0.4303874373435974, + "z": -0.005588908214122057 + }, + { + "x": -0.07044156640768051, + "y": 0.08535408973693848, + "z": -0.02657494693994522 + }, + { + "x": 0.07044156640768051, + "y": 0.08535408973693848, + "z": -0.02657494693994522 + }, + { + "x": 5.845327273341027e-32, + "y": 0.8041406869888306, + "z": 0.015106672421097756 + }, + { + "x": -1.9799062615463025e-17, + "y": 0.9036961197853088, + "z": 0.017324669286608697 + }, + { + "x": 9.846644191778954e-17, + "y": 1.1220831871032715, + "z": -0.031006742268800737 + }, + { + "x": -1.4773517131860815e-10, + "y": 1.1792173385620118, + "z": -0.0238069798797369 + }, + { + "x": -0.02044639177620411, + "y": 1.0977709293365479, + "z": -0.023341333493590356 + }, + { + "x": 0.02044639177620411, + "y": 1.0977709293365479, + "z": -0.023341333493590356 + }, + { + "x": -0.09357699751853943, + "y": 1.0873539447784424, + "z": -0.023341266438364984 + }, + { + "x": 0.09357699751853943, + "y": 1.0873539447784424, + "z": -0.023341266438364984 + }, + { + "x": -0.2845059931278229, + "y": 1.0873539447784424, + "z": -0.023341266438364984 + }, + { + "x": 0.2845059931278229, + "y": 1.0873539447784424, + "z": -0.023341266438364984 + }, + { + "x": -0.46918749809265139, + "y": 1.0873562097549439, + "z": -0.023329799994826318 + }, + { + "x": 0.46918749809265139, + "y": 1.0873562097549439, + "z": -0.023329799994826318 + }, + { + "x": -0.07044156640768051, + "y": 0.03945821523666382, + "z": 0.05399119108915329 + }, + { + "x": 0.07044156640768051, + "y": 0.03945821523666382, + "z": 0.05399119108915329 + }, + { + "x": -0.013359913602471352, + "y": 1.2342658042907715, + "z": -0.0052421544678509239 + }, + { + "x": 0.01335990708321333, + "y": 1.2342658042907715, + "z": -0.005242135841399431 + }, + { + "x": -0.47210997343063357, + "y": 1.079047441482544, + "z": -0.010077446699142456 + }, + { + "x": -0.5014460682868958, + "y": 1.077335000038147, + "z": 0.015045689418911934 + }, + { + "x": -0.5201584696769714, + "y": 1.0765297412872315, + "z": 0.029698239639401437 + }, + { + "x": -0.5234929323196411, + "y": 1.0934830904006959, + "z": -0.00701130973175168 + }, + { + "x": -0.5510778427124023, + "y": 1.0934829711914063, + "z": -0.007547762710601091 + }, + { + "x": -0.5680448412895203, + "y": 1.0929920673370362, + "z": -0.008202727884054184 + }, + { + "x": -0.5242327451705933, + "y": 1.0934832096099854, + "z": -0.021630456671118737 + }, + { + "x": -0.5549647212028503, + "y": 1.0934829711914063, + "z": -0.021630464121699334 + }, + { + "x": -0.5739237666130066, + "y": 1.0934832096099854, + "z": -0.021630479022860528 + }, + { + "x": -0.5236058831214905, + "y": 1.0934832096099854, + "z": -0.034610290080308917 + }, + { + "x": -0.5521131753921509, + "y": 1.0934830904006959, + "z": -0.03461030498147011 + }, + { + "x": -0.5685652494430542, + "y": 1.0934832096099854, + "z": -0.03461029753088951 + }, + { + "x": -0.519858717918396, + "y": 1.0934830904006959, + "z": -0.04755069687962532 + }, + { + "x": -0.5459350347518921, + "y": 1.0934829711914063, + "z": -0.04755069687962532 + }, + { + "x": -0.5609633326530457, + "y": 1.0934832096099854, + "z": -0.04755069687962532 + }, + { + "x": 0.47210997343063357, + "y": 1.079047441482544, + "z": -0.010077446699142456 + }, + { + "x": 0.5014460682868958, + "y": 1.077335000038147, + "z": 0.015045689418911934 + }, + { + "x": 0.5201584696769714, + "y": 1.0765297412872315, + "z": 0.029698239639401437 + }, + { + "x": 0.5234929323196411, + "y": 1.0934830904006959, + "z": -0.00701130973175168 + }, + { + "x": 0.5510778427124023, + "y": 1.0934829711914063, + "z": -0.007547762710601091 + }, + { + "x": 0.5680448412895203, + "y": 1.0929920673370362, + "z": -0.008202727884054184 + }, + { + "x": 0.5242327451705933, + "y": 1.0934832096099854, + "z": -0.021630456671118737 + }, + { + "x": 0.5549647212028503, + "y": 1.0934829711914063, + "z": -0.021630464121699334 + }, + { + "x": 0.5739237666130066, + "y": 1.0934832096099854, + "z": -0.021630479022860528 + }, + { + "x": 0.5236058831214905, + "y": 1.0934832096099854, + "z": -0.034610290080308917 + }, + { + "x": 0.5521131753921509, + "y": 1.0934830904006959, + "z": -0.03461030498147011 + }, + { + "x": 0.5685652494430542, + "y": 1.0934832096099854, + "z": -0.03461029753088951 + }, + { + "x": 0.519858717918396, + "y": 1.0934830904006959, + "z": -0.04755069687962532 + }, + { + "x": 0.5459350347518921, + "y": 1.0934829711914063, + "z": -0.04755069687962532 + }, + { + "x": 0.5609633326530457, + "y": 1.0934832096099854, + "z": -0.04755069687962532 + }, + { + "x": 8.778872804108958e-18, + "y": 1.0011816024780274, + "z": 0.0040480452589690689 + } + ], + "rotations": [ + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + } + ] + }, + "notes": "No UniVRM VRMA controller is attached; this is the current runtime pose, not a VRMA differential sample." + } + ] + } +} \ No newline at end of file diff --git a/Tests/Assets/VRM/AvatarSample_M.vrm b/Tests/Assets/VRM/AvatarSample_M.vrm new file mode 100644 index 00000000..eb48dbf7 Binary files /dev/null and b/Tests/Assets/VRM/AvatarSample_M.vrm differ diff --git a/Tests/VRMKitTests/GLTFEditableDocumentTests.swift b/Tests/VRMKitTests/GLTFEditableDocumentTests.swift index 7862fd89..d89664da 100644 --- a/Tests/VRMKitTests/GLTFEditableDocumentTests.swift +++ b/Tests/VRMKitTests/GLTFEditableDocumentTests.swift @@ -22,6 +22,34 @@ struct GLTFEditableDocumentTests { try expectSameBufferViews(original, reloaded) } + /// A synthetic, unrecognized extension is not the same as one of VRMKit's own; a + /// round trip has to carry both a root-level and a node-level one through untouched, + /// with the field names and nesting a real exporter might use. + @Test + func testAnUnrecognizedExtensionRoundTripsUntouched() throws { + let json = try VRMSampleAsset.seedSan.rewritingJSON { json in + json.withObject("extensions") { extensions in + extensions["EXAMPLE_totally_unmodeled"] = [ + "aString": "kept as written", + "aNumber": 42, + "nested": ["array": [1, 2, 3]] + ] + } + var nodes = json.objects("nodes") + var nodeExtensions = nodes[0].object("extensions") ?? [:] + nodeExtensions["EXAMPLE_per_node_thing"] = ["tag": "unchanged"] + nodes[0].set("extensions", nodeExtensions) + json.set("nodes", nodes) + } + + let reloaded = try GLTFDocument(data: try GLTFEditableDocument(data: json).serialize()) + let rawExtensions = try reloaded.rawJSON().object("extensions") + #expect(rawExtensions?.object("EXAMPLE_totally_unmodeled") == + ["aString": "kept as written", "aNumber": 42, "nested": ["array": [1, 2, 3]]]) + let nodeExtensions = try reloaded.rawJSON().objects("nodes")[0].object("extensions") + #expect(nodeExtensions?.object("EXAMPLE_per_node_thing") == ["tag": "unchanged"]) + } + /// A `.gltf` with external resources becomes a self-contained GLB: its buffers and /// images move into the one buffer a GLB carries, and nothing else changes. @Test(arguments: GLTFSampleAsset.allCases) diff --git a/Tests/VRMKitTests/GLTFPruneTests.swift b/Tests/VRMKitTests/GLTFPruneTests.swift index 56b43b90..0dfb68b1 100644 --- a/Tests/VRMKitTests/GLTFPruneTests.swift +++ b/Tests/VRMKitTests/GLTFPruneTests.swift @@ -35,17 +35,17 @@ struct GLTFPruneTests { @Test(arguments: VRMSampleAsset.allCases) func testPruneKeepsTheVRMExtensionsPointingAtTheSameEntries(asset: VRMSampleAsset) throws { var document = try GLTFEditableDocument(data: asset.data) - let detached = try #require(drawingSceneRoot(of: try document.typed())) - let gone = [try #require(try document.typed().nodes[detached].name), - try #require(try document.typed().nodes[detached].mesh - .flatMap { try document.typed().meshes[$0].name })] + let subjects = try renderedSubjectNames(of: try GLTFDocument(data: try document.serialize())) + let typed = try document.typed() + let detached = try #require(sceneNodeReferencedByVRMExtensions(of: typed, subjects: subjects)) + let gone = [try #require(typed.nodes[detached].name), + try #require(typed.nodes[detached].mesh.flatMap { typed.meshes[$0].name })] + #expect(!subjects.isDisjoint(with: gone)) try document.detachNode(at: GLTFNodeIndex(detached)) let before = try GLTFDocument(data: try document.serialize()) let bones = try humanoidBoneNames(of: before) let joints = try springBoneJointNames(of: before) - let subjects = try renderedSubjectNames(of: before) #expect(!bones.isEmpty) - #expect(!subjects.isDisjoint(with: gone)) try document.prune() @@ -578,6 +578,20 @@ struct GLTFPruneTests { return (gltf.scenes[scene].nodes ?? []).last { gltf.nodes[safe: $0]?.mesh != nil } } + /// A top-level scene node whose name or mesh name the VRM extensions reference, so + /// detaching it actually exercises whether pruning keeps their bindings pointing at the + /// same entries. Not every mesh-bearing node is bound this way (e.g. hair or accessory + /// meshes with no first-person or expression bind), so `drawingSceneRoot` alone is not + /// specific enough for that assertion. + private func sceneNodeReferencedByVRMExtensions(of gltf: GLTF, subjects: Set) -> Int? { + let scene = gltf.scene ?? 0 + return (gltf.scenes[scene].nodes ?? []).first { index in + guard let node = gltf.nodes[safe: index], node.mesh != nil else { return false } + let meshName = node.mesh.flatMap { gltf.meshes[safe: $0]?.name } + return [node.name, meshName].compactMap { $0 }.contains { subjects.contains($0) } + } + } + private func thumbnailBytes(of document: GLTFDocument) throws -> Data? { let extensions = try document.rawJSON().object("extensions") ?? [:] let image = extensions.object(GLTFExtension.vrm1.rawValue)?.object("meta")?.index("thumbnailImage") diff --git a/Tests/VRMKitTests/HumanoidBoneTests.swift b/Tests/VRMKitTests/HumanoidBoneTests.swift index 9794a41e..d5647728 100644 --- a/Tests/VRMKitTests/HumanoidBoneTests.swift +++ b/Tests/VRMKitTests/HumanoidBoneTests.swift @@ -69,6 +69,31 @@ struct HumanoidBoneTests { #expect(throws: VRMError.self) { try VRM1(data: missingHips) } } + @Test + func testHumanoidValidationReturnsStructuredReferenceDiagnostics() throws { + let invalid = try VRMSampleAsset.seedSan.rewritingJSON { json in + json.withObject("extensions") { extensions in + extensions.withObject(GLTFExtension.vrm1.rawValue) { vrm in + vrm.withObject("humanoid") { humanoid in + humanoid.withObject("humanBones") { bones in + bones.removeValue(forKey: HumanoidBone.spine.rawValue) + bones.withObject(HumanoidBone.head.rawValue) { $0.set("node", 999_999) } + bones.withObject(HumanoidBone.hips.rawValue) { $0.set("node", 1) } + bones.withObject(HumanoidBone.chest.rawValue) { $0.set("node", 1) } + } + } + } + } + } + + let report = try VRM1.validateHumanoid(data: invalid) + #expect(report.missingRequiredBones == [.spine]) + #expect(report.invalidNodeIndices[.head] == 999_999) + #expect(report.duplicateNodeAssignments[1]?.contains(.hips) == true) + #expect(report.duplicateNodeAssignments[1]?.contains(.chest) == true) + #expect(!report.isValid) + } + /// A property VRM does not define is not a reason to fail the parse. @Test func testAnUnknownBoneNameIsIgnored() throws { diff --git a/Tests/VRMKitTests/VRM1ConformanceTests.swift b/Tests/VRMKitTests/VRM1ConformanceTests.swift new file mode 100644 index 00000000..6fd8bef5 --- /dev/null +++ b/Tests/VRMKitTests/VRM1ConformanceTests.swift @@ -0,0 +1,46 @@ +import Testing +import VRMKit +import VRMTestSupport + +@Suite +struct VRM1ConformanceTests { + @Test + func testAvatarSampleMAddsFullHumanoidAndMaterialCoverage() throws { + let vrm = try VRM1(data: VRMSampleAsset.avatarSampleM.data) + let mtoonMaterials = vrm.document.gltf.materials.filter { + $0.extensions?.materialsMToon != nil + } + + #expect(vrm.humanoid.humanBones.bones.count == 54) + #expect(vrm.expressions != nil) + #expect(vrm.lookAt != nil) + #expect(vrm.springBone != nil) + #expect(mtoonMaterials.count == 6) + #expect(vrm.document.gltf.nodes.allSatisfy { + $0.extensions?.nodeConstraint == nil + }) + } + + @Test + func testSpecificationSampleCombinesThePhaseTwoFeatureSet() throws { + let vrm = try VRM1(data: VRMSampleAsset.vrm1ConstraintTwist.data) + let constrainedNodes = vrm.document.gltf.nodes.compactMap { $0.extensions?.nodeConstraint } + let mtoonMaterials = vrm.document.gltf.materials.filter { + $0.extensions?.materialsMToon != nil + } + + #expect(vrm.expressions != nil) + #expect(vrm.lookAt != nil) + #expect(vrm.springBone != nil) + #expect(mtoonMaterials.count > 0) + #expect(vrm.humanoid.humanBones.bones.count > 15) + #expect(constrainedNodes.contains { constraint in + if case .roll = constraint.constraint { return true } + return false + }) + #expect(constrainedNodes.contains { constraint in + if case .aim = constraint.constraint { return true } + return false + }) + } +} diff --git a/Tests/VRMKitTests/VRM1Tests.swift b/Tests/VRMKitTests/VRM1Tests.swift index f63c891b..549c2925 100644 --- a/Tests/VRMKitTests/VRM1Tests.swift +++ b/Tests/VRMKitTests/VRM1Tests.swift @@ -719,6 +719,34 @@ struct VRM1Tests { } } + @Test + func testConstraintTwistSampleContainsRollAndAimConstraints() throws { + let twist = try VRM1(data: VRMSampleAsset.vrm1ConstraintTwist.data) + var rollCount = 0 + var aimCount = 0 + + for node in twist.document.gltf.nodes { + guard let constraint = node.extensions?.nodeConstraint, + let parsed = constraint.constraint else { continue } + #expect(parsed.source >= 0) + #expect(parsed.source < twist.document.gltf.nodes.count) + #expect(parsed.weight >= 0) + #expect(parsed.weight <= 1) + + switch parsed { + case .roll: + rollCount += 1 + case .aim: + aimCount += 1 + case .rotation: + break + } + } + + #expect(rollCount > 0) + #expect(aimCount > 0) + } + /// The version decides how everything else is read, so a document carrying both /// extensions, or neither, is refused rather than guessed at. @Test diff --git a/Tests/VRMKitTests/VRMErrorTests.swift b/Tests/VRMKitTests/VRMErrorTests.swift index e6f2e98a..931e9e27 100644 --- a/Tests/VRMKitTests/VRMErrorTests.swift +++ b/Tests/VRMKitTests/VRMErrorTests.swift @@ -47,4 +47,40 @@ struct VRMErrorTests { let error = try #require(throws: VRMError.self) { try vrm.thumbnail } #expect(error.kind == .thumbnailNotFound) } + + /// However the bytes are cut short, the loader reports a typed error or, rarely, + /// succeeds on a prefix that still happens to be well formed. It never traps, and + /// its message never carries the raw bytes it failed to make sense of. + @Test + func testATruncatedFileAtEveryLengthNeverTrapsOrLeaksRawBytes() { + let whole = VRMSampleAsset.aliciaSolid.data + // A fixed number of evenly spaced samples, so this stays fast regardless of how + // large the fixture is, while still crossing every section of the file at least once. + let sampleCount = 60 + let stride = max(1, whole.count / sampleCount) + for length in Swift.stride(from: 0, to: whole.count, by: stride) { + let prefix = Data(whole.prefix(length)) + do { + _ = try VRM(data: prefix) + } catch let error as VRMError { + #expect(!error.message.contains(prefix.base64EncodedString().prefix(32)), + "message leaked the raw bytes at length \(length)") + } catch { + Issue.record("length \(length) threw \(type(of: error)) instead of VRMError") + } + } + } + + /// Every error a caller can branch on names one of the declared kinds, so a switch + /// over `VRMError.Kind` need not fall back to a raw, undiagnosable case. + @Test + func testMalformedInputsEachCarryOneOfTheDeclaredKinds() throws { + #expect(throws: VRMError.self) { try VRM(data: Data()) } + #expect(throws: VRMError.self) { + try VRM(data: Data([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])) + } + + let notAnObject = try VRMSampleAsset.aliciaSolid.rewritingJSON { $0["extensions"] = .null } + #expect(throws: VRMError.self) { try VRM(data: notAnObject) } + } } diff --git a/Tests/VRMRealityKitTests/AsyncLoadingTests.swift b/Tests/VRMRealityKitTests/AsyncLoadingTests.swift index bd9da863..e0cacf75 100644 --- a/Tests/VRMRealityKitTests/AsyncLoadingTests.swift +++ b/Tests/VRMRealityKitTests/AsyncLoadingTests.swift @@ -14,17 +14,13 @@ import VRMTestSupport struct AsyncLoadingTests { @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) private func meshShape(of entity: Entity) -> [(name: String, vertices: Int, triangles: Int)] { - var shape: [(name: String, vertices: Int, triangles: Int)] = [] - var stack = [entity] - while let next = stack.popLast() { - stack.append(contentsOf: next.children) - guard let model = next.components[ModelComponent.self] else { continue } - let parts = model.mesh.contents.models.flatMap { Array($0.parts) } - shape.append((next.name, - parts.reduce(0) { $0 + $1.positions.count }, - parts.reduce(0) { $0 + ($1.triangleIndices?.count ?? 0) / 3 })) - } - return shape.sorted { $0.name < $1.name } + TestSupport.modelEntities(in: entity) + .compactMap { modelEntity in + modelEntity.gltfMeshGeometry.map { + (modelEntity.name, $0.positions.count, TestSupport.triangleIndexCount(of: $0) / 3) + } + } + .sorted { $0.name < $1.name } } /// The primitives are decoded concurrently, so what a load builds must not @@ -149,5 +145,34 @@ struct AsyncLoadingTests { #expect(builder.prepared.primitives.isEmpty) #expect(!loader.resources.meshTemplates.isEmpty) } + + @Test + func testConcurrentLoadsOnOneLoaderProduceIndependentEntities() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try VRMEntityLoader(withData: VRMSampleAsset.seedSan.data, shaders: []) + async let first = loader.loadEntity() + async let second = loader.loadEntity() + let entities = try await [first, second] + + #expect(entities.count == 2) + #expect(entities[0] !== entities[1]) + #expect(!entities[0].children.isEmpty) + #expect(!entities[1].children.isEmpty) + } + + @Test + func testRepeatedAvatarReplacementDoesNotRetainPreviousEntities() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let loader = try VRMEntityLoader(withData: VRMSampleAsset.seedSan.data, shaders: []) + weak var previous: VRMEntity? + + for _ in 0..<4 { + do { + let replacement = try await loader.loadEntity() + previous = replacement + } + #expect(previous == nil) + } + } } #endif diff --git a/Tests/VRMRealityKitTests/EntityCloneTests.swift b/Tests/VRMRealityKitTests/EntityCloneTests.swift index cb5c3e0e..e273107c 100644 --- a/Tests/VRMRealityKitTests/EntityCloneTests.swift +++ b/Tests/VRMRealityKitTests/EntityCloneTests.swift @@ -94,6 +94,37 @@ struct EntityCloneTests { #endif } + /// A glTF entity attached under the model (an accessory) is a document of its own: its + /// copy takes rows from its own materials, not from the model's material of the same + /// index, and is lit on its own too. + @Test + func testACloneWithOwnMaterialParametersKeepsANestedEntitysOwnMaterials() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *), TestSupport.isMToonRenderingAvailable else { return } + let vrmEntity = try await VRMEntityLoader(withData: TestSupport.seedSanData, + shaders: TestSupport.noOutlineShaders).loadEntity() + let accessory = try await GLTFEntityLoader(withURL: GLTFSampleAsset.animatedMorphCube.url, + shaders: [MToonShader(source: .convertAll(MToonConversionStyle()))]).loadEntity() + let head = try #require(vrmEntity.humanoid.node(for: .head)) + head.addChild(accessory) + let tint = SIMD3(0.2, 0.4, 0.6) + accessory.setMToonLightColor(tint) + let accessoryIndex = try #require(accessory.materialIndices(under: accessory).first) + let accessoryBase = try #require(accessory.mtoonParameters(forMaterialIndex: accessoryIndex)?.baseColor) + + let copy = vrmEntity.cloneWithOwnMaterialParameters() + let copiedAccessory = try #require(copy.findEntity(named: accessory.name) as? GLTFEntity) + + // The accessory's copy draws with the accessory's rows, left out of the model's states + #expect(copiedAccessory.mtoonParameters(forMaterialIndex: accessoryIndex)?.baseColor == accessoryBase) + #expect(copiedAccessory.mtoonParameters(forMaterialIndex: accessoryIndex)?.lightColor == SIMD4(tint, 1)) + #expect(copy.materialIndices(under: copiedAccessory).isEmpty) + #expect(copiedAccessory.materialIndices(under: copiedAccessory) == [accessoryIndex]) + + // and takes lighting on its own from there + copiedAccessory.setMToonLightColor(SIMD3(1, 1, 1)) + #expect(accessory.mtoonParameters(forMaterialIndex: accessoryIndex)?.lightColor == SIMD4(tint, 1)) + } + /// A joint posed since the last update reaches the copy's meshes: the copy is drawn /// as its joints describe, not as the last solve left them. @Test diff --git a/Tests/VRMRealityKitTests/ExpressionCostBenchmark.swift b/Tests/VRMRealityKitTests/ExpressionCostBenchmark.swift index c9a7204d..53ede2cb 100644 --- a/Tests/VRMRealityKitTests/ExpressionCostBenchmark.swift +++ b/Tests/VRMRealityKitTests/ExpressionCostBenchmark.swift @@ -45,6 +45,16 @@ struct ExpressionCostBenchmark { print("BENCH names: \(available.map(\.name).joined(separator: ", "))") } + /// The agreed per-frame budget for a full batched `setExpressions` call, worst case + /// being a Perfect Sync tracker driving every key every frame (~70 keys). Set with + /// >6x margin over the ~31 µs measured on Apple Silicon, so slower devices at the + /// bottom of the support matrix still have headroom, and to keep pace with UniVRM's + /// and three-vrm's per-shape blend-weight application cost for an equivalent count. + private static let drivingBudgetMicroseconds: Double = 250 + /// The budget for a frame that hands over weights, none of which moved: the common + /// steady-state case between tracked pose changes. + private static let heldBudgetMicroseconds: Double = 100 + @Test func benchmarkPerFrameWeights() async throws { guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } @@ -58,10 +68,14 @@ struct ExpressionCostBenchmark { let trackedKeys = ownKeys + Self.perfectSyncNames.map { ExpressionKey.custom($0) } for (label, keys) in [("model keys", ownKeys), ("tracker keys", trackedKeys)] { - print(String(format: "BENCH %@ (%d): %.1f µs/frame", - label, keys.count, microsecondsPerFrame(driving: entity, keys: keys))) - print(String(format: "BENCH %@ (%d) held: %.1f µs/frame", - label, keys.count, microsecondsPerFrame(holding: entity, keys: keys))) + let driving = microsecondsPerFrame(driving: entity, keys: keys) + let held = microsecondsPerFrame(holding: entity, keys: keys) + print(String(format: "BENCH %@ (%d): %.1f µs/frame", label, keys.count, driving)) + print(String(format: "BENCH %@ (%d) held: %.1f µs/frame", label, keys.count, held)) + #expect(driving < Self.drivingBudgetMicroseconds, + "\(label) (\(keys.count)) drove a frame in \(driving) µs, over the \(Self.drivingBudgetMicroseconds) µs budget") + #expect(held < Self.heldBudgetMicroseconds, + "\(label) (\(keys.count)) held a frame in \(held) µs, over the \(Self.heldBudgetMicroseconds) µs budget") } } diff --git a/Tests/VRMRealityKitTests/GLTFAnimationPlaybackTests.swift b/Tests/VRMRealityKitTests/GLTFAnimationPlaybackTests.swift index ab35641a..8b145ca9 100644 --- a/Tests/VRMRealityKitTests/GLTFAnimationPlaybackTests.swift +++ b/Tests/VRMRealityKitTests/GLTFAnimationPlaybackTests.swift @@ -77,13 +77,13 @@ struct GLTFAnimationPlaybackTests { // (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) + var weights = try #require(modelEntity.deformedMesh?.blendShapeWeights) #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) + weights = try #require(modelEntity.deformedMesh?.blendShapeWeights) #expect(weights[0].isApproximatelyEqual(to: 0.5)) #expect(weights[1].isApproximatelyEqual(to: 1)) } @@ -95,8 +95,7 @@ struct GLTFAnimationPlaybackTests { 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) + try #require(binding.deformedMesh?.jointTransforms) } let initialPose = try pose() @@ -209,7 +208,7 @@ struct GLTFAnimationPlaybackTests { let entity = try await GLTFEntityLoader(withData: twoWeightAnimationsFixture()).loadEntity() let modelEntity = try #require(entity.morphBindings[0]?.modelEntities.first) func weight() throws -> Float { - try #require(modelEntity.blendWeights.first?.first) + try #require(modelEntity.deformedMesh?.blendShapeWeights.first) } let first = try entity.playAnimation(at: 0, loops: true) // holds 0.25 @@ -234,7 +233,7 @@ struct GLTFAnimationPlaybackTests { let entity = try await GLTFEntityLoader(withData: twoWeightAnimationsFixture()).loadEntity() let modelEntity = try #require(entity.morphBindings[0]?.modelEntities.first) func weight() throws -> Float { - try #require(modelEntity.blendWeights.first?.first) + try #require(modelEntity.deformedMesh?.blendShapeWeights.first) } let first = try entity.playAnimation(at: 0, loops: true) // holds 0.25 @@ -361,7 +360,7 @@ struct GLTFAnimationPlaybackTests { #expect(controller.animation.duration == 0) entity.updateAnimations(deltaTime: 1.0 / 60) - #expect(try #require(modelEntity.blendWeights.first?.first).isApproximatelyEqual(to: 0.5)) + #expect(try #require(modelEntity.deformedMesh?.blendShapeWeights.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)) @@ -375,8 +374,7 @@ struct GLTFAnimationPlaybackTests { let entity = try await 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) + try #require(binding.deformedMesh?.jointTransforms) } let controller = try entity.playAnimation(at: 0) diff --git a/Tests/VRMRealityKitTests/GLTFEntityLoaderTests.swift b/Tests/VRMRealityKitTests/GLTFEntityLoaderTests.swift index 84029d12..131ea9a8 100644 --- a/Tests/VRMRealityKitTests/GLTFEntityLoaderTests.swift +++ b/Tests/VRMRealityKitTests/GLTFEntityLoaderTests.swift @@ -58,14 +58,14 @@ struct GLTFEntityLoaderTests { #expect(!entity.skinBindings.isEmpty) for binding in entity.skinBindings { - #expect(binding.modelEntity.components.has(SkeletalPosesComponent.self)) + #expect(binding.deformedMesh?.jointTransforms != nil) #expect(!binding.jointEntities.isEmpty) } } /// The loader keeps no scene cache, so it hands out independently animatable - /// copies of one scene: each reuses the `MeshResource` the last one built and - /// binds joint entities of its own. + /// copies of one scene: each deforms a mesh of its own from the vertex data the + /// last one built and binds joint entities of its own. @Test func testReloadingASceneReusesItsMeshesAndBindsItsOwnJoints() async throws { guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } @@ -80,7 +80,8 @@ struct GLTFEntityLoaderTests { #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(old.modelEntity.model?.mesh !== new.modelEntity.model?.mesh) + #expect(old.deformedMesh?.source === new.deformedMesh?.source) } // Each entity's bindings stay within its own graph. for (entity, other) in [(first, second), (second, first)] { @@ -99,7 +100,7 @@ struct GLTFEntityLoaderTests { for (nodeIndex, binding) in entity.morphBindings { #expect(entity.entity(forNodeAt: nodeIndex) != nil) for modelEntity in binding.modelEntities { - #expect(modelEntity.components.has(BlendShapeWeightsComponent.self)) + #expect(modelEntity.deformedMesh?.geometry.hasBlendShapes == true) } } } @@ -107,7 +108,6 @@ struct GLTFEntityLoaderTests { @Test func testInitialMorphWeightsComeFromNodeThenMesh() async throws { guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } - // Find a node whose mesh has morph targets, then give it starting weights. let document = try GLTFDocument(data: TestSupport.seedSanData) let gltf = document.gltf let morphed = gltf.nodes.enumerated().compactMap { index, node -> (Int, Int)? in @@ -133,9 +133,7 @@ struct GLTFEntityLoaderTests { 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 - } + modelEntity.deformedMesh?.blendShapeWeights.first.map { abs($0 - 0.5) < 0.0001 } ?? false } #expect(applied) } @@ -320,8 +318,7 @@ struct GLTFEntityLoaderTests { """ let entity = try await 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) + let positions = try #require(entity.modelEntitiesInHierarchy.first?.gltfMeshGeometry?.positions) #expect(positions.count == 3) #expect(positions[2].isApproximatelyEqual(to: SIMD3(0, 5, 0))) @@ -369,8 +366,7 @@ struct GLTFEntityLoaderTests { """ let entity = try await 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) + let uvs = try #require(entity.modelEntitiesInHierarchy.first?.gltfMeshGeometry?.texcoords) #expect(uvs.count == 3) // The loader flips V (RealityKit's UV origin is bottom-left), so the TEXCOORD_1 @@ -642,13 +638,13 @@ struct GLTFEntityLoaderTests { @Test func testVRMSharesMorphTargetsAcrossPrimitivesButPlainGLTFDoesNot() async throws { guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } - // Primitives merge into the parts of one mesh, so the sharing shows as - // how many parts carry blend shapes, not as extra entities. + // Primitives merge into the slots of one mesh, so the sharing shows as + // how many slots carry blend shapes, not as extra entities. func morphablePartCount(_ entity: Entity) -> Int { entity.modelEntitiesInHierarchy - .compactMap { $0.components[ModelComponent.self]?.mesh } - .flatMap { $0.contents.models.flatMap(\.parts) } - .count { !$0.blendShapeNames.isEmpty } + .compactMap(\.gltfMeshGeometry) + .flatMap(\.slots) + .count(where: \.hasBlendShapes) } // AliciaSolid names no default scene, so the plain loader is given one. let vrm = try await VRMEntityLoader(withData: TestSupport.aliciaSolidData).loadEntity() @@ -692,10 +688,9 @@ struct GLTFEntityLoaderTests { } let entity = try await 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) + let geometry = try #require(entity.modelEntitiesInHierarchy.first?.gltfMeshGeometry) + let tangents = geometry.tangents + let bitangents = geometry.bitangents #expect(tangents.count == 6) #expect(tangents.allSatisfy { $0.x.isFinite && $0.y.isFinite && $0.z.isFinite }) @@ -735,12 +730,11 @@ struct GLTFEntityLoaderTests { """ let entity = try await 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) + let geometry = try #require(entity.modelEntitiesInHierarchy.first?.gltfMeshGeometry) + let normals = geometry.normals // Flat shading needs a vertex per triangle corner. - #expect(part.positions.elements.count == 6) + #expect(geometry.positions.count == 6) #expect(normals.count == 6) for normal in normals[0..<3] { #expect(normal.isApproximatelyEqual(to: SIMD3(0, 0, 1))) diff --git a/Tests/VRMRealityKitTests/GLTFGeometryCompactionTests.swift b/Tests/VRMRealityKitTests/GLTFGeometryCompactionTests.swift new file mode 100644 index 00000000..337f57e6 --- /dev/null +++ b/Tests/VRMRealityKitTests/GLTFGeometryCompactionTests.swift @@ -0,0 +1,43 @@ +#if canImport(RealityKit) +import Testing +import simd +@testable import VRMRealityKit + +/// Orphan vertices cost skinning and morphing on the GPU without drawing anything, +/// so the decoder drops them and renumbers the triangles. +@Suite +struct GLTFGeometryCompactionTests { + @Test + func testUnreferencedVerticesAreDroppedAndIndicesRenumbered() { + var geometry = GLTFPrimitiveGeometry() + geometry.positions = (0..<6).map { SIMD3(Float($0), 0, 0) } + geometry.normals = (0..<6).map { _ in SIMD3(0, 1, 0) } + geometry.texcoords = (0..<6).map { SIMD2(Float($0) / 10, 0) } + geometry.joints = (0..<6).map { SIMD4(UInt32($0), 0, 0, 0) } + geometry.weights = (0..<6).map { _ in SIMD4(1, 0, 0, 0) } + geometry.blendShapeOffsets = [(0..<6).map { SIMD3(0, Float($0), 0) }] + // Vertices 1 and 4 are never drawn; vertex 5 is used first, then 0 and 3. + geometry.indices = [5, 0, 3, 3, 0, 2] + + geometry.dropUnreferencedVertices() + + #expect(geometry.positions.map(\.x) == [5, 0, 3, 2]) + #expect(geometry.indices == [0, 1, 2, 2, 1, 3]) + #expect(geometry.texcoords.map(\.x) == [0.5, 0, 0.3, 0.2]) + #expect(geometry.joints.map(\.x) == [5, 0, 3, 2]) + #expect(geometry.weights.count == 4) + #expect(geometry.normals.count == 4) + #expect(geometry.blendShapeOffsets[0].map(\.y) == [5, 0, 3, 2]) + } + + @Test + func testFullyReferencedGeometryIsLeftAlone() { + var geometry = GLTFPrimitiveGeometry() + geometry.positions = (0..<3).map { SIMD3(Float($0), 0, 0) } + geometry.indices = [2, 1, 0] + geometry.dropUnreferencedVertices() + #expect(geometry.positions.count == 3) + #expect(geometry.indices == [2, 1, 0]) + } +} +#endif diff --git a/Tests/VRMRealityKitTests/GLTFMeshAttributeTests.swift b/Tests/VRMRealityKitTests/GLTFMeshAttributeTests.swift index 0e38d948..36bdc509 100644 --- a/Tests/VRMRealityKitTests/GLTFMeshAttributeTests.swift +++ b/Tests/VRMRealityKitTests/GLTFMeshAttributeTests.swift @@ -20,14 +20,16 @@ struct GLTFMeshAttributeTests { var checkedParts = 0 for modelEntity in TestSupport.modelEntities(in: vrmEntity) { - guard let mesh = modelEntity.components[ModelComponent.self]?.mesh else { continue } - for part in mesh.contents.models.flatMap(\.parts) { - guard let tangents = part.tangents?.elements, !tangents.isEmpty else { continue } - // MToon.metal falls back to the geometry normal unless both - // buffers are present and non-degenerate. - let bitangents = try #require(part.bitangents?.elements) - #expect(tangents.count == part.positions.count) - #expect(bitangents.count == tangents.count) + guard let geometry = modelEntity.gltfMeshGeometry, !geometry.tangents.isEmpty else { continue } + #expect(geometry.tangents.count == geometry.positions.count) + #expect(geometry.bitangents.count == geometry.tangents.count) + // A slot whose material samples no normal map carries zero tangents; the + // others must be complete, since MToon.metal falls back to the geometry + // normal unless both buffers are present and non-degenerate. + for slot in geometry.slots { + let tangents = geometry.tangents[slot.vertexRange] + let bitangents = geometry.bitangents[slot.vertexRange] + guard tangents.contains(where: { simd_length_squared($0) > 0 }) else { continue } #expect(tangents.allSatisfy { simd_length_squared($0) > 0.5 }) #expect(bitangents.allSatisfy { simd_length_squared($0) > 0.5 }) checkedParts += 1 @@ -50,12 +52,13 @@ struct GLTFMeshAttributeTests { var checkedTriangles = 0 var agreeingTriangles = 0 for modelEntity in TestSupport.modelEntities(in: vrmEntity) { - guard let mesh = modelEntity.components[ModelComponent.self]?.mesh else { continue } - for part in mesh.contents.models.flatMap(\.parts) { - guard let bitangents = part.bitangents?.elements, !bitangents.isEmpty, - let texcoords = part.textureCoordinates?.elements, - let indices = part.triangleIndices?.elements else { continue } - let positions = part.positions.elements + guard let geometry = modelEntity.gltfMeshGeometry, !geometry.bitangents.isEmpty, + !geometry.texcoords.isEmpty else { continue } + let bitangents = geometry.bitangents + let texcoords = geometry.texcoords + let positions = geometry.positions + for slot in geometry.slots.indices { + let indices = Array(geometry.triangleIndices(ofSlot: slot)) for triangle in stride(from: 0, to: indices.count - 2, by: 3) { let i0 = Int(indices[triangle]) let i1 = Int(indices[triangle + 1]) @@ -69,7 +72,9 @@ struct GLTFMeshAttributeTests { let edge1 = positions[i1] - positions[i0] let edge2 = positions[i2] - positions[i0] let expected = (edge2 * deltaUV1.x - edge1 * deltaUV2.x) / determinant - guard simd_length_squared(expected) > 1e-10 else { continue } + // A slot without a normal map has no basis to agree with. + guard simd_length_squared(expected) > 1e-10, + simd_length_squared(bitangents[i0]) > 0.5 else { continue } checkedTriangles += 1 if simd_dot(simd_normalize(expected), bitangents[i0]) > 0 { agreeingTriangles += 1 @@ -259,14 +264,12 @@ struct GLTFMeshAttributeTests { var checkedParts = 0 for modelEntity in TestSupport.modelEntities(in: vrmEntity) { guard let slots = modelEntity.components[GLTFMaterialSlotsComponent.self]?.materialIndices, - let mesh = modelEntity.components[ModelComponent.self]?.mesh else { continue } - for part in mesh.contents.models.flatMap(\.parts) - where part.materialIndex < slots.count && slots[part.materialIndex] == materialIndex { - let tangents = try #require(part.tangents?.elements) - let bitangents = try #require(part.bitangents?.elements) - #expect(tangents.count == part.positions.count) - #expect(bitangents.count == tangents.count) - #expect(tangents.contains { simd_length_squared($0) > 0.5 }) + let geometry = modelEntity.gltfMeshGeometry else { continue } + for (slot, slotGeometry) in geometry.slots.enumerated() + where slot < slots.count && slots[slot] == materialIndex { + #expect(geometry.tangents.count == geometry.positions.count) + #expect(geometry.bitangents.count == geometry.tangents.count) + #expect(geometry.tangents[slotGeometry.vertexRange].contains { simd_length_squared($0) > 0.5 }) checkedParts += 1 } } diff --git a/Tests/VRMRealityKitTests/GLTFSampleAssetRenderingTests.swift b/Tests/VRMRealityKitTests/GLTFSampleAssetRenderingTests.swift index 3174ea7b..9aeaafdf 100644 --- a/Tests/VRMRealityKitTests/GLTFSampleAssetRenderingTests.swift +++ b/Tests/VRMRealityKitTests/GLTFSampleAssetRenderingTests.swift @@ -23,7 +23,7 @@ struct GLTFSampleAssetRenderingTests { 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 }) + #expect(modelEntity.deformedMesh?.drawnIndexCount ?? 0 > 0) } } @@ -47,11 +47,10 @@ struct GLTFSampleAssetRenderingTests { guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } for asset in [GLTFSampleAsset.triangle, .triangleWithoutIndices] { let entity = try await TestSupport.loadEntity(asset) - let model = try #require(entity.modelEntitiesInHierarchy.first?.components[ModelComponent.self]) - let part = try #require(model.mesh.contents.models.first?.parts.first) + let geometry = try #require(entity.modelEntitiesInHierarchy.first?.gltfMeshGeometry) - #expect(part.positions.count == 3, "\(asset.rawValue)") - #expect(part.triangleIndices?.count == 3, "\(asset.rawValue)") + #expect(geometry.positions.count == 3, "\(asset.rawValue)") + #expect(geometry.triangleIndices(ofSlot: 0).count == 3, "\(asset.rawValue)") } } @@ -90,7 +89,7 @@ struct GLTFSampleAssetRenderingTests { #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)) + #expect(binding.deformedMesh?.jointTransforms != nil) } /// SimpleMorph declares `mesh.weights = [0.5, 0.5]` and no node weights, so it is the @@ -102,7 +101,7 @@ struct GLTFSampleAssetRenderingTests { let binding = try #require(entity.morphBindings[0]) #expect(binding.targetCount == 2) - let weights = try #require(binding.modelEntities.first?.blendWeights.first) + let weights = try #require(binding.modelEntities.first?.deformedMesh?.blendShapeWeights) #expect(weights.count == 2) #expect(weights.allSatisfy { $0.isApproximatelyEqual(to: 0.5) }) } @@ -150,7 +149,7 @@ struct GLTFSampleAssetRenderingTests { 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 }) + #expect(binding.modelEntities.allSatisfy { $0.deformedMesh?.geometry.hasBlendShapes == true }) } /// glTF puts no restriction on where a skinned mesh sits, so it may hang below one of diff --git a/Tests/VRMRealityKitTests/GLTFSnapshotTests.swift b/Tests/VRMRealityKitTests/GLTFSnapshotTests.swift index 5d951382..eab58054 100644 --- a/Tests/VRMRealityKitTests/GLTFSnapshotTests.swift +++ b/Tests/VRMRealityKitTests/GLTFSnapshotTests.swift @@ -51,6 +51,31 @@ struct GLTFSnapshotTests { } } + /// FNV-1a over the RGB bytes of a controlled offscreen render. The checksum is the + /// checked-in golden image for this stable synthetic scene. + @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) + private func goldenChecksum(_ image: [[SIMD3]]) -> UInt64 { + image.flatMap { $0 }.reduce(into: UInt64(14_695_981_039_346_656_037)) { hash, pixel in + for component in [pixel.x, pixel.y, pixel.z] { + hash ^= UInt64(max(0, min(255, Int(component.rounded())))) + hash &*= 1_099_511_628_211 + } + } + } + + @Test + @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) + func controlledOffscreenSceneMatchesGoldenImage() throws { + guard OffscreenRenderer.isAvailable else { return } + let entity = Entity() + let material = UnlitMaterial(color: .red) + entity.addChild(ModelEntity(mesh: .generateBox(size: 1), materials: [material])) + + let pixels = try OffscreenRenderer.render(entity, size: 64) + let checksum = goldenChecksum(pixels) + #expect(checksum == 6_827_813_804_783_346_469, "golden image checksum changed") + } + @Test(arguments: [VRMSampleAsset.aliciaSolid, .vrm1ConstraintTwist]) @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) func snapshotDrawsTheModelWithinTheFrame(asset: VRMSampleAsset) async throws { diff --git a/Tests/VRMRealityKitTests/MToonOutlineTests.swift b/Tests/VRMRealityKitTests/MToonOutlineTests.swift index 421d632d..54c67c69 100644 --- a/Tests/VRMRealityKitTests/MToonOutlineTests.swift +++ b/Tests/VRMRealityKitTests/MToonOutlineTests.swift @@ -338,13 +338,13 @@ struct MToonOutlineTests { && $0.mergedMesh?.visibleSlots.contains(false) == true }) func pose(of modelEntity: ModelEntity) -> [SIMD4]? { - modelEntity.components[SkeletalPosesComponent.self]?.poses.default? - .jointTransforms.map(\.rotation.vector) + modelEntity.deformedMesh?.jointTransforms?.map(\.rotation.vector) } let restPose = pose(of: hidden) entity.humanoid.node(for: .neck)?.transform.rotation *= simd_quatf(angle: 0.5, axis: SIMD3(0, 0, 1)) - entity.flushSkinPose() + entity.invalidateSkinPose() + entity.updateSkinPose() #expect(pose(of: hidden) != restPose) } diff --git a/Tests/VRMRealityKitTests/MToonRenderingTests.swift b/Tests/VRMRealityKitTests/MToonRenderingTests.swift index 96ad06a9..1005c973 100644 --- a/Tests/VRMRealityKitTests/MToonRenderingTests.swift +++ b/Tests/VRMRealityKitTests/MToonRenderingTests.swift @@ -66,9 +66,9 @@ struct MToonRenderingTests { let texture = try parameters.textureResource() let shader = TestSupport.mtoonShaderSource - #expect(MToonMaterialParameters.baseParameterRowCount == 18) + #expect(MToonMaterialParameters.baseParameterRowCount == 21) #expect(MToonMaterialParameters.samplerRowCount == MToonTextureSlot.allCases.count) - #expect(MToonMaterialParameters.textureRowCount == 27) + #expect(MToonMaterialParameters.textureRowCount == 30) #expect(parameters.samplers.count == MToonMaterialParameters.samplerRowCount) #expect(texture.width == MToonMaterialParameters.textureRowCount) #expect(texture.height == 1) @@ -295,6 +295,73 @@ struct MToonRenderingTests { #expect(parameters.lightDirection.isApproximatelyEqual(to: SIMD3(0, 0, -1))) } + /// Relighting a model per frame touches every material, so one call writes each + /// material's rows once, the writes of that flush share a command buffer, and the + /// same values again write nothing. + @Test + func testSetMToonLightingWritesEachMaterialOnceThroughOneCommandBuffer() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let vrmEntity = try await VRMEntityLoader(withData: TestSupport.seedSanData).loadEntity() + vrmEntity.setMToonLighting(direction: SIMD3(0, 1, 0), color: SIMD3(1, 1, 1), ambient: .zero) + let textures = vrmEntity.materialStates.keys.compactMap { + vrmEntity.mtoonState(forMaterialIndex: $0)?.parameterTexture + } + #expect(textures.count > 1) + let writes = textures.map(\.writeCount) + + let direction = SIMD3(1, 0, 0) + let color = SIMD3(0.5, 0.5, 0.5) + let ambient = SIMD3(0.1, 0.1, 0.1) + vrmEntity.setMToonLighting(direction: direction, color: color, ambient: ambient) + + #expect(textures.map(\.writeCount) == writes.map { $0 + 1 }) + let commandBuffers = Set(textures.compactMap { $0.lastWrite.map { ObjectIdentifier($0) } }) + #expect(commandBuffers.count == 1) + let parameters = try firstMToonParameters(in: vrmEntity) + #expect(parameters.lightDirection.isApproximatelyEqual(to: direction)) + #expect(parameters.lightColor.isApproximatelyEqual(to: SIMD4(color, 1))) + #expect(parameters.ambientColor.isApproximatelyEqual(to: SIMD4(ambient, 1))) + + vrmEntity.setMToonLighting(direction: direction, color: color, ambient: ambient) + #expect(textures.map(\.writeCount) == writes.map { $0 + 1 }) + } + + @Test + func testSetMToonRimLightUpdatesParameterRowsAndNilTurnsItOff() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let vrmEntity = try await VRMEntityLoader(withData: TestSupport.seedSanData).loadEntity() + let rim = MToonRimLight(color: SIMD3(1.5, 0.8, 0.4), direction: SIMD3(0, 0, -2), + width: 0.4, softness: 0.2, wrap: 0.6, viewBend: 1.5, blend: 0.25) + + vrmEntity.setMToonRimLight(rim) + + var parameters = try firstMToonParameters(in: vrmEntity) + #expect(parameters.rimLightColor.isApproximatelyEqual(to: SIMD4(1.5, 0.8, 0.4, 0.25))) + #expect(parameters.rimLightDirection.isApproximatelyEqual(to: SIMD4(0, 0, -1, 0))) + // The shape is clamped to 0...1 + #expect(parameters.rimLightShape.isApproximatelyEqual(to: SIMD4(0.4, 0.2, 0.6, 1))) + + let copy = vrmEntity.cloneWithOwnMaterialParameters() + #expect(try firstMToonParameters(in: copy).rimLightColor.isApproximatelyEqual(to: SIMD4(1.5, 0.8, 0.4, 0.25))) + + vrmEntity.setMToonRimLight(nil) + parameters = try firstMToonParameters(in: vrmEntity) + #expect(parameters.rimLightColor == SIMD4(0, 0, 0, 0)) + #expect(parameters.rimLightShape == SIMD4(0, 0, 0, 0)) + + // Scoped to a material set, the others keep their rows. Only MToon materials carry the + // rows, so the one checked is picked among them rather than from the whole material set. + let mtoonIndexes = TestSupport.materialIndexes(in: vrmEntity).filter { + vrmEntity.mtoonParameters(forMaterialIndex: $0) != nil + } + let others = Array(mtoonIndexes.dropFirst()) + let other = try #require(others.first) + vrmEntity.setMToonRimLight(rim, forMaterials: Set(others)) + #expect(try firstMToonParameters(in: vrmEntity).rimLightColor == SIMD4(0, 0, 0, 0)) + #expect(try TestSupport.mtoonParameters(in: vrmEntity, materialIndex: other).rimLightColor + .isApproximatelyEqual(to: SIMD4(1.5, 0.8, 0.4, 0.25))) + } + @Test func testSetMToonLightAndAmbientColorUpdateParameterRows() async throws { guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } diff --git a/Tests/VRMRealityKitTests/MToonRimLightRenderingTests.swift b/Tests/VRMRealityKitTests/MToonRimLightRenderingTests.swift new file mode 100644 index 00000000..10744992 --- /dev/null +++ b/Tests/VRMRealityKitTests/MToonRimLightRenderingTests.swift @@ -0,0 +1,93 @@ +#if canImport(RealityKit) +import Foundation +import RealityKit +import Testing +import VRMKit +import VRMTestSupport +@testable import VRMRealityKit + +/// What the runtime rim light does on screen: it lights the side the light is on +/// and leaves the other side as it was, which the parameter rows cannot show. +@Suite +@MainActor +struct MToonRimLightRenderingTests { + private static let size = 128 + + /// A cube turned 45° so both visible faces meet the camera at a grazing enough + /// angle for a Fresnel term to show; head-on, every normal faces the camera and + /// the rim is zero everywhere. + @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) + private func cube(rimLight: MToonRimLight?) async throws -> Entity { + let entity = try await GLTFEntityLoader(withURL: GLTFSampleAsset.animatedMorphCube.url, + shaders: [MToonShader(source: .convertAll(MToonConversionStyle()))]).loadEntity() + entity.setMToonRimLight(rimLight) + entity.waitForMToonParameterWrites() + let root = Entity() + root.addChild(entity) + root.scale = SIMD3(repeating: 0.4) + root.orientation = simd_quatf(angle: .pi / 4, axis: SIMD3(0, 1, 0)) + return root + } + + /// Mean brightness of the drawn pixels in the left and right halves of the middle rows. + private func halfBrightness(_ image: [[SIMD3]]) -> (left: Float, right: Float) { + let rows = image[(image.count / 2 - 8)..<(image.count / 2 + 8)] + func mean(_ columns: Range) -> Float { + var sum: Float = 0 + var count = 0 + for row in rows { + for pixel in row[columns] where pixel.max() > 8 { + sum += (pixel.x + pixel.y + pixel.z) / 3 + count += 1 + } + } + return count > 0 ? sum / Float(count) : 0 + } + let width = image[0].count + return (mean(0..<(width / 2)), mean((width / 2).. MToonRimLight { + MToonRimLight(color: SIMD3(repeating: 8), direction: SIMD3(1, 0, 0), + width: 0.9, softness: 0, wrap: wrap, viewBend: 0) + } + + /// The unlit render doubles as the machine's capability check: the visionOS + /// simulator hands back a blank frame wherever RealityKit's pipelines fail to compile. + private func drawsTheCube(_ brightness: (left: Float, right: Float)) -> Bool { + brightness.left > 0 && brightness.right > 0 + } + + @Test + func testRimLightBrightensTheEdgeFacingTheLightOnly() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *), OffscreenRenderer.isAvailable else { return } + let bare = halfBrightness(try OffscreenRenderer.render(await cube(rimLight: nil), size: Self.size)) +#if os(visionOS) + guard drawsTheCube(bare) else { return } +#endif + #expect(drawsTheCube(bare), "the unlit cube must be drawn") + let lit = halfBrightness(try OffscreenRenderer.render(await cube(rimLight: rim(wrap: 0)), size: Self.size)) + + #expect(lit.right > bare.right + 20, "right face: \(bare.right) -> \(lit.right)") + #expect(abs(lit.left - bare.left) < 4, "left face: \(bare.left) -> \(lit.left)") + } + + /// The wrap carries the band around the side that faces away from the light. + @Test + func testWrapCarriesTheRimAroundTheShadowedSide() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *), OffscreenRenderer.isAvailable else { return } + let bare = halfBrightness(try OffscreenRenderer.render(await cube(rimLight: nil), size: Self.size)) +#if os(visionOS) + guard drawsTheCube(bare) else { return } +#endif + #expect(drawsTheCube(bare), "the unlit cube must be drawn") + let wrapped = halfBrightness(try OffscreenRenderer.render(await cube(rimLight: rim(wrap: 1)), size: Self.size)) + + #expect(wrapped.right > bare.right + 20, "right face: \(bare.right) -> \(wrapped.right)") + #expect(wrapped.left > bare.left + 20, "left face: \(bare.left) -> \(wrapped.left)") + } +} +#endif diff --git a/Tests/VRMRealityKitTests/MemoryFootprintBenchmark.swift b/Tests/VRMRealityKitTests/MemoryFootprintBenchmark.swift new file mode 100644 index 00000000..7c8287be --- /dev/null +++ b/Tests/VRMRealityKitTests/MemoryFootprintBenchmark.swift @@ -0,0 +1,77 @@ +#if canImport(RealityKit) +import Darwin +import Foundation +import RealityKit +import Testing +import VRMKit +import VRMTestSupport +@testable import VRMRealityKit + +/// Reports process resident-size deltas for representative avatar loads. It is opt-in because +/// allocator caches and simulator services make memory numbers unsuitable for normal assertions. +/// +/// VRMKIT_BENCH=1 swift test -c release --filter MemoryFootprintBenchmark +@Suite(.enabled(if: ProcessInfo.processInfo.environment["VRMKIT_BENCH"] == "1"), .serialized) +@MainActor +struct MemoryFootprintBenchmark { + private func residentBytes() -> UInt64 { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size + / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + return result == KERN_SUCCESS ? UInt64(info.resident_size) : 0 + } + + @Test + @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) + func benchmarkRepresentativeAvatarMemory() async throws { + struct Sample { + let name: String + let load: () async throws -> VRMEntity + let playback: Bool + } + let samples = [ + Sample(name: "VRM0 Alicia", load: { + try await VRMEntityLoader(withData: VRMSampleAsset.aliciaSolid.data, + shaders: []).loadEntity() + }, playback: false), + Sample(name: "VRM1 Seed-san MToon", load: { + try await VRMEntityLoader(withData: VRMSampleAsset.seedSan.data).loadEntity() + }, playback: false), + Sample(name: "VRMA on VRM1", load: { + let entity = try await VRMEntityLoader(withData: VRMSampleAsset.seedSan.data, + shaders: []).loadEntity() + _ = try entity.playAnimation(try VRMAnimation(data: VRMASampleFixture.standard()), + loops: true) + return entity + }, playback: true) + ] + + for sample in samples { + let baseline = residentBytes() + var entities: [VRMEntity] = [] + for _ in 0..<3 { + entities.append(try await sample.load()) + } + for entity in entities where sample.playback { + entity.updateAnimations(deltaTime: 1.0 / 60.0) + } + let peak = residentBytes() + entities.removeAll() + await Task.yield() + let afterUnload = residentBytes() + print(String(format: "MEM %@: baseline=%llu peak=%llu delta=%.1f MiB afterUnload=%llu retained=%.1f MiB", + sample.name, + baseline, + peak, + Double(peak - baseline) / 1_048_576, + afterUnload, + Double(afterUnload > baseline ? afterUnload - baseline : 0) / 1_048_576)) + } + } +} +#endif diff --git a/Tests/VRMRealityKitTests/RealityKitCostBenchmark.swift b/Tests/VRMRealityKitTests/RealityKitCostBenchmark.swift index 233aa57b..735a3489 100644 --- a/Tests/VRMRealityKitTests/RealityKitCostBenchmark.swift +++ b/Tests/VRMRealityKitTests/RealityKitCostBenchmark.swift @@ -158,23 +158,6 @@ struct RealityKitCostBenchmark { allEntities(scaled).count)) } - // The marginal cost of one `SkeletalPosesComponent` write. The joint hierarchy - // is gone and the pose is written back unchanged, so nothing but the write is timed. - for count in [0, 1, 5, 10] { - let writer = try await load() - _ = detachJointHierarchy(from: writer) - let targets: [(ModelEntity, SkeletalPosesComponent)] = writer.skinBindings.prefix(count).compactMap { binding in - binding.modelEntity.components[SkeletalPosesComponent.self].map { (binding.modelEntity, $0) } - } - print(String(format: "BENCH skeleton writes %2d: %.3f ms/frame", - targets.count, - try millisecondsPerFrame(rendering: writer, frames: 200, warmup: 60) { - for (modelEntity, component) in targets { - modelEntity.components.set(component) - } - })) - } - // Driven through the entity hierarchy, as an app posing humanoid bones every // frame does: write the bones, then let the model solve constraints, gaze, // spring bones and the skin. @@ -192,32 +175,6 @@ struct RealityKitCostBenchmark { }, bones.count)) - // The same motion reaching the skin without a joint hierarchy at all: the - // ceiling a backend holding poses in arrays could reach. - let drivenPacked = try await load() - _ = detachJointHierarchy(from: drivenPacked) - var poses: [(ModelEntity, SkeletalPosesComponent)] = drivenPacked.skinBindings.compactMap { binding in - binding.modelEntity.components[SkeletalPosesComponent.self].map { (binding.modelEntity, $0) } - } - var packedTick: Float = 0 - print(String(format: "BENCH driven packed: %.3f ms/frame (skeletons %d)", - try millisecondsPerFrame(rendering: drivenPacked, frames: 200, warmup: 60) { - packedTick += 0.01 - let rotation = simd_quatf(angle: sin(packedTick) * 0.05, axis: SIMD3(1, 0, 0)) - for index in poses.indices { - var component = poses[index].1 - if var pose = component.poses.default { - for jointIndex in pose.jointTransforms.indices { - pose.jointTransforms[jointIndex].rotation = rotation - } - component.poses[pose.id] = pose - component.poses.default = pose - } - poses[index].1 = component - poses[index].0.components.set(component) - } - }, - poses.count)) } // MARK: - The first frame after a load @@ -461,8 +418,8 @@ struct RealityKitCostBenchmark { } }) - // Every skeleton is drawn by a model entity and its outline twin, each carrying - // its own `SkeletalPosesComponent`: what the second write per skeleton costs. + // Every skeleton is drawn by a model entity and its outline twin, each deforming a + // mesh of its own: what the second mesh per skeleton costs. let bare = try await VRMEntityLoader(withData: TestSupport.seedSanData, shaders: [MToonShader(outlinePass: .never)]).loadEntity() bare.isAutomaticUpdateEnabled = false diff --git a/Tests/VRMRealityKitTests/ReferenceValidationTests.swift b/Tests/VRMRealityKitTests/ReferenceValidationTests.swift new file mode 100644 index 00000000..1756dc02 --- /dev/null +++ b/Tests/VRMRealityKitTests/ReferenceValidationTests.swift @@ -0,0 +1,120 @@ +#if canImport(RealityKit) +import Foundation +import RealityKit +import Testing +import VRMKit +import VRMTestSupport +@testable import VRMRealityKit + +/// Missing, duplicate, self-referencing, and out-of-range references a VRM 1.0 extension +/// can name, none of which may crash the load: every one is refused with a typed error. +@Suite +struct ReferenceValidationTests { + @Test + func testANodeConstraintSourceOutOfRangeFailsTheLoad() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + // Node 14 is rotation-constrained by node 82 in the fixture (see VRM1Tests). + let modified = try TestSupport.modifiedSeedSanData(name: "constraint source out of range") { json in + var nodes = json.objects("nodes") + var constraint = nodes[14].object("extensions")!.object("VRMC_node_constraint")! + var rotation = constraint.object("constraint")!.object("rotation")! + rotation.set("source", 999_999) + var inner = constraint.object("constraint")! + inner.set("rotation", rotation) + constraint.set("constraint", inner) + var extensions = nodes[14].object("extensions")! + extensions.set("VRMC_node_constraint", constraint) + nodes[14].set("extensions", extensions) + json.set("nodes", nodes) + } + + await #expect(throws: VRMError.self) { + _ = try await VRMEntityLoader(withData: modified).loadEntity() + } + } + + @Test + func testANodeConstraintNamingItselfAsItsSourceFailsTheLoad() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let modified = try TestSupport.modifiedSeedSanData(name: "constraint self-reference") { json in + var nodes = json.objects("nodes") + var constraint = nodes[14].object("extensions")!.object("VRMC_node_constraint")! + var rotation = constraint.object("constraint")!.object("rotation")! + rotation.set("source", 14) + var inner = constraint.object("constraint")! + inner.set("rotation", rotation) + constraint.set("constraint", inner) + var extensions = nodes[14].object("extensions")! + extensions.set("VRMC_node_constraint", constraint) + nodes[14].set("extensions", extensions) + json.set("nodes", nodes) + } + + await #expect(throws: VRMError.self) { + _ = try await VRMEntityLoader(withData: modified).loadEntity() + } + } + + @Test + func testAHumanoidBoneNodeOutOfRangeFailsTheLoad() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let modified = try TestSupport.modifiedSeedSanData(name: "humanoid node out of range") { json in + json.withObject("extensions") { extensions in + extensions.withObject(GLTFExtension.vrm1.rawValue) { vrm in + vrm.withObject("humanoid") { humanoid in + humanoid.withObject("humanBones") { bones in + bones.withObject("hips") { $0.set("node", 999_999) } + } + } + } + } + } + + await #expect(throws: VRMError.self) { + _ = try await VRMEntityLoader(withData: modified).loadEntity() + } + } + + @Test + func testASpringBoneJointNodeOutOfRangeFailsTheLoad() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let modified = try TestSupport.modifiedSeedSanData(name: "spring joint node out of range") { json in + json.withObject("extensions") { extensions in + extensions.withObject("VRMC_springBone") { springBone in + var springs = springBone.objects("springs") + var joints = springs[0].objects("joints") + joints[0].set("node", 999_999) + springs[0].set("joints", joints) + springBone.set("springs", springs) + } + } + } + + await #expect(throws: VRMError.self) { + _ = try await VRMEntityLoader(withData: modified).loadEntity() + } + } + + /// A node claimed as a child of two different parents is a broken hierarchy, whichever + /// VRM extension happens to name it. + @Test + func testANodeClaimedByTwoParentsFailsTheLoad() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let modified = try TestSupport.modifiedSeedSanData(name: "node with two parents") { json in + var nodes = json.objects("nodes") + // Node 0 already has children; add node 1 (already a child elsewhere) as an + // additional child of node 0, so it claims two parents. + var zero = nodes[0] + var children = zero.ints("children") ?? [] + children.append(1) + zero.set("children", .array(children.map { .int($0) })) + nodes[0] = zero + json.set("nodes", nodes) + } + + await #expect(throws: VRMError.self) { + _ = try await VRMEntityLoader(withData: modified).loadEntity() + } + } +} +#endif diff --git a/Tests/VRMRealityKitTests/RenderQueueTests.swift b/Tests/VRMRealityKitTests/RenderQueueTests.swift new file mode 100644 index 00000000..aa644f61 --- /dev/null +++ b/Tests/VRMRealityKitTests/RenderQueueTests.swift @@ -0,0 +1,127 @@ +#if canImport(RealityKit) +import Foundation +import RealityKit +import Testing +import VRMKit +import VRMTestSupport +@testable import VRMRealityKit + +/// The draw order of a mesh's blended materials: where each material lands on +/// Unity's render-queue scale, and the model entities the loader splits a mesh +/// into so that RealityKit keeps that order. +@Suite +@MainActor +struct RenderQueueTests { + /// Seed-san's `head` mesh: the two eye materials it draws over `body_bake`. + private static let opaqueEye = 3 + private static let blendedEye = 4 + + @Test + func testMToonQueueIsTheBlendModeBasePlusTheOffset() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let authored = try VRMEntityLoader(withData: TestSupport.seedSanData) + #expect(try authored.shadedMaterial(withMaterialIndex: Self.blendedEye).renderQueue == 3000) + #expect(try authored.shadedMaterial(withMaterialIndex: Self.opaqueEye).renderQueue == nil) + + let offset = try TestSupport.modifiedSeedSanMToonExtension(name: "queue-offset", index: Self.blendedEye) { mtoon in + mtoon["renderQueueOffsetNumber"] = -3 + } + #expect(try VRMEntityLoader(withData: offset).shadedMaterial(withMaterialIndex: Self.blendedEye).renderQueue == 2997) + + let zWrite = try TestSupport.modifiedSeedSanMToonExtension(name: "queue-zwrite", index: Self.blendedEye) { mtoon in + mtoon["transparentWithZWrite"] = true + mtoon["renderQueueOffsetNumber"] = 2 + } + #expect(try VRMEntityLoader(withData: zWrite).shadedMaterial(withMaterialIndex: Self.blendedEye).renderQueue == 2503) + } + + /// VRM 0.x records Unity's queue outright, for MToon and any other shader. + @Test + func testVRM0QueueIsTheRecordedOne() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + // AliciaSolid's `Alicia_other` is an UnlitTransparent at Unity's default queue. + let transparentOther = 5 + let authored = try VRMEntityLoader(withData: TestSupport.aliciaSolidData) + #expect(try authored.shadedMaterial(withMaterialIndex: transparentOther).renderQueue == 3000) + #expect(try authored.shadedMaterial(withMaterialIndex: 0).renderQueue == nil) + + let raised = try TestSupport.modifiedAliciaSolidData(name: "queue-3500") { json in + var extensions = json.object("extensions") ?? [:] + var vrm = extensions.object("VRM") ?? [:] + var properties = vrm.objects("materialProperties") + properties[transparentOther]["renderQueue"] = 3500 + vrm["materialProperties"] = .objects(properties) + extensions["VRM"] = .object(vrm) + json["extensions"] = .object(extensions) + } + #expect(try VRMEntityLoader(withData: raised).shadedMaterial(withMaterialIndex: transparentOther).renderQueue == 3500) + } + + /// Blended parts sharing one queue stay parts of the mesh's one model entity, + /// ordered by RealityKit as Unity would order them by distance. + @Test + func testOneQueueKeepsTheMeshOnOneModelEntity() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try await VRMEntityLoader(withData: TestSupport.seedSanData).loadEntity() + let head = Self.headModelEntities(in: entity) + #expect(head.count == 1) + #expect(head.first?.components[GLTFMaterialSlotsComponent.self]?.materialIndices == [2, Self.opaqueEye, Self.blendedEye]) + #expect(!(head.first?.components.has(ModelSortGroupComponent.self) ?? true)) + } + + /// Blended parts at different queues draw from model entities of their own, + /// sorted in queue order, while the rest of the mesh keeps its entity. + @Test + func testDifferentQueuesSplitTheBlendedPartsIntoSortedEntities() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let modified = try TestSupport.modifiedSeedSanMaterial(name: "queue-split", index: Self.opaqueEye) { material in + material["alphaMode"] = "BLEND" + var extensions = material.object("extensions") ?? [:] + var mtoon = extensions.object("VRMC_materials_mtoon") ?? [:] + mtoon["renderQueueOffsetNumber"] = -1 + extensions["VRMC_materials_mtoon"] = .object(mtoon) + material["extensions"] = .object(extensions) + } + let entity = try await VRMEntityLoader(withData: modified).loadEntity() + let head = Self.headModelEntities(in: entity) + #expect(head.count == 3) + + func sortOrder(ofMaterial materialIndex: Int) throws -> ModelSortGroupComponent { + let modelEntity = try #require(head.first { + $0.components[GLTFMaterialSlotsComponent.self]?.materialIndices == [materialIndex] + }) + return try #require(modelEntity.components[ModelSortGroupComponent.self]) + } + let under = try sortOrder(ofMaterial: Self.opaqueEye) + let over = try sortOrder(ofMaterial: Self.blendedEye) + #expect(under.group == over.group) + #expect(under.order < over.order) + + let rest = try #require(head.first { !$0.components.has(ModelSortGroupComponent.self) }) + #expect(rest.components[GLTFMaterialSlotsComponent.self]?.materialIndices == [2]) + + // The split entities are the mesh to the runtime: skinned, morphed and + // bound to their materials like the one they came out of. + #expect(head.allSatisfy { $0.deformedMesh?.geometry.hasBlendShapes == true }) + #expect(head.allSatisfy { $0.components.has(GLTFSkinIndexComponent.self) }) + for modelEntity in head { + for case let materialIndex? in modelEntity.components[GLTFMaterialSlotsComponent.self]?.materialIndices ?? [] { + #expect(entity.materialStates[materialIndex]?.bindings.contains { $0.modelEntity === modelEntity } == true) + } + } + } + + /// The model entities drawing Seed-san's `head` mesh, the siblings of the one + /// drawing its blended eye, passes aside. + @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) + private static func headModelEntities(in entity: Entity) -> [ModelEntity] { + let modelEntities = entity.modelEntitiesInHierarchy.filter { + !$0.components.has(GLTFMaterialPassComponent.self) + } + guard let mesh = modelEntities.first(where: { + $0.components[GLTFMaterialSlotsComponent.self]?.materialIndices.contains(blendedEye) == true + })?.parent else { return [] } + return modelEntities.filter { $0.parent === mesh } + } +} +#endif diff --git a/Tests/VRMRealityKitTests/TestSupport.swift b/Tests/VRMRealityKitTests/TestSupport.swift index 714dd73d..3b6b42eb 100644 --- a/Tests/VRMRealityKitTests/TestSupport.swift +++ b/Tests/VRMRealityKitTests/TestSupport.swift @@ -173,8 +173,7 @@ enum TestSupport { @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) static func jointRotations(in root: Entity) -> [SIMD4] { modelEntities(in: root).flatMap { modelEntity in - modelEntity.components[SkeletalPosesComponent.self]?.poses.default? - .jointTransforms.map(\.rotation.vector) ?? [] + modelEntity.deformedMesh?.jointTransforms?.map(\.rotation.vector) ?? [] } } @@ -236,19 +235,14 @@ enum TestSupport { } /// The blend-shape weight currently applied for a glTF morph target index, - /// read back from the model entities the way RealityKit renders it. + /// read back from the mesh the way it is deformed. @MainActor @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) static func morphWeight(in root: Entity, targetIndex: Int) -> Float? { - let targetName = "blendShape_\(targetIndex)" for modelEntity in modelEntities(in: root) { - let weights = modelEntity.blendWeights - let names = modelEntity.blendWeightNames - for setIndex in names.indices where setIndex < weights.count { - guard let nameIndex = names[setIndex].firstIndex(of: targetName), - nameIndex < weights[setIndex].count else { continue } - return weights[setIndex][nameIndex] - } + guard let mesh = modelEntity.deformedMesh, + mesh.blendShapeWeights.indices.contains(targetIndex) else { continue } + return mesh.blendShapeWeights[targetIndex] } return nil } @@ -278,23 +272,17 @@ enum TestSupport { } } - /// Every model entity of the hierarchy a first-person camera cuts, with the - /// catalog its meshes come from. + /// Every model entity of the hierarchy a first-person camera cuts. @MainActor @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) - static func firstPersonCuts(in root: Entity) -> [(entity: ModelEntity, catalog: GLTFMergedMeshCatalog)] { - modelEntities(in: root).compactMap { modelEntity in - guard let merged = modelEntity.mergedMesh, merged.catalog.hasFirstPersonCut else { return nil } - return (modelEntity, merged.catalog) - } + static func firstPersonCuts(in root: Entity) -> [ModelEntity] { + modelEntities(in: root).filter { $0.deformedMesh?.hasFirstPersonCut == true } } + /// The indices every slot of `geometry` draws, whole or cut for a first-person camera. @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) - @MainActor - static func triangleIndexCount(of mesh: MeshResource) -> Int { - mesh.contents.models.reduce(0) { count, model in - count + model.parts.reduce(0) { $0 + ($1.triangleIndices?.count ?? 0) } - } + static func triangleIndexCount(of geometry: GLTFMeshGeometry, isFirstPerson: Bool = false) -> Int { + geometry.slots.indices.reduce(0) { $0 + geometry.triangleIndices(ofSlot: $1, isFirstPerson: isFirstPerson).count } } /// What the primitive under `entity` is drawing now. @@ -302,8 +290,7 @@ enum TestSupport { @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) static func drawnTriangleIndexCount(of entity: Entity) -> Int { modelEntities(in: entity) - .compactMap { $0.components[ModelComponent.self]?.mesh } - .map(triangleIndexCount) + .compactMap { $0.deformedMesh?.drawnIndexCount } .max() ?? 0 } diff --git a/Tests/VRMRealityKitTests/VRM1DifferentialTests.swift b/Tests/VRMRealityKitTests/VRM1DifferentialTests.swift new file mode 100644 index 00000000..73e978cf --- /dev/null +++ b/Tests/VRMRealityKitTests/VRM1DifferentialTests.swift @@ -0,0 +1,192 @@ +#if canImport(RealityKit) +import CryptoKit +import Foundation +import RealityKit +import simd +import Testing +import VRMKit +import VRMRealityKit +import VRMTestSupport + +/// Differential comparison of `AvatarSample_M.vrm` against UniVRM v0.131.2 and three-vrm v3.5.5 +/// references captured on macOS ARM64 (see `scripts/capture-reference-output/README.md`). +/// +/// Neither reference's captured coordinates are reconciled with RealityKit's by an axis +/// conversion here, so this suite compares only quantities that do not depend on one: +/// bone-to-bone distances (invariant under any rigid or mirrored transform), and expression/ +/// constraint names, counts, and scalar weights. Absolute positions, rotations, and spring-bone +/// positions are out of scope until a verified axis conversion is documented. +@Suite +@MainActor +struct VRM1DifferentialTests { + private struct ReferenceOutput: Decodable { + struct Fixture: Decodable { let sha256: String } + struct Tolerances: Decodable { + let translation: Double + let rotation: Double + let scalar: Double + } + struct Vector3: Decodable { let x: Double, y: Double, z: Double } + struct Quaternion: Decodable { let x: Double, y: Double, z: Double, w: Double } + struct Values: Decodable { + let names: [String] + let scalars: [Double] + let positions: [Vector3] + let rotations: [Quaternion] + } + struct Sample: Decodable { let values: Values } + struct Samples: Decodable { + let bones: [Sample] + let expressions: [Sample] + let constraints: [Sample] + } + let fixture: Fixture + let tolerances: Tolerances + let samples: Samples + } + + /// One reference implementation's naming convention and the expression presets it lists + /// without an authored clip, so the same checks run against every reference. + struct ReferenceCase: CustomStringConvertible, Sendable { + let asset: ReferenceOutputAsset + let description: String + let boneName: @Sendable (HumanoidBone) -> String? + let presetsListedWithoutAnAuthoredClip: Set + } + + // Unity's Mecanim thumb naming (proximal/intermediate/distal) differs from VRM 1.0's + // (metacarpal/proximal/distal); every other bone name matches by capitalizing the first letter. + private static nonisolated let unityBoneNames: [HumanoidBone: String] = { + var names = [HumanoidBone: String](minimumCapacity: HumanoidBone.allCases.count) + for bone in HumanoidBone.allCases { + names[bone] = bone.rawValue.prefix(1).uppercased() + bone.rawValue.dropFirst() + } + names[.leftThumbMetacarpal] = "LeftThumbProximal" + names[.leftThumbProximal] = "LeftThumbIntermediate" + names[.rightThumbMetacarpal] = "RightThumbProximal" + names[.rightThumbProximal] = "RightThumbIntermediate" + return names + }() + + static nonisolated let referenceCases: [ReferenceCase] = [ + ReferenceCase( + asset: .univrmAvatarSampleM, + description: "UniVRM", + boneName: { unityBoneNames[$0] }, + // UniVRM enumerates all 18 standard VRM 1.0 expression presets regardless of whether + // the model authors a clip; this fixture authors no look-expression clips (it drives + // look-at through eye bones instead). + presetsListedWithoutAnAuthoredClip: ["lookUp", "lookDown", "lookLeft", "lookRight"] + ), + ReferenceCase( + asset: .threeVrmAvatarSampleM, + description: "three-vrm", + // three-vrm's VRMHumanBoneName values match VRMKit's HumanoidBone raw values directly. + boneName: { $0.rawValue }, + presetsListedWithoutAnAuthoredClip: [] + ) + ] + + private func decodeReference(_ referenceCase: ReferenceCase) throws -> ReferenceOutput { + try JSONDecoder().decode(ReferenceOutput.self, from: referenceCase.asset.data) + } + + /// Each reference is only meaningful if it was captured from the exact fixture bytes this + /// test loads. + @Test(arguments: referenceCases) + func testTheFixtureBytesMatchTheCapturedReference(referenceCase: ReferenceCase) throws { + let reference = try decodeReference(referenceCase) + let digest = SHA256.hash(data: VRMSampleAsset.avatarSampleM.data) + let hex = digest.map { String(format: "%02x", $0) }.joined() + #expect(hex == reference.fixture.sha256, "\(referenceCase.description) fixture checksum differs") + } + + @Test(arguments: referenceCases) + func testHumanoidBoneToBoneDistancesMatchTheReference(referenceCase: ReferenceCase) async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let reference = try decodeReference(referenceCase) + let entity = try await VRMEntityLoader(withData: VRMSampleAsset.avatarSampleM.data, shaders: []).loadEntity() + let hips = try #require(entity.humanoid.node(for: .hips)) + + let referencePositions = Dictionary(uniqueKeysWithValues: + zip(reference.samples.bones[0].values.names, reference.samples.bones[0].values.positions)) + let referenceHips = try #require(referencePositions["Hips"] ?? referencePositions["hips"]) + + for bone in [HumanoidBone.head, .leftHand, .rightHand, .leftFoot, .rightFoot, .leftLowerArm] { + guard let node = entity.humanoid.node(for: bone), + let referenceName = referenceCase.boneName(bone), + let referencePosition = referencePositions[referenceName] else { + Issue.record("missing bone \(bone) on one side of the \(referenceCase.description) comparison") + continue + } + + let vrmKitDistance = simd_distance(node.position(relativeTo: entity), hips.position(relativeTo: entity)) + let dx = referencePosition.x - referenceHips.x + let dy = referencePosition.y - referenceHips.y + let dz = referencePosition.z - referenceHips.z + let referenceDistance = Float((dx * dx + dy * dy + dz * dz).squareRoot()) + + #expect(abs(vrmKitDistance - referenceDistance) < Float(reference.tolerances.translation), + "\(bone) sits a different distance from the hips than \(referenceCase.description) measured") + } + } + + @Test(arguments: referenceCases) + func testSampledHumanoidRotationsMatchTheReference(referenceCase: ReferenceCase) async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let reference = try decodeReference(referenceCase) + let entity = try await VRMEntityLoader(withData: VRMSampleAsset.avatarSampleM.data, shaders: []).loadEntity() + let values = reference.samples.bones[0].values + let referenceRotations = Dictionary(uniqueKeysWithValues: + zip(values.names, values.rotations)) + + for bone in [HumanoidBone.hips, .head, .leftHand, .rightHand, .leftFoot, .rightFoot] { + guard let node = entity.humanoid.node(for: bone), + let referenceName = referenceCase.boneName(bone), + let expected = referenceRotations[referenceName] else { + Issue.record("missing rotation for \(bone) in \(referenceCase.description) reference") + continue + } + + let actual = Transform(matrix: node.transformMatrix(relativeTo: entity)).rotation + let expectedQuaternion = simd_quatf(ix: Float(expected.x), + iy: Float(expected.y), + iz: Float(expected.z), + r: Float(expected.w)) + #expect(abs(simd_dot(actual, expectedQuaternion)) + > 1 - Float(reference.tolerances.rotation), + "\(bone) rotation differs from \(referenceCase.description)") + } + } + + @Test(arguments: referenceCases) + func testExpressionNamesAndRestWeightsMatchTheReference(referenceCase: ReferenceCase) async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let reference = try decodeReference(referenceCase) + let entity = try await VRMEntityLoader(withData: VRMSampleAsset.avatarSampleM.data, shaders: []).loadEntity() + + let referenceNames = Set(reference.samples.expressions[0].values.names) + let vrmKitNames = Set(entity.availableExpressions.compactMap { $0.key.preset?.rawValue }) + #expect(vrmKitNames == referenceNames.subtracting(referenceCase.presetsListedWithoutAnAuthoredClip), + "\(referenceCase.description) expression names differ") + + for (name, weight) in zip(reference.samples.expressions[0].values.names, + reference.samples.expressions[0].values.scalars) { + guard vrmKitNames.contains(name), let preset = ExpressionPreset(name: name) else { continue } + let actual = Double(entity.expression(for: .preset(preset))) + #expect(abs(actual - weight) < reference.tolerances.scalar, + "\(name) rest weight differs from \(referenceCase.description)") + } + } + + @Test(arguments: referenceCases) + func testNodeConstraintCountMatchesTheReference(referenceCase: ReferenceCase) throws { + let reference = try decodeReference(referenceCase) + let vrm = try VRM1(data: VRMSampleAsset.avatarSampleM.data) + let constrainedNodeCount = vrm.document.gltf.nodes.filter { $0.extensions?.nodeConstraint != nil }.count + #expect(constrainedNodeCount == reference.samples.constraints[0].values.names.count, + "\(referenceCase.description) constraint count differs") + } +} +#endif + diff --git a/Tests/VRMRealityKitTests/VRMAnimationPlaybackTests.swift b/Tests/VRMRealityKitTests/VRMAnimationPlaybackTests.swift index 31db1c91..c8810387 100644 --- a/Tests/VRMRealityKitTests/VRMAnimationPlaybackTests.swift +++ b/Tests/VRMRealityKitTests/VRMAnimationPlaybackTests.swift @@ -414,6 +414,35 @@ struct VRMAnimationPlaybackTests { #expect(abs(simd_dot(delta, expected)) > 0.999) } + @Test + func testPlaybackControlsLoopReverseSeekSpeedStopAndClipReplacement() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try await VRMEntityLoader(withData: TestSupport.seedSanData).loadEntity() + + let looping = try entity.playAnimation(fixture(), loops: true, speed: 2) + entity.updateAnimations(deltaTime: 0.75) + #expect(looping.time.isApproximatelyEqual(to: 0.5, tolerance: 0.001)) + looping.seek(to: 0.25) + #expect(looping.time.isApproximatelyEqual(to: 0.25, tolerance: 0.001)) + looping.stop() + #expect(looping.isComplete) + + let reverse = try entity.playAnimation(fixture(), speed: -1) + #expect(reverse.time.isApproximatelyEqual(to: 1, tolerance: 0.001)) + entity.updateAnimations(deltaTime: 0.25) + #expect(reverse.time.isApproximatelyEqual(to: 0.75, tolerance: 0.001)) + + let replacement = try entity.playAnimation( + try VRMAnimation(data: VRMASampleFixture.holdingPose(expressionWeight: 0.2)), + loops: true) + entity.updateAnimations(deltaTime: 0.1) + #expect(abs(entity.expression(for: .preset(.aa)) - 0.2) < 0.001) + replacement.stop() + reverse.stop() + entity.updateAnimations(deltaTime: 0) + #expect(entity.expression(for: .preset(.aa)) > 0.2) + } + /// 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 @@ -456,6 +485,23 @@ struct VRMAnimationPlaybackTests { #expect(entity.expression(for: .preset(.aa)) > 0.25) } + @Test + func testLipSyncOverridesVRMAMouthTracksUntilReleased() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try await VRMEntityLoader(withData: TestSupport.seedSanData).loadEntity() + try entity.playAnimation(fixture(), loops: true) + + entity.setLipSyncExpressions([.preset(.aa): 0.2]) + for _ in 0..<4 { + entity.updateAnimations(deltaTime: 0.2) + #expect(abs(entity.expression(for: .preset(.aa)) - 0.2) < 0.001) + } + + entity.clearLipSyncExpressions(for: [.preset(.aa)]) + entity.updateAnimations(deltaTime: 0) + #expect(entity.expression(for: .preset(.aa)) > 0.2) + } + /// Pausing holds the pose against the animations the paused one outranks, /// rather than handing them its targets for as long as it is paused. @Test diff --git a/Tests/VRMRealityKitTests/VRMFirstPersonTests.swift b/Tests/VRMRealityKitTests/VRMFirstPersonTests.swift index a47c8aa8..c05fc982 100644 --- a/Tests/VRMRealityKitTests/VRMFirstPersonTests.swift +++ b/Tests/VRMRealityKitTests/VRMFirstPersonTests.swift @@ -123,25 +123,25 @@ struct VRMFirstPersonTests { let vrmLoader = try VRMEntityLoader(withData: VRMSampleAsset.aliciaSolid.data, shaders: []) let vrmEntity = try await vrmLoader.loadEntity() let cut = TestSupport.firstPersonCuts(in: vrmEntity) - func firstPersonMesh(of catalog: GLTFMergedMeshCatalog) throws -> MeshResource? { - try catalog.mesh(visibleSlots: catalog.initiallyVisibleSlots, isFirstPerson: true) + func headlessIndexCount(of entity: ModelEntity) throws -> Int { + TestSupport.triangleIndexCount(of: try #require(entity.gltfMeshGeometry), isFirstPerson: true) } - let trimmed = try #require(try cut.first { try firstPersonMesh(of: $0.catalog) != nil }) - let dropped = try #require(try cut.first { try firstPersonMesh(of: $0.catalog) == nil }) + let trimmed = try #require(try cut.first { try headlessIndexCount(of: $0) > 0 }) + let dropped = try #require(try cut.first { try headlessIndexCount(of: $0) == 0 }) // The trimmed mesh loses triangles without losing all of them. - let whole = TestSupport.triangleIndexCount(of: trimmed.catalog.fullMesh) - let headless = TestSupport.triangleIndexCount(of: try #require(try firstPersonMesh(of: trimmed.catalog))) + let whole = TestSupport.triangleIndexCount(of: try #require(trimmed.gltfMeshGeometry)) + let headless = try headlessIndexCount(of: trimmed) #expect(headless > 0) #expect(headless < whole) vrmEntity.setFirstPersonRenderMode(.firstPerson) - #expect(TestSupport.drawnTriangleIndexCount(of: trimmed.entity) == headless) - #expect(dropped.entity.isEnabled == false) + #expect(TestSupport.drawnTriangleIndexCount(of: trimmed) == headless) + #expect(dropped.isEnabled == false) vrmEntity.setFirstPersonRenderMode(.thirdPerson) - #expect(TestSupport.drawnTriangleIndexCount(of: trimmed.entity) == whole) - #expect(dropped.entity.isEnabled == true) + #expect(TestSupport.drawnTriangleIndexCount(of: trimmed) == whole) + #expect(dropped.isEnabled == true) } } #endif diff --git a/Tests/VRMRealityKitTests/VRMSpringBoneEntityTests.swift b/Tests/VRMRealityKitTests/VRMSpringBoneEntityTests.swift index 9b0ecf73..4e5a91a8 100644 --- a/Tests/VRMRealityKitTests/VRMSpringBoneEntityTests.swift +++ b/Tests/VRMRealityKitTests/VRMSpringBoneEntityTests.swift @@ -25,11 +25,11 @@ struct VRMSpringBoneEntityTests { vrmEntity.update(deltaTime: 1.0 / 60.0) var checkedJoints = 0 - for modelEntity in TestSupport.modelEntities(in: vrmEntity) { - guard let model = modelEntity.components[ModelComponent.self], - let skeleton = model.mesh.contents.skeletons.first, - let pose = modelEntity.components[SkeletalPosesComponent.self]?.poses.default, - pose.jointTransforms.count == skeleton.joints.count else { + for binding in vrmEntity.skinBindings { + let modelEntity = binding.modelEntity + let skeleton = binding.skeleton + guard let jointTransforms = binding.deformedMesh?.jointTransforms, + jointTransforms.count == skeleton.joints.count else { continue } let jointEntities = skeleton.joints.map { vrmEntity.findEntity(named: $0.name) } @@ -47,7 +47,7 @@ struct VRMSpringBoneEntityTests { } // The pose must describe the hierarchy as it stands after // update(), not as it stood before the spring bones ran. - #expect(pose.jointTransforms[index].matrix.isApproximatelyEqual(to: expected, tolerance: 0.0005)) + #expect(jointTransforms[index].matrix.isApproximatelyEqual(to: expected, tolerance: 0.0005)) checkedJoints += 1 } } diff --git a/Tests/VRMRealityKitTests/VRMUpdateSystemTests.swift b/Tests/VRMRealityKitTests/VRMUpdateSystemTests.swift index 57c31a5f..bf19866e 100644 --- a/Tests/VRMRealityKitTests/VRMUpdateSystemTests.swift +++ b/Tests/VRMRealityKitTests/VRMUpdateSystemTests.swift @@ -4,6 +4,7 @@ import RealityKit import simd import Testing import VRMKit +import VRMTestSupport @testable import VRMRealityKit @Suite @@ -23,6 +24,26 @@ struct VRMUpdateSystemTests { #expect(vrmEntity.isAutomaticUpdateEnabled) } + @Test + func testManualModeRemovesAutomaticTickBeforeExplicitUpdate() async throws { + guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } + let entity = try await VRMEntityLoader(withData: TestSupport.seedSanData, shaders: []).loadEntity() + let hips = try #require(entity.humanoid.node(for: .hips)) + let initial = hips.transform.rotation + + entity.isAutomaticUpdateEnabled = false + #expect(!entity.isAutomaticUpdateEnabled) + #expect(!entity.components.has(VRMUpdateComponent.self)) + + try entity.playAnimation(try VRMAnimation(data: VRMASampleFixture.standard())) + entity.updateAnimations(deltaTime: 0.5) + entity.update(deltaTime: 0) + #expect(hips.transform.rotation != initial) + + entity.isAutomaticUpdateEnabled = true + #expect(entity.components.has(VRMUpdateComponent.self)) + } + @Test func testParentKeepsTheLoadedEntityAlive() async throws { guard #available(iOS 18.0, macOS 15.0, visionOS 2.0, *) else { return } diff --git a/Tests/VRMTestSupport/ReferenceOutputAsset.swift b/Tests/VRMTestSupport/ReferenceOutputAsset.swift new file mode 100644 index 00000000..83285388 --- /dev/null +++ b/Tests/VRMTestSupport/ReferenceOutputAsset.swift @@ -0,0 +1,15 @@ +import Foundation + +/// The captured differential reference JSON files under `Tests/Assets/ReferenceOutputs`. +/// +/// Each file validates against `Tests/Assets/ReferenceOutputs/schema.json` and records the +/// source implementation, its pinned revision, and the fixture it was captured from. See +/// `scripts/capture-reference-output/README.md` for the capture procedure. +public enum ReferenceOutputAsset: String, CaseIterable, Sendable { + case univrmAvatarSampleM = "univrm/AvatarSample_M.json" + case threeVrmAvatarSampleM = "three-vrm/AvatarSample_M.json" + + public var data: Data { + TestAssetBundle.data(forFixture: "ReferenceOutputs/\(rawValue)") + } +} diff --git a/Tests/VRMTestSupport/VRMSampleAsset.swift b/Tests/VRMTestSupport/VRMSampleAsset.swift index 6922ad73..03b22d34 100644 --- a/Tests/VRMTestSupport/VRMSampleAsset.swift +++ b/Tests/VRMTestSupport/VRMSampleAsset.swift @@ -9,6 +9,9 @@ public enum VRMSampleAsset: String, CaseIterable, Sendable { case seedSan = "Seed-san.vrm" /// A VRM 1.0 model exercising `VRMC_node_constraint`. case vrm1ConstraintTwist = "VRM1_Constraint_Twist_Sample.vrm" + /// A VRM 1.0 model with a full humanoid map, optional bones, expressions, + /// look-at, spring bones and MToon materials. + case avatarSampleM = "AvatarSample_M.vrm" public var url: URL { TestAssetBundle.url(forFixture: "VRM/\(rawValue)") diff --git a/docs/vrm-1.0-conformance.md b/docs/vrm-1.0-conformance.md new file mode 100644 index 00000000..e2c49f19 --- /dev/null +++ b/docs/vrm-1.0-conformance.md @@ -0,0 +1,63 @@ +# VRM 1.0 Conformance Matrix + +This file is the living evidence table for VRM 1.0 support in the package. Status values are intentionally explicit: `supported`, `partial`, `unsupported`, `not applicable`, or `tbd`. + +## Legend + +- `supported`: behavior is implemented and backed by an executable test or fixture. +- `partial`: behavior is partly implemented but missing validation, edge cases, or platform coverage. +- `unsupported`: the package intentionally does not claim support. +- `not applicable`: the feature is not relevant for this package or runtime. +- `tbd`: evidence is still pending. + +## Feature Matrix + +| Feature | Spec section | Current owner | Existing unit / fixture test | UniVRM comparison source | three-vrm comparison source | Status | Known approximation or limitation | +| --- | --- | --- | --- | --- | --- | --- | --- | +| VRM 1.0 spec-version and required extension validation | `VRMC_vrm` root extension | VRMKit | `Tests/VRMKitTests/VRM1Tests.swift` (`testMalformedSpecVersionThrowsInsteadOfCrashing`, `testUnsupportedSpecVersionIsRejected`) | UniVRM rejects unsupported `specVersion` values | N/A | supported | Unsupported versions are rejected before runtime use; no renderer-specific approximation. | +| Core metadata and license fields | VRM 1.0 metadata | VRMKit | `Tests/VRMKitTests/VRM1Tests.swift` (`testMeta`) | UniVRM metadata validation | N/A | supported | The metadata surface is decoded as typed enums/strings and remains stable; license URLs are required. | +| Humanoid required bones | Humanoid definition | VRMKitRuntime | `Tests/VRMKitTests/HumanoidBoneTests.swift` (`testAVRM1RigMissingARequiredBoneIsRejected`) | UniVRM humanoid mapping | N/A | supported | Missing required bones fail fast with a typed error; no implicit recovery is attempted. | +| Humanoid optional bones | Humanoid definition | VRMKitRuntime | `Tests/VRMKitTests/HumanoidBoneTests.swift` (`testAnUnknownBoneNameIsIgnored`) | UniVRM optional-bone handling | N/A | supported | Unknown or extra bone names are ignored; missing optional bones remain non-fatal. | +| First-person mesh annotations | VRM first-person | VRMKit / VRMRealityKit | `Tests/VRMKitTests/VRM1Tests.swift` (`testFirstPerson`) | UniVRM first-person semantics | N/A | supported | Requires renderer integration to use the annotations, but the parser and metadata layer is validated. | +| Expressions: preset and custom | Expression specification | VRMKitRuntime | `Tests/VRMKitRuntimeTests/ExpressionTests.swift`; `Tests/VRMRealityKitTests/VRMExpressionTests.swift` | UniVRM expression binds | three-vrm expression output | supported | Preset mapping, custom names, and runtime accumulation are validated in the package tests. | +| Expression binds: morph, material color, texture transform | Expression binding spec | VRMKit | `Tests/VRMKitTests/VRM1Tests.swift`; `Tests/VRMRealityKitTests/VRMExpressionTests.swift` | UniVRM bind semantics | three-vrm output differential | supported | Material-color and texture-transform binds are tested in the runtime / renderer paths, with intent captured in the fixture metadata. | +| Expression binary and override semantics | Expression override rules | VRMKitRuntime | `Tests/VRMKitRuntimeTests/ExpressionTests.swift`; `Tests/VRMRealityKitTests/VRMExpressionTests.swift` | UniVRM expression override logic | three-vrm override groups | supported | Binary override semantics, suppression, and accumulation are validated with explicit regression tests. | +| Look-at bone and expression modes | Look-at spec | VRMKitRuntime | `Tests/VRMKitRuntimeTests/LookAtRigTests.swift`; `Tests/VRMRealityKitTests/VRMLookAtTests.swift` | UniVRM look-at ranges | three-vrm normalized gaze output | supported | Range maps, orientation conversion, and model-space target handling are validated for both bone and expression-driven gaze. | +| Node constraints: roll, aim, rotation | Node constraint spec | VRMKitRuntime | `Tests/VRMKitRuntimeTests/NodeConstraintOrderTests.swift`; `Tests/VRMKitTests/VRM1Tests.swift` (`testConstraintTwistSampleContainsRollAndAimConstraints`) | UniVRM constraint semantics | three-vrm constraint output | supported | Seed-san covers rotation constraints and ordering; the VRM specification twist sample adds parser evidence for roll and aim constraints. | +| Spring bones: joints, collider groups, center transforms | Spring bone spec | VRMKitRuntime | `Tests/VRMKitRuntimeTests/SpringBoneRigTests.swift`; `Tests/VRMKitRuntimeTests/SpringBoneRuntimeTests.swift` | UniVRM spring assembly | three-vrm spring output | supported | Chain solving, gravity, colliders, reset behavior, and invalid spring definitions are validated by runtime tests. | +| MToon materials and texture transforms | MToon material spec | VRMKit / VRMRealityKit | `Tests/VRMKitTests/GLTFMToonTests.swift`; `Tests/VRMRealityKitTests/MToonRenderingTests.swift` | UniVRM material conversion | three-vrm material conversions | supported | The material conversion and runtime parameter paths are covered by conversion and rendering tests; platform-specific approximations remain explicit. | +| VRMA humanoid retargeting | VRMA humanoid track spec | VRMKitRuntime | `Tests/VRMKitTests/VRMAnimationTests.swift`; `Tests/VRMRealityKitTests/VRMAnimationPlaybackTests.swift` | UniVRM retargeting | three-vrm VRMA output | supported | Hips retargeting, parent transforms, and runtime playback are covered by the existing VRMA tests. | +| VRMA expression and look-at tracks | VRMA expression / look-at spec | VRMKitRuntime | `Tests/VRMKitTests/VRMAnimationTests.swift`; `Tests/VRMRealityKitTests/VRMAnimationPlaybackTests.swift` | UniVRM animation tracks | three-vrm output comparison | supported | Expression ownership and look-at playback are validated in the runtime tests. | +| Runtime loading, cancellation, and resource lifetime | Loader lifecycle | VRMRealityKit / VRMKit | `Tests/VRMRealityKitTests/AsyncLoadingTests.swift`; `Tests/VRMRealityKitTests/GLTFEntityLoaderTests.swift` | UniVRM loader behavior | three-vrm asset lifecycle expectations | supported | Cancellation, reuse, queued load behavior, and malformed primitive failures are all exercised by the loader tests. | +| RealityKit rendering limitations and approximations | RealityKit platform support | VRMRealityKit | `Tests/VRMRealityKitTests/*.swift` | N/A | N/A | tbd | Keep renderer-specific limits explicit and non-claiming. | + +## MToon 1.0 Property Matrix + +The MToon row above is supported by the following property-level evidence. `supported` means the +property is decoded, converted into runtime shader parameters, and covered by a focused test; +`approximation` means the value is represented with a documented RealityKit limitation rather than +silently ignored. + +| Property family | VRM 1.0 fields | Evidence | Status | Limitation | +| --- | --- | --- | --- | --- | +| Version and fallback | `specVersion`, `KHR_materials_unlit` | `MToonMaterialDescriptorTests.testUnsupportedMToonSpecVersionFallsBackToNonMToon`; `GLTFEntityLoaderTests` required-extension tests | supported | Unsupported MToon versions fall back unless the extension is required. | +| Base and shade | `shadeColorFactor`, `shadeMultiplyTexture`, `shadingShiftFactor`, `shadingShiftTexture`, `shadingToonyFactor` | `MToonMaterialDescriptorTests`; `MToonRenderingTests` parameter and texture tests | supported | Shading is implemented by the bundled RealityKit custom material. | +| GI and matcap | `giEqualizationFactor`, `matcapFactor`, `matcapTexture` | `MToonMaterialDescriptorTests.testVRM1EmissiveFieldsAndMatcapDefaultUseGltfMaterial`; `MToonRenderingTests` parameter tests | supported | Matcap follows the available RealityKit parameter path. | +| Rim | `parametricRimColorFactor`, `rimMultiplyTexture`, `rimLightingMixFactor`, `parametricRimFresnelPowerFactor`, `parametricRimLiftFactor` | `MToonMaterialDescriptorTests`; `MToonRenderingTests` rim/light parameter tests | supported | Runtime light direction/color are explicit controls. | +| Outline | `outlineWidthMode`, `outlineWidthFactor`, `outlineWidthMultiplyTexture`, `outlineColorFactor`, `outlineLightingMixFactor` | `GLTFMToonTests`; `MToonOutlineTests`; `MToonOutlineRenderingTests`; `MToonRenderingTests` | supported | Outline width is clamped to the culling margin; render-queue offsets are not material-level controls in RealityKit. | +| UV animation | `uvAnimationMaskTexture`, `uvAnimationScrollXSpeedFactor`, `uvAnimationScrollYSpeedFactor`, `uvAnimationRotationSpeedFactor` | `MToonMaterialDescriptorTests`; `MToonRenderingTests`; `TextureTransformRenderingTests` | supported | Texture transforms use the renderer's documented first-UV-accessed texture approximation. | +| Transparency and ordering | `transparentWithZWrite`, `renderQueueOffsetNumber` | `MToonRenderingTests`; `RenderQueueTests`; `MToonMaterialDescriptorTests` | approximation | `transparentWithZWrite` maps to depth writes; `renderQueueOffsetNumber` is ignored because RealityKit has no material-level draw-order hook. | +| Color and emission | `emissiveFactor`, base/shade/rim/outline colors, `emissiveTexture` | `MToonMaterialDescriptorTests`; `MToonRenderingTests`; `VRMExpressionTests` material-color binds | supported | Unity sRGB colors are converted to linear values; emission remains linear. | +| Texture metadata | MToon texture `index`, `texCoord`, `KHR_texture_transform`, shading-shift `scale` | `GLTFMToonTests`; `MToonMaterialDescriptorTests`; `TextureTransformRenderingTests` | approximation | RealityKit supports one effective UV transform per material; required unsupported combinations fail or VRM rendering uses the documented approximation. | +| Culling and normal response | glTF `doubleSided`, normal texture scale, normal texture binding | `MToonMaterialDescriptorTests`; `MToonRenderingTests`; `GLTFMeshAttributeTests` | supported | Tangents without authored `TANGENT` use the documented generated approximation. | + +## Immediate follow-up + +This scaffold is intended to turn into a closed evidence table by the end of Phase 1. Each row should be narrowed to one of the following actions before the row is considered `supported`: + +1. A focused regression test or fixture. +2. Recorded comparison output from UniVRM and/or three-vrm. +3. A clear note documenting any deliberate approximation or platform limit. +4. A source owner and review trail for the behavior. + +The first work stream should focus on the parser, metadata, and version-validation rows before moving to expression, look-at, and spring-bone parity. diff --git a/scripts/capture-reference-output/README.md b/scripts/capture-reference-output/README.md new file mode 100644 index 00000000..66f778ca --- /dev/null +++ b/scripts/capture-reference-output/README.md @@ -0,0 +1,58 @@ +# Capture conformance reference output + +This procedure records observable VRM and VRMA behavior from UniVRM and three-vrm without copying +engine-specific code into VRMKit. Captured files must validate against +`Tests/Assets/ReferenceOutputs/schema.json`. + +## Reference revisions + +Use the following revisions unless a comparison run explicitly records newer ones: + +| Source | URL | Revision | Release | +| --- | --- | --- | --- | +| UniVRM | [github.com/vrm-c/univrm](https://github.com/vrm-c/univrm) | `928b30e96439c1c11a49b172efb72eb96d2cad8b` | `v0.131.2` | +| three-vrm | [github.com/pixiv/three-vrm](https://github.com/pixiv/three-vrm) | `1b4fc0cc7ef39a49d62bb7a66dcfeca8f65316f7` | `v3.5.5` | + +## Capture contract + +1. Select one fixture and record its repository-relative path and SHA-256 digest. +2. Load the fixture with the selected reference implementation at the pinned revision. +3. Sample the same times and inputs for normalized bones, expressions, look-at, constraints, spring + positions, and VRMA playback. +4. Export one JSON document with `source`, `fixture`, `coordinateSystem`, `tolerances`, and `samples` + fields required by the schema. +5. Record any coordinate conversion in `coordinateSystem.notes`; do not silently transform values. +6. Store the result under `Tests/Assets/ReferenceOutputs/univrm/` or + `Tests/Assets/ReferenceOutputs/three-vrm/`. +7. Validate the JSON with a standards-compliant JSON Schema validator before comparing it with Swift + output. + +## three-vrm capture script + +`scripts/capture-reference-output/three-vrm/capture.mjs` captures headlessly with `three` and +`@pixiv/three-vrm@3.5.5` on Node.js (no browser/renderer required): + +```bash +cd scripts/capture-reference-output/three-vrm +npm install +node capture.mjs Tests/Assets/VRM/AvatarSample_M.vrm ../../../Tests/Assets/ReferenceOutputs/three-vrm/AvatarSample_M.json +``` + +Material and texture data is stripped from the GLB's JSON chunk before parsing, since headless Node +has no `Image`/canvas decoder and this capture only needs humanoid bones, expressions, look-at, +node constraints, and spring-bone joints, none of which depend on materials. + +## Comparison rules + +- Pin the exact source commit and release in every output; never compare moving branches implicitly. +- Compare translations and spring positions in meters, rotations as normalized quaternions or + documented Euler angles, and scalar weights with the tolerances recorded in the output. +- Convert coordinate systems once at the boundary and document the conversion. +- Do not use pixel identity as a conformance criterion. Record rendering comparisons separately from + normalized runtime samples. + +`Tests/Assets/ReferenceOutputs/univrm/AvatarSample_M.json` and +`Tests/Assets/ReferenceOutputs/three-vrm/AvatarSample_M.json` are both captured, schema-valid, and +compared against VRMKit's own output by `Tests/VRMRealityKitTests/VRM1DifferentialTests.swift`. +A differential result must not be marked complete for a new fixture until both the source output +and the corresponding VRMKit output are checked in and reviewed. diff --git a/scripts/capture-reference-output/three-vrm/capture.mjs b/scripts/capture-reference-output/three-vrm/capture.mjs new file mode 100644 index 00000000..b4fd2f7a --- /dev/null +++ b/scripts/capture-reference-output/three-vrm/capture.mjs @@ -0,0 +1,199 @@ +// Headless capture of humanoid bones, expressions, look-at, node constraints, and +// spring-bone joint positions from a VRM 1.0 asset via three-vrm, matching the schema at +// Tests/Assets/ReferenceOutputs/schema.json. See ../README.md for the capture contract. +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import * as THREE from 'three'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { VRMLoaderPlugin, VRMHumanBoneName, VRMExpressionPresetName } from '@pixiv/three-vrm'; + +const SOURCE_VERSION = 'v3.5.5'; +const SOURCE_COMMIT = '1b4fc0cc7ef39a49d62bb7a66dcfeca8f65316f7'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '../../..'); + +function toVector3(vector) { + return { x: vector.x, y: vector.y, z: vector.z }; +} + +// This capture never renders anything, so material/texture data is dropped before +// parsing rather than polyfilling a headless Image/canvas decoder for it. +function pruneTextureReferences(value) { + if (Array.isArray(value)) { + for (const item of value) pruneTextureReferences(item); + return; + } + if (value === null || typeof value !== 'object') return; + for (const key of Object.keys(value)) { + const child = value[key]; + if (child !== null && typeof child === 'object' && !Array.isArray(child) + && typeof child.index === 'number') { + delete value[key]; + } else { + pruneTextureReferences(child); + } + } +} + +function stripTexturesFromGLB(buffer) { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const jsonChunkLength = view.getUint32(12, true); + const jsonBytes = buffer.subarray(20, 20 + jsonChunkLength); + const json = JSON.parse(jsonBytes.toString('utf8')); + + delete json.images; + delete json.textures; + delete json.samplers; + pruneTextureReferences(json.materials); + + let jsonString = JSON.stringify(json); + while (jsonString.length % 4 !== 0) jsonString += ' '; + const newJsonBytes = Buffer.from(jsonString, 'utf8'); + + const binChunkStart = 12 + 8 + jsonChunkLength; + const binChunk = binChunkStart < buffer.byteLength ? buffer.subarray(binChunkStart) : Buffer.alloc(0); + + const header = Buffer.alloc(12); + header.writeUInt32LE(0x46546c67, 0); // 'glTF' + header.writeUInt32LE(2, 4); + header.writeUInt32LE(12 + 8 + newJsonBytes.length + binChunk.length, 8); + + const jsonChunkHeader = Buffer.alloc(8); + jsonChunkHeader.writeUInt32LE(newJsonBytes.length, 0); + jsonChunkHeader.writeUInt32LE(0x4e4f534a, 4); // 'JSON' + + return Buffer.concat([header, jsonChunkHeader, newJsonBytes, binChunk]); +} + +async function loadVrm(fixtureRelativePath) { + const absolutePath = path.join(repoRoot, fixtureRelativePath); + const bytes = await readFile(absolutePath); + const sanitized = stripTexturesFromGLB(bytes); + + const loader = new GLTFLoader(); + loader.register((parser) => new VRMLoaderPlugin(parser)); + const gltf = await loader.parseAsync( + sanitized.buffer.slice(sanitized.byteOffset, sanitized.byteOffset + sanitized.byteLength), ''); + const vrm = gltf.userData.vrm; + vrm.scene.updateMatrixWorld(true); + + return { vrm, bytes, absolutePath }; +} + +function captureBones(vrm) { + const names = []; + const positions = []; + const rotations = []; + const hips = vrm.humanoid.getRawBoneNode(VRMHumanBoneName.Hips); + const origin = new THREE.Vector3(); + hips.getWorldPosition(origin); + + for (const boneName of Object.values(VRMHumanBoneName)) { + const node = vrm.humanoid.getRawBoneNode(boneName); + if (!node) continue; + const position = new THREE.Vector3(); + const rotation = new THREE.Quaternion(); + node.getWorldPosition(position); + node.getWorldQuaternion(rotation); + names.push(boneName); + positions.push(toVector3(position)); + rotations.push({ x: rotation.x, y: rotation.y, z: rotation.z, w: rotation.w }); + } + + return { names, types: [], sources: [], scalars: [], positions, rotations }; +} + +function captureExpressions(vrm) { + const manager = vrm.expressionManager; + const names = []; + const scalars = []; + if (manager) { + for (const name of Object.values(VRMExpressionPresetName)) { + if (!manager.getExpression(name)) continue; + names.push(name); + scalars.push(manager.getValue(name) ?? 0); + } + } + return { names, types: [], sources: [], scalars, positions: [], rotations: [] }; +} + +function captureLookAt(vrm) { + const lookAt = vrm.lookAt; + const yaw = lookAt?.yaw ?? 0; + const pitch = lookAt?.pitch ?? 0; + return { names: ['yaw', 'pitch'], types: [], sources: [], scalars: [yaw, pitch], positions: [], rotations: [] }; +} + +function captureConstraints(vrm) { + const names = []; + const types = []; + for (const constraint of vrm.nodeConstraintManager?.constraints ?? []) { + names.push(constraint.destination?.name ?? ''); + types.push(constraint.constructor.name); + } + return { names, types, sources: [], scalars: [], positions: [], rotations: [] }; +} + +function captureSpringPositions(vrm) { + const names = []; + const positions = []; + for (const joint of vrm.springBoneManager?.joints ?? []) { + const position = new THREE.Vector3(); + joint.bone.getWorldPosition(position); + names.push(joint.bone.name); + positions.push(toVector3(position)); + } + return { names, types: [], sources: [], scalars: [], positions, rotations: [] }; +} + +async function main() { + const fixtureRelativePath = process.argv[2] ?? 'Tests/Assets/VRM/AvatarSample_M.vrm'; + const outputPath = process.argv[3] + ?? path.join(repoRoot, 'Tests/Assets/ReferenceOutputs/three-vrm/AvatarSample_M.json'); + + const { vrm, bytes } = await loadVrm(fixtureRelativePath); + const sha256 = createHash('sha256').update(bytes).digest('hex'); + + const output = { + schemaVersion: '1', + source: { + name: 'three-vrm', + version: SOURCE_VERSION, + commit: SOURCE_COMMIT, + notes: 'Captured headlessly with three.js + @pixiv/three-vrm on Node.js.' + }, + fixture: { path: fixtureRelativePath, sha256 }, + coordinateSystem: { + handedness: 'right', + upAxis: '+Y', + forwardAxis: '-Z', + units: 'meters', + notes: 'three.js world coordinates; rotations are three.js quaternions.' + }, + tolerances: { translation: 0.002, rotation: 0.001, scalar: 0.001 }, + samples: { + bones: [{ time: 0, values: captureBones(vrm), notes: '' }], + expressions: [{ time: 0, values: captureExpressions(vrm), notes: '' }], + lookAt: [{ time: 0, values: captureLookAt(vrm), notes: '' }], + constraints: [{ time: 0, values: captureConstraints(vrm), notes: '' }], + springPositions: [{ time: 0, values: captureSpringPositions(vrm), notes: '' }], + vrma: [{ + time: 0, + values: captureBones(vrm), + notes: 'No three-vrm VRMA controller is attached; this is the current runtime pose.' + }] + } + }; + + await writeFile(outputPath, JSON.stringify(output, null, 4)); + console.log(`three-vrm reference output written: ${outputPath}`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/capture-reference-output/three-vrm/package-lock.json b/scripts/capture-reference-output/three-vrm/package-lock.json new file mode 100644 index 00000000..5f60ecb6 --- /dev/null +++ b/scripts/capture-reference-output/three-vrm/package-lock.json @@ -0,0 +1,158 @@ +{ + "name": "vrmkit-three-vrm-capture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vrmkit-three-vrm-capture", + "version": "1.0.0", + "dependencies": { + "@pixiv/three-vrm": "3.5.5", + "three": "0.180.0" + } + }, + "node_modules/@pixiv/three-vrm": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm/-/three-vrm-3.5.5.tgz", + "integrity": "sha512-RPXy7jYAXs704NIpZlosB0U2ENu21G9DrqGWdQgRe8dShaCo1ugpj+6BVPRCy91nt+MPMA96j5rbsSzEl0HlQA==", + "license": "MIT", + "dependencies": { + "@pixiv/three-vrm-core": "3.5.5", + "@pixiv/three-vrm-materials-hdr-emissive-multiplier": "3.5.5", + "@pixiv/three-vrm-materials-mtoon": "3.5.5", + "@pixiv/three-vrm-materials-v0compat": "3.5.5", + "@pixiv/three-vrm-node-constraint": "3.5.5", + "@pixiv/three-vrm-springbone": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-core": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-core/-/three-vrm-core-3.5.5.tgz", + "integrity": "sha512-jjaXZbMVRsk2HAGVxqDLmSJyGOJqbMcJpuTHR1TY/TN1FxiKdAzSqdr/ZUcczYipNf8IL9vDT9NX2XmWj+ybRQ==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrm-0.0": "3.5.5", + "@pixiv/types-vrmc-vrm-1.0": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-materials-hdr-emissive-multiplier": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-hdr-emissive-multiplier/-/three-vrm-materials-hdr-emissive-multiplier-3.5.5.tgz", + "integrity": "sha512-XnHEqpY4ctYMucpeRcptdphOe0RrW7V5fUvojasD8Ja4MTjjrjCNFxsAvp3+HBkPZbkTM2sDCjJBJNzxN1scrA==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-materials-mtoon": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-mtoon/-/three-vrm-materials-mtoon-3.5.5.tgz", + "integrity": "sha512-7lThHf4OKBOsVenD+R6L6HffNUlKO3Twnhr3+z77MnUsSzsOanjVKJtjSIly9QJ6hgl8YJmI6RnxCKqXcpplog==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrm-0.0": "3.5.5", + "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-materials-v0compat": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-v0compat/-/three-vrm-materials-v0compat-3.5.5.tgz", + "integrity": "sha512-NjsTK4P4qdcr+RoL2gEUNgF2CY/aplNvyHHxI99N7D6CxDZ2H93FQ9aIuLCABj5b+4CIvldWMCcnS7YuLIr3Lw==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrm-0.0": "3.5.5", + "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-node-constraint": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-node-constraint/-/three-vrm-node-constraint-3.5.5.tgz", + "integrity": "sha512-+ShEXioZ5Ctf0EZ5ZViZ4n0jhV/qtJRH0Juw8nRnMNODZXDSr+B3An2lrfRoMhowCzG63GiUG4lXWV9noMXosA==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrmc-node-constraint-1.0": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-springbone": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-springbone/-/three-vrm-springbone-3.5.5.tgz", + "integrity": "sha512-GWAWrIegGH/u1ZA+Es03Qzc2yafrQOrhtal9jzUIAgU8Wm2DYuqAABOY+du/9RpBRK8D4T8DBPvRGQqvfrQ/uA==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrm-0.0": "3.5.5", + "@pixiv/types-vrmc-springbone-1.0": "3.5.5", + "@pixiv/types-vrmc-springbone-extended-collider-1.0": "3.5.5" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/types-vrm-0.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrm-0.0/-/types-vrm-0.0-3.5.5.tgz", + "integrity": "sha512-2S/0jswMjJTwL9ky8YzDR0CIaP6ZmE+HFuyW/0h+SA+awELf+J7WGgC8lTLM1MXzokO7Z2YVljU2F0fEhlKDIQ==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0/-/types-vrmc-materials-hdr-emissive-multiplier-1.0-3.5.5.tgz", + "integrity": "sha512-IuHw9ehXt2bymqfdFV+f2cCXo8kvJUfh0Zuut8qmKXoEb7HVyLGIQyS4TcQHG5yY22UTWHI1Axo5tQv35lPTgQ==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-materials-mtoon-1.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-mtoon-1.0/-/types-vrmc-materials-mtoon-1.0-3.5.5.tgz", + "integrity": "sha512-GFVuksAJVhrWTXYmI/ucNsE611/d8N6BoZrzhUAAINndt98loyzapW2+DJu+GsUBO5yxd+vLsL0FB16XD9+TGg==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-node-constraint-1.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-node-constraint-1.0/-/types-vrmc-node-constraint-1.0-3.5.5.tgz", + "integrity": "sha512-8EbJ1uzkn9yJQarCChmpG12+mc2c7PimHvQ8H6/tMh/hqp5NtJND0z2lR2KeVxw9A3M+TpF+xgImhLBxGjqwzQ==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-springbone-1.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-1.0/-/types-vrmc-springbone-1.0-3.5.5.tgz", + "integrity": "sha512-gRQ5Y7PaGiMefvOrJ3hvoSPqpA67jiwyhwh8ItjsPi3yri/Hn3CeXplW3Cbx3ZPbfu+C3Q2MCRa2TqAUOjeMRA==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-springbone-extended-collider-1.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-extended-collider-1.0/-/types-vrmc-springbone-extended-collider-1.0-3.5.5.tgz", + "integrity": "sha512-+6RxLaAvVguP7upjS/OT4viNPWvac1HBRQY1nmdvfYNQuCMdrMsCZqvy2af0Fcjnda4V9JbKFIpZDC0lrw1+uQ==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-vrm-1.0": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-vrm-1.0/-/types-vrmc-vrm-1.0-3.5.5.tgz", + "integrity": "sha512-WnemHHo2375BcBdumEhvrupZzsMSzTfdWlokmV9sUX2oC+OAQqJFfBjLF8ppkOhzHpl4MAPJqEfuhBJtNPm9ww==", + "license": "MIT" + }, + "node_modules/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", + "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", + "license": "MIT" + } + } +} diff --git a/scripts/capture-reference-output/three-vrm/package.json b/scripts/capture-reference-output/three-vrm/package.json new file mode 100644 index 00000000..19b01c2b --- /dev/null +++ b/scripts/capture-reference-output/three-vrm/package.json @@ -0,0 +1,11 @@ +{ + "name": "vrmkit-three-vrm-capture", + "private": true, + "type": "module", + "version": "1.0.0", + "description": "Headless three-vrm capture used to produce VRMKit conformance reference output. See ../README.md for the capture contract.", + "dependencies": { + "@pixiv/three-vrm": "3.5.5", + "three": "0.180.0" + } +}