Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ concurrency:
cancel-in-progress: true

jobs:
check-mtoon-metallibs:
name: Check MToon metallibs are up to date
# Only compares the recorded build inputs, so the cheap Linux runner is enough.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The bundled metallibs are stale when the shader sources or the compile
# settings change without re-running the build script. --check compares
# the recorded build inputs (metallib bytes vary across Metal toolchain
# versions) and needs no Metal toolchain, so it runs on the cheap runner
# ahead of the macOS jobs.
- name: Verify recorded metallib build inputs match sources
run: ./scripts/build-mtoon-metallibs.sh --check

test-package:
name: Test package (${{ matrix.platform }})
runs-on: macos-26
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,6 @@ xcuserdata/
*.xcscmblueprint
UserInterfaceState.xcuserstate

docs
.build

87 changes: 66 additions & 21 deletions Example/Example/RealityKitViewController.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Combine
import UIKit
import RealityKit
import simd
internal import VRMKit
internal import VRMRealityKit

Expand All @@ -9,15 +10,18 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg
private var arView: ARView?
private var updateSubscription: Cancellable?
private var loadedEntity: VRMEntity?
private var loadedAnchor: AnchorEntity?
private var cameraAnchor: AnchorEntity?
private var cameraEntity: PerspectiveCamera?
private var lightEntity: DirectionalLight?
private var expressionSegmentedControl: UISegmentedControl?
private var orbitYaw: Float = 0
private var orbitPitch: Float = -0.1
private var orbitDistance: Float = 2
private var orbitTarget = SIMD3<Float>(0, 0.8, 0)
private var currentModel: VRMExampleModel = .alicia
private var currentExpression: ExampleExpression = .neutral
private var isMToonEnabled = true

override func viewDidLoad() {
super.viewDidLoad()
Expand Down Expand Up @@ -61,12 +65,25 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg
expressionSegmentedControl.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(expressionSegmentedControl)
self.expressionSegmentedControl = expressionSegmentedControl


let mtoonLabel = UILabel()
mtoonLabel.text = "MToon"
let mtoonSwitch = UISwitch()
mtoonSwitch.isOn = isMToonEnabled
mtoonSwitch.addTarget(self, action: #selector(mtoonChanged(_:)), for: .valueChanged)
let mtoonControl = UIStackView(arrangedSubviews: [mtoonLabel, mtoonSwitch])
mtoonControl.axis = .horizontal
mtoonControl.spacing = 8
mtoonControl.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(mtoonControl)

NSLayoutConstraint.activate([
segmentedControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),
segmentedControl.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -50),
expressionSegmentedControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),
expressionSegmentedControl.bottomAnchor.constraint(equalTo: segmentedControl.topAnchor, constant: -20)
expressionSegmentedControl.bottomAnchor.constraint(equalTo: segmentedControl.topAnchor, constant: -20),
mtoonControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),
mtoonControl.bottomAnchor.constraint(equalTo: expressionSegmentedControl.topAnchor, constant: -16)
])
}

Expand All @@ -82,29 +99,38 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg
loadedEntity?.setExampleExpression(currentExpression, value: 1.0)
}

@objc private func mtoonChanged(_ sender: UISwitch) {
isMToonEnabled = sender.isOn
loadVRM(model: currentModel)
}

