
A simple particle life simulator that simulates symmetric or asymmetric attraction and repulsion forces between different particle types inside a toroidal rectangle.
- Button to close or open option panel
- Matrix to customize particle type relations
- Scroll up or left click for higher values
- Scroll down or right click for lower values
- Middle click to reset to 0
- Button to apply all options and respawn all particles
- Button to apply all options (some options changes may remove or spawn particles anyway)
- Button to pause the simulation
- You can use a keyboard shortcut Ctrl+P
- Button to reset camera position to default
- Button to toggle window to fullscreen
- You can use a keyboard shortcut F11
-
Install dependencies by running
mvn dependency:resolve
in the root directory of the project (where this file is located). -
Compile and launch the program by running
mvn javafx:run
ormvn clean javafx:run
in case you want to recompile from scratch.
- GUI with JavaFX (21)
- Logging with SLF4J (2.0.17) and Logback (1.5.18)
- Testing with TestNG (7.11.0)
For more details visit pom.xml.
You can launch the program with logging by passing an argument --loglevel
with one of these values:
- OFF
- ERROR
- WARN
- INFO
- DEBUG
- TRACE
For example by using --loglevel=WARN
you would set the logging level to WARN and all message with level WARN and above (as in the list) would get logged.
This app does not use JavaFX canvas. The rendering consists of 4 stages:
- RGBA values are put into an IntBuffer.
// Allocate 100 pixels
IntBuffer intBuffer = IntBuffer.allocate(100);
// Put black into the first pixel
intBuffer.put(0, 0xFF000000);
- The values of the IntBuffer are used inside a PixelBuffer.
// Create buffer of size 10x10 = 100
PixelBuffer<IntBuffer> pixelBuffer = new PixelBuffer<>(
10,
10,
intBuffer,
PixelFormat.getIntArgbPreInstance()
);
- After the PixelBuffer is prepared a WritableImage is updated with its values.
WritableImage writableImage = new WritableImage(pixelBuffer);
// The lambda passed to the updateBuffer method returning null only means that all pixels from the buffer will be copied (not just a specific segment).
pixelBuffer.updateBuffer(buffer -> null);
- An ImageView is updated with the WritableImage.
ImageView imageView = new ImageView();
imageView.setImage(writableImage);
So the basic cycle is: IntBuffer -> PixelBuffer -> WritableImage -> ImageView.
Here's a simplified process:
- Calculate the particle position relative to the camera position. This is sometimes called displacement. It tells us where the particles is in the camera view area.
- Check whether few important positions on the particle are in the camera view area. If none of the positions are in the camera view area the particle rendering is skipped.
- Go over the center position of each pixel in a box around the particle and draw it if it's inside the camera view area and is within a distance from the particle center.
The rendering process could be more optimized however I didn't ever felt a need to optimize it as it was never the bottleneck.
Each particle has 3 physical properties:
- Position
- Velocity
- First derivative of position
- Change of position in time
- (Immediate) Acceleration
- Second derivative of position
- Change of velocity in time
Here's a simplified process of a simulation tick:
- Go over each pair of particles in the simulation and calculate how particle A attracts or repels particle B and vice versa and add these values to their immediate acceleration. The distance of the particles in the pair matters. The calculation of pairs that are too far apart to interact with each other is mostly automatically skipped, because of space partitioning technique to make the simulation more efficient.
The blue particles repel the red particle while the white particles attract it. The cumulated (total) force is the red arrow.
- The calculation of particles immediate acceleration is naturally computationally exponentially demanding and before the computation finishes
timeDeltaSeconds
amount of time elapses. This time is used to transfer that acceleration we calculated to a change of velocity for each particle. The immediate aaceleration is reset to zero for the next tick after the change in velocity is calculated.
// Type declarations for better readability
Vector2Double velocity;
Vector2Double acceleration;
double timeDeltaSeconds;
velocity += acceleration.scaled(timeDeltaSeconds);
- Transfer the velocity to change of position.
// Type declarations for better readability
Vector2Double position;
Vector2Double velocity;
double timeDeltaSeconds;
position += velocity.scaled(timeDeltaSeconds);
- Correct the particle position to stay withing the simulation space by making the space toroidal. Wrap the space horizontally and vertically (to make all ends of the world connect to the opposite end) which makes for a donut shape in 3D! It's as simple as using a remainder of division by the space with and height.
// Type declarations for better readability
Vector2Double position;
Vector2Double spaceSize;
position = new Vector2Int(
Math.abs(position.x() % spaceSize.x()),
Math.abs(position.y() % spaceSize.y())
);
- Apply velocity half life (friction) over time for every particle. Velocity half life defines how many seconds it takes for a particle to naturally lose half of its velocity.
// Type declarations for better readability
Vector2Double velocity;
double timeDeltaSeconds;
double velocityHalfLife;
final double velocityHalfLifesElapsed = timeDeltaSeconds / velocityHalfLife;
velocity.scale(1 / Math.pow(2, velocityHalfLifesElapsed);
The actual calculation of 1 tick looks more like this:
When calculating properties that use timeDeltaSeconds
the timeDeltaSeconds
represents the amount of seconds it took to calculate the whole previous tick.