Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
aba35e3
Added Changelog and modified .gitignore
argus1 Sep 18, 2026
db6f02a
Added distinct roll and aim constraint coverage with twist-constraint…
argus1 Sep 18, 2026
3430cbb
Performed focused VRM1 conformance tests, added additional VRM1 model…
argus1 Sep 18, 2026
f4001bd
Write MToon lighting in one flush and batch a flush's parameter blits…
tattn Sep 19, 2026
0b93c4b
Drop the vertices no triangle references when decoding a primitive
tattn Sep 19, 2026
f41e267
Draw a mesh's blended materials in render queue order
tattn Sep 19, 2026
2ee3732
Add a runtime rim light to MToon
tattn Sep 20, 2026
efba602
Give a nested glTF entity its own material parameters in a clone
tattn Sep 20, 2026
ba69267
Skin and morph meshes with a compute kernel of our own into a LowLeve…
tattn Sep 20, 2026
5e65c93
Skip deforming a mesh that draws nothing
tattn Sep 20, 2026
140f9ee
Validated AvatarSample_M.json to be deterministic and versioned, made…
argus1 Sep 20, 2026
8b8a9db
Fixed GLTFprune test failure, built out three-vrm capture script, ref…
argus1 Sep 20, 2026
f752206
Tested missing, duplicate, malformed and out-of-range references. Ve…
argus1 Sep 20, 2026
be19476
Made Batched per frame updates to prioritize sync between three-VRM a…
argus1 Sep 20, 2026
fed017b
Sampled humanoid quaternion comparison and required-bone validation
argus1 Sep 21, 2026
c7bcc22
lip-sync overrides for VRMA mouth channels, API layering
argus1 Sep 21, 2026
e292e93
Mark the deformation submit with a signpost
tattn Sep 20, 2026
8f02628
Added and passed automatic/manual update-mode regression coverage.
argus1 Sep 21, 2026
7562a11
Verified loop, speed, reverse playback, pause, seek, stop and clip re…
argus1 Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 28 additions & 26 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -57,4 +57,6 @@ UserInterfaceState.xcuserstate

docs
.build
argus1_VRMKit_VRM1_development_plan.md
scripts/capture-reference-output/three-vrm/node_modules

16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions Example/Example/ExampleModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Example/MacExample/MacExampleModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion Example/VisionExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ final class ImmersiveViewModel {
let vrmEntity = try await loader.loadEntity()

vrmEntity.transform.translation = SIMD3<Float>(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<Float>(0, 1, 0))
rootEntity.addChild(vrmEntity)

Expand Down
1 change: 1 addition & 0 deletions Example/VisionExample/VisionExampleApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ let package = Package(
resources: [
.copy("../Assets/GLTF"),
.copy("../Assets/VRM"),
.copy("../Assets/VRMA")
.copy("../Assets/VRMA"),
.copy("../Assets/ReferenceOutputs")
]
),

Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.

<details>
<summary>Renderer limitations</summary>
Expand All @@ -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`.

Expand Down
46 changes: 45 additions & 1 deletion Sources/VRMKit/VRM/VRM1.swift
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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<HumanoidBone> = []
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 {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion Sources/VRMKit/VRM/VRMAnimation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions Sources/VRMKitRuntime/Humanoid.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ public final class Humanoid<Node> {

/// 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
}
}
Expand Down
69 changes: 50 additions & 19 deletions Sources/VRMRealityKit/CustomType/GLTFEntity+MToon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Float>) {
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<Float>, color: SIMD3<Float>, ambient: SIMD3<Float>) {
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<Float>) {
setMToonLighting(direction: direction, color: mtoonLightColor, ambient: mtoonAmbientColor)
}

/// The default is white.
public func setMToonLightColor(_ color: SIMD3<Float>) {
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<Float>) {
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<Int>) {
mutateMToonStates(forMaterials: materials) { state in
state.setRimLight(rim)
}
}

private static func normalizedMToonLightDirection(_ direction: SIMD3<Float>) -> SIMD3<Float> {
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
Expand All @@ -59,12 +96,6 @@ extension GLTFEntity {
}
}

private func updateMToonLightingRows() {
mutateMToonStates { state in
state.setLighting(color: mtoonLightColor, ambient: mtoonAmbientColor)
return true
}
}

// MARK: - Outline

Expand Down
Loading