private func loadVRM(model: VRMExampleModel) {
guard let arView = arView else { return }

currentModel = model
updateExpressionLabels()

if let loadedEntity = loadedEntity {
loadedEntity.entity.removeFromParent()
self.loadedEntity = nil
// Removing the anchor takes the whole model hierarchy out of the scene.
if let loadedAnchor {
arView.scene.removeAnchor(loadedAnchor)
self.loadedAnchor = nil
}
loadedEntity = nil

do {
let loader = try VRMEntityLoader(named: model.rawValue)
let loader = try VRMEntityLoader(named: model.rawValue, isMToonEnabled: isMToonEnabled)
let vrmEntity = try loader.loadEntity()
vrmEntity.setMToonLightDirection(RealityKitExampleLighting.direction)

let anchor = AnchorEntity(world: .zero)
vrmEntity.entity.transform.translation = SIMD3<Float>(0, -1.0, -1.5)
anchor.addChild(vrmEntity.entity)
vrmEntity.transform.translation = SIMD3<Float>(0, -1.0, -1.5)
anchor.addChild(vrmEntity)
arView.scene.addAnchor(anchor)
normalizeScale(for: vrmEntity.entity)
updateOrbitTarget(for: vrmEntity.entity, adjustDistance: false)
setUpLight(in: arView)
normalizeScale(for: vrmEntity)
updateOrbitTarget(for: vrmEntity, adjustDistance: false)
updateCameraTransform()

let neck = vrmEntity.humanoid.node(for: .neck)
let leftArm: Entity?
let rightArm: Entity?
Expand All @@ -116,7 +142,7 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg
leftArm = vrmEntity.humanoid.node(for: .leftUpperArm)
rightArm = vrmEntity.humanoid.node(for: .rightUpperArm)
}

let neckRotation = simd_quatf(angle: 20 * .pi / 180, axis: SIMD3<Float>(0, 0, 1))
let armRotation = simd_quatf(angle: 40 * .pi / 180, axis: SIMD3<Float>(0, 0, 1))
if let neck {
Expand All @@ -129,17 +155,18 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg
rightArm.transform.rotation = rightArm.transform.rotation * armRotation
}
vrmEntity.setExampleExpression(currentExpression, value: 1.0)

loadedEntity = vrmEntity

loadedAnchor = anchor

let rotationOffset = model.initialRotation

var time: TimeInterval = 0
updateSubscription = arView.scene.subscribe(to: SceneEvents.Update.self) { [weak self] event in
guard let loadedEntity = self?.loadedEntity else { return }

time += event.deltaTime

let cycle = time.truncatingRemainder(dividingBy: 1.0)
let angle: Float
if cycle < 0.5 {
Expand All @@ -149,10 +176,8 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg
let progress = Float(cycle - 0.5) / 0.5
angle = -0.5 + 0.5 * progress
}

loadedEntity.entity.transform.rotation = simd_quatf(angle: rotationOffset + angle, axis: SIMD3<Float>(0, 1, 0))

loadedEntity.update(at: event.deltaTime)

loadedEntity.transform.rotation = simd_quatf(angle: rotationOffset + angle, axis: SIMD3<Float>(0, 1, 0))
}
} catch {
print(error)
Expand Down Expand Up @@ -182,6 +207,21 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg
updateCameraTransform()
}

private func setUpLight(in arView: ARView) {
if lightEntity != nil { return }
let lightAnchor = AnchorEntity(world: .zero)
let light = DirectionalLight()
light.light.intensity = 1200
// `direction` points at the light, so place the light there and aim it
// at the origin.
light.look(at: .zero,
from: RealityKitExampleLighting.direction,
relativeTo: nil)
lightAnchor.addChild(light)
arView.scene.addAnchor(lightAnchor)
lightEntity = light
}

private func setUpGestures() {
guard let arView = arView else { return }

Expand Down Expand Up @@ -249,7 +289,7 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
guard let arView = arView, let cameraEntity = cameraEntity else { return }
let translation = gesture.translation(in: arView)
let panSpeed: Float = 0.002 * orbitDistance
let panSpeed = Float(0.002) * orbitDistance

let transform = cameraEntity.transform.matrix
let right = SIMD3<Float>(transform.columns.0.x, transform.columns.0.y, transform.columns.0.z)
Expand All @@ -275,3 +315,8 @@ final class RealityKitViewController: UIViewController, UIGestureRecognizerDeleg
return true
}
}

private enum RealityKitExampleLighting {
/// Direction from the model toward the light, as `setMToonLightDirection(_:)` expects.
static let direction = simd_normalize(SIMD3<Float>(0, 0, -1))
}
32 changes: 22 additions & 10 deletions Example/Example/ViewController.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import UIKit
import SceneKit
import simd
internal import VRMSceneKit

