javawindowsswingjava-8java-21

Java Graphics2D.drawImage() Renders Blurry Image only on Windows 125% Scaling and Only on Java Versions after Java 8


I know that Java 9 fixed issues with screen scaling and resolution that were present with Swing in Java 8. However, I have a situation where the exact same code renders clearly if being run on Java 8 but blurrily if using any version of Java 9 and after (including the most recent LTS version Java 21 and Java 22). The catch is that this only happens on Windows if Windows display settings are set to 125% scaling.

These seem to be the conditions that need to all be the case simultaneously for this to happen:

If I simply draw my lines and text on the Graphics2D object directly (the one given to me by the JComponent's paint(Graphics g) function), then everything works fine even on Java 9+ with Windows 125% scaling. However, if I render the same exact graphics but use the drawImage() method, then it is blurry.

So, if I run my code on Java 8 under any of the above conditions (Windows set at 100% or 125% scaling, using the Graphics object directly or using drawImage(), doesn't matter), everything renders clearly (i.e., without any type of aliasing or dithering or whatever is going on).

If I run my code on any version of Java 9+ with Windows not at 125% scaling, everything renders clearly (same as on Java 8). If I run my code on any version of Java 9+ with Windows at 125% scaling but using the Graphics object directly (and not drawImage()), then everything renders clearly. It is only if I use the drawImage() method that things go south.

I have a minimum reproducible example:

Here is how it renders on Java 22 with Windows at 125% scaling not using the drawImage() method:

Minimum Reproducible Example showing clearly rendered graphics

Here is how it renders on Java 22 with Windows at 125% scaling using the drawImage() method:

Minimum Reproducible Example showing blurrily rendered graphics

However, if I use Java 8, then there is no difference at all between using drawImage() vs. not using drawImage().

Here is the code:

public class MainWindow extends JFrame implements ActionListener {
    
    DrawPanel dp;

    public static void main(String[] args) {
        new MainWindow();
    }
    
    public MainWindow() {
        super("Graphics Test");
        setLayout(new BorderLayout());
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        
        JButton button = new JButton("Redraw");
        button.addActionListener(this);
        add(button, BorderLayout.PAGE_START);
        
        JPanel center = new JPanel(new FlowLayout());
        dp = new DrawPanel();
        center.add(dp);
        add(center, BorderLayout.CENTER);
        
        pack();
        setLocationRelativeTo(null);
        setVisible(true);       
    }

    @Override
    public void actionPerformed(ActionEvent e) {
        dp.drawOnImage = (e.getModifiers() & ActionEvent.CTRL_MASK) == ActionEvent.CTRL_MASK;
        dp.repaint();
    }
}

And the custom component. Notice that holding CTRL will trigger this to render using drawImage() whereas without CTRL it will use the Graphics object directly:

public class DrawPanel extends JComponent {
    
    Graphics2D gSave;
    BufferedImage bi;
    boolean drawOnImage = false;
    
    public DrawPanel() {
    }
    
    @Override
    public Dimension getPreferredSize() {
        return new Dimension(400, 400);
    }

    @Override
    public void paint(Graphics gr) {
        Graphics2D g = (Graphics2D) gr;
        gSave = g;
        
        if (drawOnImage) {
            bi = new BufferedImage(getSize().width, getSize().height, BufferedImage.TYPE_INT_ARGB);
            g = bi.createGraphics();
        }
        
        Font font = new Font("SansSerif", Font.BOLD, 36);
        g.setFont(font);
        g.setColor(Color.BLACK);
        g.drawString("Test String", 5, 40);
        g.setStroke(new BasicStroke(6.0001f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL, 0f, null, 0f));
        g.drawRect(100, 70, 100, 100);
        font = new Font("SansSerif", Font.PLAIN, 16);
        g.setFont(font);
        g.drawString("More Text to test the resolution", 5, 200);
        
        Rectangle rect2 = new Rectangle(300, 40, 100, 100);
        g.rotate(Math.toRadians(45));
        g.draw(rect2);
        
        if (drawOnImage) {
            g.dispose();
            gSave.drawImage(bi, null, 0, 0);
        }
    }
}

So the question is this: Why did an older version of Java have no problem with this but newer versions of Java seem to have degraded performance? Is there something that drawImage() does differently that I can mitigate to not have this result?

(And for the inevitable question about why I'm even doing this and why is this necessary (e.g. If it works, why don't you just directly draw on the Graphics object rather than using the drawImage() method?), I will note that the actual code I'm working on tries to draw translucent overlapping shapes on mouse dragging, which is why this BufferedImage approach using drawImage() was used. Perhaps there is a different way of doing that which doesn't require using drawImage() but I have not yet found it.)


Solution

  • Why did an older version of Java have no problem with this but newer versions of Java seem to have degraded performance?

    I think the answer is: (very) old versions of Java did not respect the monitor resolution at all. So even though your monitor is set to 125%, I think the app will launch at 100%. This is forgiveable when the difference is 125% vs 100%, but the difference becomes really stark if your monitor is set to 200% or 250% and you launch an old app that renders at 100%.

    For example, here is how your app rendered for me using old vs new JRE's:

    Comparison of GraphicsTest in Java 1.7 vs Java 16

    The Pixelation Problem

    Assuming we want to stay at 125% resolution, the problem is all about how we scale everything.

    By default the Graphics2D you're receiving from Swing has an AffineTransform that has been scaled to 1.25. So that's why when you paint your drawing without a BufferedImage it "just works".

    Things get more complicated when you add a BufferedImage. You're asking the BufferedImage to be 400x400. That's the virtual size of your window. But at 125% resolution, a 400x400px window really occupies 500x500px. So your image needs to be 500x500px to render really well.

    The Solution

    We need a 500x500 px image.

    Here is a modified copy of your app that should work much better. This takes into account the transform / scale factor of your destination.

    (There's a lot of room to discuss the design, but for brevity's sake this is a concise fix.)

    import javax.swing.*;
    import java.awt.*;
    import java.awt.event.ActionEvent;
    import java.awt.event.ActionListener;
    import java.awt.geom.AffineTransform;
    import java.awt.image.BufferedImage;
    
    public class MainWindow extends JFrame implements ActionListener {
    
        DrawPanel dp;
    
        public static void main(String[] args) {
            new MainWindow();
        }
    
        public MainWindow() {
            super("Graphics Test");
            setLayout(new BorderLayout());
            setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    
            JButton button = new JButton("Redraw");
            button.addActionListener(this);
            add(button, BorderLayout.PAGE_START);
    
            JPanel center = new JPanel(new FlowLayout());
            dp = new DrawPanel();
            center.add(dp);
            add(center, BorderLayout.CENTER);
    
            pack();
            setLocationRelativeTo(null);
            setVisible(true);
        }
    
        @Override
        public void actionPerformed(ActionEvent e) {
            dp.drawOnImage = (e.getModifiers() & ActionEvent.CTRL_MASK) == ActionEvent.CTRL_MASK;
            dp.repaint();
        }
    }
    
    class DrawPanel extends JComponent {
    
        boolean drawOnImage = false;
    
        public DrawPanel() {
        }
    
        @Override
        public Dimension getPreferredSize() {
            return new Dimension(400, 400);
        }
    
        @Override
        public void paint(Graphics gr) {
            Graphics2D g = (Graphics2D) gr;
    
            if (drawOnImage) {
                ScaledBufferedImage bi = new ScaledBufferedImage(getSize().width, getSize().height, BufferedImage.TYPE_INT_ARGB, Math.sqrt(g.getTransform().getDeterminant()));
                Graphics2D imgG = bi.createGraphics();
                imgG.setRenderingHints(g.getRenderingHints());
                paintImage(imgG);
                imgG.dispose();
    
                g.drawImage(bi, 0, 0, getSize().width, getSize().height,
                        0, 0, bi.getWidth(), bi.getHeight(), null);
            } else {
                paintImage(g);
            }
        }
    
        private void paintImage(Graphics2D g) {
            Font font = new Font("SansSerif", Font.BOLD, 36);
            g.setFont(font);
            g.setColor(Color.BLACK);
            g.drawString("Test String", 5, 40);
            g.setStroke(new BasicStroke(6.0001f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL, 0f, null, 0f));
            g.drawRect(100, 70, 100, 100);
            font = new Font("SansSerif", Font.PLAIN, 16);
            g.setFont(font);
            g.drawString("More Text to test the resolution", 5, 200);
    
            Rectangle rect2 = new Rectangle(300, 40, 100, 100);
            g.rotate(Math.toRadians(45));
            g.draw(rect2);
        }
    }
    
    class ScaledBufferedImage extends BufferedImage {
        final int virtualWidth, virtualHeight;
        final double scaleFactor;
    
        public ScaledBufferedImage(int width, int height, int imageType, double scaleFactor) {
            super( (int) Math.round(width * scaleFactor), (int) Math.round(height * scaleFactor), imageType);
            virtualHeight = height;
            virtualWidth = width;
            this.scaleFactor = scaleFactor;
        }
    
        public Graphics2D createGraphics() {
            Graphics2D returnValue = super.createGraphics();
            returnValue.scale(scaleFactor, scaleFactor);
            return returnValue;
        }
    }