Back to Blog Home
← all posts

Particle Systems via RealityKit and Multiple Scenes during Vision Pro development with NativeScript

February 15, 2024 — by Nathan Walker

Let's look at how to interact with multiple scenes and present Particle Systems during Vision Pro development with NativeScript. We will use @nativescript/swift-ui to provide a concise API to open and dismiss scenes on demand as well as even update them dynamically with contextual data.

We can explore these features with a Vision Pro project; I've created one you can refer to here:

👉 Demo Github Repo which can be run with both the Vision Pro simulator on a Mac or a physical device.

If you have a physical Vision Pro, you can also experience this fully by searching for and installing the "NativeScript Preview" app on the Vision Pro App Store, which will allow you to use this StackBlitz:

Or you can also create one anytime with:

npm i -g nativescript@vision
ns create myapp --vision

If you'd like to use any preferrable JavaScript flavor, refer to the visionOS docs here.

Interacting with Multiple Scenes

In order to support multiple scenes you always want to check your App_Resources/visionOS/Info.plist to ensure it contains at least this setting:

<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <true/>
</dict>

We interact with scenes by their unique identifier and the @nativescript/swift-ui APIs:

  • openScene(options: OpenSceneOptions): Toggle opens/dismiss on a scene
  • updateScene(options: OpenSceneOptions): Update contextual data to any opened scene

The OpenSceneOptions interface is as follows:

export interface OpenSceneOptions {
    /**
     * The id of the scene to open
     */
    id: string;
    /**
     * Whether the scene is immersive or not.
     * Only set this when you know the WindowGroup is defined with immersive style.
     */
    isImmersive?: boolean;
    /**
     * Any data bindings to pass to the scene.
     */
    data?: any;
}

You can extend these options to strongly type your data per your needs. Let's explore an example.

A Multiple Scene Example

You first want to define any number of scenes you'd like your app to provide via App_Resources/visionOS/src/NativeScriptApp.swift:

import SwiftUI

@main
struct NativeScriptApp: App {

  var body: some Scene {
    NativeScriptMainWindow()

    // NEW SCENE: A distinct window of a playable video
    WindowGroup(id: "Video") {
      VideoSceneView()
    }
    .windowStyle(.plain)
  }
}

Here we define a WindowGroup with id of 'Video' with the plain windowStyle.

The VideoSceneView, provided in the example repo, is a good example of how you can implement any number of things in your multi-scene app.

We can open that scene from our NativeScript app as follows:

import { openScene } from '@nativescript/swift-ui';

openScene({
  id: 'Video'
});

The video player opens in a new window which can be placed anywhere in the 3d space. Calling openScene again with the same id will always dismiss an already open scene with a matching id.

Providing Contextual Data to a Scene

You can also provide data for any number of SwiftUI @State properties as desired when opening or updating a scene.

import { openScene } from '@nativescript/swift-ui';

openScene({
  id: 'Video',
  data: {
    url: '<any link to a playable video resource, mp4, etc.>'
  }
});

This allows SwiftUI to consume the contextual data for anything needed as follows:

struct VideoSceneView: View {
    @State var context: NativeScriptSceneContext?
    
    func createPlayer(url: String) -> AVPlayer {
        let player = AVPlayer(url: URL(string: url)!)
        player.play()
        return player
    }
    
    var body: some View {
        ZStack {
            if context != nil {
                VideoPlayer(player: player)
                    .scaledToFill()
            } else {
                EmptyView()
            }
        }.onAppear {
            setupContext()
        }
    }
    
    func setupContext() {
        context = NativeScriptSceneRegistry.shared.getContextForId(id: "Video")
        let url = context!.data["url"] as! String
        player = createPlayer(url: url)
    }
}

Updating an Open Scene Dynamically

We can update that scene dynamically from our NativeScript app as follows:

import { updateScene } from '@nativescript/swift-ui';

updateScene({
  id: 'Video',
  data: {
    url: '<new url to play>'
  }
});

