numerics
Loading...
Searching...
No Matches
main.cpp
Go to the documentation of this file.
1/// @file apps/nbody/main.cpp
2/// @brief Gravitational N-body simulation — raylib visualiser.
3///
4/// Demonstrates num::ode_verlet (symplectic) vs num::ode_rk4 on three
5/// classical scenarios, plus a galaxy-collapse scenario with body merging.
6///
7/// Controls:
8/// 1 / 2 / 3 / 4 -- switch scenario
9/// V -- toggle Verlet ↔ RK4
10/// SPACE -- pause / resume
11/// R -- reset current scenario
12/// +/- -- increase / decrease simulation speed
13/// F -- toggle trail fade
14
15#include "nbody.hpp"
16#include <raylib.h>
17#include <deque>
18#include <vector>
19#include <random>
20#include <cmath>
21#include <cstdio>
22#include <algorithm>
23#include <string>
24
25// ── NBodySim implementation ───────────────────────────────────────────────────
26
27namespace nbody {
28
29// Color mapped from body mass: blue-white (low) → yellow (mid) → orange-red (high)
30static uint32_t mass_color(double m) {
31 const double t = std::min(m / 5.0, 1.0);
32 uint8_t r_, g_, b_;
33 if (t < 0.5) {
34 float u = float(t * 2.0);
35 r_ = uint8_t(120 + u * 135); // 120 → 255
36 g_ = uint8_t(200 + u * 38); // 200 → 238
37 b_ = uint8_t(255 - u * 85); // 255 → 170
38 } else {
39 float u = float((t - 0.5) * 2.0);
40 r_ = 255;
41 g_ = uint8_t(238 - u * 136); // 238 → 102
42 b_ = uint8_t(170 - u * 136); // 170 → 34
43 }
44 return (uint32_t(r_) << 24) | (uint32_t(g_) << 16) | (uint32_t(b_) << 8) | 0xFFu;
45}
46
47// Display radius (pixels) and physics radius for a given mass
48static constexpr float DISP_SCALE = 6.5f; // pixels for mass = 1.0
49static constexpr float PHYS_SCALE = 0.05f; // phys units for mass = 1.0
50static float disp_r(double m) { return DISP_SCALE * std::cbrt(float(m)); }
51static float phys_r(double m) { return PHYS_SCALE * std::cbrt(float(m)); }
52
53void NBodySim::make_accel(const num::Vector& pos, num::Vector& acc) const {
54 const int nb = n();
55 std::fill(acc.begin(), acc.end(), 0.0);
56 for (int i = 0; i < nb; ++i) {
57 for (int j = 0; j < nb; ++j) {
58 if (i == j) continue;
59 double dx = pos[2*j] - pos[2*i];
60 double dy = pos[2*j+1] - pos[2*i+1];
61 double r2 = dx*dx + dy*dy + soft*soft;
62 double r3 = r2 * std::sqrt(r2);
63 double fac = G * bodies[j].mass / r3;
64 acc[2*i] += fac * dx;
65 acc[2*i+1] += fac * dy;
66 }
67 }
68}
69
71 double KE = 0.0;
72 for (int i = 0; i < n(); ++i)
73 KE += 0.5 * bodies[i].mass * (v[2*i]*v[2*i] + v[2*i+1]*v[2*i+1]);
74 return KE;
75}
76
78 double PE = 0.0;
79 for (int i = 0; i < n(); ++i) {
80 for (int j = i+1; j < n(); ++j) {
81 double dx = q[2*j] - q[2*i], dy = q[2*j+1] - q[2*i+1];
82 double r = std::sqrt(dx*dx + dy*dy + soft*soft);
83 PE -= G * bodies[i].mass * bodies[j].mass / r;
84 }
85 }
86 return PE;
87}
88
89void NBodySim::step(double dt) {
90 if (use_verlet) {
91 auto accel = [this](const num::Vector& pos, num::Vector& acc) {
92 make_accel(pos, acc);
93 };
94 auto res = num::ode_verlet(accel, q, v, t, t + dt, dt);
95 q = std::move(res.q);
96 v = std::move(res.v);
97 } else {
98 const int n2 = 2 * n();
99 num::Vector y(2 * n2);
100 for (int i = 0; i < n2; ++i) { y[i] = q[i]; y[n2+i] = v[i]; }
101
102 auto rhs = [this, n2](num::real /*tt*/, const num::Vector& yy, num::Vector& dydt) {
103 num::Vector pos(n2), acc(n2);
104 for (int i = 0; i < n2; ++i) pos[i] = yy[i];
105 make_accel(pos, acc);
106 for (int i = 0; i < n2; ++i) {
107 dydt[i] = yy[n2+i];
108 dydt[n2+i] = acc[i];
109 }
110 };
111
112 auto res = num::ode_rk4(rhs, std::move(y), t, t + dt, dt);
113 for (int i = 0; i < n2; ++i) { q[i] = res.y[i]; v[i] = res.y[n2+i]; }
114 }
115 t += dt;
116}
117
118std::vector<std::pair<int,int>> NBodySim::check_merges() {
119 std::vector<std::pair<int,int>> ops;
120 int i = 0;
121 while (i < n() && n() > 1) {
122 bool merged = false;
123 for (int j = i + 1; j < n(); ++j) {
124 double dx = q[2*i] - q[2*j];
125 double dy = q[2*i+1] - q[2*j+1];
126 double mrd = bodies[i].phys_radius + bodies[j].phys_radius;
127 if (dx*dx + dy*dy >= mrd*mrd) continue;
128
129 // Merge j into i — conserve mass and momentum
130 double mi = bodies[i].mass, mj = bodies[j].mass, mt = mi + mj;
131 q[2*i] = (mi*q[2*i] + mj*q[2*j]) / mt;
132 q[2*i+1] = (mi*q[2*i+1] + mj*q[2*j+1]) / mt;
133 v[2*i] = (mi*v[2*i] + mj*v[2*j]) / mt;
134 v[2*i+1] = (mi*v[2*i+1] + mj*v[2*j+1]) / mt;
135 bodies[i].mass = mt;
136 bodies[i].display_radius = disp_r(mt);
137 bodies[i].phys_radius = phys_r(mt);
138 bodies[i].color = mass_color(mt);
139
140 // Swap j with the last body and shrink arrays
141 int last = n() - 1;
142 ops.push_back({j, last});
143 if (j != last) {
144 std::swap(bodies[j], bodies[last]);
145 q[2*j] = q[2*last]; q[2*j+1] = q[2*last+1];
146 v[2*j] = v[2*last]; v[2*j+1] = v[2*last+1];
147 }
148 bodies.pop_back();
149
150 // Rebuild q and v at the new (smaller) size
151 int nn = n();
152 num::Vector nq(2*nn, 0.0), nv(2*nn, 0.0);
153 for (int k = 0; k < 2*nn; ++k) { nq[k] = q[k]; nv[k] = v[k]; }
154 q = std::move(nq);
155 v = std::move(nv);
156
157 merged = true;
158 break; // restart j-scan for body i (it may now touch another body)
159 }
160 if (!merged) ++i;
161 }
162 return ops;
163}
164
166 t = 0.0;
167 G = 1.0;
168 enable_merges = false;
169
170 if (s == Scenario::Figure8) {
171 soft = 1e-4;
172 bodies = {
173 {1.0, 10.0f, 0xFF5555FF},
174 {1.0, 10.0f, 0x55FF55FF},
175 {1.0, 10.0f, 0x5599FFFF},
176 };
177 q = { -0.97000436, 0.24308753,
178 0.97000436, -0.24308753,
179 0.0, 0.0 };
180 v = { 0.46620368, 0.43236573,
181 0.46620368, 0.43236573,
182 -0.93240737, -0.86473146 };
183
184 } else if (s == Scenario::SolarSystem) {
185 soft = 1e-3;
186 const double Msun = 1000.0;
187 bodies = {
188 {Msun, 18.0f, 0xFFDD33FF},
189 { 1.0, 4.0f, 0xAAAAAAFF},
190 { 1.0, 6.0f, 0x44AAFFFF},
191 { 1.0, 5.0f, 0xFF6644FF},
192 {10.0, 11.0f, 0xFFAA44FF},
193 };
194 const double radii[] = {0.0, 0.22, 0.40, 0.60, 1.10};
195 const int nb = 5;
196 q = num::Vector(2*nb, 0.0);
197 v = num::Vector(2*nb, 0.0);
198 for (int i = 1; i < nb; ++i) {
199 q[2*i] = radii[i];
200 v[2*i+1] = std::sqrt(G * Msun / radii[i]);
201 }
202
203 } else if (s == Scenario::BinaryPlus) {
204 soft = 1e-3;
205 const double Mstar = 50.0;
206 bodies = {
207 {Mstar, 13.0f, 0xFF7744FF},
208 {Mstar, 13.0f, 0x44AAFFFF},
209 { 0.01, 5.0f, 0xAAFF88FF},
210 };
211 const double sep = 0.3;
212 const double omega = std::sqrt(G * 2.0 * Mstar / (sep*sep*sep));
213 double vs = omega * sep * 0.5;
214 double r_tp = 1.5;
215 double v_tp = std::sqrt(G * 2.0 * Mstar / r_tp) * 0.85;
216 q = { -sep*0.5, 0.0,
217 sep*0.5, 0.0,
218 r_tp, 0.0 };
219 v = { 0.0, -vs,
220 0.0, vs,
221 0.0, v_tp };
222
223 } else { // Galaxy
224 enable_merges = true;
225 soft = 0.10;
226
227 const int N_gal = 50;
228 const double R_disk = 3.0; // initial spread radius
229
230 bodies.clear();
231 q = num::Vector(2 * N_gal, 0.0);
232 v = num::Vector(2 * N_gal, 0.0);
233
234 std::mt19937 rng(20240101);
235 std::uniform_real_distribution<double> uni(0.0, 1.0);
236
237 // Estimate total mass to seed tangential speeds
238 // With masses uniform in [0.3, 2.0], mean ≈ 1.15, M_total ≈ 57.5
239 const double M_total_est = N_gal * 1.15;
240
241 for (int i = 0; i < N_gal; ++i) {
242 // Radius: slightly concentrated toward center
243 double r = R_disk * std::pow(uni(rng), 0.65);
244 double theta = uni(rng) * 2.0 * M_PI;
245
246 // Mass: random uniform [0.3, 2.0]
247 double m = 0.3 + 1.7 * uni(rng);
248
249 // Tangential velocity: ~35% of circular velocity at this radius
250 // This gives highly elliptical orbits that cross each other
251 double vcirc = (r > 0.05) ? std::sqrt(G * M_total_est / r) : 0.0;
252 double vtang = vcirc * (0.25 + 0.20 * uni(rng)); // 25–45% of circular
253
254 // Small random perturbation so orbits have different phases
255 double vrand_x = (2.0*uni(rng) - 1.0) * 0.3;
256 double vrand_y = (2.0*uni(rng) - 1.0) * 0.3;
257
258 q[2*i] = r * std::cos(theta);
259 q[2*i+1] = r * std::sin(theta);
260 // Tangential direction (counterclockwise): (-sin θ, cos θ)
261 v[2*i] = -vtang * std::sin(theta) + vrand_x;
262 v[2*i+1] = vtang * std::cos(theta) + vrand_y;
263
264 bodies.push_back({ m, disp_r(m), mass_color(m), phys_r(m) });
265 }
266 }
267
268 E0 = total_energy();
269}
270
271} // namespace nbody
272
273// ── Renderer ──────────────────────────────────────────────────────────────────
274
275static constexpr int SW = 1000;
276static constexpr int SH = 720;
277static constexpr int PANEL_W = 240;
278static constexpr int SIM_W = SW - PANEL_W;
279static constexpr int MAX_TRAIL = 600;
280static constexpr float BASE_DT = 1.0f / 120.0f;
281
282struct Trail {
283 std::deque<Vector2> pts;
284 void push(float x, float y) {
285 pts.push_front({x, y});
286 if ((int)pts.size() > MAX_TRAIL) pts.pop_back();
287 }
288};
289
290static Vector2 sim_to_screen(double sx, double sy,
291 float cx, float cy, float scale) {
292 return { cx + static_cast<float>(sx) * scale,
293 cy - static_cast<float>(sy) * scale };
294}
295
296static Color unpack_color(uint32_t c) {
297 return { uint8_t(c >> 24), uint8_t(c >> 16), uint8_t(c >> 8), uint8_t(c) };
298}
299
300static float scene_scale(nbody::Scenario s) {
301 switch (s) {
302 case nbody::Scenario::Figure8: return 240.0f;
303 case nbody::Scenario::SolarSystem: return 240.0f;
304 case nbody::Scenario::BinaryPlus: return 200.0f;
305 case nbody::Scenario::Galaxy: return 130.0f;
306 default: return 200.0f;
307 }
308}
309
310int main() {
311 SetConfigFlags(FLAG_MSAA_4X_HINT | FLAG_VSYNC_HINT);
312 InitWindow(SW, SH, "N-Body Gravitational Simulation");
313 SetTargetFPS(120);
314
315 nbody::NBodySim sim;
317 sim.reset(current);
318
319 std::vector<Trail> trails(sim.n());
320
321 bool paused = false;
322 bool trail_fade = true;
323 int speed_mult = 4;
324 float scale = scene_scale(current);
325 float cx = SIM_W * 0.5f;
326 float cy = SH * 0.5f;
327
328 std::deque<float> energy_hist;
329 constexpr int ENERGY_HIST = 400;
330
331 while (!WindowShouldClose()) {
332 // ── Input ─────────────────────────────────────────────────────────────
333 auto switch_scenario = [&](nbody::Scenario s) {
334 current = s;
335 sim.reset(s);
336 trails.assign(sim.n(), Trail{});
337 scale = scene_scale(s);
338 energy_hist.clear();
339 };
340
341 if (IsKeyPressed(KEY_ONE)) switch_scenario(nbody::Scenario::Figure8);
342 if (IsKeyPressed(KEY_TWO)) switch_scenario(nbody::Scenario::SolarSystem);
343 if (IsKeyPressed(KEY_THREE)) switch_scenario(nbody::Scenario::BinaryPlus);
344 if (IsKeyPressed(KEY_FOUR)) switch_scenario(nbody::Scenario::Galaxy);
345 if (IsKeyPressed(KEY_R)) switch_scenario(current);
346 if (IsKeyPressed(KEY_SPACE)) paused = !paused;
347 if (IsKeyPressed(KEY_V)) {
348 sim.use_verlet = !sim.use_verlet;
349 sim.reset(current);
350 trails.assign(sim.n(), Trail{});
351 energy_hist.clear();
352 }
353 if (IsKeyPressed(KEY_F)) trail_fade = !trail_fade;
354 if (IsKeyPressed(KEY_EQUAL) || IsKeyPressed(KEY_KP_ADD))
355 speed_mult = std::min(speed_mult + 1, 16);
356 if (IsKeyPressed(KEY_MINUS) || IsKeyPressed(KEY_KP_SUBTRACT))
357 speed_mult = std::max(speed_mult - 1, 1);
358
359 // ── Simulate ──────────────────────────────────────────────────────────
360 if (!paused) {
361 double dt = static_cast<double>(BASE_DT) / speed_mult;
362 int inner = std::max(1, speed_mult);
363
364 for (int s = 0; s < inner; ++s) {
365 sim.step(dt);
366
367 // Merge detection — mirror every swap-and-pop on the trails vector
368 if (sim.enable_merges) {
369 auto ops = sim.check_merges();
370 for (auto& [j, last_] : ops) {
371 if (j != last_) std::swap(trails[j], trails[last_]);
372 trails.pop_back();
373 }
374 }
375 }
376
377 // Record trail positions
378 for (int i = 0; i < sim.n(); ++i) {
379 auto sc = sim_to_screen(sim.q[2*i], sim.q[2*i+1], cx, cy, scale);
380 if (sc.x > 0 && sc.x < SIM_W && sc.y > 0 && sc.y < SH)
381 trails[i].push(sc.x, sc.y);
382 }
383
384 float drift = static_cast<float>(sim.energy_drift() * 100.0);
385 energy_hist.push_front(drift);
386 if ((int)energy_hist.size() > ENERGY_HIST) energy_hist.pop_back();
387 }
388
389 // ── Draw ──────────────────────────────────────────────────────────────
390 BeginDrawing();
391 ClearBackground({13, 17, 23, 255});
392
393 BeginScissorMode(0, 0, SIM_W, SH);
394
395 // Trails
396 for (int i = 0; i < sim.n(); ++i) {
397 const auto& tr = trails[i];
398 Color col = unpack_color(sim.bodies[i].color);
399 int len = static_cast<int>(tr.pts.size());
400 for (int k = 0; k + 1 < len; ++k) {
401 if (trail_fade) {
402 float alpha = 1.0f - static_cast<float>(k) / MAX_TRAIL;
403 col.a = static_cast<uint8_t>(alpha * alpha * 200);
404 }
405 DrawLineV(tr.pts[k], tr.pts[k+1], col);
406 }
407 }
408
409 // Bodies
410 for (int i = 0; i < sim.n(); ++i) {
411 auto sc = sim_to_screen(sim.q[2*i], sim.q[2*i+1], cx, cy, scale);
412 Color col = unpack_color(sim.bodies[i].color);
413 float r = sim.bodies[i].display_radius;
414 DrawCircleV(sc, r * 2.2f, {col.r, col.g, col.b, 40});
415 DrawCircleV(sc, r * 1.4f, {col.r, col.g, col.b, 100});
416 DrawCircleV(sc, r, col);
417 }
418
419 EndScissorMode();
420
421 DrawRectangle(SIM_W, 0, 1, SH, {48, 54, 61, 255});
422
423 // ── Info panel ────────────────────────────────────────────────────────
424 const int PX = SIM_W + 14;
425 int py = 18;
426 char buf[64];
427
428 auto label = [&](const char* txt, Color c = {139,148,158,255}) {
429 DrawText(txt, PX, py, 13, c);
430 py += 18;
431 };
432 auto value = [&](const char* key, const char* val) {
433 DrawText(key, PX, py, 13, {139,148,158,255});
434 DrawText(val, PX + 90, py, 13, {201,209,217,255});
435 py += 18;
436 };
437
438 DrawText(scenario_name(current), PX, py, 14, {88,166,255,255});
439 py += 22;
440 DrawLine(PX, py, SW-8, py, {48,54,61,255}); py += 10;
441
442 snprintf(buf, sizeof(buf), "%.2f", sim.t);
443 value("time", buf);
444 snprintf(buf, sizeof(buf), "%dx", speed_mult);
445 value("speed", buf);
446 value("integrator", sim.use_verlet ? "Verlet (symp)" : "RK4");
447 snprintf(buf, sizeof(buf), "%d", sim.n());
448 value("bodies", buf);
449 py += 6;
450
451 // Energy drift (skip for Galaxy — merges make it meaningless)
452 DrawLine(PX, py, SW-8, py, {48,54,61,255}); py += 10;
453 if (!sim.enable_merges) {
454 label("Energy drift (%)");
455 snprintf(buf, sizeof(buf), "%+.2e", sim.energy_drift() * 100.0);
456 Color drift_col = std::abs(sim.energy_drift()) < 1e-4
457 ? Color{63,185,80,255}
458 : std::abs(sim.energy_drift()) < 1e-2
459 ? Color{255,166,64,255}
460 : Color{248,81,73,255};
461 DrawText(buf, PX, py, 14, drift_col);
462 py += 22;
463
464 if (!energy_hist.empty()) {
465 float plot_h = 70.0f;
466 float plot_w = static_cast<float>(PANEL_W - 28);
467 float plot_x = static_cast<float>(PX);
468 float plot_y = static_cast<float>(py);
469 DrawRectangle((int)plot_x, (int)plot_y, (int)plot_w, (int)plot_h, {22,27,34,255});
470 DrawRectangleLines((int)plot_x, (int)plot_y, (int)plot_w, (int)plot_h, {48,54,61,255});
471
472 float max_abs = 0.01f;
473 for (float vv : energy_hist) max_abs = std::max(max_abs, std::abs(vv));
474 max_abs *= 1.2f;
475
476 int sz = static_cast<int>(energy_hist.size());
477 for (int k = 0; k + 1 < sz && k + 1 < (int)plot_w; ++k) {
478 float x0 = plot_x + plot_w - k - 1;
479 float x1 = plot_x + plot_w - k - 2;
480 float y0 = plot_y + plot_h*0.5f - energy_hist[k] / max_abs * (plot_h*0.45f);
481 float y1 = plot_y + plot_h*0.5f - energy_hist[k+1] / max_abs * (plot_h*0.45f);
482 DrawLineV({x0,y0}, {x1,y1}, drift_col);
483 }
484 DrawLine((int)plot_x, (int)(plot_y+plot_h*0.5f),
485 (int)(plot_x+plot_w), (int)(plot_y+plot_h*0.5f), {48,54,61,255});
486 py += static_cast<int>(plot_h) + 10;
487 }
488 } else {
489 // Galaxy: show total mass instead of energy drift
490 double total_m = 0.0;
491 for (int i = 0; i < sim.n(); ++i) total_m += sim.bodies[i].mass;
492 label("Merging simulation");
493 snprintf(buf, sizeof(buf), "%.1f", total_m);
494 value("total mass", buf);
495 if (sim.n() == 1) {
496 label("** Fully collapsed! **", {255, 166, 64, 255});
497 }
498 }
499
500 // Controls
501 py += 4;
502 DrawLine(PX, py, SW-8, py, {48,54,61,255}); py += 10;
503 label("Controls", {88,166,255,255});
504 label("1 Figure-8");
505 label("2 Solar system");
506 label("3 Binary+");
507 label("4 Galaxy collapse");
508 label("V Verlet/RK4");
509 label("SPACE pause");
510 label("R reset");
511 label("+/- speed");
512 label("F trail fade");
513
514 if (paused) {
515 DrawRectangle(SIM_W/2 - 50, SH/2 - 16, 100, 32, {0,0,0,160});
516 DrawText("PAUSED", SIM_W/2 - 38, SH/2 - 10, 20, {201,209,217,255});
517 }
518
519 DrawFPS(8, 8);
520 EndDrawing();
521 }
522
523 CloseWindow();
524 return 0;
525}
int main()
Definition main.cpp:84
Vector2 sim_to_screen(float sx, float sy)
Definition main.cpp:31
Definition main.cpp:27
Scenario
Definition nbody.hpp:33
@ BinaryPlus
Equal-mass binary + one test particle on a wide orbit.
@ Galaxy
Random N bodies — gravity only, bodies merge on contact.
@ Figure8
Chenciner-Montgomery figure-8 choreography (3 equal masses)
@ SolarSystem
Sun + 4 planets on circular Keplerian orbits.
void scale(real *v, idx n, real alpha)
v = alpha * v
double real
Definition types.hpp:10
BasicVector< real > Vector
Real-valued dense vector with full backend dispatch (CPU + GPU)
Definition vector.hpp:122
SymplecticResult ode_verlet(AccelFn accel, Vector q0, Vector v0, real t0, real t1, real h, SymplecticCallback on_step=nullptr)
Definition ode.cpp:154
ODEResult ode_rk4(ODERhsFn f, Vector y0, real t0, real t1, real h, StepCallback on_step=nullptr)
Classic 4th-order Runge-Kutta (fixed step).
Definition ode.cpp:33
Gravitational N-body simulation using num::ode_verlet / num::ode_rk4.
std::deque< Vector2 > pts
Definition main.cpp:283
void push(float x, float y)
Definition main.cpp:284
double kinetic_energy() const
Definition main.cpp:70
double potential_energy() const
Definition main.cpp:77
void step(double dt)
Advance by exactly one time step dt.
Definition main.cpp:89
num::Vector q
Positions: [x0,y0, x1,y1, ...].
Definition nbody.hpp:54
int n() const
Definition nbody.hpp:75
bool use_verlet
true = Verlet (symplectic), false = RK4
Definition nbody.hpp:61
void make_accel(const num::Vector &pos, num::Vector &acc) const
Fill acc from current positions (gravitational acceleration on each body).
Definition main.cpp:53
void reset(Scenario s)
Reset to a preset scenario.
Definition main.cpp:165
double total_energy() const
Definition nbody.hpp:79
num::Vector v
Velocities: [vx0,vy0, vx1,vy1, ...].
Definition nbody.hpp:55
std::vector< std::pair< int, int > > check_merges()
Definition main.cpp:118
double energy_drift() const
Relative energy drift since reset: (E - E0) / |E0|.
Definition nbody.hpp:82
std::vector< Body > bodies
Definition nbody.hpp:53
double E0
Initial total energy (for drift tracking)
Definition nbody.hpp:58
double soft
Softening length (avoids singularities)
Definition nbody.hpp:59
bool enable_merges
true = check_merges() is active (Galaxy scenario)
Definition nbody.hpp:62