← Gallery
Simathon 01Black HoleHas videoHas code

Silly Orb

Serah

A black hole :D

Still from Silly Orb

What it does

idk man, its just very cool

Prompt generated by Gemini Flash 3.1 Pro: You are a world-class graphics engineer operating as an autonomous agent within my Antigravity IDE on macOS.

I need you to generate, save, and execute a highly optimized, aesthetically stunning Python black hole simulation.

Please execute the following step-by-step plan:

1. CODE ARCHITECTURE REQUIREMENTS: - Use pygame and numpy for highly performant vector math. - Create an infinite particle system of at least 1,500 stars completely surrounding a central black hole. - Visuals: Pitch-black background, a perfectly dark solid black core (singularity), and dense, spiraling accretion rings utilizing a beautiful palette of cream, beige, and brilliant white. - Physics: Give stars tangential/orbital velocities so they spiral realistically into the gravity well rather than falling in a straight line. Accelerate them using a proper 1/r^2 gravity pull approximation as they approach. - VFX: Implement a motion-blur/photon-trail effect by blending alpha channels on the frame-clear step so stars visually stretch and warp at high speeds. - Infinite Loop: When a star crosses the event horizon, instantly erase it and respawn it at the outer edge.

2. EXECUTION INSTRUCTIONS FOR ANTIGRAVITY IDE: - Step 1: Check if 'pygame' and 'numpy' are installed in the current environment. If missing, use the terminal tool to run `pip install pygame numpy`. - Step 2: Write the clean, production-ready simulation code into a new file named `blackhole_sim.py` in the current workspace. - Step 3: Run the simulation file via the terminal (`python blackhole_sim.py`) so I can view the animated output instantly on my MacBook.

Please begin your task by generating the implementation plan and writing the code now.

Code generated by Claude Sonnet 5 Max and model used is Antigravity IDE Gemini 3.5 Flash High to run it.

Code Used:

# Real-Time Black Hole Simulation

A GPU-accelerated, real-time black hole visualization written in Python 3.12 using **ModernGL** + **GLSL**. A single ray-marched fragment shader integrates bent light rays (null geodesics) around a Schwarzschild black hole to produce gravitational lensing, a photon ring, a turbulent Doppler-shifted accretion disk, and a lensed procedural starfield — composited with an HDR / bloom / ACES-tonemap pipeline for a cinematic look.

Runs natively on macOS (Apple Silicon or Intel) inside **Antigravity IDE**, or from a plain terminal.

---

## 0. Why ModernGL

| Library | Why it wasn't the right fit here | |---|---| | **Pygame** | CPU-side software surface blitting at its core; no native shader/GPU compute path, so a per-pixel geodesic integration running over "thousands of stars" worth of screen area would never clear 50 fps. | | **Arcade** | Built on Pyglet/OpenGL and capable of custom shaders, but its abstractions (sprite lists, texture atlases) are built around 2D sprite rendering, not a single full-screen physics shader — you'd fight the framework more than use it. | | **VisPy** | Genuinely GPU-accelerated and can run custom GLSL, but it's oriented around scientific plotting primitives (scatter, mesh, volume) rather than owning a raw framebuffer pipeline, which is exactly what a multi-pass HDR/bloom raymarcher needs. | | **Panda3D** | A full 3D scene-graph engine with asset pipelines, physics, and audio built in — much heavier than this task needs, and its opinionated rendering pipeline makes injecting one raw full-screen ray-marching shader more awkward than doing it directly. | | **ModernGL** ✅ | A thin, modern (3.3+ core profile) Python binding straight onto OpenGL. It hands you raw framebuffers, textures, and shader programs with almost no overhead, which is exactly what's needed to: (1) run 100% of the physics and shading as GLSL on the GPU, (2) implement a custom multi-pass HDR/bloom pipeline exactly as designed, and (3) keep the CPU side down to "update a camera and set some uniforms" — directly satisfying the "avoid CPU pixel loops" / "minimize memory usage" requirements. Paired with `moderngl-window` for cross-platform window/input handling (backed by pyglet or GLFW), it's the least-abstraction path from Python to the GPU that macOS's OpenGL 4.1 core profile supports. |

## 1. Requirements

* Python 3.12.10 * An OpenGL 4.1–capable GPU (any Mac from the last decade qualifies)

## 2. Setup

```bash cd blackhole_sim python3 -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install -r requirements.txt ```

## 3. Run

```bash python main.py ```

The simulation opens in a resizable window and starts animating immediately — the accretion disk rotates, the starfield twinkles, and the photon ring stays locked to the black hole regardless of camera motion.

If your platform needs a specific windowing backend, you can force one explicitly (moderngl-window supports several):

```bash python main.py --window pyglet python main.py --window glfw ```

## 4. Controls

