TwoLinks

May 22, 2026 · View on GitHub

Real-time 3D chaotic double pendulum simulation — Android, iOS, and Web from a single Kotlin Compose Multiplatform codebase.

App Store Google Play Web App Landing Page


About

TwoLinks simulates a chaotic double pendulum in real-time 3D using physically-based rendering. Two rigid links hang from cylindrical pivots on a metal panel, orbiting planets fill the background, and a particle trail streams from the tip of the second link. Link length, pivot position, hinge offset, and colors are all adjustable. On Android, the scene can be dropped into your real physical environment via ARCore Augmented Reality.

The physics are derived from Lagrangian mechanics and numerically integrated at each frame using a fourth-order Runge-Kutta solver. The 3D rendering layer uses SceneView — a community-maintained Kotlin Multiplatform library built on Google's Filament rendering engine (Android/Web) and Apple's RealityKit (iOS).

Motivation

TwoLinks is an experiment in cross-platform 3D rendering with Kotlin Compose Multiplatform. The goal was to find a practical path to native 3D graphics — including AR — from a single shared codebase, without rewriting the rendering layer per platform.

The broader motivation comes from another app I'm developing: ARMOR (Augmented Reality Mobile Robotics), an iOS app for visualizing URDF robot models and running MuJoCo physics simulations in AR. ARMOR is currently iOS-only and I want to bring it to Android. TwoLinks is the proving ground for using SceneView as the cross-platform renderer to do that.

TwoLinks is also the second Kotlin Multiplatform app I've published to both stores. The first was YouKon — an engineering unit conversion and measurement management tool — which validated the KMP architecture and led directly to this project.


Architecture

TwoLinks Architecture Block Diagram

The project lives in TwoLinksCMP/ and is structured as a single Gradle project with one composeApp module and platform-specific source sets.

TwoLinksCMP/
├── composeApp/src/
│   ├── commonMain/    — shared Kotlin: models, physics, UI, ViewModel
│   ├── androidMain/   — Android: SceneView (Filament), SceneManager
│   ├── appleMain/     — iOS bridge: UIKitViewController factory
│   ├── iosMain/       — iOS platform impl (Platform.ios.kt, MainViewController)
│   ├── webMain/       — Web: SceneView-Web (Filament.js), SceneManager
│   ├── jsMain/        — JS-specific
│   └── wasmJsMain/    — Wasm/JS-specific
└── iosApp/            — Xcode project
    └── iosApp/
        ├── TwoLinksSceneView.swift    — SwiftUI view, hosts the 3D scene
        ├── SceneManager.swift         — entity building, transforms, planet loading
        ├── iOSApp.swift
        └── Extensions/                — SIMD, Float, Kotlin-math Swift extensions

Shared Code (commonMain)

FilePurpose
model/TwoLinks.ktPhysics model: state vector, mass matrix, equation of motion, RK4 integration
model/Link.ktSingle-link data class: geometry, mass, MOI, normalized dimension accessors
model/Planet.ktPlanet data class: name, scale, position, rotation, fallback color; file resolves to .usdz on iOS, .glb elsewhere
`functions/math.kt$\text{RK4} \text{integrator}, 2 \times 2 \text{matrix} \text{inverse}, \text{Float4} \text{validity} \text{checks}
$functions/paths.kt`fileLocation(planet) and resolveEnvironmentPath() path helpers
MainViewModel.ktCompose ViewModel: state flows, updateOnFrame, dimension/color setters, shuffle
TwoLinksSceneView.ktexpect composable, implemented per platform
views/Shared UI: MainBodyScaffold, TopAppBar, LinkDimensionEditor, LinkColorEditor, ShuffleDialog, PlayAndResetButtons

Android (androidMain)

Rendering uses SceneView for Android (Filament-based).

FilePurpose
TwoLinksSceneView.android.ktactual composable; hosts SceneView with composable node DSL
SceneManager.ktOwns Filament Engine, ModelLoader, EnvironmentLoader, camera and sun light nodes, planet ModelInstance state
MainActivity.ktEntry point

The scene hierarchy is expressed as composable nodes: DoorNodePivotNode + LinkNodePivotNode + LinkNode. Planet models load asynchronously and are added via PlanetNode once their ModelInstance is ready.


iOS (iosApp + appleMain)

Rendering uses SceneView-Swift (RealityKit-based).

FilePurpose
TwoLinksSceneView.swiftSwiftUI View; owns SceneManager and LightNode; drives frame updates via TimelineView(.animation)
SceneManager.swiftBuilds the RealityKit entity hierarchy (buildScene), applies transforms and colors each frame (applyTransforms, applyColors), loads planets with cross-fade transition
TwoLinksSceneView.apple.ktactual composable in appleMain; embeds a Swift UIViewController via UIKitViewController
iOSApp.swiftRegisters the scene view controller factory before Compose starts

Planet loading (SceneManager.loadPlanet) shows a colored placeholder sphere immediately, then cross-fades to the USDZ model via OpacityComponent and FromToByAnimation once the async load completes.

The scene is lit with a warm directional LightNode positioned at the sun's world location and aimed at the origin, against a NightSky HDR environment.


Web (webMain)

Rendering uses SceneView-Web (Filament.js via JavaScript interop).

FilePurpose
TwoLinksSceneView.web.ktactual composable; punches a transparent hole through the Skiko canvas so the Filament WebGL scene shows through; drives frame updates via window.requestAnimationFrame
SceneManager.ktHolds the HTMLCanvasElement; initializes the Filament.js scene, environment KTX files, directional light, and planet GLB models

Transform matrices are computed in Kotlin using kotlin-math (translation, rotation, scale) and passed to Filament.js via @JsFun external declarations.


AppDescriptionPlatforms
ARMORAugmented Reality Mobile Robotics — URDF viewer, MuJoCo simulation, ARKit spatial placement of real-world robotsiOS (Android planned)
YouKonUnit conversion and engineering measurement management — material properties, aerospace mass data, Imperial/SI project organizationiOS, Android

Physics

The simulation integrates the equations of motion for a double compound pendulum using 4th-order Runge-Kutta (RK4).

Notation

SymbolCodeMeaning
θ1,θ2\theta_1, \theta_2x[0], x[1]Absolute angles of link 1 and link 2
ω1,ω2\omega_1, \omega_2x[2], x[3]Angular rates
α1,α2\alpha_1, \alpha_2dx[0], dx[1]Angular accelerations (solved each step)
m1,m2m_1, m_2links[i].massLink masses
I1,I2I_1, I_2links[i].moiMoments of inertia about each link's own centre of mass
ΔI1,ΔI2\Delta I_1, \Delta I_2links[i].moiRelOffsetParallel-axis correction: mici2m_i c_i^2
c1,c2c_1, c_2links[i].offsetDistance from hinge to centre of mass along link axis
yypivotDistance from link 1's hinge to the second-link attachment point
gx,gyg_x, g_ygx, gyGravity components (gx=0g_x = 0, gy=1.62 m/s2g_y = -1.62\ \text{m/s}^2)

State vector

x=[θ1θ2ω1ω2]\mathbf{x} = \begin{bmatrix} \theta_1 & \theta_2 & \omega_1 & \omega_2 \end{bmatrix}^\top

Mass matrix

The equation of motion is M(x)[α1,α2]=f(x)\mathbf{M}(\mathbf{x})\,[\alpha_1,\, \alpha_2]^\top = \mathbf{f}(\mathbf{x}). The 2×2 mass matrix is symmetric:

M(x)=[I1+ΔI1+m2y2m2yc2cos(θ1θ2)m2yc2cos(θ1θ2)I2+ΔI2]\mathbf{M}(\mathbf{x}) = \begin{bmatrix} I_1 + \Delta I_1 + m_2 y^2 & m_2\, y\, c_2 \cos(\theta_1 - \theta_2) \\ m_2\, y\, c_2 \cos(\theta_1 - \theta_2) & I_2 + \Delta I_2 \end{bmatrix}
  • M[0,0] (m11) — inertia of the whole system about link 1's hinge, treating link 2 as a point mass at the pivot.
  • M[1,1] (m22) — inertia of link 2 about its own hinge.
  • M[0,1] = M[1,0] (m12) — coupling term; goes to zero when the links are parallel (θ1=θ2\theta_1 = \theta_2).

