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.

Questions or feedback?