DarkString
DarkString
Astra YVR 600C visionOS and RealityKit development tutorial cover
← Articles
中文版 ↗DarkString / Field Notes
visionOS 27 / Projective Textures API / RealityKit tutorial

Projective Textures on visionOS 27: Video on a 3D Model and in Physical Space

A concise Astra walkthrough: connect one video timeline to a curved model screen and a RealityKit spotlight, then understand the visionOS 27 Projective Textures API.

GunnerApple Vision Pro2026.09.07

Astra is a concrete spatial application sample: a YVR 600C USDZ model is placed in Apple Vision Pro, moved, scaled, rotated and expanded. Modeled keys switch video presets; the curved screen displays the video while a projector consumes frames from the same timeline.

The article keeps one central distinction: VideoMaterial belongs to the model screen, while SpotLightComponent.ProjectiveTexture belongs to the projector. They may share an AVPlayerItem, but UV work, frame extraction and resource lifecycles should remain separate.

Video / Astra YVR 600COpen on Bilibili ↗
01

Start with clear spatial boundaries

Let the control window own state and actions, ImmersiveSpace own the spatial scene and tracking, and a custom root own the model transform. Load the USDZ into a container, inspect its visual bounds, normalize its longest edge and then place it. Screen, button and projection systems should be independent modules.

AstraApp.swift / WindowGroup + ImmersiveSpace
@main
struct AstraApp: App {
    @State private var appModel = AppModel()

    init() {
        KeyboardActionComponent.registerComponent()
    }

    var body: some Scene {
        WindowGroup { ContentView().environment(appModel) }
        ImmersiveSpace(id: appModel.immersiveSpaceID) {
            ImmersiveView().environment(appModel)
        }
        .immersionStyle(selection: .constant(.mixed), in: .mixed)
    }
}

This makes failures diagnosable: a missing model points to placement, a missed tap to the input tree, a stretched screen to UV mapping, and a missing wall projection to availability, GPU or World Sensing conditions.

02

Play video on the 3D model screen

Replace only the named screen entity's ModelComponent. Cache the authored mesh and material, wait for the AVPlayerItem presentation size, rebuild UVs with a cover crop for the curved surface, then create VideoMaterial. On stop or replacement, pause the player, remove the current item and restore the original component.

ScreenVideoPlayer.swift / VideoMaterial
func installVideo(on screen: Entity, item: AVPlayerItem) throws {
    guard let original else { throw VideoError.missingGeometry }
    let mesh = try makeVideoMesh(
        from: original.mesh,
        videoAspectRatio: Float(item.presentationSize.width / item.presentationSize.height)
    )

    player.replaceCurrentItem(with: item)
    screen.components.set(
        ModelComponent(mesh: mesh, materials: [VideoMaterial(avPlayer: player)])
    )
    player.play()
}

func restoreScreen() {
    player.pause()
    player.replaceCurrentItem(with: nil)
    if let original { screen?.components.set(original) }
}

The screen geometry and normals remain authored by the USDZ; video is a recoverable temporary material. This keeps content changes from contaminating the model and prevents aspect-ratio distortion.

03

Make 3D model buttons respond

A visible modeled key is not interactive by itself. Create an invisible hit region for each key, attach InputTargetComponent, CollisionComponent and a custom KeyboardActionComponent, then receive the hit with targetedToAnyEntity(). Walk from the hit child toward its parents until the action owner is found, and let one action enum call playback, projection or preset logic.

ModelKeyboardControls.swift / hit region + parent routing
let region = Entity()
region.name = "AstraVideoPreset_1"
region.components.set(InputTargetComponent(allowedInputTypes: .all))
region.components.set(
    CollisionComponent(shapes: [.generateBox(size: buttonSize)])
)
region.components.set(KeyboardActionComponent(action: .preset(1)))
button.addChild(region)

func handleTap(on hit: Entity) {
    var candidate: Entity? = hit
    while let entity = candidate {
        if regions.contains(where: { $0 === entity }) {
            activate(entity)
            return
        }
        candidate = entity.parent
    }
}

Guard the action while the model is exploding, media is loading or the scene is changing, and debounce repeated hits by about 0.25 seconds. Use an outer proxy with ManipulationComponent.HitTarget for whole-model manipulation instead of a global collider that steals button hits.

04

Projective Textures API: send video frames into a spotlight

In WWDC26 Session 287 ↗, Apple shows how to add textures to spotlights. Astra replaces a static texture with video frames: AVPlayerItemVideoOutput exposes a new frame, Metal writes it into a LowLevelTexture, and SpotLightComponent.ProjectiveTexture receives it. SurroundingsLight makes the spotlight participate in supported spatial lighting.

Apple WWDC26 Session 287 video frame introducing the visionOS 27 Projective Textures API
Screenshot source: DarkString WeChat article ↗. The API background is documented in Apple Developer / WWDC26 Session 287 ↗.
VideoProjector.swift / Projective Textures
if #available(visionOS 27.0, *), device.supportsFamily(.apple6) {
    let texture = try makeProjectiveTexture(size: 1024)
    spotlight.components.set(
        SpotLightComponent.ProjectiveTexture(texture: texture)
    )
    spotlight.components.set(SpotLightComponent.SurroundingsLight())

    let output = AVPlayerItemVideoOutput(pixelBufferAttributes: [
        kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
    ])
    item.add(output)
    guard output.hasNewPixelBuffer(forItemTime: item.currentTime()),
          let buffer = output.pixelBufferAndDisplayTime(forItemTime: item.currentTime()).pixelBuffer,
          let commandBuffer = commandQueue.makeCommandBuffer() else { return }
    let target = lowLevelTexture.replace(using: commandBuffer)
    render(buffer, to: target, commandBuffer: commandBuffer)
    commandBuffer.commit()
}

The two consumers stay separate: the screen answers how video is mapped onto a curved surface; the projector answers how a video texture leaves the model and is cast into space. Real wall validation requires visionOS 27, Apple GPU family 6, World Sensing and an Apple Vision Pro device.

05

Validate in risk order

Use four checks: verify placement and recovery; verify button hits in assembled and exploded states; import a real video and check cover-cropped playback, pause and material restoration; only then validate projection direction, scale and masking on device.

VideoProjector.swift / availability guard
func makeProjectorIfSupported() -> ProjectorController? {
    guard #available(visionOS 27.0, *),
          let device = MTLCreateSystemDefaultDevice(),
          device.supportsFamily(.apple6) else { return nil }
    return ProjectorController()
}
Screenshot slot · 01
Apple Vision Pro device result: button hit, curved-screen video or wall projection

Replace this slot with an Apple Vision Pro device screenshot when available.

Conclusion

The reusable lesson in Astra is boundary ownership: ModelPlacement owns placement, KeyboardActionComponent owns input, ScreenVideoPlayer owns media, and VideoProjector owns texture submission and capability checks. Projective Textures is easier to debug when it is one explicit consumer of the video timeline rather than a hidden side effect of the screen material.

Start a spatial brief

Need a development path from spatial assets to a working visionOS prototype?

Contact DarkString →