If you’ve created a visionOS app with a volume, you probably did it wrong

2024-01-29

Have you created a visionOS app that uses a volume?

You probably started with the following sample code included in the volumetric window style documentation which was derived from Apple’s Hello World sample app:

WindowGroup(id: Module.globe.name) {
    Globe()
        .environment(model)
}
.windowStyle(.volumetric)
.defaultSize(width: 0.6, height: 0.6, depth: 0.6, in: .meters)

That code creates a volume that’s 0.6 meters wide.

Maybe you put your own model in the volume and it happened to also be 0.6 meters wide. It fit nicely.

A sphere

It looked great.

There’s only one problem.

The code in the documentation is wrong

You may not have noticed, but hidden away two levels deep in the visionOS Settings app is an option called Window Zoom which lets the user choose a preferred window size.

The visionOS Settings app display appearance view

(In visionOS 2.0, this setting can now be found under Appearance > Display > Appearance > Window Zoom.)

The default Window Zoom setting is Large.

In the examples that follow, I’ve created a cube-shaped volume that’s 0.4 meters wide. I’ve also added a sphere with a diameter of 0.4 meters to exactly match the size of the volume.

Here’s how the sphere looks with each of the four Window Zoom options.

A visionOS volume containing a sphere with extra large Window Zoom selected
Extra Large
A visionOS volume containing a sphere with large Window Zoom selected
Large (the default)
A visionOS volume containing a sphere with medium Window Zoom selected
Medium
A visionOS volume containing a sphere with small Window Zoom selected
Small

Wait, what?

Oh no.

If you base your app on Apple’s sample code, something goes horribly wrong.

What’s going on?

If you highlight the corners of the volume, you can see what’s happening.

A visionOS volume containing a sphere with medium Window Zoom selected and the corners of the volume highlighted

Whenever Display Zoom is medium or small, the sphere is clipped to the boundary of the volume.

When I first experienced this problem several months ago, I assumed it was a bug, so I dutifully reported it to Apple. (FB13216531).

A few weeks later I received this helpful response: (emphasis mine)

Please know that our engineering team has determined that this issue behaves as intended based on the information provided.

DisplayZoom applies to SwiftUI content — it is up to the developer to scale their RealityView content to fit the size of the provided volume. The size of the volume can be read using GeometryReader3D, and then transformed to RealityKit coordinates using the transform provided by RealityViewContent.transform() method. With this the developer can scale the content to fit inside that bounding box.

(In early visionOS betas, Window Zoom was called Display Zoom.)

To date, this feedback comment is the only place I’ve seen this requirement documented. (I’ve submitted FB13483416 about the lack of documentation for this issue.)

And yet, if you’re not aware of this fundamental fact about volumes, users who choose a small or medium Window Zoom setting will think your app is broken.

So what should you do?

As Apple’s amazingly helpful engineering team recommended in the feedback comment, you should use GeometryReader3D.

The simplest approach is to scale your volume’s contents to match its size.

Here’s one way to do that:

import SwiftUI
import RealityKit

struct ScaledVolumeView: View {

    // The value passed to the volumetric window group's
    // `defaultSize(_:in:)` view modifier.
    let defaultSize: Size3D

    // A root entity added to the `RealityView`.
    //
    // This entity is automatically scaled to reflect changes
    // to the user's Window Zoom setting.
    //
    // All other entities in the volume should be added as
    // children of this view instead of being added to the
    // `RealityViewContent` object directly.
    @State private var scaledRootEntity = Entity()

    var body: some View {
        GeometryReader3D { proxy in
            RealityView { content in
                content.add(scaledRootEntity)

                // Scale the volume's content to reflect the
                // initial Window Zoom setting.
                scale(entity: scaledRootEntity, content: content,
                      proxy: proxy, defaultSize: defaultSize)

                // Add your app's entities here.
                scaledRootEntity.addChild( ... )
            } update: { content in
                // Whenever the Window Zoom setting changes,
                // update the scale of the volume's content.
                scale(entity: scaledRootEntity, content: content,
                      proxy: proxy, defaultSize: defaultSize)
            }
        }
    }

    // Scales `entity` to match the current Window Zoom scale.
    func scale(entity: Entity, content: RealityViewContent,
               proxy: GeometryProxy3D, defaultSize: Size3D) {
        // The size of the volume, scaled to reflect the
        // selected Window Zoom.
        let scaledVolumeSize = content.convert(
            proxy.frame(in: .local), from: .local, to: .scene)

        // The user's selected Window Zoom scale factor, as
        // a ratio between the displayed size of the volume and
        // the default size of the volume's window group.
        let scale = (scaledVolumeSize.extents/SIMD3<Float>(defaultSize)).min()

        entity.scale = .one*scale
    }

}

When you create the window group for the volume in your app, do it like this:

let defaultSize = Size3D(width: 0.4, height: 0.4, depth: 0.4)

// ...

WindowGroup(id: "my-volume") {
    ScaledVolumeView(defaultSize: defaultSize)
}
.windowStyle(.volumetric)
.defaultSize(defaultSize, in: .meters)

