Skip to content

Instantly share code, notes, and snippets.

@Roland09
Created July 24, 2015 17:59
Show Gist options
  • Save Roland09/8375df8b01f453bcae71 to your computer and use it in GitHub Desktop.
Save Roland09/8375df8b01f453bcae71 to your computer and use it in GitHub Desktop.
Particle system using precalculated images which are drawn on a Canvas.
package application;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.text.Text;
import javafx.scene.text.TextBoundsType;
/**
* A simple node which serves as indicator for the wind direction
*/
public class Attractor extends Sprite {
public Attractor( Vector2D location, Vector2D velocity, Vector2D acceleration, double width, double height) {
super(location, velocity, acceleration, width, height);
}
/**
* Circle with a label
*/
@Override
public Node createView() {
Group group = new Group();
double radius = width / 2;
Circle circle = new Circle( radius);
circle.setCenterX(radius);
circle.setCenterY(radius);
circle.setStroke(Color.RED);
circle.setFill(Color.RED.deriveColor(1, 1, 1, 0.3));
group.getChildren().add( circle);
Text text = new Text( "Attractor\n(Direction)");
text.setStroke(Color.RED);
text.setFill(Color.RED);
text.setBoundsType(TextBoundsType.VISUAL);
text.relocate(radius - text.getLayoutBounds().getWidth() / 2, radius - text.getLayoutBounds().getHeight() / 2);
group.getChildren().add( text);
return group;
}
}
package application;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Random;
import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
public class Main extends Application {
private static Random random = new Random();
Canvas canvas;
GraphicsContext graphicsContext;
/**
* Container for canvas and other nodes like attractors and repellers
*/
Pane layerPane;
List<Attractor> allAttractors = new ArrayList<>();
List<Repeller> allRepellers = new ArrayList<>();
List<Particle> allParticles = new ArrayList<>();
AnimationTimer animationLoop;
Scene scene;
MouseGestures mouseGestures = new MouseGestures();
/**
* Container for pre-created images which have color and size depending on
* the particle's lifespan
*/
Image[] images = Utils.preCreateImages();
@Override
public void start(Stage primaryStage) {
BorderPane root = new BorderPane();
canvas = new Canvas(Settings.SCENE_WIDTH, Settings.SCENE_HEIGHT);
graphicsContext = canvas.getGraphicsContext2D();
layerPane = new Pane();
layerPane.getChildren().addAll(canvas);
root.setCenter(layerPane);
scene = new Scene(root, Settings.SCENE_WIDTH, Settings.SCENE_HEIGHT, Settings.SCENE_COLOR);
primaryStage.setScene(scene);
primaryStage.show();
// add content
prepareObjects();
// add mouse location listener
addListeners();
// run animation loop
startAnimation();
}
private void prepareObjects() {
// add attractors
for (int i = 0; i < Settings.ATTRACTOR_COUNT; i++) {
addAttractors();
}
// add repellers
for (int i = 0; i < Settings.REPELLER_COUNT; i++) {
addRepellers();
}
}
private void startAnimation() {
// start game
animationLoop = new AnimationTimer() {
@Override
public void handle(long now) {
// add new particles
for (int i = 0; i < Settings.PARTICLES_PER_ITERATION; i++) {
addParticle();
}
// apply force: gravity
allParticles.forEach(sprite -> {
sprite.applyForce(Settings.FORCE_GRAVITY);
});
// apply force: wind depending on attractor position
for (Attractor attractor : allAttractors) {
double dx = Utils.map(attractor.getLocation().x, 0, Settings.SCENE_WIDTH, -0.2, 0.2);
Vector2D windForce = new Vector2D(dx, 0);
allParticles.stream().parallel().forEach(sprite -> {
sprite.applyForce(windForce);
});
}
// apply force: repeller
for (Repeller repeller : allRepellers) {
allParticles.stream().parallel().forEach(sprite -> {
Vector2D force = repeller.repel(sprite);
sprite.applyForce(force);
});
}
// move sprite: apply acceleration, calculate velocity and location
allParticles.stream().parallel().forEach(Sprite::move);
// update in fx scene
allAttractors.forEach(Sprite::display);
allRepellers.forEach(Sprite::display);
// draw all particles on canvas
// -----------------------------------------
graphicsContext.clearRect(0, 0, canvas.getWidth(), canvas.getHeight());
// TODO: parallel?
allParticles.stream().forEach(particle -> {
Image img = images[particle.getLifeSpan()];
graphicsContext.drawImage(img, particle.getLocation().x, particle.getLocation().y);
});
// draw gradient colors for debugging purposes
/*
* for( int i=0; i < 256; i++) { gc.drawImage( images[ i], i * 2, 30); }
*/
// life span of particle
allParticles.stream().parallel().forEach(Sprite::decreaseLifeSpan);
// remove all particles that aren't visible anymore
removeDeadParticles();
// show number of particles
graphicsContext.setFill(Color.WHITE);
graphicsContext.fillText("Particles: " + allParticles.size(), 1, 10);
}
};
animationLoop.start();
}
private void removeDeadParticles() {
Iterator<Particle> iter = allParticles.iterator();
while (iter.hasNext()) {
Particle particle = iter.next();
if (particle.isDead()) {
// remove from particle list
iter.remove();
}
}
}
private void addParticle() {
// random location
double x = Settings.SCENE_WIDTH / 2 + random.nextDouble() * Settings.EMITTER_WIDTH - Settings.EMITTER_WIDTH / 2;
double y = Settings.EMITTER_LOCATION_Y;
// dimensions
double width = Settings.PARTICLE_WIDTH;
double height = Settings.PARTICLE_HEIGHT;
// create motion data
Vector2D location = new Vector2D(x, y);
double vx = random.nextGaussian() * 0.3;
double vy = random.nextGaussian() * 0.3 - 1.0;
Vector2D velocity = new Vector2D(vx, vy);
Vector2D acceleration = new Vector2D(0, 0);
// create sprite and add to layer
Particle sprite = new Particle(location, velocity, acceleration, width, height);
// register sprite
allParticles.add(sprite);
}
private void addAttractors() {
// center attractor
double x = Settings.SCENE_WIDTH / 2;
double y = Settings.SCENE_HEIGHT - Settings.SCENE_HEIGHT / 4;
// dimensions
double width = 100;
double height = 100;
// create motion data
Vector2D location = new Vector2D(x, y);
Vector2D velocity = new Vector2D(0, 0);
Vector2D acceleration = new Vector2D(0, 0);
// create sprite and add to layer
Attractor attractor = new Attractor(location, velocity, acceleration, width, height);
// register sprite
allAttractors.add(attractor);
layerPane.getChildren().add(attractor);
}
private void addRepellers() {
// center attractor
double x = Settings.SCENE_WIDTH / 2;
double y = Settings.SCENE_HEIGHT - Settings.SCENE_HEIGHT / 4 + 110;
// dimensions
double width = 100;
double height = 100;
// create motion data
Vector2D location = new Vector2D(x, y);
Vector2D velocity = new Vector2D(0, 0);
Vector2D acceleration = new Vector2D(0, 0);
// create sprite and add to layer
Repeller repeller = new Repeller(location, velocity, acceleration, width, height);
// register sprite
allRepellers.add(repeller);
layerPane.getChildren().add(repeller);
}
private void addListeners() {
// move attractors via mouse
for (Attractor attractor : allAttractors) {
mouseGestures.makeDraggable(attractor);
}
// move attractors via mouse
for (Repeller sprite : allRepellers) {
mouseGestures.makeDraggable(sprite);
}
}
public static void main(String[] args) {
launch(args);
}
}
package application;
import javafx.event.EventHandler;
import javafx.scene.input.MouseEvent;
/**
* Allow dragging of attractors and repellers
*/
public class MouseGestures {
final DragContext dragContext = new DragContext();
public void makeDraggable(final Sprite sprite) {
sprite.setOnMousePressed(onMousePressedEventHandler);
sprite.setOnMouseDragged(onMouseDraggedEventHandler);
sprite.setOnMouseReleased(onMouseReleasedEventHandler);
}
EventHandler<MouseEvent> onMousePressedEventHandler = new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent event) {
dragContext.x = event.getSceneX();
dragContext.y = event.getSceneY();
}
};
EventHandler<MouseEvent> onMouseDraggedEventHandler = new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent event) {
Sprite sprite = (Sprite) event.getSource();
double offsetX = event.getSceneX() - dragContext.x;
double offsetY = event.getSceneY() - dragContext.y;
sprite.setLocationOffset(offsetX, offsetY);
dragContext.x = event.getSceneX();
dragContext.y = event.getSceneY();
}
};
EventHandler<MouseEvent> onMouseReleasedEventHandler = new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent event) {
}
};
class DragContext {
double x;
double y;
}
}
package application;
import javafx.scene.Node;
/**
* A single particle with a per-frame reduced lifespan and now view. The particle is drawn on a canvas, it isn't actually a node
*/
public class Particle extends Sprite {
public Particle( Vector2D location, Vector2D velocity, Vector2D acceleration, double width, double height) {
super( location, velocity, acceleration, width, height);
}
@Override
public Node createView() {
return null;
}
public void decreaseLifeSpan() {
lifeSpan--;
}
}
package application;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.text.Text;
import javafx.scene.text.TextBoundsType;
/**
* A node which calculates a repelling force for particles
*/
public class Repeller extends Sprite {
public Repeller( Vector2D location, Vector2D velocity, Vector2D acceleration, double width, double height) {
super( location, velocity, acceleration, width, height);
}
/**
* Circle with a label
*/
@Override
public Node createView() {
Group group = new Group();
double radius = width / 2;
Circle circle = new Circle( radius);
circle.setCenterX(radius);
circle.setCenterY(radius);
circle.setStroke(Color.YELLOW);
circle.setFill(Color.YELLOW.deriveColor(1, 1, 1, 0.3));
group.getChildren().add( circle);
Text text = new Text( "Repeller");
text.setStroke(Color.YELLOW);
text.setFill(Color.YELLOW);
text.setBoundsType(TextBoundsType.VISUAL);
text.relocate(radius - text.getLayoutBounds().getWidth() / 2, radius - text.getLayoutBounds().getHeight() / 2);
group.getChildren().add( text);
return group;
}
public Vector2D repel(Particle particle) {
// calculate direction of force
Vector2D dir = Vector2D.subtract(location, particle.location);
// get distance (constrain distance)
double distance = dir.magnitude(); // distance between objects
dir.normalize(); // normalize vector (distance doesn't matter here, we just want this vector for direction)
distance = Utils.clamp(distance, 5, 1000); // keep distance within a reasonable range
// calculate magnitude
double force = -1.0 * Settings.REPELLER_STRENGTH / (distance * distance); // repelling force is inversely proportional to distance
// make a vector out of direction and magnitude
dir.multiply(force); // get force vector => magnitude * direction
return dir;
}
}
package application;
import javafx.scene.paint.Color;
/**
* Application settings
*/
public class Settings {
public static double SCENE_WIDTH = 1600;
public static double SCENE_HEIGHT = 900;
public static Color SCENE_COLOR = Color.BLACK;
public static int ATTRACTOR_COUNT = 1;
public static int REPELLER_COUNT = 1;
// emitter parameters
public static int PARTICLES_PER_ITERATION = 50;
public static int EMITTER_WIDTH = (int) SCENE_WIDTH;
public static double EMITTER_LOCATION_Y = SCENE_HEIGHT / 2;
// particle parameters
public static int PARTICLE_WIDTH = 40;
public static int PARTICLE_HEIGHT = PARTICLE_WIDTH;
public static double PARTICLE_LIFE_SPAN_MAX = 256;
public static double PARTICLE_MAX_SPEED = 4;
// just some artificial strength value that matches our needs.
public static double REPELLER_STRENGTH = 500;
// gravity. use negative if you want the particles to always go up, eg new Vector2D( 0,-0.04);
public static Vector2D FORCE_GRAVITY = new Vector2D( 0,0);
}
package application;
import javafx.scene.Node;
import javafx.scene.layout.Region;
/**
* Sprite base class
*/
public abstract class Sprite extends Region {
Vector2D location;
Vector2D velocity;
Vector2D acceleration;
double maxSpeed = Settings.PARTICLE_MAX_SPEED;
double radius;
Node view;
double width;
double height;
double centerX;
double centerY;
double angle;
double lifeSpanMax = Settings.PARTICLE_LIFE_SPAN_MAX - 1;
double lifeSpan = Settings.PARTICLE_LIFE_SPAN_MAX - 1;;
public Sprite( Vector2D location, Vector2D velocity, Vector2D acceleration, double width, double height) {
this.location = location;
this.velocity = velocity;
this.acceleration = acceleration;
this.width = width;
this.height = height;
this.centerX = width / 2;
this.centerY = height / 2;
this.radius = width / 2;
this.view = createView();
setPrefSize(width, height);
if( this.view != null) {
getChildren().add( view);
}
}
public abstract Node createView();
public void applyForce(Vector2D force) {
acceleration.add(force);
}
/**
* Standard movement method: calculate valocity depending on accumulated acceleration force, then calculate the location.
* Reset acceleration so that it can be recalculated in the next animation step.
*/
public void move() {
// set velocity depending on acceleration
velocity.add(acceleration);
// limit velocity to max speed
velocity.limit(maxSpeed);
// change location depending on velocity
location.add(velocity);
// angle: towards velocity (ie target)
angle = velocity.angle();
// clear acceleration
acceleration.multiply(0);
}
/**
* Update node position
*/
public void display() {
// location
relocate(location.x - centerX, location.y - centerY);
// rotation
setRotate(Math.toDegrees( angle));
}
public Vector2D getVelocity() {
return velocity;
}
public Vector2D getLocation() {
return location;
}
public void setLocation( double x, double y) {
location.x = x;
location.y = y;
}
public void setLocationOffset( double x, double y) {
location.x += x;
location.y += y;
}
public void decreaseLifeSpan() {
}
public boolean isDead() {
if (lifeSpan <= 0.0) {
return true;
} else {
return false;
}
}
public int getLifeSpan() {
return (int) lifeSpan;
}
}
package application;
import javafx.scene.Node;
import javafx.scene.SnapshotParameters;
import javafx.scene.image.Image;
import javafx.scene.image.WritableImage;
import javafx.scene.paint.Color;
import javafx.scene.paint.CycleMethod;
import javafx.scene.paint.RadialGradient;
import javafx.scene.paint.Stop;
import javafx.scene.shape.Circle;
import application.Settings;
public class Utils {
/**
* Clamp value between min and max
* @param value
* @param min
* @param max
* @return
*/
public static double clamp(double value, double min, double max) {
if (value < min)
return min;
if (value > max)
return max;
return value;
}
/**
* Map value of a given range to a target range
* @param value
* @param currentRangeStart
* @param currentRangeStop
* @param targetRangeStart
* @param targetRangeStop
* @return
*/
public static double map(double value, double currentRangeStart, double currentRangeStop, double targetRangeStart, double targetRangeStop) {
return targetRangeStart + (targetRangeStop - targetRangeStart) * ((value - currentRangeStart) / (currentRangeStop - currentRangeStart));
}
/**
* Snapshot an image out of a node, consider transparency.
*
* @param node
* @return
*/
public static Image createImage(Node node) {
WritableImage wi;
SnapshotParameters parameters = new SnapshotParameters();
parameters.setFill(Color.TRANSPARENT);
int imageWidth = (int) node.getBoundsInLocal().getWidth();
int imageHeight = (int) node.getBoundsInLocal().getHeight();
wi = new WritableImage(imageWidth, imageHeight);
node.snapshot(parameters, wi);
return wi;
}
/**
* Pre-create images with various gradient colors and sizes.
*
* @return
*/
public static Image[] preCreateImages() {
int count = (int) Settings.PARTICLE_LIFE_SPAN_MAX;
Image[] list = new Image[count];
double radius = Settings.PARTICLE_WIDTH;
for (int i = 0; i < count; i++) {
double opacity = (double) i / (double) count;
// get color depending on lifespan
Color color;
double threshold = 0.9;
double threshold2 = 0.4;
if (opacity >= threshold) {
color = Color.YELLOW.interpolate(Color.WHITE, Utils.map(opacity, threshold, 1, 0, 1));
} else if (opacity >= threshold2) {
color = Color.RED.interpolate(Color.YELLOW, Utils.map(opacity, threshold2, threshold, 0, 1));
} else {
color = Color.BLACK.interpolate(Color.RED, Utils.map(opacity, 0, threshold2, 0, 1));
}
// create gradient image with given color
Circle ball = new Circle(radius);
RadialGradient gradient1 = new RadialGradient(0, 0, 0, 0, radius, false, CycleMethod.NO_CYCLE, new Stop(0, color.deriveColor(1, 1, 1, 1)), new Stop(1, color.deriveColor(1, 1, 1, 0)));
ball.setFill(gradient1);
// create image
list[i] = Utils.createImage(ball);
}
return list;
}
}
package application;
public class Vector2D {
public double x;
public double y;
public Vector2D(double x, double y) {
this.x = x;
this.y = y;
}
public double magnitude() {
return (double) Math.sqrt(x * x + y * y);
}
public void add(Vector2D v) {
x += v.x;
y += v.y;
}
public void add(double x, double y, double z) {
this.x += x;
this.y += y;
}
public void multiply(double n) {
x *= n;
y *= n;
}
public void div(double n) {
x /= n;
y /= n;
}
public void normalize() {
double m = magnitude();
if (m != 0 && m != 1) {
div(m);
}
}
public void limit(double max) {
if (magnitude() > max) {
normalize();
multiply(max);
}
}
public double angle() {
double angle = (double) Math.atan2(-y, x);
return -1 * angle;
}
static public Vector2D subtract(Vector2D v1, Vector2D v2) {
return new Vector2D(v1.x - v2.x, v1.y - v2.y);
}
}
@kolwea
Copy link

kolwea commented Dec 5, 2018

Nioce

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment