Created
July 24, 2015 17:59
-
-
Save Roland09/8375df8b01f453bcae71 to your computer and use it in GitHub Desktop.
Particle system using precalculated images which are drawn on a Canvas.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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--; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Nioce