skip to Main Content

I am writing an app that uses RealityKit. I want one of the entities in my AR scene to animate (rotate on its X axis) continuously.

I can get it to spin 180º once on load, and I can add a slider to my UI that controls its rotation, but I can’t get it to animate continuously.

If any of this matters: I am using the latest stable versions of everything as of June 2023, no betas. Ventura 13.4, Xcode 14.3.1, Swift 5.8.1. Targeting iOS 16.4.

Here is a standalone demo of the issue, which I am trying out via Xcode Preview.

import Combine
import RealityFoundation
import RealityKit
import SwiftUI

struct ARKitPOC: View {
    var body: some View {
        ARViewContainer().edgesIgnoringSafeArea(.all)
    }
}

struct ARViewContainer: UIViewRepresentable {
    @State private var animUpdateSubscription: Cancellable?

    func makeUIView (context: Context) -> ARView {
        let redMaterial = SimpleMaterial(color: .red, roughness: 0.05, isMetallic: false)

        let boxResource = MeshResource.generateBox(width: 1.0, height: 1.0, depth: 1.0, cornerRadius: 0.1, splitFaces: false)
        let boxEntity = ModelEntity(mesh: boxResource, materials: [redMaterial])
        boxEntity.name = "box"
        boxEntity.position.z = -2
        
        let anchorEntity = AnchorEntity(world: .zero)
        anchorEntity.name = "anchor"
        anchorEntity.addChild(boxEntity)

        let arView = ARView(frame: .zero)
        arView.scene.addAnchor(anchorEntity)
        arView.cameraMode = .nonAR
        
        rotateForever(scene: arView.scene,
                      entity: boxEntity,
                      angle: -.pi,
                      axis: [0,0,1],
                      duration: 1.0)

        return arView
    }
    
    func updateUIView(_ arView: ARView, context: Context) {
    }

    func rotateForever(scene: RealityFoundation.Scene,
                       entity: HasTransform,
                       angle: Float,
                       axis: SIMD3<Float>,
                       duration: TimeInterval) {
        if animUpdateSubscription == nil {
            animUpdateSubscription = scene.subscribe(to: AnimationEvents.PlaybackCompleted.self,
                                                     on: entity,
                                                     { _ in
                print("subscription triggered")
                self.rotateForever(scene: scene,
                                   entity: entity,
                                   angle: angle,
                                   axis: axis,
                                   duration: duration)
            })
            print("subscription:", animUpdateSubscription)
        }

        var transform = entity.transform
        transform.rotation *= simd_quatf(angle: angle, axis: axis)
        entity.move(to: transform, relativeTo: entity.parent, duration: duration, timingFunction: .linear)
    }
}

struct ARKitPOC_Previews: PreviewProvider {
    static var previews: some View {
        ARKitPOC()
    }
}

Here, I render a red cube into the scene and trigger a looping animation that rotates it 180º from its current orientation. Completion of that animation is supposed to result in an event, which should trigger another animation, such that the cube rotates indefinitely.

I’ve been searching, and have found this technique recommended in several places:
https://www.appsloveworld.com/swift/100/89/realitykit-animate-a-transform-on-a-loop
https://stackoverflow.com/a/66916901/535401
https://rozengain.medium.com/quick-realitykit-tutorial-2-looping-animations-gestures-ee518b06b7f6

However, the call to scene.subscribe(to:on:_:) is returning nil, and the event handler I provided never gets invoked, so the animation only ever happens once.

