3

I'm developing a Java application where several JPanels (not JFrames) have complex animations that necessitate drawing to an off-screen buffer before blitting to the display surface. A problem I'm having is that Swing is performing UI scaling for high-DPI screens, and the off-screen buffer (a raster) isn't "aware" of the scaling. Consequently, when text or graphics are rendered to the buffer, and the buffer is blitted to the JPanel, Swing scales the graphic as a raster and the result looks like garbage.

A simple example is:

import java.awt.*;
import java.awt.geom.Line2D;

import javax.swing.JComponent;
import javax.swing.JFrame;

public class Main {
    public static void main(String[] args) {
        JFrame jf = new JFrame("Demo");
        Container cp = jf.getContentPane();
        MyCanvas tl = new MyCanvas();
        cp.add(tl);
        jf.setSize(500, 250);
        jf.setVisible(true);
        jf.setDefaultCloseOperation( JFrame.EXIT_ON_CLOSE );
    }
}

class MyCanvas extends JComponent {

    @Override
    public void paintComponent(Graphics g) {
        if( g instanceof Graphics2D g2 ) {
            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                RenderingHints.VALUE_ANTIALIAS_ON);

            g2.setFont( Font.decode( "Times New Roman-26" ) );
            g2.drawString("The poorly-scaled cake is a lie.",70,40);
            g2.setStroke( new BasicStroke( 2.3f ) );
            g2.draw( new Line2D.Double( 420, 10, 425, 70 ) );

            Image I = createImage( 500, 150 );
            Graphics2D g2_ = (Graphics2D)I.getGraphics();
            g2_.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                RenderingHints.VALUE_ANTIALIAS_ON);
            g2_.setColor( Color.BLACK );
            g2_.setFont( Font.decode( "Times New Roman-26" ) );
            g2_.drawString( "The poorly-scaled cake is a lie.",70,40 );
            g2_.setStroke( new BasicStroke( 2.3f ) );
            g2_.draw( new Line2D.Double( 420, 10, 425, 70 ) );
            g2_.dispose();
            g2.drawImage( I, 0, 130, null );
        }
    }
}

From this, compiling with JDK 20 on my Windows 11 machine, I get:

example of poor UI scaling in Java Swing

On the top is text and graphics rendered directly to the JPanel. On the bottom is the same content rendered via an intermediary image.

Ideally, I'm looking for a method, e.g., Image createScalingAwareBuffer( JPanel jp, int width, int height ) that returns an image I, in the same vein as JPanel.createImage( ... ) but where the returned Image is vector scaling aware, such that jp.drawImage( I ) or equivalent displays the lower graphic content identically to the upper content.

I suspect that rendering to the back buffer in a double-buffered Swing component has this kind of "awareness", but this isn't an option in my case since I need to precisely control when buffer flips occur on a panel-by-panel basis, which (insofar as I know) is impossible in Swing.

Is there any solution for this without a radical rewrite (i.e., migrating away from Swing, etc.)?

I should also note that I don't want to disable the UI scaling (e.g., using -Dsun.java2d.uiScale=1 in VM options), hence "just disable UI scaling" isn't really a solution.

