This may not be a perfectly satisfactory answer, but: Modifying a BufferedImage
in the main memory and uploading it to the video memory in each frame simply causes a considerable performance impact.
I created the following MCVE to reproduce the issue: It allows switching between the "direct" painting, an the painting that uses an intermediate BufferedImage
Note that some parts of this test are rather crude, and should/would not be done like this in "real" application code. This is really only to reproduce the issue:
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.image.BufferedImage;
import java.util.Deque;
import java.util.LinkedList;
import java.util.Locale;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JToggleButton;
import javax.swing.SwingUtilities;
public class BufferedImageLags
{
public static void main(String[] args)
{
SwingUtilities.invokeLater(new Runnable()
{
public void run()
{
createAndShowGUI();
}
});
}
private static void createAndShowGUI()
{
JFrame f = new JFrame();
f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
f.getContentPane().setLayout(new BorderLayout());
final BufferedImageLagsPanel p = new BufferedImageLagsPanel();
f.getContentPane().add(p, BorderLayout.CENTER);
final JToggleButton tb = new JToggleButton("Draw direct");
tb.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
p.setDrawDirect(tb.isSelected());
p.drawImagesToMainImage();
}
});
f.getContentPane().add(tb, BorderLayout.SOUTH);
Thread t = new Thread(new Runnable()
{
@Override
public void run()
{
while (true)
{
// Brutally flood the system with paint events.
// DON'T DO THIS IN PRODUCTION CODE!!!
p.repaint();
}
}
});
t.setDaemon(true);
t.start();
f.pack();
f.setLocationRelativeTo(null);
f.setVisible(true);
}
}
class SimplePerformanceMeasure
{
private static long startNs;
private static long endNs;
private static Deque<Long> durationsNs = new LinkedList<Long>();
private static int averaging = 20;
private static int counter = 0;
static void startFrame()
{
startNs = System.nanoTime();
}
static void endFrame()
{
endNs = System.nanoTime();
long durationNs = endNs - startNs;
durationsNs.addLast(durationNs);
while (durationsNs.size() > averaging)
{
durationsNs.removeFirst();
}
counter++;
if ((counter % averaging) == 0)
{
long sumNs = 0;
for (long ns : durationsNs)
{
sumNs += ns;
}
double averageMs = (double)sumNs / durationsNs.size() / 1e6;
System.out.printf(Locale.ENGLISH,
"Average time was %5.3fms\n", averageMs);
}
}
}
class BufferedImageLagsPanel extends JPanel
{
private final int w = 800;
private final int h = 800;
private boolean drawDirect = false;
private final BufferedImage mainImage =
new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
private final BufferedImage tileImage =
new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB);
public BufferedImageLagsPanel()
{
Graphics2D g = tileImage.createGraphics();
g.setColor(Color.BLACK);
g.drawLine(0, 0, 32, 32);
g.drawLine(0, 32, 32, 0);
g.dispose();
}
public void drawImagesToMainImage()
{
Graphics2D ig = mainImage.createGraphics();
drawImages(ig);
ig.dispose();
}
public void setDrawDirect(boolean drawDirect)
{
this.drawDirect = drawDirect;
}
@Override
protected void paintComponent(Graphics gr)
{
super.paintComponent(gr);
Graphics2D g = (Graphics2D)gr;
render(g);
}
public void render(Graphics2D g)
{
SimplePerformanceMeasure.startFrame();
if (drawDirect)
{
drawImages(g);
}
else
{
drawImagesToMainImage();
g.drawImage(mainImage, 0, 0, null);
}
SimplePerformanceMeasure.endFrame();
}
private void drawImages(Graphics2D g)
{
for (int i = 0; i < 500; i++)
{
g.drawImage(tileImage, 0 + i, 0 + i, null);
}
}
@Override
public Dimension getPreferredSize()
{
return new Dimension(w, h);
}
}
It can be seen that the time for painting is much higher when going through the BufferedImage
path.
Side note: Notice that I used
g.drawImage(tileImage, 0 + i, 0 + i, null);
which draws the tiles in their original size of 32x32 pixels. Your call,
g.drawImage(tileImage, 0 + i, 0 + i, 100, 100, null);
caused the image to be scaled each time it was painted. This scaling alone can decrease performance dramatically! If possible, you should create the images in the size in which you want to paint them. In doubt, you can always create a scaled version of the original image once, in the constructor, and then use the first version of the drawImage
call, to directly draw the scaled image.
Admittedly, although I already worked a bit with Swing and BufferedImage
, I was not aware of the fact that the performance impact of updating a BufferedImage
before painting it could be so significant for this particular application pattern. Until now, I only considered using such an "offscreen" image for the case where painting the image contents was expensive, but the image itself changed rarely. And then, it will of course bring a speedup. I did not yet have the case where the image had to be updated in each frame.
However, the usual recommendation in this case is to use a VolatileImage
. It allows hardware-accelerated rendering and can achieve a higher performance. This higher performance comes at the price of being a bit more fiddly to use.
In the following example, I converted the MCVE from above so that it uses a VolatileImage
. For convenience, I wrapped this into a VolatileImageHandle
class that basically encapsulates the two most important functionalities:
- It receives a
Consumer<Graphics2D>
in the constructor. This is the function that will be used to fill the image with the desired content. In your case (and this example), it just draws the 500 tile images
- It offers a
draw
method, to draw the volatile image into a Graphics2D
The bookkeping of checking the volatile image against the graphics configuration is hidden here, which simplifies stuff a bit.
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GraphicsConfiguration;
import java.awt.Transparency;
import java.awt.image.BufferedImage;
import java.awt.image.VolatileImage;
import java.util.Deque;
import java.util.LinkedList;
import java.util.Locale;
import java.util.function.Consumer;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
public class VolatileImageNoLags
{
public static void main(String[] args)
{
SwingUtilities.invokeLater(new Runnable()
{
public void run()
{
createAndShowGUI();
}
});
}
private static void createAndShowGUI()
{
JFrame f = new JFrame();
f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
f.getContentPane().setLayout(new BorderLayout());
final VolatileImageNoLagsPanel p = new VolatileImageNoLagsPanel();
f.getContentPane().add(p, BorderLayout.CENTER);
Thread t = new Thread(new Runnable()
{
@Override
public void run()
{
while (true)
{
// Brutally flood the system with paint events.
// DON'T DO THIS IN PRODUCTION CODE!!!
p.repaint();
}
}
});
t.setDaemon(true);
t.start();
f.pack();
f.setLocationRelativeTo(null);
f.setVisible(true);
}
}
class SimplePerformanceMeasure
{
private static long startNs;
private static long endNs;
private static Deque<Long> durationsNs = new LinkedList<Long>();
private static int averaging = 20;
private static int counter = 0;
static void startFrame()
{
startNs = System.nanoTime();
}
static void endFrame()
{
endNs = System.nanoTime();
long durationNs = endNs - startNs;
durationsNs.addLast(durationNs);
while (durationsNs.size() > averaging)
{
durationsNs.removeFirst();
}
counter++;
if ((counter % averaging) == 0)
{
long sumNs = 0;
for (long ns : durationsNs)
{
sumNs += ns;
}
double averageMs = (double)sumNs / durationsNs.size() / 1e6;
System.out.printf(Locale.ENGLISH,
"Average time was %5.3fms\n", averageMs);
}
}
}
class VolatileImageHandle
{
private VolatileImage image;
private final Component component;
private final int width;
private final int height;
private final boolean transparent;
private Consumer<? super Graphics2D> painter;
VolatileImageHandle(Component component,
int width, int height, boolean transparent,
Consumer<? super Graphics2D> painter)
{
this.component = component;
this.width = width;
this.height = height;
this.transparent = transparent;
this.painter = painter;
}
private void createImage()
{
GraphicsConfiguration graphicsConfiguration =
component.getGraphicsConfiguration();
int transparency = transparent ?
Transparency.TRANSLUCENT : Transparency.OPAQUE;
image = graphicsConfiguration.createCompatibleVolatileImage(
width, height, transparency);
}
private void validateImage()
{
if (image == null)
{
createImage();
}
else
{
GraphicsConfiguration graphicsConfiguration =
component.getGraphicsConfiguration();
int validationResult =
image.validate(graphicsConfiguration);
if (validationResult == VolatileImage.IMAGE_INCOMPATIBLE)
{
image.flush();
createImage();
}
}
}
private void updateImage()
{
do
{
validateImage();
Graphics2D g = image.createGraphics();
painter.accept(g);
g.dispose();
} while (image.contentsLost());
}
public void draw(Graphics2D g, int x, int y)
{
do
{
validateImage();
updateImage();
g.drawImage(image, x, y, null);
} while (image.contentsLost());
}
}
class VolatileImageNoLagsPanel extends JPanel
{
private final int w = 800;
private final int h = 800;
private boolean drawDirect = false;
private final VolatileImageHandle mainImage =
new VolatileImageHandle(this, w, h, true, this::drawImages);
private final BufferedImage tileImage =
new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB);
public VolatileImageNoLagsPanel()
{
Graphics2D g = tileImage.createGraphics();
g.setColor(Color.BLACK);
g.drawLine(0, 0, 32, 32);
g.drawLine(0, 32, 32, 0);
g.dispose();
}
public void setDrawDirect(boolean drawDirect)
{
this.drawDirect = drawDirect;
}
@Override
protected void paintComponent(Graphics gr)
{
super.paintComponent(gr);
Graphics2D g = (Graphics2D)gr;
render(g);
}
public void render(Graphics2D g)
{
SimplePerformanceMeasure.startFrame();
mainImage.draw(g, 0, 0);
SimplePerformanceMeasure.endFrame();
}
private void drawImages(Graphics2D g)
{
for (int i = 0; i < 500; i++)
{
g.drawImage(tileImage, 0 + i, 0 + i, null);
}
}
@Override
public Dimension getPreferredSize()
{
return new Dimension(w, h);
}
}
The increase of performance (on my machine) is remarkable. Again, this performance test is questionable in some ways, but the rough time measures show that in the original version, it took ~25ms to update and paint the image, and in the version that uses the volatile image, this decreased to less than 1 millisecond. YMMV.