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.
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

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)
| File | Purpose |
|---|---|
model/TwoLinks.kt | Physics model: state vector, mass matrix, equation of motion, RK4 integration |
model/Link.kt | Single-link data class: geometry, mass, MOI, normalized dimension accessors |
model/Planet.kt | Planet 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.kt | Compose ViewModel: state flows, updateOnFrame, dimension/color setters, shuffle |
TwoLinksSceneView.kt | expect composable, implemented per platform |
views/ | Shared UI: MainBodyScaffold, TopAppBar, LinkDimensionEditor, LinkColorEditor, ShuffleDialog, PlayAndResetButtons |
Android (androidMain)
Rendering uses SceneView for Android (Filament-based).
| File | Purpose |
|---|---|
TwoLinksSceneView.android.kt | actual composable; hosts SceneView with composable node DSL |
SceneManager.kt | Owns Filament Engine, ModelLoader, EnvironmentLoader, camera and sun light nodes, planet ModelInstance state |
MainActivity.kt | Entry point |
The scene hierarchy is expressed as composable nodes: DoorNode → PivotNode + LinkNode → PivotNode + LinkNode. Planet models load asynchronously and are added via PlanetNode once their ModelInstance is ready.
iOS (iosApp + appleMain)
Rendering uses SceneView-Swift (RealityKit-based).
| File | Purpose |
|---|---|
TwoLinksSceneView.swift | SwiftUI View; owns SceneManager and LightNode; drives frame updates via TimelineView(.animation) |
SceneManager.swift | Builds the RealityKit entity hierarchy (buildScene), applies transforms and colors each frame (applyTransforms, applyColors), loads planets with cross-fade transition |
TwoLinksSceneView.apple.kt | actual composable in appleMain; embeds a Swift UIViewController via UIKitViewController |
iOSApp.swift | Registers 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).
| File | Purpose |
|---|---|
TwoLinksSceneView.web.kt | actual composable; punches a transparent hole through the Skiko canvas so the Filament WebGL scene shows through; drives frame updates via window.requestAnimationFrame |
SceneManager.kt | Holds 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.
Related Apps
| App | Description | Platforms |
|---|---|---|
| ARMOR | Augmented Reality Mobile Robotics — URDF viewer, MuJoCo simulation, ARKit spatial placement of real-world robots | iOS (Android planned) |
| YouKon | Unit conversion and engineering measurement management — material properties, aerospace mass data, Imperial/SI project organization | iOS, Android |
Physics
The simulation integrates the equations of motion for a double compound pendulum using 4th-order Runge-Kutta (RK4).
Notation
| Symbol | Code | Meaning |
|---|---|---|
x[0], x[1] | Absolute angles of link 1 and link 2 | |
x[2], x[3] | Angular rates | |
dx[0], dx[1] | Angular accelerations (solved each step) | |
links[i].mass | Link masses | |
links[i].moi | Moments of inertia about each link's own centre of mass | |
links[i].moiRelOffset | Parallel-axis correction: | |
links[i].offset | Distance from hinge to centre of mass along link axis | |
pivot | Distance from link 1's hinge to the second-link attachment point | |
gx, gy | Gravity components (, ) |
State vector
Mass matrix
The equation of motion is . The 2×2 mass matrix is symmetric:
- 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 ().
Forcing vector (right-hand side)
General form (with and ):
\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 and (lunar gravity, ) 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:
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

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
offsetfrom H along the link axis - P — pivot for the second link, at distance
pivotfrom 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.