After a lot of research and continuing development using Slick2D, I have decided that the only way to do this in libGDX would be a fundamental re-write. After some tests, it also turns out that this is a Good Thing.
The solution I have tried is to hold the data for the graphics in arrays, use TreeSet indexes to be able to quickly locate which items should be on screen at any one time and then draw these once per loop. Per loop, this removes the getSubImage() call and repaint of this sub-image to the main graphics canvas. Having tested the approach with both the old graphics-based class and the new array/TreeSet-based class the difference in performance is significant. Using the graphics approach the game runs unchecked at about 205 FPS. Using the new array/TreeSet approach it runs at around 280 FPS.
I've included an edited version of my Stars class here to show how this works in practice. This is the simplest case since the stars are simply dots on screen. It gets more complicated for other shapes, but the logic is essentially the same except for having to check for objects being partially on-screen, i.e. check all vertices. I've also included a small class below that I use for the TreeSet indices used to search for on-screen objects. Both use dyn4j (physics engine) co-ordinates for the stars and also for the edges of the screen (picked up as an Observer of another class) to calculate what should be shown on-screen.
public final class Stars implements PlanetObserver {
private Color dots[]; // Colours for different star intensities
private final Settings settings;
private final int bWIDTH;
private final int bHEIGHT;
private final int sWIDTH;
private final int sHEIGHT;
private int scrLeftX; // Where screen is in world co-ordinates
private int scrTopY;
private int[][] star_array; // Array of stars
private TreeSet<Index> xIndex; // Index of x values
private TreeSet<Index> yIndex; // Index of x values
private static final Integer MAX_STARS = 500;
/* Static placeholders for values held in star_array include:
x co-ordinate of star
y co-ordinate of star
intensity of star brightness
speed of star update
direction of update - up or down
*/
private static final int STAR_X = 0;
private static final int STAR_Y = 1;
private static final int STAR_INTENSITY = 2;
private static final int STAR_SPEED = 3;
private static final int UP_DOWN = 4;
private static final int DOWN = 0;
private static final int UP = 1;
public Stars(Settings setts) {
settings = setts; // Global settings class
bWIDTH = settings.getbWIDTH(); // Width of buffer for star field
bHEIGHT = settings.getbHEIGHT(); // Height of buffer for star field
sWIDTH = settings.getsWIDTH(); // Current screen width
sHEIGHT = settings.getsHEIGHT(); // Current screen height
init();
}
public void init() {
settings.getPlanetObj().registerObserver(this); // Register for scrTopLeftX/Y updates
// Create star images - saves recreating them each cycle
dots = new Color[11];
dots[0] = new Color(110, 145, 230, 0f); // Match sky colour at 1000m altitude
for (int i = 1; i < 11; i++) {
dots[i] = new Color(110, 145, 230, (float) (i / 10f));
}
// Create the star field
star_array = new int[MAX_STARS][5]; // Array of star values: x, y, intensity and update speed
xIndex = new TreeSet<>(); // Set up new indices for x and y axes
yIndex = new TreeSet<>();
Random rand = new Random(); // Random number generator
rand.setSeed(System.currentTimeMillis()); // Seed random with current time
for (int i = 0; i < MAX_STARS; i++) {
star_array[i][STAR_X] = rand.nextInt(bWIDTH); // x co-ordinate
star_array[i][STAR_Y] = rand.nextInt(bHEIGHT); // y co-ordinate
star_array[i][STAR_INTENSITY] = rand.nextInt(11);// intensity 0 to 10, where 0 = black and 10 = white
star_array[i][STAR_SPEED] = rand.nextInt(3); // glow speed 0 to 2, where 0 = slow, 1 = medium, 2 = fast
star_array[i][UP_DOWN] = rand.nextInt(2); // Direction of glow travel - 0=down, 1=up
// Add entries to indices for x and y values
xIndex.add(new Index(star_array[i][STAR_X], i));
yIndex.add(new Index(star_array[i][STAR_Y], i));
}
}
public void update() {
// Loop through star array and increment/decrement intensity as appropriate
for (int[] star : star_array) {
if (star[UP_DOWN] == UP) {
star[STAR_INTENSITY]++;
if (star[STAR_INTENSITY] > 10) {
star[STAR_INTENSITY] = 9;
star[UP_DOWN] = DOWN;
}
} else {
star[STAR_INTENSITY]--;
if (star[STAR_INTENSITY] < -5) { // Minus numbers allow for period where str is 'off'
star[STAR_INTENSITY] = -4;
star[UP_DOWN] = UP;
}
}
}
}
public void render(Graphics g) {
// Calculate screen bottom-edge Y taking into account values > bHEIGHT
int scrBtmY = scrTopY - sHEIGHT;
if (scrBtmY >= bHEIGHT) {
scrBtmY = scrBtmY % bHEIGHT;
if (FastMath.abs(scrBtmY) == bHEIGHT) { // Wrap-around bug at y == bHEIGHT
scrBtmY = 0;
}
}
// Get x and y co-ordinate values that are in range of on-screen World co-ordinates
Index index;
index = new Index(scrLeftX, 0);
Index xLower = this.xIndex.ceiling(index);
if (xLower == null) {
xLower = this.xIndex.last();
}
if (scrLeftX + sWIDTH <= bWIDTH) {
index = new Index(scrLeftX + sWIDTH, 0);
} else {
index = new Index((scrLeftX + sWIDTH) - bWIDTH, 0);
}
Index xUpper = this.xIndex.floor(index);
if (xUpper == null) {
xUpper = this.xIndex.first();
}
index = new Index(scrBtmY, 0);
Index yLower = this.yIndex.ceiling(index);
if (yLower == null) {
yLower = this.yIndex.last();
}
if (scrBtmY + sHEIGHT <= bHEIGHT) { // Check if range of y values wraps around top of buffer
index = new Index(scrBtmY + sHEIGHT, 0);
} else {
index = new Index((scrBtmY + sHEIGHT) - bHEIGHT, 0);
}
Index yUpper = this.yIndex.floor(index);
if (yUpper == null) {
yUpper = this.yIndex.first();
}
// Extract y value pointers to star_array into a temporary TreeSet
TreeSet<Integer> yPointer = new TreeSet<>();
SortedSet ySet = new TreeSet();
if (yLower.xyValue < yUpper.xyValue) {
ySet = this.yIndex.subSet(yLower, yUpper);
} else { // Wrapping occured - extract 2 ranges and join
ySet.addAll(this.yIndex.tailSet(yLower, true));
ySet.addAll(this.yIndex.headSet(yUpper, true));
}
for (Iterator it = ySet.iterator(); it.hasNext();) {
Index i = (Index) it.next();
yPointer.add(i.pointer);
}
// Create list of x value index elements that might be on-screen
ArrayList<Integer> result = new ArrayList<>();
SortedSet xSet = new TreeSet();
if (xLower.xyValue < xUpper.xyValue) {
xSet = this.xIndex.subSet(xLower, xUpper);
} else { // Wrapping occured - extract 2 ranges and join
xSet.addAll(this.xIndex.tailSet(xLower, true));
xSet.addAll(this.xIndex.headSet(xUpper, true));
}
/* Check if x value pointers exist in y pointer list - if
so then point is on screen: store in results list
*/
for (Iterator it = xSet.iterator(); it.hasNext();) {
Index i = (Index) it.next();
if (yPointer.contains(i.pointer)) {
result.add(i.pointer);
}
}
// Draw the stars on-screen that are in the results list
for (Integer pointer : result) {
if (star_array[pointer][STAR_INTENSITY] >= 0) {
g.setColor(dots[star_array[pointer][STAR_INTENSITY]]);
int x = star_array[pointer][STAR_X] - scrLeftX;
int y = sHEIGHT - (star_array[pointer][STAR_Y] - scrBtmY);
g.drawLine(x, y, x, y);
}
}
}
@Override
public void updateScreen(float topLeftX, float topLeftY, int xoffset, int yoffset) {
scrLeftX = (int) topLeftX;
scrTopY = (int) topLeftY;
}
}
.. and here is the class for the Index elements used in the TreeSets:
public final class Index implements Comparator<Index>, Comparable<Index> {
public final int xyValue;
public final int pointer;
public Index(int val, int p) {
xyValue = val;
pointer = p;
}
@Override
public int compare(Index o1, Index o2) {
return o1.xyValue - o2.xyValue;
}
@Override
public int compareTo(Index o) {
return this.xyValue - o.xyValue;
}
}