| Input | Action | |------------------------|--------------------------------------| | Left-mouse drag | Orbit the camera around the black hole | | Right-mouse drag | Pan the camera's look-at point | | Scroll wheel | Zoom / dolly in and out | | `[` / `]` | Decrease / increase field of view | | `1` / `2` | Lower / raise anti-aliasing quality (1× or 2×2 supersampling) | | `Q` | Toggle locked maximum quality (ignores the fps target — use for screenshots) | | `R` | Reset the camera to its default view | | `F` | Toggle fullscreen | | `Esc` | Quit |

The window title doubles as a live HUD: current FPS, the internal render resolution percentage, and the current geodesic step ceiling, e.g. `Real-Time Black Hole Simulation — 58.2 FPS (100% res, 190 steps)`.

All camera motion is exponentially smoothed (see `camera.py`), so input never causes jitter or sudden snaps, even at low frame rates.

## 5. Project layout

``` blackhole_sim/ ├── main.py # Window, framebuffers, render loop, input handling ├── camera.py # Smoothed orbit camera ├── performance.py # Adaptive render-quality controller (see section 7) ├── scene_config.py # All tunable physical / visual parameters ├── requirements.txt ├── README.md └── shaders/ ├── fullscreen.vert # Shared full-screen-quad vertex shader ├── blackhole.frag # Geodesic ray marching: lensing, disk, stars, horizon ├── bright_pass.frag # Bloom stage 1: HDR threshold extraction ├── blur.frag # Bloom stage 2: separable Gaussian blur └── composite.frag # Bloom stage 3: exposure, ACES tonemap, gamma ```

## 6. How the physics works

The simulation does not use a lookup texture or a pre-baked lensing map — every frame, every pixel's ray is integrated live through curved space:

1. **Geodesic integration.** For a photon with position `pos` and direction `dir`, its specific angular momentum `h = pos × dir` is conserved along the path. The weak-field-to-strong-field approximation `d²pos/dλ² = -1.5 · h² · pos / |pos|⁵` (derived from the Schwarzschild null-geodesic equation in the `u = 1/r` formulation) is integrated with an adaptive-step semi-implicit Euler scheme. This bends light smoothly around the black hole, reproduces the photon sphere at `r ≈ 1.5·rs`, and naturally creates multiple lensed images of the disk and stars. 2. **Photon ring.** Every ray tracks the closest radius it ever reaches (`closestApproach`). Rays that graze the photon sphere orbit several times before escaping or falling in — exactly the physical mechanism behind the bright ring seen in the Event Horizon Telescope image — so a soft, HDR-bright glow is added whenever `closestApproach` lands near `1.5·rs`, on top of (not instead of) the ring naturally formed by lensed disk and star images. Because the capture radius is a perfect sphere, this boundary is an exact circle in screen space from any viewing angle — the real, well-known result for a non-spinning (Schwarzschild) hole, which is why this project doesn't add Kerr-style spin: it would make the shadow slightly asymmetric instead of "perfectly circular." 3. **Event horizon.** Any ray whose radius falls below `≈0.98·rs` is considered captured; it contributes no further light, leaving a perfectly black, crisp-edged disc. 4. **Accretion disk.** Modeled as an infinitesimally thin equatorial disk between the ISCO (`3·rs`) and an outer radius. Each crossing of the disk plane is shaded with Keplerian differential rotation, multi-octave fbm turbulence, a blackbody-inspired radial color gradient (blue-white → gold → amber → deep red), relativistic Doppler beaming (approaching material brightens and blue-shifts, receding material dims and red-shifts), and gravitational redshift darkening near the inner edge. 5. **Starfield.** Procedurally generated directly from the ray's final, lensed direction — meaning stars behind the black hole are automatically warped into the characteristic curved arcs and Einstein-ring-like images, with no separate distortion pass required. A ray only samples the starfield once it has cleanly reached `escape_radius`; a ray that merely ran out of step budget while still orbiting near the photon sphere skips this, avoiding a noisy, aliased edge right at the ring. 6. **HDR / bloom / tonemapping.** The raymarch pass renders to a 16-bit float framebuffer. A bright-pass filter extracts the hottest pixels, which are blurred at half resolution across several Gaussian passes and added back on top of the sharp image. The result is exposed and run through the ACES filmic tonemapping curve before gamma correction, giving smooth, banding-free gradients and a natural cinematic contrast curve.

## 7. Guaranteeing 50+ FPS on any Mac: the adaptive performance system

Ray-marched, per-pixel geodesic integration is not "cheap" the way a textured triangle is — cost scales with pixel count and with how many integration steps each ray needs. Rather than hand-picking one fixed quality level and hoping it's fast enough on your particular machine, `performance.py` implements a small closed-loop controller (`AdaptivePerformance`) that watches a rolling average of real frame times and continuously tunes two render-quality levers so the simulation settles at `target_fps` (55, comfortably above the 50 fps requirement) on whatever GPU it's actually running on:

1. **`render_scale`** (1.0 → 0.55): the raymarch pass renders into a framebuffer smaller than the window, which is then upscaled with linear filtering during compositing. This is the single biggest performance lever, since cost scales directly with pixel count, and it degrades gracefully (a slightly softer image) rather than visibly breaking anything. 2. **`max_steps`** (190 → 130): only pulled down as a second line of defense once resolution is already at its floor.

Both levers relax back towards full quality automatically whenever there's headroom — shrink the window, or run on a faster GPU, and quality climbs back up on its own. Press `Q` at any time to pin maximum quality regardless of frame rate (handy for a screenshot).

**A subtlety worth calling out:** lowering `max_steps` is only safe if rays that *aren't* headed toward the black hole still reliably reach `escape_radius` (and therefore correctly sample the starfield) within that budget — otherwise, under heavy load, stars could incorrectly go black. The far-field step size is deliberately capped at a generous `2.5·rs` (rather than the tighter cap you'd want near the hole) so that even at the lowest quality tier and the closest allowed camera distance, a non-lensed ray reaches escape radius in ~74 steps — leaving 56 steps of headroom inside the 130-step floor for genuine near-hole lensing work. `escape_radius` itself is also kept well above `cam_max_distance` (65 vs. 45 world units) with a margin enforced by an assertion in `main.py`, since a camera allowed to orbit past the escape sphere would cause every ray to register as "escaped" before a single integration step — making the black hole itself disappear at far zoom.

## 8. Other performance notes

* All physics and shading run entirely on the GPU; the CPU side only updates the camera, the adaptive-quality controller, and a handful of uniforms per frame. * Bloom blurring happens at half of the (possibly scaled) scene resolution to keep the cost of the wide Gaussian kernel low. * Anti-aliasing is an explicit, adjustable 2×2 supersample (toggle with `1`/`2`) rather than MSAA, since the scene is generated by a single full-screen ray-marching pass where MSAA would have no effect. * The adaptive step size in the geodesic integrator takes small steps only near the black hole (where curvature is strong) and large steps far away, keeping the average per-pixel cost low without sacrificing lensing accuracy near the photon sphere.

""" main.py ======= Entry point for the real-time black hole simulation.

Sets up a ModernGL-backed window (via moderngl-window), owns the HDR / bloom framebuffer pipeline, forwards user input to the OrbitCamera, drives the per-frame render loop, and continuously adapts render resolution / integration quality to hold the configured target frame rate (see performance.py) regardless of the host GPU.

Run: pip install -r requirements.txt python main.py

Controls: Left-mouse drag - orbit camera Right-mouse drag - pan camera target Scroll wheel - zoom (dolly) [ / ] - decrease / increase field of view 1 / 2 - lower / raise anti-aliasing quality Q - toggle locked maximum quality (ignores fps target) R - reset camera F - toggle fullscreen Esc - quit """

from __future__ import annotations

from pathlib import Path

import moderngl import moderngl_window as mglw import numpy as np

from camera import OrbitCamera from performance import AdaptivePerformance from scene_config import SceneConfig

SHADER_DIR = Path(__file__).parent / "shaders"

def _read_shader(name: str) -> str: return (SHADER_DIR / name).read_text(encoding="utf-8")

class BlackHoleApp(mglw.WindowConfig): """Owns the render pipeline and wires user input to the camera."""

gl_version = (4, 1) title = SceneConfig.window_title window_size = SceneConfig.window_size aspect_ratio = None resizable = True samples = 0 vsync = SceneConfig.vsync resource_dir = SHADER_DIR

def __init__(self, **kwargs): super().__init__(**kwargs) self.cfg = SceneConfig()

# The camera must never be able to orbit out past the "escape # sphere" the shader uses to decide a ray has reached the # starfield — if it did, every ray would immediately register as # escaped before a single integration step, and the black hole # would simply vanish at far zoom levels. Kept as a loud assertion # rather than a silent clamp so a future config edit can't # reintroduce this silently. assert self.cfg.cam_max_distance < self.cfg.escape_radius * 0.85, ( "cam_max_distance is too close to escape_radius: the black hole " "would disappear when zoomed out. Keep at least ~15% margin." )

# ------------------------------------------------------------- # Fullscreen-quad geometry, shared by every pass. # ------------------------------------------------------------- quad = np.array( [ # x, y, u, v -1.0, -1.0, 0.0, 0.0, 1.0, -1.0, 1.0, 0.0, -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, ], dtype="f4", ) self.quad_vbo = self.ctx.buffer(quad.tobytes())

# ------------------------------------------------------------- # Shader programs. # ------------------------------------------------------------- vert_src = _read_shader("fullscreen.vert") self.prog_raymarch = self.ctx.program( vertex_shader=vert_src, fragment_shader=_read_shader("blackhole.frag") ) self.prog_bright = self.ctx.program( vertex_shader=vert_src, fragment_shader=_read_shader("bright_pass.frag") ) self.prog_blur = self.ctx.program( vertex_shader=vert_src, fragment_shader=_read_shader("blur.frag") ) self.prog_composite = self.ctx.program( vertex_shader=vert_src, fragment_shader=_read_shader("composite.frag") )

self.vao_raymarch = self._make_vao(self.prog_raymarch) self.vao_bright = self._make_vao(self.prog_bright) self.vao_blur = self._make_vao(self.prog_blur) self.vao_composite = self._make_vao(self.prog_composite)

# ------------------------------------------------------------- # Camera & interaction state. # ------------------------------------------------------------- self.camera = OrbitCamera( distance=self.cfg.cam_distance, azimuth=self.cfg.cam_azimuth, elevation=self.cfg.cam_elevation, fov_deg=self.cfg.cam_fov_deg, min_distance=self.cfg.cam_min_distance, max_distance=self.cfg.cam_max_distance, smoothing=self.cfg.cam_smoothing, ) self.aa_samples = self.cfg.aa_samples self._dragging_left = False self._dragging_right = False

# ------------------------------------------------------------- # Adaptive performance: keeps us at/above cfg.target_fps on any # GPU by scaling internal render resolution and, if needed, the # geodesic integration step ceiling. # ------------------------------------------------------------- self.perf = AdaptivePerformance( target_fps=self.cfg.target_fps, min_render_scale=self.cfg.min_render_scale, max_render_scale=1.0, min_max_steps=self.cfg.min_march_steps, max_max_steps=self.cfg.max_march_steps, ) self._title_timer = 0.0

# ------------------------------------------------------------- # Framebuffers (created lazily / recreated on resize or when the # adaptive render scale changes). # ------------------------------------------------------------- self.scene_fbo = None self.bright_fbo = None self.blur_fbo_a = None self.blur_fbo_b = None self._scene_size = (0, 0) self._half_size = (0, 0) self._update_framebuffers()

# ------------------------------------------------------------------ # Setup helpers # ------------------------------------------------------------------ def _make_vao(self, program: moderngl.Program) -> moderngl.VertexArray: return self.ctx.vertex_array( program, [(self.quad_vbo, "2f 2f", "in_position", "in_uv")], )

def _make_hdr_texture(self, size) -> moderngl.Texture: tex = self.ctx.texture(size, 4, dtype="f2") tex.filter = (moderngl.LINEAR, moderngl.LINEAR) tex.repeat_x = False tex.repeat_y = False return tex

def _current_scene_size(self) -> tuple[int, int]: window_w, window_h = self.wnd.buffer_size scale = self.perf.render_scale return ( max(1, int(window_w * scale)), max(1, int(window_h * scale)), )

def _update_framebuffers(self) -> None: """(Re)allocates the HDR/bloom framebuffers if the required scene resolution has changed, either from a window resize or from the adaptive performance controller changing `render_scale`.""" size = self._current_scene_size() if size == self._scene_size: return self._scene_size = size

for fbo in (self.scene_fbo, self.bright_fbo, self.blur_fbo_a, self.blur_fbo_b): if fbo is not None: fbo.color_attachments[0].release() fbo.release()

