1

I'm currently working on an implementation of the card game "Munchkin" (for me and my friends to play during the pandemic) and display the Munchkin logo on the launcher. The code actually works pretty well in setting the logo exactly where it should be.

However I encounter an issue on resizing events of the JFrame: Initially when e.g. resizing the frame to its minimum size, the image takes about 5 seconds to load and only after that the content contained within the frame is updated. Before the reloading of the image is done, the content is only partially visible inside the screen, e.g. the buttons are still outside of the frame.

After repeatedly resizing the loading times of the UI seem to increase exponentially (possibly because the loading and scaling of the image is performed for multiple frame sizes between the sizes before and after scaling). Also the image takes a perceivable amount of time to load (like 0.5 seconds) when the frame is newly generated.

To make clearer what I mean, here's what it looks like initially and shortly after resizing:

Before:

enter image description here

After: enter image description here

To give you some insight here's the code, doing all the UI stuff:

package de.munchkin.frontend;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Image;
import java.awt.Toolkit;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;

import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;

public class MunchkinLauncher extends JFrame{

    private static final long serialVersionUID = 1598309638008663371L;
    
    private Toolkit toolkit = Toolkit.getDefaultToolkit();
    private Dimension screenDim = toolkit.getScreenSize();
    private JPanel contentPane;
    private JButton btnHostGame, btnJoinGame;
    private JLabel imageLabel;
    
    
    public static void main(String[] args) {
        new MunchkinLauncher();
    }
    
    public MunchkinLauncher() {
        setTitle("Munchkin Launcher");
        setDefaultCloseOperation(EXIT_ON_CLOSE);
        setLocation(screenDim.width / 4, screenDim.height / 4);
        setSize(screenDim.width / 2, screenDim.height / 2);
        setMinimumSize(new Dimension(700, 387));
        
        contentPane = new JPanel(null);
        contentPane.setLayout(null);
        setLayout(null);
        setContentPane(contentPane);
        contentPane.setBackground(new Color(253, 205, 136));
        
        setVisible(true);
        
        loadComponents();
        loadBounds();
        addActionListeners();
        
    }
    
    private void loadComponents() {
        
        // Load header image
        imageLabel = new JLabel("");
        
        btnHostGame = new JButton("Host Game");
        
        btnJoinGame = new JButton("Join Game");
        
    }
    
    private void loadBounds() {
        
        int width = contentPane.getSize().width;
        int height = contentPane.getSize().height;
        
        btnHostGame.setBounds(width/2 - 300, height * 2 / 3, 250, 100);
        contentPane.add(btnHostGame);
        
        btnJoinGame.setBounds(width/2 + 50, height * 2 / 3, 250, 100);
        contentPane.add(btnJoinGame);
        
        imageLabel.setIcon(new ImageIcon(new ImageIcon(this.getClass().getResource("/Munchkin_logo.jpg"))
                .getImage().getScaledInstance(width - 40, height * 2 / 3 - 40, Image.SCALE_DEFAULT)));
        imageLabel.setBounds(20, 0, width - 40, height * 2 / 3);
        contentPane.add(imageLabel);
        
        revalidate();
        repaint();
        
    }
    
    private void addActionListeners() {
        
        addComponentListener(new ComponentAdapter() {
            
            @Override
            public void componentResized(ComponentEvent e) {
                super.componentResized(e);
                loadBounds();
            }
            
        });
        
        btnHostGame.addActionListener(e -> {
            new MatchCreation();
            dispose();
        });
        
        btnJoinGame.addActionListener(e -> {
            new MatchJoin();
            dispose();
        });
        
    }
    
    
}

I also tried to load the inner ImageIcon resource itself separately in loadComponents() in order to reduce loading times, however it appears to me as if it takes even longer to update by doing so. I think it could have something to do with the image's original size as it has a resolution of 4961 x 1658 px. I also tried different scaling algorithms and also using Image.SCALE_FAST didn't perceivably accelerate the process.

What puzzles me the most is that this process is executed on high-end hardware (3900X + 2080Ti) which means that mid-range or low-end PCs probably would take even longer to load and update this which makes this even more absurd to me. If there was no other solution I assume just even basic 2D games would still lag as hell on nowadays hardware which they obviously don't.

Are there any best practices when loading images for resizing that I'm missing or how could I solve this issue in general?

halfer
  • 19,824
  • 17
  • 99
  • 186