class ViewController: UIViewController {
Expand All @@ -12,12 +13,12 @@ class ViewController: UIViewController {
scnView.backgroundColor = UIColor.black
}
}

private var vrmNode: VRMNode?
private var expressionSegmentedControl: UISegmentedControl?
private var currentModel: VRMExampleModel = .alicia
private var currentExpression: ExampleExpression = .neutral

override func viewDidLoad() {
super.viewDidLoad()
setupUI()
Expand All @@ -31,19 +32,19 @@ class ViewController: UIViewController {
segmentedControl.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
segmentedControl.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(segmentedControl)

let expressionItems = ExampleExpression.allCases.map { $0.displayName(for: currentModel) }
let expressionSegmentedControl = UISegmentedControl(items: expressionItems)
expressionSegmentedControl.selectedSegmentIndex = 0
expressionSegmentedControl.addTarget(self, action: #selector(expressionSegmentChanged(_:)), for: .valueChanged)
expressionSegmentedControl.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(expressionSegmentedControl)
self.expressionSegmentedControl = expressionSegmentedControl

NSLayoutConstraint.activate([
segmentedControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),
segmentedControl.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -50),

expressionSegmentedControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),
expressionSegmentedControl.bottomAnchor.constraint(equalTo: segmentedControl.topAnchor, constant: -20)
])
Expand All @@ -53,7 +54,7 @@ class ViewController: UIViewController {
let model = VRMExampleModel.allCases[sender.selectedSegmentIndex]
loadVRM(model: model)
}

@objc private func expressionSegmentChanged(_ sender: UISegmentedControl) {
let expression = ExampleExpression.allCases[sender.selectedSegmentIndex]
vrmNode?.setExampleExpression(currentExpression, value: 0.0)
Expand All @@ -72,12 +73,11 @@ class ViewController: UIViewController {
scnView.delegate = self
let node = scene.vrmNode
self.vrmNode = node

let rotationOffset = CGFloat(model.initialRotation)
node.eulerAngles = SCNVector3(0, rotationOffset, 0)

node.setExampleExpression(currentExpression, value: 1.0)

node.humanoid.node(for: .neck)?.eulerAngles = SCNVector3(0, 0, 20 * CGFloat.pi / 180)
let leftArm: SCNNode?
let rightArm: SCNNode?
Expand All @@ -91,7 +91,7 @@ class ViewController: UIViewController {
}
leftArm?.eulerAngles = SCNVector3(0, 0, 40 * CGFloat.pi / 180)
rightArm?.eulerAngles = SCNVector3(0, 0, 40 * CGFloat.pi / 180)

node.runAction(SCNAction.repeatForever(SCNAction.sequence([
SCNAction.rotateBy(x: 0, y: -0.5, z: 0, duration: 0.5),
SCNAction.rotateBy(x: 0, y: 0.5, z: 0, duration: 0.5),
Expand Down Expand Up @@ -120,9 +120,21 @@ class ViewController: UIViewController {

cameraNode.position = SCNVector3(0, 0.8, -1.6)
cameraNode.rotation = SCNVector4(0, 1, 0, Float.pi)

let lightNode = SCNNode()
lightNode.light = SCNLight()
lightNode.light?.type = .directional
lightNode.light?.intensity = 1200
lightNode.simdPosition = -SceneKitExampleLighting.direction
lightNode.look(at: SCNVector3Zero)
scene.rootNode.addChildNode(lightNode)
}
}

private enum SceneKitExampleLighting {
static let direction = simd_normalize(SIMD3<Float>(0.35, 0.55, 0.75))
}

@available(*, deprecated, message: "Deprecated. Use VRMRealityKit instead.")
extension ViewController: SCNSceneRendererDelegate {
nonisolated func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
Expand Down
Loading
Loading