Hello USD - Part 22: The Control Flow is a lie.

This article is part of a series.

Continued from last post, a fleshed out protocol based result builder now drives SketchPad! It took some ironing out to get full parity with the [Sphere] based one, especially the for loops. I’m happy as the new code puts me in good shape to add new features, but I don’t love it yet.

The actual proposal on result builders highlights just how much cleverness backs them up, but also, on close reading, why they seem to be so fiddly as soon as generics and protocols come into play.

The key detail – anything that looks like “control flow” in a Result Builder… isn’t. The purported “for…in” is just a closure that must have a single return. NOT a real code loop AT ALL. No break, no continue… nothing for-loopy about them at all. Just a sad sad zombie for loop. A foreach! I’m considering taking them out of the LayerBuilder entirely like SwiftUI does because they feel so misleading in a creative coding context.

In addition, the data structures the Protocol-Builder pair produces are just chock full of glue-layers that really aught to be melted out like the support material on a 3D print. Want an if statement, now you need an _Wrapped struct. Want that if/else statement, now you need a _Either struct. Want a for loop, now you need a _Array struct. StringNode’s resultBuilder needed none of that because it has a concrete type backing it up. Winnowing the scaffolding and returning something more ordered could be the job of a buildFinalResult function perhaps, but in the mean time I’ll just load out the USD files as the “source of truth”.

To have a record and for experimentation, I made a version of the code that isolates out Layers and the LayerBuilder with a render function that prints to the console. (runs in Playground)

raw code file | gist

At the bottom, a IndexLoop() function addresses some of my concerns with the default for implementation. It could be rewritten with sequence, to take in a range, etc.

struct IndexLoop<Content:Layer>:Layer, RenderableLayer  {
    var id: String { "Repeating" }
    //var count:Int

    var elements: [Content]

    public init(from start:Int = 0, to limit:Int, by increment:Int = 1, @LayerBuilder content: (Int) -> Content) {
        self.elements = []
        for index in stride(from: start, to: limit, by: increment) {
            self.elements.append(content(index))
        }
    }

    func render() {
        for element in elements {
            element._render()
        }
    }
}

let multiType2 = Assembly {
    Square()
    IndexLoop(to: 3) { index in
        let adjusted = index+2
        CircleWithParam(radius: adjusted*3)
        SquareWithParam(side: adjusted*2)
    }
    Circle()
}

The resulting print out:

Assembly<Tuple2Layer<Tuple2Layer<Square, IndexLoop<Tuple2Layer<CircleWithParam, SquareWithParam>>>, Circle>>(content: __lldb_expr_15.Tuple2Layer<__lldb_expr_15.Tuple2Layer<__lldb_expr_15.Square, __lldb_expr_15.IndexLoop<__lldb_expr_15.Tuple2Layer<__lldb_expr_15.CircleWithParam, __lldb_expr_15.SquareWithParam>>>, __lldb_expr_15.Circle>(first: __lldb_expr_15.Tuple2Layer<__lldb_expr_15.Square, __lldb_expr_15.IndexLoop<__lldb_expr_15.Tuple2Layer<__lldb_expr_15.CircleWithParam, __lldb_expr_15.SquareWithParam>>>(first: __lldb_expr_15.Square(), second: __lldb_expr_15.IndexLoop<__lldb_expr_15.Tuple2Layer<__lldb_expr_15.CircleWithParam, __lldb_expr_15.SquareWithParam>>(elements: [__lldb_expr_15.Tuple2Layer<__lldb_expr_15.CircleWithParam, __lldb_expr_15.SquareWithParam>(first: __lldb_expr_15.CircleWithParam(radius: 6), second: __lldb_expr_15.SquareWithParam(side: 4)), __lldb_expr_15.Tuple2Layer<__lldb_expr_15.CircleWithParam, __lldb_expr_15.SquareWithParam>(first: __lldb_expr_15.CircleWithParam(radius: 9), second: __lldb_expr_15.SquareWithParam(side: 6)), __lldb_expr_15.Tuple2Layer<__lldb_expr_15.CircleWithParam, __lldb_expr_15.SquareWithParam>(first: __lldb_expr_15.CircleWithParam(radius: 12), second: __lldb_expr_15.SquareWithParam(side: 8))])), second: __lldb_expr_15.Circle()))
Square
Circle with radius 6
Square with side 4
Circle with radius 9
Square with side 6
Circle with radius 12
Square with side 8
Circle

Updating the Builders

As the printout illustrates so well, the new result builder doesn’t have a pure representation of the 3D scene. Instead the "Stage" Layer must be traversed with functions that:

