Lab 110 – Curved Display Surface
Using MeshDescriptor to create a curved surface.
In Lab 109 we created a curved surface to render portal materials. We made this lab to document those curved surfaces.
We create these with MeshDescriptor and a bit of math. We can adjust the width, height, and corner radius of our mesh. Check out the video demo and dive into the code below.
Video Demo
A video demo showing the size of the curved mesh changing based on height, width, and corner radius.
Lab Code
struct Lab110: View {
@State private var draftWidth: Float = 1.05
@State private var draftHeight: Float = 0.45
@State private var draftRounding: Float = 0.36
private let panelName = "Curved Rounded Surface"
private let sphereRadius: Float = 0.5
private let horizontalSegments = 96
private let middleVerticalSegments = 10
private let cornerVerticalSegments = 44
var body: some View {
RealityView { content in
let panel = Entity()
panel.name = panelName
configurePanel(panel)
content.add(panel)
} update: { content in
guard let panel = content.entities.first(where: { $0.name == panelName }) else { return }
configurePanel(panel)
}
.realityViewLayoutBehavior(.centered)
.debugBorder3D(.white)
.ornament(attachmentAnchor: .scene(.bottomFront), contentAlignment: .bottomFront) {
controls
}
}
private var controls: some View {
VStack(alignment: .leading, spacing: 12) {
sliderRow(
title: "Width",
value: $draftWidth,
range: 0.45...1.35
)
sliderRow(
title: "Height",
value: $draftHeight,
range: 0.2...0.8
)
sliderRow(
title: "Rounding",
value: $draftRounding,
range: 0...1
)
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
.frame(width: 360)
.glassBackgroundEffect()
}
private func sliderRow(
title: String,
value: Binding<Float>,
range: ClosedRange<Float>
) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(title)
.fontWeight(.semibold)
Spacer()
Text(String(format: "%.2f", value.wrappedValue))
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: value, in: range)
}
}
private func configurePanel(_ panel: Entity) {
guard let mesh = makeRoundedSphericalPanel(
sphereRadius: sphereRadius,
angularSize: [draftWidth, draftHeight],
cornerRadius: maxCornerRadius * draftRounding,
horizontalSegments: horizontalSegments,
middleVerticalSegments: middleVerticalSegments,
cornerVerticalSegments: cornerVerticalSegments
) else { return }
var material = UnlitMaterial(color: .stepGreen)
material.blending = .transparent(opacity: 0.75)
material.faceCulling = .none
panel.components.set(ModelComponent(mesh: mesh, materials: [material]))
}
private var maxCornerRadius: Float {
min(draftWidth, draftHeight) / 2
}
private func makeRoundedSphericalPanel(
sphereRadius: 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: [Float]
if cornerRadius <= .ulpOfOne {
pitchSamples = panelPitchSamples(
halfHeight: halfHeight,
segments: middleVerticalSegments + cornerVerticalSegments * 2
)
} else {
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, pitch: pitch)
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: panelName)
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 panelPitchSamples(halfHeight: Float, segments: Int) -> [Float] {
let segments = max(1, segments)
return (0...segments).map { index in
let t = Float(index) / Float(segments)
return mix(-halfHeight, halfHeight, t: t)
}
}
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
}
}Support our work so we can continue to bring you new examples and articles.

Follow Step Into Vision