I'm trying to write a painting application in JavaFX. I want a brush resembling a real paintbrush, but I'm not sure how to start the algorithm. The code below shows my current paintbrush stroke, although it's a useful stroke, it's not really a paintbrush:
import javafx.application.Application;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.MouseEvent;
import javafx.scene.paint.Color;
import javafx.scene.shape.StrokeLineCap;
import javafx.scene.shape.StrokeLineJoin;
import javafx.stage.Stage;
import static javafx.scene.input.MouseEvent.*;
public class BrushTester extends Application {
private static final Color color = Color.CHOCOLATE;
private static final double START_OPACITY = 0.3;
private static final double OPACITY_MODIFIER = 0.002;
private double currentOpacity = START_OPACITY;
private double strokeWidth = 15;
public static void main(String[] args) {
Application.launch(BrushTester.class);
}
@Override
public void start(Stage primaryStage) throws Exception {
Canvas canvas = new Canvas(600d, 600d);
GraphicsContext gc = canvas.getGraphicsContext2D();
canvas.addEventHandler(MOUSE_DRAGGED, e -> BrushTester.this.handleMouseDragged(gc, e));
canvas.addEventHandler(MOUSE_PRESSED, e -> handleMousePressed(gc, e));
canvas.addEventHandler(MOUSE_RELEASED, e -> handleMouseReleased(gc, e));
Group root = new Group();
root.getChildren().add(canvas);
primaryStage.setScene(new Scene(root, Color.DARKGRAY));
primaryStage.show();
}
private void configureGraphicsContext(GraphicsContext gc) {
gc.setStroke(new Color(color.getRed(), color.getGreen(), color.getBlue(), currentOpacity));
gc.setLineCap(StrokeLineCap.ROUND);
gc.setLineJoin(StrokeLineJoin.ROUND);
gc.setLineWidth(strokeWidth);
}
public void handleMousePressed(GraphicsContext gc, MouseEvent e) {
configureGraphicsContext(gc);
gc.beginPath();
gc.moveTo(e.getX(), e.getY());
gc.stroke();
}
public void handleMouseReleased(GraphicsContext gc, MouseEvent e) {
currentOpacity = START_OPACITY;
gc.closePath();
}
public void handleMouseDragged(GraphicsContext gc, MouseEvent e) {
currentOpacity = Math.max(0, currentOpacity - OPACITY_MODIFIER);
configureGraphicsContext(gc);
gc.lineTo(e.getX(), e.getY());
gc.stroke();
}
}
Anyone with some tips on how to get closer to the real thing?
It all depends on what you're trying to achieve. Personally I would use
- an AnimationTimer
- a customizable Brush (i. e. an Image) instead of a stroke, so you can specify size and hardness
- a line drawing algorithm (like Bresenham) to connect the previous mouse location with the current one to get a full line between points
A quick example with a simple drawing algorithm:
import java.util.Random;
import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.SnapshotParameters;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.image.WritableImage;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
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 javafx.stage.Stage;
public class Main extends Application {
private static double SCENE_WIDTH = 1280;
private static double SCENE_HEIGHT = 720;
static Random random = new Random();
Canvas canvas;
GraphicsContext graphicsContext;
AnimationTimer loop;
Point2D mouseLocation = new Point2D( 0, 0);
boolean mousePressed = false;
Point2D prevMouseLocation = new Point2D( 0, 0);
Scene scene;
Image brush = createBrush( 30.0, Color.CHOCOLATE);
double brushWidthHalf = brush.getWidth() / 2.0;
double brushHeightHalf = brush.getHeight() / 2.0;
@Override
public void start(Stage primaryStage) {
BorderPane root = new BorderPane();
canvas = new Canvas( SCENE_WIDTH, SCENE_HEIGHT);
graphicsContext = canvas.getGraphicsContext2D();
Pane layerPane = new Pane();
layerPane.getChildren().addAll(canvas);
root.setCenter(layerPane);
scene = new Scene(root, SCENE_WIDTH, SCENE_HEIGHT);
primaryStage.setScene(scene);
primaryStage.show();
addListeners();
startAnimation();
}
private void startAnimation() {
loop = new AnimationTimer() {
@Override
public void handle(long now) {
if( mousePressed) {
// try this
// graphicsContext.drawImage( brush, mouseLocation.getX() - brushWidthHalf, mouseLocation.getY() - brushHeightHalf);
// then this
bresenhamLine( prevMouseLocation.getX(), prevMouseLocation.getY(), mouseLocation.getX(), mouseLocation.getY());
}
prevMouseLocation = new Point2D( mouseLocation.getX(), mouseLocation.getY());
}
};
loop.start();
}
// https://de.wikipedia.org/wiki/Bresenham-Algorithmus
private void bresenhamLine(double x0, double y0, double x1, double y1)
{
double dx = Math.abs(x1-x0), sx = x0<x1 ? 1. : -1.;
double dy = -Math.abs(y1-y0), sy = y0<y1 ? 1. : -1.;
double err = dx+dy, e2; /* error value e_xy */
while( true){
graphicsContext.drawImage( brush, x0 - brushWidthHalf, y0 - brushHeightHalf);
if (x0==x1 && y0==y1) break;
e2 = 2.*err;
if (e2 > dy) { err += dy; x0 += sx; } /* e_xy+e_x > 0 */
if (e2 < dx) { err += dx; y0 += sy; } /* e_xy+e_y < 0 */
}
}
private void addListeners() {
scene.addEventFilter(MouseEvent.ANY, e -> {
mouseLocation = new Point2D(e.getX(), e.getY());
mousePressed = e.isPrimaryButtonDown();
});
}
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;
}
public static Image createBrush( double radius, Color color) {
// create gradient image with given color
Circle brush = new Circle(radius);
RadialGradient gradient1 = new RadialGradient(0, 0, 0, 0, radius, false, CycleMethod.NO_CYCLE, new Stop(0, color.deriveColor(1, 1, 1, 0.3)), new Stop(1, color.deriveColor(1, 1, 1, 0)));
brush.setFill(gradient1);
// create image
return createImage(brush);
}
public static void main(String[] args) {
launch(args);
}
}
Of course you can extend this with e. g.
- multiple layers
- JavaFX's blend modes on layer and graphicscontext level
- to simulate force I'd use a paint delay (eg 200 ms) and a buffer for the mouse locations and let the opacity depend on whether the mouse is still pressed or not
- smooth the lines by using bezier curves
- ...
Example with Brush variations when you start painting:
import java.util.Random;
import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.SnapshotParameters;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.image.WritableImage;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
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 javafx.stage.Stage;
public class Main extends Application {
private static double SCENE_WIDTH = 1280;
private static double SCENE_HEIGHT = 720;
static Random random = new Random();
Canvas canvas;
GraphicsContext graphicsContext;
AnimationTimer loop;
Point2D mouseLocation = new Point2D( 0, 0);
boolean mousePressed = false;
Point2D prevMouseLocation = new Point2D( 0, 0);
Scene scene;
double brushMaxSize = 30;
Image brush = createBrush( brushMaxSize, Color.CHOCOLATE);
double brushWidthHalf = brush.getWidth() / 2.0;
double brushHeightHalf = brush.getHeight() / 2.0;
double pressure = 0;
double pressureDelay = 0.04;
private Image[] brushVariations = new Image[256];
@Override
public void start(Stage primaryStage) {
BorderPane root = new BorderPane();
canvas = new Canvas( SCENE_WIDTH, SCENE_HEIGHT);
for( int i=0; i < brushVariations.length; i++) {
double size = (brushMaxSize - 1) / (double) brushVariations.length * (double) i + 1;
brushVariations[i] = createBrush( size, Color.CHOCOLATE);
}
graphicsContext = canvas.getGraphicsContext2D();
Pane layerPane = new Pane();
layerPane.getChildren().addAll(canvas);
root.setCenter(layerPane);
scene = new Scene(root, SCENE_WIDTH, SCENE_HEIGHT);
primaryStage.setScene(scene);
primaryStage.show();
addListeners();
startAnimation();
}
private void startAnimation() {
loop = new AnimationTimer() {
@Override
public void handle(long now) {
if( mousePressed) {
// try this
// graphicsContext.drawImage( brush, mouseLocation.getX() - brushWidthHalf, mouseLocation.getY() - brushHeightHalf);
// then this
bresenhamLine( prevMouseLocation.getX(), prevMouseLocation.getY(), mouseLocation.getX(), mouseLocation.getY());
pressure += pressureDelay;
if( pressure > 1) {
pressure = 1;
}
} else {
pressure = 0;
}
prevMouseLocation = new Point2D( mouseLocation.getX(), mouseLocation.getY());
}
};
loop.start();
}
// https://de.wikipedia.org/wiki/Bresenham-Algorithmus
private void bresenhamLine(double x0, double y0, double x1, double y1)
{
double dx = Math.abs(x1-x0), sx = x0<x1 ? 1. : -1.;
double dy = -Math.abs(y1-y0), sy = y0<y1 ? 1. : -1.;
double err = dx+dy, e2; /* error value e_xy */
while( true){
int variation = (int) (pressure * (brushVariations.length - 1));
Image brushVariation = brushVariations[ variation ];
graphicsContext.setGlobalAlpha(pressure);
graphicsContext.drawImage( brushVariation, x0 - brushWidthHalf, y0 - brushHeightHalf);
if (x0==x1 && y0==y1) break;
e2 = 2.*err;
if (e2 > dy) { err += dy; x0 += sx; } /* e_xy+e_x > 0 */
if (e2 < dx) { err += dx; y0 += sy; } /* e_xy+e_y < 0 */
}
}
private void addListeners() {
scene.addEventFilter(MouseEvent.ANY, e -> {
mouseLocation = new Point2D(e.getX(), e.getY());
mousePressed = e.isPrimaryButtonDown();
});
}
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;
}
public static Image createBrush( double radius, Color color) {
// create gradient image with given color
Circle brush = new Circle(radius);
RadialGradient gradient1 = new RadialGradient(0, 0, 0, 0, radius, false, CycleMethod.NO_CYCLE, new Stop(0, color.deriveColor(1, 1, 1, 0.3)), new Stop(1, color.deriveColor(1, 1, 1, 0)));
brush.setFill(gradient1);
// create image
return createImage(brush);
}
public static void main(String[] args) {
launch(args);
}
}
Example with variation for limiting the brush length
import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.SnapshotParameters;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.ColorPicker;
import javafx.scene.image.Image;
import javafx.scene.image.WritableImage;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
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 javafx.stage.Stage;
public class Main extends Application {
private static double SCENE_WIDTH = 1280;
private static double SCENE_HEIGHT = 720;
Canvas canvas;
GraphicsContext graphicsContext;
AnimationTimer loop;
Point2D mouseLocation = new Point2D(0, 0);
boolean mousePressed = false;
Point2D prevMouseLocation = new Point2D(0, 0);
Scene scene;
double brushMaxSize = 30;
double pressure = 0;
double pressureDelay = 0.04;
double pressureDirection = 1;
double strokeTimeMax = 1;
double strokeTime = 0;
double strokeTimeDelay = 0.07;
private Image[] brushVariations = new Image[256];
ColorPicker colorPicker = new ColorPicker();
@Override
public void start(Stage primaryStage) {
BorderPane root = new BorderPane();
canvas = new Canvas(SCENE_WIDTH, SCENE_HEIGHT);
graphicsContext = canvas.getGraphicsContext2D();
graphicsContext.setFill(Color.WHITE);
graphicsContext.fillRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT);
Pane layerPane = new Pane();
layerPane.getChildren().addAll(canvas);
colorPicker.setValue(Color.CHOCOLATE);
colorPicker.setOnAction(e -> {
createBrushVariations();
});
root.setCenter(layerPane);
root.setTop(colorPicker);
scene = new Scene(root, SCENE_WIDTH, SCENE_HEIGHT, Color.WHITE);
primaryStage.setScene(scene);
primaryStage.show();
createBrushVariations();
addListeners();
startAnimation();
}
private void createBrushVariations() {
for (int i = 0; i < brushVariations.length; i++) {
double size = (brushMaxSize - 1) / (double) brushVariations.length * (double) i + 1;
brushVariations[i] = createBrush(size, colorPicker.getValue());
}
}
private void startAnimation() {
loop = new AnimationTimer() {
@Override
public void handle(long now) {
if (mousePressed) {
// try this
// graphicsContext.drawImage( brush, mouseLocation.getX() -
// brushWidthHalf, mouseLocation.getY() - brushHeightHalf);
// then this
bresenhamLine(prevMouseLocation.getX(), prevMouseLocation.getY(), mouseLocation.getX(), mouseLocation.getY());
// increasing or decreasing
strokeTime += strokeTimeDelay * pressureDirection;
// invert direction
if (strokeTime > strokeTimeMax) {
pressureDirection = -1;
}
// while still
if (strokeTime > 0) {
pressure += pressureDelay * pressureDirection;
// clamp value of pressure to be [0,1]
if (pressure > 1) {
pressure = 1;
} else if (pressure < 0) {
pressure = 0;
}
} else {
pressure = 0;
}
} else {
pressure = 0;
pressureDirection = 1;
strokeTime = 0;
}
prevMouseLocation = new Point2D(mouseLocation.getX(), mouseLocation.getY());
}
};
loop.start();
}
// https://de.wikipedia.org/wiki/Bresenham-Algorithmus
private void bresenhamLine(double x0, double y0, double x1, double y1) {
double dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1. : -1.;
double dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1. : -1.;
double err = dx + dy, e2; /* error value e_xy */
while (true) {
int variation = (int) (pressure * (brushVariations.length - 1));
Image brushVariation = brushVariations[variation];
graphicsContext.setGlobalAlpha(pressure);
graphicsContext.drawImage(brushVariation, x0 - brushVariation.getWidth() / 2.0, y0 - brushVariation.getHeight() / 2.0);
if (x0 == x1 && y0 == y1)
break;
e2 = 2. * err;
if (e2 > dy) {
err += dy;
x0 += sx;
} /* e_xy+e_x > 0 */
if (e2 < dx) {
err += dx;
y0 += sy;
} /* e_xy+e_y < 0 */
}
}
private void addListeners() {
canvas.addEventFilter(MouseEvent.ANY, e -> {
mouseLocation = new Point2D(e.getX(), e.getY());
mousePressed = e.isPrimaryButtonDown();
});
}
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;
}
public static Image createBrush(double radius, Color color) {
// create gradient image with given color
Circle brush = new Circle(radius);
RadialGradient gradient1 = new RadialGradient(0, 0, 0, 0, radius, false, CycleMethod.NO_CYCLE, new Stop(0, color.deriveColor(1, 1, 1, 0.3)), new Stop(1, color.deriveColor(1, 1, 1, 0)));
brush.setFill(gradient1);
// create image
return createImage(brush);
}
public static void main(String[] args) {
launch(args);
}
}
This is how it looks like:
or using different colors, I added a color picker in the last example: