On Mac Catalyst, RealityKit TextComponent renders vertically inverted text.
The issue occurs with both ARView and RealityView. The same text renders correctly on iOS and in a native macOS RealityView application (not Catalyst). Tested on macOS 26 and macOS 27 beta, with Xcode 26 and Xcode 27 Beta.
Here is a screenshot of a text entity with a text component rendering the string "Text Component":
Workaround
On Mac Catalyst:
- Detect when the model generated by
TextComponentbecomes available on the text entity. - Find the generated
UnlitMaterialin theModelComponentof the text entity. - Disable face culling on the material.
- Invert the Y scale of the text entity.
Here is the result:
Reproduce
Below is the full code that reproduces the problem, with both ARView and RealityView, as well as the workaround implementation.
import SwiftUI
import RealityKit
import Combine
// MARK: Text
func createTextEntity() -> Entity {
let textEntity = Entity()
let attributes: [NSAttributedString.Key: Any] = [
.font: MeshResource.Font.systemFont(ofSize: 48, weight: .bold),
.foregroundColor: Material.Color.white
]
let attributedText = AttributedString(
NSAttributedString(
string: "Text Component",
attributes: attributes
)
)
var textComponent = TextComponent()
textComponent.text = attributedText
textComponent.size = CGSize(width: 400, height: 200)
textComponent.backgroundColor = Material.Color.darkGray.cgColor
textEntity.components.set(textComponent)
return textEntity
}
// MARK: Camera
func createCameraEntity() -> Entity {
let cameraEntity = Entity()
cameraEntity.components.set(PerspectiveCameraComponent())
cameraEntity.look(at: .zero, from: [0, 0, 0.3], relativeTo: nil)
return cameraEntity
}
// MARK: ARView
struct CatalystTextBugARView: UIViewRepresentable {
let applyWorkaround: Bool = true
func makeUIView(context: Context) -> ARView {
let arView = ARView(frame: .zero)
arView.cameraMode = .nonAR
arView.automaticallyConfigureSession = false
arView.environment.background = .color(.black)
let rootEntity = AnchorEntity()
let textEntity = createTextEntity()
#if targetEnvironment(macCatalyst)
if applyWorkaround {
/// React when the text entity's ModelComponent becomes available or changes
context.coordinator.modelDidAddSubscription = arView.scene.subscribe(
to: ComponentEvents.DidAdd.self,
on: textEntity,
componentType: ModelComponent.self
) { _ in
applyTextBugWorkaround(to: textEntity)
}
context.coordinator.modelDidChangeSubscription = arView.scene.subscribe(
to: ComponentEvents.DidChange.self,
on: textEntity,
componentType: ModelComponent.self
) { _ in
applyTextBugWorkaround(to: textEntity)
}
}
#endif
rootEntity.addChild(textEntity)
rootEntity.addChild(createCameraEntity())
arView.scene.addAnchor(rootEntity)
return arView
}
func updateUIView(_ uiView: ARView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator {
/// Retain the scene subscriptions for the lifetime of the ARView.
var modelDidAddSubscription: (any Cancellable)?
var modelDidChangeSubscription: (any Cancellable)?
}
}
// MARK: RealityView
struct CatalystTextBugRealityView: View {
let applyWorkaround: Bool = true
/// Retain the RealityView subscriptions for the lifetime of the view.
@State private var modelDidAddSubscription: EventSubscription?
@State private var modelDidChangeSubscription: EventSubscription?
var body: some View {
RealityView { content in
let textEntity = createTextEntity()
#if targetEnvironment(macCatalyst)
if applyWorkaround {
/// React when the text entity's ModelComponent becomes available or changes.
modelDidAddSubscription = content.subscribe(
to: ComponentEvents.DidAdd.self,
on: textEntity,
componentType: ModelComponent.self
) { _ in
applyTextBugWorkaround(to: textEntity)
}
modelDidChangeSubscription = content.subscribe(
to: ComponentEvents.DidChange.self,
on: textEntity,
componentType: ModelComponent.self
) { _ in
applyTextBugWorkaround(to: textEntity)
}
}
#endif
content.add(textEntity)
content.add(createCameraEntity())
}
.background(.black)
}
}
// MARK: Workaround
func applyTextBugWorkaround(to textEntity: Entity) {
#if targetEnvironment(macCatalyst)
guard var modelComponent = textEntity.components[ModelComponent.self] else {
return
}
var foundTextMaterial = false
var changedMaterial = false
for materialIndex in modelComponent.materials.indices {
guard var textMaterial = modelComponent.materials[materialIndex] as? UnlitMaterial else {
continue
}
foundTextMaterial = true
/// If face culling is already disabled, skip
if case .none = textMaterial.faceCulling {
continue
}
textMaterial.faceCulling = .none
modelComponent.materials[materialIndex] = textMaterial
changedMaterial = true
}
guard foundTextMaterial else {
return
}
if changedMaterial {
textEntity.components.set(modelComponent)
}
/// Counteract vertically inverted text.
textEntity.scale.y = -abs(textEntity.scale.y)
#endif
}
Usage
- Run either
CatalystTextBugRealityView()orCatalystTextBugARView()on a Mac Catalyst target. - Toggle
applyWorkaroundon either view to try with and without the workaround.