Samaranth
  • 385
  • 3
  • 16
  • 1
    Not sure, cause not experienced in this, but look here: https://stackoverflow.com/questions/16497853/scale-a-bufferedimage-the-fastest-and-easiest-way – Janos Vinceller Jan 07 '21 at 12:04
  • @JanosVinceller Thanks, it's an idea to start at. But apart from that I think that nevertheless Java should be able to perform such operations very much faster than this out of the box. I can't imagine that vanilla Java takes several hundred milliseconds (or several seconds) on high-end hardware for an action that requires other libraries (HW unknown) 80ms and in case of openCV even only 13ms (as given in the example answer of the post you linked). Actually taking into account that the original size is larger and the scaled size is much smaller than in my case. This just doesn't feel right. – Samaranth Jan 07 '21 at 12:15
  • @JanosVinceller also I'm not sure whether I can integrate this correctly as JLabel takes an `ImageIcon` whereas the methods in the linked article are utilizing `BufferedImage`. – Samaranth Jan 07 '21 at 12:24
  • 3
    At least part of the problem here, is that you not only re-render, but *re-loads and re-scales* the entire image each time the component is resized. Given the size of your image, this is likely to waste quite a bit of memory, leading to excessive garbage collection and sluggish UI response as a result. Instead, read the image *once* in the constructor, and it's probably better to *not* use `getScaledInstance()` and `ImageIcon`, but instead use a custom `JPanel` with an image background (see @JanosVinceller's link). – Harald K Jan 07 '21 at 13:25
  • Ok, thanks for that advice. Then I will try to do it this way. I thought the recommended way for displaying images in JFrames was using a JLabel as this is what mostly comes up when searching for it which is why I tried doing it this way. – Samaranth Jan 07 '21 at 13:42
  • 1
    I'd definitely add "swing" to your keywords. Would additionaly remove "jlabel" and "imageicon". – Janos Vinceller Jan 07 '21 at 14:13
  • Also check the image size (it looks too good). If it is a _large_ JPEG in order to reduce artifact blocks, try use png. – Joop Eggen Jan 07 '21 at 14:37
  • Just a point: Steve Jackson Games can be picky about using their IP without permission. – NomadMaker Jan 07 '21 at 15:29

1 Answers1

3

So, I re-wrote parts of your code.

If you ran your old code and added some outputs, in the output you would see where the actual problem lies: in the amount of times that the resizing is actually done. Second problem is the following: if you use getScaledInstance() together with new ImageIcon(), the ImageIcon initialization will wait until the picture has been created/painted COMPLETELY. This will BLOCK all other UI operations until finished.

Another small problem was that you re-add controls to their parents, which also might cause lots of unnecessary re-layouting and re-painting.

A much easier approach is to use/write an ImagePanel. The Graphics.drawImage() method has an ImageObserver. Whenever you call drawImage(), large images can be drawn asynchronously (non-blocking) and thus you will not have such horrific load times. PLUS it will do validation etc on its own, so you do not need any calls to that, which might cause unnecssary layouting and repainting.

In the example below I also included some additional hints like using 'final', 'early initialization' and 'fail-fast-fail-early'.

So lo an behold how simple the solution is:

Example:

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Image;
import java.awt.Toolkit;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.net.URL;

import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JPanel;

public class MunchkinLauncher extends JFrame {

    private static final long serialVersionUID = 1598309638008663371L;

    private final Toolkit       toolkit     = Toolkit.getDefaultToolkit();
    private final Dimension     screenDim   = toolkit.getScreenSize();
    private final JPanel        contentPane = new JPanel(null);
    private final JButton       btnHostGame = new JButton("Host Game");     // make as many 'final' as possible. earlier initialization saves you from a lot of problems when it comes to inheritance and events
    private final JButton       btnJoinGame = new JButton("Join Game");
    private final ImagePanel    imageLabel  = new ImagePanel();

    private final Image backgroundImageicon; // pardon my camelCase, imo composite logic is more important


    public static void main(final String[] args) {
        new MunchkinLauncher().setVisible(true);
    }

    public MunchkinLauncher() {
        setDefaultCloseOperation(EXIT_ON_CLOSE); // should always be first, you never know what exception happens next and this might keep a lot of JVMs running if due to exception a visual jframe is not disposed properly
        setTitle("Munchkin Launcher");
        setLocation(screenDim.width / 4, screenDim.height / 4);
        setSize(screenDim.width / 2, screenDim.height / 2);
        setMinimumSize(new Dimension(700, 387));
        setLayout(null);

        contentPane.setLayout(null);
        contentPane.setBackground(new Color(253, 205, 136));
        contentPane.add(imageLabel);
        contentPane.add(btnHostGame);
        contentPane.add(btnJoinGame);
        setContentPane(contentPane);

        loadComponents();

        backgroundImageicon = loadBgImage();
        imageLabel.setImage(backgroundImageicon);

        loadBounds();

        addActionListeners();

        //      setVisible(true); // should be last, unless you need something specific to happen durint init
        // and ACTUALLY it should be used outside the CTOR so caller has more control
    }