COTO
  • 163
  • 1
  • 5
  • 1
    I'm curious as to the reason for creating a "off screen buffer" image within `paintComponent`, which is already double buffered, but lets, for the moment, just assume that this is a demonstration of the problem and not the actual implementation. You could make use of the `Graphics` current transformation, `Graphics2D#getTransform` and it's `getScaleX/getScaleY` methods - you could then generate an image and apply this scaling to it (based on what I've read) – MadProgrammer Aug 08 '23 at 21:38
  • 1
    https://stackoverflow.com/questions/43057457/jdk-9-high-dpi-disable-for-specific-panel might help a bit – MadProgrammer Aug 08 '23 at 21:40

1 Answers1

2

There is something like scalingAwareBuffer. It’s the RenderableImage interface.

I’m not certain this will help, but in theory it should. RenderableImage looks like it has a lot of methods, but most of them are very simple. The important ones are the three create… methods.

Even this won’t produce an identical copy from the image (due to the use of the GPU when drawing directly to the screen?), but the image should at least scale properly.

import java.util.Vector;
import java.util.Map;

import java.awt.Container;
import java.awt.Color;
import java.awt.Font;
import java.awt.Image;
import java.awt.Shape;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.BasicStroke;
import java.awt.RenderingHints;
import java.awt.EventQueue;

import java.awt.geom.Line2D;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.awt.geom.AffineTransform;

import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.awt.image.renderable.RenderableImage;
import java.awt.image.renderable.RenderContext;

import javax.swing.JComponent;
import javax.swing.JFrame;

public class ScaledImageRenderExample1 {
    public static void main(String[] args) {
        EventQueue.invokeLater(() -> {
            JFrame jf = new JFrame("Demo");
            Container cp = jf.getContentPane();
            MyCanvas tl = new MyCanvas();
            cp.add(tl);
            jf.setSize(500, 250);
            jf.setLocationByPlatform( true );
            jf.setVisible(true);
            jf.setDefaultCloseOperation( JFrame.EXIT_ON_CLOSE );
        });
    }

    static class MyCanvas extends JComponent {
        private static final long serialVersionUID = 1;

        @Override
        public void paintComponent(Graphics g) {
            super.paintComponent(g);
            if( g instanceof Graphics2D g2 ) {
                g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                    RenderingHints.VALUE_ANTIALIAS_ON);

                g2.setFont( Font.decode( "Times New Roman-26" ) );
                g2.drawString("The poorly-scaled cake is a lie.",70,40);
                g2.setStroke( new BasicStroke( 2.3f ) );
                g2.draw( new Line2D.Double( 420, 10, 425, 70 ) );

                g2.drawRenderableImage(new CakeImage(),
                    AffineTransform.getTranslateInstance(0, 130));
            }
        }
    }

    static class CakeImage implements RenderableImage {
        private static final int DEFAULT_WIDTH = 500;
        private static final int DEFAULT_HEIGHT = 150;

        private static final RenderingHints DEFAULT_HINTS =
            new RenderingHints(Map.of(
                RenderingHints.KEY_ANTIALIASING,
                RenderingHints.VALUE_ANTIALIAS_ON,

                RenderingHints.KEY_TEXT_ANTIALIASING,
                RenderingHints.VALUE_TEXT_ANTIALIAS_ON,

                RenderingHints.KEY_FRACTIONALMETRICS,
                RenderingHints.VALUE_FRACTIONALMETRICS_ON,

                RenderingHints.KEY_RESOLUTION_VARIANT,
                RenderingHints.VALUE_RESOLUTION_VARIANT_SIZE_FIT,

                RenderingHints.KEY_RENDERING,
                RenderingHints.VALUE_RENDER_QUALITY,

                RenderingHints.KEY_INTERPOLATION,
                RenderingHints.VALUE_INTERPOLATION_BICUBIC,

                RenderingHints.KEY_STROKE_CONTROL,
                RenderingHints.VALUE_STROKE_PURE,

                RenderingHints.KEY_COLOR_RENDERING,
                RenderingHints.VALUE_COLOR_RENDER_QUALITY
        ));


        private final float x;
        private final float y;

        CakeImage() {
            this(0, 0);
        }

        CakeImage(float x,
                  float y) {

            this.x = x;
            this.y = y;
        }

        @Override
        public float getMinX() {
            return x;
        }

        @Override
        public float getMinY() {
            return y;
        }

        @Override
        public float getWidth() {
            return DEFAULT_WIDTH;
        }

        @Override
        public float getHeight() {
            return DEFAULT_HEIGHT;
        }

        @Override
        public boolean isDynamic() {
            return false;
        }

        @Override
        public Object getProperty(String name) {
            return Image.UndefinedProperty;
        }

        @Override
        public String[] getPropertyNames() {
            return new String[0];
        }

        @Override
        public Vector<RenderableImage> getSources() {
            return null;
        }

        private void drawIn(Graphics2D g2) {
            g2.setColor( Color.BLACK );
            g2.setFont( Font.decode( "Times New Roman-26" ) );
            g2.drawString( "The poorly-scaled cake is a lie.",70,40 );
            g2.setStroke( new BasicStroke( 2.3f ) );
            g2.draw( new Line2D.Double( 420, 10, 425, 70 ) );
        }

        @Override
        public RenderedImage createDefaultRendering() {
            BufferedImage image = new BufferedImage(
                DEFAULT_WIDTH, DEFAULT_HEIGHT, BufferedImage.TYPE_INT_ARGB);

            Graphics2D g2 = (Graphics2D) image.getGraphics();
            RenderingHints hints = g2.getRenderingHints();
            hints.putAll(DEFAULT_HINTS);
            g2.setRenderingHints(hints);
            drawIn(g2);
            g2.dispose();

            return image;
        }

        @Override
        public RenderedImage createScaledRendering(int width,
                                                   int height,
                                                   RenderingHints hints) {

            BufferedImage image = new BufferedImage(width, height,
                BufferedImage.TYPE_INT_ARGB);

            Graphics2D g2 = (Graphics2D) image.getGraphics();
            g2.setRenderingHints(hints);
            g2.scale((double) width / DEFAULT_WIDTH,
                     (double) height / DEFAULT_HEIGHT);
            drawIn(g2);
            g2.dispose();

            return image;
        }

        @Override
        public RenderedImage createRendering(RenderContext context) {
            Point2D size = new Point2D.Float(getWidth(), getHeight());

            Shape shape = context.getAreaOfInterest();
            if (shape != null) {
                Rectangle2D bounds = shape.getBounds2D();
                size = new Point2D.Double(
                    bounds.getWidth(), bounds.getHeight());
            }

            context.getTransform().transform(size, size);

            BufferedImage image = new BufferedImage(
                (int) Math.ceil(size.getX()),
                (int) Math.ceil(size.getY()),
                BufferedImage.TYPE_INT_ARGB);

            Graphics2D g2 = (Graphics2D) image.getGraphics();
            RenderingHints hints = context.getRenderingHints();
            if (hints != null) {
                g2.setRenderingHints(hints);
            } else {
                hints = g2.getRenderingHints();
                hints.putAll(DEFAULT_HINTS);
                g2.setRenderingHints(hints);
            }
            g2.setTransform(context.getTransform());
            drawIn(g2);
            g2.dispose();

            return image;
        }
    }
}
VGR
  • 40,506
  • 4
  • 48
  • 63
  • Curious that the rendering results aren't identical, as you say. But the result looks nice. The content being rendered by `drawIn()` is provided by multiple subroutines in my case, rather than being static. But I can adapt this to my needs by having the class compile a list of `Graphics2D` operations to perform when `drawIn()` is invoked (rather than performing them immediately), and dispatch them all at once. Thanks for the advice. – COTO Aug 08 '23 at 23:28
  • @VGR Just an FYI, I think Java 1.4 (or so), a `Graphics` context been passed to a Swing component is guaranteed to be an instance of `Graphics2D` – MadProgrammer Aug 08 '23 at 23:30
  • @MadProgrammer I’m aware, but the instanceof check was in the question, and I didn’t want to change the code more than I had to. – VGR Aug 09 '23 at 04:38