An n-body simulation that was inspired by this example: http://physics.princeton.edu/~fpretori/Nbody/code.htm. Calculates force - and changes in position - on multiple bodies in one or more threads, and renders them in a graphics engine in a separate thread. JMonkeyEngine (JME) is used as the graphics engine. The simulation implements elastic collisions in 3D thanks to: https://www.plasmaphysics.org.uk/programs/coll3d_cpp.htm. It supports different behaviors for bodies in the simulation when they collide:
- Subsume - larger radius bodies absorb smaller radius bodies
- Elastic Collision - bodies bounce off each other (thank you plasmaphysics.org.uk!)
- Fragment - bodies fragment into smaller bodies based on force of impact
- None - bodies pass through each other
This has been tested on a 12 core Ubuntu 18.04.3 LTS desktop with an integrated Intel graphics card. With this configuration, about 2000 bodies can be run with the JME frame rate running in the 50's. More bodies (approaching the 3000's) will start to slow the simulation. The number of bodies the sim can compute directly relates to the number of cores, and CPU speed. JMonkey is more influenced, obviously, by the number of bodies it has to render.
Key points:
- In the diagram, the green items were developed as part of this project, and the blue items are components integrated into this project.
- A body queue holds all bodies in the simulation. A thread pool continually computes force, position, and collisions on the body queue.
- Once each compute cycle is complete, the body info is copied into a render queue. The JMonkey Engine renders the render queue in its own thread while the compute thread pool runs another compute cycle on the body queue in parallel.
- The user can interact with the simulation using a gRPC Java client that talks to a gRPC server. This is a command line interface that allows the user to add/remove bodies, change simulation characteristics, and so on while the simulation is running. A simple shell script is provided to wrap the gRPC client.
- Instrumentation is provided that integrates with Prometheus and Grafana to provide instrumentation on simulation characteristics like thread interaction, etc. So using the gRPC client, you can increase/decrease threads and see the impact on performance, and so on. See https://prometheus.io/ and https://grafana.com/ for more information on those components.
- The
additional/scripts
directory provides a Bash script (start-containers
) to start Prometheus and Grafana in Docker containers with configurations that automatically integrate with the simulation. Details are discussed further on down.
This is a multi-module Maven project that produces two fat jars with dependencies:
- server/target/server.jar is the simulation runner. You start it like:
java -jar server/target/server.jar
- client/target/client.jar is the gRPC client. You run it like:
java -jar client/target/client.jar
with args to communicate with the simulation. Or, you can use theadditional/scripts/nbcli
Bash script after you tweak it for your environment.
The version of Java that I have tested with is 17.0.12:
$ java --version
openjdk 17.0.12 2024-07-16
OpenJDK Runtime Environment (build 17.0.12+7-Ubuntu-1ubuntu222.04)
OpenJDK 64-Bit Server VM (build 17.0.12+7-Ubuntu-1ubuntu222.04, mixed mode, sharing)
So to build and run:
$ mvn package
$ java -jar server/target/server.jar&
The above command (no args) runs the default canned simulation, which starts with four spherical clusters of bodies orbiting a sun. There are five canned sims, controlled via the --sim-name
arg: --sim-name=sim1
(the default) through --sim-name=sim5
. The default number of bodies is 1000. You can also do: --sim-name=empty
for an empty sim and then use the gRPC client to add bodies (more on that below.) Finally you can do --csv=/path/to/a/file.csv
to load the sim from a CSV (more on that below).
With JME (I think due to the underlying libraries) there appear to be two options with regard to screen resolution: Option 1 supports full screen but you can't detach/attach the mouse and keyboard. Option 2 supports a windowed display - which is what I use - but then you can't resize the window once it is created (or, I have not figured out how to do so.)
When you run the sim it takes control of the mouse and keyboard. F12 disengages the sim from the mouse and keyboard. So you can use other windows. To give the sim the mouse and the keyboard back, click on the sim window and press F12 and the mouse pointer will disappear - indicating that JMonkey again owns the mouse and keyboard. The sim is defaulted to run with five threads computing the body queue. You can override that and many other settings using command line options and params. These are all documented further on in the README.
While JME has the mouse and keyboard, the following controls are active:
Control | Meaning |
---|---|
W | Cam Forward |
A | " Left |
S | " Back |
D | " Right |
Q | " Up |
Z | " Down |
Mouse | Look |
keypad+ | Increase cam speed |
keypad- | Decrease cam speed |
F12 | Unbind/bind keyboard from/to the sim window |
ESC | Exit simulation |
The following options are supported by the server jar. Most take short- and long-form:
Option | Meaning |
---|---|
‑z,‑‑resolution | The default screen resolution is 2560x1405 because that's what happens to work for my configuration. You override it with this option: --resolution=2000x1000 or whatever value works for your configuration. |
‑‑vsync | Tells JMonkeyEngine to render in sync with the monitor vertical sync. Results in smoother rendering but overrides frame rate. True by default so override with ‑‑vsync=false . |
‑‑frame-rate | Specifies the frame rate. Ignored unless vsync is set to false (or omitted.) If vsync is false and frame rate is not specified then the frame rate is whatever JME selects. |
‑r,‑‑no-render | A testing/debugging option. Turns off rendering in JME. Useful for debugging the computation runner and performance testing the body computation code independent of rendering. |
‑n,‑‑sim‑name | Runs one of the built-in simulations. sim1 starts four clusters of bodies around a sun. The clusters are captured into orbit. sim2 generates a sun at 0,0,0 and a cluster of bodies off-screen headed for a very close pass around with the sun at high velocity. Typically, a few bodies are captured by the sun but most travel away. sim3 generates a simulation with a sun far removed from the focus area just to serve as light source. Then it creates two clusters composed of many colliding spheres in close proximity. The two clusters exert gravitational attraction toward each other as if they were solids. They also exert gravitational force within themselves, preserving their spherical shape. The two clusters orbit and then collide, merging into a single cluster of colliding spheres. sim5 generates a sun far removed from the focus area just to serve as light source, then creates a large planet at the center of the sim orbited by three moons. It then creates a small impactor headed for the large planet. The impactor is configured to fragment into many smaller bodies on impact. |
‑a,‑‑sim‑args | Some built-in sims can take additional configuration options but - you'll have to check the code out for that. |
‑c,‑‑collision | Specifies the default collision resolution. Collision resolution can be specified as bodies are loaded from a CSV to start a new sim (see below) or individually as bodies are injected into the sim via gRPC. When bodies are added from CSV or gRPC if no collision resolution is specified then this value is used. ELASTIC is the default, meaning bodies bounce off each other. |
‑b,‑‑bodies | The number of bodies. The default is 1000. Your processing capacity will determine the number of bodies that can run in the sim with a smooth frame rate. |
‑t,‑‑threads | The number of threads for the body queue computation. Since each body's force is the product of each other body in the queue, the more threads, the more bodies you can support. JMonkey runs in its own thread and so this setting does not affect JMonkey. Body computation is purely CPU-bound so this value shouldn't exceed the number of available cores on your system. |
‑m,‑‑scaling | A time scale applied to force and velocity computation. The default value is .000000001F. It is a multiplier so the smaller the number the slower the simulation runs. |
‑f,‑‑csv | Runs a simulation by loading bodies from a csv. The format is documented later on in this README. |
‑l,‑‑body‑color | Only pertains to canned sims and CSV-loaded sims. Overrides body colors defined in the simulation. Some canned sims ignore this. |
‑i,‑‑initial‑cam | Sets the initial camera position. The default is x=-100, y=300, z=1200. |
Once you start the simulation, you can interact with it using the gRPC client. The client communicates with a server component in the simulation to alter the sim.
You invoke the client in one of two ways: either using the provided shell script: additional/scripts/nbcli [command] [options]
, or by directly running the client jar: java -jar client/target/client.jar [command] [options]
. Whether you use shell or the jar, the commands and options are identical:
Command | Options and effect | Example (using the shell script) |
---|---|---|
set‑threads | Sets the number of threads allocated to the body queue computation runner that calcs force from gravity, updates velocity, and resolves collisions. The default is 5 | nbcli set-threads 8 |
set‑queue‑size | Primarily for testing. The simulation has the ability to compute "n" cycles ahead of the JME engine and enqueue the computed results but the size of the compute-ahead queue is limited by this value. The default is 10 | nbcli set-queue-size 100 |
set‑time‑scale | Sets the time scale that is used to calc force and velocity change. The default value is .000000001F. It's a multiplier so the smaller the number the slower the sim runs | nbcli set-time-scale .0000000005F . Since this is a smaller number than the default it will have the effect of slowing the simulation |
set‑restitution | Sets the coefficient of restitution for elastic collisions. The default is one, meaning each collision is perfectly elastic. The setting applies to all bodies in the simulation. Values less than one cause collisions to become less energetic. Values larger than one cause more energetic collisions | nbcli set-restitution .6 This will cause all body elastic collisions to have less energy and the bodies to move away from each other less after collision |
remove‑bodies | Removes approximately the specified number of bodies from the simulation. Pinned bodies are not removed. Specifying -1 removes all bodies, including pinned bodies. It's a way of resetting the sim back to empty before adding new bodies. | nbcli remove-bodies 100 Removes 100 bodies randomly from the sim, skipping bodies marked as pinned (more on that below.) |
mod‑body | Modifies properties of a body during the sim. E.g. changing the mass, radius, etc. | nbcli mod-body id=123 ... (more in this below) |
mod‑bodies | Modifies multiple bodies. More below | nbcli mod-bodies class=asteroid ... (more in this below) |
get‑config | Displays sim configuration values: restitution coefficient, number of bodies, threads in the computation runner thread pool, etc. | nbcli get-config |
add‑body | Adds a body. More below | nbcli add-body ... (more on this below) |
get‑body | Gets information about a body and displays it to the console. You can provide an id or a name | nbcli get-body id=123 or nbcli get-body name=the-sun |
add‑bodies | Adds bodies. More below | nbcli add-bodies ... (more on this below) |
The add-body
command takes the eight mandatory params. That means that you provide the params in order, specifying only the params values separated by spaces: x y z vx vy vz mass radius
Example: nbcli add-body 10 10 10 10000000 10000000 10000000 10E21 30
This command adds a body at x,y,z of 10,10,10 with vx,vy,vz of 10000000,10000000,10000000 having a mass of 10E21 and a radius of 30.
Additional params are supported in different forms:
Boolean params mean "true" if present and false if absent. Booleans are: is-sun
, telemetry
, and pinned
. Example:
nbcli add-body 10 10 10 10000000 10000000 10000000 10E21 30 is-sun telemetry pinned
This adds a body just like the prior example, but sets it as a sun, turns on telemetry, and marks it as pinned.
The other args supported by the add-body
command require parameter values: collision
, color
, frag-factor
, frag-step
, class
, and name
. These are documented in the table below, along with all the params discussed above:
Option | Meaning | Example |
---|---|---|
x y z | Initial x, y, z position. Refer to this link for the JMonkeyEngine coordinate system: https://wiki.jmonkeyengine.org/jme3/the_scene_graph.html | see above |
vx vy vz | Initial velocity in x, y, z | see above |
mass | Mass. Generally, avoid masses greater than E30. Below, you will find references to existing sim examples that you can model | see above |
radius | The radius. A radius of .5 is very small. A radius of 150 is rather large | see above |
is-sun | Means the body is a sun. A sun is a light source. You need at least one sun in every sim to serve as a light source otherwise the entire sim will be black! | see above |
telemetry | Emanates detailed info on a body to the console on each compute system. Produces large volumes of info. Primarily for debugging | see above |
pinned | Means the body won't be deleted by the remove-bodies command unless a the command is provided with a value of -1. E.g. nbcli remove-bodies -1 will remove even pinned bodies, but nbcli remove-bodies 1000 will not |
see above |
collision | Defines the collision behavior. Values are (in any case): elastic , fragment , subsume , none . Elastic collisions result in bodies bouncing off each other. Fragment causes a body to fragment into pieces on collision. Subsume means that a body with a larger radius will absorb a body with a smaller radius - even a more massive body - on impact. And - none means no collision - bodies pass through each other. If not specified, the default is elastic |
collision=fragment |
color | Defines the body color. Values are (in any case): random, black, white, darkgray, gray, lightgray, red, green, blue, yellow, magenta, cyan, orange, brown, pink . Color is ignored if is-sun is specified |
color=red |
frag‑factor | Only meaningful if collision=fragment . Defines a hardness. If two bodies of equal mass approaching at exactly opposite velocities collide in an elastic collision, they depart in exactly opposite velocity. This would be a frag factor of 1. So to make a body more likely to fragment, set the factor to a smaller number like .5, .1, etc. Note that a large fast moving massive body colliding with a small, slow, less massive body can dramatically alter the smaller body's velocity so - this is just a general way to simulate a body being shattered by an impact |
frag-factor=.1 |
frag‑step | Only meaningful if collision=fragment. When a body is configured for fragmentation, this parameter participates in the calculation of the number of fragments to generate. Values from 10 to 1000 cause a fewer or greater number of fragments to be generated. Be aware that a large number of fragments can swamp the simulation | frag-step=500 |
class | Used to group bodies together for subsequent modification | class=asteroid |
name | Names a body for subsequent modification | name=earth |
Examples:
This adds a sun that will absorb every smaller body that impacts it
nbcli add-body 0 0 0 0 0 0 1E20 30 is-sun telemetry pinned collision=subsume
This adds a body that will bounce off other bodies configured for elastic collision:
nbcli add-body 100 100 100 10000000 10000000 10000000 1E3 3 collision=elastic color=blue
This adds a body configured to fragment on collision:
nbcli add-body 600 0 -600 -550000000 0 550000000 9E12 30 color=yellow collision=fragment frag-factor=.5 frag-step=400
Each time you add a body, the ID of the created body is reported back to the console.
The add-bodies
command adds multiple bodies at one time. It takes all the same params as add-body
plus the following:
Option | Meaning | Example |
---|---|---|
qty | The number of bodies to add | qty=100 |
delay | A delay (in seconds) between the addition of each body | delay=.5 |
posrand | Randomizes starting x,y,z from zero to the specified value | posrand=10 |
vrand | Randomizes velocity | vrand=10000 |
massrand | Randomizes mass | massrand=1E4 |
rrand | Randomizes radius | rrand=20 |
The following command adds 100 bodies that will bounce off other bodies configured for elastic collision with a tenth of a second delay between the addition of each body:
nbcli add-bodies 100 100 100 10000000 10000000 10000000 1E3 3 collision=elastic color=blue qty=100 delay=.1
For each added body, the ID of the created body is reported back to the console. The additional/sims
directory contains a few examples of creating n-body simulations using the command line to add bodies to an empty sim.
Sometimes it is interesting to change the properties of a body and observe the effect on the simulation. The mod-body
command does this: nbcli mod-body id=42 mass=12E29
. Or: nbcli mod-body name=jupiter radius=150
The following can be provided to mod-body
to select the body to modify:
Option | Meaning | Example |
---|---|---|
id | The ID of the object | nbcli mod-body id=123 |
name | The name of the object - you had to have specified that when you created it | nbcli mod-body name=foo |
Then you can specify all the same params to modify that you originally specified when you created the body, in param=value
form. E.g.: nbcli mod-body name=foo radius=100 vz=-112312312 color=blue collision=elastic
. The only thing you cannot do is change a sun to a non-sun or vice versa. There is one additional param: exists=false
which removes the body from the simulation
The mod-bodies
command is the same as the mod-body
command except that if you specify class=someclass
then all the bodies of that class will receive the modification. Example:
nbcli mod-body class=asteroid exists=false
This example removes all the bodies from the simulation that were assigned the asteroid
class when they were initially created.
You can run a simulation populated from a CSV this way:
$ java -jar server/target/server.jar --csv=/full/path/to/file.csv
The format of the CSV is:
x,y,z,vx,vy,vz,mass,radius,is_sun,collision_behavior,color,fragmentation_factor,fragmentation_step
The following fields are required: x, y, z, vx, vy, vz, mass, radius
. The remaining fields (is_sun, collision_behavior, color, fragmentation_factor,fragmentation_step
) are optional and can be completely omitted, or specified with empty comma-delimited values - but the order must be exactly as shown above.
In other words - the header isn't parsed - it's just documentation for you. The CSV loader is really simply and expects things in ordinal position. If the first row is a header it is ignored. If any row cannot be parsed, it is echoed to the console and ignored.
Here is an example CSV that works:
x, y, z, vx, vy, vz, mass, radius, is_sun
0, 3, -3, 9, 2, 0,5.96E+30, 20, t
-146,-215,-328,-497827312,392344240,403464000,2.93E+12,0.421431
-269,-160,-442,-497827312,392344240,403464000,2.93E+12,0.421431
The CSV can be compact (meaning without spaces). The example above is column-aligned for clarity of documentation. The "is-sun" column can be anything that evaluates to a boolean. If omitted or blank, the body is not a sun. There are a few CSVs in the "additional" directory (see below)
The additional
directory contains the following sub-directories that have various examples and other features:
Sub-directory | Contents |
---|---|
csvs | Contains a handful of CSVs as examples E.g.: java -jar server/target/server.jar --csv=additional/csvs/single-clump.csv |
grafana | Contains Grafana assets that are mounted into a Grafana Docker container by the start-containers script in the scripts directory. Prometheus/Grafana integration is discussed in more detail below |
grpc-cli-examples | Contains a few examples of how to create simulations using something called grpc_cli which is a general purpose gRPC CLI tool that also works with the simulation in addition to the Java client. See https://github.com/grpc/grpc/blob/master/doc/command_line_tool.md |
images | Misc. images linked by this README |
original_cpp | This is the original C++ elastic collision code from plasmaphysics.org.uk that the elastic collision in this n-body sim incorporates. Just in case the link were ever to go away |
prometheus | Has a Prometheus scrape config that is mounted into a Prometheus Docker container by the start-containers script in the scripts directory. |
scripts | Contains the script that spins up the Prometheus/Grafana Docker containers, as well as a Bash script (nbcli ) to wrap the gRPC client JAR. The idea for the client JAR wrapper script is: you edit into the script the location of your client JAR, and put the script in your PATH. Then you can run gRPC client commands more succinctly: nbcli ... |
sims | Contains a few examples of how to create simulations using the Java gRPC client that is provided as part of this project |
An an exercise to get more familiar with Grafana and Prometheus, I implemented support for instrumentation. The instrumentation
package contains an InstrumentationManager
that is intended to be used like the Log4j LogManager. You get an instance of the instrumentation manager singleton, and register a counter:
private static final Metric metricComputationCount = InstrumentationManager.getInstrumentation()
.registerLabeledCounter("nbody_computation_count/thread", "runner", "Simulation cycles");
Then, in a place in your code where you want to gather metrics:
metricComputationCount.incValue();
The three Prometheus metric types are supported: counter, summary, and gauge. The default implementation for instrumentation is a NOP implementation. So you can leave the instrumentation calls in your code while incurring a very minor overhead. My belief is - even in something like the body queue computation runner that is running 60 cycles per second, the overhead of the NOP implementation is low. Then, when you want to enable instrumentation, you run the server with a JVM property defined indicating the instrumentation class you want to load. The instrumentation
package contains a class PrometheusInstrumentation
. So to enable instrumentation using the included Prometheus instrumentation, you run the server this way:
$ java \
-Dorg.ericace.instrumentation.class=org.ericace.instrumentation.PrometheusInstrumentation \
-jar server/target/server.jar&
The Prometheus implementation integrates with the Prometheus Java libraries and runs an HTTP server that exposes metrics consumable by Prometheus on port 12345 (this is hard-coded). If you run with the Prometheus implementation, you can see the metrics using: watch curl -s http://localhost:12345/metrics
. Of course, this isn't all that interesting. That's where Grafana comes in. After you run the server as described above, you run another script: additional/scripts/start-containers
. First you will need to edit a path in the script that matches where you cloned the git repo. When you run this script it starts two containers and mounts the configs needed by both Prometheus and Grafana to present the metrics exposed by the n-body sim:
$ additional/scripts/start-containers
$ docker ps
CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES
95b3e7122709 grafana/grafana "/run.sh" Up 7 minutes grafana
7a9cab3d2931 prom/prometheus "/bin/prometheus --c…" Up 7 minutes prometheus
After the the sim and the containers are running, view the dashboard at http://localhost:3000. Make sure your browser doesn't have tracking protection, or has disabled cookies etc. Log in as root/secret and you should see the n-body dashboard:
As you use the gRPC client to interact with the sim you can observe the impact on the simulation runtime. When you're done, just stop the Docker containers: docker stop grafana prometheus
.
As mentioned earlier this is a multi-module Maven project. The modules are:
Module | Purpose |
---|---|
client | Provides a gRPC client in Java. This is packaged into the client.jar with dependencies |
grpc | This module contains the protobuf definition of the gRPC interface in grpc/src/main/resources/nbodyservice.proto . The module also includes the protobuf-maven-plugin from https://www.xolstice.org/protobuf-maven-plugin . The plugin runs the protobuf compiler - which you have to install yourself - resulting in the generation of Java code to support the gRPC interface. On Ubuntu, installing the protobuf compiler was as simple as sudo apt install protobuf-compiler followed by which protoc . The protoc compiler is then referenced in the maven plugin using the <protocExecutable>/usr/bin/protoc</protocExecutable> element. All of the Java in this project is generated by the protobuf compiler on each build. So the workflow is - decide how you you want the gRPC interface to change - then change the nbodyservice.proto file to implement those changes - rebuild to regenerate the Java classes - then make the related changes in both the client and server modules using the generated Java in the grpc module to implement the functionality |
server | The server runs the simulation, supports loading CSVs, parsing command line args, runs canned sims, includes a gRPC Server implementation for adding bodies and modifying sim characteristics, Integrates with JMonkey, runs the body computation thread pood, and exposes the Prometheus metrics. It is packaged in the fat server.jar module with dependencies - including the grpc module |
shared | Just some shared globals packaged in with the client and the server jars |
Most of the interesting code is in the server module. The module contains four packages:
Package | Purpose |
---|---|
grpcserver | This package implements the gRPC server. It manages the RPC connection and delegates all the actual sim changes to the sim package |
instrumentation | Implements NOP - and Prometheus - instrumentation. Integrates and wraps the Prometheus libs. Exposes the metrics on HTTP port 12345 |
nbody | Encapsulates calculating each body's force, velocity, position, and collision once each sim cycle in a nested loop: for each body - for each other body - calc force and collision |
sim | Contains the Main class for arg parsing, a class for loading CSVs and generating canned sims, and the NBodySim class which is the main sim runner - instantiating and starting all other classes and cleaning up on exit |
Class | Purpose |
---|---|
Body | Holds all the logic for force and velocity computation, collision resolution, and fragmentation |
BodyMod | Encapsulates modifications to bodies in the sim on behalf of the gRPC server |
ComputationRunner | Runs the body queue computation using a thread pool. Each body is scheduled into the pool and has access to each other body - when a body needs to modify a body other than itself it uses a dual tryLock mechanism. Places computed results into the ResultQueueHolder for JME |
JMEApp | Subclasses the JMonkeyEngine SimpleApplication - renders the simulation from the computation results in the ResultQueueHolder |
ResultQueueHolder | Used by the compution runner to feed the JMonkeyEngine so JMonkey can render - and the body computation can run - in parallel without thread contention |
SimpleVector | A basic 3d vector class that also includes some interesting methods that were scavenged online for generating clusters of bodies used by the sim generators. Attribution in the source code |
Class | Purpose |
---|---|
NBodyServiceServer | Serves the gRPC endpoint - parses the requests, delegates to the NBodySim class in the sim package, and handles the communication channel as reqruired by gRPC |
Class | Purpose |
---|---|
InstrumentationManager | Singleton. Inspects the JVM for a defined property and uses it to instantiate the instrumentation class defined by the property. If no property is defined, instantiates the NoopInstrumentation class which does nothing |
NoopInstrumentation | When called to tally metrics, does nothing |
PrometheusInstrumentation | Integrates with the Prometheus Java libs, gathers metrics and exposes them on port 12345 for Prometheus to scrape |
Class | Purpose |
---|---|
Main | Parses command line args, and delegates to the SimGenerator class to create simulations. Then transfers control to the NBodySim class to actually run the sim |
NBodySim | Sim runner. Starts all threads, handles requests from the gRPC server to modify the simulation or report configuration, shuts everything down when the sim is over (when user presses ESC while JME has the keyboard) |
SimGenerator | Generates one of five canned sims, or loads a sim from a CSV |
- Windows testing - currently have tested exclusively on Ubuntu. Will require porting the Bash scripts to DOS/PowerShell
- Install Geforce RTX and see what that offers in terms of performance on the rendering side
If you have questions, you can reach me at: ericace-at-protonmail-dot-com