    private Image loadBgImage() {
        final long nowMs = System.currentTimeMillis();
        final URL res = this.getClass().getResource("/Munchkin_logo.jpg");

        // load Image
        //      final ImageIcon ret1 = new ImageIcon(res); // is not as good, we eventually only need an Image, we do not need the wrapper that ImageIcon provides
        //      final BufferedImage ret2 = ImageIO.read(res); // is better, but gives us BufferedImage, which is also more than we need. plus throws IOException
        final Image ret3 = Toolkit.getDefaultToolkit().getImage(res); // best way. If behaviour plays a role: Loads the image the same way that ImageIcon CTOR would.

        final long durMs = System.currentTimeMillis() - nowMs;
        System.out.println("Loading BG Image took " + durMs + "ms.");

        return ret3;
    }

    private void loadComponents() {}

    void loadBounds() {
        final int width = contentPane.getSize().width;
        final int height = contentPane.getSize().height;
        btnHostGame.setBounds(width / 2 - 300, height * 2 / 3, 250, 100);
        btnJoinGame.setBounds(width / 2 + 50, height * 2 / 3, 250, 100);
        imageLabel.setBounds(20, 0, width - 40, height * 2 / 3);
    }

    private void addActionListeners() {
        addComponentListener(new ComponentAdapter() {
            @Override public void componentResized(final ComponentEvent e) {
                super.componentResized(e);
                loadBounds(); // this access needs protected loadBounds() or synthetic accessor method
            }
        });
        btnHostGame.addActionListener(e -> {
            System.out.println("MunchkinLauncher.addActionListeners(1)");
            //          new MatchCreation();
            dispose();
        });
        btnJoinGame.addActionListener(e -> {
            System.out.println("MunchkinLauncher.addActionListeners(2)");
            //          new MatchJoin();
            dispose();
        });
    }
}

and the ImagePanel:

import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.RenderingHints;

import javax.swing.JPanel;

public class ImagePanel extends JPanel {


    private static final long serialVersionUID = 6196514831308649310L;



    private Image mImage;

    public ImagePanel(final Image pImage) {
        mImage = pImage;
    }
    public ImagePanel() {}


    public Image getImage() {
        return mImage;
    }
    public void setImage(final Image pImage) {
        mImage = pImage;
        repaint();
    }



    @Override protected void paintComponent(final Graphics pG) {
        if (mImage == null) return;

        final Graphics2D g = (Graphics2D) pG;
        g.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);// can add more
        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        g.drawImage(mImage, 0, 0, getWidth(), getHeight(), this);
    }
}

EDIT: On a side note, you should limit the your embedded image size to reasonable sizes, for example 4K if you used in in full-screen display. As a windowed controls display, 720p usually suffices. Everything else only bloats you software and makes it slower. In lots of cases the use of vector graphics goes recommended.

JayC667
  • 2,418
  • 2
  • 17
  • 31
  • Thank you very much, this works perfectly. I just have a question regarding your comment on the call to `loadBounds()` in the `ComponentListener`: Why does it need to be `protected`? Setting it to `private` doesn't seem to change anything performance-wise, so what is the reasoning behind that? I thought using `protected` only is important if inheritance plays a role, which is not the case for that window as it is not inherited from any other class? – Samaranth Jan 07 '21 at 19:17
  • 2
    *"Second problem is the following: if you use getScaledInstance() the method will wait until the picture has been created/painted COMPLETELY. This will BLOCK all other UI operations until finished."* -- This is *not* true. The API docs states "[...] The new Image object may be loaded asynchronously even if the original source image has already been loaded completely." Actually, the entire `java.awt.Image` API is asynchronous. – Harald K Jan 07 '21 at 20:30
  • 1
    @Samaranth When you're inside that Anonymous Class 'ComponentAdapter', you technically cannot access private members of the class MunchkinLauncher, even though you're technically still 'inside' it. Some JVMs do not allow such deviations from the standards. So to keep the app compatible, the Compiler realizes that situation, and when compiling, it will create an additional method, a so-called 'Synthetic Accessor Method' that lets the code inside ComponentAdapter.componentResized() access the private Method MunchkinLauncher.loadBounds(). See https://stackoverflow.com/a/16226833/1932011 for more – JayC667 Jan 08 '21 at 06:04
  • 1
    @haraldK That is true. I stated that really badly. Better: ImageIcon uses its own MediaTracker. As soon as the ImageIcon Constructor gets called with an Image as parameter, the ImageIcon will call its internal 'loadImage' method, which in turn runs into a synchronized block, in which it will wait upon the MediaTracker to await complete loading of the picture. So the use of `getScaledInstance()` followed by `new ImageIcon(Image)` will always block. This - and the multiple re-sizes - is what slows down the rendering so much. And the simplistic approach navigates around that problem. – JayC667 Jan 08 '21 at 06:16