The use of scene.subsribe required that I import Combine, which leads me to believe that this chunk of code:

            animUpdateSubscription = scene.subscribe(to: AnimationEvents.PlaybackCompleted.self,
                                                     on: entity,
                                                     { _ in

is shorthand for this Combine-style subscription:

            animUpdateSubscription = scene.publisher(for: AnimationEvents.PlaybackCompleted.self, on: entity)
                                          .sink(receiveValue: { _ in

I get exactly the same behavior with that substitution.

Apple’s documentation says nothing about the possibility of a nil return value. Ditto for the proposed Combine equivalent. What am I missing here? (see update below)

Update

By changing to the publisher(...).sink(...) approach, then chaining a call to print("publisher(for:)") between those, I can see the following in my log output:

publisher(for:): receive subscription: (RealityFoundation.REEventDispatcher<__C.REAnimationHasCompletedEvent>.(unknown context at $10b680970).EventSubscription<RealityKit.Scene.CorePublisher<__C.REAnimationHasCompletedEvent>.(unknown context at $10b686788).Inner<Combine.Publishers.CompactMap<RealityKit.Scene.CorePublisher<__C.REAnimationHasCompletedEvent>, RealityKit.AnimationEvents.PlaybackCompleted>.(unknown context at $10a11e878).Inner<Combine.Publishers.Print<RealityKit.Scene.Publisher<RealityKit.AnimationEvents.PlaybackCompleted>>.(unknown context at $10a119ff8).Inner<Combine.Subscribers.Sink<RealityKit.AnimationEvents.PlaybackCompleted, Swift.Never>>>>>)
publisher(for:): request unlimited
publisher(for:): receive cancel

So, a valid subscription is getting created, but then it’s getting canceled almost immediately. How do I figure out what’s doing that? Is the Swift.Never or the unknown context near the end of the first line meaningful?

2

Answers


  1. Chosen as BEST ANSWER

    I figured it out, with some outside help.

    In SwiftUI, you can't store into @State during makeUIView or updateUIView. The reason being that mutations to State are one of the signals that triggers a SwiftUI View to update, which means mutating a State causes SwiftUI to call updateUIView to give you a change to react to the new state. Setting State from inside updateUIView then creates a cycle, which SwiftUI tries to break.

    It was recommended that I move the rotateForever function into a Coordinator, as follows:

    import Combine
    import RealityFoundation
    import RealityKit
    import SwiftUI
    
    struct POC: View {
        var body: some View {
            POCViewContainer().edgesIgnoringSafeArea(.all)
        }
    }
    
    final class Coordinator {
        var subscription: AnyCancellable?
        
        func rotateForever(arView: ARView,
                           scene: RealityFoundation.Scene,
                           entity: HasTransform,
                           angle: Float,
                           axis: SIMD3<Float>,
                           duration: TimeInterval) {
            var transform = entity.transform
            transform.rotation *= simd_quatf(angle: angle, axis: axis)
            entity.move(to: transform, relativeTo: entity.parent, duration: duration, timingFunction: .linear)
    
            if subscription == nil {
                subscription = scene.publisher(for: AnimationEvents.PlaybackCompleted.self, on: entity)
                                              .sink(receiveValue: { _ in
                    self.rotateForever(arView: arView,
                                       scene: scene,
                                       entity: entity,
                                       angle: angle,
                                       axis: axis,
                                       duration: duration)
                })
            }
        }
    }
    
    struct POCViewContainer: UIViewRepresentable {
        @State private var animUpdateSubscription: Cancellable?
    
        func makeCoordinator() -> Coordinator {
          Coordinator()
        }
    
        func makeUIView(context: Context) -> ARView {
            let redMaterial = SimpleMaterial(color: .red, roughness: 0.05, isMetallic: false)
    
            let boxResource = MeshResource.generateBox(width: 1.0, height: 1.0, depth: 1.0, cornerRadius: 0.1, splitFaces: false)
            let boxEntity = ModelEntity(mesh: boxResource, materials: [redMaterial])
            boxEntity.name = "box"
            boxEntity.position.z = -2
            
            let anchorEntity = AnchorEntity(world: .zero)
            anchorEntity.name = "anchor"
            anchorEntity.addChild(boxEntity)
            
            let arView = ARView(frame: .zero)
            arView.scene.addAnchor(anchorEntity)
            arView.cameraMode = .nonAR
    
            context.coordinator.rotateForever(arView: arView,
                                              scene: arView.scene,
                                              entity: boxEntity,
                                              angle: -.pi,
                                              axis: [0,0,1],
                                              duration: 1.0)
    
            return arView
        }
        
        func updateUIView(_ uiView: ARView, context: Context) {}
        
    }
    
    struct POC_Previews: PreviewProvider {
        static var previews: some View {
            POC()
        }
    }
    

    With this code, the box rotates smoothly forever. I've removed the debugging print statements that were present in the question, but if you add them back, you can see it working as intended.


  2. I believe the animation won’t occur because your anchor has no anchor component set. try setting its target to a world transform or position.
    If an entity’s anchor changes in some way the animation does end up stopping – or not running if the animation is called before an entity is anchored in the scene.

    There is also a spin animation in the swift package RealityUI to make it easier:

    https://github.com/maxxfrazer/RealityUI/wiki/Animations

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search