Blog Infos
Author
Published
Topics
, , , ,
Published

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)

Cartesian : conventional vs computer graphics

Cartesian conversion, used Computer Graphics / Game Engines / Robotics

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)

Rotation around Y Axis, we will get a plane with x-z (Game version)

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

Yaw demonstration

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

Perspective projection : casting a 3d (z axis) to 2d

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

2d casted , x and y values

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.

Here, after replacing fov value, z ≫ 1 usually, the common normalized projection formula will become the first one x`

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)

Origin shifting

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.

References : Medium Article , LinkedIn Post

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Compose Multiplatform for iOS: Ready for Production Use

Compose Multiplatform, the declarative framework by JetBrains for building shared UIs, is now stable and production-ready on iOS – so it’s time to start building! In this talk, you will get an overview of the…
Watch Video

Compose Multiplatform for iOS: Ready for Production Use

Sebastian Aigner
Developer Advocate
JetBrains

Compose Multiplatform for iOS: Ready for Production Use

Sebastian Aigner
Developer Advocate
JetBrains

Compose Multiplatform for iOS: Ready for Production Use

Sebastian Aigner
Developer Advocate
JetBrains

Jobs

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

Menu