Real-time black hole and accretion disk visualisation for Android.
A GPU-driven particle engine that renders a gravitationally lensed accretion disk — event horizon shadow, photon ring, and the far side of the disk arcing over the top — at up to 1.25 million particles, on hardware that has no business doing so.
Built in Kotlin and C++ with a Vulkan renderer, an OpenGL ES fallback, and a closed-loop quality governor that keeps a frame budget on everything from a Snapdragon 6-class phone upward.
app.blackbox.auriel
- Up to 1,250,000 particles with state resident in GPU memory and integration in compute shaders
- Gravitational lensing per particle — the shadow, the photon ring, and the lensed arcs above
and below the disk, from a thin-lens solve costing one
sqrtper particle - Tree-PM gravity solver —
O(n)forces via a particle mesh, with Barnes-Hut run on the mesh cells rather than on the particles - Relativistic effects — Doppler beaming, gravitational redshift, Kerr ISCO, Lense-Thirring frame dragging
- Seven Image Modes — complete display pipeline configurations, from calibrated sRGB through HDR10 and HLG
- Vulkan 1.1+ primary, OpenGL ES 3.1 fallback, chosen at runtime
- Adaptive quality that scales both ways — it sheds work on weak hardware and promotes itself above the selected tier on strong hardware
Grab the APK from Releases and tap it. Android will ask you to allow installs from your browser or file manager the first time — that is the standard prompt for anything not distributed through the Play Store.
Requires Android 8.0 (API 26) or newer on a 64-bit ARM device, which is every Android phone released since roughly 2019.
Both figures below are the engine's own telemetry, sustained, after the governor has converged.
| Device | SoC | GPU | Result |
|---|---|---|---|
| OnePlus Nord 4 | Snapdragon 7+ Gen 3 | Adreno 732 | 700,000 particles @ 66–80 fps, bloom + lensing + counter-image, GPU 14.4 ms |
| Galaxy M31 | Exynos 9611 | Mali-G72 MP3 | 142,000 particles @ 30 fps, lensing on, GPU 24 ms |
The Nord 4 figure is the governor promoting itself two tiers above the Medium preset it started on. The M31 figure is it settling two tiers below. Neither was configured by hand.
| Gesture | Action |
|---|---|
| Drag | Orbit — azimuth and inclination |
| Pinch | Zoom, 24–420 gravitational radii |
| Idle 6 s | Slow automatic drift resumes |
A live frame rate sits in the top left. The tune icon opens advanced settings: Image Mode, simulation physics, performance controls, and a device capability readout. Every control states its cost.
For the full telemetry breakdown — per-stage GPU timings, governor state, thermal and battery inputs — enable the overlay in Settings → Performance, or watch it from a terminal:
adb logcat -s BlackBoxAn Image Mode is a complete display pipeline configuration — colour profile, contrast curve, saturation, bloom weight, brightness mapping, HDR behaviour — not a filter bolted on at the end. All seven share one shader; only a push-constant block and the requested swapchain format differ.
| IM1 | Reference | Neutral filmic sRGB. The calibration baseline every other mode deviates from. |
| IM2 | Deep Contrast | LCD-tuned. Crushes the toe to hide backlight glow, raises midtone contrast. |
| IM3 | Vivid Wide | Display-P3. Spends the extra gamut on the blue-shifted approaching limb. |
| IM4 | HDR10 | BT.2020 + PQ. Maps the photon ring above SDR white instead of into it. |
| IM5 | HLG | Scene-referred HDR; degrades gracefully on panels that over-report their peak. |
| IM6 | Spectral | Planckian-locus colour, minimal bloom. Colour readable as data. |
| IM7 | Power Saver | Lower average picture level, two-level bloom. |
Selection is automatic by default, based on what the display reports. It is also always manually overridable, because device HDR and wide-gamut reporting is unreliable enough on mid-range hardware that trusting it blindly produces a visibly wrong image. The renderer independently checks what the swapchain actually granted and downgrades the transfer function rather than encoding PQ into an sRGB surface.
You never need to change a system display setting.
Quality tiers are coherent presets — particle count, render scale, bloom depth, gravity mesh and substeps move together, so the ratio of compute cost to bandwidth cost stays roughly constant.
| Tier | Particles | Render scale | Bloom | Gravity mesh |
|---|---|---|---|---|
| Minimum | 90,000 | 50% | 2 | 32×16×32 |
| Low | 180,000 | 60% | 3 | 48×24×48 |
| Medium | 380,000 | 72% | 4 | 64×32×64 |
| High | 700,000 | 85% | 4 | 96×48×96 |
| Ultra | 1,250,000 | 100% | 5 | 128×64×128 |
Frame rate targets are 30 / 45 / 60 / 90 / uncapped, and each is a genuine trade rather than a cap. A lower target buys density: on mid-range hardware, 30 fps sustains five to six times the particle count of 60 fps. Each target carries its own tuning — starting position on the quality ladder, a display refresh-rate hint so non-divisor rates like 45 pace cleanly, and FIFO versus mailbox presentation.
The governor closes a loop on the 95th-percentile frame interval using additive-increase / multiplicative-decrease: it remembers the quality level that proved unsustainable and refuses to climb back past it, so it converges instead of oscillating. Thermal and battery state bias it, but never override measured reality — vendor thermal reporting is treated with the same scepticism as vendor HDR reporting, and for the same reason.
Geometrised units throughout: G = M = c = 1, lengths in gravitational radii r_g = GM/c².
- Paczyński–Wiita pseudo-potential — reproduces the ISCO and the plunge region for the cost of one divide
- Kerr ISCO (Bardeen–Press–Teukolsky) — 6
r_gata = 0, 1.24r_gata = 0.998 - Lense–Thirring frame dragging to leading order,
ω ≈ 2aM/r³ - Shakura–Sunyaev α-viscosity on the radial velocity component — the mechanism that makes the disk actually accrete rather than orbit forever
T ∝ r^(−3/4)thin-disk temperature profile, with compression heating in dense regions- Relativistic Doppler beaming (
δ⁴) and gravitational redshift (√(1 − r_s/r)) - Velocity Verlet on a fixed 1/120 s timestep, decoupled from frame rate
The goal is visual accuracy at the scale a phone screen resolves, not a research-grade
astrophysics simulator. Where an approximation is made, ARCHITECTURE.md says
exactly what it gets wrong.
| Gradle | 9.1.0 |
| Android Gradle Plugin | 8.13.1 |
| Kotlin | 2.2.20 |
| compileSdk / targetSdk | 36 |
| minSdk | 26 |
| NDK | 28.2.13676358 |
| CMake | 3.22.1 |
| JDK | 21 |
| ABI | arm64-v8a |
./gradlew :app:assembleDebug # validation layers + engine assertions
./gradlew :app:assembleRelease # R8 + resource shrinking
./gradlew :app:testDebugUnitTest # 26 tests, JNI wire-format contract
./gradlew :app:installDebugglslc is taken from $ANDROID_NDK/shader-tools/, so no separate Vulkan SDK is required. The 12
GLSL shaders are compiled to SPIR-V at build time and embedded in .rodata — there is no runtime
file I/O and no way to ship a binary whose shaders disagree with its pipeline layouts.
The build is arm64-only by design: the NEON kernels use AArch64-only instructions with no runtime dispatch, which is a deliberate trade documented in the architecture notes.
Release builds read keystore.properties from the repo root (gitignored). Without it,
assembleRelease produces an unsigned APK rather than silently falling back to a debug key —
Android identifies an app by its signing certificate, so a debug-signed build that reaches users
could never be updated by a properly signed one.
storeFile=keystore/release.jks
storePassword=…
keyAlias=…
keyPassword=…Four layers with hard boundaries: Android platform, engine host, simulation, renderer.
Kotlin ─ UI · settings · display / thermal / power probing
│ JNI — control plane only, never per frame
C++ ─ render thread, backend selection, frame orchestration
├── Simulation particle store · gravity mesh · Barnes-Hut · integration
├── Renderer Vulkan │ OpenGL ES · camera · Image Modes
└── Governor closed loop on frame time, thermal and power aware
The rule that shapes everything: JNI is a control plane, not a data plane. The render thread is created in C++ and never re-enters the JVM, so the frame loop carries no transition cost and cannot be stalled by garbage collection.
ARCHITECTURE.md covers the reasoning in full — bandwidth analysis, memory
layout, why Barnes-Hut runs on the mesh instead of the particles, the rendering pipeline, and
every significant trade-off stated plainly.
app/src/main/kotlin/ platform layer, UI, settings, JNI declarations
app/src/main/cpp/
engine.cpp render thread, backend selection, orchestration
core/ arena allocator, job system, NEON math, timing
sim/ particle store, gravity mesh, Barnes-Hut, integration
render/vulkan/ loader, context, allocator, renderer
render/gles/ EGL, runtime GLSL, upload ring
platform/ adaptive quality governor
shaders/ GLSL → SPIR-V, embedded at build time
ARCHITECTURE.md engineering rationale
- The OpenGL ES backend is untested on real hardware — both development devices have working Vulkan drivers, so the fallback path has never executed.
- The lensed counter-image (thin arcs hugging the shadow) is a second full vertex pass and is the first thing the governor sheds on mid-range GPUs.
- Lensing uses a thin-lens approximation, not geodesic integration. It produces the shadow, the ring and the arcs correctly; it does not produce third-and-higher-order images.
- Colour is close to, but not identical with, published reference renderings — the disk lacks their fine radial striations.