This change also improves the code’s architecture. Instead of a USDFileBuilder with a buildSphere() function, I now have a Sphere that conforms to a USDRenderable protocol with a forUSDA() function with same output.

extension Sphere {
    func render(context: RenderContext) -> RenderContext {
        context + [self.forUSDA()]
    }
}

extension Sphere:USDRenderable {
    func  radiusString(_ radius:Double) -> String {
        "double radius = \(radius)"
    }
    
    //NOTE: Multi-geometry relevant support functions live in an 
    //extension of the protocol USDRenderable
    func forUSDA() -> StringNodeable {
        CurlyBraced(opening: "def Xform \"\(self.id)\"", style: .expanded) {
            
            if !self.transformations.isEmpty {
                transformStringNode(shape:self)
            }
            CurlyBraced(opening: "def \(self.shapeName) \"\(self.id.lowercased())\"",
                        style: .expanded) {
                "\(extentString(shape: self))"
                if !self.surfaces.isEmpty {
                    colorString(shape:self)
                }
                "\(radiusString(self.radius))"
            }
        }
    }
}

The USDFileBuilder still exists, but it takes on a managerial role of all the supported geometries’ extensions and making the initial call to the layer’s _render() function.

    public func generateString(for stage:some Layer) -> String {
        let items = stage._walk(items: [])
        let document = Document {
            stage._render(context: [generateHeader(defaultPrimID:items[defaultPrimIndex])])
        }
        
        return document.render(style: .multilineIndented)
    }

Note that generateString had to collect the names of the prims using a _walk function in order to pick one to be the default:

extension Layer {
    public func _walk(items:[String]) -> [String] {
        if let bottom = self as? _RenderableLayer {
            return bottom.ids(items: items)
        } else {
            return body._walk(items: items)
        }
    }
}

extension _RenderableLayer {
    func ids(items:[String]) -> [String] {
        items + ["\(id)"]
    }
}

The X3DFileBuilder converted over nicely as well. Since I had given the “built in” _render() and render() to the USDFileBuilder, Layer and each of the scaffolding structs needed new X3D dedicated functions.

protocol _X3DRenderable:_RenderableLayer {
    func renderX3D(context:RenderContext) -> RenderContext
}

extension Layer {
    func _renderX3D(context:RenderContext) -> RenderContext  {
        if let bottom = self as? _X3DRenderable {
            return bottom.renderX3D(context: context)
        } else {
            return body._renderX3D(context: context)
        }
    }
}

extension _ArrayLayer:_X3DRenderable {
    func renderX3D(context:RenderContext) -> RenderContext {
         var myContext = context
         for element in elements {
             myContext = element._renderX3D(context: myContext)
         }
         return myContext
     }
}

extension _TupleLayer:_X3DRenderable {
    func renderX3D(context:RenderContext) -> RenderContext {
        second._renderX3D(context: first._renderX3D(context: context))
    }
}


extension _Either: _X3DRenderable {
    func renderX3D(context:RenderContext) -> RenderContext {
        switch storage {
        case .first(let storedLayer):
            return storedLayer._renderX3D(context: context)
        case .second(let storedLayer):
            return storedLayer._renderX3D(context: context)
        }
    }
}

Then Sphere needs the X3D functions as well.

extension Sphere:_X3DRenderable {
    func renderX3D(context: RenderContext) -> RenderContext {
        context + [self.forX3D()]
    }
}

extension Sphere:X3DRenderable {
    func forX3D() -> StringNodeable {
        var content = Tag("Shape") {
            Tag("Appearance") {
                if !self.surfaces.isEmpty {
                    materialString(self.surfaces)
                }
            }
            "<Sphere radius='\(self.radius)'></Sphere>"
        }
        
        if !self.transformations.isEmpty {
            let orderedTransforms = self.transformations.reversed()
            for item in orderedTransforms {
                let attributes = transformAttribute(transform: item)
                content = Tag("Transform", attributes:attributes) { content }
            }
        }
        
        return content
    }
    
}

Update the generateString function for the X3DFileBuilder went smoothly as well.

    public func generateString(for stage:some Layer) -> String {
        let content = stage._renderX3D(context: [])
         let document = Document {
             TopMatter
             Tag("X3D", attributes: X3DAttributes) {
                 head()
                 Tag("Scene") {
                     content
                 }
             }
         }
         return document.render(style: .multilineIndented)
    }

With a quick

swift run sketchpad multiball -s
usdrecord --complexity veryhigh multiball_20230805T084605.usda multiball.png

et voilà:

Hi-Res version of the same type of clump of spheres imagery previous posts on this topic have shown.

A high quality render of a multiball output like never before seen!

Also still in X3D:

This article is part of a series.