For this to work correctly, ScaledVolumeView must be passed the same value you passed to the volumetric window group’s defaultSize view modifier.

You’re also welcome to copy code from a working Xcode project that demonstrations this solution in my GitHub repo.

Here’s how the fixed version looks with each Window Zoom option:

A visionOS volume containing a sphere with extra large Window Zoom selected and the sphere scaled to the size of the volume
Extra Large
A visionOS volume containing a sphere with large Window Zoom selected and the sphere scaled to the size of the volume
Large
A visionOS volume containing a sphere with medium Window Zoom selected and the sphere scaled to the size of the volume
Medium
A visionOS volume containing a sphere with small Window Zoom selected and the sphere scaled to the size of the volume
Small

Hurray. 🎉

You now know how to handle Window Zoom correctly in your app, but please tell all your visionOS developer friends about this issue too.

Otherwise, because the issue is apparently only documented in Feedback Assistant comments, I expect that many apps with volumes will appear broken when users select a small or medium Window Zoom setting.

The release notes are also wrong

More recently, regarding this same issue, I noticed that the visionOS 1.0 release notes state the following:

Volumes using the defaultSize(_: Rect3D, in: UnitLength) modifier to specify size will only be the specified physical size at default (large) display zoom. At smaller display zooms the Volume will be smaller and clip content. (116579319) (FB13240946)

Workaround: Specify a size for the Volume about 1.5x larger than necessary, and ensure content is not clipped at smallest display zoom setting.

This is not a good solution.

If you do scale your volume 1.5x larger than necessary, it’ll have excessive invisible padding around it. This will position the volume’s window bar awkwardly far away from the volume’s contents.

Also, neighboring windows will become prematurely transparent when they intersect the invisible bounds of this unnecessarily large volume.

A visionOS volume containing a sphere in a volume that is 1.5 times larger than the sphere

Why does Window Zoom work this way?

It might seem odd that the Window Zoom setting doesn’t simply scale the contents of each volume along with the size of the volume. That would be consistent with the effect Window Zoom has on 2D SwiftUI windows. It’s probably what most users assume should happen.

But perhaps in a future visionOS release, users will be able to resize volumes arbitrarily, like they can now with 2D windows.

If that ever happens, apps will require complete flexibility with respect to how they to respond to volume size changes, not by simply scaling its contents. Maybe an app will want to display less content when a volume gets smaller, or align content to the edges of a volume when it gets larger.

In that future version of visionOS, currently available only to time travelers, the Window Zoom behavior we’re observing with volumes in visionOS 1.0 will make more sense.

Update: In visionOS 2.0, volumes can be resized by the user, and the code above can be used to resize a volume’s contents to match the size of the volume.

Update #1

The visionOS 1.1 release notes include the following resolved issue:

Fixed: Volumes using the defaultSize(_: Rect3D, in: UnitLength) modifier to specify size, will now be the specified physical size at all display zooms. (116579319) (FB13240946)

This is good news. The intention is that volumes will no longer change size when the user selects a new Window Zoom setting, and that your volume’s contents will never be clipped. This would imply that the approach described in this post is no longer necessary, and that the code in the volumetric window style documentation should now function as expected in visionOS 1.1.

The visionOS 1.1 release notes also include the following resolved issue:

Fixed: If the display zoom is changed in settings while a volume with a physical size is open, the content might be clipped. (120554484)

The bad news is that this is not actually true. This is issue has not been resolved in visionOS 1.1.

This means that unfortunately, at least as of visionOS 1.0 beta, the approach described in this post is still required if you’d like your volume to work correctly in all cases.

If you use the code in this post, your app will work correctly in both visionOS 1.0 and visionOS 1.1 beta in all cases.

Update #2

I’ve discovered that the code in this post also addresses another non-obvious issue, which is that regardless of the size you may specify for your volume with the defaultSize view modifier, visionOS may constrain the size you request.

For example, when the Window Zoom setting is Large, volumes aren’t allowed to be larger than 2 meters. But if the user sets Window Zoom to Small, the limit is about 1.47 meters. There’s no way to predict if these values will change in future versions of visionOS.

So, it’s still necessary to use the code in this post, to deal with the possibility that visionOS may not honor the size you request.

However, if your volume is small, perhaps 1 meter or less, and your app doesn’t support visionOS 1.0, you could probably get away without worrying about this issue. But, it’s still good to be aware of.

Update #3

In visionOS 2.0, volumes can now be resized by the user.

In fact, just as with windows, resizability is now the default behavior for volumes. Consequently, if you do nothing, your volume’s contents will be now clipped when the user resizes your volume, even if your app didn’t exhibit the clipping problem in visionOS 1.x.

To address this issue, it’s now necessary to either use the code above to resize your volume’s contents to match the size of the volume, or to use the frame(width:height:) and frame(depth:) view modifiers to disable volume resizing. However, with these APIs, it’s necessary to specify the size of a volume in points, not meters, which is an option with the defaultSize(:in:) scene modifier.

If your app supports visionOS 1.0, 1.1, and 1.2, you’ll want to use the code to resize your volume’s contents to handle the Window Zoom issue correctly in all cases.