Hello USD - Part 9: Parlez vous MultiBall? DSL starts here
Wanna really never ship? Start writing a Domain Specific Language…
Honestly though, Swift enables doing that fairly easily with ResultBuilders
, already in use in this project with StringBuilder
. I’m assuming the reader has at least heard of ResultBuilders. SwiftUI
uses them for View
creation, for example. References provided in case they are totally new.
Today’s post documents the creation of CanvasBuilder
. For this post it will be using the concrete type Sphere instead of its future TDB more abstract type because ResultBuilders are MUCH simpler with concrete types, although that has recently improved.
I used this code:
public struct MultiBallStage {
public init(count:Int) {
self.count = count
}
let count:Int
let minTranslate = -4.0
let maxTranslate = 4.0
let minRadius = 0.8
let maxRadius = 2.0
public func buildStage() -> Canvas3D {
Canvas3D {
//blue origin sphere
Sphere(radius: 1.0).color(red: 0, green: 0, blue: 1.0)
//the multi in multiball
for _ in 0..<count {
Sphere(radius: Double.random(in: minRadius...maxRadius))
.color(
red: Double.random(in: 0...1),
green: Double.random(in: 0...1),
blue: Double.random(in: 0...1)
)
.translateBy(Vector.random(range: minTranslate...maxTranslate))
}
}
}
}
To create this USDA file
A difference to classic multiball? Its all one file. No references. That’s a feature for another day.
Setup Info
- Xcode 14.3.1
- MacOS 13.4.1
- Swift 5.8.1
References
Relevant Repos:
Basics:
- https://www.hackingwithswift.com/swift/5.4/result-builders
- https://www.avanderlee.com/swift/result-builders/
Excellent Example:
- Write a DSL in Swift using result builders: https://developer.apple.com/videos/play/wwdc2021/10253/
- Fruta Example Code: https://developer.apple.com/documentation/swiftui/fruta_building_a_feature-rich_app_with_swiftui
More:
- https://github.com/apple/swift-evolution/blob/main/proposals/0289-result-builders.md
- New in 5.8 features: https://forums.swift.org/t/improved-result-builder-implementation-in-swift-5-8/63192
- https://github.com/apple/swift-evolution/blob/main/proposals/0289-result-builders.md
- https://github.com/carson-katri/awesome-result-builders
TODO:
- https://www.pointfree.co/clips/815143145
- https://github.com/pointfreeco/swift-validated
- https://talk.objc.io/episodes/S01E343-swiftui-style-backend-library
- https://talk.objc.io/episodes/S01E337-attributed-string-builder-part-1
Non-String Custom Type Result Builder
It was kind of trick to find examples of ResultBuilder
s that weren’t concatenating String
s. String
has a bit of recursion in that a String
is both a single thing and a Sequence
itself. A Sphere
, on the other hand, can never be a collection of Sphere
s, so the String
based examples didn’t show me everything I needed.
Running the following code in a Playground prints the number 14 to the console.
// The fundamental type
public struct Question {
let question = "What is the meaning of life, the universe, everything?"
let answer:Int = 42
}
// The distinct container type
public struct Exam {
let content:[Question]
public init(@TestBuilder content: () -> [Question]) {
self.content = content()
}
}
@resultBuilder
public enum TestBuilder {
// Essential
// Don't use (_ components: Question...) -> [Question] { components }
public static func buildBlock(_ components: [Question]...) -> [Question] {
components.flatMap { $0 }
}
// Allows for using both arrays and single items
public static func buildExpression(_ expression: [Question]) -> [Question] {
expression
}
public static func buildExpression(_ expression: Question) -> [Question] {
[expression]
}
// Allows if's with no else's
public static func buildOptional(_ component: [Question]?) -> [Question] {
component ?? []
}
// Allows if-else statements
public static func buildEither(first component: [Question]) -> [Question] {
component
}
public static func buildEither(second component: [Question]) -> [Question] {
component
}
// Allows for loops
public static func buildArray(_ components: [[Question]]) -> [Question] {
return components.flatMap { $0 }
}
}
func run(count: Int) {
let build = Exam {
Question()
Question()
Question()
for _ in 0...5 {
Question()
}
if 5 == count {
Question()
} else {
Question()
Question()
}
[Question(), Question(), Question()]
}
print(build.content.count)
}
run(count: 3)
The only absolutely necessary function in th ResultBuilder is
public static func buildBlock(_ components: [Question]...) -> [Question] {
components.flatMap { $0 }
}
Using (_ components: Question...) -> [Question] { components }
works until any of the other functions are also implemented. Errors like Cannot pass array of type '[Sphere]' as variadic arguments of type 'Sphere'
refer to this type miss-match that gets hidden in String
based examples.
Lining the DSL up with USD
The nice thing about Multiball is that it’s all Spheres, so I that’s only one concrete type needed in my Canvas for now.
But what does that type look like and how does it work with USD Format output?
First off we have the protocol Geometry
which mashes together Boundable
, Surfaceble
and Transformable
, which roughly translate to UsdGeomBoundable
, and I think Imagable
and Xform
? It should end up feeling like GPrim
Boundable
types can return a Bounds3D
modeled after mdlaxisalignedboundingbox and what USD files have written down as Extent
Transformable
types are those things that can receive the common 3D linear transformations (translate, rotate, scale).
Surfacable
for now means could one apply a material or a color to it. Hopefully one day a shader as well!
The functions that apply surfaces and transform geometries cannot be mutating, but instead must return something that the CanvasBuilder will accept like ViewModifiers in SwiftUI
.
In this, our proto CanvasBuilder, that means Spheres.
public protocol Geometry:Transformable & Boundable & Surfaceable {
var id:String { get }
var shapeName:String { get } //This might become an enum?
}
public protocol Boundable {
var currentBounds:Bounds3D { get }
}
public struct Bounds3D {
var minBounds:Vector
var maxBounds:Vector
}
public enum Transformation {
case translate(Vector)
}
public protocol Transformable {
var transformations:[Transformation] { get set }
mutating func translateBy(_ vector:Vector) -> Self
}
public extension Transformable {
func translateBy(_ vector: Vector) -> Self {
var copy = self
copy.transformations.append(.translate(vector))
return copy
}
}
public enum Surface {
case diffuseColor((r:Double, g:Double, b:Double))
case emissiveColor((r:Double, g:Double, b:Double))
case metallic(Double)
case displayColor((r:Double, g:Double, b:Double)) // Only one that matters for now.
}
public protocol Surfaceable {
var surfaces:[Surface] { get set }
//func diffuseColor(red:Double, green:Double, blue:Double) -> Self
//func emissiveColor(red:Double, green:Double, blue:Double) -> Self
func color(red:Double, green:Double, blue:Double) -> Self
}
public extension Surfaceable {
func color(red:Double, green:Double, blue:Double) -> Self {
var copy = self
copy.surfaces.append(.displayColor((red, green, blue)))
return copy
}
}
The current implementation of sphere has a lot of boiler plate… is this a possible future macro?
public struct Sphere:Geometry {
static var shapeName = "Sphere"
//UUID are too long
public let id:String = IdString.make(prefix: Self.shapeName)
let radius:Double
public var shapeName: String {
Self.shapeName
}
public init(radius: Double, transformations:[Transformation] = []) {
self.radius = radius
self.transformations = transformations
}
//Boundable
public var currentBounds: Bounds3D {
let minVect = Vector(x: -radius, y: -radius, z: -radius)
let maxVect = Vector(x: radius, y: radius, z: radius)
return Bounds3D(minBounds: minVect, maxBounds: maxVect)
}
//Transformable
public var transformations:[Transformation] = []
//Surfaceable
public var surfaces:[Surface] = []
}
Like with regular USD, the layout of all the spheres in the scene has to go to a “renderer”. Unlike with USD, I don’t have to do any Graphics Programming, I just need to mash text together.
The New USDAFileBuilder
takes in a canvas and it’s attendant spheres and some meta data and uses that data structure to create a USD file with a lot more flexibility than the USDAFileBuilder
.
import Foundation
public struct USDAFileBuilder {
var stage:Canvas3D
var defaultPrimIndex:Int
let metersPerUnit:Int
let upAxis:String
let documentationNote:String?
public init(stage: Canvas3D,
defaultPrimIndex: Int = 0,
metersPerUnit: Int = 1,
upAxis: String = "Y",
docNote:String? = nil) {
self.stage = stage
self.defaultPrimIndex = defaultPrimIndex
self.metersPerUnit = metersPerUnit
self.upAxis = upAxis
self.documentationNote = docNote
}
@StringBuilder func generateHeader() -> String {
"#usda 1.0\n("
"\tdefaultPrim = \"\(stage.content[defaultPrimIndex].id)\""
"\tmetersPerUnit = \(metersPerUnit)"
"\tupAxis = \"\(upAxis)\""
if let documentationNote {
"doc = \"\(documentationNote)\""
}
")"
}
func colorString(_ red:Double, _ green:Double, _ blue:Double) -> String {
"color3f[] primvars:displayColor = [(\(red), \(green), \(blue))]"
}
func colorString(shape:Geometry) -> String {
//There has been a problem.
if shape.surfaces.count != 1 { fatalError() }
let color = shape.surfaces[0]
switch color {
case .displayColor(let c):
return "\t\t\(colorString(c.r, c.g, c.b))"
default:
fatalError()
}
}
func radiusString(_ radius:Double) -> String {
"double radius = \(radius)"
}
func extentString(shape:Geometry) -> String {
let minBounds = shape.currentBounds.minBounds
let maxBounds = shape.currentBounds.maxBounds
return "float3[] extent = [(\(minBounds.x), \(minBounds.y), \(minBounds.z)), (\(maxBounds.x), \(maxBounds.y), \(maxBounds.z))]"
}
//TODO: Right now, can do the one and only one transform
func transformString(shape:Geometry) -> String {
if shape.transformations.count != 1 { return "" }
let translate = shape.transformations[0]
switch translate {
case .translate(let v):
return """
\t\tdouble3 xformOp:translate = (\(v.x), \(v.y), \(v.z))
\t\tuniform token[] xformOpOrder = [\"xformOp:translate\"]
"""
}
}
@StringBuilder func sphereBuilder(shape:Sphere) -> String {
"def Xform \"\(shape.id)\"\n{"
//"def Xform \"\(shape.shapeName)_\(shape.id)\"\n{"
if !shape.transformations.isEmpty {
transformString(shape:shape)
}
//"\tdef \(shape.shapeName) \"\(shape.shapeName.lowercased())_\(shape.id)\"\n\t{"
"\tdef \(shape.shapeName) \"\(shape.id.lowercased())\"\n\t{"
"\t\t\(extentString(shape: shape))"
//TODO: How to handle surfaces more generally
if !shape.surfaces.isEmpty {
colorString(shape:shape)
}
//This is what makes it a SPHERE builder.
"\t\t\(radiusString(shape.radius))"
"\t}"
"}"
}
@StringBuilder public func generateStringFromStage() -> String {
generateHeader()
for item in stage.content {
sphereBuilder(shape: item)
}
}
}
Getting and Saving the Result
For now the CLI creates a MultiBallStage struct passing in the count and then requests the output of the generateStringFromStage()
function, saving it to a file. Everything else in the CLI remains the same.
let fileBuilder = USDAFileBuilder(stage: MultiBallStage(count:count).buildStage())
let fileString:String = fileBuilder.generateStringFromStage()
Next time on Why No Test Flight…
It’s a mess, but I made a matching X3D FileBuilder as well.