We can support dynamic updates to any scene views by adding an onReceive modifier:

var body: some View {
    ZStack {
        // video player
    }.onReceive(NotificationCenter.default
        .publisher(for: NSNotification.Name("NativeScriptUpdateScene")), perform: { obj in
          // allow our scene data to be updated if already opened!
          setupContext()
    }).onAppear {
      // upon first open
      setupContext()
    }
}

Interacting with Immersize Spaces and Particles with RealityKit

The debut of Particle Emitters with visionOS brings an exciting API to the table. Let's look at how to interact with particles through Immersize Spaces.

An Immersize Space is another type of Scene and we can use RealityKit to display particle systems within it.

Let's look at using a ParticleEmitterComponent in addition to exploring Vortex by @twostraws which is a Swift Package that brings some nice additions.

Using Vortex

We can modify our nativescript.config.ts to include the SPM (Swift Package Manager) for visionos:

visionos: {
  SPMPackages: [
    {
      name: 'Vortex',
      libs: ['Vortex'],
      repositoryURL: 'https://github.com/twostraws/Vortex.git',
      version: '1.0.0'
    }
  ]
}

We can then try out a sample view setup from Vortex:

struct FirefliesView: View {
  var body: some View {
    VortexViewReader { proxy in
      ZStack(alignment: .bottom) {
        VortexView(.fireflies.makeUniqueCopy()) {
          Circle()
              .fill(.white)
              .frame(width: 32)
              .blur(radius: 3)
              .blendMode(.plusLighter)
              .tag("circle")
        }
      }
    }
  }
}

Let's add a new Scene to open:

@main
struct NativeScriptApp: App {

    var body: some Scene {
        NativeScriptMainWindow()
        
        WindowGroup(id: "Fireflies") {
            FirefliesView()
        }
        .windowStyle(.plain)

We can now open the dynamic particle effect via openScene in our NativeScript app:

import { openScene } from '@nativescript/swift-ui';

openScene({
  id: 'Fireflies'
});

Using ParticleEmitterComponent with RealityKit in an Immersize Space

Let's now try adding an Immersize Space with Particles:

@main
struct NativeScriptApp: App {
    @State private var immersionStyle: ImmersionStyle = .mixed

    var body: some Scene {
        NativeScriptMainWindow()
        
         ImmersiveSpace(id: "ParticleEmitter") {
            ParticleEmitterView()
        }
        .immersionStyle(selection: $immersionStyle, in: .mixed, .full)

We can setup our ParticleEmitterView to also support dynamic data from our NativeScript app:

struct ParticleEmitterView: View {
  @State var context: NativeScriptSceneContext?
  @State var preset: String = "magic"

  var body: some View {
      ZStack {
          if context != nil {
              RealityView { content in
                  var particles = ParticleEmitterComponent.Presets.magic
                  
                  switch preset {
                  case "fireworks":
                      particles = ParticleEmitterComponent.Presets.fireworks
                  default:
                      particles = ParticleEmitterComponent.Presets.magic
                  }
                  
                  let particleEntity = Entity()
                  particleEntity.components[ParticleEmitterComponent.self] = particles
                  
                  content.add(particleEntity)
              }
          } else {
              EmptyView()
          }
      }.onAppear {
        setupContext()
      }
  }

  func setupContext() {
    context = NativeScriptSceneRegistry.shared.getContextForId(id: "ParticleEmitter")
    preset = context!.data["preset"] as! String
  }
}

We can use openScene in our NativeScript app again but this time we'll use the isImmersive option to designate this is specifically to open an immersive space:

import { openScene } from '@nativescript/swift-ui';

openScene({
  id: 'ParticleEmitter',
  isImmersive: true,
  data: {
    preset: 'fireworks'
  }
});

Neat stuff!

About nStudio

nStudio is recognized for establishing a healthy open source governance model to serve the global communities interest around NativeScript. If you are in need of professional assistance on your project, nStudio offers services spanning multiple disciplines and can be reached at [email protected].