Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

History

40 Commits

Repository files navigation

Java N-Body Simulation

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:

  1. Subsume - larger radius bodies absorb smaller radius bodies
  2. Elastic Collision - bodies bounce off each other (thank you plasmaphysics.org.uk!)
  3. Fragment - bodies fragment into smaller bodies based on force of impact
  4. 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.

Design:

Design

Key points:

  1. In the diagram, the green items were developed as part of this project, and the blue items are components integrated into this project.
  2. A body queue holds all bodies in the simulation. A thread pool continually computes force, position, and collisions on the body queue.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

  1. server/target/server.jar is the simulation runner. You start it like: java -jar server/target/server.jar
  2. 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 the additional/scripts/nbcli Bash script after you tweak it for your environment.

To run the application

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:

ControlMeaning
WCam Forward
A" Left
S" Back
D" Right
Q" Up
Z" Down
MouseLook
keypad+Increase cam speed
keypad-Decrease cam speed
F12Unbind/bind keyboard from/to the sim window
ESCExit simulation

Simulator command line options

The following options are supported by the server jar. Most take short- and long-form:

OptionMeaning
‑z,‑‑resolutionThe 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.
‑‑vsyncTells 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-rateSpecifies 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-renderA 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‑nameRuns 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‑argsSome built-in sims can take additional configuration options but - you'll have to check the code out for that.
‑c,‑‑collisionSpecifies 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,‑‑bodiesThe 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,‑‑threadsThe 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,‑‑scalingA 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,‑‑csvRuns a simulation by loading bodies from a csv. The format is documented later on in this README.
‑l,‑‑body‑colorOnly pertains to canned sims and CSV-loaded sims. Overrides body colors defined in the simulation. Some canned sims ignore this.
‑i,‑‑initial‑camSets the initial camera position. The default is x=-100, y=300, z=1200.

The gRPC client

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:

CommandOptions and effectExample (using the shell script)
set‑threadsSets the number of threads allocated to the body queue computation runner that calcs force from gravity, updates velocity, and resolves collisions. The default is 5nbcli set-threads 8
set‑queue‑sizePrimarily 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 10nbcli set-queue-size 100
set‑time‑scaleSets 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 runsnbcli set-time-scale .0000000005F.
Since this is a smaller number than the default it will have the effect of slowing the simulation
set‑restitutionSets 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 collisionsnbcli 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‑bodiesRemoves 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‑bodyModifies properties of a body during the sim. E.g. changing the mass, radius, etc.nbcli mod-body id=123 ... (more in this below)
mod‑bodiesModifies multiple bodies. More belownbcli mod-bodies class=asteroid ... (more in this below)
get‑configDisplays sim configuration values: restitution coefficient, number of bodies, threads in the computation runner thread pool, etc.nbcli get-config
add‑bodyAdds a body. More belownbcli add-body ... (more on this below)
get‑bodyGets information about a body and displays it to the console. You can provide an id or a namenbcli get-body id=123 or nbcli get-body name=the-sun
add‑bodiesAdds bodies. More belownbcli add-bodies ... (more on this below)

Adding and modifying bodies in the running simulation using the gRPC client

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:

OptionMeaningExample
x y zInitial x, y, z position. Refer to this link for the JMonkeyEngine coordinate system: https://wiki.jmonkeyengine.org/jme3/the_scene_graph.htmlsee above
vx vy vzInitial velocity in x, y, zsee above
massMass. Generally, avoid masses greater than E30. Below, you will find references to existing sim examples that you can modelsee above
radiusThe radius. A radius of .5 is very small. A radius of 150 is rather largesee above
is-sunMeans 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
telemetryEmanates detailed info on a body to the console on each compute system. Produces large volumes of info. Primarily for debuggingsee above
pinnedMeans 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 notsee above
collisionDefines 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 elasticcollision=fragment
colorDefines 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 specifiedcolor=red
frag‑factorOnly 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 impactfrag-factor=.1
frag‑stepOnly 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 simulationfrag-step=500
classUsed to group bodies together for subsequent modificationclass=asteroid
nameNames a body for subsequent modificationname=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

The add-bodies command adds multiple bodies at one time. It takes all the same params as add-body plus the following:

OptionMeaningExample
qtyThe number of bodies to addqty=100
delayA delay (in seconds) between the addition of each bodydelay=.5
posrandRandomizes starting x,y,z from zero to the specified valueposrand=10
vrandRandomizes velocityvrand=10000
massrandRandomizes massmassrand=1E4
rrandRandomizes radiusrrand=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.

The mod-body command

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:

OptionMeaningExample
idThe ID of the objectnbcli mod-body id=123
nameThe name of the object - you had to have specified that when you created itnbcli 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

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.

Loading a CSV

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 contents

The additional directory contains the following sub-directories that have various examples and other features:

Sub-directoryContents
csvsContains a handful of CSVs as examples E.g.: java -jar server/target/server.jar --csv=additional/csvs/single-clump.csv
grafanaContains 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-examplesContains 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
imagesMisc. images linked by this README
original_cppThis 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
prometheusHas a Prometheus scrape config that is mounted into a Prometheus Docker container by the start-containers script in the scripts directory.
scriptsContains 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 ...
simsContains a few examples of how to create simulations using the Java gRPC client that is provided as part of this project

Prometheus/Grafana integration

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:

privatestaticfinalMetricmetricComputationCount = 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:

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.

Developer Docs

Modules:

As mentioned earlier this is a multi-module Maven project. The modules are:

ModulePurpose
clientProvides a gRPC client in Java. This is packaged into the client.jar with dependencies
grpcThis 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
serverThe 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
sharedJust some shared globals packaged in with the client and the server jars

Key classes

Most of the interesting code is in the server module. The module contains four packages:

PackagePurpose
grpcserverThis package implements the gRPC server. It manages the RPC connection and delegates all the actual sim changes to the sim package
instrumentationImplements NOP - and Prometheus - instrumentation. Integrates and wraps the Prometheus libs. Exposes the metrics on HTTP port 12345
nbodyEncapsulates 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
simContains 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

Some interesting classes in the nbody package:

ClassPurpose
BodyHolds all the logic for force and velocity computation, collision resolution, and fragmentation
BodyModEncapsulates modifications to bodies in the sim on behalf of the gRPC server
ComputationRunnerRuns 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
JMEAppSubclasses the JMonkeyEngine SimpleApplication - renders the simulation from the computation results in the ResultQueueHolder
ResultQueueHolderUsed by the compution runner to feed the JMonkeyEngine so JMonkey can render - and the body computation can run - in parallel without thread contention
SimpleVectorA 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

Some interesting classes in the grpcserver package:

ClassPurpose
NBodyServiceServerServes the gRPC endpoint - parses the requests, delegates to the NBodySim class in the sim package, and handles the communication channel as reqruired by gRPC

Some interesting classes in the instrumentation package:

ClassPurpose
InstrumentationManagerSingleton. 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
NoopInstrumentationWhen called to tally metrics, does nothing
PrometheusInstrumentationIntegrates with the Prometheus Java libs, gathers metrics and exposes them on port 12345 for Prometheus to scrape

Some interesting classes in the sim package:

ClassPurpose
MainParses command line args, and delegates to the SimGenerator class to create simulations. Then transfers control to the NBodySim class to actually run the sim
NBodySimSim 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)
SimGeneratorGenerates one of five canned sims, or loads a sim from a CSV

Remaining TODOs

  • 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

Releases

Packages

Used by

Contributors

Languages