Load beatmap background images in a new thread.

This eliminates the game-wide lag (100-200ms on my computer) when switching song nodes. Attempted to mask the loading time with a fade-in effect.

Signed-off-by: Jeffrey Han <itdelatrisu@gmail.com>
This commit is contained in:
Jeffrey Han 2015-09-03 19:24:07 -05:00
parent e767800702
commit 3214916d60
7 changed files with 280 additions and 48 deletions

View File

@ -27,7 +27,6 @@ import java.util.Map;
import org.newdawn.slick.Color;
import org.newdawn.slick.Image;
import org.newdawn.slick.SlickException;
import org.newdawn.slick.util.Log;
/**
@ -39,20 +38,19 @@ public class Beatmap implements Comparable<Beatmap> {
/** Background image cache. */
@SuppressWarnings("serial")
private static final LRUCache<File, Image> bgImageCache = new LRUCache<File, Image>(10) {
private static final LRUCache<File, ImageLoader> bgImageCache = new LRUCache<File, ImageLoader>(10) {
@Override
public void eldestRemoved(Map.Entry<File, Image> eldest) {
Image img = eldest.getValue();
if (img != null && !img.isDestroyed()) {
try {
img.destroy(); // destroy the removed image
} catch (SlickException e) {
Log.warn(String.format("Failed to destroy image '%s'.", img.getResourceReference()), e);
}
}
public void eldestRemoved(Map.Entry<File, ImageLoader> eldest) {
if (eldest.getKey() == lastBG)
lastBG = null;
ImageLoader imageLoader = eldest.getValue();
imageLoader.destroy();
}
};
/** The last background image loaded. */
private static File lastBG;
/**
* Clears the background image cache.
* <p>
@ -276,24 +274,55 @@ public class Beatmap implements Comparable<Beatmap> {
}
/**
* Draws the beatmap background.
* Loads the beatmap background image.
*/
public void loadBackground() {
if (bg == null || bgImageCache.containsKey(bg) || !bg.isFile())
return;
if (lastBG != null) {
ImageLoader lastImageLoader = bgImageCache.get(lastBG);
if (lastImageLoader != null && lastImageLoader.isLoading()) {
lastImageLoader.interrupt(); // only allow loading one image at a time
bgImageCache.remove(lastBG);
}
}
ImageLoader imageLoader = new ImageLoader(bg);
bgImageCache.put(bg, imageLoader);
imageLoader.load(true);
lastBG = bg;
}
/**
* Returns whether the beatmap background image is currently loading.
* @return true if loading
*/
public boolean isBackgroundLoading() {
if (bg == null)
return false;
ImageLoader imageLoader = bgImageCache.get(bg);
return (imageLoader != null && imageLoader.isLoading());
}
/**
* Draws the beatmap background image.
* @param width the container width
* @param height the container height
* @param alpha the alpha value
* @param stretch if true, stretch to screen dimensions; otherwise, maintain aspect ratio
* @return true if successful, false if any errors were produced
*/
public boolean drawBG(int width, int height, float alpha, boolean stretch) {
public boolean drawBackground(int width, int height, float alpha, boolean stretch) {
if (bg == null)
return false;
try {
Image bgImage = bgImageCache.get(this);
if (bgImage == null) {
if (!bg.isFile())
ImageLoader imageLoader = bgImageCache.get(bg);
if (imageLoader == null)
return false;
bgImage = new Image(bg.getAbsolutePath());
bgImageCache.put(bg, bgImage);
}
Image bgImage = imageLoader.getImage();
if (bgImage == null)
return true;
int swidth = width;
int sheight = height;
@ -311,14 +340,8 @@ public class Beatmap implements Comparable<Beatmap> {
sheight = (int) (width * bgImage.getHeight() / (float) bgImage.getWidth());
}
bgImage = bgImage.getScaledCopy(swidth, sheight);
bgImage.setAlpha(alpha);
bgImage.drawCentered(width / 2, height / 2);
} catch (Exception e) {
Log.warn(String.format("Failed to get background image '%s'.", bg), e);
bg = null; // don't try to load the file again until a restart
return false;
}
return true;
}

View File

@ -0,0 +1,179 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014, 2015 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.beatmap;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import org.newdawn.slick.Image;
import org.newdawn.slick.SlickException;
import org.newdawn.slick.opengl.ImageData;
import org.newdawn.slick.opengl.ImageDataFactory;
import org.newdawn.slick.opengl.LoadableImageData;
import org.newdawn.slick.util.Log;
/**
* Simple threaded image loader for a single image file.
*/
public class ImageLoader {
/** The image file. */
private final File file;
/** The loaded image. */
private Image image;
/** The image data. */
private LoadedImageData data;
/** The image loader thread. */
private Thread loaderThread;
/** ImageData wrapper, needed because {@code ImageIOImageData} doesn't implement {@code getImageBufferData()}. */
private class LoadedImageData implements ImageData {
/** The image data implementation. */
private final ImageData imageData;
/** The stored image. */
private final ByteBuffer buffer;
/**
* Constructor.
* @param imageData the class holding the image properties
* @param buffer the stored image
*/
public LoadedImageData(ImageData imageData, ByteBuffer buffer) {
this.imageData = imageData;
this.buffer = buffer;
}
@Override public int getDepth() { return imageData.getDepth(); }
@Override public int getWidth() { return imageData.getWidth(); }
@Override public int getHeight() { return imageData.getHeight();}
@Override public int getTexWidth() { return imageData.getTexWidth(); }
@Override public int getTexHeight() { return imageData.getTexHeight(); }
@Override public ByteBuffer getImageBufferData() { return buffer; }
}
/** Image loading thread. */
private class ImageLoaderThread extends Thread {
/** The image file input stream. */
private BufferedInputStream in;
@Override
public void interrupt() {
super.interrupt();
if (in != null) {
try {
in.close(); // interrupt I/O
} catch (IOException e) {}
}
}
@Override
public void run() {
// load image data into a ByteBuffer to use constructor Image(ImageData)
LoadableImageData imageData = ImageDataFactory.getImageDataFor(file.getAbsolutePath());
try (BufferedInputStream in = this.in = new BufferedInputStream(new FileInputStream(file))) {
ByteBuffer textureBuffer = imageData.loadImage(in, false, null);
if (!isInterrupted())
data = new LoadedImageData(imageData, textureBuffer);
} catch (IOException e) {
if (!isInterrupted())
Log.warn(String.format("Failed to load background image '%s'.", file), e);
}
this.in = null;
}
}
/**
* Constructor. Call {@link ImageLoader#load(boolean)} to load the image.
* @param file the image file
*/
public ImageLoader(File file) {
this.file = file;
}
/**
* Loads the image.
* @param threaded true to load the image data in a new thread
*/
public void load(boolean threaded) {
if (!file.isFile())
return;
if (threaded) {
if (loaderThread != null && loaderThread.isAlive())
loaderThread.interrupt();
loaderThread = new ImageLoaderThread();
loaderThread.start();
} else {
try {
image = new Image(file.getAbsolutePath());
} catch (SlickException e) {
Log.warn(String.format("Failed to load background image '%s'.", file), e);
}
}
}
/**
* Returns the image.
* @return the loaded image, or null if not loaded
*/
public Image getImage() {
if (image == null && data != null) {
image = new Image(data);
data = null;
}
return image;
}
/**
* Returns whether an image is currently loading in another thread.
* @return true if loading, false otherwise
*/
public boolean isLoading() { return (loaderThread != null && loaderThread.isAlive()); }
/**
* Interrupts the image loader, if running.
*/
public void interrupt() {
if (isLoading())
loaderThread.interrupt();
}
/**
* Releases all resources.
*/
public void destroy() {
interrupt();
loaderThread = null;
if (image != null && !image.isDestroyed()) {
try {
image.destroy();
} catch (SlickException e) {
Log.warn(String.format("Failed to destroy image '%s'.", image.getResourceReference()), e);
}
image = null;
}
data = null;
}
}

View File

@ -305,7 +305,7 @@ public class Game extends BasicGameState {
else
dimLevel = 1f;
}
if (Options.isDefaultPlayfieldForced() || !beatmap.drawBG(width, height, dimLevel, false)) {
if (Options.isDefaultPlayfieldForced() || !beatmap.drawBackground(width, height, dimLevel, false)) {
Image playfield = GameImage.PLAYFIELD.getImage();
playfield.setAlpha(dimLevel);
playfield.draw();

View File

@ -106,7 +106,7 @@ public class GameRanking extends BasicGameState {
Beatmap beatmap = MusicController.getBeatmap();
// background
if (!beatmap.drawBG(width, height, 0.7f, true))
if (!beatmap.drawBackground(width, height, 0.7f, true))
GameImage.PLAYFIELD.getImage().draw(0,0);
// ranking screen elements

View File

@ -234,7 +234,7 @@ public class MainMenu extends BasicGameState {
// draw background
Beatmap beatmap = MusicController.getBeatmap();
if (Options.isDynamicBackgroundEnabled() &&
beatmap != null && beatmap.drawBG(width, height, bgAlpha.getValue(), true))
beatmap != null && beatmap.drawBackground(width, height, bgAlpha.getValue(), true))
;
else {
Image bg = GameImage.MENU_BG.getImage();
@ -349,6 +349,8 @@ public class MainMenu extends BasicGameState {
MusicController.toggleTrackDimmed(0.33f);
// fade in background
Beatmap beatmap = MusicController.getBeatmap();
if (!(Options.isDynamicBackgroundEnabled() && beatmap != null && beatmap.isBackgroundLoading()))
bgAlpha.update(delta);
// buttons

View File

@ -216,6 +216,12 @@ public class SongMenu extends BasicGameState {
/** Whether the song folder changed (notified via the watch service). */
private boolean songFolderChanged = false;
/** The last background image. */
private File lastBackgroundImage;
/** Background alpha level (for fade-in effect). */
private AnimatedValue bgAlpha = new AnimatedValue(800, 0f, 1f, AnimationEquation.OUT_QUAD);
/**
* Beatmaps whose difficulties were recently computed (if flag is non-null).
* Unless the Boolean flag is null, then upon removal, the beatmap's objects will
@ -344,7 +350,7 @@ public class SongMenu extends BasicGameState {
// background
if (focusNode != null) {
Beatmap focusNodeBeatmap = focusNode.getBeatmapSet().get(focusNode.beatmapIndex);
if (!focusNodeBeatmap.drawBG(width, height, 1.0f, true))
if (!focusNodeBeatmap.drawBackground(width, height, bgAlpha.getValue(), true))
GameImage.PLAYFIELD.getImage().draw();
}
@ -545,6 +551,13 @@ public class SongMenu extends BasicGameState {
}
}
// fade in background
if (focusNode != null) {
Beatmap focusNodeBeatmap = focusNode.getBeatmapSet().get(focusNode.beatmapIndex);
if (!focusNodeBeatmap.isBackgroundLoading())
bgAlpha.update(delta);
}
// search
search.setFocus(true);
searchTimer += delta;
@ -996,6 +1009,7 @@ public class SongMenu extends BasicGameState {
beatmapMenuTimer = -1;
searchTransitionTimer = SEARCH_TRANSITION_TIME;
songInfo = null;
bgAlpha.setTime(bgAlpha.getDuration());
// reset song stack
randomStack = new Stack<SongNode>();
@ -1253,6 +1267,14 @@ public class SongMenu extends BasicGameState {
if (startNode.index == focusNode.index && startNode.beatmapIndex == -1)
changeIndex(1);
// load background image
beatmap.loadBackground();
boolean isBgNull = lastBackgroundImage == null || beatmap.bg == null;
if ((isBgNull && lastBackgroundImage != beatmap.bg) || (!isBgNull && !beatmap.bg.equals(lastBackgroundImage))) {
bgAlpha.setTime(0);
lastBackgroundImage = beatmap.bg;
}
return oldFocus;
}
@ -1352,6 +1374,7 @@ public class SongMenu extends BasicGameState {
searchTimer = SEARCH_DELAY;
searchTransitionTimer = SEARCH_TRANSITION_TIME;
searchResultString = null;
lastBackgroundImage = null;
// reload songs in new thread
reloadThread = new Thread() {

View File

@ -77,6 +77,11 @@ public class AnimatedValue {
updateValue();
}
/**
* Returns the total animation duration, in milliseconds.
*/
public int getDuration() { return duration; }
/**
* Sets the animation duration.
* @param duration the new duration, in milliseconds