javajavafxcanvasblurhighdpi

JavaFX - avoid blur when drawing with rect on Canvas with highdpi


I'd like to draw on a JavaFX Canvas. Coordinates for the canvas are provided by double values, but I understand that in order to "snap" to the pixel grid, I need integer numbers to avoid drawing "inbetween" pixels, which will result in a blur/smoothing on edges.

Consider this minimal example:

public class App extends Application {

    @Override
    public void start(Stage stage) {

        var vbox = new VBox();
        var canvas = new Canvas(600, 400);
        var gc = canvas.getGraphicsContext2D();

        // draw a background
        gc.beginPath();
        gc.setFill(Color.rgb(200,200,200));
        gc.rect(0, 0, 600, 400);
        gc.fill();

        // draw a smaller square
        gc.beginPath();
        gc.setFill(Color.rgb(100,100,100));
        gc.rect(9.0, 9.0, 50, 50);  // snap to grid
        gc.fill();

        vbox.getChildren().addAll(canvas);

        var scene = new Scene(vbox, 640, 480);
        stage.setScene(scene);
        stage.show();
    }

    public static void main(String[] args) {
        launch();
    }

}

This works and will result in a clear edge, consider this image:

enter image description here

Now suppose HighDPI is enabled, i.e. consider if we scale graphics in Windows 10 to 125 percent. This will result in a scale factor of 1.25 (we can obtain this via getOutputScaleX/Y).

However then the whole canvas is scaled with that factor, and we get a blur. See the attached images (but you have to zoom, and maybe view them in a graphics program and not in a browser).

enter image description here

Then I thought that we can adjust the original coordinates such that the scaled coordinates will result in integers. For example to make sure we hit an integer with offset 9 * 1.25 = 11.25 on the scaled image, let's try to target 11.0 instead, i.e. change this line

gc.rect(9.0, 9.0, 50, 50); 

to

gc.rect(11.0/1.25, 11.0/1.25, 50, 50); 

But this still results in a blur at the edge.

How can we work around this? Ideally I would like to turn off dpi scaling completely for the canvas, and do my own (pixel-perfect) calculations. Is this possible or is there any other solution?

For drawImage there is a parameter setSmoothing, but there is nothing when drawing shapes directly (like rect).


Solution

  • You can solve this by eliminating the scaling used by scaling the Canvas to a smaller size. Here is a sample (note that lines are drawn "in between" pixels in JavaFX, so to get them sharp you need to add 0.5).

    This example also places a Group around the scaled Canvas. This is required to take the scale into account when doing layout, but also has some effect on rendering. This example was tested at 100%, 125% and 150%.

    import javafx.application.Application;
    import javafx.beans.property.SimpleDoubleProperty;
    import javafx.scene.Group;
    import javafx.scene.Scene;
    import javafx.scene.canvas.Canvas;
    import javafx.scene.canvas.GraphicsContext;
    import javafx.scene.layout.HBox;
    import javafx.scene.paint.Color;
    import javafx.stage.Stage;
    
    public class CanvasTest extends Application {
    
      public static void main(String[] args) {
        Application.launch(args);
      }
    
      @Override
      public void start(Stage stage) throws Exception {
        HBox hbox = new HBox();
    
        Canvas canvas = new Canvas(100,100);
    
        GraphicsContext g2d = canvas.getGraphicsContext2D();
    
        g2d.fillRect(15, 15, 60, 10);
        g2d.fillRect(15.5, 30, 60, 10);  // supposed to be blurry
        g2d.fillRect(16, 45, 60, 10);
        g2d.fillRect(16.5, 60, 60, 10);  // supposed to be blurry
    
        g2d.setStroke(Color.RED);
        g2d.strokeLine(13.5, 13.5, 13.5, 93.5);
        g2d.strokeLine(13.5, 13.5, 93.5, 93.5);
        hbox.getChildren().add(new Group(canvas));
    
        Scene scene = new Scene(hbox);
    
        stage.setScene(scene);
        stage.show();
    
        canvas.scaleXProperty().bind(new SimpleDoubleProperty(1.0).divide(scene.getWindow().outputScaleXProperty()));
        canvas.scaleYProperty().bind(new SimpleDoubleProperty(1.0).divide(scene.getWindow().outputScaleYProperty()));
      }
    }
    

    Here is how it looks on a 150% monitor (left side is scaled up 5 times so you can see there is no blur):

    enter image description here

    And here is how it looks at 125% with the updated example:

    enter image description here