OCCTSwiftViewport Metal Architecture
Overview
This document describes the architecture for replacing OCCTSwiftViewport’s RealityKit renderer with a direct Metal implementation, and how it integrates with OCCTSwift as the geometry source.
Why Replace RealityKit
RealityKit is designed for AR experiences and general-purpose 3D content. For a CAD viewport, it creates friction:
- No wireframe rendering. RealityKit provides no API to render edges or wireframes. The current
DisplayMode.wireframeand.shadedWithEdgesmodes cannot be implemented. - No render pipeline access. Custom shaders, depth bias for edge overlay, transparency control, and x-ray modes are not possible.
- Entity overhead. Scene elements like the dot grid require hundreds of individual
ModelEntityinstances (961 for a 31x31 grid) instead of a single instanced draw call. - AR-centric camera model. The
PerspectiveCamera+ manualTransformapproach works but fights the framework’s assumptions.
A direct Metal renderer eliminates all of these constraints. For the CAD use case (flat colors, wireframes, simple shading), the Metal code is small and the performance ceiling is dramatically higher.
Layer Separation
+------------------+
| Your App | Connects OCCTSwift geometry to OCCTSwiftViewport display.
+--------+---------+ Owns the domain logic (what to show, when to update).
|
+----+----+
| |
+---v---+ +---v--------+
|OCCTSwift| |OCCTSwiftViewport| Two independent libraries.
| | | | No dependency between them.
+-------+ +-----------------+
Geometry Rendering
kernel + viewport
OCCTSwift produces geometry: Shape -> Mesh (vertices, normals, indices, face associations), edge polylines, bounding boxes. It has no knowledge of rendering.
OCCTSwiftViewport consumes geometry and renders it: camera, gestures, Metal pipeline, display modes, selection feedback, grid, axes. It has no knowledge of OCCT or B-Rep topology.
The app bridges the two: tessellates shapes, extracts edges, feeds the data to OCCTSwiftViewport, and maps selection hits back to OCCT faces/edges.
This separation means:
- OCCTSwift can be used with any renderer (SceneKit, game engines, headless testing)
- OCCTSwiftViewport can display geometry from any source (procedural, file parsers, other CAD kernels)
- Either library can be replaced independently
Data Flow
Shape to Screen
OCCTSwift App OCCTSwiftViewport
--------- --- -----------
Shape.mesh(params) --> Mesh --> viewport.setMesh(...)
.vertexData: [Float] extract raw buffers Metal vertex buffer
.normalData: [Float] per-body colors Metal normal buffer
.indices: [UInt32] edge polylines Metal index buffer
.trianglesWithFaces() face-index buffer
Shape.edges() --> [Edge] --> viewport.setEdges(...)
.points(count:): [SIMD3] discretize to polylines Metal line vertex buffer
Shape.boundingBox --> (min, max) --> viewport.focusOn(bounds:)
Selection (Screen to Shape)
OCCTSwiftViewport App OCCTSwift
----------- --- ---------
User taps screen
--> ray cast from camera
--> intersect triangles
--> HitResult {
position: SIMD3<Float>,
triangleIndex: Int,
bodyIndex: Int
}
--> Map triangleIndex to Shape.faces()[faceIndex]
faceIndex via Mesh --> highlight, inspect,
.trianglesWithFaces() or modify
The key data structure enabling this round-trip is Triangle.faceIndex from OCCTSwift’s Mesh.trianglesWithFaces(). Each mesh triangle carries the index of the B-Rep face it was tessellated from. OCCTSwiftViewport doesn’t interpret this value – it stores it per-triangle and returns it in hit results. The app uses it to index back into the OCCT topology.
OCCTSwiftViewport Public API
Core Types
/// A renderable body in the viewport. Geometry-source agnostic.
public struct ViewportBody: Identifiable, Sendable {
public let id: String
/// Interleaved vertex data: [px, py, pz, nx, ny, nz, ...]
/// Stride: 6 floats per vertex.
public var vertexData: [Float]
/// Triangle indices (3 per triangle).
public var indices: [UInt32]
/// Per-triangle source face index (parallel to triangle count).
/// Used for selection mapping. Pass empty array if not needed.
public var faceIndices: [Int32]
/// Edge polylines for wireframe rendering.
/// Each inner array is one polyline: [p0, p1, p2, ...] as SIMD3<Float>.
public var edges: [[SIMD3<Float>]]
/// Display color (RGBA, 0-1).
public var color: SIMD4<Float>
/// Per-face colors, indexed by face index. Overrides body color
/// for individual faces (e.g. selection highlighting).
/// Pass empty dictionary for uniform color.
public var faceColors: [Int32: SIMD4<Float>]
/// Opacity (0-1). Values < 1 enable transparency.
public var opacity: Float
/// Whether this body is visible.
public var isVisible: Bool
/// Whether this body participates in hit testing.
public var isSelectable: Bool
}
/// Result of a hit test against viewport geometry.
public struct HitResult: Sendable {
/// ID of the body that was hit.
public let bodyID: String
/// World-space position of the hit.
public let position: SIMD3<Float>
/// Index of the triangle that was hit.
public let triangleIndex: Int
/// Source face index from the body's faceIndices array.
/// -1 if the body has no face index data.
public let faceIndex: Int32
/// Distance from the camera to the hit point.
public let distance: Float
}
ViewportView (Revised)
public struct ViewportView: View {
public init(
controller: ViewportController,
bodies: [ViewportBody]
)
}
The entities: [Entity] parameter from the RealityKit version is replaced with bodies: [ViewportBody]. Bodies are plain value types – no RealityKit dependency.
ViewportController Extensions
The existing ViewportController API (camera, gestures, display modes, toggles) remains unchanged. New additions:
extension ViewportController {
/// Perform a hit test at a screen point.
/// Returns hits sorted by distance (nearest first).
@MainActor
public func hitTest(at point: CGPoint) -> [HitResult]
/// Focus the camera to frame the given bounding box.
public func focusOn(
min: SIMD3<Float>,
max: SIMD3<Float>,
animated: Bool = true
)
}
App-Level Usage
import OCCTSwift
import OCCTSwiftViewport
struct CADView: View {
@StateObject private var controller = ViewportController(
configuration: .cad
)
@State private var bodies: [ViewportBody] = []
let shape: Shape
var body: some View {
ViewportView(controller: controller, bodies: bodies)
.onAppear { loadGeometry() }
}
func loadGeometry() {
let mesh = shape.mesh(linearDeflection: 0.1)
// Extract edge polylines for wireframe
let edges: [[SIMD3<Float>]] = shape.edges().map { edge in
edge.points(count: 64)
}
// Build interleaved vertex data [px, py, pz, nx, ny, nz, ...]
let verts = mesh.vertexData // [px, py, pz, ...]
let norms = mesh.normalData // [nx, ny, nz, ...]
var interleaved = [Float]()
interleaved.reserveCapacity(mesh.vertexCount * 6)
for i in 0..<mesh.vertexCount {
interleaved.append(verts[i * 3])
interleaved.append(verts[i * 3 + 1])
interleaved.append(verts[i * 3 + 2])
interleaved.append(norms[i * 3])
interleaved.append(norms[i * 3 + 1])
interleaved.append(norms[i * 3 + 2])
}
// Face indices for selection mapping
let tris = mesh.trianglesWithFaces()
let faceIndices = tris.map { $0.faceIndex }
bodies = [
ViewportBody(
id: "part-1",
vertexData: interleaved,
indices: mesh.indices,
faceIndices: faceIndices,
edges: edges,
color: SIMD4(0.7, 0.7, 0.7, 1.0),
faceColors: [:],
opacity: 1.0,
isVisible: true,
isSelectable: true
)
]
}
}
Metal Renderer
Architecture
ViewportView (SwiftUI)
|
+-- MetalViewRepresentable (UIViewRepresentable / NSViewRepresentable)
|
+-- MTKView (drawable surface)
|
+-- ViewportRenderer (MTKViewDelegate)
|
+-- Pipelines:
| ShadedPipeline (triangles, lit, flat color)
| WireframePipeline (lines, constant color, depth bias)
| GridPipeline (instanced dots or lines)
| AxisPipeline (colored cylinders, optional screen-space)
|
+-- Buffers:
| Per-body vertex/index/faceIndex buffers
| Uniform buffer (MVP, camera, lighting)
| Grid instance buffer
|
+-- RenderGraph:
1. Clear (background color)
2. Grid (if enabled)
3. Axes (if enabled)
4. Bodies - opaque pass (sorted front-to-back)
5. Wireframe overlay (if display mode includes edges)
6. Bodies - transparent pass (sorted back-to-front)
7. Selection highlight overlay
Shader Design
The rendering requirements are minimal. Four shader pairs cover all display modes.
Vertex Layout
struct Vertex {
float3 position [[attribute(0)]];
float3 normal [[attribute(1)]];
};
Matches the interleaved [px, py, pz, nx, ny, nz] layout of ViewportBody.vertexData. Stride: 24 bytes.
Uniforms
struct Uniforms {
float4x4 modelMatrix;
float4x4 viewMatrix;
float4x4 projectionMatrix;
float4x4 normalMatrix; // transpose(inverse(modelView)), for lighting
float3 cameraPosition;
float3 lightDirection; // single directional light
float lightIntensity;
float ambientIntensity;
};
struct BodyUniforms {
float4 color;
float opacity;
int faceIndexOffset; // offset into face-index buffer for this body
};
Shaded Pipeline (triangles, lit)
vertex VertexOut shaded_vertex(
Vertex in [[stage_in]],
constant Uniforms& u [[buffer(1)]]
) {
VertexOut out;
float4 worldPos = u.modelMatrix * float4(in.position, 1.0);
out.position = u.projectionMatrix * u.viewMatrix * worldPos;
out.worldNormal = (u.normalMatrix * float4(in.normal, 0.0)).xyz;
out.worldPosition = worldPos.xyz;
return out;
}
fragment float4 shaded_fragment(
VertexOut in [[stage_in]],
constant Uniforms& u [[buffer(1)]],
constant BodyUniforms& body [[buffer(2)]]
) {
float3 N = normalize(in.worldNormal);
float3 L = normalize(-u.lightDirection);
float diffuse = max(dot(N, L), 0.0) * u.lightIntensity;
float ambient = u.ambientIntensity;
float3 color = body.color.rgb * (diffuse + ambient);
return float4(color, body.opacity);
}
This handles DisplayMode.shaded, .flat (skip normal interpolation), and .rendered. The fragment shader is intentionally simple – no specular, no PBR. For CAD, diffuse + ambient with a single directional light is the standard.
Wireframe Pipeline (lines, depth-biased)
vertex float4 wireframe_vertex(
float3 position [[attribute(0)]],
constant Uniforms& u [[buffer(1)]]
) {
float4 worldPos = u.modelMatrix * float4(position, 1.0);
float4 clipPos = u.projectionMatrix * u.viewMatrix * worldPos;
// Depth bias: pull edges slightly toward camera to prevent z-fighting
clipPos.z -= 0.0001 * clipPos.w;
return clipPos;
}
fragment float4 wireframe_fragment(
constant BodyUniforms& body [[buffer(2)]]
) {
return float4(body.color.rgb * 0.2, 1.0); // darker than face color
}
Used for DisplayMode.wireframe (edges only) and .shadedWithEdges (edges overlaid on shaded pass). The edge vertex buffer contains polyline segments from ViewportBody.edges, drawn as MTLPrimitiveType.line.
X-Ray Pipeline (transparent, back-faces visible)
// Same vertex shader as shaded. Fragment shader adds transparency:
fragment float4 xray_fragment(
VertexOut in [[stage_in]],
constant Uniforms& u [[buffer(1)]],
constant BodyUniforms& body [[buffer(2)]]
) {
float3 N = normalize(in.worldNormal);
float3 V = normalize(u.cameraPosition - in.worldPosition);
// Fresnel-like falloff: more opaque at glancing angles
float facing = abs(dot(N, V));
float alpha = mix(0.4, 0.1, facing);
return float4(body.color.rgb * 0.5, alpha);
}
The x-ray pipeline renders with both front and back face culling disabled, blending enabled, and depth writes disabled. This gives the classic CAD x-ray look where internal edges and surfaces are visible.
Grid Pipeline (instanced)
struct GridInstance {
float2 offset;
};
vertex float4 grid_vertex(
float3 position [[attribute(0)]], // unit quad
constant Uniforms& u [[buffer(1)]],
constant GridInstance* instances [[buffer(2)]],
uint instanceID [[instance_id]]
) {
float3 worldPos = position;
worldPos.xz += instances[instanceID].offset;
return u.projectionMatrix * u.viewMatrix * float4(worldPos, 1.0);
}
One draw call renders all 961 grid dots via instancing. The instance buffer contains the 2D offsets. When grid spacing changes, only the instance buffer is rebuilt – the mesh (a single tiny quad) never changes.
Display Mode Mapping
| DisplayMode | Passes |
|---|---|
.wireframe | Wireframe only (edges as lines) |
.shaded | Shaded triangles |
.shadedWithEdges | Shaded triangles, then wireframe overlay with depth bias |
.flat | Shaded triangles with flat (face) normals instead of smooth |
.xray | X-ray pass (transparent, no depth write), then wireframe |
.rendered | Same as shaded (no PBR needed for CAD) |
Buffer Management
Each ViewportBody maps to a set of Metal buffers:
struct BodyBuffers {
let vertexBuffer: MTLBuffer // interleaved positions + normals
let indexBuffer: MTLBuffer // triangle indices
let faceIndexBuffer: MTLBuffer? // per-triangle face index (for picking)
let edgeVertexBuffer: MTLBuffer? // edge polyline vertices
let edgeIndexBuffer: MTLBuffer? // line segment indices
let triangleCount: Int
let edgeVertexCount: Int
}
Buffers are created/updated when bodies changes. The renderer diffs body IDs to avoid recreating buffers for unchanged bodies.
func updateBuffers(bodies: [ViewportBody], device: MTLDevice) {
var newBufferMap: [String: BodyBuffers] = [:]
for body in bodies {
if let existing = bufferMap[body.id], !bodyChanged(body, existing) {
newBufferMap[body.id] = existing // reuse
} else {
newBufferMap[body.id] = createBuffers(for: body, device: device)
}
}
bufferMap = newBufferMap
}
Camera Integration
The existing CameraState already computes everything Metal needs:
// In ViewportRenderer, each frame:
var uniforms = Uniforms()
uniforms.viewMatrix = cameraState.viewMatrix // new computed property
uniforms.projectionMatrix = cameraState.projectionMatrix(
aspectRatio: Float(drawableSize.width / drawableSize.height)
)
uniforms.cameraPosition = cameraState.position
CameraState needs two new computed properties:
extension CameraState {
/// View matrix (world -> camera space).
public var viewMatrix: simd_float4x4 {
let pos = position
let target = pivot
let up = upVector
return simd_float4x4(lookAt: target, from: pos, up: up)
}
/// Projection matrix for the given aspect ratio.
public func projectionMatrix(aspectRatio: Float) -> simd_float4x4 {
if isOrthographic {
let halfWidth = orthographicScale * aspectRatio / 2
let halfHeight = orthographicScale / 2
return simd_float4x4(
orthographic: -halfWidth...halfWidth,
bottom: -halfHeight, top: halfHeight,
near: 0.01, far: maxDistance * 2
)
} else {
return simd_float4x4(
perspectiveFOV: fieldOfView * .pi / 180,
aspectRatio: aspectRatio,
near: 0.01,
far: maxDistance * 2
)
}
}
}
These replace the implicit projection that RealityKit’s PerspectiveCamera was providing.
Hit Testing (Selection)
GPU Picking vs CPU Ray Casting
Two viable approaches:
CPU ray casting (recommended for initial implementation):
- Cast a ray from the camera through the tap point
- Intersect against all triangle meshes on CPU
- Use the face-index array to map hit triangle -> OCCT face
- Simple, no GPU readback latency, works on all hardware
- Performance: fine for < 100K triangles; for larger scenes, add a BVH
GPU picking (future optimisation):
- Render a face-index buffer to an offscreen texture (each pixel stores the face index)
- Read back the pixel at the tap point
- One draw call, constant time regardless of triangle count
- Requires an additional render pass and GPU -> CPU readback
CPU Ray Cast Implementation
struct Ray {
let origin: SIMD3<Float>
let direction: SIMD3<Float>
}
func hitTest(ray: Ray, bodies: [ViewportBody]) -> [HitResult] {
var hits: [HitResult] = []
for body in bodies where body.isSelectable && body.isVisible {
let vertices = body.vertexData // stride 6
let indices = body.indices
for tri in 0..<(indices.count / 3) {
let i0 = Int(indices[tri * 3])
let i1 = Int(indices[tri * 3 + 1])
let i2 = Int(indices[tri * 3 + 2])
let v0 = SIMD3(vertices[i0*6], vertices[i0*6+1], vertices[i0*6+2])
let v1 = SIMD3(vertices[i1*6], vertices[i1*6+1], vertices[i1*6+2])
let v2 = SIMD3(vertices[i2*6], vertices[i2*6+1], vertices[i2*6+2])
if let t = rayTriangleIntersection(ray: ray, v0: v0, v1: v1, v2: v2) {
let position = ray.origin + ray.direction * t
let faceIndex = tri < body.faceIndices.count
? body.faceIndices[tri] : -1
hits.append(HitResult(
bodyID: body.id,
position: position,
triangleIndex: tri,
faceIndex: faceIndex,
distance: t
))
}
}
}
return hits.sorted { $0.distance < $1.distance }
}
The rayTriangleIntersection function implements the Moller-Trumbore algorithm. Standard, fast, no dependencies.
Selection Highlighting
When the app receives a HitResult, it can highlight the hit face by setting a face color:
func onTap(at point: CGPoint) {
let hits = controller.hitTest(at: point)
guard let hit = hits.first else { return }
// Highlight the tapped face
bodies[bodyIndex(for: hit.bodyID)].faceColors = [
hit.faceIndex: SIMD4(0.2, 0.5, 1.0, 1.0) // blue highlight
]
}
The renderer handles per-face coloring by checking the face-index buffer during fragment shading. If a face index has an entry in faceColors, that color is used instead of the body color. This avoids regenerating any buffers for selection feedback.
Implementation in the fragment shader:
fragment float4 shaded_fragment(
VertexOut in [[stage_in]],
constant Uniforms& u [[buffer(1)]],
constant BodyUniforms& body [[buffer(2)]],
constant int32_t* faceIndices [[buffer(3)]], // per-triangle
constant float4* faceColors [[buffer(4)]], // sparse override table
constant int& faceColorCount [[buffer(5)]],
uint primitiveID [[primitive_id]]
) {
float4 baseColor = body.color;
// Check for per-face color override
int32_t faceIdx = faceIndices[primitiveID + body.faceIndexOffset];
for (int i = 0; i < faceColorCount; i++) {
// faceColors is packed as [faceIndex (as float), r, g, b, a, ...]
// (actual implementation would use a lookup buffer)
}
// ... lighting as before ...
}
A cleaner approach for the sparse face-color lookup is a small buffer of (faceIndex, color) pairs, searched linearly in the shader. With typical selection counts (1-10 faces), this is effectively free.
Grid and Axes
Grid (Metal)
The adaptive dot grid algorithm from the current implementation carries over, but rendering changes from 961 entities to one instanced draw call:
struct GridRenderer {
let dotMesh: MTLBuffer // 4 vertices for a tiny quad
var instanceBuffer: MTLBuffer // 961 x GridInstance (float2 offset)
var currentSpacing: Float = 0
mutating func update(
cameraState: CameraState,
config: ViewportConfiguration,
device: MTLDevice
) {
let spacing = computeSpacing(
distance: cameraState.distance,
fov: cameraState.fieldOfView,
baseSpacing: config.gridBaseSpacing,
subdivisions: config.gridSubdivisions
)
guard spacing != currentSpacing else { return }
currentSpacing = spacing
// Rebuild instance buffer (961 offsets)
var instances = [SIMD2<Float>]()
let pivot = cameraState.pivot
let cx = (pivot.x / spacing).rounded() * spacing
let cz = (pivot.z / spacing).rounded() * spacing
for ix in -15...15 {
for iz in -15...15 {
instances.append(SIMD2(
cx + Float(ix) * spacing,
cz + Float(iz) * spacing
))
}
}
instanceBuffer = device.makeBuffer(
bytes: instances,
length: instances.count * MemoryLayout<SIMD2<Float>>.stride,
options: .storageModeShared
)!
}
func draw(encoder: MTLRenderCommandEncoder) {
encoder.setVertexBuffer(dotMesh, offset: 0, index: 0)
encoder.setVertexBuffer(instanceBuffer, offset: 0, index: 2)
encoder.drawIndexedPrimitives(
type: .triangle,
indexCount: 6, // two triangles for quad
indexType: .uint16,
indexBuffer: quadIndices,
indexBufferOffset: 0,
instanceCount: 961
)
}
}
One draw call instead of 961 entities. The spacing algorithm is identical to the current implementation.
Axes (Metal)
Axes are three coloured cylinders. For the .constantScreenWidth style, scale the radius uniform instead of the entity transform:
struct AxisRenderer {
let cylinderMesh: MTLBuffer // shared cylinder geometry
let cylinderIndices: MTLBuffer
func draw(
encoder: MTLRenderCommandEncoder,
config: ViewportConfiguration,
cameraState: CameraState
) {
let radius: Float
if config.axisStyle == .constantScreenWidth {
let scale = cameraState.distance / config.initialCameraState.distance
radius = config.axisRadius * scale
} else {
radius = config.axisRadius
}
// Draw X (red), Y (green), Z (blue) with per-axis model matrix
for (color, modelMatrix) in axisTransforms(length: config.axisLength) {
var bodyUniforms = BodyUniforms(color: color, opacity: 1.0)
encoder.setVertexBytes(&bodyUniforms, length: ..., index: 2)
// ... set model matrix, draw
}
}
}
Three draw calls total for all axes. The cylinder mesh is generated once at initialisation.
SwiftUI Integration
MetalViewRepresentable
#if os(iOS)
struct MetalViewRepresentable: UIViewRepresentable {
let renderer: ViewportRenderer
func makeUIView(context: Context) -> MTKView {
let view = MTKView()
view.device = MTLCreateSystemDefaultDevice()
view.delegate = renderer
view.colorPixelFormat = .bgra8Unorm
view.depthStencilPixelFormat = .depth32Float
view.clearColor = MTLClearColor(...)
view.preferredFramesPerSecond = 60
view.isPaused = false
view.enableSetNeedsDisplay = false
return view
}
func updateUIView(_ view: MTKView, context: Context) {
// Trigger buffer updates when bodies change
renderer.bodiesDidChange()
}
}
#elseif os(macOS)
struct MetalViewRepresentable: NSViewRepresentable {
// Same pattern with NSView
}
#endif
Gesture Handling
The existing gesture architecture (SwiftUI gestures on macOS, UIKit two-finger pan on iOS) attaches to the MetalViewRepresentable the same way it currently attaches to RealityView. The ViewportController API is unchanged – gestures still call handleOrbit, handlePan, handleZoom, etc.
The only addition is tap-to-select:
#if os(iOS)
private var tapGesture: some Gesture {
SpatialTapGesture()
.onEnded { value in
let hits = controller.hitTest(at: value.location)
onHit?(hits.first)
}
}
#endif
Migration Path
Phase 1: Metal Renderer (core)
Replace RealityView with MTKView. Implement:
ViewportRenderer(MTKViewDelegate)- Shaded pipeline (triangles with flat-color lighting)
- Wireframe pipeline (line rendering with depth bias)
- Uniform management (MVP from CameraState)
ViewportBodyas the geometry input typeMetalViewRepresentablefor SwiftUI
At this point, DisplayMode.shaded, .wireframe, and .shadedWithEdges all work. The grid and axes render via Metal instead of entities.
Phase 2: Selection
Implement:
- CPU ray casting (
hitTest) - Face-index buffer plumbing
HitResulttype- Per-face colour overrides in the fragment shader
- Tap gesture integration
Phase 3: Advanced Display Modes
Implement:
- X-ray pipeline (transparency, no depth write, fresnel falloff)
- Flat shading (face normals instead of vertex normals)
- Section planes (clip via fragment discard or stencil)
Phase 4: Performance
If needed for large models:
- BVH for ray casting
- Frustum culling per body
- GPU picking (offscreen face-index render pass)
- LOD (multiple tessellation levels per body, select by distance)
What Gets Removed
RealityKitimport and allRealityKittypesRealityViewmake/update closuresEntity-based grid and axis managementPerspectiveCamerasetup- The
entities: [Entity]parameter onViewportView
What Stays Unchanged
ViewportController(camera state, gesture handling, display mode, toggles)CameraState(with newviewMatrix/projectionMatrixcomputed properties)CameraController(orbit, pan, zoom, inertia, animation)ViewportConfiguration(all settings, including the new axis/grid properties)GestureConfiguration(sensitivity, inertia, key mappings)DisplayModeenum (cases and properties)StandardViewandViewCubeView- All gesture code (iOS two-finger pan, macOS modifier keys, etc.)
File Structure (Projected)
Sources/OCCTSwiftViewport/
Camera/
CameraState.swift (+ viewMatrix, projectionMatrix)
CameraController.swift (unchanged)
RotationStyle.swift (unchanged)
StandardView.swift (unchanged)
Configuration/
ViewportConfiguration.swift (unchanged)
GestureConfiguration.swift (unchanged)
Display/
DisplayMode.swift (unchanged)
LightingConfiguration.swift (unchanged)
Renderer/ (new directory)
ViewportRenderer.swift (MTKViewDelegate, render loop)
ShadedPipeline.swift (triangle rendering)
WireframePipeline.swift (edge rendering)
XRayPipeline.swift (transparent pass)
GridRenderer.swift (instanced dot grid)
AxisRenderer.swift (coloured cylinders)
Shaders.metal (all MSL shaders)
BufferManager.swift (body -> Metal buffer management)
HitTesting.swift (ray casting, Moller-Trumbore)
Views/
ViewportView.swift (rewritten: MTKView instead of RealityView)
ViewportController.swift (+ hitTest method)
ViewCubeView.swift (unchanged)
MetalViewRepresentable.swift (new: UIViewRepresentable/NSViewRepresentable)
Types/ (new directory)
ViewportBody.swift (geometry input type)
HitResult.swift (selection output type)
Dependencies
After migration:
- Metal (system framework) – rendering
- MetalKit (system framework) – MTKView
- simd (system framework) – math types
- SwiftUI (system framework) – view integration
- Combine (system framework) – controller state observation
RealityKit is removed entirely. No external dependencies.
Performance Characteristics
| Operation | RealityKit (current) | Metal (projected) |
|---|---|---|
| Grid (961 dots) | 961 entities, 961 draw calls | 1 instanced draw call |
| Wireframe | Not possible | 1 draw call per body |
| Shaded + edges | Not possible | 2 draw calls per body |
| Body with 50K triangles | 1 RealityKit entity | 1 draw call |
| Selection highlight | Regenerate entity material | Update 16-byte uniform |
| X-ray mode | Not possible | 1 draw call per body |
| Memory (grid) | ~961 Entity objects + components | ~8 KB instance buffer |
The Metal renderer should maintain 60 fps for scenes with < 1M triangles on any Apple Silicon device. For reference, a typical mechanical assembly (100 parts) tessellated at interactive quality produces ~200K-500K triangles total.