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.
- If the up-axis coordinate is determined by
sin(some_angle)
, that angle is an elevation, up from the azimuth - If the up-axis coordinate is determined by a
cos(some_angle)
, it is in fact polar, i.e. displacement “downward”, away from, the primary axis.
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))
}
}
}
}
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))
}
}
}
}
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.
-
Z is up
-
X is towards the front
-
Y is to the right
-
Can be expressed as (r, θ, φ)
- radial distance
- r ≥ 0,
- polar angle (theta):
- 0° ≤ θ ≤ 180° (π rad)
- also referred to as inclination/elevation)
- Positive is from z towards y (left handed/cw when thumb is x)
- azimuth (phi)
- 0° ≤ φ < 360° (2π rad).
- Positive is from x towards y (right handed/ccw, when thumb is z)
- radial distance
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.
-
Y is up
-
Z is towards the front
-
X is to the right
-
xyPlane
- polar plane
- positive number moves item up/clockwise
-
xzPlane
- azimuthal
- positive number moves item forward/clockwise
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.
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.
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
}
Full code
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
- QuickLook seems to have a limit of 199 for the number of root prims it will draw. Time for
over
? Assemblages? Just aScene
? - How would I change the background color?
- What went wrong with the color in the ring X3D file?
- Note that I added
<viewpoint position="0 0 50"></viewpoint>
to the x3d embeds. How would I do that via my own code. - Lat-Long layout
- Does Usd have such a thing as a “stroke” or “line”?
- Really miss having a loop.
- Different functions use different values for the “golden angle”. TODO: test actual speed difference? If it’s stored as a static who cares?
- Double.pi * (3-(5.0).squareRoot()) // 2.399963229728653
- Double.pi * ((5.0).squareRoot() - 1.0) // 3.883222077450933
- Double.pi * (1 + (5.0).squareRoot()) //10.166407384630519
- Wraps around to goldenAngleCompliment (582.5°-360°=222.5°)
References
-
https://stackoverflow.com/questions/9600801/evenly-distributing-n-points-on-a-sphere
-
https://math.stackexchange.com/questions/1585975/how-to-generate-random-points-on-a-sphere
-
https://agupubs.onlinelibrary.wiley.com/doi/pdf/10.1029/2007GC001581
-
https://scholar.rose-hulman.edu/cgi/viewcontent.cgi?article=1387&context=rhumj