Hello USD - Part 18: I can make a sphere out of Spheres!

This article is part of a series.

After all the refactoring and waiting, I needed some playtime with SketchPad just the way it is for a moment. Get a feel for how working with it compares to something like p5js or Processing. Nice thing about the X3D files is I can embed them directly in the page. Some day .usda, some day…

UPDATE: In some of the code below I refer to angles of elevation as polar, which is not correct.

Make a Ring

First I made a ring, because I’ve made those before.

In the ring code I used a single and and a distance angle to create 2 coordinates, basic polar coordinate to cartesian conversion.

    let theta = base_theta * Double(i) 
    let x = cos(theta) * radius
    let y = sin(theta) * radius

Ring(count:18, radius: 12).buildStage()

public struct Ring {
    public init(count:Int, radius:Double, ratio:Double = 0.1) {
        self.count = count
        self.radius = radius
        self.ratio = ratio
    }
    let count:Int
    let radius:Double
    let ratio:Double

    let tau = Double.pi * 2

    public func buildStage() -> Canvas3D {
        //the change between each sphere based on the number 
        //wanted in the ring. 
        let base_theta = tau/Double(count) 
        let sphere_radius = radius*ratio
        return Canvas3D {
            Sphere(radius: sphere_radius).color(red: 0.18, green: 0.18, blue: 0.18)
             for i in 0..<count {
                 let theta = base_theta * Double(i) 
                 let x = cos(theta) * radius
                 let y = sin(theta) * radius
                 
                 Sphere(radius: sphere_radius)
                 .color(
                     red: sin(theta),
                     green: 0.2,
                     blue: cos(theta)
                 )
                 .translateBy(Vector(x: x, y: y, z: 0.0))
             }
        }
    }
}

ring usda file

Screen shot of a ring made out of 18 spheres in a mid-tone rainbow color scheme

Random Shell

The real trick is is to make a sphere.

A ring is simply a special case of spherical coordinates where the second, azimuthal, angle stays at zero.

    let theta = base_theta * Double(i)
    let phi = 0 
    //cos(phi) == 1
    let x = cos(theta) * cos(phi) * radius
    let y = sin(theta) * cos(phi) * radius
    //sin(phi) == 0
    let z = sin(phi)

This means, to make a shell of randomly placed spheres doesn’t require a whole lot of extra code over just making a ring.

//
//  RandomShell.swift
//  
//
//  Created by Carlyn Maw on 7/25/23.
//

import Foundation

//https://en.wikipedia.org/wiki/Sphere

public struct RandomShell {
    public init(count:Int, radius:Double, ratio:Double = 0.1) {
        self.count = count
        self.radius = radius
        self.ratio = ratio
    }
    let count:Int
    let radius:Double
    let ratio:Double

    let tau = Double.pi * 2
    let π = Double.pi

    public func buildStage() -> Canvas3D {
        let sun_color = 0.9
        let sphere_radius = radius*ratio
        return Canvas3D {
            Sphere(radius: sphere_radius).color(red: sun_color, green: sun_color, blue: sun_color)
             for _ in 0..<count {
                 let theta = Double.random(in: 0...π)
                 let phi = Double.random(in: 0...tau)
                 let x = radius * sin(theta) * cos(phi)
                 let y = radius * sin(theta) * sin(phi)
                 let z = radius * cos(theta)
                 
                 Sphere(radius: sphere_radius)
                 .color(
                    red: cos(phi).magnitude,
                     green: cos(theta).magnitude,
                    blue: sin(phi).magnitude
                 )
                 .translateBy(Vector(x: x, y: y, z: z))
             }
        }
    }
}

random shell usda file

Precisely placed item

Creating a randomly placed sphere in the shell doesn’t require understanding the coordinate system because no matter which way is up, or forward, or back, it will still look like a sphere.

Having actual control over placement in space requires understanding the coordinate system.

I made two different functions for placing an item using spherical coordinates.

The first follows the classic physics model.

Illustration of a 3D spherical coordinate system

Derivative of image by Andeggs - Own work, Public Domain, https://commons.wikimedia.org/w/index.php?curid=7478049


    func sphericalCoordinate(theta:Double, phi:Double) -> Vector {
        let sin_theta = sin(theta)
        let x = sin_theta * cos(phi)
        let y = sin_theta * sin(phi)
        let z = cos(theta)
        return Vector(x: x, y: y, z: z)
    }

The second, very similar function, tries to create more intuitive behavior in the OpenUSD/X3D coordinate system with “Y” set as the Up Axis, which is common.

    func sphericalCoordinate(xyPlane:Double, xzPlane:Double) -> Vector {
        let cos_xz = cos(xzPlane)
        let x = cos_xz * cos(xyPlane)
        let y = cos_xz * sin(xyPlane)
        let z = sin(xzPlane)
        return Vector(x: x, y: y, z: z)
    }

Placed in the context of Axis layout:

public struct AxisWithSpheres {
    public init(count:Int, radius:Double, ratio:Double = 0.1) {
        self.count = count
        self.radius = radius
        self.ratio = ratio
    }
    let count:Int
    let radius:Double
    let ratio:Double
    
    let π = Double.pi
    
    public func buildStage() -> Canvas3D {
        
        let stride = radius/Double(count)
        let sphere_radius = stride * ratio
        let r = radius/2
        let polar = π/4
        let azimuthal = π/8
        
        return Canvas3D {
            Sphere(radius: sphere_radius*3)
                .color(red: 0.1, green: 0.8, blue: 1)
                .translateBy(sphericalCoordinate(xyPlane: polar, xzPlane: azimuthal).scaled(by: r))
            Sphere(radius: sphere_radius*3)
                .color(red: 1, green: 0.8, blue: 0.1)
                .translateBy(sphericalCoordinate(theta: polar, phi: azimuthal).scaled(by: r))  
            //Axis --------------------------------  
            for i in 0...count {
                Sphere(radius: sphere_radius)
                    .color(red: 1, green: 0, blue: 0)
                    .translateBy(Vector(x: Double(i)*stride, y: 0, z: 0))
            }
            for i in 0...count {
                Sphere(radius: sphere_radius)
                    .color(red: 0, green: 1, blue: 0)
                    .translateBy(Vector(x: 0, y: Double(i)*stride, z: 0))
            }
            for i in 0...count {
                Sphere(radius: sphere_radius)
                    .color(red: 0, green: 0, blue: 1)
                    .translateBy(Vector(x: 0, y: 0, z: Double(i)*stride))
            }

        }
    }

Yellow placed with physics-math. Pale blue placed with the 3DGraphics coordinate system friendly function. Same angles but different axis orientation means entirely different locations.

axis_plus_spheres usda file

Spiral Distribution

The ring represents an even distribution around a circle. But what is an even distribution around a sphere? A common choice is the Fibonacci Spiral.

Unfortunately the variable phi, commonly used for the golden ratio, is already in use for azimuths, so I’ll just call it goldenRatio. In fact, I will try avoid it in sphere talk as well because Mathematicians and Physicists apparently swap theta and phi around, because of course they do.

Version 1

A popular answer on StackOverflow uses a paper by Álvaro Gonzálezto as the basis of its python code.

I reworked it like this:

    let goldenAngle = Double.pi * (3-(5.0).squareRoot())
    //"Top", (low) indices, start at max Y. Spiral is around Y axis
    func generatePoints_SOV1(count:Int, radius:Double) -> [Vector] {
        var points:[Vector] = []
        for i in 1..<count {
            
            //pick a Y value in the domain of the unit circle (-1, 1)
            //based on a percentage of how far into the the count we are
            let sin_polar:Double = 1 - (Double(i) / Double(count - 1)) * 2
            
            //√(c^2 - b^2) = a, in the stack overflow code they call this a radius
            //More specifically its the cos the angle in the plane of y and x,
            //which gives us the radius of the circular slice in the perpendicular plane
            //that x and z will be on.
            let cos_polar = (1.0 - sin_polar * sin_polar).squareRoot()
            
            //Used actual golden angle, the SO answer uses the compliment
            //for a different winding.
            let azimuthal = goldenAngle * Double(i)
            
            //since we picked Y to be our index axis, what's left is x and z
            //They complement each other (sin,cos pair), but get tilted by the original
            let x = cos(azimuthal) * cos_polar
            let z = sin(azimuthal) * cos_polar
            let y = sin_polar //the up is the lonely one.
            
            //Code could potentially apply the scaling to the points afterwards
            //en masse and in parallel. Not an issue at this point.
            points.append(Vector(x: x, y: y, z: z).scaled(by: radius))
        }
        return points
    }

The code works much like the sphericalCoordinate(xyPlane:Double, xzPlane:Double) function, but it leads with a y-value with a known distance from the center (it’s a unit sphere) to calculate the polar-angle and uses the goldenAngle combined with the index number to generate an azimuthal value.

spiralv1 usda file

Version 2

A second popular answer works similarly, but has a slightly nicer wrapping due to an offset suggested by its source. The article and code use Mathematics notation rather than physics notation; the theta and phi are swapped from how I used them earlier. I left out those symbols and used “polar” and “azimuthal” for clarity.

Also, as originally written, it grows along the Z axis. I put the Y axis winding code in but left it commented out.

     //"Top", (low) indices, start at max z. Spiral is around Z axis.
    let goldenAngleThree =  Double.pi * (1 + (5.0).squareRoot())
    func generatePoints_SOV2(count:Int, radius:Double) -> [Vector] {
        var points:[Vector] = []
        //original code uses numpy's "arrange" (evenly spaced values
        //within a given interval) to create indices.
        for i in 0..<count {
            
            //offset effects packing. See references in blog post.
            //could also fuzz the value here to blur lines since not using arrange.
            let shiftedIndex = Double(i)+0.5
            
            //comparing to generatePoints_SOV1, functionally very similar
            //i.e. 
            //    - get a value based on a percentage into the count 
            //    - map it to a value between -1 and 1, 
            //    - treat that as either the sin or cos value depending
            //      on which axis should be the spindle
            //In _SOV1 the Y is the spindle, here it will be the Z
            let polar:Double = acos(1 - 2*shiftedIndex/Double(count))
            let azimuthal:Double = goldenAngleThree * shiftedIndex
            
            //As original, z-axis winding
            points.append(Vector(
                x: cos(azimuthal) * sin(polar),
                y: sin(azimuthal) * sin(polar),
                z: cos(polar)
            ).scaled(by: radius))
            
            //If would prefer y-axis instead.
//            points.append(Vector(
//                x: cos(azimuthal) * sin(polar),
//                y: cos(polar),
//                z: sin(azimuthal) * sin(polar)
//            ).scaled(by: radius))
        }
        return points
    }

spiralv2 usda file

Full code

SpiralShell.swift

Note that my Vector type also newly has a “scaled(by)” value that multiplies each component of the vector. I’m not worrying about using Accelerate or simd values at the moment to make this code faster because I’m just saving it to disk. The loaders and renderers need to have speed. This step not so much yet. TBD if I’ll pull in the PSVector type from (Particle System)

Things to Explore

References

This article is part of a series.