half_size = (max(1, size[0] // 2), max(1, size[1] // 2)) self._half_size = half_size

scene_tex = self._make_hdr_texture(size) self.scene_fbo = self.ctx.framebuffer(color_attachments=[scene_tex])

bright_tex = self._make_hdr_texture(half_size) self.bright_fbo = self.ctx.framebuffer(color_attachments=[bright_tex])

blur_a_tex = self._make_hdr_texture(half_size) self.blur_fbo_a = self.ctx.framebuffer(color_attachments=[blur_a_tex])

blur_b_tex = self._make_hdr_texture(half_size) self.blur_fbo_b = self.ctx.framebuffer(color_attachments=[blur_b_tex])

# ------------------------------------------------------------------ # Rendering # ------------------------------------------------------------------ def render(self, current_time: float, frame_time: float) -> None: self.perf.update(frame_time) self.camera.update(frame_time) self._update_framebuffers()

self._pass_raymarch(current_time) self._pass_bloom() self._pass_composite() self._update_title(frame_time)

def _pass_raymarch(self, current_time: float) -> None: pos, right, up, forward = self.camera.basis()

prog = self.prog_raymarch prog["u_resolution"].value = self._scene_size prog["u_time"].value = current_time prog["u_cam_pos"].value = tuple(pos) prog["u_cam_right"].value = tuple(right) prog["u_cam_up"].value = tuple(up) prog["u_cam_forward"].value = tuple(forward) prog["u_tan_half_fov"].value = self.camera.tan_half_fov()

prog["u_rs"].value = self.cfg.schwarzschild_radius prog["u_disk_inner"].value = self.cfg.disk_inner_radius prog["u_disk_outer"].value = self.cfg.disk_outer_radius prog["u_disk_rot_speed"].value = self.cfg.disk_rotation_speed prog["u_disk_turb_scale"].value = self.cfg.disk_turbulence_scale prog["u_disk_brightness"].value = self.cfg.disk_brightness prog["u_star_density"].value = self.cfg.star_density prog["u_star_twinkle_speed"].value = self.cfg.star_twinkle_speed prog["u_escape_radius"].value = self.cfg.escape_radius prog["u_max_steps"].value = int(self.perf.max_steps) prog["u_aa_samples"].value = self.aa_samples

self.scene_fbo.use() self.ctx.clear(0.0, 0.0, 0.0, 1.0) self.vao_raymarch.render(moderngl.TRIANGLE_STRIP)

def _pass_bloom(self) -> None: # 1) Bright-pass extraction at half the (possibly scaled) scene # resolution. self.bright_fbo.use() self.ctx.clear(0.0, 0.0, 0.0, 1.0) self.scene_fbo.color_attachments[0].use(location=0) self.prog_bright["u_scene"].value = 0 self.prog_bright["u_threshold"].value = self.cfg.bloom_threshold self.vao_bright.render(moderngl.TRIANGLE_STRIP)

# 2) Ping-pong separable Gaussian blur for a wide, soft halo. texel = (1.0 / self._half_size[0], 1.0 / self._half_size[1]) src_tex = self.bright_fbo.color_attachments[0] ping, pong = self.blur_fbo_a, self.blur_fbo_b

for _ in range(self.cfg.bloom_blur_passes): # Horizontal ping.use() self.ctx.clear(0.0, 0.0, 0.0, 1.0) src_tex.use(location=0) self.prog_blur["u_tex"].value = 0 self.prog_blur["u_texel_size"].value = texel self.prog_blur["u_direction"].value = (1.0, 0.0) self.vao_blur.render(moderngl.TRIANGLE_STRIP)

# Vertical pong.use() self.ctx.clear(0.0, 0.0, 0.0, 1.0) ping.color_attachments[0].use(location=0) self.prog_blur["u_tex"].value = 0 self.prog_blur["u_texel_size"].value = texel self.prog_blur["u_direction"].value = (0.0, 1.0) self.vao_blur.render(moderngl.TRIANGLE_STRIP)

src_tex = pong.color_attachments[0]

self._bloom_result = src_tex

def _pass_composite(self) -> None: # The scene/bloom textures may be smaller than the window (when # render_scale < 1.0); LINEAR filtering on both textures makes # this an implicit, cheap upscale with no extra shader work. self.ctx.screen.use() self.ctx.clear(0.0, 0.0, 0.0, 1.0)

self.scene_fbo.color_attachments[0].use(location=0) self._bloom_result.use(location=1)

self.prog_composite["u_scene"].value = 0 self.prog_composite["u_bloom"].value = 1 self.prog_composite["u_exposure"].value = self.cfg.exposure self.prog_composite["u_bloom_strength"].value = self.cfg.bloom_strength

self.vao_composite.render(moderngl.TRIANGLE_STRIP)

def _update_title(self, frame_time: float) -> None: # Throttled to twice a second so setting the window title never # becomes a measurable cost itself. self._title_timer += frame_time if self._title_timer < 0.5: return self._title_timer = 0.0

lock_tag = " [LOCKED MAX QUALITY]" if self.perf.locked else "" scale_pct = int(round(self.perf.render_scale * 100)) self.wnd.title = ( f"{self.cfg.window_title} — {self.perf.current_fps:5.1f} FPS " f"({scale_pct}% res, {self.perf.max_steps} steps){lock_tag}" )

# ------------------------------------------------------------------ # Input handling # ------------------------------------------------------------------ def mouse_press_event(self, x: int, y: int, button: int) -> None: if button == self.wnd.mouse.left: self._dragging_left = True elif button == self.wnd.mouse.right: self._dragging_right = True

def mouse_release_event(self, x: int, y: int, button: int) -> None: if button == self.wnd.mouse.left: self._dragging_left = False elif button == self.wnd.mouse.right: self._dragging_right = False

def mouse_position_event(self, x: int, y: int, dx: int, dy: int) -> None: # Not used for dragging (handled in mouse_drag_event); reserved for # future hover-based effects. pass

def mouse_drag_event(self, x: int, y: int, dx: int, dy: int) -> None: if self._dragging_left: self.camera.orbit(dx, dy) elif self._dragging_right: self.camera.pan(dx, dy)

def mouse_scroll_event(self, x_offset: float, y_offset: float) -> None: self.camera.zoom(y_offset)

def key_event(self, key, action, modifiers) -> None: keys = self.wnd.keys if action != keys.ACTION_PRESS: return

if key == keys.ESCAPE: self.wnd.close() elif key == keys.R: self.camera.reset() elif key == keys.F: self.wnd.fullscreen = not self.wnd.fullscreen elif key == keys.LEFT_BRACKET: self.camera.change_fov(-3.0) elif key == keys.RIGHT_BRACKET: self.camera.change_fov(3.0) elif key == keys.NUMBER_1: self.aa_samples = 1 elif key == keys.NUMBER_2: self.aa_samples = 2 elif key == keys.Q: self.perf.toggle_lock()

def resize(self, width: int, height: int) -> None: self._update_framebuffers()

if __name__ == "__main__": mglw.run_window_config(BlackHoleApp) """ scene_config.py ================ Central configuration for the black hole simulation.

All physical distances are expressed in units of the Schwarzschild radius (u_rs = 1.0). Keeping everything in these "natural units" makes the geodesic integration in the shader numerically well behaved and makes the visual proportions (photon sphere, ISCO, disk extent) independent of any real-world unit system. """

from dataclasses import dataclass

@dataclass class SceneConfig: # ---- Window / rendering ---- window_size: tuple = (1600, 900) window_title: str = "Real-Time Black Hole Simulation" vsync: bool = True samples: int = 0 # MSAA handled manually via AA supersampling instead aa_samples: int = 1 # 1 = single sample/pixel, 2 = 2x2 supersampling (4 taps)

# ---- Adaptive performance (keeps frame rate >= target_fps on any GPU) ---- target_fps: float = 55.0 # comfortably clears the "50 fps or higher" bar min_render_scale: float = 0.55 # lowest internal render resolution multiplier min_march_steps: int = 130 # lowest allowed geodesic step ceiling

# ---- Black hole physics (Schwarzschild, natural units, rs = 1.0) ---- schwarzschild_radius: float = 1.0 photon_sphere_radius: float = 1.5 # 1.5 * rs, informational / used for glow shaping isco_radius: float = 3.0 # innermost stable circular orbit (non-spinning) capture_radius_factor: float = 0.98 # ray considered captured just inside rs

# ---- Accretion disk ---- disk_inner_radius: float = 3.0 # == ISCO disk_outer_radius: float = 11.0 disk_rotation_speed: float = 1.15 # overall multiplier on Keplerian angular velocity disk_turbulence_scale: float = 2.6 disk_brightness: float = 2.9

# ---- Camera ---- cam_distance: float = 9.5 cam_min_distance: float = 2.6 cam_max_distance: float = 45.0 # MUST stay comfortably below escape_radius (see above) cam_azimuth: float = 0.35 cam_elevation: float = 0.28 cam_fov_deg: float = 45.0 cam_min_fov_deg: float = 15.0 cam_max_fov_deg: float = 100.0 cam_smoothing: float = 12.0 # higher = snappier, lower = floatier

# ---- Starfield ---- star_density: float = 1.0 star_twinkle_speed: float = 1.0

# ---- Post-processing ---- exposure: float = 1.25 bloom_threshold: float = 1.2 bloom_strength: float = 0.8 bloom_blur_passes: int = 4 # number of horizontal+vertical blur pairs

# ---- Ray marching quality ---- max_march_steps: int = 190 # ceiling; AdaptivePerformance can pull this down further escape_radius: float = 65.0 # multiples of rs; MUST stay > cam_max_distance (below) with # margin, or the camera could zoom out past the "infinity" # sphere and the black hole would never get ray-marched at all """ performance.py =============== A lightweight adaptive-quality controller that keeps the simulation at or above a target frame rate across a wide range of Mac hardware (from older Intel integrated GPUs to the latest Apple Silicon) without any manual tuning.

It watches a rolling average of recent frame times and, a couple of times per second, nudges two cheap render-quality levers:

1. `render_scale` - the resolution the raymarching pass renders at, relative to the window (composited back up with linear filtering). This is the single biggest lever, since raymarching cost scales directly with pixel count. 2. `max_steps` - a ceiling on geodesic-integration steps per ray, pulled down only as a second line of defense if resolution scaling alone isn't enough.

Both levers only ever relax back towards their high-quality defaults when there is headroom, so visual quality recovers automatically once the GPU is no longer the bottleneck (after shrinking the window, for example, or simply on a faster machine). A manual `locked` mode is also provided so the user can pin maximum quality on demand (e.g. to line up a screenshot) at the cost of frame rate. """

from __future__ import annotations

from collections import deque

class AdaptivePerformance: def __init__( self, target_fps: float = 55.0, fps_low_margin: float = 6.0, fps_high_margin: float = 10.0, min_render_scale: float = 0.55, max_render_scale: float = 1.0, min_max_steps: int = 110, max_max_steps: int = 190, history_len: int = 40, adjust_interval: float = 0.5, ) -> None: self.target_fps = target_fps self.low_threshold = target_fps - fps_low_margin self.high_threshold = target_fps + fps_high_margin

self.min_render_scale = min_render_scale self.max_render_scale = max_render_scale self.min_max_steps = min_max_steps self.max_max_steps = max_max_steps

self.render_scale = max_render_scale self.max_steps = max_max_steps self.locked = False

self._history: deque[float] = deque(maxlen=history_len) self._time_since_adjust = 0.0 self.adjust_interval = adjust_interval self.current_fps = target_fps

def update(self, frame_time: float) -> None: """Call once per frame with the last frame's duration in seconds.""" if self.locked: self.render_scale = self.max_render_scale self.max_steps = self.max_max_steps if frame_time > 0.0: self.current_fps = 1.0 / frame_time return

if frame_time <= 0.0: return self._history.append(frame_time) self._time_since_adjust += frame_time

if self._time_since_adjust < self.adjust_interval or not self._history: return self._time_since_adjust = 0.0

avg_frame_time = sum(self._history) / len(self._history) self.current_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else self.target_fps

if self.current_fps < self.low_threshold: self._degrade() elif self.current_fps > self.high_threshold: self._improve()

def _degrade(self) -> None: # Prefer dropping resolution first (least visually noticeable, # most effective); only reduce integration quality if resolution # is already at its floor. if self.render_scale > self.min_render_scale: self.render_scale = max(self.min_render_scale, self.render_scale - 0.08) elif self.max_steps > self.min_max_steps: self.max_steps = max(self.min_max_steps, self.max_steps - 20)

def _improve(self) -> None: # Restore integration quality before resolution, since a full- # resolution image with slightly coarser far-field steps looks # better than a low-resolution image with perfect integration. if self.max_steps < self.max_max_steps: self.max_steps = min(self.max_max_steps, self.max_steps + 20) elif self.render_scale < self.max_render_scale: self.render_scale = min(self.max_render_scale, self.render_scale + 0.05)

def toggle_lock(self) -> None: self.locked = not self.locked """ camera.py ========= A smoothed orbit ("arcball style") camera used to fly around the black hole. The camera stores both a *target* state (driven directly by user input) and a *current* (smoothed) state that is exponentially interpolated towards the target every frame. This removes any jitter from discrete mouse events and produces the floaty, cinematic camera motion typical of astrophysical visualizations.

The camera works in spherical coordinates around a pivot point (`look_at`), which itself can be panned. All distances are expressed in Schwarzschild-radius units, matching `scene_config.SceneConfig`. """

from __future__ import annotations

import math from dataclasses import dataclass, field

import numpy as np

def _clamp(value: float, lo: float, hi: float) -> float: return max(lo, min(hi, value))

@dataclass class OrbitCamera: distance: float = 9.5 azimuth: float = 0.35 # radians, rotation around world-up axis elevation: float = 0.28 # radians, angle above the equatorial plane fov_deg: float = 45.0

min_distance: float = 2.6 max_distance: float = 45.0 min_elevation: float = -1.45 max_elevation: float = 1.45 min_fov_deg: float = 15.0 max_fov_deg: float = 100.0

smoothing: float = 12.0 # exponential smoothing rate (1/seconds)

orbit_sensitivity: float = 0.0045 pan_sensitivity: float = 0.0025 zoom_sensitivity: float = 0.12

look_at: np.ndarray = field(default_factory=lambda: np.zeros(3, dtype=np.float32))

def __post_init__(self) -> None: # Target state mirrors the current state; user input only ever # mutates the *target*, and `update()` chases it smoothly. self._t_distance = self.distance self._t_azimuth = self.azimuth self._t_elevation = self.elevation self._t_fov_deg = self.fov_deg self._t_look_at = self.look_at.copy()

self._defaults = dict( distance=self.distance, azimuth=self.azimuth, elevation=self.elevation, fov_deg=self.fov_deg, )

# ------------------------------------------------------------------ # Input handlers (mutate target state only) # ------------------------------------------------------------------ def orbit(self, dx: float, dy: float) -> None: self._t_azimuth -= dx * self.orbit_sensitivity self._t_elevation = _clamp( self._t_elevation + dy * self.orbit_sensitivity, self.min_elevation, self.max_elevation, )

def pan(self, dx: float, dy: float) -> None: right, up, _ = self._basis_vectors(self._t_azimuth, self._t_elevation) offset = (-right * dx + up * dy) * self.pan_sensitivity * (self._t_distance * 0.1) self._t_look_at = self._t_look_at + offset

def zoom(self, amount: float) -> None: factor = math.exp(-amount * self.zoom_sensitivity) self._t_distance = _clamp( self._t_distance * factor, self.min_distance, self.max_distance )

def change_fov(self, delta_deg: float) -> None: self._t_fov_deg = _clamp( self._t_fov_deg + delta_deg, self.min_fov_deg, self.max_fov_deg )

def reset(self) -> None: self._t_distance = self._defaults["distance"] self._t_azimuth = self._defaults["azimuth"] self._t_elevation = self._defaults["elevation"] self._t_fov_deg = self._defaults["fov_deg"] self._t_look_at = np.zeros(3, dtype=np.float32)

# ------------------------------------------------------------------ # Per-frame update # ------------------------------------------------------------------ def update(self, dt: float) -> None: alpha = 1.0 - math.exp(-self.smoothing * dt) self.distance += (self._t_distance - self.distance) * alpha self.fov_deg += (self._t_fov_deg - self.fov_deg) * alpha self.look_at += (self._t_look_at - self.look_at) * alpha

# Angles need shortest-path interpolation to avoid wrap-around pops. d_az = _wrap_angle(self._t_azimuth - self.azimuth) self.azimuth += d_az * alpha self.elevation += (self._t_elevation - self.elevation) * alpha

# ------------------------------------------------------------------ # Derived quantities used by the renderer # ------------------------------------------------------------------ @staticmethod def _basis_vectors(azimuth: float, elevation: float): """Right / up / forward-to-camera unit vectors for given angles.""" cos_el = math.cos(elevation) offset = np.array( [ cos_el * math.sin(azimuth), math.sin(elevation), cos_el * math.cos(azimuth), ], dtype=np.float32, ) world_up = np.array([0.0, 1.0, 0.0], dtype=np.float32) # `offset` points from the pivot towards the camera. forward_to_pivot = -offset right = np.cross(forward_to_pivot, world_up) norm = np.linalg.norm(right) if norm < 1e-6: right = np.array([1.0, 0.0, 0.0], dtype=np.float32) else: right = right / norm up = np.cross(right, forward_to_pivot) up = up / (np.linalg.norm(up) + 1e-9) return right, up, offset

def position(self) -> np.ndarray: _, _, offset = self._basis_vectors(self.azimuth, self.elevation) return self.look_at + offset * self.distance

def basis(self): """Returns (position, right, up, forward) for shader uniforms.""" right, up, offset = self._basis_vectors(self.azimuth, self.elevation) pos = self.look_at + offset * self.distance forward = -offset # camera looks back towards the pivot forward = forward / (np.linalg.norm(forward) + 1e-9) return pos, right, up, forward

def tan_half_fov(self) -> float: return math.tan(math.radians(self.fov_deg) * 0.5)

def _wrap_angle(angle: float) -> float: """Wraps an angle delta into the range [-pi, pi].""" return (angle + math.pi) % (2.0 * math.pi) - math.pi # Real-Time Black Hole Simulation — dependencies # Tested with Python 3.12.10 on macOS (Apple Silicon & Intel)

moderngl==5.10.0 moderngl-window==2.4.6 numpy>=1.26,<2.0 pyglet==2.0.18 glfw==2.7.0 Pillow>=10.0.0 """ scene_config.py ================ Central configuration for the black hole simulation.

All physical distances are expressed in units of the Schwarzschild radius (u_rs = 1.0). Keeping everything in these "natural units" makes the geodesic integration in the shader numerically well behaved and makes the visual proportions (photon sphere, ISCO, disk extent) independent of any real-world unit system. """

from dataclasses import dataclass

@dataclass class SceneConfig: # ---- Window / rendering ---- window_size: tuple = (1600, 900) window_title: str = "Real-Time Black Hole Simulation" vsync: bool = True samples: int = 0 # MSAA handled manually via AA supersampling instead aa_samples: int = 1 # 1 = single sample/pixel, 2 = 2x2 supersampling (4 taps)

# ---- Adaptive performance (keeps frame rate >= target_fps on any GPU) ---- target_fps: float = 55.0 # comfortably clears the "50 fps or higher" bar min_render_scale: float = 0.55 # lowest internal render resolution multiplier min_march_steps: int = 130 # lowest allowed geodesic step ceiling

# ---- Black hole physics (Schwarzschild, natural units, rs = 1.0) ---- schwarzschild_radius: float = 1.0 photon_sphere_radius: float = 1.5 # 1.5 * rs, informational / used for glow shaping isco_radius: float = 3.0 # innermost stable circular orbit (non-spinning) capture_radius_factor: float = 0.98 # ray considered captured just inside rs

# ---- Accretion disk ---- disk_inner_radius: float = 3.0 # == ISCO disk_outer_radius: float = 11.0 disk_rotation_speed: float = 1.15 # overall multiplier on Keplerian angular velocity disk_turbulence_scale: float = 2.6 disk_brightness: float = 2.9

# ---- Camera ---- cam_distance: float = 9.5 cam_min_distance: float = 2.6 cam_max_distance: float = 45.0 # MUST stay comfortably below escape_radius (see above) cam_azimuth: float = 0.35 cam_elevation: float = 0.28 cam_fov_deg: float = 45.0 cam_min_fov_deg: float = 15.0 cam_max_fov_deg: float = 100.0 cam_smoothing: float = 12.0 # higher = snappier, lower = floatier

# ---- Starfield ---- star_density: float = 1.0 star_twinkle_speed: float = 1.0

# ---- Post-processing ---- exposure: float = 1.25 bloom_threshold: float = 1.2 bloom_strength: float = 0.8 bloom_blur_passes: int = 4 # number of horizontal+vertical blur pairs

# ---- Ray marching quality ---- max_march_steps: int = 190 # ceiling; AdaptivePerformance can pull this down further escape_radius: float = 65.0 # multiples of rs; MUST stay > cam_max_distance (below) with # margin, or the camera could zoom out past the "infinity" # sphere and the black hole would never get ray-marched at all