5

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?

BartCr
  • 65
  • 1
  • 5
  • How do you define "the real thing"? – Roland Aug 11 '15 at 00:20
  • For me that means when using that brush I get the feeling I'm running a real oil paintbrush over a canvas. Not getting that with my current implementation. – BartCr Aug 11 '15 at 06:11

1 Answers1

8

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:

enter image description here

or using different colors, I added a color picker in the last example:

enter image description here

Roland
  • 18,114
  • 12
  • 62
  • 93
  • This looks like an interesting approach. Although not yet perfect it seems closer to what I want then my attempt. I'm gonna explore this a bit further to see if I can get the brush to run out of paint and to make it blend a bit with other colors painted over. – BartCr Aug 11 '15 at 19:36
  • There are various ways. You could achieve that by using e. g. an array of precalculated images. From small to normal with varying opacity, or only varying the opacity in the animation timer. And then in the animation timer you check how long the pen was pressed and depending on that choose the proper image. Or you only vary the opacity. Depends on the effect you wish to achieve. – Roland Aug 12 '15 at 04:41
  • @BartCr: I added an example about a way in which you can change the brush size and the opacity when you start the stroke. And one using modifications for start and end with stroke time limitation. It really all depends on what you want to achieve and how you set the parameters. – Roland Aug 12 '15 at 05:39
  • 4
    +2-1 thats a nice flower! – Uluk Biy Aug 12 '15 at 06:52
  • This is much more then I could ask for. I think I only need to finetune the timings a bit and I will have my paintbrush stroke. – BartCr Aug 12 '15 at 11:11