Lab 109 – Layers
Standing in one world while seeing layers of two others.
This lab reuses the scenes we created in Labs 105-107. We load the droplet scene as the background for the lab. Then we create a dozen curved surfaces placed at pseudo-random positions at different depths around the user. We set each of these surfaces to display one of the other scenes using portal material. We use a MeshDescriptor to create these curved planes. We may switch this up to use LowLevelMesh in future labs.
Notice that we still have some issues with lighting our portal content. The root scene is receiving some environment lighting. It is getting a little bit of extra help from visionOS. The scenes loaded inside the portals don’t have this advantage. We may be able to improve this situation by using shared image-based light and virtual light probes. More on that soon.
Video Demo
A video demo recorded in Apple Vision Pro showing two scenes layered over top of another.
Lab Code
struct Lab109: View {
@State private var subscriptions = [EventSubscription]()
private let sphereCenterHeight: Float = 1.7
private let panelAngularWidth: Float = 1.05
private let panelAspectRatio: Float = 21 / 9
private let panelCornerRadius: Float = 0.09
private let horizontalSegments = 96
private let middleVerticalSegments = 10
private let cornerVerticalSegments = 44
private var panelAngularSize: SIMD2<Float> {
[panelAngularWidth, panelAngularWidth / panelAspectRatio]
}
private var panels: [PanelConfiguration] {
[
PanelConfiguration(name: "Vapor Portal 01", radius: 2.5, angularCenter: [0.00, 0.08], sceneName: "Vapor", angularVelocity: 0.055),
PanelConfiguration(name: "Vapor Portal 02", radius: 2.9, angularCenter: [1.10, 0.34], sceneName: "Vapor", angularVelocity: -0.041),
PanelConfiguration(name: "Vapor Portal 03", radius: 3.2, angularCenter: [-1.22, -0.18], sceneName: "Vapor", angularVelocity: 0.034),
PanelConfiguration(name: "Vapor Portal 04", radius: 3.6, angularCenter: [2.46, 0.14], sceneName: "Vapor", angularVelocity: -0.062),
PanelConfiguration(name: "Vapor Portal 05", radius: 2.6, angularCenter: [0.42, 0.52], sceneName: "Vapor", angularVelocity: 0.025),
PanelConfiguration(name: "Vapor Portal 06", radius: 3.0, angularCenter: [-0.98, -0.46], sceneName: "Vapor", angularVelocity: -0.052),
PanelConfiguration(name: "Mist Portal 01", radius: 2.7, angularCenter: [-0.62, 0.38], sceneName: "Mist", angularVelocity: 0.047),
PanelConfiguration(name: "Mist Portal 02", radius: 3.1, angularCenter: [0.78, -0.32], sceneName: "Mist", angularVelocity: -0.029),
PanelConfiguration(name: "Mist Portal 03", radius: 3.4, angularCenter: [-2.28, 0.24], sceneName: "Mist", angularVelocity: 0.068),
PanelConfiguration(name: "Mist Portal 04", radius: 3.8, angularCenter: [3.04, -0.16], sceneName: "Mist", angularVelocity: -0.036),
PanelConfiguration(name: "Mist Portal 05", radius: 3.5, angularCenter: [1.82, -0.06], sceneName: "Mist", angularVelocity: 0.039),
PanelConfiguration(name: "Mist Portal 06", radius: 3.9, angularCenter: [-2.86, 0.42], sceneName: "Mist", angularVelocity: -0.071)
]
}
var body: some View {
RealityView { content in
if let droplet = try? await Entity(named: "Droplet", in: realityKitContentBundle) {
droplet.name = "Droplet Root Scene"
content.add(droplet)
}
var rotatingPanels: [(entity: Entity, angularVelocity: Float)] = []
for configuration in panels {
guard let panelMesh = makeRoundedSphericalPanel(
sphereRadius: configuration.radius,
angularCenter: configuration.angularCenter,
angularSize: panelAngularSize,
cornerRadius: panelCornerRadius,
horizontalSegments: horizontalSegments,
middleVerticalSegments: middleVerticalSegments,
cornerVerticalSegments: cornerVerticalSegments
) else { continue }
let portalWorld = Entity()
portalWorld.name = "\(configuration.name) World"
portalWorld.components.set(WorldComponent())
content.add(portalWorld)
guard let portalScene = try? await Entity(named: configuration.sceneName, in: realityKitContentBundle) else {
continue
}
portalWorld.addChild(portalScene)
var material = PortalMaterial()
material.faceCulling = .none
let panel = ModelEntity(mesh: panelMesh, materials: [material])
panel.name = configuration.name
panel.position.y = sphereCenterHeight
panel.components.set(PortalComponent(
target: portalWorld,
clippingMode: .disabled,
crossingMode: .disabled
))
content.add(panel)
rotatingPanels.append((panel, configuration.angularVelocity))
}
subscriptions.append(content.subscribe(to: SceneEvents.Update.self) { event in
for rotatingPanel in rotatingPanels {
let rotation = simd_quatf(
angle: Float(event.deltaTime) * rotatingPanel.angularVelocity,
axis: [0, 1, 0]
)
rotatingPanel.entity.orientation = rotation * rotatingPanel.entity.orientation
}
})
}
}
private func makeRoundedSphericalPanel(
sphereRadius: Float,
angularCenter: SIMD2<Float>,
angularSize: SIMD2<Float>,
cornerRadius: Float,
horizontalSegments: Int,
middleVerticalSegments: Int,
cornerVerticalSegments: Int
) -> MeshResource? {
let halfWidth = angularSize.x / 2
let halfHeight = angularSize.y / 2
let cornerRadius = min(cornerRadius, halfWidth, halfHeight)
let innerHalfWidth = halfWidth - cornerRadius
let innerHalfHeight = halfHeight - cornerRadius
let pitchSamples = roundedPanelPitchSamples(
halfHeight: halfHeight,
innerHalfHeight: innerHalfHeight,
middleSegments: middleVerticalSegments,
cornerSegments: cornerVerticalSegments
)
var positions: [SIMD3<Float>] = []
var normals: [SIMD3<Float>] = []
var textureCoordinates: [SIMD2<Float>] = []
var triangleIndices: [UInt32] = []
for pitch in pitchSamples {
let v = (pitch + halfHeight) / (halfHeight * 2)
let rowHalfWidth = rowWidth(
forPitch: pitch,
halfWidth: halfWidth,
innerHalfWidth: innerHalfWidth,
innerHalfHeight: innerHalfHeight,
cornerRadius: cornerRadius
)
for column in 0...horizontalSegments {
let u = Float(column) / Float(horizontalSegments)
let yaw = mix(-rowHalfWidth, rowHalfWidth, t: u)
let direction = sphericalDirection(
yaw: yaw + angularCenter.x,
pitch: pitch + angularCenter.y
)
positions.append(direction * sphereRadius)
normals.append(-direction)
textureCoordinates.append([u, 1 - v])
}
}
let rowVertexCount = horizontalSegments + 1
let rowCount = pitchSamples.count
for row in 0..<(rowCount - 1) {
for column in 0..<horizontalSegments {
let upperLeft = UInt32(row * rowVertexCount + column)
let upperRight = upperLeft + 1
let lowerLeft = UInt32((row + 1) * rowVertexCount + column)
let lowerRight = lowerLeft + 1
triangleIndices.append(contentsOf: [
upperLeft, lowerLeft, upperRight,
upperRight, lowerLeft, lowerRight
])
}
}
var descriptor = MeshDescriptor(name: "Rounded Spherical Panel")
descriptor.positions = MeshBuffers.Positions(positions)
descriptor.normals = MeshBuffers.Normals(normals)
descriptor.textureCoordinates = MeshBuffers.TextureCoordinates(textureCoordinates)
descriptor.primitives = .triangles(triangleIndices)
return try? MeshResource.generate(from: [descriptor])
}
private func roundedPanelPitchSamples(
halfHeight: Float,
innerHalfHeight: Float,
middleSegments: Int,
cornerSegments: Int
) -> [Float] {
var samples: [Float] = []
let middleSegments = max(1, middleSegments)
let cornerSegments = max(1, cornerSegments)
appendPitchSamples(
to: &samples,
from: -halfHeight,
to: -innerHalfHeight,
segments: cornerSegments,
includesStart: true
)
appendPitchSamples(
to: &samples,
from: -innerHalfHeight,
to: innerHalfHeight,
segments: middleSegments,
includesStart: false
)
appendPitchSamples(
to: &samples,
from: innerHalfHeight,
to: halfHeight,
segments: cornerSegments,
includesStart: false
)
return samples
}
private func appendPitchSamples(
to samples: inout [Float],
from start: Float,
to end: Float,
segments: Int,
includesStart: Bool
) {
let firstIndex = includesStart ? 0 : 1
for index in firstIndex...segments {
let t = Float(index) / Float(segments)
samples.append(mix(start, end, t: t))
}
}
private func rowWidth(
forPitch pitch: Float,
halfWidth: Float,
innerHalfWidth: Float,
innerHalfHeight: Float,
cornerRadius: Float
) -> Float {
let absolutePitch = abs(pitch)
guard absolutePitch > innerHalfHeight else {
return halfWidth
}
let cornerY = absolutePitch - innerHalfHeight
return innerHalfWidth + sqrt(max(0, cornerRadius * cornerRadius - cornerY * cornerY))
}
private func sphericalDirection(yaw: Float, pitch: Float) -> SIMD3<Float> {
let cosPitch = cos(pitch)
return normalize([
sin(yaw) * cosPitch,
sin(pitch),
-cos(yaw) * cosPitch
])
}
private func mix(_ start: Float, _ end: Float, t: Float) -> Float {
start + (end - start) * t
}
private struct PanelConfiguration {
let name: String
let radius: Float
let angularCenter: SIMD2<Float>
let sceneName: String
let angularVelocity: Float
}
}Support our work so we can continue to bring you new examples and articles.
Download the Xcode project with this and many more labs from Step Into Vision.

Follow Step Into Vision