Forcing vector (right-hand side)

General form (with gxg_x and gyg_y):

\begin{aligned} f_1 &= -y\, c_2\, m_2\, \omega_$2^{2}$ \sin(\theta_1 - \theta_2) - y\, g_x\, m_2 \sin\theta_1 + y\, g_y\, m_2 \cos\theta_1 - c_1 g_x m_1 \sin\theta_1 + c_1 g_y m_1 \cos\theta_1 \\[6pt] f_2 &= c_2\, m_2 \bigl( y\, \omega_$1^{2}$ \sin(\theta_1 - \theta_2) - g_x \sin\theta_2 + g_y \cos\theta_2 \bigr) \end{aligned}

The first terms in each row are Coriolis/centripetal; the remaining terms are gravity acting on each centre of mass. With gx=0g_x = 0 and gy=gg_y = -g (lunar gravity, g=1.62 m/s2g = 1.62\ \text{m/s}^2) this simplifies to:

\begin{aligned} f_1 &= -y\, c_2\, m_2\, \omega_$2^{2}$ \sin(\theta_1 - \theta_2) - g\,(y\, m_2 + c_1 m_1)\cos\theta_1 \\[6pt] f_2 &= c_2\, m_2 \bigl( y\, \omega_$1^{2}$ \sin(\theta_1 - \theta_2) - g \cos\theta_2 \bigr) \end{aligned} ``$ ### \text{Integration} \text{The} \text{angular} \text{accelerations} \text{are} \text{recovered} \text{by} \text{inverting} \text{the} 2 \times 2 \text{mass} \text{matrix} \text{analytically} ($invert2x2`): ```math \begin{bmatrix} \alpha_1 \\ \alpha_2 \end{bmatrix} = \mathbf{M}^{-1}(\mathbf{x})\, \mathbf{f}(\mathbf{x})

The full state derivative passed to RK4 is:

x˙=[ω1ω2α1α2]\dot{\mathbf{x}} = \begin{bmatrix} \omega_1 & \omega_2 & \alpha_1 & \alpha_2 \end{bmatrix}^\top

Frame time is capped at 0.1 s before each RK4 step to prevent the integrator from diverging after app suspend/resume. If the resulting state contains NaN or infinity the previous state is kept.


Calculating Offsets and Pivot Points

Dimensional Diagram

Five normalized values define the geometry of the two-link system. Each link has a length L and an offset x — the distance from the hinge point H to the link's centre of mass C. The first link also exposes a pivot point P at distance y from H, where the second link attaches.

          H────────x────────C────────────────────┤
          │◄──── offset ───►│
          │◄──────────── y (pivot) ──────────────►│
          │◄────────────── L/2 + offset ──────────►│
  • H — hinge point (origin of the link's local frame; world origin for link 1)
  • C — centre of mass, at distance offset from H along the link axis
  • P — pivot for the second link, at distance pivot from H (TwoLinks.pivot)

Normalized offset

The user controls offsetNorm ∈ [0, 1], where 0 places H near one end and 1 centres H on the link:

offsetNorm = 1 − offset / (L/2 − minDistanceFromEdge)

Solving for offset:

$ \text{offset} = (1 − \text{offsetNorm}) \times (\text{L}/2 − \text{minDistanceFromEdge}) $

minDistanceFromEdge = 0.03 m keeps the hinge at least 3 cm from either end.

Pivot range

The pivot y can range from 0 (at the hinge) up to the far end of the link minus the minimum edge clearance:

maxPivot = offset + L/2 − minDistanceFromEdge

pivotNorm = pivot / maxPivot maps linearly to this range. When link 1's length or offset changes, the pivot is clamped to the new maxPivot so it never falls outside the link.

3D pivot position

TwoLinks.pivotPosition returns the pivot in 3D space relative to link 1's hinge:

Float3(pivot, 0f, links[0].thickness)

The Z offset by links[0].thickness places the second link's hinge flush with the front face of the first link.