
3D Spherical Projection Animation
This UI was inspired by my secondary school maths teacher
Hey Everyone,
Today we’ll learn how to build a beautiful 3D spherical projection animation in Compose for Android and iOS, with Jetpack Compose, Perspective Projection, trigonometry, Polar and Cartesian coords, and a bit of curiosity.
Supported platform — Compose Multiplatform

Step 1 : Polar to Cartesian Coords
In order to build the UI, we need to understand the Spherical system, because we are dealing things in 3D and the AIM is to CAST 3d System to 2D Systems, without using Camera APIs as we are not building for Android only
- The Z axis will be Transformed to Scale along X,Y Rotation of the Sphere
- Graphic layer modifier to implement the change
.graphicsLayer {
transformOrigin = TransformOrigin(0.5f, 0.5f)
scaleX = baseScale
scaleY = baseScale
this.alpha = alpha
}
→ Spherical to Cartesian conversion (Game Version)


The image isn’t using the conventional spherical coordinate system. In computer graphics, the Y-axis is treated as the vertical axis instead of Z, so the usual conventions change accordingly.
We will write the Kotlin code, for the Same
private data class Vec3(val x: Float, val y: Float, val z: Float)
private fun sphericalToCartesian(radius: Float, theta: Float, phi: Float): Vec3 {
val st = sin(theta)
val x = radius * st * cos(phi)
val y = radius * cos(theta)
val z = radius * st * sin(phi)
return Vec3(x, y, z)
}
→ Rotation (User Interaction → Rotate the World)

Next, When we drag the sphere left–right or up–down, we are effectively rotating the entire world (the Compose UI) around the X or Y axis. Because of this rotation, we must recalculate the new X, Y, and Z coordinate values accordingly.
private fun rotateY(p: Vec3, angle: Float): Vec3 {
val c = cos(angle)
val s = sin(angle)
return Vec3(
p.x * c + p.z * s,
p.y,
-p.x * s + p.z * c
)
}
private fun rotateX(p: Vec3, angle: Float): Vec3 {
val c = cos(angle)
val s = sin(angle)
return Vec3(
p.x,
p.y * c - p.z * s,
p.y * s + p.z * c
)
}
→ Camera Transform 3d

Here, Instead of an airplane, imagine an imaginary camera. We use yaw and pitch transformations to rotate this camera — see live
The camera’s Yaw and Pitch will be changed when we drag left or right, on the screen, and will read this with pointerInput modifier

→ Perspective Projection (3D → 2D Conversion)


FOV (Field of View) : The extent of the observable world that is seen at any given moment, measured in degrees or physical dimensions
FOV Angle (alpha) : we only consider half the angle (alpha/2), to get a right angled triangle and calculate the value of x and y , changing z axis to the x-y plane with angle alpha

In computer graphics, we generally convert the angles to fov (focal length, float) values, which we can define by ourselves, like fov can be the full width of the screens or 80% of the width etc.

In short the new projected values of x, y are
x' = x.scale
y' = y.scale
where scale = fov/(fov+z) // fov = focal length
Here’s how we use this in our code :
private fun project(p: Vec3, fov: Float, cx: Float, cy: Float): Pair<Float, Float> {
val totalDistance = p.z + fov
val scale = fov / totalDistance // field_of_view.div(totaDistance)
val x2 = p.x * scale + cx
val y2 = p.y * scale + cy // cx and cy -> see, next screen trasnform
return x2 to y2
}
→ Screen Transform (Center the Scene)

