This tutorial builds a reusable Swift package that draws procedural geometry into Untold Engine. The finished plugin owns its shader library, render pipeline, and render-graph pass. It uses the engine camera and scene color/depth targets without accessing private renderer state.
Use this tutorial when rendering code must be shared by more than one application. For application-local code, start with Using Rendering Extensions. A complete buildable package is available in Examples/RenderingExtensions/SwiftPackagePlugin.
The plugin will:
- load a package-owned precompiled Metal library;
- register a pipeline using the engine's working scene formats;
- add a pass at a stable render-graph stage;
- read the current eye's camera matrices;
- create a safe encoder for the scene color and depth targets;
- issue a depth-tested triangle draw; and
- install and uninstall as one owned plugin transaction.
- Xcode with the SDKs for every platform you intend to support;
- Swift 6;
- an Untold Engine checkout or released package dependency; and
- a reverse-DNS namespace owned by you.
The examples use com.example.procedural. Replace it with your own namespace.
From the directory that will contain the package:
mkdir ProceduralRenderPlugin
cd ProceduralRenderPlugin
swift package init --type library --name ProceduralRenderPluginUse this layout:
ProceduralRenderPlugin/
├── Package.swift
├── Scripts/
│ └── build-metallib.sh
├── Sources/
│ └── ProceduralRenderPlugin/
│ ├── ProceduralRenderPlugin.swift
│ ├── ProceduralRenderExtension.swift
│ ├── Resources/
│ │ └── ProceduralShaders-macos.metallib
│ └── Shaders/
│ └── ProceduralShaders.metal
└── Tests/
└── ProceduralRenderPluginTests/
└── ProceduralRenderPluginTests.swift
For local development, point SwiftPM at your engine checkout:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "ProceduralRenderPlugin",
platforms: [.macOS(.v14)],
products: [
.library(
name: "ProceduralRenderPlugin",
targets: ["ProceduralRenderPlugin"]
),
],
dependencies: [
.package(path: "/path/to/UntoldEngine"),
],
targets: [
.target(
name: "ProceduralRenderPlugin",
dependencies: [
.product(name: "UntoldEngine", package: "UntoldEngine"),
],
exclude: ["Shaders"],
resources: [
.copy("Resources/ProceduralShaders-macos.metallib"),
],
swiftSettings: [.swiftLanguageMode(.v6)]
),
.testTarget(
name: "ProceduralRenderPluginTests",
dependencies: [
"ProceduralRenderPlugin",
.product(name: "UntoldEngine", package: "UntoldEngine"),
],
swiftSettings: [.swiftLanguageMode(.v6)]
),
]
)The dependency path is resolved relative to this Package.swift. Use an
absolute path only for local development. A distributed plugin should use the
canonical engine repository URL and a compatible release requirement. The
application and plugin must resolve the same Untold Engine package identity and
version.
This workflow excludes Metal source from the target and copies a precompiled library. It does not ask SwiftPM to compile the package shader.
Create ProceduralRenderPlugin.swift:
import Foundation
import UntoldEngine
public enum ProceduralPluginContract {
public static let pluginID = "com.example.procedural"
public static let extensionID = "com.example.procedural.renderer"
public static let shaderLibraryID: RenderShaderLibraryID =
"com.example.procedural.shaders"
public static let pipelineID: RenderPipelineType =
"com.example.procedural.scene"
public static let passID = "com.example.procedural.scene-pass"
}IDs occupy engine-wide registries. Namespace shader libraries, pipelines, passes, textures, buffers, and argument layouts. An extension supplied by a plugin must equal the plugin ID or begin with the plugin ID followed by a dot.
Continue in ProceduralRenderPlugin.swift:
public struct ProceduralRenderPlugin: RenderExtensionPlugin {
public static var bundledMetallibURL: URL? {
Bundle.module.url(
forResource: "ProceduralShaders-macos",
withExtension: "metallib"
)
}
public let manifest = RenderExtensionPluginManifest(
id: ProceduralPluginContract.pluginID,
displayName: "Procedural Rendering",
version: RenderExtensionPluginVersion(major: 1, minor: 0, patch: 0)
)
public init() {}
public func makeRenderExtensions() -> [any RenderExtension] {
[ProceduralRenderExtension(shaderBundle: .module)]
}
}
@discardableResult
public func registerProceduralRenderPlugin()
-> RenderExtensionPluginInstallationResult
{
RenderExtensionPluginRegistry.shared.install(ProceduralRenderPlugin())
}The public installation function is the consumer entry point. Do not also
register the internal extension with setRendering; the plugin registry owns
its complete lifecycle.
Create Shaders/ProceduralShaders.metal:
#include <metal_stdlib>
using namespace metal;
struct ProceduralVertexOut {
float4 position [[position]];
float3 color;
};
vertex ProceduralVertexOut proceduralVertex(
uint vertexID [[vertex_id]],
constant float4x4 &viewProjection [[buffer(0)]])
{
constexpr float3 positions[] = {
float3(-0.75, 0.0, 0.0),
float3( 0.75, 0.0, 0.0),
float3( 0.0, 1.1, 0.0),
};
constexpr float3 colors[] = {
float3(0.95, 0.15, 0.10),
float3(0.10, 0.75, 0.35),
float3(0.10, 0.35, 0.95),
};
ProceduralVertexOut out;
out.position = viewProjection * float4(positions[vertexID], 1.0);
out.color = colors[vertexID];
return out;
}
fragment float4 proceduralFragment(ProceduralVertexOut in [[stage_in]]) {
return float4(in.color, 1.0);
}This shader uses vertex_id, so the first version needs no vertex buffer or
vertex descriptor.
Create Scripts/build-metallib.sh:
#!/bin/sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
package_root=$(CDPATH= cd -- "$script_dir/.." && pwd)
source_file="$package_root/Sources/ProceduralRenderPlugin/Shaders/ProceduralShaders.metal"
resource_dir="$package_root/Sources/ProceduralRenderPlugin/Resources"
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/ProceduralShaders.XXXXXX")
trap 'rm -rf "$work_dir"' EXIT
mkdir -p "$resource_dir"
xcrun -sdk macosx metal -c "$source_file" \
-fmodules-cache-path="$work_dir/ModuleCache" \
-o "$work_dir/ProceduralShaders.air"
xcrun -sdk macosx metallib "$work_dir/ProceduralShaders.air" \
-o "$resource_dir/ProceduralShaders-macos.metallib"Then run:
chmod +x Scripts/build-metallib.sh
Scripts/build-metallib.shA metallib is SDK-specific. Before declaring iOS or visionOS support, also
compile and bundle libraries using iphoneos, iphonesimulator, xros, and
xrsimulator as applicable. Rebuild every library whenever shader code or
shared shader declarations change.
Create ProceduralRenderExtension.swift:
import Foundation
import Metal
import simd
import UntoldEngine
final class ProceduralRenderExtension: RenderExtension, @unchecked Sendable {
let id = ProceduralPluginContract.extensionID
private let shaderBundle: Bundle
init(shaderBundle: Bundle) {
self.shaderBundle = shaderBundle
}
func registerShaderLibraries(_ registry: RenderShaderLibraryRegistry) {
registry.registerLibrary(
ProceduralPluginContract.shaderLibraryID,
bundle: shaderBundle,
resource: "ProceduralShaders-macos"
)
}
func registerPipelines(_ registry: RenderPipelineRegistry) {
registry.registerScenePipeline(
ProceduralPluginContract.pipelineID,
vertexShader: "proceduralVertex",
fragmentShader: "proceduralFragment",
vertexShaderLibrary: .registered(
ProceduralPluginContract.shaderLibraryID
),
fragmentShaderLibrary: .registered(
ProceduralPluginContract.shaderLibraryID
),
depthCompareFunction: .lessEqual,
depthEnabled: true,
reverseZCompatible: true,
blendMode: .none,
name: "Procedural Scene Geometry"
)
}
func buildGraph(
_ builder: inout RenderGraphBuilder,
context _: RenderGraphBuildContext
) {
builder.addPass(
id: ProceduralPluginContract.passID,
stage: .beforePostProcess
) { context in
guard let pipeline = context.renderPipelines.pipeline(
ProceduralPluginContract.pipelineID
), let pipelineState = pipeline.pipelineState,
let encoder = context.sceneRenderTargets.makeRenderCommandEncoder(
actions: .loadAndStore,
label: "Procedural Scene Geometry"
)
else {
return
}
defer { encoder.endEncoding() }
var viewProjection = context.camera.viewProjectionMatrix
encoder.setRenderPipelineState(pipelineState)
if let depthState = pipeline.depthState {
encoder.setDepthStencilState(depthState)
}
encoder.setVertexBytes(
&viewProjection,
length: MemoryLayout<simd_float4x4>.stride,
index: 0
)
encoder.drawPrimitives(
type: .triangle,
vertexStart: 0,
vertexCount: 3
)
}
}
}registerScenePipeline resolves the engine's working color and depth formats.
Do not hard-code drawable or depth attachment formats. With
reverseZCompatible: true, the engine adapts ordered depth comparisons to its
active convention.
The scene encoder is available only at .afterOpaqueLighting,
.beforeTransparency, .afterTransparency, and .beforePostProcess. Always
end it before the pass closure returns.
Add the package product to the application target and install it once before creating the renderer:
import ProceduralRenderPlugin
import UntoldEngine
func installProceduralRendering() -> Bool {
switch registerProceduralRenderPlugin() {
case .installed, .replaced:
return true
case let .rejected(failure):
print("Validation errors:", failure.validationErrors)
print("Artifact conflicts:", failure.artifactConflicts)
print("Graph errors:", failure.graphValidationErrors)
return false
}
}
guard installProceduralRendering() else {
fatalError("Rendering extension installation failed")
}
let renderer = UntoldRenderer.create()Do not ignore .rejected. It distinguishes manifest validation, artifact
conflicts, shader or pipeline failures, and graph errors.
For a multi-platform plugin, select the resource compiled for the current SDK inside package-owned code:
private var shaderResourceName: String {
#if os(visionOS)
#if targetEnvironment(simulator)
"ProceduralShaders-xrossim"
#else
"ProceduralShaders-xros"
#endif
#elseif os(iOS)
#if targetEnvironment(simulator)
"ProceduralShaders-iossim"
#else
"ProceduralShaders-ios"
#endif
#else
"ProceduralShaders-macos"
#endif
}Copy each corresponding .metallib in Package.swift. Only declare platforms
for which the package contains compatible artifacts. Replace the hard-coded
"ProceduralShaders-macos" registration and URL helper with
shaderResourceName when enabling these additional platforms.
context.camera, context.currentEye, and the scene targets describe the eye
currently being rendered. Scene geometry must normally draw for every eye.
Eye-independent work such as simulation or an offscreen map should run once per XR frame when appropriate:
guard context.currentEye == 0 else { return }Do not apply that guard to visible scene geometry. Doing so would omit the draw from the other eye.
Once the procedural draw works, add advanced features incrementally:
- declare textures and buffers in
registerResources; - match texture usage to graph access declarations;
- register compute work through
registerComputePipelines; - retrieve declared resources only through
context.resources; - use
registerModelSurfacePipelinefor normal engine entity drawing; and - use the lower-level pipeline descriptor for extension-owned offscreen targets.
See Using Rendering Extensions for these APIs and the package fixture for compute, resources, argument buffers, model surfaces, and custom geometry in one buildable package.
At minimum, test manifest ownership and bundled shader functions:
import Metal
import UntoldEngine
@testable import ProceduralRenderPlugin
import XCTest
final class ProceduralRenderPluginTests: XCTestCase {
func testManifestOwnsEveryExtension() {
let plugin = ProceduralRenderPlugin()
XCTAssertTrue(RenderExtensionPluginValidator.validate(plugin).isValid)
XCTAssertTrue(plugin.makeRenderExtensions().allSatisfy {
$0.id == plugin.manifest.id ||
$0.id.hasPrefix(plugin.manifest.id + ".")
})
}
func testBundledFunctionsExist() throws {
let url = try XCTUnwrap(ProceduralRenderPlugin.bundledMetallibURL)
let device = try XCTUnwrap(MTLCreateSystemDefaultDevice())
let library = try device.makeLibrary(URL: url)
XCTAssertNotNil(library.makeFunction(name: "proceduralVertex"))
XCTAssertNotNil(library.makeFunction(name: "proceduralFragment"))
}
}Integration tests should also cover successful installation, unavailable IDs, pipeline cleanup, shader-library cleanup, pass removal, camera context, scene target access, depth state, and an actual encoded draw.
Run:
swift build
swift test- The plugin builds independently from the engine package.
- Every artifact ID is namespaced.
- Installation occurs before renderer creation and handles rejection.
- The package loads its own platform-compatible metallib through
Bundle.module. - Shader source is not added to the engine target.
- Scene pipelines use engine-resolved formats and reverse-Z compatibility.
- Visible XR geometry draws for every eye.
- Every encoder is ended before its pass returns.
- Every declared resource access matches its Metal usage.
- Uninstall removes pipelines, shader libraries, resources, and graph passes.
| Symptom | Likely cause |
|---|---|
| Plugin installation is rejected | Invalid namespace, missing metallib or function, duplicate ID, or invalid graph declaration. |
Pipeline lookup returns nil |
Pipeline registration failed or registration and lookup IDs differ. |
Scene encoder returns nil |
The pass uses an incompatible stage or scene color/depth targets are unavailable. |
Resource lookup returns nil |
The pass did not declare that resource and access mode. |
| macOS works but visionOS fails | The package bundled the wrong SDK's metallib or skipped per-eye drawing. |
| Depth appears inverted | The pipeline disabled reverse-Z compatibility or used an unsuitable comparison. |
| Metal reports corrupt uniforms | Swift and Metal size, alignment, or field order differ. |
For API details, continue with Using Rendering Extensions. For engine internals, see Rendering Extensions Architecture.