A Scrolly, Clippy Swing Optimization

by Chris Adamson

This blog comes from two different places. The first is my decade-long general indifference to really using the clipping rectangle, which in Java2D is a shape that indicates what part of a Graphics object is to be painted in. It offers the potential for graphics optimizations because the rendering code can assume that anything outside of the clipping area is unchanged, meaning it can be copied from a back buffer, not repainted, etc., depending on context.



But I got burned the first time I played with the clipping rectangle (back in Java 1.0, it wasn't an arbitrary shape...). Dutifully typing in programs from Laura Lemay's Teach Yourself Java in 21 Days, I found that the Mac JDK 1.0 mis-implemented the clipping rectangle. Every time you set it, the resulting clipping rect would be the intersection of your rect and the existing clipping rect. This meant that setClip() calls could only make the clipping rectangle smaller, and ultimately making it impossible to draw to your Graphics. Not good. So, I was a little gun-shy about clipping after that, even after Sun was chased away from the Mac and the job of writing Mac Java runtimes fell to Roaster, Metrowerks, and ultimately Apple.



Fast forward to today. I seemingly can't shake this insane idea I have about writing a kick-ass podcast editor with QuickTime for Java. Insane because I don't have enough time to take care of my editing responsibilities for ONJava and java.net as it is. I guess I'm really itching to code something, and I think QTJ's editing API's would really help move such a project along quickly.



One of the things I worried about, though, is the idea of the waveform viewer. This is a custom component that represents the samples graphically, which requires (among other things) looking up a bunch of samples to determine how high to draw the bar. Yeah, there's already one of these in Swing Hacks (courtesy of contributor Jonathan Simon), but this needs to be different because it won't necessarily be able to load all the audio into RAM (or, at the very least, I want to just be able to call a QuickTime sound media's getSample() and not care how the data gets to me). After all, at CD quality of 44,100 samples a second, a one-hour podcast would have 44100 samples/sec * 60 sec/min * 60 min/hr * 1 hr = 158,760,000 samples. If you zoom into a detail view of the wave-form, all the way down to the individual sample level (i.e., one pixel per sample), that component's going to get mighty big.



That got me thinking that a component that's tens of thousands, or even millions of pixels wide, would not necessarily be a good thing to put in a JScrollPane. After all, if you have a custom component, with a custom paintComponent() method, you'll do all your painting, blissfully unaware of whether a given pixel is going to be seen through the scroll pane's viewport or not, and if you're showing just a few hundred horizontal pixels of something thousands of times wider, that'll be wasteful. In fact, it will be intolerably expensive if you have to do a lot of work (e.g., looking up samples) in order to draw pixels that won't even be shown.



That brings me back to the clipping rectangle. Graphics, an instance of which is handed to your rendering code in paintComponent(), has a getClip() method. I wondered to myself if this represented the sub-section of the Graphics that would actually be seen, i.e., the portion in the scroll-pane's viewport. If so, there's a potential for optimization: before you go to draw something, look to see if any part of it is in the clip area. If not, don't bother painting it.



Here's a piece of code to exercise this approach. It creates a custom component, PaintyThing, that consists of a horizontal series of numbered red boxes. By default, it draws 10,000 100x200 boxes, meaning the component is one million pixels wide. That should be an appropriately brutal test of the rendering optimzation.




import java.awt.*;
import javax.swing.*;

public class ScrollClip extends JFrame {

public static final int PAINTY_HEIGHT=200;
public static final int PAINTY_WIDTH_INCREMENT=100;
public static final int PAINTY_COUNT = 10000;
public static boolean useClipOptimization;
static {
String useClipPref = System.getProperty ("use.clip.optimization");
useClipOptimization = (useClipPref != null) &&
useClipPref.equalsIgnoreCase ("true");
if (! useClipOptimization)
System.out.println ("Run with -Duse.clip.optimization=true "+
"to use cliprect optimization");
}

JScrollPane pain;
PaintyThing paintyThing;

public ScrollClip () {
super ("Scroll Clip");
paintyThing = new PaintyThing (PAINTY_COUNT);
pain =
new JScrollPane (paintyThing,
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_ALWAYS);
getContentPane().add (pain);
}


public static void main (String[] args) {
ScrollClip sc = new ScrollClip();
sc.pack();
// force a reasonable size so swing doesn't really try
// to show the whole paintyThing
sc.setSize (PAINTY_WIDTH_INCREMENT * 3,
sc.getSize().height + 1);
sc.setVisible(true);
}


class PaintyThing extends JComponent {
int paintyCount = 1;
Dimension calcSize = null;
Stroke boxStroke = null;
public PaintyThing (int i) {
super();
paintyCount = i;
calcSize = new Dimension (i * PAINTY_WIDTH_INCREMENT,
PAINTY_HEIGHT);
boxStroke = new BasicStroke (3);
}

// not sure if these are useful. habit.
public Dimension getPreferredSize() {return calcSize;}
public Dimension getMinimumSize() {return calcSize;}
public Dimension getMaximumSize() {return calcSize;}

public void paintComponent (Graphics og) {
long inTime = System.currentTimeMillis();
Graphics2D g = (Graphics2D) og;
g.setColor (Color.white);
g.fillRect (0, 0, getWidth(), getHeight());
// System.out.println (g.getClip());
Rectangle clipRect =
(g.getClip() instanceof Rectangle) ?
(Rectangle) g.getClip() : null;
g.setStroke (boxStroke);
for (int i=0; i<paintyCount; i++) {
Rectangle paintBox =
new Rectangle (i * PAINTY_WIDTH_INCREMENT,
0,
PAINTY_WIDTH_INCREMENT - 4,
PAINTY_HEIGHT);

// performance boost - don't paint if no part of the
// rect to paint is in the current clipping region
if (useClipOptimization &&
(clipRect != null) &&
(! paintBox.intersects (clipRect))) {
// System.out.println ("don't paint " + i);
continue;
}

// System.out.println ("paint " + i);
g.setColor (Color.red);
g.draw (paintBox);
g.setColor (Color.blue);
g.drawString (Integer.toString (i),
(i * PAINTY_WIDTH_INCREMENT) + 10,
PAINTY_HEIGHT/2);
}
System.out.println ("paintComponent(): " +
(System.currentTimeMillis() - inTime));
}
}
}


The ScrollClip class is mostly just responsible for putting a PaintyThing in a scroll pane, then putting that in a reasonably-sized JFrame and putting it on screen. It also uses a static initializer to look to see if you set a system property, use.clip.optimization, to enable the optimization.



Look closely at the optimization, because that's what matters. As it loops through the rectangle coordinates, it asks for any given rectangle "Am I doing optimization, does the Graphics have a cliprect, and is this rectangle I'm about to draw completely outside of the cliprect? If so, skip to the next rectangle."



Here's what the app looks like:



image



If you notice, I left in some println's to show how long it takes to get through paintComponent(). Run with java ScrollClip and you'll find that scrolling is somewhat unresponsive. That's because the repaints are taking about half a second each (on a dual 1.8 G5 Mac):




paintComponent(): 354
paintComponent(): 389
paintComponent(): 348
paintComponent(): 445
paintComponent(): 389
paintComponent(): 351


Run again with java -Duse.clip.optimization=true ScrollClip to use the "paint only if necessary" optimization. Notice how much more responsive this version is. You can zip the scrollbar back and forth with impunity because the repaint time is consistently less than 10 ms.




paintComponent(): 5
paintComponent(): 4
paintComponent(): 8
paintComponent(): 5
paintComponent(): 6
paintComponent(): 4


Not bad for scrolling around a million-pixel wide component. Better than I expected, actually. Looking at paintComponent(), I can also see right away two further optimizations that could be made. Do you see them?



If you push the number of pixels higher, you see a fairly consistent two-orders-of-magnitude advantage to the optimization. Use 100,000 rectangles, and the optimized repaints will be in the 10's of milliseconds (which is still great). As you go higher still, you approach two limits: the repainting is slow even with the optimization, and the width of PaintyThing approaches Integer.MAX_VALUE, which is the end of the party.



My Swing Hacks co-author, now a member of Sun's Swing team, thinks this is something of an edge-case, since not that many people are creating custom components, and particularly not of this extreme size. He may be right; though I've created a lot of custom components in my work, they've never been millions of pixels wide until now.



In fact, I've assumed that I would probably need to drop the scroll pane and manage my own JScrollBar, dropping the fiction of the single component that shows the whole waveform and instead having one that just renders the part of the waveform indicated by the zoom level and the scrollbar. Still, it's nice to see that that's potentially an optimization to do later, and that use of the clipping rectangle could provide performance that's good enough to use in early prototyping.



It also makes me think I should have been more aggressive about using clipping in my previous Swing work. Maybe the payoff won't be big, but my sense of Swing performance is that there's no single thing that slows down Swing, but rather that you die the "death of a thousand papercuts" from a deep hierarchy of less-than optimal layout and rendering choices. Examining the clip before you paint, and setting it yourself when you paint non-scrolling components, is a simple optimization that could basically be reduced to a standard refactoring step, and one that might score you some performance.



By the way, did you find the additional optiizations in PaintyThing.paintComponent() that I mentioned?