Creating Multi-image Panoramas in VisionOS
By Rotem Cohen
Abstract
It was very motivating to read Ron Brinkmann’s article about the possibilities of comics in VR and specifically the Vision Pro. After completing the necessary immediate features that Spalsh needed to be a fully fleshed out digital comic book reader, I set out to see his vision come to life, and above is the result, now available in v1.5.
I believe there’s a lot more to change and tweak, but since it took some looking and researching on my part, here are all the dots connected in one article, if you wish to recreate my panorama view approach, with or without combining images.
This approach would allow you to put any static SwiftUI view (not just images) on a panoramic curved plane that will scale according to your view’s size.
ViewModel variables
We’ll start by creating published variables in the viewModel that will store the image URLs of our pages and a boolean that is active when we’re in immersive panorama state:
// ViewModel.Swift
@Published var panoPages: [URL] = []
@Published var isPanoVisible: Bool = false
Create a new RealityView
This is the view that will host the curved plane. You can add attachments if you want to add buttons to get out of the view. I chose to utilize the view that calls the panorama for controls. For simplicity, I left that part out, but I might touch on it in future articles.
import SwiftUI
import RealityKit
import RealityKitContent
struct PanoView: View {
@EnvironmentObject var viewModel: ViewModel
var body: some View {
RealityView { content in
let panoEntity = ModelEntity()
panoEntity.name = “Panorama”
content.add(panoEntity)
} update: { content in
// create new entity whenever panoPages updates
if viewModel.panoPages.count > 0 {
createPanoEntity(fileURLs: viewModel.panoPages, content: content)
}
}
}
}
Creating the immersive Entity
The main method that combines creating the combined images’ texture, attach it to the curved plane mesh, and place it in correct position and orientation in front of the user:
private func createPanoEntity(fileURLs: [URL], content: RealityViewContent) {
do {
// combine images into one
let combinedImages = saveImageTexture(fileURLs: fileURLs)
// create a material with the combined images as its texture
let material = createTexture(drawing: combinedImages)
// calculate dimensions, so we’ll always have the same height, but different width
let imageWidth = combinedImages.size.width
let imageHeight = combinedImages.size.height
let aspectRatio = Float(imageHeight / imageWidth)
// create a curved plane mesh
let PANO_HEIGHT : Float = 7.5
let planeMesh = try generateCurvedPlane(width: PANO_HEIGHT / aspectRatio, depth: PANO_HEIGHT, vertices: (20,20), curveMagnitude: 1 + 1/aspectRatio)
// find and update the entity
var panoEntity = content.entities.first { entity in
entity.name == "Panorama"
}
panoEntity!.components.set(
ModelComponent(mesh: planeMesh, materials: [material])
)
// rotate the created plane so it faces the user
let floorToWallQuat = simd_quatf(angle: Float.pi / 2, axis: simd_float3(x: 1, y: 0, z: 0))
panoEntity!.setOrientation(floorToWallQuat, relativeTo: nil)
// place the plane a bit away from the user and a little higher than the floor
panoEntity!.setPosition(SIMD3<Float>(x: 0, y:1.5, z:-5.0), relativeTo: nil)
} catch {
print("Error creating panorama entity: \(error)")
}
}
Converting a SwiftUI to an image
We use the method described here to convert a swiftUI view to an image
private func saveImageTexture(fileURLs: [URL])-> UIImage{
let printedView = ImageView(fileURLs: fileURLs)
let renderer = ImageRenderer(content: printedView)
guard let image = renderer.uiImage else { return UIImage(imageLiteralResourceName: "Image") }
return image
}
The very simple view that combines all images together. You can replace this with any static view to put practically anything in a curved plane:
@ViewBuilder
private func ImageView(fileURLs: [URL]) -> some View {
HStack(spacing: 0) {
ForEach(fileURLs, id:\.self) { url in
if let uiImage = UIImage(contentsOfFile: url.path) {
Image(uiImage: uiImage)
}
}
}
}
Generating the texture:
private func createTexture(drawing: UIImage) -> UnlitMaterial {
let texture = try! TextureResource.generate(from: drawing.cgImage!, options: .init(semantic: .normal))
var material = UnlitMaterial()
material.color = .init(texture: .init(texture))
return material
}
Our method for creating a curved plane using code:
private func generateCurvedPlane(
width: Float, depth: Float, vertices: (Int, Int), curveMagnitude: Float = 1.0
) throws -> MeshResource {
var descr = MeshDescriptor()
var meshPositions: [SIMD3<Float>] = []
var indices: [UInt32] = []
var textureMap: [SIMD2<Float>] = []
for x_v in 0..<(vertices.0) {
let vertexCounts = meshPositions.count
for y_v in 0..<(vertices.1) {
let xPosition = (Float(x_v) / Float(vertices.0 - 1) - 0.5) * width
let zPosition = (0.5 - Float(y_v) / Float(vertices.1 - 1)) * depth
let yPosition = -1 * (curveMagnitude - pow(xPosition, 2) * curveMagnitude / pow(width / 2, 2))
meshPositions.append([xPosition, yPosition, zPosition])
textureMap.append([Float(x_v) / Float(vertices.0 - 1), Float(y_v) / Float(vertices.1 - 1)])
if x_v > 0 && y_v > 0 {
indices.append(
contentsOf: [
vertexCounts - vertices.1, vertexCounts, vertexCounts - vertices.1 + 1,
vertexCounts - vertices.1 + 1, vertexCounts, vertexCounts + 1
].map { UInt32($0 + y_v - 1) })
}
}
}
descr.primitives = .triangles(indices)
descr.positions = MeshBuffer(meshPositions)
descr.textureCoordinates = MeshBuffers.TextureCoordinates(textureMap)
return try .generate(from: [descr])
}
Add to App file
ImmersiveSpace(id: "ImmersiveSpace") {
PanoView()
.environmentObject(viewModel)
.preferredSurroundingsEffect(.systemDark)
}
.immersionStyle(selection: .constant(viewModel.immersionStyle), in: .progressive, .mixed)
.windowStyle(.volumetric)
.defaultSize(width: 2.0, height: 2.0, depth: 2.0, in: .meters)
Open and close pano view methods
private func showPanoView(shownPages: Int) {
Task {
var images : [URL] = []
for index in 0...shownPages - 1 {
let currentIndex = Int(selectedPage) + index
if currentIndex < imageFiles.count{
panoImages.append(imageFiles[currentIndex])
}
}
viewModel.panoPages = images
viewModel.isPanoVisible = true
if viewModel.activeEnv == nil {
await openImmersiveSpace(id: "ImmersiveSpace")
}
}
}
private func closePanoView() {
Task {
openWindow(value: “Content”)
}
await dismissImmersiveSpace()
}
}
That’s it!