hageldave / JPlotter

OpenGL based 2D Plotting Library for Java using AWT and LWJGL
https://github.com/hageldave/JPlotter/wiki
MIT License
45 stars 6 forks source link

BarycentricGradientPaint triangle does not overlap with a triangle painted using drawPolygon #54

Closed hrkalona closed 1 year ago

hrkalona commented 1 year ago

Using this, you can see that a white line appears, even if the fill takes place after the polygonFill public class TriangleExample {

public static void main(String[] args) throws Exception {
    // Create a new BufferedImage object
    int width = 400;
    int height = 400;
    BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);

    // Get the Graphics2D object
    Graphics2D g2d = image.createGraphics();

    // Define three vertices of the triangle
    int[] xPoints = {50, 350, 200};
    int[] yPoints = {50, 50, 350};

    // Define three colors for the gradient
    Color color1 = Color.RED;
    Color color2 = Color.GREEN;
    Color color3 = Color.BLUE;

    // Draw the triangle with a linear gradient
    Polygon triangle = new Polygon(xPoints, yPoints, 3);
    Point2D p1 = new Point2D.Float(xPoints[0], yPoints[0]);
    Point2D p2 = new Point2D.Float(xPoints[1], yPoints[1]);
    Point2D p3 = new Point2D.Float(xPoints[2], yPoints[2]);

    g2d.setColor(Color.WHITE);
    g2d.drawPolygon(triangle);
    BarycentricGradientPaint gradient = new BarycentricGradientPaint(p1, p2, p3, color1, color2, color3);
    g2d.setPaint(gradient);
    g2d.fill(triangle);

    // Save the image to a file
    File output = new File("triangle.png");
    ImageIO.write(image, "png", output);
}
hageldave commented 1 year ago

oh I'm sorry, I've just seen this now. Let me look into this.

hageldave commented 1 year ago

Okay, so I see what you mean. I made another test against a regular color fill and it seems like it is expected that you see the outline.

g2d.setColor(Color.WHITE);
g2d.drawPolygon(triangle);

g2d.setColor(Color.BLUE);
g2d.fillPolygon(triangle);

solid_fill

whereas the barycentric gradient fill produces the following:

g2d.setColor(Color.WHITE);
g2d.drawPolygon(triangle);

g2d.setPaint(new BarycentricGradientPaint(p1, p2, p3, color1, color2, color3));
g2d.fillPolygon(triangle);

gradient_fill

However, it appears that the right edge is thicker, meaning that the gradient fill extends like 1 pixel less to the right than the solid color fill. Here's a zoomed in comparison: comparison

I need to look into this more deeply. It could be that it was implemented like this on purpose, so that multiple triangles can be put next to each other seamlessly (each connecting on one edge of the other) for triangle meshes.

hageldave commented 1 year ago

Okay I did some more testing. Two things to note:

  1. The order of the points (winding clockwise vs counter clockwise) makes a difference in drawing a polyline. So there are no pixel perfect guarantees for polylines in the first place.
    
    int[] xPoints    = { 50, 350, 200};
    int[] yPoints    = { 50,  50, 350};
    int[] xPointsCC  = {350,  50, 200};
    int[] yPointsCC  = { 50,  50, 350 };

Polygon triangle = new Polygon(xPoints, yPoints, 3); g2d.setColor(Color.WHITE); g2d.drawPolygon(triangle);

Polygon triangleCC = new Polygon(xPointsCC, yPointsCC, 3); g2d.setColor(Color.BLUE); g2d.drawPolygon(triangleCC);


![winding_difference](https://user-images.githubusercontent.com/2974361/231903152-950a41a4-2143-4014-a1a4-586160f28923.png)

2. `BarycentricGradientPaint` currently has a half-pixel shift in coordinates, which is debatable. 
https://github.com/hageldave/JPlotter/blob/878bb5fdca6e9e3c2b13265789e99af2cc3f55b2/jplotter/src/main/java/hageldave/jplotter/util/BarycentricGradientPaint.java#L268-L274

![shift-vs-noshift](https://user-images.githubusercontent.com/2974361/231900505-d4d6278f-a648-4df7-a208-da61b7547850.png)
On the right side is the result without the `+.5f` shift. Left and right edge are consistent with the solid color fill in this case, but the top edge of the gradient fill is now fuzzy. The fuzzy edge is probably an artifact due to numeric inaccuracies, a very minor subpixel shift, e.g. `+.001f` could do the trick. This needs some more thinking and testing though. And if this will be done, the sample positions for the multi sampling anti aliasing (MSAA) need to be made consistent. 
hageldave commented 1 year ago

@hrkalona : all of this is probably not helping with your problem though. Since the regular solid color fill

g2d.setColor(Color.WHITE);
g2d.drawPolygon(triangle);
g2d.setColor(Color.BLUE);
g2d.fillPolygon(triangle);

also does not cover the polygon lines perfectly, I'm not so sure if there will be a satisfactory solution for your use case. The problem seems to be rooted in the way polylines are drawn and how line drawing differs from area filling. Maybe there is a workaround for your problem? For the BarycentricGradientPaint you could shift the Point2D coordinates by +.499f to get consistent with the solid color fill behavior. You can also try to grow the triangle very slightly for the filling.

hrkalona commented 1 year ago

The actual problem appeared when I wanted to use BarycentricGradientPaint on a mesh of triangles.

The original mesh with each triangle rendered with a single color, using graphics2d fill method was working ok. When I tried to use the gradient paint, I was observing lines between the triangles.

I will post an image in a couple of days, so you can see the actual issue, since I am currently off for Easter vacations.

Thanks for taking the time to look into it!

hageldave commented 1 year ago

Ah I see. You need to use g2d.fillRect(x,y,w,h) because fillPolygon(Polygon) or fill(Shape) will perform clipping on the paint which can result in edges being cut off slightly.

See this part of the triangle renderer: https://github.com/hageldave/JPlotter/blob/14ef91f1b63dca2711d998dfe0c673f9150579dc/jplotter/src/main/java/hageldave/jplotter/renderers/TrianglesRenderer.java#L282-L288

hrkalona commented 1 year ago

Thanks I will test it. If I got it correctly even though you fill a rectangle, because you use the BarycentricGradientPaint, you only fill with color only a triangle inside the rectangle?

hageldave commented 1 year ago

Exactly. Also, there needs to be some testing done with the mentioned subpixel shift if this will allow seamless filling with polygons.

@lvcarx if you have time for this, could you do some tests with 0.001f shift instead of 0.5f and fillPolygon(...) instead of fillRect(..)?

reichmla commented 1 year ago

Had a brief look at it and it looks like it's better with a shift of 0.001f (no artifacts at the top & same line width on the right/left side).

Smaller values seem to lead to the artifacts at the top again.

triangle

hageldave commented 1 year ago

cool, if you could you also try a mesh of triangles and see if there are obvious seams between the triangles that would be great. Until now the code always used fillRect() with a rectangle slightly larger than the triangle. Maybe with the change in subpixel shift, we can also use fillPolygon() and still have seamless connections between adjacent triangles.

hrkalona commented 1 year ago

It worked great with fillRect. The meshing is seamless:

image

reichmla commented 1 year ago
package hageldave.jplotter;

import hageldave.imagingkit.core.util.ImageFrame;
import hageldave.jplotter.util.BarycentricGradientPaint;

import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.io.File;

public class BarycentricTest {
    public static void main(String[] args) throws Exception {
        // Create a new BufferedImage object
        int width = 800;
        int height = 800;
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);

        // Get the Graphics2D object
        Graphics2D g2d = image.createGraphics();

        // Define three vertices of the triangle
        int[] xPoints = {50, 350, 200};
        int[] yPoints = {50, 50, 350};

        int[] xPoints2 = {350, 200, 500};
        int[] yPoints2 = {50, 350, 350};

        int[] xPoints3 = {350, 650, 500};
        int[] yPoints3 = {50, 50, 350};

        int[] xPoints4 = {-100, 50, 200};
        int[] yPoints4 = {350, 50, 350};

        int[] xPoints5 = {200, 500, 350};
        int[] yPoints5 = {350, 350, 650};

        int[] xPoints6 = {500, 650, 350};
        int[] yPoints6 = {350, 650, 650};

        // Define three colors for the gradient
        Color color1 = Color.RED;
        Color color2 = Color.GREEN;
        Color color3 = Color.BLUE;

        // Draw the triangle with a linear gradient
        Polygon triangle = new Polygon(xPoints, yPoints, 3);
        Point2D p1 = new Point2D.Float(xPoints[0], yPoints[0]);
        Point2D p2 = new Point2D.Float(xPoints[1], yPoints[1]);
        Point2D p3 = new Point2D.Float(xPoints[2], yPoints[2]);

        Polygon triangle2 = new Polygon(xPoints2, yPoints2, 3);
        Point2D p21 = new Point2D.Float(xPoints2[0], yPoints2[0]);
        Point2D p22 = new Point2D.Float(xPoints2[1], yPoints2[1]);
        Point2D p23 = new Point2D.Float(xPoints2[2], yPoints2[2]);

        Polygon triangle3 = new Polygon(xPoints3, yPoints3, 3);
        Point2D p31 = new Point2D.Float(xPoints3[0], yPoints3[0]);
        Point2D p32 = new Point2D.Float(xPoints3[1], yPoints3[1]);
        Point2D p33 = new Point2D.Float(xPoints3[2], yPoints3[2]);

        Polygon triangle4 = new Polygon(xPoints4, yPoints4, 3);
        Point2D p41 = new Point2D.Float(xPoints4[0], yPoints4[0]);
        Point2D p42 = new Point2D.Float(xPoints4[1], yPoints4[1]);
        Point2D p43 = new Point2D.Float(xPoints4[2], yPoints4[2]);

        Polygon triangle5 = new Polygon(xPoints5, yPoints5, 3);
        Point2D p51 = new Point2D.Float(xPoints5[0], yPoints5[0]);
        Point2D p52 = new Point2D.Float(xPoints5[1], yPoints5[1]);
        Point2D p53 = new Point2D.Float(xPoints5[2], yPoints5[2]);

        Polygon triangle6 = new Polygon(xPoints6, yPoints6, 3);
        Point2D p61 = new Point2D.Float(xPoints6[0], yPoints6[0]);
        Point2D p62 = new Point2D.Float(xPoints6[1], yPoints6[1]);
        Point2D p63 = new Point2D.Float(xPoints6[2], yPoints6[2]);

        g2d.setColor(Color.WHITE);
        //g2d.drawPolygon(triangle);

        g2d.setPaint(new BarycentricGradientPaint(p1, p2, p3, color1, color2, color3));
        g2d.fillPolygon(triangle);

        g2d.setPaint(new BarycentricGradientPaint(p21, p22, p23, color2, color3, color1));
        g2d.fillPolygon(triangle2);

        g2d.setPaint(new BarycentricGradientPaint(p31, p32, p33, color2, color3, color1));
        g2d.fillPolygon(triangle3);

        g2d.setPaint(new BarycentricGradientPaint(p41, p42, p43, color2, color1, color3));
        g2d.fillPolygon(triangle4);

        g2d.setPaint(new BarycentricGradientPaint(p51, p52, p53, color3, color1, color2));
        g2d.fillPolygon(triangle5);

        g2d.setPaint(new BarycentricGradientPaint(p61, p62, p63, color1, color3, color2));
        g2d.fillPolygon(triangle6);

        // Save the image to a file
        ImageFrame.display(image);
        File output = new File("triangle.png");
        ImageIO.write(image, "png", output);
    }

}
reichmla commented 1 year ago

With rotation:

image

Without rotation:

image

Corresponding code:

public class BarycentricTest {
    public static void main(String[] args) throws Exception {
        // Create a new BufferedImage object
        int width = 800;
        int height = 800;
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);

        // Get the Graphics2D object
        Graphics2D g2d = image.createGraphics();

        AffineTransform at = new AffineTransform();
        at.rotate(Math.toRadians(10));

        // Define three vertices of the triangle
        int[] xPoints = {50, 350, 200};
        int[] yPoints = {50, 50, 350};

        int[] xPoints2 = {350, 200, 500};
        int[] yPoints2 = {50, 350, 350};

        int[] xPoints3 = {350, 650, 500};
        int[] yPoints3 = {50, 50, 350};

        int[] xPoints4 = {-100, 50, 200};
        int[] yPoints4 = {350, 50, 350};

        int[] xPoints5 = {200, 500, 350};
        int[] yPoints5 = {350, 350, 650};

        int[] xPoints6 = {500, 650, 350};
        int[] yPoints6 = {350, 650, 650};

        // Define three colors for the gradient
        Color color1 = Color.RED;
        Color color2 = Color.GREEN;
        Color color3 = Color.BLUE;

        // Triangle 1
        drawTriangle(xPoints, yPoints, color1, color2, color3, at, g2d);
        // Triangle 2
        drawTriangle(xPoints2, yPoints2, color2, color3, color1, at, g2d);
        // Triangle 3
        drawTriangle(xPoints3, yPoints3, color2, color3, color1, at, g2d);
        // Triangle 4
        drawTriangle(xPoints4, yPoints4, color2, color1, color3, at, g2d);
        // Triangle 5
        drawTriangle(xPoints5, yPoints5, color3, color1, color2, at, g2d);
        // Triangle 6
        drawTriangle(xPoints6, yPoints6, color1, color3, color2, at, g2d);

        // Save the image to a file
        ImageFrame.display(image);
        File output = new File("triangle.png");
        ImageIO.write(image, "png", output);
    }

    public static void drawTriangle(int[] xPoints, int[] yPoints, Color color1, Color color2, Color color3, AffineTransform at, Graphics2D g2d) {
        g2d.setColor(Color.WHITE);

        int[][] transformedCoords = transformCoords(xPoints, yPoints, at);
        int[] xPointsDouble = transformedCoords[0];
        int[] yPointsDouble = transformedCoords[1];

        Polygon triangle = new Polygon(xPoints, yPoints, 3);
        Point2D p1 = new Point2D.Float((float) xPointsDouble[0], (float) yPointsDouble[0]);
        Point2D p2 = new Point2D.Float((float) xPointsDouble[1], (float) yPointsDouble[1]);
        Point2D p3 = new Point2D.Float((float) xPointsDouble[2], (float) yPointsDouble[2]);

        g2d.setPaint(new BarycentricGradientPaint(p1, p2, p3, color1, color2, color3));
        g2d.fillPolygon(triangle);
    }

    public static int[][] transformCoords(int[] inputCoordsX, int[] inputCoordsY, AffineTransform at) {
        List<Double> xPointsList = Arrays.stream(inputCoordsX).asDoubleStream().boxed().collect(Collectors.toList());
        List<Double> yPointsList = Arrays.stream(inputCoordsY).asDoubleStream().boxed().collect(Collectors.toList());

        double[] mergedArray = new double[inputCoordsX.length + inputCoordsY.length];
        for (int i = 0; i < mergedArray.length; i++) {
            if (i % 2 == 0) {
                mergedArray[i] = xPointsList.get(0);
                xPointsList.remove(0);
            } else {
                mergedArray[i] = yPointsList.get(0);
                yPointsList.remove(0);
            }
        }

        double[] pointsDoubleDest = new double[mergedArray.length];

        // Coord transformation happens here
        at.transform(mergedArray, 0, pointsDoubleDest, 0, inputCoordsX.length);

        int x = 0;
        int y = 0;
        for (int i=0; i<mergedArray.length; i++){
            if (i % 2 == 0) {
                inputCoordsX[x] = (int) pointsDoubleDest[i];
                x++;
            } else {
                inputCoordsY[y] = (int) pointsDoubleDest[i];
                y++;
            }
        }
        return new int[][]{inputCoordsX, inputCoordsY};
    }
}
hageldave commented 1 year ago

Alright, thank you @lvcarx for further looking into this. We see that the polygon clipping is still interfering and results in visible seams between adjacent triangles despite the smaller subpixel shift. This means that the gradient paint is not perfectly compatible with polygon filling in its current state, so we will continue to use filling with triangle enclosing rectangles.