Since the (0,0) is located at the top left, hence every transformation will take place from there, we need to shift the origin to the centre, thus adding cx and cy
→ Fibonacci sphere algorithm
We used the Fibonacci sphere algorithm to evenly distribute the elements (chips) across the sphere, ensuring they are spaced out properly and don’t appear overcrowded
// i - index , n = total number of elements
// PHI (φ) - Vertical angle:
φᵢ = arccos(1 - 2zᵢ)
where: zᵢ = (i + 0.5) / n
// THETA (θ) - Horizontal angle:
θᵢ = π(1 + √5) × i
The code :
We added a small offset to θ (using index + 1f) because the first element has index 0, which would make θ = 0. Without this offset, the first element remains fixed and does not rotate, so adding 1f ensures it also participates in the rotation
val labelsWithPositions = remember(labels) {
labels.mapIndexed { index, label ->
val totalLabels = labels.size
// Distribute labels evenly across the sphere using Fibonacci sphere algorithm
val phi = acos(1 - 2 * (index + 0.5f) / totalLabels)
val theta = PI.toFloat() * (1 + sqrt(5f)) * (index + 1f)
Triple(label, theta, phi)
}
}

→ Ui Breakdown

- Generate Random Dots :
- 3f is the minimum size, returning Tripe with theta, phi, Pair of color and size
// Generate random colorful dots on sphere surface
val randomDots = remember {
List(80) {
val theta = Random.nextFloat() * 2 * PI.toFloat()
val phi = Random.nextFloat() * PI.toFloat()
val color = listOf(
Color(0xFFFF6B81), // Pink
Color(0xFF4D96FF), // Blue
Color(0xFF10B981), // Emerald
Color(0xFFFF9F1C), // Orange
Color(0xFFE66DC7), // Orchid
Color(0xFF40E0D0), // Turquoise
Color(0xFFFFD700), // Gold
Color(0xFF4B0082) // Dark Purple
).random()
val size = 3f + Random.nextFloat() * (8f - 3f)
Triple(theta, phi, Pair(color, size))
}
}
The outer Gradient Circle :
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color(0xFFBBDEFB).copy(alpha = 0.3f), // LightPrimary
Color(0xFFE3F2FD).copy(alpha = 0.2f), // LightestBlue
Color(0xFF80DEEA).copy(alpha = 0.1f), // SoftCyan
Color.Transparent
),
center = center,
radius = radius + 60f
),
radius = radius + 60f,
center = center
)
Finally using the above functions : observe
- sphericalToCartesian -> changes from polar to x,y,z
- rotateX -> first this rotates around Y then X , because if we don’t apply that, it will show only one movement when dragged
- project -> converting 3d (x,y,z) to 2d (x,y)
- scale -> calculating scale, based on the fov value described
randomDots.forEach { (theta, phi, colorData) ->
val (color, size) = colorData
val p = sphericalToCartesian(radius, theta, phi)
val rotated = rotateX(rotateY(p, cameraYaw + autoRotate.value), cameraPitch)
if (rotated.z + fov <= 0.001f) return@forEach
val (x2, y2) = project(rotated, fov, cx, cy)
val scale = fov / (rotated.z + fov)
val depth = rotated.z
val dotAlpha = ((scale - 0.2f) / (1f - 0.2f)).coerceIn(0.2f, 1f)
val dotSize = (0.2f + (size * scale - 2f) / (6f - 2f) * (0.6f - 0.2f)).coerceIn(0.2f, 0.6f)
Then we apply, this transformations in the graphics layer
Box(
modifier = Modifier
.offset { IntOffset(posX.roundToInt(), posY.roundToInt()) }
.graphicsLayer {
transformOrigin = TransformOrigin(0.5f, 0.5f)
scaleX = baseScale
scaleY = baseScale
this.alpha = alpha
}
.zIndex(zInd)
)
And that’s all,
Here are the list of resources, you will need to fully understand this.
- Full Code : Gist
- Spherical coordinates (3d explanation)
- Perspective projection (3d to 2d casting)
- 3d Yaw Pitch (Visualise)
- Bloch Sphere (Rotation)
- Sphere Visualiser (theta and alpha)
References : Medium Article , LinkedIn Post
Job Offers

Hope, you understood that well
Let’s connect and share our greatest and weirdest ideas :
LinkedIn
Github
Youtube [Hindi]
Thank you !!
This article was previously published on proandroiddev.com



