/* * Copyright 2013 MicaByte Systems * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express * or implied. See the License for the specific language governing permissions and limitations under * the License. */ package com.micabytes.gfx; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.BitmapRegionDecoder; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Point; import android.graphics.Rect; import android.support.annotation.Nullable; import com.micabytes.util.GameLog; import org.jetbrains.annotations.NonNls; import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; /** * GameSurfaceRendererBitmap is a renderer that handles the rendering of a background bitmap to the * screen (e.g., a game map). It is able to do this even if the bitmap is too large to fit into * memory. The game should subclass the renderer and extend the drawing methods to add other game * elements. */ public class BitmapSurfaceRenderer extends SurfaceRenderer { private static final String TAG = BitmapSurfaceRenderer.class.getName(); // Default Settings @NonNls private static final String CACHE_THREAD = "cacheThread"; public static final Bitmap.Config DEFAULT_CONFIG = Bitmap.Config.RGB_565; private static final int DEFAULT_SAMPLE_SIZE = 2; private static final int DEFAULT_MEM_USAGE = 20; private static final float DEFAULT_THRESHOLD = 0.75f; // BitmapRegionDecoder - this is the class that does the magic private BitmapRegionDecoder decoder; // The cached portion of the background image private final CacheBitmap cachedBitmap = new CacheBitmap(); // The low resolution version of the background image private Bitmap lowResBitmap; /** * Options for loading the bitmaps */ private final BitmapFactory.Options options = new BitmapFactory.Options(); /** * What is the down sample size for the sample image? 1=1/2, 2=1/4 3=1/8, etc */ private final int sampleSize; /** * What percent of total memory should we use for the cache? The bigger the cache, the longer it * takes to read -- 1.2 secs for 25%, 600ms for 10%, 500ms for 5%. User experience seems to be * best for smaller values. */ private int memUsage; /** * Threshold for using low resolution image */ private final float lowResThreshold; /** * Calculated rect */ private final Rect calculatedCacheWindowRect = new Rect(); @SuppressWarnings("unused") private BitmapSurfaceRenderer(Context con) { super(con); options.inPreferredConfig = DEFAULT_CONFIG; sampleSize = DEFAULT_SAMPLE_SIZE; setMemUsage(DEFAULT_MEM_USAGE); lowResThreshold = DEFAULT_THRESHOLD; } protected BitmapSurfaceRenderer(Context con, Bitmap.Config config, int sample, int memUse, float threshold) { super(con); options.inPreferredConfig = config; sampleSize = sample; setMemUsage(memUse); lowResThreshold = threshold; } static class FlushedInputStream extends FilterInputStream { FlushedInputStream(InputStream inputStream) { super(inputStream); } @Override public long skip(long byteCount) throws IOException { long totalBytesSkipped = 0L; while (totalBytesSkipped < byteCount) { long bytesSkipped = in.skip(byteCount - totalBytesSkipped); if (bytesSkipped == 0L) { int bytes = read(); if (bytes < 0) { break; // we reached EOF } else { bytesSkipped = 1; // we read one byte } } totalBytesSkipped += bytesSkipped; } return totalBytesSkipped; } } /** * Set the Background bitmap * * @param inputStream InputStream to the raw data of the bitmap */ public void setBitmap(InputStream inputStream) throws IOException { FlushedInputStream fixedInput = new FlushedInputStream(inputStream); BitmapFactory.Options opt = new BitmapFactory.Options(); decoder = BitmapRegionDecoder.newInstance(fixedInput, false); fixedInput.reset(); // Grab the bounds of the background bitmap opt.inPreferredConfig = DEFAULT_CONFIG; opt.inJustDecodeBounds = true; GameLog.d(TAG, "Decode inputStream for Background Bitmap"); BitmapFactory.decodeStream(fixedInput, null, opt); fixedInput.reset(); backgroundSize.set(opt.outWidth, opt.outHeight); GameLog.d(TAG, "Background Image: w=" + opt.outWidth + " h=" + opt.outHeight); // Create the low resolution background opt.inJustDecodeBounds = false; opt.inSampleSize = 1 << sampleSize; lowResBitmap = BitmapFactory.decodeStream(fixedInput, null, opt); GameLog.d(TAG, "Low Res Image: w=" + lowResBitmap.getWidth() + " h=" + lowResBitmap.getHeight()); // Initialize cache if (cachedBitmap.getState() == CacheState.NOT_INITIALIZED) { synchronized (cachedBitmap) { cachedBitmap.setState(CacheState.IS_INITIALIZED); } } } @SuppressWarnings("MethodOnlyUsedFromInnerClass") private synchronized int getMemUsage() { return memUsage; } private synchronized void setMemUsage(int i) { memUsage = i; } @Override protected void drawBase() { cachedBitmap.draw(viewPort); } @Override protected void drawLayer() { // NOOP - override function and add game specific code } @Override protected void drawFinal() { // NOOP - override function and add game specific code } /** * Starts the renderer */ @Override public void start() { cachedBitmap.start(); } /** * Stops the renderer */ @Override public void stop() { cachedBitmap.stop(); } /** * Suspend the renderer */ @Override public void suspend() { cachedBitmap.suspend(); } /** * Suspend the renderer */ @Override public void resume() { cachedBitmap.resume(); } /** * Invalidate the cache. */ public void invalidate() { cachedBitmap.invalidate(); } /** * The current state of the cached bitmap */ private enum CacheState { READY, NOT_INITIALIZED, IS_INITIALIZED, BEGIN_UPDATE, IS_UPDATING, DISABLED } /** * The cached bitmap object. This object is continually kept up to date by CacheThread. If * the object is locked, the background is updated using the low resolution background image * instead */ @SuppressWarnings("AssignmentToNull") private class CacheBitmap { /** * The current position and dimensions of the cache within the background image */ final Rect cacheWindow = new Rect(0, 0, 0, 0); /** * The current state of the cache */ private CacheState state = CacheState.NOT_INITIALIZED; /** * The currently cached bitmap */ @Nullable Bitmap bitmap; /** * The cache bitmap loading thread */ private CacheThread cacheThread; synchronized CacheState getState() { return state; } synchronized void setState(CacheState newState) { state = newState; } void start() { if (cacheThread != null) { cacheThread.setRunning(false); cacheThread.interrupt(); cacheThread = null; } cacheThread = new CacheThread(this); cacheThread.setName(CACHE_THREAD); cacheThread.start(); } void stop() { cacheThread.setRunning(false); cacheThread.interrupt(); boolean retry = true; while (retry) { try { cacheThread.join(); retry = false; } catch (InterruptedException ignored) { // Wait until thread is dead } } cacheThread = null; } void invalidate() { setState(CacheState.IS_INITIALIZED); cacheThread.interrupt(); } public void suspend() { setState(CacheState.DISABLED); } public void resume() { if (getState() == CacheState.DISABLED) { setState(CacheState.IS_INITIALIZED); } } /** * Draw the CacheBitmap on the viewport */ @SuppressWarnings("OverlyComplexMethod") void draw(SurfaceRenderer.ViewPort p) { if (cacheThread == null) return; Bitmap bmp = null; switch (getState()) { case NOT_INITIALIZED: // Error GameLog.e(TAG, "Attempting to update an uninitialized CacheBitmap"); return; case IS_INITIALIZED: // Start data caching setState(CacheState.BEGIN_UPDATE); cacheThread.interrupt(); break; case BEGIN_UPDATE: case IS_UPDATING: // Currently updating; low resolution version used break; case DISABLED: // Use of high resolution version disabled break; case READY: if ((bitmap == null) || !cacheWindow.contains(p.getWindow())) { // No data loaded OR No cached data available setState(CacheState.BEGIN_UPDATE); cacheThread.interrupt(); } else { bmp = bitmap; } break; } // Use the low resolution version if the cache is empty or scale factor is < threshold if ((bmp == null) || (getZoom() < lowResThreshold)) drawLowResolution(); else drawHighResolution(bmp); } /** * Used to hold the source Rect for bitmap drawing */ private final Rect srcRect = new Rect(0, 0, 0, 0); /** * Used to hold the dest Rect for bitmap drawing */ private final Rect dstRect = new Rect(0, 0, 0, 0); private final Point dstSize = new Point(); /** * Use the high resolution cached bitmap for drawing */ void drawHighResolution(Bitmap bmp) { Rect wSize = viewPort.getWindow(); if (bmp != null) { synchronized (viewPort) { int left = wSize.left - cacheWindow.left; int top = wSize.top - cacheWindow.top; int right = left + wSize.width(); int bottom = top + wSize.height(); viewPort.getPhysicalSize(dstSize); srcRect.set(left, top, right, bottom); dstRect.set(0, 0, dstSize.x, dstSize.y); synchronized (viewPort.bitmapLock) { Canvas canvas = new Canvas(viewPort.bitmap); canvas.drawColor(Color.BLACK); canvas.drawBitmap(bmp, srcRect, dstRect, null); } } } } void drawLowResolution() { if (getState() != CacheState.NOT_INITIALIZED) { drawLowResolutionBackground(); } } /** * This method fills the passed-in bitmap with sample data. This function must return data fast; * this is our fall back solution in all the cases where the user is moving too fast for us to * load the actual bitmap data from memory. The quality of the user experience rests on the speed * of this function. */ @SuppressWarnings("AccessingNonPublicFieldOfAnotherObject") private void drawLowResolutionBackground() { int w = 0; int h = 0; synchronized (viewPort.bitmapLock) { if (viewPort.bitmap == null) return; w = viewPort.bitmap.getWidth(); h = viewPort.bitmap.getHeight(); } Rect rect = viewPort.getWindow(); int left = rect.left >> sampleSize; int top = rect.top >> sampleSize; int right = rect.right >> sampleSize; int bottom = rect.bottom >> sampleSize; Rect sRect = new Rect(left, top, right, bottom); Rect dRect = new Rect(0, 0, w, h); // Draw to Canvas synchronized (viewPort.bitmapLock) { if (viewPort.bitmap != null && lowResBitmap != null) { Canvas canvas = new Canvas(viewPort.bitmap); canvas.drawBitmap(lowResBitmap, sRect, dRect, null); } } } } /** * This thread handles the background loading of the {@link CacheBitmap}. <p/> The CacheThread * starts an update when the {@link CacheBitmap#state} is {@link CacheState#BEGIN_UPDATE} and * updates the bitmap given the current window. <p/> The CacheThread needs to be careful how it * locks {@link CacheBitmap} in order to ensure the smoothest possible performance (loading can * take a while). */ @SuppressWarnings("ClassExplicitlyExtendsThread") class CacheThread extends Thread { @SuppressWarnings("FieldAccessedSynchronizedAndUnsynchronized") private boolean running; // The CacheBitmap private final CacheBitmap cache; CacheThread(CacheBitmap cached) { setName(CACHE_THREAD); cache = cached; } @SuppressWarnings({"MethodWithMultipleLoops", "OverlyComplexMethod", "OverlyNestedMethod", "WhileLoopSpinsOnField", "RefusedBequest"}) @Override public void run() { running = true; Rect viewportRect = new Rect(0, 0, 0, 0); while (running) { // Wait until we are ready to go while (running && (cache.getState() != CacheState.BEGIN_UPDATE)) { try { //noinspection BusyWait Thread.sleep(Integer.MAX_VALUE); } catch (InterruptedException ignored) { // NOOP } } if (!running) return; // Start Loading Timer long startTime = System.currentTimeMillis(); // Load Data boolean startLoading = false; synchronized (cache) { if (cache.getState() == CacheState.BEGIN_UPDATE) { cache.setState(CacheState.IS_UPDATING); cache.bitmap = null; startLoading = true; } } if (startLoading) { synchronized (viewPort) { viewportRect.set(viewPort.getWindow()); } boolean continueLoading = false; synchronized (cache) { if (cache.getState() == CacheState.IS_UPDATING) { cache.cacheWindow.set(calculateCacheDimensions(viewportRect)); continueLoading = true; } } if (continueLoading) { //noinspection ErrorNotRethrown try { Bitmap bitmap = loadCachedBitmap(cache.cacheWindow); if (bitmap != null) { synchronized (cache) { if (cache.getState() == CacheState.IS_UPDATING) { cache.bitmap = bitmap; cache.setState(CacheState.READY); } else { GameLog.d(TAG, "Loading of background image cache aborted"); } } } // End Loading Timer long endTime = System.currentTimeMillis(); GameLog.d(TAG, "Loaded background image in " + (endTime - startTime) + " ms"); } catch (OutOfMemoryError ignored) { GameLog.d(TAG, "CacheThread out of memory"); // Out of memory ERROR detected. Lower the memory allocation cacheBitmapOutOfMemoryError(); synchronized (cache) { if (cache.getState() == CacheState.IS_UPDATING) { cache.setState(CacheState.BEGIN_UPDATE); } } } } } } } /** * Determine the dimensions of the CacheBitmap based on the current ViewPort. <p/> Minimum size is * equal to the viewport; otherwise it is dimensioned relative to the available memory. {@link * CacheBitmap} is locked while the calculation is done, so this has to be fast. * * @param rect The dimensions of the current viewport * @return The dimensions of the cache */ private Rect calculateCacheDimensions(Rect rect) { long bytesToUse = (Runtime.getRuntime().maxMemory() * getMemUsage()) / 100; Point sz = getBackgroundSize(); int vw = rect.width(); int vh = rect.height(); // Calculate the margins within the memory budget int tw = 0; int th = 0; int mw = tw; int mh = th; int bytesPerPixel = 4; while (((vw + tw) * (vh + th) * bytesPerPixel) < bytesToUse) { tw++; mw = tw; th++; mh = th; } // Trim margins to image size if ((vw + mw) > sz.x) mw = Math.max(0, sz.x - vw); if ((vh + mh) > sz.y) mh = Math.max(0, sz.y - vh); // Figure out the left & right based on the margin. // LATER: THe logic here assumes that the viewport is <= our size. // If that's not the case, then this logic breaks. int left = rect.left - (mw >> 1); int right = rect.right + (mw >> 1); if (left < 0) { right -= left; // Adds the overage on the left side back to the right left = 0; } if (right > sz.x) { left -= right - sz.x; // Adds overage on right side back to left right = sz.x; } // Figure out the top & bottom based on the margin. We assume our viewport // is <= our size. If that's not the case, then this logic breaks. int top = rect.top - (mh >> 1); int bottom = rect.bottom + (mh >> 1); if (top < 0) { bottom -= top; // Adds the overage on the top back to the bottom top = 0; } if (bottom > sz.y) { top -= bottom - sz.y; // Adds overage on bottom back to top bottom = sz.y; } // Set the origin based on our new calculated values. calculatedCacheWindowRect.set(left, top, right, bottom); return calculatedCacheWindowRect; } public void setRunning(boolean r) { running = r; } /** * Loads the relevant slice of the background bitmap that needs to be kept in memory. <p/> The * loading can take a long time depending on the size. * * @param rect The portion of the background bitmap to be cached * @return The bitmap representing the requested area of the background */ private Bitmap loadCachedBitmap(Rect rect) { return decoder.decodeRegion(rect, options); } /** * This function tries to recover from an OutOfMemoryError in the CacheThread. */ private void cacheBitmapOutOfMemoryError() { if (getMemUsage() > 0) setMemUsage(getMemUsage() - 1); GameLog.e(TAG, "OutOfMemory caught; reducing cache size to " + getMemUsage() + " percent."); } } }