diff --git a/pom.xml b/pom.xml
index 42a4011c8e777f298492f6c59cc28312f31d7ee8..f8e37219ddcc86df1040f9d17b44e5979e52deda 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,7 +11,7 @@
 
 	<groupId>sc.fiji</groupId>
 	<artifactId>bigdataviewer-core</artifactId>
-	<version>9.0.7-SNAPSHOT</version>
+	<version>10.0.0-SNAPSHOT</version>
 
 	<name>BigDataViewer Core</name>
 	<description>BigDataViewer core classes with minimal dependencies.</description>
@@ -159,10 +159,6 @@
 			<groupId>net.imglib2</groupId>
 			<artifactId>imglib2-cache</artifactId>
 		</dependency>
-		<dependency>
-			<groupId>net.imglib2</groupId>
-			<artifactId>imglib2-ui</artifactId>
-		</dependency>
 		<dependency>
 			<groupId>net.imglib2</groupId>
 			<artifactId>imglib2-algorithm</artifactId>
diff --git a/src/main/java/bdv/BehaviourTransformEventHandler3D.java b/src/main/java/bdv/BehaviourTransformEventHandler3D.java
deleted file mode 100644
index 09f59a6281f623f71758757b9a5a2984a5342808..0000000000000000000000000000000000000000
--- a/src/main/java/bdv/BehaviourTransformEventHandler3D.java
+++ /dev/null
@@ -1,497 +0,0 @@
-/*-
- * #%L
- * BigDataViewer core classes with minimal dependencies.
- * %%
- * Copyright (C) 2012 - 2020 BigDataViewer developers.
- * %%
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- * 
- * 1. Redistributions of source code must retain the above copyright notice,
- *    this list of conditions and the following disclaimer.
- * 2. Redistributions in binary form must reproduce the above copyright notice,
- *    this list of conditions and the following disclaimer in the documentation
- *    and/or other materials provided with the distribution.
- * 
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
- * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
- * POSSIBILITY OF SUCH DAMAGE.
- * #L%
- */
-package bdv;
-
-import org.scijava.ui.behaviour.Behaviour;
-import org.scijava.ui.behaviour.ClickBehaviour;
-import org.scijava.ui.behaviour.DragBehaviour;
-import org.scijava.ui.behaviour.ScrollBehaviour;
-import org.scijava.ui.behaviour.io.InputTriggerConfig;
-import org.scijava.ui.behaviour.util.Behaviours;
-import org.scijava.ui.behaviour.util.TriggerBehaviourBindings;
-
-import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.TransformEventHandler;
-import net.imglib2.ui.TransformEventHandlerFactory;
-import net.imglib2.ui.TransformListener;
-
-/**
- * A {@link TransformEventHandler} that changes an {@link AffineTransform3D}
- * through a set of {@link Behaviour}s.
- *
- * @author Stephan Saalfeld
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
- */
-public class BehaviourTransformEventHandler3D implements BehaviourTransformEventHandler< AffineTransform3D >
-{
-	public static TransformEventHandlerFactory< AffineTransform3D > factory()
-	{
-		return new BehaviourTransformEventHandler3DFactory();
-	}
-
-	public static class BehaviourTransformEventHandler3DFactory implements BehaviourTransformEventHandlerFactory< AffineTransform3D >
-	{
-		private InputTriggerConfig config = new InputTriggerConfig();
-
-		@Override
-		public void setConfig( final InputTriggerConfig config )
-		{
-			this.config = config;
-		}
-
-		@Override
-		public BehaviourTransformEventHandler3D create( final TransformListener< AffineTransform3D > transformListener )
-		{
-			return new BehaviourTransformEventHandler3D( transformListener, config );
-		}
-	}
-
-	/**
-	 * Current source to screen transform.
-	 */
-	protected final AffineTransform3D affine = new AffineTransform3D();
-
-	/**
-	 * Whom to notify when the {@link #affine current transform} is changed.
-	 */
-	private TransformListener< AffineTransform3D > listener;
-
-	/**
-	 * Copy of {@link #affine current transform} when mouse dragging started.
-	 */
-	final protected AffineTransform3D affineDragStart = new AffineTransform3D();
-
-	/**
-	 * Coordinates where mouse dragging started.
-	 */
-	protected double oX, oY;
-
-	/**
-	 * Current rotation axis for rotating with keyboard, indexed {@code x->0, y->1,
-	 * z->2}.
-	 */
-	protected int axis = 0;
-
-	/**
-	 * The screen size of the canvas (the component displaying the image and
-	 * generating mouse events).
-	 */
-	protected int canvasW = 1, canvasH = 1;
-
-	/**
-	 * Screen coordinates to keep centered while zooming or rotating with the
-	 * keyboard. These are set to <em>(canvasW/2, canvasH/2)</em>
-	 */
-	protected int centerX = 0, centerY = 0;
-
-	protected final Behaviours behaviours;
-
-	public BehaviourTransformEventHandler3D( final TransformListener< AffineTransform3D > listener, final InputTriggerConfig config )
-	{
-		this.listener = listener;
-
-		final String DRAG_TRANSLATE = "drag translate";
-		final String ZOOM_NORMAL = "scroll zoom";
-		final String SELECT_AXIS_X = "axis x";
-		final String SELECT_AXIS_Y = "axis y";
-		final String SELECT_AXIS_Z = "axis z";
-
-		final double[] speed =      { 1.0,     10.0,     0.1 };
-		final String[] SPEED_NAME = {  "",  " fast", " slow" };
-		final String[] speedMod =   {  "", "shift ", "ctrl " };
-
-		final String DRAG_ROTATE = "drag rotate";
-		final String SCROLL_Z = "scroll browse z";
-		final String ROTATE_LEFT = "rotate left";
-		final String ROTATE_RIGHT = "rotate right";
-		final String KEY_ZOOM_IN = "zoom in";
-		final String KEY_ZOOM_OUT = "zoom out";
-		final String KEY_FORWARD_Z = "forward z";
-		final String KEY_BACKWARD_Z = "backward z";
-
-		behaviours = new Behaviours( config, "bdv" );
-
-		behaviours.behaviour( new TranslateXY(), DRAG_TRANSLATE, "button2", "button3" );
-		behaviours.behaviour( new Zoom( speed[ 0 ] ), ZOOM_NORMAL, "meta scroll", "ctrl shift scroll" );
-		behaviours.behaviour( new SelectRotationAxis( 0 ), SELECT_AXIS_X, "X" );
-		behaviours.behaviour( new SelectRotationAxis( 1 ), SELECT_AXIS_Y, "Y" );
-		behaviours.behaviour( new SelectRotationAxis( 2 ), SELECT_AXIS_Z, "Z" );
-
-		for ( int s = 0; s < 3; ++s )
-		{
-			behaviours.behaviour( new Rotate( speed[ s ] ), DRAG_ROTATE + SPEED_NAME[ s ], speedMod[ s ] + "button1" );
-			behaviours.behaviour( new TranslateZ( speed[ s ] ), SCROLL_Z + SPEED_NAME[ s ], speedMod[ s ] + "scroll" );
-			behaviours.behaviour( new KeyRotate( speed[ s ] ), ROTATE_LEFT + SPEED_NAME[ s ], speedMod[ s ] + "LEFT" );
-			behaviours.behaviour( new KeyRotate( -speed[ s ] ), ROTATE_RIGHT + SPEED_NAME[ s ], speedMod[ s ] + "RIGHT" );
-			behaviours.behaviour( new KeyZoom( speed[ s ] ), KEY_ZOOM_IN + SPEED_NAME[ s ], speedMod[ s ] + "UP" );
-			behaviours.behaviour( new KeyZoom( -speed[ s ] ), KEY_ZOOM_OUT + SPEED_NAME[ s ], speedMod[ s ] + "DOWN" );
-			behaviours.behaviour( new KeyTranslateZ( speed[ s ] ), KEY_FORWARD_Z + SPEED_NAME[ s ], speedMod[ s ] + "COMMA" );
-			behaviours.behaviour( new KeyTranslateZ( -speed[ s ] ), KEY_BACKWARD_Z + SPEED_NAME[ s ], speedMod[ s ] + "PERIOD" );
-		}
-	}
-
-	@Override
-	public void install( final TriggerBehaviourBindings bindings )
-	{
-		behaviours.install( bindings, "transform" );
-	}
-
-	@Override
-	public AffineTransform3D getTransform()
-	{
-		synchronized ( affine )
-		{
-			return affine.copy();
-		}
-	}
-
-	@Override
-	public void setTransform( final AffineTransform3D transform )
-	{
-		synchronized ( affine )
-		{
-			affine.set( transform );
-		}
-	}
-
-	@Override
-	public void setCanvasSize( final int width, final int height, final boolean updateTransform )
-	{
-		if ( width == 0 || height == 0 ) {
-			// NB: We are probably in some intermediate layout scenario.
-			// Attempting to trigger a transform update with 0 size will result
-			// in the exception "Matrix is singular" from imglib2-realtrasform.
-			return;
-		}
-		if ( updateTransform )
-		{
-			synchronized ( affine )
-			{
-				affine.set( affine.get( 0, 3 ) - canvasW / 2, 0, 3 );
-				affine.set( affine.get( 1, 3 ) - canvasH / 2, 1, 3 );
-				affine.scale( ( double ) width / canvasW );
-				affine.set( affine.get( 0, 3 ) + width / 2, 0, 3 );
-				affine.set( affine.get( 1, 3 ) + height / 2, 1, 3 );
-				notifyListener();
-			}
-		}
-		canvasW = width;
-		canvasH = height;
-		centerX = width / 2;
-		centerY = height / 2;
-	}
-
-	@Override
-	public void setTransformListener( final TransformListener< AffineTransform3D > transformListener )
-	{
-		listener = transformListener;
-	}
-
-	@Override
-	public String getHelpString()
-	{
-		return helpString;
-	}
-
-	/**
-	 * notifies {@link #listener} that the current transform changed.
-	 */
-	private void notifyListener()
-	{
-		if ( listener != null )
-			listener.transformChanged( affine );
-	}
-
-	/**
-	 * One step of rotation (radian).
-	 */
-	final protected static double step = Math.PI / 180;
-
-	final protected static String NL = System.getProperty( "line.separator" );
-
-	final protected static String helpString =
-			"Mouse control:" + NL + " " + NL +
-					"Pan and tilt the volume by left-click and dragging the image in the canvas, " + NL +
-					"move the volume by middle-or-right-click and dragging the image in the canvas, " + NL +
-					"browse alongside the z-axis using the mouse-wheel, and" + NL +
-					"zoom in and out using the mouse-wheel holding CTRL+SHIFT or META." + NL + " " + NL +
-					"Key control:" + NL + " " + NL +
-					"X - Select x-axis as rotation axis." + NL +
-					"Y - Select y-axis as rotation axis." + NL +
-					"Z - Select z-axis as rotation axis." + NL +
-					"CURSOR LEFT - Rotate clockwise around the choosen rotation axis." + NL +
-					"CURSOR RIGHT - Rotate counter-clockwise around the choosen rotation axis." + NL +
-					"CURSOR UP - Zoom in." + NL +
-					"CURSOR DOWN - Zoom out." + NL +
-					"./> - Forward alongside z-axis." + NL +
-					",/< - Backward alongside z-axis." + NL +
-					"SHIFT - Rotate and browse 10x faster." + NL +
-					"CTRL - Rotate and browse 10x slower.";
-
-	private void scale( final double s, final double x, final double y )
-	{
-		// center shift
-		affine.set( affine.get( 0, 3 ) - x, 0, 3 );
-		affine.set( affine.get( 1, 3 ) - y, 1, 3 );
-
-		// scale
-		affine.scale( s );
-
-		// center un-shift
-		affine.set( affine.get( 0, 3 ) + x, 0, 3 );
-		affine.set( affine.get( 1, 3 ) + y, 1, 3 );
-	}
-
-	/**
-	 * Rotate by d radians around axis. Keep screen coordinates (
-	 * {@link #centerX}, {@link #centerY}) fixed.
-	 */
-	private void rotate( final int axis, final double d )
-	{
-		// center shift
-		affine.set( affine.get( 0, 3 ) - centerX, 0, 3 );
-		affine.set( affine.get( 1, 3 ) - centerY, 1, 3 );
-
-		// rotate
-		affine.rotate( axis, d );
-
-		// center un-shift
-		affine.set( affine.get( 0, 3 ) + centerX, 0, 3 );
-		affine.set( affine.get( 1, 3 ) + centerY, 1, 3 );
-	}
-
-	private class Rotate implements DragBehaviour
-	{
-		private final double speed;
-
-		public Rotate( final double speed )
-		{
-			this.speed = speed;
-		}
-
-		@Override
-		public void init( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				oX = x;
-				oY = y;
-				affineDragStart.set( affine );
-			}
-		}
-
-		@Override
-		public void drag( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				final double dX = oX - x;
-				final double dY = oY - y;
-
-				affine.set( affineDragStart );
-
-				// center shift
-				affine.set( affine.get( 0, 3 ) - oX, 0, 3 );
-				affine.set( affine.get( 1, 3 ) - oY, 1, 3 );
-
-				final double v = step * speed;
-				affine.rotate( 0, -dY * v );
-				affine.rotate( 1, dX * v );
-
-				// center un-shift
-				affine.set( affine.get( 0, 3 ) + oX, 0, 3 );
-				affine.set( affine.get( 1, 3 ) + oY, 1, 3 );
-				notifyListener();
-			}
-		}
-
-		@Override
-		public void end( final int x, final int y )
-		{}
-	}
-
-	private class TranslateXY implements DragBehaviour
-	{
-		@Override
-		public void init( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				oX = x;
-				oY = y;
-				affineDragStart.set( affine );
-			}
-		}
-
-		@Override
-		public void drag( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				final double dX = oX - x;
-				final double dY = oY - y;
-
-				affine.set( affineDragStart );
-				affine.set( affine.get( 0, 3 ) - dX, 0, 3 );
-				affine.set( affine.get( 1, 3 ) - dY, 1, 3 );
-				notifyListener();
-			}
-		}
-
-		@Override
-		public void end( final int x, final int y )
-		{}
-	}
-
-	private class TranslateZ implements ScrollBehaviour
-	{
-		private final double speed;
-
-		public TranslateZ( final double speed )
-		{
-			this.speed = speed;
-		}
-
-		@Override
-		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				final double dZ = speed * -wheelRotation;
-				// TODO (optionally) correct for zoom
-				affine.set( affine.get( 2, 3 ) - dZ, 2, 3 );
-				notifyListener();
-			}
-		}
-	}
-
-	private class Zoom implements ScrollBehaviour
-	{
-		private final double speed;
-
-		public Zoom( final double speed )
-		{
-			this.speed = speed;
-		}
-
-		@Override
-		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				final double s = speed * wheelRotation;
-				final double dScale = 1.0 + 0.05;
-				if ( s > 0 )
-					scale( 1.0 / dScale, x, y );
-				else
-					scale( dScale, x, y );
-				notifyListener();
-			}
-		}
-	}
-
-	private class SelectRotationAxis implements ClickBehaviour
-	{
-		private final int axis;
-
-		public SelectRotationAxis( final int axis )
-		{
-			this.axis = axis;
-		}
-
-		@Override
-		public void click( final int x, final int y )
-		{
-			BehaviourTransformEventHandler3D.this.axis = axis;
-		}
-	}
-
-	private class KeyRotate implements ClickBehaviour
-	{
-		private final double speed;
-
-		public KeyRotate( final double speed )
-		{
-			this.speed = speed;
-		}
-
-		@Override
-		public void click( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				rotate( axis, step * speed );
-				notifyListener();
-			}
-		}
-	}
-
-	private class KeyZoom implements ClickBehaviour
-	{
-		private final double dScale;
-
-		public KeyZoom( final double speed )
-		{
-			if ( speed > 0 )
-				dScale = 1.0 + 0.1 * speed;
-			else
-				dScale = 1.0 / ( 1.0 - 0.1 * speed );
-		}
-
-		@Override
-		public void click( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				scale( dScale, centerX, centerY );
-				notifyListener();
-			}
-		}
-	}
-
-	private class KeyTranslateZ implements ClickBehaviour
-	{
-		private final double speed;
-
-		public KeyTranslateZ( final double speed )
-		{
-			this.speed = speed;
-		}
-
-		@Override
-		public void click( final int x, final int y )
-		{
-			synchronized ( affine )
-			{
-				affine.set( affine.get( 2, 3 ) + speed, 2, 3 );
-				notifyListener();
-			}
-		}
-	}
-}
diff --git a/src/main/java/bdv/BigDataViewer.java b/src/main/java/bdv/BigDataViewer.java
index 26d5e27257312db4c2cbf6c067ebd95bb9320d2d..042a3929c4b5914cf0be3f78e87bcaf74d593e7b 100644
--- a/src/main/java/bdv/BigDataViewer.java
+++ b/src/main/java/bdv/BigDataViewer.java
@@ -328,8 +328,6 @@ public class BigDataViewer
 			final ViewerOptions options )
 	{
 		final InputTriggerConfig inputTriggerConfig = getInputTriggerConfig( options );
-		if ( options.values.getTransformEventHandlerFactory() instanceof BehaviourTransformEventHandlerFactory )
-			( ( BehaviourTransformEventHandlerFactory< ? > ) options.values.getTransformEventHandlerFactory() ).setConfig( inputTriggerConfig );
 
 		viewerFrame = new ViewerFrame( sources, numTimepoints, cache, options.inputTriggerConfig( inputTriggerConfig ) );
 		if ( windowTitle != null )
@@ -375,11 +373,11 @@ public class BigDataViewer
 
 		movieDialog = new RecordMovieDialog( viewerFrame, viewer, progressWriter );
 		// this is just to get updates of window size:
-		viewer.getDisplay().addOverlayRenderer( movieDialog );
+		viewer.getDisplay().overlays().add( movieDialog );
 
 		movieMaxProjectDialog = new RecordMaxProjectionDialog( viewerFrame, viewer, progressWriter );
 		// this is just to get updates of window size:
-		viewer.getDisplay().addOverlayRenderer( movieMaxProjectDialog );
+		viewer.getDisplay().overlays().add( movieMaxProjectDialog );
 
 		activeSourcesDialog = new VisibilityAndGroupingDialog( viewerFrame, viewer.state() );
 
diff --git a/src/main/java/bdv/TransformEventHandler.java b/src/main/java/bdv/TransformEventHandler.java
new file mode 100644
index 0000000000000000000000000000000000000000..530ba94a9f05ceb6c5197b6396ecacb22cc67ca0
--- /dev/null
+++ b/src/main/java/bdv/TransformEventHandler.java
@@ -0,0 +1,74 @@
+/*
+ * #%L
+ * ImgLib2: a general-purpose, multidimensional image processing library.
+ * %%
+ * Copyright (C) 2009 - 2016 Tobias Pietzsch, Stephan Preibisch, Stephan Saalfeld,
+ * John Bogovic, Albert Cardona, Barry DeZonia, Christian Dietz, Jan Funke,
+ * Aivar Grislis, Jonathan Hale, Grant Harris, Stefan Helfrich, Mark Hiner,
+ * Martin Horn, Steffen Jaensch, Lee Kamentsky, Larry Lindsey, Melissa Linkert,
+ * Mark Longair, Brian Northan, Nick Perry, Curtis Rueden, Johannes Schindelin,
+ * Jean-Yves Tinevez and Michael Zinsmaier.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv;
+
+import org.scijava.ui.behaviour.util.Behaviours;
+
+/**
+ * Change a transformation in response to user input (mouse events, key events, etc.)
+ *
+ * The {@link TransformEventHandler} receives notifications about changes of the
+ * canvas size (it may react for example by changing the scale of the
+ * transformation accordingly).
+ *
+ * @author Tobias Pietzsch
+ */
+public interface TransformEventHandler
+{
+	/**
+	 * Install transformation behaviours into the specified {@code behaviours} contrainer.
+	 */
+	void install( Behaviours behaviours );
+
+	/**
+	 * This is called, when the screen size of the canvas (the component
+	 * displaying the image and generating mouse events) changes. This can be
+	 * used to determine screen coordinates to keep fixed while zooming or
+	 * rotating with the keyboard, e.g., set these to
+	 * <em>(width/2, height/2)</em>. It can also be used to update the current
+	 * source-to-screen transform, e.g., to change the zoom along with the
+	 * canvas size.
+	 *
+	 * @param width
+	 *            the new canvas width.
+	 * @param height
+	 *            the new canvas height.
+	 * @param updateTransform
+	 *            whether the current source-to-screen transform should be
+	 *            updated. This will be <code>false</code> for the initial
+	 *            update of a new {@link TransformEventHandler} and
+	 *            <code>true</code> on subsequent calls.
+	 */
+	void setCanvasSize( int width, int height, boolean updateTransform );
+}
diff --git a/src/main/java/bdv/TransformEventHandler2D.java b/src/main/java/bdv/TransformEventHandler2D.java
new file mode 100644
index 0000000000000000000000000000000000000000..1b809e18004684cb99e93629da42edbb0b00dea4
--- /dev/null
+++ b/src/main/java/bdv/TransformEventHandler2D.java
@@ -0,0 +1,464 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies
+ * %%
+ * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
+ * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv;
+
+import net.imglib2.realtransform.AffineTransform3D;
+import org.scijava.ui.behaviour.Behaviour;
+import org.scijava.ui.behaviour.ClickBehaviour;
+import org.scijava.ui.behaviour.DragBehaviour;
+import org.scijava.ui.behaviour.ScrollBehaviour;
+import org.scijava.ui.behaviour.util.Behaviours;
+
+/**
+ * A {@link TransformEventHandler} that changes an {@link AffineTransform3D}
+ * through a set of {@link Behaviour}s.
+ *
+ * @author Tobias Pietzsch
+ */
+public class TransformEventHandler2D implements TransformEventHandler
+{
+	// -- behaviour names --
+
+	private static final String DRAG_TRANSLATE = "2d drag translate";
+	private static final String DRAG_ROTATE = "2d drag rotate";
+
+	private static final String ZOOM_NORMAL = "2d scroll zoom";
+	private static final String SCROLL_TRANSLATE = "2d scroll translate";
+	private static final String SCROLL_ROTATE = "2d scroll rotate";
+	private static final String ROTATE_LEFT = "2d rotate left";
+	private static final String ROTATE_RIGHT = "2d rotate right";
+	private static final String KEY_ZOOM_IN = "2d zoom in";
+	private static final String KEY_ZOOM_OUT = "2d zoom out";
+
+	private static final String ZOOM_FAST = "2d scroll zoom fast";
+	private static final String SCROLL_TRANSLATE_FAST = "2d scroll translate fast";
+	private static final String SCROLL_ROTATE_FAST = "2d scroll rotate fast";
+	private static final String ROTATE_LEFT_FAST = "2d rotate left fast";
+	private static final String ROTATE_RIGHT_FAST = "2d rotate right fast";
+	private static final String KEY_ZOOM_IN_FAST = "2d zoom in fast";
+	private static final String KEY_ZOOM_OUT_FAST = "2d zoom out fast";
+
+	private static final String ZOOM_SLOW = "2d scroll zoom slow";
+	private static final String SCROLL_TRANSLATE_SLOW = "2d scroll translate slow";
+	private static final String SCROLL_ROTATE_SLOW = "2d scroll rotate slow";
+	private static final String ROTATE_LEFT_SLOW = "2d rotate left slow";
+	private static final String ROTATE_RIGHT_SLOW = "2d rotate right slow";
+	private static final String KEY_ZOOM_IN_SLOW = "2d zoom in slow";
+	private static final String KEY_ZOOM_OUT_SLOW = "2d zoom out slow";
+
+	// -- default shortcuts --
+
+	private static final String[] DRAG_TRANSLATE_KEYS = new String[] { "button2", "button3" };
+	private static final String[] DRAG_ROTATE_KEYS = new String[] { "button1" };
+
+	private static final String[] ZOOM_NORMAL_KEYS = new String[] { "meta scroll", "ctrl shift scroll" };
+	private static final String[] SCROLL_TRANSLATE_KEYS = new String[] { "not mapped" };
+	private static final String[] SCROLL_ROTATE_KEYS = new String[] { "scroll" };
+	private static final String[] ROTATE_LEFT_KEYS = new String[] { "LEFT" };
+	private static final String[] ROTATE_RIGHT_KEYS = new String[] { "RIGHT" };
+	private static final String[] KEY_ZOOM_IN_KEYS = new String[] { "UP" };
+	private static final String[] KEY_ZOOM_OUT_KEYS = new String[] { "DOWN" };
+
+	private static final String[] ZOOM_FAST_KEYS = new String[] { "shift scroll" };
+	private static final String[] SCROLL_TRANSLATE_FAST_KEYS = new String[] { "not mapped" };
+	private static final String[] SCROLL_TRANSLATE_SLOW_KEYS = new String[] { "not mapped" };
+	private static final String[] ROTATE_LEFT_FAST_KEYS = new String[] { "shift LEFT" };
+	private static final String[] ROTATE_RIGHT_FAST_KEYS = new String[] { "shift RIGHT" };
+	private static final String[] KEY_ZOOM_IN_FAST_KEYS = new String[] { "shift UP" };
+	private static final String[] KEY_ZOOM_OUT_FAST_KEYS = new String[] { "shift DOWN" };
+
+	private static final String[] ZOOM_SLOW_KEYS = new String[] { "ctrl scroll" };
+	private static final String[] SCROLL_ROTATE_FAST_KEYS = new String[] { "shift scroll" };
+	private static final String[] SCROLL_ROTATE_SLOW_KEYS = new String[] { "ctrl scroll" };
+	private static final String[] ROTATE_LEFT_SLOW_KEYS = new String[] { "ctrl LEFT" };
+	private static final String[] ROTATE_RIGHT_SLOW_KEYS = new String[] { "ctrl RIGHT" };
+	private static final String[] KEY_ZOOM_IN_SLOW_KEYS = new String[] { "ctrl UP" };
+	private static final String[] KEY_ZOOM_OUT_SLOW_KEYS = new String[] { "ctrl DOWN" };
+
+	// -- behaviours --
+
+	private final DragTranslate dragTranslate;
+	private final DragRotate dragRotate;
+	private final Zoom zoom;
+	private final Zoom zoomFast;
+	private final Zoom zoomSlow;
+	private final ScrollTranslate scrollTranslate;
+	private final ScrollTranslate scrollTranslateFast;
+	private final ScrollTranslate scrollTranslateSlow;
+	private final ScrollRotate scrollRotate;
+	private final ScrollRotate scrollRotateFast;
+	private final ScrollRotate scrollRotateSlow;
+	private final KeyRotate keyRotateLeft;
+	private final KeyRotate keyRotateLeftFast;
+	private final KeyRotate keyRotateLeftSlow;
+	private final KeyRotate keyRotateRight;
+	private final KeyRotate keyRotateRightFast;
+	private final KeyRotate keyRotateRightSlow;
+	private final KeyZoom keyZoomIn;
+	private final KeyZoom keyZoomInFast;
+	private final KeyZoom keyZoomInSlow;
+	private final KeyZoom keyZoomOut;
+	private final KeyZoom keyZoomOutFast;
+	private final KeyZoom keyZoomOutSlow;
+
+	private static final double[] speed = { 1.0, 10.0, 0.1 };
+
+	/**
+	 * Copy of transform when mouse dragging started.
+	 */
+	private final AffineTransform3D affineDragStart = new AffineTransform3D();
+
+	/**
+	 * Current transform during mouse dragging.
+	 */
+	private final AffineTransform3D affineDragCurrent = new AffineTransform3D();
+
+	/**
+	 * Coordinates where mouse dragging started.
+	 */
+	private double oX, oY;
+
+	/**
+	 * The screen size of the canvas (the component displaying the image and
+	 * generating mouse events).
+	 */
+	private int canvasW = 1, canvasH = 1;
+
+	/**
+	 * Screen coordinates to keep centered while zooming or rotating with the
+	 * keyboard. These are set to <em>(canvasW/2, canvasH/2)</em>
+	 */
+	private int centerX = 0, centerY = 0;
+
+	private final TransformState transform;
+
+	public TransformEventHandler2D( final TransformState transform )
+	{
+		this.transform = transform;
+
+		dragTranslate = new DragTranslate();
+		dragRotate = new DragRotate();
+
+		scrollTranslate = new ScrollTranslate( speed[ 0 ] );
+		scrollTranslateFast = new ScrollTranslate( speed[ 1 ] );
+		scrollTranslateSlow = new ScrollTranslate( speed[ 2 ] );
+
+		zoom = new Zoom( speed[ 0 ] );
+		zoomFast = new Zoom( speed[ 1 ] );
+		zoomSlow = new Zoom( speed[ 2 ] );
+
+		scrollRotate = new ScrollRotate( 2 * speed[ 0 ] );
+		scrollRotateFast = new ScrollRotate( 2 * speed[ 1 ] );
+		scrollRotateSlow = new ScrollRotate( 2 * speed[ 2 ] );
+
+		keyRotateLeft = new KeyRotate( speed[ 0 ] );
+		keyRotateLeftFast = new KeyRotate( speed[ 1 ] );
+		keyRotateLeftSlow = new KeyRotate( speed[ 2 ] );
+		keyRotateRight = new KeyRotate( -speed[ 0 ] );
+		keyRotateRightFast = new KeyRotate( -speed[ 1 ] );
+		keyRotateRightSlow = new KeyRotate( -speed[ 2 ] );
+
+		keyZoomIn = new KeyZoom( speed[ 0 ] );
+		keyZoomInFast = new KeyZoom( speed[ 1 ] );
+		keyZoomInSlow = new KeyZoom( speed[ 2 ] );
+		keyZoomOut = new KeyZoom( -speed[ 0 ] );
+		keyZoomOutFast = new KeyZoom( -speed[ 1 ] );
+		keyZoomOutSlow = new KeyZoom( -speed[ 2 ] );
+	}
+
+	@Override
+	public void install( final Behaviours behaviours )
+	{
+		behaviours.behaviour( dragTranslate, DRAG_TRANSLATE, DRAG_TRANSLATE_KEYS );
+		behaviours.behaviour( dragRotate, DRAG_ROTATE,  DRAG_ROTATE_KEYS );
+
+		behaviours.behaviour( scrollTranslate, SCROLL_TRANSLATE, SCROLL_TRANSLATE_KEYS );
+		behaviours.behaviour( scrollTranslateFast, SCROLL_TRANSLATE_FAST, SCROLL_TRANSLATE_FAST_KEYS );
+		behaviours.behaviour( scrollTranslateSlow, SCROLL_TRANSLATE_SLOW, SCROLL_TRANSLATE_SLOW_KEYS );
+
+		behaviours.behaviour( zoom, ZOOM_NORMAL, ZOOM_NORMAL_KEYS );
+		behaviours.behaviour( zoomFast, ZOOM_FAST, ZOOM_FAST_KEYS );
+		behaviours.behaviour( zoomSlow, ZOOM_SLOW, ZOOM_SLOW_KEYS );
+
+		behaviours.behaviour( scrollRotate, SCROLL_ROTATE, SCROLL_ROTATE_KEYS );
+		behaviours.behaviour( scrollRotateFast, SCROLL_ROTATE_FAST, SCROLL_ROTATE_FAST_KEYS );
+		behaviours.behaviour( scrollRotateSlow, SCROLL_ROTATE_SLOW, SCROLL_ROTATE_SLOW_KEYS );
+
+		behaviours.behaviour( keyRotateLeft, ROTATE_LEFT, ROTATE_LEFT_KEYS );
+		behaviours.behaviour( keyRotateLeftFast, ROTATE_LEFT_FAST, ROTATE_LEFT_FAST_KEYS );
+		behaviours.behaviour( keyRotateLeftSlow, ROTATE_LEFT_SLOW, ROTATE_LEFT_SLOW_KEYS );
+		behaviours.behaviour( keyRotateRight, ROTATE_RIGHT, ROTATE_RIGHT_KEYS );
+		behaviours.behaviour( keyRotateRightFast, ROTATE_RIGHT_FAST, ROTATE_RIGHT_FAST_KEYS );
+		behaviours.behaviour( keyRotateRightSlow, ROTATE_RIGHT_SLOW, ROTATE_RIGHT_SLOW_KEYS );
+
+		behaviours.behaviour( keyZoomIn,  KEY_ZOOM_IN, KEY_ZOOM_IN_KEYS );
+		behaviours.behaviour( keyZoomInFast, KEY_ZOOM_IN_FAST, KEY_ZOOM_IN_FAST_KEYS );
+		behaviours.behaviour( keyZoomInSlow, KEY_ZOOM_IN_SLOW, KEY_ZOOM_IN_SLOW_KEYS );
+		behaviours.behaviour( keyZoomOut, KEY_ZOOM_OUT, KEY_ZOOM_OUT_KEYS );
+		behaviours.behaviour( keyZoomOutFast, KEY_ZOOM_OUT_FAST, KEY_ZOOM_OUT_FAST_KEYS );
+		behaviours.behaviour( keyZoomOutSlow, KEY_ZOOM_OUT_SLOW, KEY_ZOOM_OUT_SLOW_KEYS );
+	}
+
+	@Override
+	public void setCanvasSize( final int width, final int height, final boolean updateTransform )
+	{
+		if ( width == 0 || height == 0 ) {
+			// NB: We are probably in some intermediate layout scenario.
+			// Attempting to trigger a transform update with 0 size will result
+			// in the exception "Matrix is singular" from imglib2-realtrasform.
+			return;
+		}
+		if ( updateTransform )
+		{
+			final AffineTransform3D affine = transform.get();
+			affine.set( affine.get( 0, 3 ) - canvasW / 2, 0, 3 );
+			affine.set( affine.get( 1, 3 ) - canvasH / 2, 1, 3 );
+			affine.scale( ( double ) width / canvasW );
+			affine.set( affine.get( 0, 3 ) + width / 2, 0, 3 );
+			affine.set( affine.get( 1, 3 ) + height / 2, 1, 3 );
+			transform.set( affine );
+		}
+		canvasW = width;
+		canvasH = height;
+		centerX = width / 2;
+		centerY = height / 2;
+	}
+
+	/**
+	 * One step of rotation (radian).
+	 */
+	final private static double step = Math.PI / 180;
+
+	private void scale( final double s, final double x, final double y )
+	{
+		final AffineTransform3D affine = transform.get();
+
+		// center shift
+		affine.set( affine.get( 0, 3 ) - x, 0, 3 );
+		affine.set( affine.get( 1, 3 ) - y, 1, 3 );
+
+		// scale
+		affine.scale( s );
+
+		// center un-shift
+		affine.set( affine.get( 0, 3 ) + x, 0, 3 );
+		affine.set( affine.get( 1, 3 ) + y, 1, 3 );
+
+		transform.set( affine );
+	}
+
+	/**
+	 * Rotate by d radians around Z axis. Keep screen coordinates {@code (centerX, centerY)} fixed.
+	 */
+	private void rotate( final AffineTransform3D affine, final double d )
+	{
+		// center shift
+		affine.set( affine.get( 0, 3 ) - centerX, 0, 3 );
+		affine.set( affine.get( 1, 3 ) - centerY, 1, 3 );
+
+		// rotate
+		affine.rotate( 2, d );
+
+		// center un-shift
+		affine.set( affine.get( 0, 3 ) + centerX, 0, 3 );
+		affine.set( affine.get( 1, 3 ) + centerY, 1, 3 );
+	}
+
+	private class DragRotate implements DragBehaviour
+	{
+		@Override
+		public void init( final int x, final int y )
+		{
+			oX = x;
+			oY = y;
+			transform.get( affineDragStart );
+		}
+
+		@Override
+		public void drag( final int x, final int y )
+		{
+			final double dX = x - centerX;
+			final double dY = y - centerY;
+			final double odX = oX - centerX;
+			final double odY = oY - centerY;
+			final double theta = Math.atan2( dY, dX ) - Math.atan2( odY, odX );
+
+			affineDragCurrent.set( affineDragStart );
+			rotate( affineDragCurrent, theta );
+			transform.set( affineDragCurrent );
+		}
+
+		@Override
+		public void end( final int x, final int y )
+		{}
+	}
+
+	private class ScrollRotate implements ScrollBehaviour
+	{
+		private final double speed;
+
+		public ScrollRotate( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
+		{
+			final AffineTransform3D affine = transform.get();
+
+			final double theta = speed * wheelRotation * Math.PI / 180.0;
+
+			// center shift
+			affine.set( affine.get( 0, 3 ) - x, 0, 3 );
+			affine.set( affine.get( 1, 3 ) - y, 1, 3 );
+
+			affine.rotate( 2, theta );
+
+			// center un-shift
+			affine.set( affine.get( 0, 3 ) + x, 0, 3 );
+			affine.set( affine.get( 1, 3 ) + y, 1, 3 );
+
+			transform.set( affine );
+		}
+	}
+
+	private class DragTranslate implements DragBehaviour
+	{
+		@Override
+		public void init( final int x, final int y )
+		{
+			oX = x;
+			oY = y;
+			transform.get( affineDragStart );
+		}
+
+		@Override
+		public void drag( final int x, final int y )
+		{
+			final double dX = oX - x;
+			final double dY = oY - y;
+
+			affineDragCurrent.set( affineDragStart );
+			affineDragCurrent.set( affineDragCurrent.get( 0, 3 ) - dX, 0, 3 );
+			affineDragCurrent.set( affineDragCurrent.get( 1, 3 ) - dY, 1, 3 );
+
+			transform.set( affineDragCurrent );
+		}
+
+		@Override
+		public void end( final int x, final int y )
+		{}
+	}
+
+	private class ScrollTranslate implements ScrollBehaviour
+	{
+
+		private final double speed;
+
+		public ScrollTranslate( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
+		{
+			final AffineTransform3D affine = transform.get();
+
+			final double d = -wheelRotation * 10 * speed;
+			if ( isHorizontal )
+				affine.translate( d, 0, 0 );
+			else
+				affine.translate( 0, d, 0 );
+
+			transform.set( affine );
+		}
+	}
+
+	private class Zoom implements ScrollBehaviour
+	{
+
+		private final double speed;
+
+		public Zoom( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
+		{
+			final double s = speed * wheelRotation;
+			final double dScale = 1.0 + 0.05 * Math.abs( s );
+			if ( s > 0 )
+				scale( 1.0 / dScale, x, y );
+			else
+				scale( dScale, x, y );
+		}
+	}
+
+	private class KeyRotate implements ClickBehaviour
+	{
+		private final double speed;
+
+		public KeyRotate( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void click( final int x, final int y )
+		{
+			final AffineTransform3D affine = transform.get();
+			rotate( affine, step * speed );
+			transform.set( affine );
+		}
+	}
+
+	private class KeyZoom implements ClickBehaviour
+	{
+		private final double dScale;
+
+		public KeyZoom( final double speed )
+		{
+			if ( speed > 0 )
+				dScale = 1.0 + 0.1 * speed;
+			else
+				dScale = 1.0 / ( 1.0 - 0.1 * speed );
+		}
+
+		@Override
+		public void click( final int x, final int y )
+		{
+			scale( dScale, centerX, centerY );
+		}
+	}
+}
diff --git a/src/main/java/bdv/TransformEventHandler3D.java b/src/main/java/bdv/TransformEventHandler3D.java
new file mode 100644
index 0000000000000000000000000000000000000000..e690d9a9337d3c5697d35e1ad6881e63c602e2c5
--- /dev/null
+++ b/src/main/java/bdv/TransformEventHandler3D.java
@@ -0,0 +1,515 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies.
+ * %%
+ * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv;
+
+import net.imglib2.realtransform.AffineTransform3D;
+import org.scijava.ui.behaviour.Behaviour;
+import org.scijava.ui.behaviour.ClickBehaviour;
+import org.scijava.ui.behaviour.DragBehaviour;
+import org.scijava.ui.behaviour.ScrollBehaviour;
+import org.scijava.ui.behaviour.util.Behaviours;
+
+/**
+ * A {@link TransformEventHandler} that changes an {@link AffineTransform3D}
+ * through a set of {@link Behaviour}s.
+ *
+ * @author Stephan Saalfeld
+ * @author Tobias Pietzsch
+ */
+public class TransformEventHandler3D implements TransformEventHandler
+{
+	// -- behaviour names --
+
+	public static final String DRAG_TRANSLATE = "drag translate";
+	public static final String ZOOM_NORMAL = "scroll zoom";
+	public static final String SELECT_AXIS_X = "axis x";
+	public static final String SELECT_AXIS_Y = "axis y";
+	public static final String SELECT_AXIS_Z = "axis z";
+
+	public static final String DRAG_ROTATE = "drag rotate";
+	public static final String SCROLL_Z = "scroll browse z";
+	public static final String ROTATE_LEFT = "rotate left";
+	public static final String ROTATE_RIGHT = "rotate right";
+	public static final String KEY_ZOOM_IN = "zoom in";
+	public static final String KEY_ZOOM_OUT = "zoom out";
+	public static final String KEY_FORWARD_Z = "forward z";
+	public static final String KEY_BACKWARD_Z = "backward z";
+
+	public static final String DRAG_ROTATE_FAST = "drag rotate fast";
+	public static final String SCROLL_Z_FAST = "scroll browse z fast";
+	public static final String ROTATE_LEFT_FAST = "rotate left fast";
+	public static final String ROTATE_RIGHT_FAST = "rotate right fast";
+	public static final String KEY_ZOOM_IN_FAST = "zoom in fast";
+	public static final String KEY_ZOOM_OUT_FAST = "zoom out fast";
+	public static final String KEY_FORWARD_Z_FAST = "forward z fast";
+	public static final String KEY_BACKWARD_Z_FAST = "backward z fast";
+
+	public static final String DRAG_ROTATE_SLOW = "drag rotate slow";
+	public static final String SCROLL_Z_SLOW = "scroll browse z slow";
+	public static final String ROTATE_LEFT_SLOW = "rotate left slow";
+	public static final String ROTATE_RIGHT_SLOW = "rotate right slow";
+	public static final String KEY_ZOOM_IN_SLOW = "zoom in slow";
+	public static final String KEY_ZOOM_OUT_SLOW = "zoom out slow";
+	public static final String KEY_FORWARD_Z_SLOW = "forward z slow";
+	public static final String KEY_BACKWARD_Z_SLOW = "backward z slow";
+
+	// -- default shortcuts --
+
+	private static final String[] DRAG_TRANSLATE_KEYS = new String[] { "button2", "button3" };
+	private static final String[] ZOOM_NORMAL_KEYS = new String[] { "meta scroll", "ctrl shift scroll" };
+	private static final String[] SELECT_AXIS_X_KEYS = new String[] { "X" };
+	private static final String[] SELECT_AXIS_Y_KEYS = new String[] { "Y" };
+	private static final String[] SELECT_AXIS_Z_KEYS = new String[] { "Z" };
+
+	private static final String[] DRAG_ROTATE_KEYS = new String[] { "button1" };
+	private static final String[] SCROLL_Z_KEYS = new String[] { "scroll" };
+	private static final String[] ROTATE_LEFT_KEYS = new String[] { "LEFT" };
+	private static final String[] ROTATE_RIGHT_KEYS = new String[] { "RIGHT" };
+	private static final String[] KEY_ZOOM_IN_KEYS = new String[] { "UP" };
+	private static final String[] KEY_ZOOM_OUT_KEYS = new String[] { "DOWN" };
+	private static final String[] KEY_FORWARD_Z_KEYS = new String[] { "COMMA" };
+	private static final String[] KEY_BACKWARD_Z_KEYS = new String[] { "PERIOD" };
+
+	private static final String[] DRAG_ROTATE_FAST_KEYS = new String[] { "shift button1" };
+	private static final String[] SCROLL_Z_FAST_KEYS = new String[] { "shift scroll" };
+	private static final String[] ROTATE_LEFT_FAST_KEYS = new String[] { "shift LEFT" };
+	private static final String[] ROTATE_RIGHT_FAST_KEYS = new String[] { "shift RIGHT" };
+	private static final String[] KEY_ZOOM_IN_FAST_KEYS = new String[] { "shift UP" };
+	private static final String[] KEY_ZOOM_OUT_FAST_KEYS = new String[] { "shift DOWN" };
+	private static final String[] KEY_FORWARD_Z_FAST_KEYS = new String[] { "shift COMMA" };
+	private static final String[] KEY_BACKWARD_Z_FAST_KEYS = new String[] { "shift PERIOD" };
+
+	private static final String[] DRAG_ROTATE_SLOW_KEYS = new String[] { "ctrl button1" };
+	private static final String[] SCROLL_Z_SLOW_KEYS = new String[] { "ctrl scroll" };
+	private static final String[] ROTATE_LEFT_SLOW_KEYS = new String[] { "ctrl LEFT" };
+	private static final String[] ROTATE_RIGHT_SLOW_KEYS = new String[] { "ctrl RIGHT" };
+	private static final String[] KEY_ZOOM_IN_SLOW_KEYS = new String[] { "ctrl UP" };
+	private static final String[] KEY_ZOOM_OUT_SLOW_KEYS = new String[] { "ctrl DOWN" };
+	private static final String[] KEY_FORWARD_Z_SLOW_KEYS = new String[] { "ctrl COMMA" };
+	private static final String[] KEY_BACKWARD_Z_SLOW_KEYS = new String[] { "ctrl PERIOD" };
+
+	// -- behaviours --
+
+	private final TranslateXY dragTranslate;
+	private final Zoom zoom;
+	private final SelectRotationAxis selectRotationAxisX;
+	private final SelectRotationAxis selectRotationAxisY;
+	private final SelectRotationAxis selectRotationAxisZ;
+	private final Rotate dragRotate;
+	private final Rotate dragRotateFast;
+	private final Rotate dragRotateSlow;
+	private final TranslateZ translateZ;
+	private final TranslateZ translateZFast;
+	private final TranslateZ translateZSlow;
+	private final KeyRotate rotateLeft;
+	private final KeyRotate rotateLeftFast;
+	private final KeyRotate rotateLeftSlow;
+	private final KeyRotate rotateRight;
+	private final KeyRotate rotateRightFast;
+	private final KeyRotate rotateRightSlow;
+	private final KeyZoom keyZoomIn;
+	private final KeyZoom keyZoomInFast;
+	private final KeyZoom keyZoomInSlow;
+	private final KeyZoom keyZoomOut;
+	private final KeyZoom keyZoomOutFast;
+	private final KeyZoom keyZoomOutSlow;
+	private final KeyTranslateZ keyForwardZ;
+	private final KeyTranslateZ keyForwardZFast;
+	private final KeyTranslateZ keyForwardZSlow;
+	private final KeyTranslateZ keyBackwardZ;
+	private final KeyTranslateZ keyBackwardZFast;
+	private final KeyTranslateZ keyBackwardZSlow;
+
+	private static final double[] speed = { 1.0, 10.0, 0.1 };
+
+	/**
+	 * Copy of transform when mouse dragging started.
+	 */
+	private final AffineTransform3D affineDragStart = new AffineTransform3D();
+
+	/**
+	 * Current transform during mouse dragging.
+	 */
+	private final AffineTransform3D affineDragCurrent = new AffineTransform3D();
+
+	/**
+	 * Coordinates where mouse dragging started.
+	 */
+	private double oX, oY;
+
+	/**
+	 * Current rotation axis for rotating with keyboard, indexed {@code x->0, y->1,
+	 * z->2}.
+	 */
+	private int axis = 0;
+
+	/**
+	 * The screen size of the canvas (the component displaying the image and
+	 * generating mouse events).
+	 */
+	private int canvasW = 1, canvasH = 1;
+
+	/**
+	 * Screen coordinates to keep centered while zooming or rotating with the
+	 * keyboard. These are set to <em>(canvasW/2, canvasH/2)</em>
+	 */
+	private int centerX = 0, centerY = 0;
+
+	private final TransformState transform;
+
+	public TransformEventHandler3D( final TransformState transform )
+	{
+		this.transform = transform;
+
+		dragTranslate = new TranslateXY();
+		zoom = new Zoom();
+		selectRotationAxisX = new SelectRotationAxis( 0 );
+		selectRotationAxisY = new SelectRotationAxis( 1 );
+		selectRotationAxisZ = new SelectRotationAxis( 2 );
+
+		dragRotate = new Rotate( speed[ 0 ] );
+		dragRotateFast = new Rotate( speed[ 1 ] );
+		dragRotateSlow = new Rotate( speed[ 2 ] );
+
+		translateZ = new TranslateZ( speed[ 0 ] );
+		translateZFast = new TranslateZ( speed[ 1 ] );
+		translateZSlow = new TranslateZ( speed[ 2 ] );
+
+		rotateLeft = new KeyRotate( speed[ 0 ] );
+		rotateLeftFast = new KeyRotate( speed[ 1 ] );
+		rotateLeftSlow = new KeyRotate( speed[ 2 ] );
+		rotateRight = new KeyRotate( -speed[ 0 ] );
+		rotateRightFast = new KeyRotate( -speed[ 1 ] );
+		rotateRightSlow = new KeyRotate( -speed[ 2 ] );
+
+		keyZoomIn = new KeyZoom( speed[ 0 ] );
+		keyZoomInFast = new KeyZoom( speed[ 1 ] );
+		keyZoomInSlow = new KeyZoom( speed[ 2 ] );
+		keyZoomOut = new KeyZoom( -speed[ 0 ] );
+		keyZoomOutFast = new KeyZoom( -speed[ 1 ] );
+		keyZoomOutSlow = new KeyZoom( -speed[ 2 ] );
+
+		keyForwardZ = new KeyTranslateZ( speed[ 0 ] );
+		keyForwardZFast = new KeyTranslateZ( speed[ 1 ] );
+		keyForwardZSlow = new KeyTranslateZ( speed[ 2 ] );
+		keyBackwardZ = new KeyTranslateZ( -speed[ 0 ] );
+		keyBackwardZFast = new KeyTranslateZ( -speed[ 1 ] );
+		keyBackwardZSlow = new KeyTranslateZ( -speed[ 2 ] );
+	}
+
+	@Override
+	public void install( final Behaviours behaviours )
+	{
+		behaviours.behaviour( dragTranslate, DRAG_TRANSLATE, DRAG_TRANSLATE_KEYS );
+		behaviours.behaviour( zoom, ZOOM_NORMAL, ZOOM_NORMAL_KEYS );
+
+		behaviours.behaviour( selectRotationAxisX, SELECT_AXIS_X, SELECT_AXIS_X_KEYS );
+		behaviours.behaviour( selectRotationAxisY, SELECT_AXIS_Y, SELECT_AXIS_Y_KEYS );
+		behaviours.behaviour( selectRotationAxisZ, SELECT_AXIS_Z, SELECT_AXIS_Z_KEYS );
+
+		behaviours.behaviour( dragRotate, DRAG_ROTATE, DRAG_ROTATE_KEYS );
+		behaviours.behaviour( dragRotateFast, DRAG_ROTATE_FAST, DRAG_ROTATE_FAST_KEYS );
+		behaviours.behaviour( dragRotateSlow, DRAG_ROTATE_SLOW, DRAG_ROTATE_SLOW_KEYS );
+
+		behaviours.behaviour( translateZ, SCROLL_Z, SCROLL_Z_KEYS );
+		behaviours.behaviour( translateZFast, SCROLL_Z_FAST, SCROLL_Z_FAST_KEYS );
+		behaviours.behaviour( translateZSlow, SCROLL_Z_SLOW, SCROLL_Z_SLOW_KEYS );
+
+		behaviours.behaviour( rotateLeft, ROTATE_LEFT, ROTATE_LEFT_KEYS );
+		behaviours.behaviour( rotateLeftFast, ROTATE_LEFT_FAST, ROTATE_LEFT_FAST_KEYS );
+		behaviours.behaviour( rotateLeftSlow, ROTATE_LEFT_SLOW, ROTATE_LEFT_SLOW_KEYS );
+		behaviours.behaviour( rotateRight, ROTATE_RIGHT, ROTATE_RIGHT_KEYS );
+		behaviours.behaviour( rotateRightFast, ROTATE_RIGHT_FAST, ROTATE_RIGHT_FAST_KEYS );
+		behaviours.behaviour( rotateRightSlow, ROTATE_RIGHT_SLOW, ROTATE_RIGHT_SLOW_KEYS );
+
+		behaviours.behaviour( keyZoomIn, KEY_ZOOM_IN, KEY_ZOOM_IN_KEYS );
+		behaviours.behaviour( keyZoomInFast, KEY_ZOOM_IN_FAST, KEY_ZOOM_IN_FAST_KEYS );
+		behaviours.behaviour( keyZoomInSlow, KEY_ZOOM_IN_SLOW, KEY_ZOOM_IN_SLOW_KEYS );
+		behaviours.behaviour( keyZoomOut, KEY_ZOOM_OUT, KEY_ZOOM_OUT_KEYS );
+		behaviours.behaviour( keyZoomOutFast, KEY_ZOOM_OUT_FAST, KEY_ZOOM_OUT_FAST_KEYS );
+		behaviours.behaviour( keyZoomOutSlow, KEY_ZOOM_OUT_SLOW, KEY_ZOOM_OUT_SLOW_KEYS );
+
+		behaviours.behaviour( keyForwardZ, KEY_FORWARD_Z, KEY_FORWARD_Z_KEYS );
+		behaviours.behaviour( keyForwardZFast, KEY_FORWARD_Z_SLOW, KEY_FORWARD_Z_SLOW_KEYS );
+		behaviours.behaviour( keyForwardZSlow, KEY_FORWARD_Z_FAST, KEY_FORWARD_Z_FAST_KEYS );
+		behaviours.behaviour( keyBackwardZ, KEY_BACKWARD_Z, KEY_BACKWARD_Z_KEYS );
+		behaviours.behaviour( keyBackwardZFast, KEY_BACKWARD_Z_FAST, KEY_BACKWARD_Z_FAST_KEYS );
+		behaviours.behaviour( keyBackwardZSlow, KEY_BACKWARD_Z_SLOW, KEY_BACKWARD_Z_SLOW_KEYS );
+	}
+
+	@Override
+	public void setCanvasSize( final int width, final int height, final boolean updateTransform )
+	{
+		if ( width == 0 || height == 0 ) {
+			// NB: We are probably in some intermediate layout scenario.
+			// Attempting to trigger a transform update with 0 size will result
+			// in the exception "Matrix is singular" from imglib2-realtrasform.
+			return;
+		}
+		if ( updateTransform )
+		{
+			final AffineTransform3D affine = transform.get();
+			affine.set( affine.get( 0, 3 ) - canvasW / 2, 0, 3 );
+			affine.set( affine.get( 1, 3 ) - canvasH / 2, 1, 3 );
+			affine.scale( ( double ) width / canvasW );
+			affine.set( affine.get( 0, 3 ) + width / 2, 0, 3 );
+			affine.set( affine.get( 1, 3 ) + height / 2, 1, 3 );
+			transform.set( affine );
+		}
+		canvasW = width;
+		canvasH = height;
+		centerX = width / 2;
+		centerY = height / 2;
+	}
+
+	/**
+	 * One step of rotation (radian).
+	 */
+	final private static double step = Math.PI / 180;
+
+	private void scale( final double s, final double x, final double y )
+	{
+		final AffineTransform3D affine = transform.get();
+
+		// center shift
+		affine.set( affine.get( 0, 3 ) - x, 0, 3 );
+		affine.set( affine.get( 1, 3 ) - y, 1, 3 );
+
+		// scale
+		affine.scale( s );
+
+		// center un-shift
+		affine.set( affine.get( 0, 3 ) + x, 0, 3 );
+		affine.set( affine.get( 1, 3 ) + y, 1, 3 );
+
+		transform.set( affine );
+	}
+
+	/**
+	 * Rotate by d radians around axis. Keep screen coordinates (
+	 * {@link #centerX}, {@link #centerY}) fixed.
+	 */
+	private void rotate( final int axis, final double d )
+	{
+		final AffineTransform3D affine = transform.get();
+
+		// center shift
+		affine.set( affine.get( 0, 3 ) - centerX, 0, 3 );
+		affine.set( affine.get( 1, 3 ) - centerY, 1, 3 );
+
+		// rotate
+		affine.rotate( axis, d );
+
+		// center un-shift
+		affine.set( affine.get( 0, 3 ) + centerX, 0, 3 );
+		affine.set( affine.get( 1, 3 ) + centerY, 1, 3 );
+
+		transform.set( affine );
+	}
+
+	private class Rotate implements DragBehaviour
+	{
+		private final double speed;
+
+		public Rotate( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void init( final int x, final int y )
+		{
+			oX = x;
+			oY = y;
+			transform.get( affineDragStart );
+		}
+
+		@Override
+		public void drag( final int x, final int y )
+		{
+			final double dX = oX - x;
+			final double dY = oY - y;
+
+			affineDragCurrent.set( affineDragStart );
+
+			// center shift
+			affineDragCurrent.set( affineDragCurrent.get( 0, 3 ) - oX, 0, 3 );
+			affineDragCurrent.set( affineDragCurrent.get( 1, 3 ) - oY, 1, 3 );
+
+			final double v = step * speed;
+			affineDragCurrent.rotate( 0, -dY * v );
+			affineDragCurrent.rotate( 1, dX * v );
+
+			// center un-shift
+			affineDragCurrent.set( affineDragCurrent.get( 0, 3 ) + oX, 0, 3 );
+			affineDragCurrent.set( affineDragCurrent.get( 1, 3 ) + oY, 1, 3 );
+
+			transform.set( affineDragCurrent );
+		}
+
+		@Override
+		public void end( final int x, final int y )
+		{}
+	}
+
+	private class TranslateXY implements DragBehaviour
+	{
+		@Override
+		public void init( final int x, final int y )
+		{
+			oX = x;
+			oY = y;
+			transform.get( affineDragStart );
+		}
+
+		@Override
+		public void drag( final int x, final int y )
+		{
+			final double dX = oX - x;
+			final double dY = oY - y;
+
+			affineDragCurrent.set( affineDragStart );
+			affineDragCurrent.set( affineDragCurrent.get( 0, 3 ) - dX, 0, 3 );
+			affineDragCurrent.set( affineDragCurrent.get( 1, 3 ) - dY, 1, 3 );
+
+			transform.set( affineDragCurrent );
+		}
+
+		@Override
+		public void end( final int x, final int y )
+		{}
+	}
+
+	private class TranslateZ implements ScrollBehaviour
+	{
+		private final double speed;
+
+		public TranslateZ( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
+		{
+			final AffineTransform3D affine = transform.get();
+
+			final double dZ = speed * -wheelRotation;
+			// TODO (optionally) correct for zoom
+			affine.set( affine.get( 2, 3 ) - dZ, 2, 3 );
+
+			transform.set( affine );
+		}
+	}
+
+	private class Zoom implements ScrollBehaviour
+	{
+		private final double speed = 1.0;
+
+		@Override
+		public void scroll( final double wheelRotation, final boolean isHorizontal, final int x, final int y )
+		{
+			final double s = speed * wheelRotation;
+			final double dScale = 1.0 + 0.05;
+			if ( s > 0 )
+				scale( 1.0 / dScale, x, y );
+			else
+				scale( dScale, x, y );
+		}
+	}
+
+	private class SelectRotationAxis implements ClickBehaviour
+	{
+		private final int axis;
+
+		public SelectRotationAxis( final int axis )
+		{
+			this.axis = axis;
+		}
+
+		@Override
+		public void click( final int x, final int y )
+		{
+			TransformEventHandler3D.this.axis = axis;
+		}
+	}
+
+	private class KeyRotate implements ClickBehaviour
+	{
+		private final double speed;
+
+		public KeyRotate( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void click( final int x, final int y )
+		{
+			rotate( axis, step * speed );
+		}
+	}
+
+	private class KeyZoom implements ClickBehaviour
+	{
+		private final double dScale;
+
+		public KeyZoom( final double speed )
+		{
+			if ( speed > 0 )
+				dScale = 1.0 + 0.1 * speed;
+			else
+				dScale = 1.0 / ( 1.0 - 0.1 * speed );
+		}
+
+		@Override
+		public void click( final int x, final int y )
+		{
+			scale( dScale, centerX, centerY );
+		}
+	}
+
+	private class KeyTranslateZ implements ClickBehaviour
+	{
+		private final double speed;
+
+		public KeyTranslateZ( final double speed )
+		{
+			this.speed = speed;
+		}
+
+		@Override
+		public void click( final int x, final int y )
+		{
+			final AffineTransform3D affine = transform.get();
+			affine.set( affine.get( 2, 3 ) + speed, 2, 3 );
+			transform.set( affine );
+		}
+	}
+}
diff --git a/src/main/java/bdv/BehaviourTransformEventHandlerFactory.java b/src/main/java/bdv/TransformEventHandlerFactory.java
similarity index 63%
rename from src/main/java/bdv/BehaviourTransformEventHandlerFactory.java
rename to src/main/java/bdv/TransformEventHandlerFactory.java
index 3fa4f3a6d8f2c966050cfb6b63f1c551427e3e56..552571a3047639073978b30c3efbcd2a090d1980 100644
--- a/src/main/java/bdv/BehaviourTransformEventHandlerFactory.java
+++ b/src/main/java/bdv/TransformEventHandlerFactory.java
@@ -1,18 +1,23 @@
-/*-
+/*
  * #%L
- * BigDataViewer core classes with minimal dependencies.
+ * ImgLib2: a general-purpose, multidimensional image processing library.
  * %%
- * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * Copyright (C) 2009 - 2016 Tobias Pietzsch, Stephan Preibisch, Stephan Saalfeld,
+ * John Bogovic, Albert Cardona, Barry DeZonia, Christian Dietz, Jan Funke,
+ * Aivar Grislis, Jonathan Hale, Grant Harris, Stefan Helfrich, Mark Hiner,
+ * Martin Horn, Steffen Jaensch, Lee Kamentsky, Larry Lindsey, Melissa Linkert,
+ * Mark Longair, Brian Northan, Nick Perry, Curtis Rueden, Johannes Schindelin,
+ * Jean-Yves Tinevez and Michael Zinsmaier.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- * 
+ *
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- * 
+ *
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -28,11 +33,15 @@
  */
 package bdv;
 
-import org.scijava.ui.behaviour.io.InputTriggerConfig;
-
-import net.imglib2.ui.TransformEventHandlerFactory;
-
-public interface BehaviourTransformEventHandlerFactory< A > extends TransformEventHandlerFactory< A >
+/**
+ * Factory for {@code TransformEventHandler}.
+ *
+ * @author Tobias Pietzsch
+ */
+public interface TransformEventHandlerFactory
 {
-	public void setConfig( final InputTriggerConfig config );
+	/**
+	 * Create a new {@code TransformEventHandler}.
+	 */
+	TransformEventHandler create( TransformState transformState );
 }
diff --git a/src/main/java/bdv/TransformState.java b/src/main/java/bdv/TransformState.java
new file mode 100644
index 0000000000000000000000000000000000000000..937c3119c97f1c6153326c4ce79e86992fcb53a5
--- /dev/null
+++ b/src/main/java/bdv/TransformState.java
@@ -0,0 +1,50 @@
+package bdv;
+
+import java.util.function.Consumer;
+import net.imglib2.realtransform.AffineTransform3D;
+
+public interface TransformState
+{
+	/**
+	 * Get the current transform.
+	 *
+	 * @param transform
+	 *     is set to the current transform
+	 */
+	void get( AffineTransform3D transform );
+
+	/**
+	 * Get the current transform.
+	 *
+	 * @return a copy of the current transform
+	 */
+	default AffineTransform3D get()
+	{
+		final AffineTransform3D transform = new AffineTransform3D();
+		get( transform );
+		return transform;
+	}
+
+	/**
+	 * Set the transform.
+	 */
+	void set( AffineTransform3D transform );
+
+	static TransformState from( Consumer< AffineTransform3D > get, Consumer< AffineTransform3D > set )
+	{
+		return new TransformState()
+		{
+			@Override
+			public void get( final AffineTransform3D transform )
+			{
+				get.accept( transform );
+			}
+
+			@Override
+			public void set( final AffineTransform3D transform )
+			{
+				set.accept( transform );
+			}
+		};
+	}
+}
diff --git a/src/main/java/bdv/export/n5/WriteSequenceToN5.java b/src/main/java/bdv/export/n5/WriteSequenceToN5.java
index a4347857d4e3f8af365796cb6ee2dcd3e1c3d86d..9de577c7c4275ec4ec957b5a5d2b1ce457b56a1f 100644
--- a/src/main/java/bdv/export/n5/WriteSequenceToN5.java
+++ b/src/main/java/bdv/export/n5/WriteSequenceToN5.java
@@ -104,7 +104,7 @@ public class WriteSequenceToN5
 	 *            {@link ExportMipmapInfo} contains for each mipmap level, the
 	 *            subsampling factors and subdivision block sizes.
 	 * @param compression
-	 *            whether to compress the data with the HDF5 DEFLATE filter.
+	 *            n5 compression scheme.
 	 * @param n5File
 	 *            n5 root.
 	 * @param loopbackHeuristic
diff --git a/src/main/java/bdv/tools/InitializeViewerState.java b/src/main/java/bdv/tools/InitializeViewerState.java
index 0d8edea0198b5bbfd20d8c2c6f126b95547a112a..02f1cb8562cf248382b35c69a9527ee660bc6be7 100644
--- a/src/main/java/bdv/tools/InitializeViewerState.java
+++ b/src/main/java/bdv/tools/InitializeViewerState.java
@@ -77,7 +77,7 @@ public class InitializeViewerState
 	{
 		final Dimension dim = viewer.getDisplay().getSize();
 		final AffineTransform3D viewerTransform = initTransform( dim.width, dim.height, false, viewer.state().snapshot() );
-		viewer.setCurrentViewerTransform( viewerTransform );
+		viewer.state().setViewerTransform( viewerTransform );
 	}
 
 	/**
diff --git a/src/main/java/bdv/tools/RecordMaxProjectionDialog.java b/src/main/java/bdv/tools/RecordMaxProjectionDialog.java
index 5e4680d2708d346bdcb343e2cdd0de029ef99ade..d2b076638596892417775eb2879c09fcd123ee0e 100644
--- a/src/main/java/bdv/tools/RecordMaxProjectionDialog.java
+++ b/src/main/java/bdv/tools/RecordMaxProjectionDialog.java
@@ -32,10 +32,10 @@ import bdv.cache.CacheControl;
 import bdv.export.ProgressWriter;
 import bdv.util.Prefs;
 import bdv.viewer.BasicViewerState;
-import bdv.viewer.SynchronizedViewerState;
 import bdv.viewer.ViewerPanel;
 import bdv.viewer.ViewerState;
 import bdv.viewer.overlay.ScaleBarOverlayRenderer;
+import bdv.viewer.render.awt.BufferedImageRenderResult;
 import bdv.viewer.render.MultiResolutionRenderer;
 import java.awt.BorderLayout;
 import java.awt.Frame;
@@ -73,9 +73,8 @@ import net.imglib2.img.Img;
 import net.imglib2.img.array.ArrayImgs;
 import net.imglib2.realtransform.AffineTransform3D;
 import net.imglib2.type.numeric.ARGBType;
-import net.imglib2.ui.OverlayRenderer;
-import net.imglib2.ui.PainterThread;
-import net.imglib2.ui.RenderTarget;
+import bdv.viewer.OverlayRenderer;
+import bdv.viewer.render.RenderTarget;
 import net.imglib2.util.LinAlgHelpers;
 
 public class RecordMaxProjectionDialog extends JDialog implements OverlayRenderer
@@ -313,14 +312,11 @@ public class RecordMaxProjectionDialog extends JDialog implements OverlayRendere
 
 		final ScaleBarOverlayRenderer scalebar = Prefs.showScaleBarInMovie() ? new ScaleBarOverlayRenderer() : null;
 
-		class MyTarget implements RenderTarget
+		class MyTarget implements RenderTarget< BufferedImageRenderResult >
 		{
-			final ARGBScreenImage accumulated;
+			final ARGBScreenImage accumulated = new ARGBScreenImage( width, height );
 
-			public MyTarget()
-			{
-				accumulated = new ARGBScreenImage( width, height );
-			}
+			final BufferedImageRenderResult renderResult = new BufferedImageRenderResult();
 
 			public void clear()
 			{
@@ -329,8 +325,21 @@ public class RecordMaxProjectionDialog extends JDialog implements OverlayRendere
 			}
 
 			@Override
-			public BufferedImage setBufferedImage( final BufferedImage bufferedImage )
+			public BufferedImageRenderResult getReusableRenderResult()
+			{
+				return renderResult;
+			}
+
+			@Override
+			public BufferedImageRenderResult createRenderResult()
+			{
+				return new BufferedImageRenderResult();
+			}
+
+			@Override
+			public void setRenderResult( final BufferedImageRenderResult renderResult )
 			{
+				final BufferedImage bufferedImage = renderResult.getBufferedImage();
 				final Img< ARGBType > argbs = ArrayImgs.argbs( ( ( DataBufferInt ) bufferedImage.getData().getDataBuffer() ).getData(), width, height );
 				final Cursor< ARGBType > c = argbs.cursor();
 				for ( final ARGBType acc : accumulated )
@@ -343,7 +352,6 @@ public class RecordMaxProjectionDialog extends JDialog implements OverlayRendere
 							Math.max( ARGBType.blue( in ), ARGBType.blue( current ) ),
 							Math.max( ARGBType.alpha( in ), ARGBType.alpha( current ) )	) );
 				}
-				return null;
 			}
 
 			@Override
@@ -360,7 +368,7 @@ public class RecordMaxProjectionDialog extends JDialog implements OverlayRendere
 		}
 		final MyTarget target = new MyTarget();
 		final MultiResolutionRenderer renderer = new MultiResolutionRenderer(
-				target, new PainterThread( null ), new double[] { 1 }, 0, false, 1, null, false,
+				target, () -> {}, new double[] { 1 }, 0, 1, null, false,
 				viewer.getOptionValues().getAccumulateProjectorFactory(), new CacheControl.Dummy() );
 		progressWriter.setProgress( 0 );
 		for ( int timepoint = minTimepointIndex; timepoint <= maxTimepointIndex; ++timepoint )
@@ -377,7 +385,7 @@ public class RecordMaxProjectionDialog extends JDialog implements OverlayRendere
 				affine.concatenate( tGV );
 				renderState.setViewerTransform( affine );
 				renderer.requestRepaint();
-				renderer.paint( new bdv.viewer.state.ViewerState( new SynchronizedViewerState( renderState ) ) );
+				renderer.paint( renderState );
 			}
 
 			final BufferedImage bi = target.accumulated.image();
diff --git a/src/main/java/bdv/tools/RecordMovieDialog.java b/src/main/java/bdv/tools/RecordMovieDialog.java
index d4660159d1913ec168a3b1346889c367e0185dfe..9ae851aa1bcd269c953521ebcd1ff63f0987ca5c 100644
--- a/src/main/java/bdv/tools/RecordMovieDialog.java
+++ b/src/main/java/bdv/tools/RecordMovieDialog.java
@@ -32,10 +32,10 @@ import bdv.cache.CacheControl;
 import bdv.export.ProgressWriter;
 import bdv.util.Prefs;
 import bdv.viewer.BasicViewerState;
-import bdv.viewer.SynchronizedViewerState;
 import bdv.viewer.ViewerPanel;
 import bdv.viewer.ViewerState;
 import bdv.viewer.overlay.ScaleBarOverlayRenderer;
+import bdv.viewer.render.awt.BufferedImageRenderResult;
 import bdv.viewer.render.MultiResolutionRenderer;
 import java.awt.BorderLayout;
 import java.awt.Frame;
@@ -67,9 +67,8 @@ import javax.swing.WindowConstants;
 import javax.swing.event.ChangeEvent;
 import javax.swing.event.ChangeListener;
 import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.OverlayRenderer;
-import net.imglib2.ui.PainterThread;
-import net.imglib2.ui.RenderTarget;
+import bdv.viewer.OverlayRenderer;
+import bdv.viewer.render.RenderTarget;
 
 public class RecordMovieDialog extends JDialog implements OverlayRenderer
 {
@@ -269,17 +268,26 @@ public class RecordMovieDialog extends JDialog implements OverlayRenderer
 
 		final ScaleBarOverlayRenderer scalebar = Prefs.showScaleBarInMovie() ? new ScaleBarOverlayRenderer() : null;
 
-		class MyTarget implements RenderTarget
+		class MyTarget implements RenderTarget< BufferedImageRenderResult >
 		{
-			BufferedImage bi;
+			final BufferedImageRenderResult renderResult = new BufferedImageRenderResult();
 
 			@Override
-			public BufferedImage setBufferedImage( final BufferedImage bufferedImage )
+			public BufferedImageRenderResult getReusableRenderResult()
 			{
-				bi = bufferedImage;
-				return null;
+				return renderResult;
 			}
 
+			@Override
+			public BufferedImageRenderResult createRenderResult()
+			{
+				return new BufferedImageRenderResult();
+			}
+
+			@Override
+			public void setRenderResult( final BufferedImageRenderResult renderResult )
+			{}
+
 			@Override
 			public int getWidth()
 			{
@@ -294,24 +302,25 @@ public class RecordMovieDialog extends JDialog implements OverlayRenderer
 		}
 		final MyTarget target = new MyTarget();
 		final MultiResolutionRenderer renderer = new MultiResolutionRenderer(
-				target, new PainterThread( null ), new double[] { 1 }, 0, false, 1, null, false,
+				target, () -> {}, new double[] { 1 }, 0, 1, null, false,
 				viewer.getOptionValues().getAccumulateProjectorFactory(), new CacheControl.Dummy() );
 		progressWriter.setProgress( 0 );
 		for ( int timepoint = minTimepointIndex; timepoint <= maxTimepointIndex; ++timepoint )
 		{
 			renderState.setCurrentTimepoint( timepoint );
 			renderer.requestRepaint();
-			renderer.paint( new bdv.viewer.state.ViewerState( new SynchronizedViewerState( renderState ) ) );
+			renderer.paint( renderState );
 
+			final BufferedImage bi = target.renderResult.getBufferedImage();
 			if ( Prefs.showScaleBarInMovie() )
 			{
-				final Graphics2D g2 = target.bi.createGraphics();
+				final Graphics2D g2 = bi.createGraphics();
 				g2.setClip( 0, 0, width, height );
 				scalebar.setViewerState( renderState );
 				scalebar.paint( g2 );
 			}
 
-			ImageIO.write( target.bi, "png", new File( String.format( "%s/img-%03d.png", dir, timepoint ) ) );
+			ImageIO.write( bi, "png", new File( String.format( "%s/img-%03d.png", dir, timepoint ) ) );
 			progressWriter.setProgress( ( double ) (timepoint - minTimepointIndex + 1) / (maxTimepointIndex - minTimepointIndex + 1) );
 		}
 	}
diff --git a/src/main/java/bdv/tools/VisibilityAndGroupingDialog.java b/src/main/java/bdv/tools/VisibilityAndGroupingDialog.java
index 09b2d22cc00601bbc8a81116f5026dad568895ab..b4a42dca5980fff34b6b93abacda8e8eb0ad09f1 100644
--- a/src/main/java/bdv/tools/VisibilityAndGroupingDialog.java
+++ b/src/main/java/bdv/tools/VisibilityAndGroupingDialog.java
@@ -32,7 +32,6 @@ import bdv.viewer.SourceAndConverter;
 import bdv.viewer.ViewerState;
 import bdv.viewer.VisibilityAndGrouping;
 import bdv.viewer.SourceGroup;
-import bdv.viewer.SynchronizedViewerState;
 import bdv.viewer.ViewerStateChange;
 import bdv.viewer.ViewerStateChangeListener;
 import java.awt.BorderLayout;
@@ -42,12 +41,16 @@ import java.awt.GridBagLayout;
 import java.awt.Insets;
 import java.awt.Window;
 import java.awt.event.ActionEvent;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
 import java.awt.event.KeyEvent;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BooleanSupplier;
 import java.util.function.Consumer;
 import javax.swing.AbstractAction;
 import javax.swing.Action;
@@ -89,7 +92,7 @@ public class VisibilityAndGroupingDialog extends JDialog
 	{
 		super( owner, "visibility and grouping", false );
 
-		visibilityPanel = new VisibilityPanel( state );
+		visibilityPanel = new VisibilityPanel( state, this::isVisible );
 		visibilityPanel.setBorder( BorderFactory.createCompoundBorder(
 				BorderFactory.createEmptyBorder( 4, 2, 4, 2 ),
 				BorderFactory.createCompoundBorder(
@@ -98,7 +101,7 @@ public class VisibilityAndGroupingDialog extends JDialog
 								"visibility" ),
 						BorderFactory.createEmptyBorder( 2, 2, 2, 2 ) ) ) );
 
-		groupingPanel = new GroupingPanel( state );
+		groupingPanel = new GroupingPanel( state, this::isVisible );
 		groupingPanel.setBorder( BorderFactory.createCompoundBorder(
 				BorderFactory.createEmptyBorder( 4, 2, 4, 2 ),
 				BorderFactory.createCompoundBorder(
@@ -107,7 +110,7 @@ public class VisibilityAndGroupingDialog extends JDialog
 								"grouping" ),
 						BorderFactory.createEmptyBorder( 2, 2, 2, 2 ) ) ) );
 
-		modePanel = new ModePanel( state );
+		modePanel = new ModePanel( state, this::isVisible );
 
 		final JPanel content = new JPanel();
 		content.setLayout( new BoxLayout( content, BoxLayout.PAGE_AXIS ) );
@@ -132,6 +135,17 @@ public class VisibilityAndGroupingDialog extends JDialog
 		im.put( KeyStroke.getKeyStroke( KeyEvent.VK_ESCAPE, 0 ), hideKey );
 		am.put( hideKey, hideAction );
 
+		addComponentListener( new ComponentAdapter()
+		{
+			@Override
+			public void componentShown( final ComponentEvent e )
+			{
+				visibilityPanel.shown();
+				groupingPanel.shown();
+				modePanel.shown();
+			}
+		} );
+
 		pack();
 		setDefaultCloseOperation( WindowConstants.HIDE_ON_CLOSE );
 	}
@@ -155,91 +169,128 @@ public class VisibilityAndGroupingDialog extends JDialog
 
 		private final ArrayList< Consumer< Set< SourceAndConverter< ? > > > > updateVisibleBoxes = new ArrayList<>();
 
-		public VisibilityPanel( final ViewerState state )
+		private final BooleanSupplier isVisible;
+
+		public VisibilityPanel( final ViewerState state, BooleanSupplier isVisible )
 		{
 			super( new GridBagLayout() );
 			this.state = state;
+			this.isVisible = isVisible;
 			state.changeListeners().add( this );
-			synchronized ( state )
-			{
-				recreateContent();
-				update();
-			}
 		}
 
-		protected void recreateContent()
+		private void shown()
 		{
-			removeAll();
-			currentButtonsMap.clear();
-			updateActiveBoxes.clear();
-			updateVisibleBoxes.clear();
+			if ( recreateContentPending.getAndSet( false ) )
+				recreateContentNow();
+			if ( updatePending.getAndSet( false ) )
+				updateNow();
+		}
 
-			final List< SourceAndConverter < ? > > sources = state.getSources();
+		private final AtomicBoolean recreateContentPending = new AtomicBoolean( true );
 
-			final GridBagConstraints c = new GridBagConstraints();
-			c.insets = new Insets( 0, 5, 0, 5 );
-
-			// source names
-			c.gridx = 0;
-			c.gridy = 0;
-			add( new JLabel( "source" ), c );
-			c.anchor = GridBagConstraints.LINE_END;
-			c.gridy = GridBagConstraints.RELATIVE;
-			for ( final SourceAndConverter< ? > source : sources )
-				add( new JLabel( source.getSpimSource().getName() ), c );
-
-			// "current" radio-buttons
-			c.anchor = GridBagConstraints.CENTER;
-			c.gridx = 1;
-			c.gridy = 0;
-			add( new JLabel( "current" ), c );
-			c.gridy = GridBagConstraints.RELATIVE;
-			final ButtonGroup currentButtonGroup = new ButtonGroup();
-			for ( final SourceAndConverter< ? > source : sources )
+		private void recreateContent()
+		{
+			if ( isVisible.getAsBoolean() )
 			{
-				final JRadioButton b = new JRadioButton();
-				b.addActionListener( e -> {
-					if ( b.isSelected() )
-						state.setCurrentSource( source );
-				} );
-				currentButtonsMap.put( source, b );
-				currentButtonGroup.add( b );
-				add( b, c );
+				recreateContentNow();
+				recreateContentPending.set( false );
 			}
+			else
+				recreateContentPending.set( true );
+		}
 
-			// "active in fused" check-boxes
-			c.gridx = 2;
-			c.gridy = 0;
-			add( new JLabel( "active in fused" ), c );
-			c.gridy = GridBagConstraints.RELATIVE;
-			for ( final SourceAndConverter< ? > source : sources )
+		private void recreateContentNow()
+		{
+			synchronized ( state )
 			{
-				final JCheckBox b = new JCheckBox();
-				b.addActionListener( e -> state.setSourceActive( source, b.isSelected() ) );
-				updateActiveBoxes.add( active -> b.setSelected( active.contains( source ) ) );
-				add( b, c );
+				removeAll();
+				currentButtonsMap.clear();
+				updateActiveBoxes.clear();
+				updateVisibleBoxes.clear();
+
+				final List< SourceAndConverter< ? > > sources = state.getSources();
+
+				final GridBagConstraints c = new GridBagConstraints();
+				c.insets = new Insets( 0, 5, 0, 5 );
+
+				// source names
+				c.gridx = 0;
+				c.gridy = 0;
+				add( new JLabel( "source" ), c );
+				c.anchor = GridBagConstraints.LINE_END;
+				c.gridy = GridBagConstraints.RELATIVE;
+				for ( final SourceAndConverter< ? > source : sources )
+					add( new JLabel( source.getSpimSource().getName() ), c );
+
+				// "current" radio-buttons
+				c.anchor = GridBagConstraints.CENTER;
+				c.gridx = 1;
+				c.gridy = 0;
+				add( new JLabel( "current" ), c );
+				c.gridy = GridBagConstraints.RELATIVE;
+				final ButtonGroup currentButtonGroup = new ButtonGroup();
+				for ( final SourceAndConverter< ? > source : sources )
+				{
+					final JRadioButton b = new JRadioButton();
+					b.addActionListener( e -> {
+						if ( b.isSelected() )
+							state.setCurrentSource( source );
+					} );
+					currentButtonsMap.put( source, b );
+					currentButtonGroup.add( b );
+					add( b, c );
+				}
+
+				// "active in fused" check-boxes
+				c.gridx = 2;
+				c.gridy = 0;
+				add( new JLabel( "active in fused" ), c );
+				c.gridy = GridBagConstraints.RELATIVE;
+				for ( final SourceAndConverter< ? > source : sources )
+				{
+					final JCheckBox b = new JCheckBox();
+					b.addActionListener( e -> state.setSourceActive( source, b.isSelected() ) );
+					updateActiveBoxes.add( active -> b.setSelected( active.contains( source ) ) );
+					add( b, c );
+				}
+
+				// "currently visible" check-boxes
+				c.gridx = 3;
+				c.gridy = 0;
+				add( new JLabel( "visible" ), c );
+				c.gridy = GridBagConstraints.RELATIVE;
+				for ( final SourceAndConverter< ? > source : sources )
+				{
+					final JCheckBox b = new JCheckBox();
+					updateVisibleBoxes.add( visible -> b.setSelected( visible.contains( source ) ) );
+					b.setEnabled( false );
+					add( b, c );
+				}
+
+				invalidate();
+				final Window frame = SwingUtilities.getWindowAncestor( this );
+				if ( frame != null )
+					frame.pack();
+
+				update();
 			}
+		}
 
-			// "currently visible" check-boxes
-			c.gridx = 3;
-			c.gridy = 0;
-			add( new JLabel( "visible" ), c );
-			c.gridy = GridBagConstraints.RELATIVE;
-			for ( final SourceAndConverter< ? > source : sources )
+		private final AtomicBoolean updatePending = new AtomicBoolean( true );
+
+		private void update()
+		{
+			if ( isVisible.getAsBoolean() )
 			{
-				final JCheckBox b = new JCheckBox();
-				updateVisibleBoxes.add( visible -> b.setSelected( visible.contains( source ) ) );
-				b.setEnabled( false );
-				add( b, c );
+				updateNow();
+				updatePending.set( false );
 			}
-
-			invalidate();
-			final Window frame = SwingUtilities.getWindowAncestor( this );
-			if ( frame != null )
-				frame.pack();
+			else
+				updatePending.set( true );
 		}
 
-		protected void update()
+		private void updateNow()
 		{
 			final SourceAndConverter< ? > currentSource = state.getCurrentSource();
 			if ( currentSource == null )
@@ -257,6 +308,9 @@ public class VisibilityAndGroupingDialog extends JDialog
 		@Override
 		public void viewerStateChanged( final ViewerStateChange change )
 		{
+			final AtomicBoolean pendingUpdate = new AtomicBoolean();
+			final AtomicBoolean pendingRecreate = new AtomicBoolean();
+
 			switch ( change )
 			{
 			case CURRENT_SOURCE_CHANGED:
@@ -265,13 +319,7 @@ public class VisibilityAndGroupingDialog extends JDialog
 				SwingUtilities.invokeLater( this::update );
 				break;
 			case NUM_SOURCES_CHANGED:
-				SwingUtilities.invokeLater( () -> {
-					synchronized ( state )
-					{
-						recreateContent();
-						update();
-					}
-				} );
+				SwingUtilities.invokeLater( this::recreateContent );
 				break;
 			}
 		}
@@ -287,10 +335,13 @@ public class VisibilityAndGroupingDialog extends JDialog
 
 		private JCheckBox fusedModeBox;
 
-		public ModePanel( final ViewerState state )
+		private final BooleanSupplier isVisible;
+
+		public ModePanel( final ViewerState state, BooleanSupplier isVisible )
 		{
 			super( new GridBagLayout() );
 			this.state = state;
+			this.isVisible = isVisible;
 			state.changeListeners().add( this );
 			synchronized ( state )
 			{
@@ -299,6 +350,12 @@ public class VisibilityAndGroupingDialog extends JDialog
 			}
 		}
 
+		private void shown()
+		{
+			if ( updatePending.getAndSet( false ) )
+				updateNow();
+		}
+
 		private void recreateContent()
 		{
 			final GridBagConstraints c = new GridBagConstraints();
@@ -333,7 +390,20 @@ public class VisibilityAndGroupingDialog extends JDialog
 			add( new JLabel("enable fused mode"), c );
 		}
 
+		private final AtomicBoolean updatePending = new AtomicBoolean( true );
+
 		private void update()
+		{
+			if ( isVisible.getAsBoolean() )
+			{
+				updateNow();
+				updatePending.set( false );
+			}
+			else
+				updatePending.set( true );
+		}
+
+		private void updateNow()
 		{
 			groupingBox.setSelected( state.getDisplayMode().hasGrouping() );
 			fusedModeBox.setSelected( state.getDisplayMode().hasFused() );
@@ -361,154 +431,184 @@ public class VisibilityAndGroupingDialog extends JDialog
 
 		private final ViewerState state;
 
-		public GroupingPanel( final ViewerState state )
+		private final BooleanSupplier isVisible;
+
+		public GroupingPanel( final ViewerState state, BooleanSupplier isVisible )
 		{
 			super( new GridBagLayout() );
 			this.state = state;
+			this.isVisible = isVisible;
 			state.changeListeners().add( this );
-			synchronized ( state )
-			{
-				recreateContent();
-				update();
-			}
 		}
 
-		private void recreateContent()
+		private void shown()
 		{
-			removeAll();
-			updateNames.clear();
-			currentButtonsMap.clear();
-			updateActiveBoxes.clear();
-			updateAssignBoxes.clear();
-
-			final GridBagConstraints c = new GridBagConstraints();
-			c.insets = new Insets( 0, 5, 0, 5 );
-
-			final List< SourceAndConverter< ? > > sources = state.getSources();
-			final List< SourceGroup > groups = state.getGroups();
-
-			// source shortcuts
-			// TODO: shortcut "names" should not be hard-coded here!
-			c.gridx = 0;
-			c.gridy = 0;
-			add( new JLabel( "shortcut" ), c );
-			c.anchor = GridBagConstraints.LINE_END;
-			c.gridy = GridBagConstraints.RELATIVE;
-			final int nShortcuts = Math.min( groups.size(), 10 );
-			for ( int i = 0; i < nShortcuts; ++i )
-				add( new JLabel( Integer.toString( i == 10 ? 0 : i + 1 ) ), c );
-
-			// source names
-			c.gridx = 1;
-			c.gridy = 0;
-			c.anchor = GridBagConstraints.CENTER;
-			add( new JLabel( "group name" ), c );
-			c.anchor = GridBagConstraints.LINE_END;
-			c.gridy = GridBagConstraints.RELATIVE;
-			for ( final SourceGroup group : groups )
-			{
-				final JTextField tf = new JTextField( state.getGroupName( group ), 10 );
-				tf.getDocument().addDocumentListener( new DocumentListener()
-				{
-					private void doit()
-					{
-						state.setGroupName( group, tf.getText() );
-					}
-
-					@Override
-					public void removeUpdate( final DocumentEvent e )
-					{
-						doit();
-					}
+			if ( recreateContentPending.getAndSet( false ) )
+				recreateContentNow();
+			if ( updateCurrentGroupPending.getAndSet( false ) )
+				updateCurrentGroupNow();
+			if ( updateGroupNamesPending.getAndSet( false ) )
+				updateGroupNamesNow();
+			if ( updateGroupActivityPending.getAndSet( false ) )
+				updateGroupActivityNow();
+			if ( updateGroupAssignmentsPending.getAndSet( false ) )
+				updateGroupAssignmentsNow();
+		}
 
-					@Override
-					public void insertUpdate( final DocumentEvent e )
-					{
-						doit();
-					}
+		private final AtomicBoolean recreateContentPending = new AtomicBoolean( true );
 
-					@Override
-					public void changedUpdate( final DocumentEvent e )
-					{
-						doit();
-					}
-				} );
-				updateNames.add( () -> {
-					final String name = state.getGroupName( group );
-					if ( !tf.getText().equals( name ) )
-					{
-						tf.setText( name );
-					}
-				} );
-				add( tf, c );
-			}
-
-			// "current" radio-buttons
-			c.anchor = GridBagConstraints.CENTER;
-			c.gridx = 2;
-			c.gridy = 0;
-			add( new JLabel( "current" ), c );
-			c.gridy = GridBagConstraints.RELATIVE;
-			final ButtonGroup currentButtonGroup = new ButtonGroup();
-			for ( final SourceGroup group : groups )
+		private void recreateContent()
+		{
+			if ( isVisible.getAsBoolean() )
 			{
-				final JRadioButton b = new JRadioButton();
-				b.addActionListener( e -> {
-					if ( b.isSelected() )
-						state.setCurrentGroup( group );
-				} );
-				currentButtonsMap.put( group, b );
-				currentButtonGroup.add( b );
-				add( b, c );
+				recreateContentNow();
+				recreateContentPending.set( false );
 			}
+			else
+				recreateContentPending.set( true );
+		}
 
-			// "active in fused" check-boxes
-			c.gridx = 3;
-			c.gridy = 0;
-			c.anchor = GridBagConstraints.CENTER;
-			add( new JLabel( "active in fused" ), c );
-			c.gridy = GridBagConstraints.RELATIVE;
-			for ( final SourceGroup group : groups )
+		private void recreateContentNow()
+		{
+			synchronized ( state )
 			{
-				final JCheckBox b = new JCheckBox();
-				b.addActionListener( e -> state.setGroupActive( group, b.isSelected() ) );
-				updateActiveBoxes.add( active -> b.setSelected( active.contains( group ) ) );
-				add( b, c );
-			}
+				removeAll();
+				updateNames.clear();
+				currentButtonsMap.clear();
+				updateActiveBoxes.clear();
+				updateAssignBoxes.clear();
+
+				final GridBagConstraints c = new GridBagConstraints();
+				c.insets = new Insets( 0, 5, 0, 5 );
+
+				final List< SourceAndConverter< ? > > sources = state.getSources();
+				final List< SourceGroup > groups = state.getGroups();
+
+				// source shortcuts
+				// TODO: shortcut "names" should not be hard-coded here!
+				c.gridx = 0;
+				c.gridy = 0;
+				add( new JLabel( "shortcut" ), c );
+				c.anchor = GridBagConstraints.LINE_END;
+				c.gridy = GridBagConstraints.RELATIVE;
+				final int nShortcuts = Math.min( groups.size(), 10 );
+				for ( int i = 0; i < nShortcuts; ++i )
+					add( new JLabel( Integer.toString( i == 10 ? 0 : i + 1 ) ), c );
+
+				// source names
+				c.gridx = 1;
+				c.gridy = 0;
+				c.anchor = GridBagConstraints.CENTER;
+				add( new JLabel( "group name" ), c );
+				c.anchor = GridBagConstraints.LINE_END;
+				c.gridy = GridBagConstraints.RELATIVE;
+				for ( final SourceGroup group : groups )
+				{
+					final JTextField tf = new JTextField( state.getGroupName( group ), 10 );
+					tf.getDocument().addDocumentListener( new DocumentListener()
+					{
+						private void doit()
+						{
+							state.setGroupName( group, tf.getText() );
+						}
+
+						@Override
+						public void removeUpdate( final DocumentEvent e )
+						{
+							doit();
+						}
+
+						@Override
+						public void insertUpdate( final DocumentEvent e )
+						{
+							doit();
+						}
+
+						@Override
+						public void changedUpdate( final DocumentEvent e )
+						{
+							doit();
+						}
+					} );
+					updateNames.add( () -> {
+						final String name = state.getGroupName( group );
+						if ( !tf.getText().equals( name ) )
+						{
+							tf.setText( name );
+						}
+					} );
+					add( tf, c );
+				}
 
-			// setup-to-group assignments
-			c.gridx = 4;
-			c.gridy = 0;
-			c.gridwidth = sources.size();
-			c.anchor = GridBagConstraints.CENTER;
-			add( new JLabel( "assigned sources" ), c );
-			c.gridwidth = 1;
-			c.anchor = GridBagConstraints.LINE_END;
-			for ( final SourceAndConverter< ? > source : sources )
-			{
-				c.gridy = 1;
+				// "current" radio-buttons
+				c.anchor = GridBagConstraints.CENTER;
+				c.gridx = 2;
+				c.gridy = 0;
+				add( new JLabel( "current" ), c );
+				c.gridy = GridBagConstraints.RELATIVE;
+				final ButtonGroup currentButtonGroup = new ButtonGroup();
 				for ( final SourceGroup group : groups )
 				{
-					final JCheckBox b = new JCheckBox();
+					final JRadioButton b = new JRadioButton();
 					b.addActionListener( e -> {
 						if ( b.isSelected() )
-							state.addSourceToGroup( source, group );
-						else
-							state.removeSourceFromGroup( source, group );
-					} );
-					updateAssignBoxes.add( () -> {
-						b.setSelected( state.getSourcesInGroup( group ).contains( source ) );
+							state.setCurrentGroup( group );
 					} );
+					currentButtonsMap.put( group, b );
+					currentButtonGroup.add( b );
 					add( b, c );
-					c.gridy++;
 				}
-				c.gridx++;
-			}
 
-			invalidate();
-			final Window frame = SwingUtilities.getWindowAncestor( this );
-			if ( frame != null )
-				frame.pack();
+				// "active in fused" check-boxes
+				c.gridx = 3;
+				c.gridy = 0;
+				c.anchor = GridBagConstraints.CENTER;
+				add( new JLabel( "active in fused" ), c );
+				c.gridy = GridBagConstraints.RELATIVE;
+				for ( final SourceGroup group : groups )
+				{
+					final JCheckBox b = new JCheckBox();
+					b.addActionListener( e -> state.setGroupActive( group, b.isSelected() ) );
+					updateActiveBoxes.add( active -> b.setSelected( active.contains( group ) ) );
+					add( b, c );
+				}
+
+				// setup-to-group assignments
+				c.gridx = 4;
+				c.gridy = 0;
+				c.gridwidth = sources.size();
+				c.anchor = GridBagConstraints.CENTER;
+				add( new JLabel( "assigned sources" ), c );
+				c.gridwidth = 1;
+				c.anchor = GridBagConstraints.LINE_END;
+				for ( final SourceAndConverter< ? > source : sources )
+				{
+					c.gridy = 1;
+					for ( final SourceGroup group : groups )
+					{
+						final JCheckBox b = new JCheckBox();
+						b.addActionListener( e -> {
+							if ( b.isSelected() )
+								state.addSourceToGroup( source, group );
+							else
+								state.removeSourceFromGroup( source, group );
+						} );
+						updateAssignBoxes.add( () -> {
+							b.setSelected( state.getSourcesInGroup( group ).contains( source ) );
+						} );
+						add( b, c );
+						c.gridy++;
+					}
+					c.gridx++;
+				}
+
+				invalidate();
+				final Window frame = SwingUtilities.getWindowAncestor( this );
+				if ( frame != null )
+					frame.pack();
+
+				update();
+			}
 		}
 
 		private void update()
@@ -519,23 +619,75 @@ public class VisibilityAndGroupingDialog extends JDialog
 			updateGroupAssignments();
 		}
 
+		private final AtomicBoolean updateGroupNamesPending = new AtomicBoolean( true );
+
 		private void updateGroupNames()
+		{
+			if ( isVisible.getAsBoolean() )
+			{
+				updateGroupNamesNow();
+				updateGroupNamesPending.set( false );
+			}
+			else
+				updateGroupNamesPending.set( true );
+		}
+
+		private void updateGroupNamesNow()
 		{
 			updateNames.forEach( Runnable::run );
 		}
 
+		private final AtomicBoolean updateGroupAssignmentsPending = new AtomicBoolean( true );
+
 		private void updateGroupAssignments()
+		{
+			if ( isVisible.getAsBoolean() )
+			{
+				updateGroupAssignmentsNow();
+				updateGroupAssignmentsPending.set( false );
+			}
+			else
+				updateGroupAssignmentsPending.set( true );
+		}
+
+		private void updateGroupAssignmentsNow()
 		{
 			updateAssignBoxes.forEach( Runnable::run );
 		}
 
+		private final AtomicBoolean updateGroupActivityPending = new AtomicBoolean( true );
+
 		private void updateGroupActivity()
+		{
+			if ( isVisible.getAsBoolean() )
+			{
+				updateGroupActivityNow();
+				updateGroupActivityPending.set( false );
+			}
+			else
+				updateGroupActivityPending.set( true );
+		}
+
+		private void updateGroupActivityNow()
 		{
 			final Set< SourceGroup > activeGroups = state.getActiveGroups();
 			updateActiveBoxes.forEach( c -> c.accept( activeGroups ) );
 		}
 
+		private final AtomicBoolean updateCurrentGroupPending = new AtomicBoolean( true );
+
 		private void updateCurrentGroup()
+		{
+			if ( isVisible.getAsBoolean() )
+			{
+				updateCurrentGroupNow();
+				updateCurrentGroupPending.set( false );
+			}
+			else
+				updateCurrentGroupPending.set( true );
+		}
+
+		private void updateCurrentGroupNow()
 		{
 			final SourceGroup currentGroup = state.getCurrentGroup();
 			if ( currentGroup == null )
@@ -563,13 +715,7 @@ public class VisibilityAndGroupingDialog extends JDialog
 				break;
 			case NUM_GROUPS_CHANGED:
 			case NUM_SOURCES_CHANGED:
-				SwingUtilities.invokeLater( () -> {
-					synchronized ( state )
-					{
-						recreateContent();
-						update();
-					}
-				} );
+				SwingUtilities.invokeLater( this::recreateContent );
 				break;
 			}
 		}
diff --git a/src/main/java/bdv/tools/boundingbox/BoundingBoxDialog.java b/src/main/java/bdv/tools/boundingbox/BoundingBoxDialog.java
index 43c71f4f3f01ce2a7d90fe438bd6228da00ed83c..52002dbe20e47434401d8ce7812f984b5524ddc9 100644
--- a/src/main/java/bdv/tools/boundingbox/BoundingBoxDialog.java
+++ b/src/main/java/bdv/tools/boundingbox/BoundingBoxDialog.java
@@ -202,8 +202,8 @@ public class BoundingBoxDialog extends JDialog
 				}
 				if ( showBoxOverlay )
 				{
-					viewer.getDisplay().addOverlayRenderer( boxOverlay );
-					viewer.addRenderTransformListener( boxOverlay );
+					viewer.getDisplay().overlays().add( boxOverlay );
+					viewer.renderTransformListeners().add( boxOverlay );
 				}
 			}
 
@@ -218,7 +218,7 @@ public class BoundingBoxDialog extends JDialog
 				}
 				if ( showBoxOverlay )
 				{
-					viewer.getDisplay().removeOverlayRenderer( boxOverlay );
+					viewer.getDisplay().overlays().remove( boxOverlay );
 					viewer.removeTransformListener( boxOverlay );
 				}
 			}
diff --git a/src/main/java/bdv/tools/boundingbox/TransformedBoxEditor.java b/src/main/java/bdv/tools/boundingbox/TransformedBoxEditor.java
index 5c828ddd63de76dc1326c605fb4a5d80f6015f3b..0dcc495436eb7f00bbde294d0ff1e84565ac02dc 100644
--- a/src/main/java/bdv/tools/boundingbox/TransformedBoxEditor.java
+++ b/src/main/java/bdv/tools/boundingbox/TransformedBoxEditor.java
@@ -155,8 +155,8 @@ public class TransformedBoxEditor
 
 	public void install()
 	{
-		viewer.getDisplay().addOverlayRenderer( boxOverlay );
-		viewer.addRenderTransformListener( boxOverlay );
+		viewer.getDisplay().overlays().add( boxOverlay );
+		viewer.renderTransformListeners().add( boxOverlay );
 		viewer.getDisplay().addHandler( boxOverlay.getCornerHighlighter() );
 
 		refreshBlockMap();
@@ -168,7 +168,7 @@ public class TransformedBoxEditor
 
 	public void uninstall()
 	{
-		viewer.getDisplay().removeOverlayRenderer( boxOverlay );
+		viewer.getDisplay().overlays().remove( boxOverlay );
 		viewer.removeTransformListener( boxOverlay );
 		viewer.getDisplay().removeHandler( boxOverlay.getCornerHighlighter() );
 
diff --git a/src/main/java/bdv/tools/boundingbox/TransformedBoxOverlay.java b/src/main/java/bdv/tools/boundingbox/TransformedBoxOverlay.java
index 97a127982b9ca1d8dc18a45a2953540156921014..7439703cad1b91241acfb288388da9e8d0c20e4c 100644
--- a/src/main/java/bdv/tools/boundingbox/TransformedBoxOverlay.java
+++ b/src/main/java/bdv/tools/boundingbox/TransformedBoxOverlay.java
@@ -45,8 +45,8 @@ import java.awt.geom.GeneralPath;
 import net.imglib2.Interval;
 import net.imglib2.RealInterval;
 import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.OverlayRenderer;
-import net.imglib2.ui.TransformListener;
+import bdv.viewer.OverlayRenderer;
+import bdv.viewer.TransformListener;
 
 import org.scijava.listeners.ChangeListener;
 import org.scijava.listeners.ListenableVar;
diff --git a/src/main/java/bdv/tools/brightness/BrightnessDialog.java b/src/main/java/bdv/tools/brightness/BrightnessDialog.java
index 0cd6be4dc339814e606df9e70536c38ccb3a8158..1e71f960cdaec7ba438f14de6c7fe35903ae15c1 100644
--- a/src/main/java/bdv/tools/brightness/BrightnessDialog.java
+++ b/src/main/java/bdv/tools/brightness/BrightnessDialog.java
@@ -42,6 +42,7 @@ import java.awt.event.KeyEvent;
 import java.lang.reflect.InvocationTargetException;
 import java.util.ArrayList;
 
+import java.util.concurrent.atomic.AtomicBoolean;
 import javax.swing.AbstractAction;
 import javax.swing.Action;
 import javax.swing.ActionMap;
@@ -103,6 +104,8 @@ public class BrightnessDialog extends JDialog
 		im.put( KeyStroke.getKeyStroke( KeyEvent.VK_ESCAPE, 0 ), hideKey );
 		am.put( hideKey, hideAction );
 
+		final AtomicBoolean recreateContentPending = new AtomicBoolean();
+
 		setupAssignments.setUpdateListener( new SetupAssignments.UpdateListener()
 		{
 			@Override
@@ -111,8 +114,17 @@ public class BrightnessDialog extends JDialog
 				try
 				{
 					InvokeOnEDT.invokeAndWait( () -> {
-						colorsPanel.recreateContent();
-						minMaxPanels.recreateContent();
+						if ( isVisible() )
+						{
+							System.out.println( "colorsPanel.recreateContent()" );
+							colorsPanel.recreateContent();
+							minMaxPanels.recreateContent();
+							recreateContentPending.set( false );
+						}
+						else
+						{
+							recreateContentPending.set( true );
+						}
 					} );
 				}
 				catch ( InvocationTargetException | InterruptedException e )
@@ -122,6 +134,19 @@ public class BrightnessDialog extends JDialog
 			}
 		} );
 
+		addComponentListener( new ComponentAdapter()
+		{
+			@Override
+			public void componentShown( final ComponentEvent e )
+			{
+				if ( recreateContentPending.getAndSet( false ) )
+				{
+					colorsPanel.recreateContent();
+					minMaxPanels.recreateContent();
+				}
+			}
+		} );
+
 		pack();
 		setDefaultCloseOperation( WindowConstants.HIDE_ON_CLOSE );
 	}
diff --git a/src/main/java/bdv/tools/transformation/ManualTransformationEditor.java b/src/main/java/bdv/tools/transformation/ManualTransformationEditor.java
index 60d03b1fce1a6a6e7fefe3fc69abb016ab356880..d0f9146ba36c40c5a1f6b7d5be222052e1331973 100644
--- a/src/main/java/bdv/tools/transformation/ManualTransformationEditor.java
+++ b/src/main/java/bdv/tools/transformation/ManualTransformationEditor.java
@@ -39,7 +39,7 @@ import javax.swing.ActionMap;
 import javax.swing.InputMap;
 import javax.swing.KeyStroke;
 import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.TransformListener;
+import bdv.viewer.TransformListener;
 import org.scijava.listeners.Listeners;
 import org.scijava.ui.behaviour.util.InputActionBindings;
 import org.scijava.ui.behaviour.util.RunnableAction;
@@ -97,7 +97,7 @@ public class ManualTransformationEditor implements TransformListener< AffineTran
 			final AffineTransform3D identity = new AffineTransform3D();
 			for ( final TransformedSource< ? > source : sourcesToModify )
 				source.setIncrementalTransform( identity );
-			viewer.setCurrentViewerTransform( frozenTransform );
+			viewer.state().setViewerTransform( frozenTransform );
 			viewer.showMessage( "aborted manual transform" );
 			active = false;
 			manualTransformActiveListeners.list.forEach( l -> l.manualTransformActiveChanged( active ) );
@@ -118,7 +118,7 @@ public class ManualTransformationEditor implements TransformListener< AffineTran
 			{
 				source.setIncrementalTransform( identity );
 			}
-			viewer.setCurrentViewerTransform( frozenTransform );
+			viewer.state().setViewerTransform( frozenTransform );
 			viewer.showMessage( "reset manual transform" );
 		}
 	}
@@ -181,7 +181,7 @@ public class ManualTransformationEditor implements TransformListener< AffineTran
 			tmp.identity();
 			for ( final TransformedSource< ? > source : sourcesToFix )
 				source.setIncrementalTransform( tmp );
-			viewer.setCurrentViewerTransform( frozenTransform );
+			viewer.state().setViewerTransform( frozenTransform );
 			viewer.showMessage( "fixed manual transform" );
 		}
 		manualTransformActiveListeners.list.forEach( l -> l.manualTransformActiveChanged( active ) );
diff --git a/src/main/java/bdv/util/AWTUtils.java b/src/main/java/bdv/util/AWTUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..c26c86d626bc38ee30e9dec031678ab679a24c27
--- /dev/null
+++ b/src/main/java/bdv/util/AWTUtils.java
@@ -0,0 +1,118 @@
+/*
+ * #%L
+ * ImgLib2: a general-purpose, multidimensional image processing library.
+ * %%
+ * Copyright (C) 2009 - 2016 Tobias Pietzsch, Stephan Preibisch, Stephan Saalfeld,
+ * John Bogovic, Albert Cardona, Barry DeZonia, Christian Dietz, Jan Funke,
+ * Aivar Grislis, Jonathan Hale, Grant Harris, Stefan Helfrich, Mark Hiner,
+ * Martin Horn, Steffen Jaensch, Lee Kamentsky, Larry Lindsey, Melissa Linkert,
+ * Mark Longair, Brian Northan, Nick Perry, Curtis Rueden, Johannes Schindelin,
+ * Jean-Yves Tinevez and Michael Zinsmaier.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.util;
+
+import java.awt.GraphicsConfiguration;
+import java.awt.GraphicsDevice;
+import java.awt.GraphicsEnvironment;
+import java.awt.Transparency;
+import java.awt.image.BufferedImage;
+import java.awt.image.ColorModel;
+import java.awt.image.DataBuffer;
+import java.awt.image.DirectColorModel;
+import java.awt.image.Raster;
+import java.awt.image.SampleModel;
+import java.awt.image.WritableRaster;
+import net.imglib2.display.screenimage.awt.ARGBScreenImage;
+
+/**
+ * Static helper methods for setting up {@link GraphicsConfiguration} and
+ * {@link BufferedImage BufferedImages}.
+ *
+ * @author Tobias Pietzsch
+ */
+public class AWTUtils
+{
+	/**
+	 * Get a {@link GraphicsConfiguration} from the default screen
+	 * {@link GraphicsDevice} that matches the
+	 * {@link ColorModel#getTransparency() transparency} of the given
+	 * <code>colorModel</code>. If no matching configuration is found, the
+	 * default configuration of the {@link GraphicsDevice} is returned.
+	 */
+	public static GraphicsConfiguration getSuitableGraphicsConfiguration( final ColorModel colorModel )
+	{
+		final GraphicsDevice device = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice();
+		final GraphicsConfiguration defaultGc = device.getDefaultConfiguration();
+
+		final int transparency = colorModel.getTransparency();
+
+		if ( defaultGc.getColorModel( transparency ).equals( colorModel ) )
+			return defaultGc;
+
+		for ( final GraphicsConfiguration gc : device.getConfigurations() )
+			if ( gc.getColorModel( transparency ).equals( colorModel ) )
+				return gc;
+
+		return defaultGc;
+	}
+
+	public static final ColorModel ARGB_COLOR_MODEL = new DirectColorModel( 32, 0xff0000, 0xff00, 0xff, 0xff000000 );
+
+	public static final ColorModel RGB_COLOR_MODEL = new DirectColorModel( 24, 0xff0000, 0xff00, 0xff );
+
+	/**
+	 * Get a {@link BufferedImage} for the given {@link ARGBScreenImage}.
+	 *
+	 * @param screenImage
+	 * 		the image.
+	 * @param discardAlpha
+	 * 		Whether to discard the <code>screenImage</code> alpha
+	 * 		components when drawing.
+	 */
+	public static BufferedImage getBufferedImage( final ARGBScreenImage screenImage, final boolean discardAlpha )
+	{
+		final BufferedImage si = screenImage.image();
+		if ( discardAlpha && ( si.getTransparency() != Transparency.OPAQUE ) )
+		{
+			final SampleModel sampleModel = RGB_COLOR_MODEL.createCompatibleWritableRaster( 1, 1 ).getSampleModel().createCompatibleSampleModel( si.getWidth(), si.getHeight() );
+			final DataBuffer dataBuffer = si.getRaster().getDataBuffer();
+			final WritableRaster rgbRaster = Raster.createWritableRaster( sampleModel, dataBuffer, null );
+			return new BufferedImage( RGB_COLOR_MODEL, rgbRaster, false, null );
+		}
+		return si;
+	}
+
+	/**
+	 * Get a {@link BufferedImage} for the given {@link ARGBScreenImage}.
+	 * Discard the <code>screenImage</code> alpha components when drawing.
+	 *
+	 * @param screenImage
+	 * 		the image.
+	 */
+	public static BufferedImage getBufferedImage( final ARGBScreenImage screenImage )
+	{
+		return getBufferedImage( screenImage, true );
+	}
+}
diff --git a/src/main/java/bdv/util/MovingAverage.java b/src/main/java/bdv/util/MovingAverage.java
new file mode 100644
index 0000000000000000000000000000000000000000..8ea05bd5d943247f2d42ce100907772b5cbe3be6
--- /dev/null
+++ b/src/main/java/bdv/util/MovingAverage.java
@@ -0,0 +1,43 @@
+package bdv.util;
+
+import java.util.Arrays;
+
+/**
+ * Maintains a moving average over the last {@code width} values {@link #add
+ * added}. The average can be {@link #init initialized} to some value (or starts
+ * as 0, i.e., as if {@code width} 0 values had been added)
+ */
+public class MovingAverage
+{
+	private final double[] values;
+
+	private final int width;
+
+	private int index = 0;
+
+	private double average;
+
+	public MovingAverage( final int width )
+	{
+		values = new double[ width ];
+		this.width = width;
+	}
+
+	public void init( final double initialValue )
+	{
+		Arrays.fill( values, initialValue );
+		average = initialValue;
+	}
+
+	public void add( final double value )
+	{
+		average = average + ( value - values[ index ] ) / width;
+		values[ index ] = value;
+		index = ( index + 1 ) % width;
+	}
+
+	public double getAverage()
+	{
+		return average;
+	}
+}
diff --git a/src/main/java/bdv/util/TripleBuffer.java b/src/main/java/bdv/util/TripleBuffer.java
new file mode 100644
index 0000000000000000000000000000000000000000..36f7fa35c79c3cefa5645cbdedf5bcf2dcd7fe23
--- /dev/null
+++ b/src/main/java/bdv/util/TripleBuffer.java
@@ -0,0 +1,132 @@
+package bdv.util;
+
+import java.util.function.Supplier;
+
+/**
+ * Class that provides a triple-buffer-algorithm. For communication between a
+ * painter thread and a display.
+ *
+ * @param <T>
+ *     Type of the buffer.
+ *
+ * @author Matthias Arzt
+ * @author Tobias Pietzsch
+ * @see <a href="https://en.wikipedia.org/wiki/Multiple_buffering">Wikipedia Multiple Buffering</a>
+ */
+public class TripleBuffer< T >
+{
+	private T writable;
+	private T readable;
+	private T exchange;
+
+	private boolean pending = false;
+
+	private final Supplier< T > factory;
+
+	/**
+	 * Creates a triple buffer.
+	 *
+	 * @param factory
+	 * 		Factory method, that is used to create the three buffers.
+	 */
+	public TripleBuffer( Supplier< T > factory )
+	{
+		this.factory = factory;
+	}
+
+	public TripleBuffer()
+	{
+		this( () -> null );
+	}
+
+	/**
+	 * Returns a buffer that can be used in the painter thread for rendering.
+	 * The returned buffer might be a previously {@link #doneWriting submitted}
+	 * buffer that is not used by the display thread anymore.
+	 *
+	 * @return buffer that can be used for rendering, may be {@code null}.
+	 */
+	public synchronized T getWritableBuffer()
+	{
+		if ( writable == null )
+			writable = factory.get();
+		return writable;
+	}
+
+	/**
+	 * This method should be called by the painter thread, to signal that the rendering is completed.
+	 * Assumes that the buffer used for rendering was returned by the last call to {@link #getWritableBuffer()}
+	 */
+	public synchronized void doneWriting()
+	{
+		doneWriting( writable );
+	}
+
+	/**
+	 * This method should be called by the painter thread, to submit a completed
+	 * buffer. This buffer will be supplied to the display thread with the next
+	 * {@link #getReadableBuffer} (unless another {@link #doneWriting} happens
+	 * in between).
+	 */
+	public synchronized void doneWriting( final T value )
+	{
+		writable = exchange;
+		exchange = value;
+		pending = true;
+	}
+
+	/**
+	 * This method should be called in the display thread. To get the latest
+	 * buffer that was completely rendered.
+	 */
+	public synchronized ReadableBuffer< T > getReadableBuffer()
+	{
+		final boolean updated = pending;
+		if ( pending )
+		{
+			final T tmp = exchange;
+			exchange = readable;
+			readable = tmp;
+			pending = false;
+		}
+		return new ReadableBuffer<>( readable, updated );
+	}
+
+	/**
+	 * Returned from {@link #getReadableBuffer}.
+	 * A buffer and a flag indicating whether the buffer is different than the one
+	 * returned from the previous {@link #getReadableBuffer}
+	 */
+	public static class ReadableBuffer< T >
+	{
+		private final T buffer;
+
+		private final boolean updated;
+
+		ReadableBuffer( final T buffer, final boolean updated )
+		{
+			this.buffer = buffer;
+			this.updated = updated;
+		}
+
+		public T getBuffer()
+		{
+			return buffer;
+		}
+
+		public boolean isUpdated()
+		{
+			return updated;
+		}
+	}
+
+	/**
+	 * Free all buffers.
+	 */
+	public synchronized void clear()
+	{
+		writable = null;
+		readable = null;
+		exchange = null;
+	}
+}
diff --git a/src/main/java/bdv/viewer/InteractiveDisplayCanvas.java b/src/main/java/bdv/viewer/InteractiveDisplayCanvas.java
new file mode 100644
index 0000000000000000000000000000000000000000..2f2b6a228049909241f9e1e86b97b2aaac4d67dd
--- /dev/null
+++ b/src/main/java/bdv/viewer/InteractiveDisplayCanvas.java
@@ -0,0 +1,223 @@
+/*
+ * #%L
+ * ImgLib2: a general-purpose, multidimensional image processing library.
+ * %%
+ * Copyright (C) 2009 - 2016 Tobias Pietzsch, Stephan Preibisch, Stephan Saalfeld,
+ * John Bogovic, Albert Cardona, Barry DeZonia, Christian Dietz, Jan Funke,
+ * Aivar Grislis, Jonathan Hale, Grant Harris, Stefan Helfrich, Mark Hiner,
+ * Martin Horn, Steffen Jaensch, Lee Kamentsky, Larry Lindsey, Melissa Linkert,
+ * Mark Longair, Brian Northan, Nick Perry, Curtis Rueden, Johannes Schindelin,
+ * Jean-Yves Tinevez and Michael Zinsmaier.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer;
+
+import bdv.TransformEventHandler;
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.Graphics;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.event.FocusListener;
+import java.awt.event.KeyListener;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseListener;
+import java.awt.event.MouseMotionListener;
+import java.awt.event.MouseWheelListener;
+import javax.swing.JComponent;
+import org.scijava.listeners.Listeners;
+
+/*
+ * A {@link JComponent} that uses {@link OverlayRenderer OverlayRenderers}
+ * to render a canvas displayed on screen.
+ * <p>
+ * {@code InteractiveDisplayCanvas} has a {@code TransformEventHandler} that is notified when the component size is changed.
+ * <p>
+ * {@link #addHandler}/{@link #removeHandler} provide simplified that implement {@code MouseListener}, {@code KeyListener}, etc can be
+ *
+ * @param <A>
+ *            transform type
+ *
+ * @author Tobias Pietzsch
+ */
+public class InteractiveDisplayCanvas extends JComponent
+{
+	/**
+	 * Mouse/Keyboard handler that manipulates the view transformation.
+	 */
+	private TransformEventHandler handler;
+
+	/**
+	 * To draw this component, {@link OverlayRenderer#drawOverlays} is invoked for each renderer.
+	 */
+	final private Listeners.List< OverlayRenderer > overlayRenderers;
+
+	/**
+	 * Create a new {@code InteractiveDisplayCanvas}.
+	 *
+	 * @param width
+	 *            preferred component width.
+	 * @param height
+	 *            preferred component height.
+	 */
+	public InteractiveDisplayCanvas( final int width, final int height )
+	{
+		super();
+		setPreferredSize( new Dimension( width, height ) );
+		setFocusable( true );
+
+		overlayRenderers = new Listeners.SynchronizedList<>( r -> r.setCanvasSize( getWidth(), getHeight() ) );
+
+		addComponentListener( new ComponentAdapter()
+		{
+			@Override
+			public void componentResized( final ComponentEvent e )
+			{
+				final int w = getWidth();
+				final int h = getHeight();
+				// NB: Update of overlayRenderers needs to happen before update of handler
+				// Otherwise repaint might start before the render target receives the size change.
+				overlayRenderers.list.forEach( r -> r.setCanvasSize( w, h ) );
+				if ( handler != null )
+					handler.setCanvasSize( w, h, true );
+				// enableEvents( AWTEvent.MOUSE_MOTION_EVENT_MASK );
+			}
+		} );
+
+		addMouseListener( new MouseAdapter()
+		{
+			@Override
+			public void mousePressed( final MouseEvent e )
+			{
+				requestFocusInWindow();
+			}
+		} );
+	}
+
+	/**
+	 * OverlayRenderers can be added/removed here.
+	 * {@link OverlayRenderer#drawOverlays} is invoked for each renderer (in the order they were added).
+	 */
+	public Listeners< OverlayRenderer > overlays()
+	{
+		return overlayRenderers;
+	}
+
+	/**
+	 * Add new event handler. Depending on the interfaces implemented by
+	 * <code>handler</code> calls {@link Component#addKeyListener(KeyListener)},
+	 * {@link Component#addMouseListener(MouseListener)},
+	 * {@link Component#addMouseMotionListener(MouseMotionListener)},
+	 * {@link Component#addMouseWheelListener(MouseWheelListener)}.
+	 *
+	 * @param h handler to remove
+	 */
+	public void addHandler( final Object h )
+	{
+		if ( h instanceof KeyListener )
+			addKeyListener( ( KeyListener ) h );
+
+		if ( h instanceof MouseMotionListener )
+			addMouseMotionListener( ( MouseMotionListener ) h );
+
+		if ( h instanceof MouseListener )
+			addMouseListener( ( MouseListener ) h );
+
+		if ( h instanceof MouseWheelListener )
+			addMouseWheelListener( ( MouseWheelListener ) h );
+
+		if ( h instanceof FocusListener )
+			addFocusListener( ( FocusListener ) h );
+	}
+
+	/**
+	 * Remove an event handler. Add new event handler. Depending on the
+	 * interfaces implemented by <code>handler</code> calls
+	 * {@link Component#removeKeyListener(KeyListener)},
+	 * {@link Component#removeMouseListener(MouseListener)},
+	 * {@link Component#removeMouseMotionListener(MouseMotionListener)},
+	 * {@link Component#removeMouseWheelListener(MouseWheelListener)}.
+	 *
+	 * @param h handler to remove
+	 */
+	public void removeHandler( final Object h )
+	{
+		if ( h instanceof KeyListener )
+			removeKeyListener( ( KeyListener ) h );
+
+		if ( h instanceof MouseMotionListener )
+			removeMouseMotionListener( ( MouseMotionListener ) h );
+
+		if ( h instanceof MouseListener )
+			removeMouseListener( ( MouseListener ) h );
+
+		if ( h instanceof MouseWheelListener )
+			removeMouseWheelListener( ( MouseWheelListener ) h );
+
+		if ( h instanceof FocusListener )
+			removeFocusListener( ( FocusListener ) h );
+	}
+
+	/**
+	 * Set the {@link TransformEventHandler} that will be notified when component is resized.
+	 *
+	 * @param transformEventHandler
+	 *            handler to use
+	 */
+	public void setTransformEventHandler( final TransformEventHandler transformEventHandler )
+	{
+		if ( handler != null )
+			removeHandler( handler );
+		handler = transformEventHandler;
+		handler.setCanvasSize( getWidth(), getHeight(), false );
+		addHandler( handler );
+	}
+
+	@Override
+	public void paintComponent( final Graphics g )
+	{
+		overlayRenderers.list.forEach( r -> r.drawOverlays( g ) );
+	}
+
+	// -- deprecated API --
+
+	/**
+	 * @deprecated Use {@code overlays().add(renderer)} instead
+	 */
+	@Deprecated
+	public void addOverlayRenderer( final OverlayRenderer renderer )
+	{
+		overlayRenderers.add( renderer );
+	}
+
+	/**
+	 * @deprecated Use {@code overlays().remove(renderer)} instead
+	 */
+	@Deprecated
+	public void removeOverlayRenderer( final OverlayRenderer renderer )
+	{
+		overlayRenderers.remove( renderer );
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/TransformAwareRenderTarget.java b/src/main/java/bdv/viewer/OverlayRenderer.java
similarity index 52%
rename from src/main/java/bdv/viewer/render/TransformAwareRenderTarget.java
rename to src/main/java/bdv/viewer/OverlayRenderer.java
index b9f84b92f43dd7cfce6e9a9298928c3797492566..7b8ba718fabe2be2b91285103018f357b7b34525 100644
--- a/src/main/java/bdv/viewer/render/TransformAwareRenderTarget.java
+++ b/src/main/java/bdv/viewer/OverlayRenderer.java
@@ -1,18 +1,23 @@
 /*
  * #%L
- * BigDataViewer core classes with minimal dependencies.
+ * ImgLib2: a general-purpose, multidimensional image processing library.
  * %%
- * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * Copyright (C) 2009 - 2016 Tobias Pietzsch, Stephan Preibisch, Stephan Saalfeld,
+ * John Bogovic, Albert Cardona, Barry DeZonia, Christian Dietz, Jan Funke,
+ * Aivar Grislis, Jonathan Hale, Grant Harris, Stefan Helfrich, Mark Hiner,
+ * Martin Horn, Steffen Jaensch, Lee Kamentsky, Larry Lindsey, Melissa Linkert,
+ * Mark Longair, Brian Northan, Nick Perry, Curtis Rueden, Johannes Schindelin,
+ * Jean-Yves Tinevez and Michael Zinsmaier.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- * 
+ *
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- * 
+ *
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -26,28 +31,33 @@
  * POSSIBILITY OF SUCH DAMAGE.
  * #L%
  */
-package bdv.viewer.render;
+package bdv.viewer;
 
-import java.awt.image.BufferedImage;
+import java.awt.Graphics;
 
-import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.RenderTarget;
-import net.imglib2.ui.TransformListener;
-
-public interface TransformAwareRenderTarget extends RenderTarget
+/**
+ * Draw something to a {@link Graphics} canvas and receive notifications about
+ * changes of the canvas size.
+ *
+ * @author Tobias Pietzsch
+ */
+public interface OverlayRenderer
 {
 	/**
-	 * Set the {@link BufferedImage} that is to be drawn on the canvas, and the
-	 * transform with which this image was created.
-	 *
-	 * @param img
-	 *            image to draw (may be null).
+	 * Render overlays.
 	 */
-	public BufferedImage setBufferedImageAndTransform( final BufferedImage img, final AffineTransform3D transform );
-
-	public void addTransformListener( final TransformListener< AffineTransform3D > listener );
+	void drawOverlays( Graphics g );
 
-	public void addTransformListener( final TransformListener< AffineTransform3D > listener, final int index );
-
-	public void removeTransformListener( final TransformListener< AffineTransform3D > listener );
+	/**
+	 * This is called, when the screen size of the canvas (the component
+	 * displaying the image and generating mouse events) changes. This can be
+	 * used to determine scale of overlay or screen coordinates relative to the
+	 * border.
+	 *
+	 * @param width
+	 *            the new canvas width.
+	 * @param height
+	 *            the new canvas height.
+	 */
+	void setCanvasSize( int width, int height );
 }
diff --git a/src/main/java/bdv/viewer/RequestRepaint.java b/src/main/java/bdv/viewer/RequestRepaint.java
index f0cc527630e3ddb7a8e38301a4e7b80b711ec1c3..a6eedd483db18dbca2a31166a591f42c189c4edd 100644
--- a/src/main/java/bdv/viewer/RequestRepaint.java
+++ b/src/main/java/bdv/viewer/RequestRepaint.java
@@ -33,5 +33,5 @@ public interface RequestRepaint
 	/**
 	 * Repaint as soon as possible.
 	 */
-	public void requestRepaint();
+	void requestRepaint();
 }
diff --git a/src/main/java/bdv/viewer/TimePointListener.java b/src/main/java/bdv/viewer/TimePointListener.java
index 7c3a5cf7589c3f1bcf2174515c7f3bc0bc77b39a..abd0d022b291825c966c1f06e1d721a83ecb5a3a 100644
--- a/src/main/java/bdv/viewer/TimePointListener.java
+++ b/src/main/java/bdv/viewer/TimePointListener.java
@@ -30,5 +30,5 @@ package bdv.viewer;
 
 public interface TimePointListener
 {
-	public void timePointChanged( int timePointIndex );
+	void timePointChanged( int timePointIndex );
 }
diff --git a/src/main/java/bdv/BehaviourTransformEventHandler.java b/src/main/java/bdv/viewer/TransformListener.java
similarity index 68%
rename from src/main/java/bdv/BehaviourTransformEventHandler.java
rename to src/main/java/bdv/viewer/TransformListener.java
index 83ce47643b22e36efe7baf8326c60227c8c203b1..0f5aa796e490dbb79a3d67d1090dfe7d8a82d3f0 100644
--- a/src/main/java/bdv/BehaviourTransformEventHandler.java
+++ b/src/main/java/bdv/viewer/TransformListener.java
@@ -1,18 +1,23 @@
-/*-
+/*
  * #%L
- * BigDataViewer core classes with minimal dependencies.
+ * ImgLib2: a general-purpose, multidimensional image processing library.
  * %%
- * Copyright (C) 2012 - 2020 BigDataViewer developers.
+ * Copyright (C) 2009 - 2016 Tobias Pietzsch, Stephan Preibisch, Stephan Saalfeld,
+ * John Bogovic, Albert Cardona, Barry DeZonia, Christian Dietz, Jan Funke,
+ * Aivar Grislis, Jonathan Hale, Grant Harris, Stefan Helfrich, Mark Hiner,
+ * Martin Horn, Steffen Jaensch, Lee Kamentsky, Larry Lindsey, Melissa Linkert,
+ * Mark Longair, Brian Northan, Nick Perry, Curtis Rueden, Johannes Schindelin,
+ * Jean-Yves Tinevez and Michael Zinsmaier.
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- * 
+ *
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- * 
+ *
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -26,13 +31,9 @@
  * POSSIBILITY OF SUCH DAMAGE.
  * #L%
  */
-package bdv;
+package bdv.viewer;
 
-import org.scijava.ui.behaviour.util.TriggerBehaviourBindings;
-
-import net.imglib2.ui.TransformEventHandler;
-
-public interface BehaviourTransformEventHandler< A > extends TransformEventHandler< A >
+public interface TransformListener< A >
 {
-	public void install( final TriggerBehaviourBindings bindings );
+	void transformChanged( A transform );
 }
diff --git a/src/main/java/bdv/viewer/ViewerFrame.java b/src/main/java/bdv/viewer/ViewerFrame.java
index a528899b52422f8ce11ac4bf65304ece5c714bd4..1eebe5bfd4099452e743e19d65a161077007f6c3 100644
--- a/src/main/java/bdv/viewer/ViewerFrame.java
+++ b/src/main/java/bdv/viewer/ViewerFrame.java
@@ -6,13 +6,13 @@
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- * 
+ *
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- * 
+ *
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -28,6 +28,7 @@
  */
 package bdv.viewer;
 
+import bdv.TransformEventHandler;
 import bdv.ui.CardPanel;
 import bdv.ui.BdvDefaultCards;
 import bdv.ui.splitpanel.SplitPanel;
@@ -42,13 +43,12 @@ import javax.swing.SwingUtilities;
 import javax.swing.WindowConstants;
 
 import org.scijava.ui.behaviour.MouseAndKeyHandler;
+import org.scijava.ui.behaviour.util.Behaviours;
 import org.scijava.ui.behaviour.util.InputActionBindings;
 import org.scijava.ui.behaviour.util.TriggerBehaviourBindings;
 
-import bdv.BehaviourTransformEventHandler;
 import bdv.cache.CacheControl;
-import net.imglib2.ui.TransformEventHandler;
-import net.imglib2.ui.util.GuiUtil;
+import bdv.util.AWTUtils;
 
 /**
  * A {@link JFrame} containing a {@link ViewerPanel} and associated
@@ -98,7 +98,7 @@ public class ViewerFrame extends JFrame
 			final ViewerOptions optional )
 	{
 //		super( "BigDataViewer", GuiUtil.getSuitableGraphicsConfiguration( GuiUtil.ARGB_COLOR_MODEL ) );
-		super( "BigDataViewer", GuiUtil.getSuitableGraphicsConfiguration( GuiUtil.RGB_COLOR_MODEL ) );
+		super( "BigDataViewer", AWTUtils.getSuitableGraphicsConfiguration( AWTUtils.RGB_COLOR_MODEL ) );
 		viewer = new ViewerPanel( sources, numTimepoints, cacheControl, optional );
 		setups = new ConverterSetups( viewer.state() );
 		setups.listeners().add( s -> viewer.requestRepaint() );
@@ -133,9 +133,12 @@ public class ViewerFrame extends JFrame
 		mouseAndKeyHandler.setKeypressManager( optional.values.getKeyPressedManager(), viewer.getDisplay() );
 		viewer.getDisplay().addHandler( mouseAndKeyHandler );
 
-		final TransformEventHandler< ? > tfHandler = viewer.getDisplay().getTransformEventHandler();
-		if ( tfHandler instanceof BehaviourTransformEventHandler )
-			( ( BehaviourTransformEventHandler< ? > ) tfHandler ).install( triggerbindings );
+		// TODO: should be a field?
+		final Behaviours transformBehaviours = new Behaviours( optional.values.getInputTriggerConfig(), "bdv" );
+		transformBehaviours.install( triggerbindings, "transform" );
+
+		final TransformEventHandler tfHandler = viewer.getTransformEventHandler();
+		tfHandler.install( transformBehaviours );
 	}
 
 	public ViewerPanel getViewerPanel()
diff --git a/src/main/java/bdv/viewer/ViewerOptions.java b/src/main/java/bdv/viewer/ViewerOptions.java
index bed250828e3be6ca430551a5c3fcbbfb50a86e26..fe55ed2464e87bc700545eabe796a071fbfa9b86 100644
--- a/src/main/java/bdv/viewer/ViewerOptions.java
+++ b/src/main/java/bdv/viewer/ViewerOptions.java
@@ -6,13 +6,13 @@
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- * 
+ *
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- * 
+ *
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -28,12 +28,12 @@
  */
 package bdv.viewer;
 
+import bdv.TransformEventHandler3D;
 import java.awt.event.KeyListener;
 
 import org.scijava.ui.behaviour.KeyPressedManager;
 import org.scijava.ui.behaviour.io.InputTriggerConfig;
 
-import bdv.BehaviourTransformEventHandler3D;
 import bdv.viewer.animate.MessageOverlayAnimator;
 import bdv.viewer.render.AccumulateProjector;
 import bdv.viewer.render.AccumulateProjectorARGB;
@@ -41,7 +41,7 @@ import bdv.viewer.render.AccumulateProjectorFactory;
 import bdv.viewer.render.MultiResolutionRenderer;
 import net.imglib2.realtransform.AffineTransform3D;
 import net.imglib2.type.numeric.ARGBType;
-import net.imglib2.ui.TransformEventHandlerFactory;
+import bdv.TransformEventHandlerFactory;
 
 /**
  * Optional parameters for {@link ViewerPanel}.
@@ -110,19 +110,6 @@ public class ViewerOptions
 		return this;
 	}
 
-	/**
-	 * Set whether to used double buffered rendering.
-	 *
-	 * @param d
-	 *            Whether to use double buffered rendering.
-	 * @see MultiResolutionRenderer
-	 */
-	public ViewerOptions doubleBuffered( final boolean d )
-	{
-		values.doubleBuffered = d;
-		return this;
-	}
-
 	/**
 	 * Set how many threads to use for rendering.
 	 *
@@ -168,7 +155,7 @@ public class ViewerOptions
 		return this;
 	}
 
-	public ViewerOptions transformEventHandlerFactory( final TransformEventHandlerFactory< AffineTransform3D > f )
+	public ViewerOptions transformEventHandlerFactory( final TransformEventHandlerFactory f )
 	{
 		values.transformEventHandlerFactory = f;
 		return this;
@@ -231,8 +218,6 @@ public class ViewerOptions
 
 		private long targetRenderNanos = 30 * 1000000l;
 
-		private boolean doubleBuffered = true;
-
 		private int numRenderingThreads = 3;
 
 		private int numSourceGroups = 10;
@@ -241,7 +226,7 @@ public class ViewerOptions
 
 		private MessageOverlayAnimator msgOverlay = new MessageOverlayAnimator( 800 );
 
-		private TransformEventHandlerFactory< AffineTransform3D > transformEventHandlerFactory = BehaviourTransformEventHandler3D.factory();
+		private TransformEventHandlerFactory transformEventHandlerFactory = TransformEventHandler3D::new;
 
 		private AccumulateProjectorFactory< ARGBType > accumulateProjectorFactory = AccumulateProjectorARGB.factory;
 
@@ -256,7 +241,6 @@ public class ViewerOptions
 				height( height ).
 				screenScales( screenScales ).
 				targetRenderNanos( targetRenderNanos ).
-				doubleBuffered( doubleBuffered ).
 				numRenderingThreads( numRenderingThreads ).
 				numSourceGroups( numSourceGroups ).
 				useVolatileIfAvailable( useVolatileIfAvailable ).
@@ -286,11 +270,6 @@ public class ViewerOptions
 			return targetRenderNanos;
 		}
 
-		public boolean isDoubleBuffered()
-		{
-			return doubleBuffered;
-		}
-
 		public int getNumRenderingThreads()
 		{
 			return numRenderingThreads;
@@ -311,7 +290,7 @@ public class ViewerOptions
 			return msgOverlay;
 		}
 
-		public TransformEventHandlerFactory< AffineTransform3D > getTransformEventHandlerFactory()
+		public TransformEventHandlerFactory getTransformEventHandlerFactory()
 		{
 			return transformEventHandlerFactory;
 		}
diff --git a/src/main/java/bdv/viewer/ViewerPanel.java b/src/main/java/bdv/viewer/ViewerPanel.java
index 238502f5d6b91ad0fc6fc9bab312e53e3aa0af05..612592832ad6f0b45712c57da4e6b5b673e28cbb 100644
--- a/src/main/java/bdv/viewer/ViewerPanel.java
+++ b/src/main/java/bdv/viewer/ViewerPanel.java
@@ -28,6 +28,8 @@
  */
 package bdv.viewer;
 
+import bdv.TransformEventHandler;
+import bdv.TransformState;
 import java.awt.BorderLayout;
 import java.awt.Color;
 import java.awt.Font;
@@ -54,14 +56,13 @@ import javax.swing.JPanel;
 import javax.swing.JSlider;
 import javax.swing.SwingConstants;
 import javax.swing.SwingUtilities;
-import javax.swing.event.ChangeEvent;
-import javax.swing.event.ChangeListener;
 
+import net.imglib2.Interval;
+import bdv.viewer.render.awt.BufferedImageOverlayRenderer;
 import org.jdom2.Element;
 
 import bdv.cache.CacheControl;
 import bdv.util.Affine3DHelpers;
-import bdv.util.InvokeOnEDT;
 import bdv.util.Prefs;
 import bdv.viewer.animate.AbstractTransformAnimator;
 import bdv.viewer.animate.MessageOverlayAnimator;
@@ -73,7 +74,6 @@ import bdv.viewer.overlay.MultiBoxOverlayRenderer;
 import bdv.viewer.overlay.ScaleBarOverlayRenderer;
 import bdv.viewer.overlay.SourceInfoOverlayRenderer;
 import bdv.viewer.render.MultiResolutionRenderer;
-import bdv.viewer.render.TransformAwareBufferedImageOverlayRenderer;
 import bdv.viewer.state.SourceGroup;
 import bdv.viewer.state.ViewerState;
 import bdv.viewer.state.XmlIoViewerState;
@@ -82,17 +82,13 @@ import net.imglib2.RealLocalizable;
 import net.imglib2.RealPoint;
 import net.imglib2.RealPositionable;
 import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.InteractiveDisplayCanvasComponent;
-import net.imglib2.ui.OverlayRenderer;
-import net.imglib2.ui.PainterThread;
-import net.imglib2.ui.TransformEventHandler;
-import net.imglib2.ui.TransformListener;
+import bdv.viewer.render.PainterThread;
 import net.imglib2.util.LinAlgHelpers;
-
+import org.scijava.listeners.Listeners;
 
 /**
  * A JPanel for viewing multiple of {@link Source}s. The panel contains a
- * {@link InteractiveDisplayCanvasComponent canvas} and a time slider (if there
+ * {@link InteractiveDisplayCanvas canvas} and a time slider (if there
  * are multiple time-points). Maintains a {@link ViewerState render state}, the
  * renderer, and basic navigation help overlays. It has it's own
  * {@link PainterThread} for painting, which is started on construction (use
@@ -100,7 +96,7 @@ import net.imglib2.util.LinAlgHelpers;
  *
  * @author Tobias Pietzsch
  */
-public class ViewerPanel extends JPanel implements OverlayRenderer, TransformListener< AffineTransform3D >, PainterThread.Paintable, ViewerStateChangeListener, RequestRepaint
+public class ViewerPanel extends JPanel implements OverlayRenderer, PainterThread.Paintable, ViewerStateChangeListener, RequestRepaint
 {
 	private static final long serialVersionUID = 1L;
 
@@ -118,7 +114,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	/**
 	 * TODO
 	 */
-	protected final TransformAwareBufferedImageOverlayRenderer renderTarget;
+	protected final BufferedImageOverlayRenderer renderTarget;
 
 	/**
 	 * Overlay navigation boxes.
@@ -137,16 +133,13 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	 */
 	protected final ScaleBarOverlayRenderer scaleBarOverlayRenderer;
 
-	/**
-	 * Transformation set by the interactive viewer.
-	 */
-	protected final AffineTransform3D viewerTransform;
+	private final TransformEventHandler transformEventHandler;
 
 	/**
 	 * Canvas used for displaying the rendered {@link #renderTarget image} and
 	 * overlays.
 	 */
-	protected final InteractiveDisplayCanvasComponent< AffineTransform3D > display;
+	protected final InteractiveDisplayCanvas display;
 
 	protected final JSlider sliderTime;
 
@@ -183,7 +176,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 
 	/**
 	 * These listeners will be notified about changes to the
-	 * {@link #viewerTransform}. This is done <em>before</em> calling
+	 * viewer-transform. This is done <em>before</em> calling
 	 * {@link #requestRepaint()} so listeners have the chance to interfere.
 	 */
 	protected final CopyOnWriteArrayList< TransformListener< AffineTransform3D > > transformListeners;
@@ -257,14 +250,13 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		threadGroup = new ThreadGroup( this.toString() );
 		painterThread = new PainterThread( threadGroup, this );
 		painterThread.setDaemon( true );
-		viewerTransform = new AffineTransform3D();
-		display = new InteractiveDisplayCanvasComponent<>(
-				options.getWidth(), options.getHeight(), options.getTransformEventHandlerFactory() );
-		display.addTransformListener( this );
-		renderTarget = new TransformAwareBufferedImageOverlayRenderer();
-		renderTarget.setCanvasSize( options.getWidth(), options.getHeight() );
-		display.addOverlayRenderer( renderTarget );
-		display.addOverlayRenderer( this );
+		transformEventHandler = options.getTransformEventHandlerFactory().create(
+				TransformState.from( state()::getViewerTransform, state()::setViewerTransform ) );
+		renderTarget = new BufferedImageOverlayRenderer();
+		display = new InteractiveDisplayCanvas( options.getWidth(), options.getHeight() );
+		display.setTransformEventHandler( transformEventHandler );
+		display.overlays().add( renderTarget );
+		display.overlays().add( this );
 
 		renderingExecutorService = Executors.newFixedThreadPool(
 				options.getNumRenderingThreads(),
@@ -273,7 +265,6 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 				renderTarget, painterThread,
 				options.getScreenScales(),
 				options.getTargetRenderNanos(),
-				options.isDoubleBuffered(),
 				options.getNumRenderingThreads(),
 				renderingExecutorService,
 				options.isUseVolatileIfAvailable(),
@@ -418,7 +409,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	{
 		assert gPos.length >= 3;
 
-		viewerTransform.applyInverse( gPos, gPos );
+		state().getViewerTransform().applyInverse( gPos, gPos );
 	}
 
 	/**
@@ -432,7 +423,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	{
 		assert gPos.numDimensions() >= 3;
 
-		viewerTransform.applyInverse( gPos, gPos );
+		state().getViewerTransform().applyInverse( gPos, gPos );
 	}
 
 	/**
@@ -448,7 +439,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		final RealPoint lPos = new RealPoint( 3 );
 		lPos.setPosition( x, 0 );
 		lPos.setPosition( y, 1 );
-		viewerTransform.applyInverse( gPos, lPos );
+		state().getViewerTransform().applyInverse( gPos, lPos );
 	}
 
 	/**
@@ -463,7 +454,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		assert gPos.numDimensions() == 3;
 		final RealPoint lPos = new RealPoint( 3 );
 		mouseCoordinates.getMouseCoordinates( lPos );
-		viewerTransform.applyInverse( gPos, lPos );
+		state().getViewerTransform().applyInverse( gPos, lPos );
 	}
 
 	/**
@@ -479,7 +470,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	@Override
 	public void paint()
 	{
-		imageRenderer.paint( state );
+		imageRenderer.paint( state.getState() );
 
 		display.repaint();
 
@@ -487,12 +478,12 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		{
 			if ( currentAnimator != null )
 			{
-				final TransformEventHandler< AffineTransform3D > handler = display.getTransformEventHandler();
 				final AffineTransform3D transform = currentAnimator.getCurrent( System.currentTimeMillis() );
-				handler.setTransform( transform );
-				transformChanged( transform );
+				state().setViewerTransform( transform );
 				if ( currentAnimator.isComplete() )
 					currentAnimator = null;
+				else
+					requestRepaint();
 			}
 		}
 	}
@@ -506,6 +497,11 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		imageRenderer.requestRepaint();
 	}
 
+	public void requestRepaint( final Interval screenInterval )
+	{
+		imageRenderer.requestRepaint( screenInterval );
+	}
+
 	@Override
 	public void drawOverlays( final Graphics g )
 	{
@@ -553,16 +549,6 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 			display.repaint();
 	}
 
-	@Override
-	public synchronized void transformChanged( final AffineTransform3D transform )
-	{
-		viewerTransform.set( transform );
-		state.setViewerTransform( transform );
-		for ( final TransformListener< AffineTransform3D > l : transformListeners )
-			l.transformChanged( viewerTransform );
-		requestRepaint();
-	}
-
 	@Override
 	public void viewerStateChanged( final ViewerStateChange change )
 	{
@@ -630,7 +616,11 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 			requestRepaint();
 			break;
 		}
-//		case VIEWER_TRANSFORM_CHANGED:
+		case VIEWER_TRANSFORM_CHANGED:
+			final AffineTransform3D transform = state().getViewerTransform();
+			for ( final TransformListener< AffineTransform3D > l : transformListeners )
+				l.transformChanged( transform );
+			requestRepaint();
 		}
 	}
 
@@ -697,7 +687,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		final double[] qTarget = new double[ 4 ];
 		LinAlgHelpers.quaternionInvert( qTmpSource, qTarget );
 
-		final AffineTransform3D transform = display.getTransformEventHandler().getTransform();
+		final AffineTransform3D transform = state().getViewerTransform();
 		double centerX;
 		double centerY;
 		if ( mouseCoordinates.isMouseInsidePanel() )
@@ -712,7 +702,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		}
 		currentAnimator = new RotationAnimator( transform, centerX, centerY, qTarget, 300 );
 		currentAnimator.setTime( System.currentTimeMillis() );
-		transformChanged( transform );
+		requestRepaint();
 	}
 
 	public synchronized void setTransformAnimator( final AbstractTransformAnimator animator )
@@ -751,12 +741,12 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	}
 
 	/**
-	 * Set the viewer transform.
+	 * @deprecated Modify {@link #state()} directly ({@code state().setViewerTransform(t)})
 	 */
-	public synchronized void setCurrentViewerTransform( final AffineTransform3D viewerTransform )
+	@Deprecated
+	public void setCurrentViewerTransform( final AffineTransform3D viewerTransform )
 	{
-		display.getTransformEventHandler().setTransform( viewerTransform );
-		transformChanged( viewerTransform );
+		state().setViewerTransform( viewerTransform );
 	}
 
 	/**
@@ -845,11 +835,16 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	 *
 	 * @return the viewer canvas.
 	 */
-	public InteractiveDisplayCanvasComponent< AffineTransform3D > getDisplay()
+	public InteractiveDisplayCanvas getDisplay()
 	{
 		return display;
 	}
 
+	public TransformEventHandler getTransformEventHandler()
+	{
+		return transformEventHandler;
+	}
+
 	/**
 	 * Display the specified message in a text overlay for a short time.
 	 *
@@ -901,37 +896,34 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 	}
 
 	/**
-	 * Add a {@link TransformListener} to notify about viewer transformation
+	 * Add/remove {@code TransformListener}s to notify about viewer transformation
 	 * changes. Listeners will be notified when a new image has been painted
 	 * with the viewer transform used to render that image.
-	 *
+	 * <p>
 	 * This happens immediately after that image is painted onto the screen,
 	 * before any overlays are painted.
-	 *
-	 * @param listener
-	 *            the transform listener to add.
 	 */
+	public Listeners< TransformListener< AffineTransform3D > > renderTransformListeners()
+	{
+		return renderTarget.transformListeners();
+	}
+
+	/**
+	 * @deprecated Use {@code renderTransformListeners().add( listener )}.
+	 */
+	@Deprecated
 	public void addRenderTransformListener( final TransformListener< AffineTransform3D > listener )
 	{
-		renderTarget.addTransformListener( listener );
+		renderTransformListeners().add( listener );
 	}
 
 	/**
-	 * Add a {@link TransformListener} to notify about viewer transformation
-	 * changes. Listeners will be notified when a new image has been painted
-	 * with the viewer transform used to render that image.
-	 *
-	 * This happens immediately after that image is painted onto the screen,
-	 * before any overlays are painted.
-	 *
-	 * @param listener
-	 *            the transform listener to add.
-	 * @param index
-	 *            position in the list of listeners at which to insert this one.
+	 * @deprecated Use {@code renderTransformListeners().add( index, listener )}.
 	 */
+	@Deprecated
 	public void addRenderTransformListener( final TransformListener< AffineTransform3D > listener, final int index )
 	{
-		renderTarget.addTransformListener( listener, index );
+		renderTransformListeners().add( index, listener );
 	}
 
 	/**
@@ -963,7 +955,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		{
 			final int s = transformListeners.size();
 			transformListeners.add( index < 0 ? 0 : index > s ? s : index, listener );
-			listener.transformChanged( viewerTransform );
+			listener.transformChanged( state().getViewerTransform() );
 		}
 	}
 
@@ -979,7 +971,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		{
 			transformListeners.remove( listener );
 		}
-		renderTarget.removeTransformListener( listener );
+		renderTarget.transformListeners().remove( listener );
 	}
 
 	/**
@@ -1156,6 +1148,7 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		renderingExecutorService.shutdown();
 		state.kill();
 		imageRenderer.kill();
+		renderTarget.kill();
 	}
 
 	protected static final AtomicInteger panelNumber = new AtomicInteger( 1 );
@@ -1182,11 +1175,6 @@ public class ViewerPanel extends JPanel implements OverlayRenderer, TransformLis
 		}
 	}
 
-	public TransformAwareBufferedImageOverlayRenderer renderTarget()
-	{
-		return this.renderTarget;
-	}
-
 	@Override
 	public boolean requestFocusInWindow()
 	{
diff --git a/src/main/java/bdv/viewer/render/AccumulateProjector.java b/src/main/java/bdv/viewer/render/AccumulateProjector.java
index 483f44e4cc5e30d86ad964016c305455b1b26900..91be77098862c3dec46629db2bb75981534b836b 100644
--- a/src/main/java/bdv/viewer/render/AccumulateProjector.java
+++ b/src/main/java/bdv/viewer/render/AccumulateProjector.java
@@ -29,56 +29,63 @@
 package bdv.viewer.render;
 
 import java.util.ArrayList;
+import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.atomic.AtomicBoolean;
-
 import net.imglib2.Cursor;
 import net.imglib2.IterableInterval;
 import net.imglib2.RandomAccessible;
 import net.imglib2.RandomAccessibleInterval;
-import net.imglib2.ui.InterruptibleProjector;
-import net.imglib2.ui.util.StopWatch;
+import net.imglib2.util.StopWatch;
 import net.imglib2.view.Views;
 
+// TODO javadoc
 public abstract class AccumulateProjector< A, B > implements VolatileProjector
 {
-	protected final ArrayList< VolatileProjector > sourceProjectors;
+	/**
+	 * Projectors that render the source images to accumulate.
+	 * For every rendering pass, ({@link VolatileProjector#map(boolean)}) is run on each source projector that is not yet {@link VolatileProjector#isValid() valid}.
+	 */
+	private final List< VolatileProjector > sourceProjectors;
 
-	protected final ArrayList< IterableInterval< ? extends A > > sources;
+	/**
+	 * The source images to accumulate
+	 */
+	private final List< IterableInterval< ? extends A > > sources;
 
 	/**
 	 * The target interval. Pixels of the target interval should be set by
-	 * {@link InterruptibleProjector#map()}
+	 * {@link #map}
 	 */
-	protected final RandomAccessibleInterval< B > target;
+	private final RandomAccessibleInterval< B > target;
 
 	/**
 	 * A reference to the target image as an iterable.  Used for source-less
 	 * operations such as clearing its content.
 	 */
-	protected final IterableInterval< B > iterableTarget;
+	private final IterableInterval< B > iterableTarget;
 
 	/**
      * Number of threads to use for rendering
      */
-    protected final int numThreads;
+    private final int numThreads;
 
-	protected final ExecutorService executorService;
+	private final ExecutorService executorService;
 
     /**
      * Time needed for rendering the last frame, in nano-seconds.
      */
-    protected long lastFrameRenderNanoTime;
+    private long lastFrameRenderNanoTime;
 
-	protected final AtomicBoolean interrupted = new AtomicBoolean();
+	private final AtomicBoolean canceled = new AtomicBoolean();
 
-	protected volatile boolean valid = false;
+	private volatile boolean valid = false;
 
 	public AccumulateProjector(
-			final ArrayList< VolatileProjector > sourceProjectors,
-			final ArrayList< ? extends RandomAccessible< ? extends A > > sources,
+			final List< VolatileProjector > sourceProjectors,
+			final List< ? extends RandomAccessible< ? extends A > > sources,
 			final RandomAccessibleInterval< B > target,
 			final int numThreads,
 			final ExecutorService executorService )
@@ -94,19 +101,12 @@ public abstract class AccumulateProjector< A, B > implements VolatileProjector
 		lastFrameRenderNanoTime = -1;
 	}
 
-	@Override
-	public boolean map()
-	{
-		return map( true );
-	}
-
 	@Override
 	public boolean map( final boolean clearUntouchedTargetPixels )
 	{
-		interrupted.set( false );
+		canceled.set( false );
 
-		final StopWatch stopWatch = new StopWatch();
-		stopWatch.start();
+		final StopWatch stopWatch = StopWatch.createAndStart();
 
 		valid = true;
 		for ( final VolatileProjector p : sourceProjectors )
@@ -118,49 +118,21 @@ public abstract class AccumulateProjector< A, B > implements VolatileProjector
 
 		final int width = ( int ) target.dimension( 0 );
 		final int height = ( int ) target.dimension( 1 );
-		final int length = width * height;
+		final int size = width * height;
+
+		final int numTasks = numThreads <= 1 ? 1 : Math.min( numThreads * 10, height );
+		final double taskLength = ( double ) size / numTasks;
+		final int[] taskOffsets = new int[ numTasks + 1 ];
+		for ( int i = 0; i < numTasks; ++i )
+			taskOffsets[ i ] = ( int ) ( i * taskLength );
+		taskOffsets[ numTasks ] = size;
 
 		final boolean createExecutor = ( executorService == null );
 		final ExecutorService ex = createExecutor ? Executors.newFixedThreadPool( numThreads ) : executorService;
-		final int numTasks = Math.min( numThreads * 10, height );
-		final double taskLength = ( double ) length / numTasks;
-		final int numSources = sources.size();
-		final ArrayList< Callable< Void > > tasks = new ArrayList<>( numTasks );
-		for ( int taskNum = 0; taskNum < numTasks; ++taskNum )
-		{
-			final int myOffset = ( int ) ( taskNum * taskLength );
-			final int myLength = ( (taskNum == numTasks - 1 ) ? length : ( int ) ( ( taskNum + 1 ) * taskLength ) ) - myOffset;
-
-			final Callable< Void > r = new Callable< Void >()
-			{
-				@SuppressWarnings( "unchecked" )
-				@Override
-				public Void call()
-				{
-					if ( interrupted.get() )
-						return null;
-
-					final Cursor< ? extends A >[] sourceCursors = new Cursor[ numSources ];
-					for ( int s = 0; s < numSources; ++s )
-					{
-						final Cursor< ? extends A > c = sources.get( s ).cursor();
-						c.jumpFwd( myOffset );
-						sourceCursors[ s ] = c;
-					}
-					final Cursor< B > targetCursor = iterableTarget.cursor();
-					targetCursor.jumpFwd( myOffset );
-
-					for ( int i = 0; i < myLength; ++i )
-					{
-						for ( int s = 0; s < numSources; ++s )
-							sourceCursors[ s ].fwd();
-						accumulate( sourceCursors, targetCursor.next() );
-					}
-					return null;
-				}
-			};
-			tasks.add( r );
-		}
+
+		final List< Callable< Void > > tasks = new ArrayList<>( numTasks );
+		for( int i = 0; i < numTasks; ++i )
+			tasks.add( createMapTask( taskOffsets[ i ], taskOffsets[ i + 1 ] ) );
 		try
 		{
 			ex.invokeAll( tasks );
@@ -174,7 +146,50 @@ public abstract class AccumulateProjector< A, B > implements VolatileProjector
 
 		lastFrameRenderNanoTime = stopWatch.nanoTime();
 
-		return !interrupted.get();
+		return !canceled.get();
+	}
+
+	/**
+	 * @return a {@code Callable} that runs {@code map(startOffset, endOffset)}
+	 */
+	private Callable< Void > createMapTask( final int startOffset, final int endOffset )
+	{
+		return Executors.callable( () -> map( startOffset, endOffset ), null );
+	}
+
+	/**
+	 * Accumulate pixels from {@code startOffset} up to {@code endOffset}
+	 * (exclusive) of all sources to target. Before starting, check
+	 * whether rendering was {@link #cancel() canceled}.
+	 *
+	 * @param startOffset
+	 *     pixel range start (flattened index)
+	 * @param endOffset
+	 *     pixel range end (exclusive, flattened index)
+	 */
+	private void map( final int startOffset, final int endOffset )
+	{
+		if ( canceled.get() )
+			return;
+
+		final int numSources = sources.size();
+		final Cursor< ? extends A >[] sourceCursors = new Cursor[ numSources ];
+		for ( int s = 0; s < numSources; ++s )
+		{
+			final Cursor< ? extends A > c = sources.get( s ).cursor();
+			c.jumpFwd( startOffset );
+			sourceCursors[ s ] = c;
+		}
+		final Cursor< B > targetCursor = iterableTarget.cursor();
+		targetCursor.jumpFwd( startOffset );
+
+		final int size = endOffset - startOffset;
+		for ( int i = 0; i < size; ++i )
+		{
+			for ( int s = 0; s < numSources; ++s )
+				sourceCursors[ s ].fwd();
+			accumulate( sourceCursors, targetCursor.next() );
+		}
 	}
 
 	protected abstract void accumulate( final Cursor< ? extends A >[] accesses, final B target );
@@ -182,7 +197,7 @@ public abstract class AccumulateProjector< A, B > implements VolatileProjector
 	@Override
 	public void cancel()
 	{
-		interrupted.set( true );
+		canceled.set( true );
 		for ( final VolatileProjector p : sourceProjectors )
 			p.cancel();
 	}
diff --git a/src/main/java/bdv/viewer/render/AccumulateProjectorARGB.java b/src/main/java/bdv/viewer/render/AccumulateProjectorARGB.java
index e7b1861c04f9d7d3975f19099eba8824fc7a718f..c92260f53354d218e78dc68740ac5e268ce22dfc 100644
--- a/src/main/java/bdv/viewer/render/AccumulateProjectorARGB.java
+++ b/src/main/java/bdv/viewer/render/AccumulateProjectorARGB.java
@@ -30,46 +30,257 @@ package bdv.viewer.render;
 
 import bdv.viewer.SourceAndConverter;
 import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
 import net.imglib2.Cursor;
+import net.imglib2.Interval;
 import net.imglib2.RandomAccessible;
 import net.imglib2.RandomAccessibleInterval;
 import net.imglib2.type.numeric.ARGBType;
+import net.imglib2.util.Intervals;
+import net.imglib2.util.StopWatch;
 
-public class AccumulateProjectorARGB extends AccumulateProjector< ARGBType, ARGBType >
+public class AccumulateProjectorARGB implements VolatileProjector
 {
 	public static AccumulateProjectorFactory< ARGBType > factory = new AccumulateProjectorFactory< ARGBType >()
 	{
 		@Override
-		public AccumulateProjectorARGB createProjector(
-				final ArrayList< VolatileProjector > sourceProjectors,
-				final ArrayList< SourceAndConverter< ? > > sources,
-				final ArrayList< ? extends RandomAccessible< ? extends ARGBType > > sourceScreenImages,
+		public VolatileProjector createProjector(
+				final List< VolatileProjector > sourceProjectors,
+				final List< SourceAndConverter< ? > > sources,
+				final List< ? extends RandomAccessible< ? extends ARGBType > > sourceScreenImages,
 				final RandomAccessibleInterval< ARGBType > targetScreenImage,
 				final int numThreads,
 				final ExecutorService executorService )
 		{
-			return new AccumulateProjectorARGB( sourceProjectors, sourceScreenImages, targetScreenImage, numThreads, executorService );
+			try
+			{
+				return new AccumulateProjectorARGB( sourceProjectors, sourceScreenImages, targetScreenImage, numThreads, executorService );
+			}
+			catch ( IllegalArgumentException ignored )
+			{}
+			return new AccumulateProjectorARGBGeneric( sourceProjectors, sourceScreenImages, targetScreenImage, numThreads, executorService );
 		}
 	};
 
+	public static class AccumulateProjectorARGBGeneric extends AccumulateProjector< ARGBType, ARGBType >
+	{
+		public AccumulateProjectorARGBGeneric(
+				final List< VolatileProjector > sourceProjectors,
+				final List< ? extends RandomAccessible< ? extends ARGBType > > sources,
+				final RandomAccessibleInterval< ARGBType > target,
+				final int numThreads,
+				final ExecutorService executorService )
+		{
+			super( sourceProjectors, sources, target, numThreads, executorService );
+		}
+
+		@Override
+		protected void accumulate( final Cursor< ? extends ARGBType >[] accesses, final ARGBType target )
+		{
+			int aSum = 0, rSum = 0, gSum = 0, bSum = 0;
+			for ( final Cursor< ? extends ARGBType > access : accesses )
+			{
+				final int value = access.get().get();
+				final int a = ARGBType.alpha( value );
+				final int r = ARGBType.red( value );
+				final int g = ARGBType.green( value );
+				final int b = ARGBType.blue( value );
+				aSum += a;
+				rSum += r;
+				gSum += g;
+				bSum += b;
+			}
+			if ( aSum > 255 )
+				aSum = 255;
+			if ( rSum > 255 )
+				rSum = 255;
+			if ( gSum > 255 )
+				gSum = 255;
+			if ( bSum > 255 )
+				bSum = 255;
+			target.set( ARGBType.rgba( rSum, gSum, bSum, aSum ) );
+		}
+	}
+
+	/**
+	 * Projectors that render the source images to accumulate.
+	 * For every rendering pass, ({@link VolatileProjector#map(boolean)}) is run on each source projector that is not yet {@link VolatileProjector#isValid() valid}.
+	 */
+	private final List< VolatileProjector > sourceProjectors;
+
+	/**
+	 * The source images to accumulate
+	 */
+	private final List< ? extends RandomAccessible< ? extends ARGBType > > sources;
+
+	private final int[][] sourceData;
+
+	/**
+	 * The target interval. Pixels of the target interval should be set by
+	 * {@link #map}
+	 */
+	private final RandomAccessibleInterval< ARGBType > target;
+
+	private final int[] targetData;
+
+	/**
+     * Number of threads to use for rendering
+     */
+    private final int numThreads;
+
+	private final ExecutorService executorService;
+
+    /**
+     * Time needed for rendering the last frame, in nano-seconds.
+     */
+    private long lastFrameRenderNanoTime;
+
+	private final AtomicBoolean canceled = new AtomicBoolean();
+
+	private volatile boolean valid = false;
+
 	public AccumulateProjectorARGB(
-			final ArrayList< VolatileProjector > sourceProjectors,
-			final ArrayList< ? extends RandomAccessible< ? extends ARGBType > > sources,
+			final List< VolatileProjector > sourceProjectors,
+			final List< ? extends RandomAccessible< ? extends ARGBType > > sources,
 			final RandomAccessibleInterval< ARGBType > target,
 			final int numThreads,
 			final ExecutorService executorService )
 	{
-		super( sourceProjectors, sources, target, numThreads, executorService );
+		this.sourceProjectors = sourceProjectors;
+		this.sources = sources;
+		this.target = target;
+		this.numThreads = numThreads;
+		this.executorService = executorService;
+		lastFrameRenderNanoTime = -1;
+
+		targetData = ProjectorUtils.getARGBArrayImgData( target );
+		if ( targetData == null )
+			throw new IllegalArgumentException();
+
+		final int numSources = sources.size();
+		sourceData = new int[ numSources ][];
+		for ( int i = 0; i < numSources; ++i )
+		{
+			final RandomAccessible< ? extends ARGBType > source = sources.get( i );
+			if ( ! ( source instanceof RandomAccessibleInterval ) )
+				throw new IllegalArgumentException();
+			if ( ! Intervals.equals( target, ( Interval ) source ) )
+				throw new IllegalArgumentException();
+			sourceData[ i ] = ProjectorUtils.getARGBArrayImgData( source );
+			if ( sourceData[ i ] == null )
+				throw new IllegalArgumentException();
+		}
 	}
 
 	@Override
-	protected void accumulate( final Cursor< ? extends ARGBType >[] accesses, final ARGBType target )
+	public boolean map( final boolean clearUntouchedTargetPixels )
+	{
+		if ( canceled.get() )
+			return false;
+
+		final StopWatch stopWatch = StopWatch.createAndStart();
+
+		valid = true;
+		for ( final VolatileProjector p : sourceProjectors )
+			if ( !p.isValid() )
+				if ( !p.map( clearUntouchedTargetPixels ) )
+					return false;
+				else
+					valid &= p.isValid();
+
+		final int size = ( int ) Intervals.numElements( target );
+
+		final int numTasks = numThreads <= 1 ? 1 : Math.min( numThreads * 10, size );
+		final double taskLength = ( double ) size / numTasks;
+		final int[] taskOffsets = new int[ numTasks + 1 ];
+		for ( int i = 0; i < numTasks; ++i )
+			taskOffsets[ i ] = ( int ) ( i * taskLength );
+		taskOffsets[ numTasks ] = size;
+
+		final boolean createExecutor = ( executorService == null );
+		final ExecutorService ex = createExecutor ? Executors.newFixedThreadPool( numThreads ) : executorService;
+
+		final List< Callable< Void > > tasks = new ArrayList<>( numTasks );
+		for( int i = 0; i < numTasks; ++i )
+			tasks.add( createMapTask( taskOffsets[ i ], taskOffsets[ i + 1 ] ) );
+		try
+		{
+			ex.invokeAll( tasks );
+		}
+		catch ( final InterruptedException e )
+		{
+			Thread.currentThread().interrupt();
+		}
+		if ( createExecutor )
+			ex.shutdown();
+
+		lastFrameRenderNanoTime = stopWatch.nanoTime();
+
+		return !canceled.get();
+	}
+
+	@Override
+	public void cancel()
+	{
+		canceled.set( true );
+		for ( final VolatileProjector p : sourceProjectors )
+			p.cancel();
+	}
+
+	@Override
+	public long getLastFrameRenderNanoTime()
+	{
+		return lastFrameRenderNanoTime;
+	}
+
+	@Override
+	public boolean isValid()
+	{
+		return valid;
+	}
+
+	/**
+	 * @return a {@code Callable} that runs {@code map(startOffset, endOffset)}
+	 */
+	private Callable< Void > createMapTask( final int startOffset, final int endOffset )
+	{
+		return Executors.callable( () -> map( startOffset, endOffset ), null );
+	}
+
+	/**
+	 * Accumulate pixels from {@code startOffset} up to {@code endOffset}
+	 * (exclusive) of all sources to target. Before starting, check
+	 * whether rendering was {@link #cancel() canceled}.
+	 *
+	 * @param startOffset
+	 *     pixel range start (flattened index)
+	 * @param endOffset
+	 *     pixel range end (exclusive, flattened index)
+	 */
+	private void map( final int startOffset, final int endOffset )
+	{
+		if ( canceled.get() )
+			return;
+
+		final int numSources = sources.size();
+		final int[] values = new int[ numSources ];
+		for ( int i = startOffset; i < endOffset; ++i )
+		{
+			for ( int s = 0; s < numSources; ++s )
+				values[ s ] = sourceData[ s ][ i ];
+			targetData[ i ] = accumulate( values );
+		}
+	}
+
+	protected int accumulate( final int[] values )
 	{
 		int aSum = 0, rSum = 0, gSum = 0, bSum = 0;
-		for ( final Cursor< ? extends ARGBType > access : accesses )
+		for ( final int value : values )
 		{
-			final int value = access.get().get();
 			final int a = ARGBType.alpha( value );
 			final int r = ARGBType.red( value );
 			final int g = ARGBType.green( value );
@@ -87,6 +298,6 @@ public class AccumulateProjectorARGB extends AccumulateProjector< ARGBType, ARGB
 			gSum = 255;
 		if ( bSum > 255 )
 			bSum = 255;
-		target.set( ARGBType.rgba( rSum, gSum, bSum, aSum ) );
+		return ARGBType.rgba( rSum, gSum, bSum, aSum );
 	}
 }
diff --git a/src/main/java/bdv/viewer/render/AccumulateProjectorFactory.java b/src/main/java/bdv/viewer/render/AccumulateProjectorFactory.java
index 4343f596c2b6c0cf240b69cc15c61344a6439d47..8a141730781be7eca6c1decbed8fa39c48fbb94d 100644
--- a/src/main/java/bdv/viewer/render/AccumulateProjectorFactory.java
+++ b/src/main/java/bdv/viewer/render/AccumulateProjectorFactory.java
@@ -30,6 +30,7 @@ package bdv.viewer.render;
 
 import bdv.viewer.SourceAndConverter;
 import java.util.ArrayList;
+import java.util.List;
 import java.util.concurrent.ExecutorService;
 
 import bdv.viewer.Source;
@@ -54,9 +55,9 @@ public interface AccumulateProjectorFactory< A >
 	 *            {@link ExecutorService} to use for rendering. may be null.
 	 */
 	default VolatileProjector createProjector(
-			final ArrayList< VolatileProjector > sourceProjectors,
-			final ArrayList< SourceAndConverter< ? > > sources,
-			final ArrayList< ? extends RandomAccessible< ? extends A > > sourceScreenImages,
+			final List< VolatileProjector > sourceProjectors,
+			final List< SourceAndConverter< ? > > sources,
+			final List< ? extends RandomAccessible< ? extends A > > sourceScreenImages,
 			final RandomAccessibleInterval< A > targetScreenImage,
 			final int numThreads,
 			final ExecutorService executorService )
@@ -64,11 +65,17 @@ public interface AccumulateProjectorFactory< A >
 		final ArrayList< Source< ? > > spimSources = new ArrayList<>();
 		for ( SourceAndConverter< ? > source : sources )
 			spimSources.add( source.getSpimSource() );
-		return createAccumulateProjector( sourceProjectors, spimSources, sourceScreenImages, targetScreenImage, numThreads, executorService );
+		final ArrayList< VolatileProjector > sp = sourceProjectors instanceof ArrayList
+				? ( ArrayList ) sourceProjectors
+				: new ArrayList<>( sourceProjectors );
+		final ArrayList< ? extends RandomAccessible< ? extends A > > si = sourceScreenImages instanceof ArrayList
+				? ( ArrayList ) sourceScreenImages
+				: new ArrayList<>( sourceScreenImages );
+		return createAccumulateProjector( sp, spimSources, si, targetScreenImage, numThreads, executorService );
 	}
 
 	/**
-	 * @deprecated Use {@link #createProjector(ArrayList, ArrayList, ArrayList, RandomAccessibleInterval, int, ExecutorService)} instead.
+	 * @deprecated Use {@link #createProjector(List, List, List, RandomAccessibleInterval, int, ExecutorService)} instead.
 	 *
 	 * @param sourceProjectors
 	 *            projectors that will be used to render {@code sources}.
diff --git a/src/main/java/bdv/viewer/render/EmptyProjector.java b/src/main/java/bdv/viewer/render/EmptyProjector.java
index 67d1e6edb14a75a9b92691607783acaf1a024d1a..6aee316765ad53c77f08748cea519dbbce9f987a 100644
--- a/src/main/java/bdv/viewer/render/EmptyProjector.java
+++ b/src/main/java/bdv/viewer/render/EmptyProjector.java
@@ -6,13 +6,13 @@
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- * 
+ *
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- * 
+ *
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -28,9 +28,15 @@
  */
 package bdv.viewer.render;
 
+import java.util.Arrays;
 import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.img.array.ArrayImg;
+import net.imglib2.img.basictypeaccess.array.IntArray;
+import net.imglib2.type.numeric.ARGBType;
 import net.imglib2.type.numeric.NumericType;
-import net.imglib2.ui.util.StopWatch;
+import net.imglib2.util.Intervals;
+import net.imglib2.util.StopWatch;
+import net.imglib2.util.Util;
 import net.imglib2.view.Views;
 
 public class EmptyProjector< T extends NumericType< T> > implements VolatileProjector
@@ -45,20 +51,24 @@ public class EmptyProjector< T extends NumericType< T> > implements VolatileProj
 		lastFrameRenderNanoTime = -1;
 	}
 
-	@Override
-	public boolean map()
-	{
-		return map( false );
-	}
-
 	@Override
 	public boolean map( final boolean clearUntouchedTargetPixels )
 	{
-		final StopWatch stopWatch = new StopWatch();
-		stopWatch.start();
+		final StopWatch stopWatch = StopWatch.createAndStart();
 		if ( clearUntouchedTargetPixels )
-			for ( final T t : Views.iterable( target ) )
-				t.setZero();
+		{
+			final int[] data = ProjectorUtils.getARGBArrayImgData( target );
+			if ( data != null )
+			{
+				final int size = ( int ) Intervals.numElements( target );
+				Arrays.fill( data, 0, size, 0 );
+			}
+			else
+			{
+				for ( final T t : Views.iterable( target ) )
+					t.setZero();
+			}
+		}
 		lastFrameRenderNanoTime = stopWatch.nanoTime();
 		return true;
 	}
diff --git a/src/main/java/bdv/viewer/render/MultiResolutionRenderer.java b/src/main/java/bdv/viewer/render/MultiResolutionRenderer.java
index b21777828de374527f353ee2eb442d4e104cbbd9..7e114fbd540a243ee60a58e18dc3d2432184c091 100644
--- a/src/main/java/bdv/viewer/render/MultiResolutionRenderer.java
+++ b/src/main/java/bdv/viewer/render/MultiResolutionRenderer.java
@@ -6,13 +6,13 @@
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- * 
+ *
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- * 
+ *
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -28,260 +28,218 @@
  */
 package bdv.viewer.render;
 
-import bdv.viewer.SourceAndConverter;
-import java.awt.image.BufferedImage;
-import java.util.ArrayDeque;
 import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
 import java.util.concurrent.ExecutorService;
 
-import bdv.cache.CacheControl;
-import bdv.img.cache.VolatileCachedCellImg;
-import bdv.viewer.Interpolation;
-import bdv.viewer.Source;
-import bdv.viewer.render.MipmapOrdering.Level;
-import bdv.viewer.render.MipmapOrdering.MipmapHints;
-import bdv.viewer.state.SourceState;
-import bdv.viewer.state.ViewerState;
-import net.imglib2.Dimensions;
-import net.imglib2.RandomAccess;
-import net.imglib2.RandomAccessible;
+import net.imglib2.Interval;
 import net.imglib2.RandomAccessibleInterval;
-import net.imglib2.RealRandomAccessible;
 import net.imglib2.Volatile;
 import net.imglib2.cache.iotiming.CacheIoTiming;
-import net.imglib2.cache.volatiles.CacheHints;
-import net.imglib2.cache.volatiles.LoadingStrategy;
-import net.imglib2.converter.Converter;
-import net.imglib2.display.screenimage.awt.ARGBScreenImage;
 import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.realtransform.RealViews;
 import net.imglib2.type.numeric.ARGBType;
-import net.imglib2.ui.PainterThread;
-import net.imglib2.ui.RenderTarget;
-import net.imglib2.ui.Renderer;
-import net.imglib2.ui.SimpleInterruptibleProjector;
-import net.imglib2.ui.TransformListener;
-import net.imglib2.ui.util.GuiUtil;
+import net.imglib2.util.Intervals;
+
+import bdv.cache.CacheControl;
+import bdv.util.MovingAverage;
+import bdv.viewer.RequestRepaint;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.ViewerState;
+import bdv.viewer.render.ScreenScales.IntervalRenderData;
+import bdv.viewer.render.ScreenScales.ScreenScale;
 
 /**
- * A {@link Renderer} that uses a coarse-to-fine rendering scheme. First, a
- * small {@link BufferedImage} at a fraction of the canvas resolution is
- * rendered. Then, increasingly larger images are rendered, until the full
- * canvas resolution is reached.
- * <p>
- * When drawing the low-resolution {@link BufferedImage} to the screen, they
- * will be scaled up by Java2D to the full canvas size, which is relatively
- * fast. Rendering the small, low-resolution images is usually very fast, such
- * that the display is very interactive while the user changes the viewing
- * transformation for example. When the transformation remains fixed for a
- * longer period, higher-resolution details are filled in successively.
+ * A renderer that uses a coarse-to-fine rendering scheme. First, a small target
+ * image at a fraction of the canvas resolution is rendered. Then, increasingly
+ * larger images are rendered, until the full canvas resolution is reached.
  * <p>
- * The renderer allocates a {@link BufferedImage} for each of a predefined set
- * of <em>screen scales</em> (a screen scale of 1 means that 1 pixel in the
- * screen image is displayed as 1 pixel on the canvas, a screen scale of 0.5
- * means 1 pixel in the screen image is displayed as 2 pixel on the canvas,
- * etc.)
+ * When drawing the low-resolution target images to the screen, they will be
+ * scaled up by Java2D (or JavaFX, etc) to the full canvas size, which is
+ * relatively fast. Rendering the small, low-resolution images is usually very
+ * fast, such that the display is very interactive while the user changes the
+ * viewing transformation for example. When the transformation remains fixed for
+ * a longer period, higher-resolution details are filled in successively.
  * <p>
- * At any time, one of these screen scales is selected as the
- * <em>highest screen scale</em>. Rendering starts with this highest screen
- * scale and then proceeds to lower screen scales (higher resolution images).
- * Unless the highest screen scale is currently rendering,
- * {@link #requestRepaint() repaint request} will cancel rendering, such that
- * display remains interactive.
+ * The renderer allocates a {@code RenderResult} for each of a predefined set of
+ * <em>screen scales</em> (a screen scale of 1 means that 1 pixel in the screen
+ * image is displayed as 1 pixel on the canvas, a screen scale of 0.5 means 1
+ * pixel in the screen image is displayed as 2 pixel on the canvas, etc.)
  * <p>
- * The renderer tries to maintain a per-frame rendering time close to a desired
- * number of <code>targetRenderNanos</code> nanoseconds. If the rendering time
- * (in nanoseconds) for the (currently) highest scaled screen image is above
- * this threshold, a coarser screen scale is chosen as the highest screen scale
- * to use. Similarly, if the rendering time for the (currently) second-highest
- * scaled screen image is below this threshold, this finer screen scale chosen
- * as the highest screen scale to use.
+ * At any time, one of these screen scales is selected as the <em>highest screen
+ * scale</em>. Rendering starts with this highest screen scale and then proceeds
+ * to lower screen scales (higher resolution images). Unless the highest screen
+ * scale is currently rendering, {@link #requestRepaint() repaint request} will
+ * cancel rendering, such that display remains interactive.
  * <p>
- * The renderer uses multiple threads (if desired) and double-buffering (if
- * desired).
+ * The renderer tries to maintain a per-frame rendering time close to
+ * {@code targetRenderNanos} nanoseconds. The current highest screen scale is
+ * chosen to match this time based on per-output-pixel time measured in previous
+ * frames.
  * <p>
- * Double buffering means that three {@link BufferedImage BufferedImages} are
- * created for every screen scale. After rendering the first one of them and
- * setting it to the {@link RenderTarget}, next time, rendering goes to the
- * second one, then to the third. The {@link RenderTarget} will always have a
- * complete image, which is not rendered to while it is potentially drawn to the
- * screen. When setting an image to the {@link RenderTarget}, the
- * {@link RenderTarget} will release one of the previously set images to be
- * rendered again. Thus, rendering will not interfere with painting the
- * {@link BufferedImage} to the canvas.
+ * The renderer uses multiple threads (if desired).
  * <p>
  * The renderer supports rendering of {@link Volatile} sources. In each
  * rendering pass, all currently valid data for the best fitting mipmap level
- * and all coarser levels is rendered to a {@link #renderImages temporary image}
- * for each visible source. Then the temporary images are combined to the final
- * image for display. The number of passes required until all data is valid
- * might differ between visible sources.
+ * and all coarser levels is rendered to a temporary image for each visible
+ * source. Then the temporary images are combined to the final image for
+ * display. The number of passes required until all data is valid might differ
+ * between visible sources.
  * <p>
- * Rendering timing is tied to a {@link CacheControl} control for IO budgeting, etc.
+ * Rendering timing is tied to a {@link CacheControl} control for IO budgeting,
+ * etc.
  *
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Tobias Pietzsch
  */
 public class MultiResolutionRenderer
 {
 	/**
-	 * Receiver for the {@link BufferedImage BufferedImages} that we render.
+	 * Receiver for the {@code BufferedImage BufferedImages} that we render.
 	 */
-	protected final TransformAwareRenderTarget display;
+	private final RenderTarget< ? > display;
 
 	/**
 	 * Thread that triggers repainting of the display.
 	 * Requests for repainting are send there.
 	 */
-	protected final PainterThread painterThread;
+	private final RequestRepaint painterThread;
 
 	/**
-	 * Currently active projector, used to re-paint the display. It maps the
-	 * source data to {@link #screenImages}.
+	 * Creates projectors for rendering current {@code ViewerState} to a
+	 * {@code screenImage}.
 	 */
-	protected VolatileProjector projector;
+	private final ProjectorFactory projectorFactory;
 
 	/**
-	 * The index of the screen scale of the {@link #projector current projector}.
+	 * Controls IO budgeting and fetcher queue.
 	 */
-	protected int currentScreenScaleIndex;
+	private final CacheControl cacheControl;
 
-	/**
-	 * Whether double buffering is used.
-	 */
-	protected final boolean doubleBuffered;
+	// TODO: should be settable
+	private final long[] iobudget = new long[] { 100l * 1000000l,  10l * 1000000l };
 
 	/**
-	 * Double-buffer index of next {@link #screenImages image} to render.
+	 * Maintains current sizes and transforms at every screen scale level.
+	 * Records interval rendering requests.
 	 */
-	protected final ArrayDeque< Integer > renderIdQueue;
+	private final ScreenScales screenScales;
 
 	/**
-	 * Maps from {@link BufferedImage} to double-buffer index.
-	 * Needed for double-buffering.
+	 * Maintains arrays for intermediate per-source render images and masks.
 	 */
-	protected final HashMap< BufferedImage, Integer > bufferedImageToRenderId;
+	private final RenderStorage renderStorage;
 
 	/**
-	 * Used to render an individual source. One image per screen resolution and
-	 * visible source. First index is screen scale, second index is index in
-	 * list of visible sources.
+	 * Estimate of the time it takes to render one screen pixel from one source,
+	 * in nanoseconds.
 	 */
-	protected ARGBScreenImage[][] renderImages;
+	private final MovingAverage renderNanosPerPixelAndSource;
 
 	/**
-	 * Storage for mask images of {@link VolatileHierarchyProjector}.
-	 * One array per visible source. (First) index is index in list of visible sources.
+	 * Currently active projector, used to re-paint the display. It maps the
+	 * source data to {@code ©screenImages}. {@code projector.cancel()} can be
+	 * used to cancel the ongoing rendering operation.
 	 */
-	protected byte[][] renderMaskArrays;
+	private VolatileProjector projector;
 
 	/**
-	 * Used to render the image for display. Three images per screen resolution
-	 * if double buffering is enabled. First index is screen scale, second index
-	 * is double-buffer.
+	 * Whether the current rendering operation may be cancelled (to start a new
+	 * one). Rendering may be cancelled unless we are rendering at the
+	 * (estimated) coarsest screen scale meeting the rendering time threshold.
 	 */
-	protected ARGBScreenImage[][] screenImages;
+	private boolean renderingMayBeCancelled;
 
 	/**
-	 * {@link BufferedImage}s wrapping the data in the {@link #screenImages}.
-	 * First index is screen scale, second index is double-buffer.
+	 * Snapshot of the ViewerState that is currently being rendered.
+	 * A new snapshot is taken in the first {@code paint()} pass after a (full frame) {@link #requestRepaint()}
 	 */
-	protected BufferedImage[][] bufferedImages;
+	private ViewerState currentViewerState;
 
 	/**
-	 * Scale factors from the {@link #display viewer canvas} to the
-	 * {@link #screenImages}.
-	 *
-	 * A scale factor of 1 means 1 pixel in the screen image is displayed as 1
-	 * pixel on the canvas, a scale factor of 0.5 means 1 pixel in the screen
-	 * image is displayed as 2 pixel on the canvas, etc.
+	 * The sources that are actually visible on screen currently. This means
+	 * that the sources both are visible in the {@link #currentViewerState} (via
+	 * {@link ViewerState#getVisibleAndPresentSources()
+	 * getVisibleAndPresentSources}) and, when transformed to viewer
+	 * coordinates, overlap the screen area ({@link #display}).
 	 */
-	protected final double[] screenScales;
+	private final List< SourceAndConverter< ? > > currentVisibleSourcesOnScreen;
+
 
 	/**
-	 * The scale transformation from viewer to {@link #screenImages screen
-	 * image}. Each transformations corresponds to a {@link #screenScales screen
-	 * scale}.
+	 * The last successfully rendered (not cancelled) full frame result.
+	 * This result is by full frame refinement passes and/or interval rendering passes.
 	 */
-	protected AffineTransform3D[] screenScaleTransforms;
+	private RenderResult currentRenderResult;
 
 	/**
-	 * If the rendering time (in nanoseconds) for the (currently) highest scaled
-	 * screen image is above this threshold, increase the
-	 * {@link #maxScreenScaleIndex index} of the highest screen scale to use.
-	 * Similarly, if the rendering time for the (currently) second-highest
-	 * scaled screen image is below this threshold, decrease the
-	 * {@link #maxScreenScaleIndex index} of the highest screen scale to use.
+	 * If {@code true}, then we are painting intervals currently.
+	 * If {@code false}, then we are painting full frames.
 	 */
-	protected final long targetRenderNanos;
+	private boolean intervalMode;
 
-	/**
-	 * The index of the (coarsest) screen scale with which to start rendering.
-	 * Once this level is painted, rendering proceeds to lower screen scales
-	 * until index 0 (full resolution) has been reached. While rendering, the
-	 * maxScreenScaleIndex is adapted such that it is the highest index for
-	 * which rendering in {@link #targetRenderNanos} nanoseconds is still
-	 * possible.
+	/*
+	 *
+	 * === FULL FRAME RENDERING ===
+	 *
 	 */
-	protected int maxScreenScaleIndex;
 
 	/**
-	 * The index of the screen scale which should be rendered next.
+	 * Screen scale of the last successful (not cancelled) rendering pass in
+	 * full-frame mode.
 	 */
-	protected int requestedScreenScaleIndex;
+	private int currentScreenScaleIndex;
 
 	/**
-	 * Whether the current rendering operation may be cancelled (to start a
-	 * new one). Rendering may be cancelled unless we are rendering at
-	 * coarsest screen scale and coarsest mipmap level.
+	 * The index of the screen scale which should be rendered next in full-frame
+	 * mode.
 	 */
-	protected volatile boolean renderingMayBeCancelled;
+	private int requestedScreenScaleIndex;
 
 	/**
-	 * How many threads to use for rendering.
+	 * Whether a full frame repaint was {@link #requestRepaint() requested}.
+	 * Supersedes {@link #newIntervalRequest}.
 	 */
-	protected final int numRenderingThreads;
+	private boolean newFrameRequest;
 
-	/**
-	 * {@link ExecutorService} used for rendering.
+	/*
+	 *
+	 * === INTERVAL RENDERING ===
+	 *
 	 */
-	protected final ExecutorService renderingExecutorService;
 
 	/**
-	 * TODO
+	 * Screen scale of the last successful (not cancelled) rendering pass in
+	 * interval mode.
 	 */
-	protected final AccumulateProjectorFactory< ARGBType > accumulateProjectorFactory;
+	private int currentIntervalScaleIndex;
 
 	/**
-	 * Controls IO budgeting and fetcher queue.
+	 * The index of the screen scale which should be rendered next in interval
+	 * mode.
 	 */
-	protected final CacheControl cacheControl;
+	private int requestedIntervalScaleIndex;
 
 	/**
-	 * Whether volatile versions of sources should be used if available.
+	 * Whether repainting of an interval was {@link #requestRepaint(Interval)
+	 * requested}. The union of all pending intervals are recorded in
+	 * {@link #screenScales}. Pending interval requests are obsoleted by full
+	 * frame repaints requests ({@link #newFrameRequest}).
 	 */
-	protected final boolean useVolatileIfAvailable;
+	private boolean newIntervalRequest;
 
 	/**
-	 * Whether a repaint was {@link #requestRepaint() requested}. This will
-	 * cause {@link CacheControl#prepareNextFrame()}.
+	 * Re-used for all interval rendering.
 	 */
-	protected boolean newFrameRequest;
+	private final RenderResult intervalResult;
 
 	/**
-	 * The timepoint for which last a projector was
-	 * {@link #createProjector(ViewerState, ARGBScreenImage) created}.
+	 * Currently rendering interval. This is pulled from {@link #screenScales}
+	 * at the start of {@code paint()}, which clears requested intervals at
+	 * {@link #currentIntervalScaleIndex} or coarser. (This ensures that new
+	 * interval requests arriving during rendering are not missed. If the
+	 * requested intervals would be cleared after rendering, this might happen.
+	 * Instead we re-request the pulled intervals, if rendering fails.)
 	 */
-	protected int previousTimepoint;
-
-	// TODO: should be settable
-	protected long[] iobudget = new long[] { 100l * 1000000l,  10l * 1000000l };
-
-	// TODO: should be settable
-	protected boolean prefetchCells = true;
+	private IntervalRenderData intervalRenderData;
 
 	/**
 	 * @param display
@@ -289,7 +247,7 @@ public class MultiResolutionRenderer
 	 * @param painterThread
 	 *            Thread that triggers repainting of the display. Requests for
 	 *            repainting are send there.
-	 * @param screenScales
+	 * @param screenScaleFactors
 	 *            Scale factors from the viewer canvas to screen images of
 	 *            different resolutions. A scale factor of 1 means 1 pixel in
 	 *            the screen image is displayed as 1 pixel on the canvas, a
@@ -298,8 +256,6 @@ public class MultiResolutionRenderer
 	 * @param targetRenderNanos
 	 *            Target rendering time in nanoseconds. The rendering time for
 	 *            the coarsest rendered scale should be below this threshold.
-	 * @param doubleBuffered
-	 *            Whether to use double buffered rendering.
 	 * @param numRenderingThreads
 	 *            How many threads to use for rendering.
 	 * @param renderingExecutorService
@@ -316,213 +272,247 @@ public class MultiResolutionRenderer
 	 *            the cache controls IO budgeting and fetcher queue.
 	 */
 	public MultiResolutionRenderer(
-			final RenderTarget display,
-			final PainterThread painterThread,
-			final double[] screenScales,
+			final RenderTarget< ? > display,
+			final RequestRepaint painterThread,
+			final double[] screenScaleFactors,
 			final long targetRenderNanos,
-			final boolean doubleBuffered,
 			final int numRenderingThreads,
 			final ExecutorService renderingExecutorService,
 			final boolean useVolatileIfAvailable,
 			final AccumulateProjectorFactory< ARGBType > accumulateProjectorFactory,
 			final CacheControl cacheControl )
 	{
-		this.display = wrapTransformAwareRenderTarget( display );
+		this.display = display;
 		this.painterThread = painterThread;
 		projector = null;
 		currentScreenScaleIndex = -1;
-		this.screenScales = screenScales.clone();
-		this.doubleBuffered = doubleBuffered;
-		renderIdQueue = new ArrayDeque<>();
-		bufferedImageToRenderId = new HashMap<>();
-		renderImages = new ARGBScreenImage[ screenScales.length ][ 0 ];
-		renderMaskArrays = new byte[ 0 ][];
-		screenImages = new ARGBScreenImage[ screenScales.length ][ 3 ];
-		bufferedImages = new BufferedImage[ screenScales.length ][ 3 ];
-		screenScaleTransforms = new AffineTransform3D[ screenScales.length ];
-
-		this.targetRenderNanos = targetRenderNanos;
-
-		maxScreenScaleIndex = screenScales.length - 1;
-		requestedScreenScaleIndex = maxScreenScaleIndex;
-		renderingMayBeCancelled = true;
-		this.numRenderingThreads = numRenderingThreads;
-		this.renderingExecutorService = renderingExecutorService;
-		this.useVolatileIfAvailable = useVolatileIfAvailable;
-		this.accumulateProjectorFactory = accumulateProjectorFactory;
+		currentVisibleSourcesOnScreen = new ArrayList<>();
+		screenScales = new ScreenScales( screenScaleFactors, targetRenderNanos );
+		renderStorage = new RenderStorage();
+
+		renderNanosPerPixelAndSource = new MovingAverage( 3 );
+		renderNanosPerPixelAndSource.init( 500 );
+
+		requestedScreenScaleIndex = screenScales.size() - 1;
+		renderingMayBeCancelled = false;
 		this.cacheControl = cacheControl;
 		newFrameRequest = false;
-		previousTimepoint = -1;
+
+		intervalResult = display.createRenderResult();
+
+		projectorFactory = new ProjectorFactory(
+				numRenderingThreads,
+				renderingExecutorService,
+				useVolatileIfAvailable,
+				accumulateProjectorFactory );
 	}
 
 	/**
-	 * Check whether the size of the display component was changed and
-	 * recreate {@link #screenImages} and {@link #screenScaleTransforms} accordingly.
-	 *
-	 * @return whether the size was changed.
+	 * Request a repaint of the display from the painter thread. The painter
+	 * thread will trigger a {@link #paint} as soon as possible (that is,
+	 * immediately or after the currently running {@link #paint} has completed).
 	 */
-	protected synchronized boolean checkResize()
+	public synchronized void requestRepaint()
 	{
-		final int componentW = display.getWidth();
-		final int componentH = display.getHeight();
-		if ( screenImages[ 0 ][ 0 ] == null || screenImages[ 0 ][ 0 ].dimension( 0 ) != ( int ) ( componentW * screenScales[ 0 ] ) || screenImages[ 0 ][ 0 ].dimension( 1 ) != ( int ) ( componentH  * screenScales[ 0 ] ) )
-		{
-			renderIdQueue.clear();
-			renderIdQueue.addAll( Arrays.asList( 0, 1, 2 ) );
-			bufferedImageToRenderId.clear();
-			for ( int i = 0; i < screenScales.length; ++i )
-			{
-				final double screenToViewerScale = screenScales[ i ];
-				final int w = ( int ) ( screenToViewerScale * componentW );
-				final int h = ( int ) ( screenToViewerScale * componentH );
-				if ( doubleBuffered )
-				{
-					for ( int b = 0; b < 3; ++b )
-					{
-						// reuse storage arrays of level 0 (highest resolution)
-						screenImages[ i ][ b ] = ( i == 0 ) ?
-								new ARGBScreenImage( w, h ) :
-								new ARGBScreenImage( w, h, screenImages[ 0 ][ b ].getData() );
-						final BufferedImage bi = GuiUtil.getBufferedImage( screenImages[ i ][ b ] );
-						bufferedImages[ i ][ b ] = bi;
-						bufferedImageToRenderId.put( bi, b );
-					}
-				}
-				else
-				{
-					screenImages[ i ][ 0 ] = new ARGBScreenImage( w, h );
-					bufferedImages[ i ][ 0 ] = GuiUtil.getBufferedImage( screenImages[ i ][ 0 ] );
-				}
-				final AffineTransform3D scale = new AffineTransform3D();
-				final double xScale = ( double ) w / componentW;
-				final double yScale = ( double ) h / componentH;
-				scale.set( xScale, 0, 0 );
-				scale.set( yScale, 1, 1 );
-				scale.set( 0.5 * xScale - 0.5, 0, 3 );
-				scale.set( 0.5 * yScale - 0.5, 1, 3 );
-				screenScaleTransforms[ i ] = scale;
-			}
-
-			return true;
-		}
-		return false;
+		if ( renderingMayBeCancelled && projector != null )
+			projector.cancel();
+		newFrameRequest = true;
+		painterThread.requestRepaint();
 	}
 
-	protected boolean checkRenewRenderImages( final int numVisibleSources )
+	/**
+	 * Request a repaint of the given {@code interval} of the display from the
+	 * painter thread. The painter thread will trigger a {@link #paint} as soon
+	 * as possible (that is, immediately or after the currently running
+	 * {@link #paint} has completed).
+	 */
+	public synchronized void requestRepaint( final Interval interval )
 	{
-		final int n = numVisibleSources > 1 ? numVisibleSources : 0;
-		if ( n != renderImages[ 0 ].length ||
-				( n != 0 &&
-					( renderImages[ 0 ][ 0 ].dimension( 0 ) != screenImages[ 0 ][ 0 ].dimension( 0 ) ||
-					  renderImages[ 0 ][ 0 ].dimension( 1 ) != screenImages[ 0 ][ 0 ].dimension( 1 ) ) ) )
+		if ( !intervalMode && !renderingMayBeCancelled )
 		{
-			renderImages = new ARGBScreenImage[ screenScales.length ][ n ];
-			for ( int i = 0; i < screenScales.length; ++i )
-			{
-				final int w = ( int ) screenImages[ i ][ 0 ].dimension( 0 );
-				final int h = ( int ) screenImages[ i ][ 0 ].dimension( 1 );
-				for ( int j = 0; j < n; ++j )
-				{
-					renderImages[ i ][ j ] = ( i == 0 ) ?
-						new ARGBScreenImage( w, h ) :
-						new ARGBScreenImage( w, h, renderImages[ 0 ][ j ].getData() );
-				}
-			}
-			return true;
+			/*
+			 * We are currently rendering a full frame at the coarsest
+			 * resolution. There is no point in painting an interval now. Just
+			 * request a new full frame.
+			 */
+			newFrameRequest = true;
 		}
-		return false;
-	}
-
-	protected boolean checkRenewMaskArrays( final int numVisibleSources )
-	{
-		if ( numVisibleSources != renderMaskArrays.length ||
-				( numVisibleSources != 0 &&	( renderMaskArrays[ 0 ].length < screenImages[ 0 ][ 0 ].size() ) ) )
+		else
 		{
-			final int size = ( int ) screenImages[ 0 ][ 0 ].size();
-			renderMaskArrays = new byte[ numVisibleSources ][];
-			for ( int j = 0; j < numVisibleSources; ++j )
-				renderMaskArrays[ j ] = new byte[ size ];
-			return true;
+			if ( renderingMayBeCancelled && projector != null )
+				projector.cancel();
+			screenScales.requestInterval( interval );
+			newIntervalRequest = true;
 		}
-		return false;
+		painterThread.requestRepaint();
 	}
 
-	protected final AffineTransform3D currentProjectorTransform = new AffineTransform3D();
+	/**
+	 * DON'T USE THIS.
+	 * <p>
+	 * This is a work around for JDK bug
+	 * https://bugs.openjdk.java.net/browse/JDK-8029147 which leads to
+	 * ViewerPanel not being garbage-collected when ViewerFrame is closed. So
+	 * instead we need to manually let go of resources...
+	 */
+	public void kill()
+	{
+		projector = null;
+		currentViewerState = null;
+		currentRenderResult = null;
+		currentVisibleSourcesOnScreen.clear();
+		renderStorage.clear();
+	}
 
 	/**
 	 * Render image at the {@link #requestedScreenScaleIndex requested screen
 	 * scale}.
 	 */
-	public boolean paint( final ViewerState state )
+	public boolean paint( final ViewerState viewerState )
 	{
-		if ( display.getWidth() <= 0 || display.getHeight() <= 0 )
+		final int screenW = display.getWidth();
+		final int screenH = display.getHeight();
+		if ( screenW <= 0 || screenH <= 0 )
 			return false;
 
-		final boolean resized = checkResize();
-
-		// the BufferedImage that is rendered to (to paint to the canvas)
-		final BufferedImage bufferedImage;
-
-		final ARGBScreenImage screenImage;
-
-		final boolean clearQueue;
-
+		final boolean newFrame;
+		final boolean newInterval;
+		final boolean prepareNextFrame;
 		final boolean createProjector;
-
 		synchronized ( this )
 		{
-			// Rendering may be cancelled unless we are rendering at coarsest
-			// screen scale and coarsest mipmap level.
-			renderingMayBeCancelled = ( requestedScreenScaleIndex < maxScreenScaleIndex );
-
-			clearQueue = newFrameRequest;
-			if ( clearQueue )
-				cacheControl.prepareNextFrame();
-			createProjector = newFrameRequest || resized || ( requestedScreenScaleIndex != currentScreenScaleIndex );
-			newFrameRequest = false;
+			final boolean resized = screenScales.checkResize( screenW, screenH );
 
-			if ( createProjector )
+			newFrame = newFrameRequest || resized;
+			if ( newFrame )
 			{
-				final int renderId = renderIdQueue.peek();
-				currentScreenScaleIndex = requestedScreenScaleIndex;
-				bufferedImage = bufferedImages[ currentScreenScaleIndex ][ renderId ];
-				screenImage = screenImages[ currentScreenScaleIndex ][ renderId ];
+				intervalMode = false;
+				screenScales.clearRequestedIntervals();
 			}
-			else
+
+			newInterval = newIntervalRequest && !newFrame;
+			if ( newInterval )
 			{
-				bufferedImage = null;
-				screenImage = null;
+				intervalMode = true;
+				final int numSources = currentVisibleSourcesOnScreen.size();
+				final double renderNanosPerPixel = renderNanosPerPixelAndSource.getAverage() * numSources;
+				requestedIntervalScaleIndex = screenScales.suggestIntervalScreenScale( renderNanosPerPixel, currentScreenScaleIndex );
+			}
+
+			prepareNextFrame = newFrame || newInterval;
+			renderingMayBeCancelled = !prepareNextFrame;
+
+			if ( intervalMode )
+			{
+				createProjector = newInterval || ( requestedIntervalScaleIndex != currentIntervalScaleIndex );
+				if ( createProjector )
+					intervalRenderData = screenScales.pullIntervalRenderData( requestedIntervalScaleIndex, currentScreenScaleIndex );
 			}
+			else
+				createProjector = newFrame || ( requestedScreenScaleIndex != currentScreenScaleIndex );
+
+			newFrameRequest = false;
+			newIntervalRequest = false;
+		}
 
-			requestedScreenScaleIndex = 0;
+		if ( prepareNextFrame )
+			cacheControl.prepareNextFrame();
+
+		if ( newFrame )
+		{
+			currentViewerState = viewerState.snapshot();
+			VisibilityUtils.computeVisibleSourcesOnScreen( currentViewerState, screenScales.get( 0 ), currentVisibleSourcesOnScreen );
+			final int numSources = currentVisibleSourcesOnScreen.size();
+			final double renderNanosPerPixel = renderNanosPerPixelAndSource.getAverage() * numSources;
+			requestedScreenScaleIndex = screenScales.suggestScreenScale( renderNanosPerPixel );
 		}
 
+		if ( !intervalMode && requestedScreenScaleIndex < 0 )
+			return true;
+
+		return intervalMode
+				? paintInterval( createProjector )
+				: paintFullFrame( createProjector );
+	}
+
+	private boolean paintFullFrame( final boolean createProjector )
+	{
 		// the projector that paints to the screenImage.
 		final VolatileProjector p;
 
-		if ( createProjector )
+		// holds new RenderResult, in case that a new projector is created in full frame mode
+		RenderResult renderResult = null;
+
+		// whether to request a newFrame, in case that a new projector is created in full frame mode
+		boolean requestNewFrameIfIncomplete = false;
+
+		synchronized ( this )
 		{
-			synchronized ( state.getState() )
+			if ( createProjector )
 			{
-				final int numVisibleSources = state.getVisibleSourceIndices().size();
-				checkRenewRenderImages( numVisibleSources );
-				checkRenewMaskArrays( numVisibleSources );
-				p = createProjector( state, screenImage );
+				final ScreenScale screenScale = screenScales.get( requestedScreenScaleIndex );
+
+				renderResult = display.getReusableRenderResult();
+				renderResult.init( screenScale.width(), screenScale.height() );
+				renderResult.setScaleFactor( screenScale.scale() );
+				currentViewerState.getViewerTransform( renderResult.getViewerTransform() );
+
+				renderStorage.checkRenewData( screenScales.get( 0 ).width(), screenScales.get( 0 ).height(), currentVisibleSourcesOnScreen.size() );
+				projector = createProjector( currentViewerState, currentVisibleSourcesOnScreen, requestedScreenScaleIndex, renderResult.getTargetImage(), 0, 0 );
+				requestNewFrameIfIncomplete = projectorFactory.requestNewFrameIfIncomplete();
 			}
-			synchronized ( this )
+			p = projector;
+		}
+
+		// try rendering
+		final boolean success = p.map( createProjector );
+		final long rendertime = p.getLastFrameRenderNanoTime();
+
+		synchronized ( this )
+		{
+			// if rendering was not cancelled...
+			if ( success )
 			{
-				projector = p;
+				currentScreenScaleIndex = requestedScreenScaleIndex;
+				if ( createProjector )
+				{
+					renderResult.setUpdated();
+					( ( RenderTarget ) display ).setRenderResult( renderResult );
+					currentRenderResult = renderResult;
+					recordRenderTime( renderResult, rendertime );
+				}
+				else
+					currentRenderResult.setUpdated();
+
+				if ( !p.isValid() && requestNewFrameIfIncomplete )
+					requestRepaint();
+				else if ( p.isValid() && currentScreenScaleIndex == 0 )
+					// indicate that rendering is complete
+					requestedScreenScaleIndex = -1;
+				else
+					iterateRepaint( Math.max( 0, currentScreenScaleIndex - 1 ) );
 			}
 		}
-		else
+
+		return success;
+	}
+
+	private boolean paintInterval( final boolean createProjector )
+	{
+		// the projector that paints to the screenImage.
+		final VolatileProjector p;
+
+		synchronized ( this )
 		{
-			synchronized ( this )
+			if ( createProjector )
 			{
-				p = projector;
+				intervalResult.init( intervalRenderData.width(), intervalRenderData.height() );
+				intervalResult.setScaleFactor( intervalRenderData.scale() );
+				projector = createProjector( currentViewerState, currentVisibleSourcesOnScreen, requestedIntervalScaleIndex, intervalResult.getTargetImage(), intervalRenderData.offsetX(), intervalRenderData.offsetY() );
 			}
+			p = projector;
 		}
 
-
 		// try rendering
 		final boolean success = p.map( createProjector );
 		final long rendertime = p.getLastFrameRenderNanoTime();
@@ -532,357 +522,112 @@ public class MultiResolutionRenderer
 			// if rendering was not cancelled...
 			if ( success )
 			{
-				if ( createProjector )
-				{
-					final BufferedImage bi = display.setBufferedImageAndTransform( bufferedImage, currentProjectorTransform );
-					if ( doubleBuffered )
-					{
-						renderIdQueue.pop();
-						final Integer id = bufferedImageToRenderId.get( bi );
-						if ( id != null )
-							renderIdQueue.add( id );
-					}
+				currentIntervalScaleIndex = requestedIntervalScaleIndex;
+				currentRenderResult.patch( intervalResult, intervalRenderData.targetInterval(), intervalRenderData.tx(), intervalRenderData.ty() );
 
-					if ( currentScreenScaleIndex == maxScreenScaleIndex )
-					{
-						if ( rendertime > targetRenderNanos && maxScreenScaleIndex < screenScales.length - 1 )
-							maxScreenScaleIndex++;
-						else if ( rendertime < targetRenderNanos / 3 && maxScreenScaleIndex > 0 )
-							maxScreenScaleIndex--;
-					}
-					else if ( currentScreenScaleIndex == maxScreenScaleIndex - 1 )
-					{
-						if ( rendertime < targetRenderNanos && maxScreenScaleIndex > 0 )
-							maxScreenScaleIndex--;
-					}
-//					System.out.println( String.format( "rendering:%4d ms", rendertime / 1000000 ) );
-//					System.out.println( "scale = " + currentScreenScaleIndex );
-//					System.out.println( "maxScreenScaleIndex = " + maxScreenScaleIndex + "  (" + screenImages[ maxScreenScaleIndex ][ 0 ].dimension( 0 ) + " x " + screenImages[ maxScreenScaleIndex ][ 0 ].dimension( 1 ) + ")" );
-				}
+				if ( createProjector )
+					recordRenderTime( intervalResult, rendertime );
 
-				if ( currentScreenScaleIndex > 0 )
-					requestRepaint( currentScreenScaleIndex - 1 );
-				else if ( !p.isValid() )
+				if ( currentIntervalScaleIndex > currentScreenScaleIndex )
+					iterateRepaintInterval( currentIntervalScaleIndex - 1 );
+				else if ( p.isValid() )
 				{
-					try
-					{
-						Thread.sleep( 1 );
-					}
-					catch ( final InterruptedException e )
+					// if full frame rendering was not yet complete
+					if ( requestedScreenScaleIndex >= 0 )
 					{
-						// restore interrupted state
-						Thread.currentThread().interrupt();
+						// go back to full frame rendering
+						intervalMode = false;
+						if ( requestedScreenScaleIndex == currentScreenScaleIndex )
+							++currentScreenScaleIndex;
+						painterThread.requestRepaint();
 					}
-					requestRepaint( currentScreenScaleIndex );
 				}
+				else
+					iterateRepaintInterval( currentIntervalScaleIndex );
 			}
+			// if rendering was cancelled...
+			else
+				intervalRenderData.reRequest();
 		}
 
 		return success;
 	}
 
-	/**
-	 * Request a repaint of the display from the painter thread, with maximum
-	 * screen scale index and mipmap level.
-	 */
-	public synchronized void requestRepaint()
+	private void recordRenderTime( final RenderResult result, final long renderNanos )
 	{
-		newFrameRequest = true;
-		requestRepaint( maxScreenScaleIndex );
+		final int numSources = currentVisibleSourcesOnScreen.size();
+		final int numRenderPixels = ( int ) Intervals.numElements( result.getTargetImage() ) * numSources;
+		if ( numRenderPixels >= 4096 )
+			renderNanosPerPixelAndSource.add( renderNanos / ( double ) numRenderPixels );
 	}
 
 	/**
-	 * Request a repaint of the display from the painter thread. The painter
-	 * thread will trigger a {@link #paint(ViewerState)} as soon as possible (that is,
-	 * immediately or after the currently running {@link #paint(ViewerState)} has
-	 * completed).
+	 * Request iterated repaint at the specified {@code screenScaleIndex}. This
+	 * is used to repaint the {@code currentViewerState} in a loop, until
+	 * everything is painted at highest resolution from valid data (or until
+	 * painting is interrupted by a new request}.
 	 */
-	public synchronized void requestRepaint( final int screenScaleIndex )
+	private void iterateRepaint( final int screenScaleIndex )
 	{
-		if ( renderingMayBeCancelled && projector != null )
-			projector.cancel();
-		if ( screenScaleIndex > requestedScreenScaleIndex )
-			requestedScreenScaleIndex = screenScaleIndex;
+		if ( screenScaleIndex == currentScreenScaleIndex )
+			usleep();
+		requestedScreenScaleIndex = screenScaleIndex;
 		painterThread.requestRepaint();
 	}
 
 	/**
-	 * DON'T USE THIS.
-	 * <p>
-	 * This is a work around for JDK bug
-	 * https://bugs.openjdk.java.net/browse/JDK-8029147 which leads to
-	 * ViewerPanel not being garbage-collected when ViewerFrame is closed. So
-	 * instead we need to manually let go of resources...
+	 * Request iterated repaint at the specified {@code intervalScaleIndex}.
+	 * This is used to repaint the current interval in a loop, until everything
+	 * is painted at highest resolution from valid data (or until painting is
+	 * interrupted by a new request}.
 	 */
-	public void kill()
+	private void iterateRepaintInterval( final int intervalScaleIndex )
 	{
-		if ( display instanceof TransformAwareBufferedImageOverlayRenderer )
-			( ( TransformAwareBufferedImageOverlayRenderer ) display ).kill();
-		projector = null;
-		renderIdQueue.clear();
-		bufferedImageToRenderId.clear();
-		for ( int i = 0; i < renderImages.length; ++i )
-			renderImages[ i ] = null;
-		for ( int i = 0; i < renderMaskArrays.length; ++i )
-			renderMaskArrays[ i ] = null;
-		for ( int i = 0; i < screenImages.length; ++i )
-			screenImages[ i ] = null;
-		for ( int i = 0; i < bufferedImages.length; ++i )
-			bufferedImages[ i ] = null;
-	}
-
-	private VolatileProjector createProjector(
-			final ViewerState viewerState,
-			final ARGBScreenImage screenImage )
-	{
-		/*
-		 * This shouldn't be necessary, with
-		 * CacheHints.LoadingStrategy==VOLATILE
-		 */
-//		CacheIoTiming.getIoTimeBudget().clear(); // clear time budget such that prefetching doesn't wait for loading blocks.
-		final List< SourceState< ? > > sourceStates = viewerState.getSources();
-		final List< Integer > visibleSourceIndices = viewerState.getVisibleSourceIndices();
-		VolatileProjector projector;
-		if ( visibleSourceIndices.isEmpty() )
-			projector = new EmptyProjector<>( screenImage );
-		else if ( visibleSourceIndices.size() == 1 )
-		{
-			final int i = visibleSourceIndices.get( 0 );
-			projector = createSingleSourceProjector( viewerState, sourceStates.get( i ), i, currentScreenScaleIndex, screenImage, renderMaskArrays[ 0 ] );
-		}
-		else
+		if ( intervalScaleIndex == currentIntervalScaleIndex )
 		{
-			final ArrayList< VolatileProjector > sourceProjectors = new ArrayList<>();
-			final ArrayList< ARGBScreenImage > sourceImages = new ArrayList<>();
-			final ArrayList< SourceAndConverter< ? > > sources = new ArrayList<>();
-			int j = 0;
-			for ( final int i : visibleSourceIndices )
-			{
-				final ARGBScreenImage renderImage = renderImages[ currentScreenScaleIndex ][ j ];
-				final byte[] maskArray = renderMaskArrays[ j ];
-				++j;
-				final VolatileProjector p = createSingleSourceProjector(
-						viewerState, sourceStates.get( i ), i, currentScreenScaleIndex,
-						renderImage, maskArray );
-				sourceProjectors.add( p );
-				sources.add( sourceStates.get( i ).getHandle() );
-				sourceImages.add( renderImage );
-			}
-			projector = accumulateProjectorFactory.createProjector( sourceProjectors, sources, sourceImages, screenImage, numRenderingThreads, renderingExecutorService );
+			intervalRenderData.reRequest();
+			usleep();
 		}
-		previousTimepoint = viewerState.getCurrentTimepoint();
-		viewerState.getViewerTransform( currentProjectorTransform );
-		CacheIoTiming.getIoTimeBudget().reset( iobudget );
-		return projector;
+		requestedIntervalScaleIndex = intervalScaleIndex;
+		painterThread.requestRepaint();
 	}
 
-	private static class SimpleVolatileProjector< A, B > extends SimpleInterruptibleProjector< A, B > implements VolatileProjector
+	/**
+	 * Wait for 1ms so that fetcher threads get a chance to do work.
+	 */
+	private void usleep()
 	{
-		private boolean valid = false;
-
-		public SimpleVolatileProjector(
-				final RandomAccessible< A > source,
-				final Converter< ? super A, B > converter,
-				final RandomAccessibleInterval< B > target,
-				final int numThreads,
-				final ExecutorService executorService )
+		try
 		{
-			super( source, converter, target, numThreads, executorService );
+			Thread.sleep( 1 );
 		}
-
-		@Override
-		public boolean map( final boolean clearUntouchedTargetPixels )
+		catch ( final InterruptedException e )
 		{
-			final boolean success = super.map();
-			valid |= success;
-			return success;
+			// restore interrupted state
+			Thread.currentThread().interrupt();
 		}
-
-		@Override
-		public boolean isValid()
-		{
-			return valid;
-		}
-	}
-
-	private < T > VolatileProjector createSingleSourceProjector(
-			final ViewerState viewerState,
-			final SourceState< T > source,
-			final int sourceIndex,
-			final int screenScaleIndex,
-			final ARGBScreenImage screenImage,
-			final byte[] maskArray )
-	{
-		if ( useVolatileIfAvailable )
-		{
-			if ( source.asVolatile() != null )
-				return createSingleSourceVolatileProjector( viewerState, source.asVolatile(), sourceIndex, screenScaleIndex, screenImage, maskArray );
-			else if ( source.getSpimSource().getType() instanceof Volatile )
-			{
-				@SuppressWarnings( "unchecked" )
-				final SourceState< ? extends Volatile< ? > > vsource = ( SourceState< ? extends Volatile< ? > > ) source;
-				return createSingleSourceVolatileProjector( viewerState, vsource, sourceIndex, screenScaleIndex, screenImage, maskArray );
-			}
-		}
-
-		final AffineTransform3D screenScaleTransform = screenScaleTransforms[ currentScreenScaleIndex ];
-		final int bestLevel = viewerState.getBestMipMapLevel( screenScaleTransform, sourceIndex );
-		return new SimpleVolatileProjector<>(
-				getTransformedSource( viewerState, source.getSpimSource(), screenScaleTransform, bestLevel, null ),
-				source.getConverter(), screenImage, numRenderingThreads, renderingExecutorService );
 	}
 
-	private < T extends Volatile< ? > > VolatileProjector createSingleSourceVolatileProjector(
+	private VolatileProjector createProjector(
 			final ViewerState viewerState,
-			final SourceState< T > source,
-			final int sourceIndex,
+			final List< SourceAndConverter< ? > > visibleSourcesOnScreen,
 			final int screenScaleIndex,
-			final ARGBScreenImage screenImage,
-			final byte[] maskArray )
+			final RandomAccessibleInterval< ARGBType > screenImage,
+			final int offsetX,
+			final int offsetY )
 	{
-		final AffineTransform3D screenScaleTransform = screenScaleTransforms[ currentScreenScaleIndex ];
-		final ArrayList< RandomAccessible< T > > renderList = new ArrayList<>();
-		final Source< T > spimSource = source.getSpimSource();
-		final int t = viewerState.getCurrentTimepoint();
-
-		final MipmapOrdering ordering = MipmapOrdering.class.isInstance( spimSource ) ?
-			( MipmapOrdering ) spimSource : new DefaultMipmapOrdering( spimSource );
-
-		final AffineTransform3D screenTransform = new AffineTransform3D();
-		viewerState.getViewerTransform( screenTransform );
+		final AffineTransform3D screenScaleTransform = screenScales.get( screenScaleIndex ).scaleTransform();
+		final AffineTransform3D screenTransform = viewerState.getViewerTransform();
 		screenTransform.preConcatenate( screenScaleTransform );
-		final MipmapHints hints = ordering.getMipmapHints( screenTransform, t, previousTimepoint );
-		final List< Level > levels = hints.getLevels();
-
-		if ( prefetchCells )
-		{
-			Collections.sort( levels, MipmapOrdering.prefetchOrderComparator );
-			for ( final Level l : levels )
-			{
-				final CacheHints cacheHints = l.getPrefetchCacheHints();
-				if ( cacheHints == null || cacheHints.getLoadingStrategy() != LoadingStrategy.DONTLOAD )
-					prefetch( viewerState, spimSource, screenScaleTransform, l.getMipmapLevel(), cacheHints, screenImage );
-			}
-		}
-
-		Collections.sort( levels, MipmapOrdering.renderOrderComparator );
-		for ( final Level l : levels )
-			renderList.add( getTransformedSource( viewerState, spimSource, screenScaleTransform, l.getMipmapLevel(), l.getRenderCacheHints() ) );
-
-		if ( hints.renewHintsAfterPaintingOnce() )
-			newFrameRequest = true;
-
-		return new VolatileHierarchyProjector<>( renderList, source.getConverter(), screenImage, maskArray, numRenderingThreads, renderingExecutorService );
-	}
-
-	private static < T > RandomAccessible< T > getTransformedSource(
-			final ViewerState viewerState,
-			final Source< T > source,
-			final AffineTransform3D screenScaleTransform,
-			final int mipmapIndex,
-			final CacheHints cacheHints )
-	{
-		final int timepoint = viewerState.getCurrentTimepoint();
-
-		final RandomAccessibleInterval< T > img = source.getSource( timepoint, mipmapIndex );
-		if ( VolatileCachedCellImg.class.isInstance( img ) )
-			( ( VolatileCachedCellImg< ?, ? > ) img ).setCacheHints( cacheHints );
-
-		final Interpolation interpolation = viewerState.getInterpolation();
-		final RealRandomAccessible< T > ipimg = source.getInterpolatedSource( timepoint, mipmapIndex, interpolation );
-
-		final AffineTransform3D sourceToScreen = new AffineTransform3D();
-		viewerState.getViewerTransform( sourceToScreen );
-		final AffineTransform3D sourceTransform = new AffineTransform3D();
-		source.getSourceTransform( timepoint, mipmapIndex, sourceTransform );
-		sourceToScreen.concatenate( sourceTransform );
-		sourceToScreen.preConcatenate( screenScaleTransform );
-
-		return RealViews.affine( ipimg, sourceToScreen );
-	}
-
-	private static < T > void prefetch(
-			final ViewerState viewerState,
-			final Source< T > source,
-			final AffineTransform3D screenScaleTransform,
-			final int mipmapIndex,
-			final CacheHints prefetchCacheHints,
-			final Dimensions screenInterval )
-	{
-		final int timepoint = viewerState.getCurrentTimepoint();
-		final RandomAccessibleInterval< T > img = source.getSource( timepoint, mipmapIndex );
-		if ( VolatileCachedCellImg.class.isInstance( img ) )
-		{
-			final VolatileCachedCellImg< ?, ? > cellImg = ( VolatileCachedCellImg< ?, ? > ) img;
-
-			CacheHints hints = prefetchCacheHints;
-			if ( hints == null )
-			{
-				final CacheHints d = cellImg.getDefaultCacheHints();
-				hints = new CacheHints( LoadingStrategy.VOLATILE, d.getQueuePriority(), false );
-			}
-			cellImg.setCacheHints( hints );
-			final int[] cellDimensions = new int[ 3 ];
-			cellImg.getCellGrid().cellDimensions( cellDimensions );
-			final long[] dimensions = new long[ 3 ];
-			cellImg.dimensions( dimensions );
-			final RandomAccess< ? > cellsRandomAccess = cellImg.getCells().randomAccess();
-
-			final Interpolation interpolation = viewerState.getInterpolation();
-
-			final AffineTransform3D sourceToScreen = new AffineTransform3D();
-			viewerState.getViewerTransform( sourceToScreen );
-			final AffineTransform3D sourceTransform = new AffineTransform3D();
-			source.getSourceTransform( timepoint, mipmapIndex, sourceTransform );
-			sourceToScreen.concatenate( sourceTransform );
-			sourceToScreen.preConcatenate( screenScaleTransform );
-
-			Prefetcher.fetchCells( sourceToScreen, cellDimensions, dimensions, screenInterval, interpolation, cellsRandomAccess );
-		}
-	}
-
-	private static TransformAwareRenderTarget wrapTransformAwareRenderTarget( final RenderTarget t )
-	{
-		if ( t instanceof TransformAwareRenderTarget )
-			return ( TransformAwareRenderTarget ) t;
-		else
-			return new TransformAwareRenderTarget()
-			{
-				@Override
-				public BufferedImage setBufferedImage( final BufferedImage img )
-				{
-					return t.setBufferedImage( img );
-				}
-
-				@Override
-				public int getWidth()
-				{
-					return t.getWidth();
-				}
-
-				@Override
-				public int getHeight()
-				{
-					return t.getHeight();
-				}
-
-				@Override
-				public BufferedImage setBufferedImageAndTransform( final BufferedImage img, final AffineTransform3D transform )
-				{
-					return t.setBufferedImage( img );
-				}
-
-				@Override
-				public void removeTransformListener( final TransformListener< AffineTransform3D > listener )
-				{}
-
-				@Override
-				public void addTransformListener( final TransformListener< AffineTransform3D > listener, final int index )
-				{}
-
-				@Override
-				public void addTransformListener( final TransformListener< AffineTransform3D > listener )
-				{}
-			};
+		screenTransform.translate( -offsetX, -offsetY, 0 );
+
+		final VolatileProjector projector = projectorFactory.createProjector(
+				viewerState,
+				visibleSourcesOnScreen,
+				screenImage,
+				screenTransform,
+				renderStorage );
+		CacheIoTiming.getIoTimeBudget().reset( iobudget );
+		return projector;
 	}
 }
diff --git a/src/main/java/bdv/viewer/render/PainterThread.java b/src/main/java/bdv/viewer/render/PainterThread.java
new file mode 100644
index 0000000000000000000000000000000000000000..16292db127f59512ef3ce7348bdd9868acaaa8af
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/PainterThread.java
@@ -0,0 +1,123 @@
+/*
+ * #%L
+ * ImgLib2: a general-purpose, multidimensional image processing library.
+ * %%
+ * Copyright (C) 2009 - 2016 Tobias Pietzsch, Stephan Preibisch, Stephan Saalfeld,
+ * John Bogovic, Albert Cardona, Barry DeZonia, Christian Dietz, Jan Funke,
+ * Aivar Grislis, Jonathan Hale, Grant Harris, Stefan Helfrich, Mark Hiner,
+ * Martin Horn, Steffen Jaensch, Lee Kamentsky, Larry Lindsey, Melissa Linkert,
+ * Mark Longair, Brian Northan, Nick Perry, Curtis Rueden, Johannes Schindelin,
+ * Jean-Yves Tinevez and Michael Zinsmaier.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render;
+
+import bdv.viewer.RequestRepaint;
+import java.util.concurrent.RejectedExecutionException;
+
+/**
+ * Thread to repaint display.
+ */
+public class PainterThread extends Thread implements RequestRepaint
+{
+	public interface Paintable
+	{
+		/**
+		 * This is called by the painter thread to repaint the display.
+		 */
+		void paint();
+	}
+
+	private final Paintable paintable;
+
+	private boolean pleaseRepaint;
+
+	public PainterThread( final Paintable paintable )
+	{
+		this( null, "PainterThread", paintable );
+	}
+
+	public PainterThread( final ThreadGroup group, final Paintable paintable )
+	{
+		this( group, "PainterThread", paintable );
+	}
+
+	public PainterThread( final ThreadGroup group, final String name, final Paintable paintable )
+	{
+		super( group, name );
+		this.paintable = paintable;
+		this.pleaseRepaint = false;
+	}
+
+	@Override
+	public void run()
+	{
+		while ( !isInterrupted() )
+		{
+			final boolean b;
+			synchronized ( this )
+			{
+				b = pleaseRepaint;
+				pleaseRepaint = false;
+			}
+			if ( b )
+				try
+				{
+					paintable.paint();
+				}
+				catch ( final RejectedExecutionException e )
+				{
+					// this happens when the rendering threadpool
+					// is killed before the painter thread.
+				}
+			synchronized ( this )
+			{
+				try
+				{
+					if ( !pleaseRepaint )
+						wait();
+				}
+				catch ( final InterruptedException e )
+				{
+					break;
+				}
+			}
+		}
+	}
+
+	/**
+	 * Request repaint. This will trigger a call to {@link Paintable#paint()}
+	 * from the {@link PainterThread}.
+	 */
+	@Override
+	public void requestRepaint()
+	{
+		synchronized ( this )
+		{
+//			DebugHelpers.printStackTrace( 15 );
+			pleaseRepaint = true;
+			notify();
+		}
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/ProjectorFactory.java b/src/main/java/bdv/viewer/render/ProjectorFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..89e249e451855fb5c6c30bccf39c76f66a01c3e4
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/ProjectorFactory.java
@@ -0,0 +1,308 @@
+package bdv.viewer.render;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+
+import net.imglib2.Dimensions;
+import net.imglib2.RandomAccess;
+import net.imglib2.RandomAccessible;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.RealRandomAccessible;
+import net.imglib2.Volatile;
+import net.imglib2.cache.volatiles.CacheHints;
+import net.imglib2.cache.volatiles.LoadingStrategy;
+import net.imglib2.realtransform.AffineTransform3D;
+import net.imglib2.realtransform.RealViews;
+import net.imglib2.type.numeric.ARGBType;
+
+import bdv.img.cache.VolatileCachedCellImg;
+import bdv.util.MipmapTransforms;
+import bdv.viewer.Interpolation;
+import bdv.viewer.Source;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.ViewerState;
+
+/**
+ * Creates projectors for rendering a given {@code ViewerState} to a given
+ * {@code screenImage}, with the current visible sources and timepoint of the
+ * {@code ViewerState}, and a given {@code screenTransform} from global
+ * coordinates to coordinates in the {@code screenImage}.
+ */
+class ProjectorFactory
+{
+	/**
+	 * How many threads to use for rendering.
+	 */
+	private final int numRenderingThreads;
+
+	/**
+	 * {@link ExecutorService} used for rendering.
+	 */
+	private final ExecutorService renderingExecutorService;
+
+	/**
+	 * Whether volatile versions of sources should be used if available.
+	 */
+	private final boolean useVolatileIfAvailable;
+
+	/**
+	 * Constructs projector that combines rendere ARGB images for individual
+	 * sources to the final screen image. This can be used to customize how
+	 * sources are combined.
+	 */
+	private final AccumulateProjectorFactory< ARGBType > accumulateProjectorFactory;
+
+	/**
+	 * Whether repainting should be triggered after the previously
+	 * {@link #createProjector constructed} projector returns an incomplete
+	 * result.
+	 * <p>
+	 * The use case for this is the following:
+	 * <p>
+	 * When scrolling through time, we often get frames for which no data was
+	 * loaded yet. To speed up rendering in these cases, use only two mipmap
+	 * levels: the optimal and the coarsest. By doing this, we require at most
+	 * two passes over the image at the expense of ignoring data present in
+	 * intermediate mipmap levels. The assumption is, that we will either be
+	 * moving back and forth between images that have all data present already
+	 * or that we move to a new image with no data present at all.
+	 * <p>
+	 * However, we only want this two-pass rendering to happen once, then switch
+	 * to normal multi-pass rendering if we remain longer on the same frame.
+	 * <p>
+	 * (This method is implemented by {@link DefaultMipmapOrdering}.)
+	 */
+	private boolean newFrameRequest;
+
+	/**
+	 * The timepoint for which last a projector was {@link #createProjector
+	 * created}.
+	 */
+	private int previousTimepoint = -1;
+
+	// TODO: should be settable
+	private final boolean prefetchCells = true;
+
+	/**
+	 * @param numRenderingThreads
+	 *     How many threads to use for rendering.
+	 * @param renderingExecutorService
+	 *     if non-null, this is used for rendering. Note, that it is still
+	 *     important to supply the numRenderingThreads parameter, because that
+	 *     is used to determine into how many sub-tasks rendering is split.
+	 * @param useVolatileIfAvailable
+	 *     whether volatile versions of sources should be used if available.
+	 * @param accumulateProjectorFactory
+	 *     can be used to customize how sources are combined.
+	 */
+	public ProjectorFactory(
+			final int numRenderingThreads,
+			final ExecutorService renderingExecutorService,
+			final boolean useVolatileIfAvailable,
+			final AccumulateProjectorFactory< ARGBType > accumulateProjectorFactory )
+	{
+		this.numRenderingThreads = numRenderingThreads;
+		this.renderingExecutorService = renderingExecutorService;
+		this.useVolatileIfAvailable = useVolatileIfAvailable;
+		this.accumulateProjectorFactory = accumulateProjectorFactory;
+	}
+
+	/**
+	 * Create a projector for rendering the specified {@code ViewerState} to the
+	 * specified {@code screenImage}, with the current visible sources (visible
+	 * in {@code ViewerState} and actually currently visible on screen) and
+	 * timepoint of the {@code ViewerState}, and the specified
+	 * {@code screenTransform} from global coordinates to coordinates in the
+	 * {@code screenImage}.
+	 */
+	public VolatileProjector createProjector(
+			final ViewerState viewerState,
+			final List< SourceAndConverter< ? > > visibleSourcesOnScreen,
+			final RandomAccessibleInterval< ARGBType > screenImage,
+			final AffineTransform3D screenTransform,
+			final RenderStorage renderStorage )
+	{
+		/*
+		 * This shouldn't be necessary, with
+		 * CacheHints.LoadingStrategy==VOLATILE
+		 */
+//		CacheIoTiming.getIoTimeBudget().clear(); // clear time budget such that prefetching doesn't wait for loading blocks.
+		newFrameRequest = false;
+
+		final int width = ( int ) screenImage.dimension( 0 );
+		final int height = ( int ) screenImage.dimension( 1 );
+
+		VolatileProjector projector;
+		if ( visibleSourcesOnScreen.isEmpty() )
+			projector = new EmptyProjector<>( screenImage );
+		else if ( visibleSourcesOnScreen.size() == 1 )
+		{
+			final byte[] maskArray = renderStorage.getMaskArray( 0 );
+			projector = createSingleSourceProjector( viewerState, visibleSourcesOnScreen.get( 0 ), screenImage, screenTransform, maskArray );
+		}
+		else
+		{
+			final ArrayList< VolatileProjector > sourceProjectors = new ArrayList<>();
+			final ArrayList< RandomAccessibleInterval< ARGBType > > sourceImages = new ArrayList<>();
+			int j = 0;
+			for ( final SourceAndConverter< ? > source : visibleSourcesOnScreen )
+			{
+				final RandomAccessibleInterval< ARGBType > renderImage = renderStorage.getRenderImage( width, height, j );
+				final byte[] maskArray = renderStorage.getMaskArray( j );
+				++j;
+				final VolatileProjector p = createSingleSourceProjector( viewerState, source, renderImage, screenTransform, maskArray );
+				sourceProjectors.add( p );
+				sourceImages.add( renderImage );
+			}
+			projector = accumulateProjectorFactory.createProjector( sourceProjectors, visibleSourcesOnScreen, sourceImages, screenImage, numRenderingThreads, renderingExecutorService );
+		}
+		previousTimepoint = viewerState.getCurrentTimepoint();
+		return projector;
+	}
+
+	private < T > VolatileProjector createSingleSourceProjector(
+			final ViewerState viewerState,
+			final SourceAndConverter< T > source,
+			final RandomAccessibleInterval< ARGBType > screenImage,
+			final AffineTransform3D screenTransform,
+			final byte[] maskArray )
+	{
+		if ( useVolatileIfAvailable )
+		{
+			if ( source.asVolatile() != null )
+				return createSingleSourceVolatileProjector( viewerState, source.asVolatile(), screenImage, screenTransform, maskArray );
+			else if ( source.getSpimSource().getType() instanceof Volatile )
+			{
+				@SuppressWarnings( "unchecked" )
+				final SourceAndConverter< ? extends Volatile< ? > > vsource = ( SourceAndConverter< ? extends Volatile< ? > > ) source;
+				return createSingleSourceVolatileProjector( viewerState, vsource, screenImage, screenTransform, maskArray );
+			}
+		}
+
+		final int bestLevel = getBestMipMapLevel( viewerState, source, screenTransform );
+		return new SimpleVolatileProjector<>(
+				getTransformedSource( viewerState, source.getSpimSource(), screenTransform, bestLevel, null ),
+				source.getConverter(), screenImage, numRenderingThreads, renderingExecutorService );
+	}
+
+	private < T extends Volatile< ? > > VolatileProjector createSingleSourceVolatileProjector(
+			final ViewerState viewerState,
+			final SourceAndConverter< T > source,
+			final RandomAccessibleInterval< ARGBType > screenImage,
+			final AffineTransform3D screenTransform,
+			final byte[] maskArray )
+	{
+		final ArrayList< RandomAccessible< T > > renderList = new ArrayList<>();
+		final Source< T > spimSource = source.getSpimSource();
+		final int t = viewerState.getCurrentTimepoint();
+
+		final MipmapOrdering ordering = spimSource instanceof MipmapOrdering ?
+				( MipmapOrdering ) spimSource : new DefaultMipmapOrdering( spimSource );
+
+		final MipmapOrdering.MipmapHints hints = ordering.getMipmapHints( screenTransform, t, previousTimepoint );
+		final List< MipmapOrdering.Level > levels = hints.getLevels();
+
+		if ( prefetchCells )
+		{
+			levels.sort( MipmapOrdering.prefetchOrderComparator );
+			for ( final MipmapOrdering.Level l : levels )
+			{
+				final CacheHints cacheHints = l.getPrefetchCacheHints();
+				if ( cacheHints == null || cacheHints.getLoadingStrategy() != LoadingStrategy.DONTLOAD )
+					prefetch( viewerState, spimSource, screenTransform, l.getMipmapLevel(), cacheHints, screenImage );
+			}
+		}
+
+		levels.sort( MipmapOrdering.renderOrderComparator );
+		for ( final MipmapOrdering.Level l : levels )
+			renderList.add( getTransformedSource( viewerState, spimSource, screenTransform, l.getMipmapLevel(), l.getRenderCacheHints() ) );
+
+		if ( hints.renewHintsAfterPaintingOnce() )
+			newFrameRequest = true;
+
+		return new VolatileHierarchyProjector<>( renderList, source.getConverter(), screenImage, maskArray, numRenderingThreads, renderingExecutorService );
+	}
+
+	/**
+	 * Get the mipmap level that best matches the given screen scale for the
+	 * given source.
+	 *
+	 * @param screenTransform
+	 *     transforms screen image coordinates to global coordinates.
+	 *
+	 * @return mipmap level
+	 */
+	private static int getBestMipMapLevel(
+			final ViewerState viewerState,
+			final SourceAndConverter< ? > source,
+			final AffineTransform3D screenTransform )
+	{
+		return MipmapTransforms.getBestMipMapLevel( screenTransform, source.getSpimSource(), viewerState.getCurrentTimepoint() );
+	}
+
+	private static < T > RandomAccessible< T > getTransformedSource(
+			final ViewerState viewerState,
+			final Source< T > source,
+			final AffineTransform3D screenTransform,
+			final int mipmapIndex,
+			final CacheHints cacheHints )
+	{
+		final int timepoint = viewerState.getCurrentTimepoint();
+
+		final RandomAccessibleInterval< T > img = source.getSource( timepoint, mipmapIndex );
+		if ( img instanceof VolatileCachedCellImg )
+			( ( VolatileCachedCellImg< ?, ? > ) img ).setCacheHints( cacheHints );
+
+		final Interpolation interpolation = viewerState.getInterpolation();
+		final RealRandomAccessible< T > ipimg = source.getInterpolatedSource( timepoint, mipmapIndex, interpolation );
+
+		final AffineTransform3D sourceToScreen = new AffineTransform3D();
+		source.getSourceTransform( timepoint, mipmapIndex, sourceToScreen );
+		sourceToScreen.preConcatenate( screenTransform );
+
+		return RealViews.affine( ipimg, sourceToScreen );
+	}
+
+	private static < T > void prefetch(
+			final ViewerState viewerState,
+			final Source< T > source,
+			final AffineTransform3D screenTransform,
+			final int mipmapIndex,
+			final CacheHints prefetchCacheHints,
+			final Dimensions screenInterval )
+	{
+		final int timepoint = viewerState.getCurrentTimepoint();
+		final RandomAccessibleInterval< T > img = source.getSource( timepoint, mipmapIndex );
+		if ( img instanceof VolatileCachedCellImg )
+		{
+			final VolatileCachedCellImg< ?, ? > cellImg = ( VolatileCachedCellImg< ?, ? > ) img;
+
+			CacheHints hints = prefetchCacheHints;
+			if ( hints == null )
+			{
+				final CacheHints d = cellImg.getDefaultCacheHints();
+				hints = new CacheHints( LoadingStrategy.VOLATILE, d.getQueuePriority(), false );
+			}
+			cellImg.setCacheHints( hints );
+			final int[] cellDimensions = new int[ 3 ];
+			cellImg.getCellGrid().cellDimensions( cellDimensions );
+			final long[] dimensions = new long[ 3 ];
+			cellImg.dimensions( dimensions );
+			final RandomAccess< ? > cellsRandomAccess = cellImg.getCells().randomAccess();
+
+			final Interpolation interpolation = viewerState.getInterpolation();
+
+			final AffineTransform3D sourceToScreen = new AffineTransform3D();
+			source.getSourceTransform( timepoint, mipmapIndex, sourceToScreen );
+			sourceToScreen.preConcatenate( screenTransform );
+
+			Prefetcher.fetchCells( sourceToScreen, cellDimensions, dimensions, screenInterval, interpolation, cellsRandomAccess );
+		}
+	}
+
+	public boolean requestNewFrameIfIncomplete()
+	{
+		return newFrameRequest;
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/ProjectorUtils.java b/src/main/java/bdv/viewer/render/ProjectorUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..8c0b7d476ad4ef4a2a9b532703c43069ac1d0536
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/ProjectorUtils.java
@@ -0,0 +1,30 @@
+package bdv.viewer.render;
+
+import net.imglib2.RandomAccessible;
+import net.imglib2.img.array.ArrayImg;
+import net.imglib2.img.basictypeaccess.array.IntArray;
+import net.imglib2.type.numeric.ARGBType;
+
+public class ProjectorUtils
+{
+	/**
+	 * Extracts the underlying {@code int[]} array in case {@code img} is a
+	 * standard {@code ArrayImg<ARGBType>}. This supports certain (optional)
+	 * optimizations in projector implementations.
+	 *
+	 * @return the underlying {@code int[]} array of {@code img}, if it is a
+	 * standard {@code ArrayImg<ARGBType>}. Otherwise {@code null}.
+	 */
+	public static int[] getARGBArrayImgData( final RandomAccessible< ? > img )
+	{
+		if ( ! ( img instanceof ArrayImg ) )
+			return null;
+		final ArrayImg< ?, ? > aimg = ( ArrayImg< ?, ? > ) img;
+		if( ! ( aimg.firstElement() instanceof ARGBType ) )
+			return null;
+		final Object access = aimg.update( null );
+		if ( ! ( access instanceof IntArray ) )
+			return null;
+		return ( ( IntArray ) access ).getCurrentStorageArray();
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/RenderResult.java b/src/main/java/bdv/viewer/render/RenderResult.java
new file mode 100644
index 0000000000000000000000000000000000000000..176952cdca1dc520eaf35ca3d467d200497d2582
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/RenderResult.java
@@ -0,0 +1,70 @@
+package bdv.viewer.render;
+
+import net.imglib2.Interval;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.realtransform.AffineTransform3D;
+import net.imglib2.type.numeric.ARGBType;
+
+/**
+ * Provides the {@link MultiResolutionRenderer renderer} with a target image
+ * ({@code RandomAccessibleInterval<ARGBType>}) to render to. Provides the
+ * {@link RenderTarget} with the rendered image and transform etc necessary to
+ * display it.
+ */
+public interface RenderResult
+{
+	/**
+	 * Allocate storage such that {@link #getTargetImage()} holds an image of
+	 * {@code width * height}.
+	 * <p>
+	 * (Called by the {@link MultiResolutionRenderer renderer}.)
+	 */
+	void init( int width, int height );
+
+	/**
+	 * Get the image to render to.
+	 * <p>
+	 * (Called by the {@link MultiResolutionRenderer renderer}.)
+	 *
+	 * @return the image to render to
+	 */
+	// TODO: rename getTargetImage() ???
+	RandomAccessibleInterval< ARGBType > getTargetImage();
+
+	/**
+	 * Get the viewer transform used to render image. This is with respect to
+	 * the screen resolution (doesn't include scaling).
+	 * <p>
+	 * (Called by the {@link MultiResolutionRenderer renderer} to set the
+	 * transform.)
+	 */
+	AffineTransform3D getViewerTransform();
+
+	/**
+	 * Get the scale factor from target coordinates to screen resolution.
+	 */
+	double getScaleFactor();
+
+	/**
+	 * Set the scale factor from target coordinates to screen resolution.
+	 */
+	void setScaleFactor( double scaleFactor );
+
+	/**
+	 * Fill in {@code interval} with data from {@code patch}, scaled by the
+	 * relative scale between this {@code RenderResult} and {@code patch}, and
+	 * shifted such that {@code (0,0)} of the {@code patch} is placed at
+	 * {@code (ox,oy)} of this {@code RenderResult}
+	 * <p>
+	 * Note that only data in {@code interval} will be modified, although the
+	 * scaled and shifted {@code patch} might fall partially outside.
+	 */
+	void patch( final RenderResult patch, final Interval interval, final double ox, final double oy );
+
+	/**
+	 * Notify that the {@link #getTargetImage() target image} data was changed.
+	 * <p>
+	 * (Called by the {@link MultiResolutionRenderer renderer}.)
+	 */
+	void setUpdated();
+}
diff --git a/src/main/java/bdv/viewer/render/RenderStorage.java b/src/main/java/bdv/viewer/render/RenderStorage.java
new file mode 100644
index 0000000000000000000000000000000000000000..d98e99f08ba7c9cf87da66f4aa77d920e4f6c2b0
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/RenderStorage.java
@@ -0,0 +1,64 @@
+package bdv.viewer.render;
+
+import java.util.ArrayList;
+import java.util.List;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.img.array.ArrayImgs;
+import net.imglib2.type.numeric.ARGBType;
+
+/**
+ * Maintains {@code byte[]} and {@code int[]} arrays for mask and intermediate images needed for rendering.
+ * <p>
+ * Call {@link #checkRenewData} to update number and size of arrays when number of visible sources or screen size changes.
+ */
+class RenderStorage
+{
+	/**
+	 * Storage for mask images of {@link VolatileHierarchyProjector}. One array
+	 * per visible source.
+	 */
+	private final List< byte[] > renderMaskArrays = new ArrayList<>();
+
+	/**
+	 * Storage for render images of {@link VolatileHierarchyProjector}.
+	 * Used to render an individual source before combining to final target image.
+	 * One array per visible source, if more than one source is visible.
+	 * (If exactly one source is visible, it is rendered directly to the target image.)
+	 */
+	private final List< int[] > renderImageArrays = new ArrayList<>();
+
+	public void checkRenewData( final int screenW, final int screenH, final int numVisibleSources )
+	{
+		final int size = screenW * screenH;
+		final int currentSize = renderMaskArrays.isEmpty() ? 0 : renderMaskArrays.get( 0 ).length;
+		if  ( size != currentSize )
+			clear();
+
+		while ( renderMaskArrays.size() > numVisibleSources )
+			renderMaskArrays.remove( renderMaskArrays.size() - 1 );
+		while ( renderMaskArrays.size() < numVisibleSources )
+			renderMaskArrays.add( new byte[ size ] );
+
+		final int numRenderImages = numVisibleSources > 1 ? numVisibleSources : 0;
+		while ( renderImageArrays.size() > numRenderImages )
+			renderImageArrays.remove( renderImageArrays.size() - 1 );
+		while ( renderImageArrays.size() < numRenderImages )
+			renderImageArrays.add( new int[ size ] );
+	}
+
+	public byte[] getMaskArray( final int index )
+	{
+		return renderMaskArrays.get( index );
+	}
+
+	public RandomAccessibleInterval< ARGBType > getRenderImage( final int width, final int height, final int index )
+	{
+		return ArrayImgs.argbs( renderImageArrays.get( index ), width, height );
+	}
+
+	public void clear()
+	{
+		renderMaskArrays.clear();
+		renderImageArrays.clear();
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/RenderTarget.java b/src/main/java/bdv/viewer/render/RenderTarget.java
new file mode 100644
index 0000000000000000000000000000000000000000..7a23c607e26ad6d0eaaa7e6935f7008926f64d95
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/RenderTarget.java
@@ -0,0 +1,83 @@
+/*
+ * #%L
+ * ImgLib2: a general-purpose, multidimensional image processing library.
+ * %%
+ * Copyright (C) 2009 - 2016 Tobias Pietzsch, Stephan Preibisch, Stephan Saalfeld,
+ * John Bogovic, Albert Cardona, Barry DeZonia, Christian Dietz, Jan Funke,
+ * Aivar Grislis, Jonathan Hale, Grant Harris, Stefan Helfrich, Mark Hiner,
+ * Martin Horn, Steffen Jaensch, Lee Kamentsky, Larry Lindsey, Melissa Linkert,
+ * Mark Longair, Brian Northan, Nick Perry, Curtis Rueden, Johannes Schindelin,
+ * Jean-Yves Tinevez and Michael Zinsmaier.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render;
+
+import bdv.viewer.render.RenderResult;
+
+/**
+ * Receiver for a rendered image (to be drawn onto a canvas later).
+ * <p>
+ * A renderer will render source data into a {@link RenderResult} and
+ * provide this to the {@code RenderTarget}.
+ * <p>
+ * See {@code BufferedImageOverlayRenderer}, which is both a {@code RenderTarget} and
+ * an {@code OverlayRenderer} that draws the {@code RenderResult}.
+ *
+ * @author Tobias Pietzsch
+ */
+public interface RenderTarget< R extends RenderResult >
+{
+	/**
+	 * Returns a {@code RenderResult} for rendering to.
+	 * This may be a new {@code RenderResult} or a previously {@link #setRenderResult set RenderResult}
+	 * that is no longer needed for display.
+	 * Note that consecutive {@code getReusableRenderResult()} calls without intermediate
+	 * {@code setRenderResult()} may return the same {@code RenderResult}.
+	 */
+	R getReusableRenderResult();
+
+	/**
+	 * Returns a new {@code RenderResult}.
+	 */
+	R createRenderResult();
+
+	/**
+	 * Set the {@link RenderResult} that is to be drawn on the canvas.
+	 */
+	void setRenderResult( R renderResult );
+
+	/**
+	 * Get the current canvas width.
+	 *
+	 * @return canvas width.
+	 */
+	int getWidth();
+
+	/**
+	 * Get the current canvas height.
+	 *
+	 * @return canvas height.
+	 */
+	int getHeight();
+}
diff --git a/src/main/java/bdv/viewer/render/ScreenScales.java b/src/main/java/bdv/viewer/render/ScreenScales.java
new file mode 100644
index 0000000000000000000000000000000000000000..a2aa016d0578311178b98fac078e9ed4fde9bf1d
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/ScreenScales.java
@@ -0,0 +1,318 @@
+package bdv.viewer.render;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import net.imglib2.Interval;
+import net.imglib2.realtransform.AffineTransform3D;
+import net.imglib2.util.Intervals;
+
+/**
+ * Maintains current sizes and transforms at every screen scale level. Records
+ * interval rendering requests. Suggests full frame or interval scale to render
+ * in order to meet a specified target rendering time in nanoseconds.
+ */
+class ScreenScales
+{
+	/**
+	 * Target rendering time in nanoseconds. The rendering time for the coarsest
+	 * rendered scale should be below this threshold. After the coarsest scale,
+	 * increasingly finer scales are rendered, but these render passes may be
+	 * canceled (while the coarsest may not).
+	 */
+	private final double targetRenderNanos;
+
+	private final List< ScreenScale > screenScales;
+
+	private int screenW = 0;
+
+	private int screenH = 0;
+
+	/**
+	 * @param screenScaleFactors
+	 *     Scale factors from the viewer canvas to screen images of different
+	 *     resolutions. A scale factor of 1 means 1 pixel in the screen image is
+	 *     displayed as 1 pixel on the canvas, a scale factor of 0.5 means 1
+	 *     pixel in the screen image is displayed as 2 pixel on the canvas, etc.
+	 * @param targetRenderNanos
+	 *     Target rendering time in nanoseconds. The rendering time for the
+	 *     coarsest rendered scale should be below this threshold.
+	 */
+	public ScreenScales( final double[] screenScaleFactors, final double targetRenderNanos )
+	{
+		this.targetRenderNanos = targetRenderNanos;
+		screenScales = new ArrayList<>();
+		for ( final double scale : screenScaleFactors )
+			screenScales.add( new ScreenScale( scale ) );
+	}
+
+	/**
+	 * Check whether the screen size was changed and resize {@link #screenScales} accordingly.
+	 *
+	 * @return whether the size was changed.
+	 */
+	public boolean checkResize( final int newScreenW, final int newScreenH )
+	{
+		if ( newScreenW != screenW || newScreenH != screenH )
+		{
+			screenW = newScreenW;
+			screenH = newScreenH;
+			screenScales.forEach( s -> s.resize( screenW, screenH ) );
+			return true;
+		}
+		return false;
+	}
+
+	/**
+	 * @return the screen scale at {@code index}
+	 */
+	public ScreenScale get( final int index )
+	{
+		return screenScales.get( index );
+	}
+
+	/**
+	 * @return number of screen scales.
+	 */
+	public int size()
+	{
+		return screenScales.size();
+	}
+
+	public int suggestScreenScale( final double renderNanosPerPixel )
+	{
+		for ( int i = 0; i < screenScales.size() - 1; i++ )
+		{
+			final double renderTime = screenScales.get( i ).estimateRenderNanos( renderNanosPerPixel );
+			if ( renderTime <= targetRenderNanos )
+				return i;
+		}
+		return screenScales.size() - 1;
+	}
+
+	public int suggestIntervalScreenScale( final double renderNanosPerPixel, final int minScreenScaleIndex )
+	{
+		for ( int i = minScreenScaleIndex; i < screenScales.size() - 1; i++ )
+		{
+			final double renderTime = screenScales.get( i ).estimateIntervalRenderNanos( renderNanosPerPixel );
+			if ( renderTime <= targetRenderNanos )
+				return i;
+		}
+		return screenScales.size() - 1;
+	}
+
+	public void requestInterval( final Interval screenInterval )
+	{
+		screenScales.forEach( s -> s.requestInterval( screenInterval ) );
+	}
+
+	public void clearRequestedIntervals()
+	{
+		screenScales.forEach( ScreenScale::pullScreenInterval );
+	}
+
+	public IntervalRenderData pullIntervalRenderData( final int intervalScaleIndex, final int targetScaleIndex )
+	{
+		return new IntervalRenderData( intervalScaleIndex, targetScaleIndex );
+	}
+
+	static class ScreenScale
+	{
+		/**
+		 * Scale factor from the viewer coordinates to target image of this screen scale.
+		 */
+		private final double scale;
+
+		/**
+		 * The width of the target image at this ScreenScale.
+		 */
+		private int w = 0;
+
+		/**
+		 * The height of the target image at this ScreenScale.
+		 */
+		private int h = 0;
+
+		/**
+		 * The transformation from viewer to target image coordinates at this ScreenScale.
+		 */
+		private final AffineTransform3D scaleTransform = new AffineTransform3D();
+
+		/**
+		 * Pending interval request.
+		 * This is in viewer coordinates.
+		 * To transform to target coordinates of this scale, use {@link #scaleScreenInterval}.
+		 */
+		private Interval requestedScreenInterval = null;
+
+		/**
+		 * @param scale
+		 * 		Scale factor from the viewer coordinates to target image of this screen scale/
+		 */
+		ScreenScale( final double scale )
+		{
+			this.scale = scale;
+		}
+
+		/**
+		 * Add {@code screenInterval} to requested interval (union).
+		 * Note that the requested interval is maintained in screen coordinates!
+		 */
+		public void requestInterval( final Interval screenInterval )
+		{
+			requestedScreenInterval = requestedScreenInterval == null
+					? screenInterval
+					: Intervals.union( requestedScreenInterval, screenInterval );
+		}
+
+		/**
+		 * Return and clear requested interval.
+		 * Note that the requested interval is maintained in screen coordinates!
+		 */
+		public Interval pullScreenInterval()
+		{
+			final Interval interval = requestedScreenInterval;
+			requestedScreenInterval = null;
+			return interval;
+		}
+
+		void resize( final int screenW, final int screenH )
+		{
+			w = ( int ) Math.ceil( scale * screenW );
+			h = ( int ) Math.ceil( scale * screenH );
+
+			scaleTransform.set( scale, 0, 0 );
+			scaleTransform.set( scale, 1, 1 );
+			scaleTransform.set( 0.5 * scale - 0.5, 0, 3 );
+			scaleTransform.set( 0.5 * scale - 0.5, 1, 3 );
+
+			requestedScreenInterval = null;
+		}
+
+		double estimateRenderNanos( final double renderNanosPerPixel )
+		{
+			return renderNanosPerPixel * w * h;
+		}
+
+		double estimateIntervalRenderNanos( final double renderNanosPerPixel )
+		{
+			return renderNanosPerPixel * Intervals.numElements( scaleScreenInterval( requestedScreenInterval ) );
+		}
+
+		Interval scaleScreenInterval( final Interval requestedScreenInterval )
+		{
+			// This is equivalent to
+			// Intervals.intersect( new FinalInterval( w, h ), Intervals.smallestContainingInterval( Intervals.scale( requestedScreenInterval, screenToViewerScale ) ) );
+			return Intervals.createMinMax(
+					Math.max( 0, ( int ) Math.floor( requestedScreenInterval.min( 0 ) * scale ) ),
+					Math.max( 0, ( int ) Math.floor( requestedScreenInterval.min( 1 ) * scale ) ),
+					Math.min( w - 1, ( int ) Math.ceil( requestedScreenInterval.max( 0 ) * scale ) ),
+					Math.min( h - 1, ( int ) Math.ceil( requestedScreenInterval.max( 1 ) * scale ) )
+			);
+		}
+
+		public int width()
+		{
+			return w;
+		}
+
+		public int height()
+		{
+			return h;
+		}
+
+		public double scale()
+		{
+			return scale;
+		}
+
+		public AffineTransform3D scaleTransform()
+		{
+			return scaleTransform;
+		}
+	}
+
+	class IntervalRenderData
+	{
+		private final int renderScaleIndex;
+
+		private final Interval renderInterval;
+
+		private final Interval targetInterval;
+
+		private final double tx;
+
+		private final double ty;
+
+		private final Interval[] screenIntervals;
+
+		public IntervalRenderData( final int renderScaleIndex, final int targetScaleIndex )
+		{
+			this.renderScaleIndex = renderScaleIndex;
+
+			screenIntervals = new Interval[ size() ];
+			for ( int i = renderScaleIndex; i < screenIntervals.length; ++i )
+				screenIntervals[ i ] = get( i ).pullScreenInterval();
+			final Interval screenInterval = screenIntervals[ renderScaleIndex ];
+
+			final ScreenScale renderScale = get( renderScaleIndex );
+			renderInterval = renderScale.scaleScreenInterval( screenInterval );
+
+			final ScreenScale targetScale = get( targetScaleIndex );
+			targetInterval = targetScale.scaleScreenInterval( screenInterval );
+
+			final double relativeScale = targetScale.scale() / renderScale.scale();
+			tx = renderInterval.min( 0 ) * relativeScale;
+			ty = renderInterval.min( 1 ) * relativeScale;
+		}
+
+		public void reRequest()
+		{
+			for ( int i = renderScaleIndex; i < screenIntervals.length; ++i )
+			{
+				final Interval interval = screenIntervals[ i ];
+				if ( interval != null )
+					get( i ).requestInterval( interval );
+			}
+		}
+
+		public int width()
+		{
+			return ( int ) renderInterval.dimension( 0 );
+		}
+
+		public int height()
+		{
+			return ( int ) renderInterval.dimension( 1 );
+		}
+
+		public int offsetX()
+		{
+			return ( int ) renderInterval.min( 0 );
+		}
+		public int offsetY()
+		{
+			return ( int ) renderInterval.min( 1 );
+		}
+
+		public double scale()
+		{
+			return get( renderScaleIndex ).scale();
+		}
+
+		public Interval targetInterval()
+		{
+			return targetInterval;
+		}
+
+		public double tx()
+		{
+			return tx;
+		}
+
+		public double ty()
+		{
+			return ty;
+		}
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/SimpleVolatileProjector.java b/src/main/java/bdv/viewer/render/SimpleVolatileProjector.java
new file mode 100644
index 0000000000000000000000000000000000000000..47640270913d8792b79dc63419c1c851bed8a370
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/SimpleVolatileProjector.java
@@ -0,0 +1,224 @@
+package bdv.viewer.render;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+import net.imglib2.FinalInterval;
+import net.imglib2.RandomAccess;
+import net.imglib2.RandomAccessible;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.converter.Converter;
+import net.imglib2.util.Intervals;
+import net.imglib2.util.StopWatch;
+
+// TODO Fix naming. This is a VolatileProjector for a non-volatile source...
+/**
+ * An {@link VolatileProjector}, that renders a target 2D
+ * {@code RandomAccessibleInterval} by copying values from a source
+ * {@code RandomAccessible}. The source can have more dimensions than the
+ * target. Target coordinate <em>(x,y)</em> is copied from source coordinate
+ * <em>(x,y,0,...,0)</em>.
+ * <p>
+ * A specified number of threads is used for rendering.
+ *
+ * @param <A>
+ *            pixel type of the source {@code RandomAccessible}.
+ * @param <B>
+ *            pixel type of the target {@code RandomAccessibleInterval}.
+ *
+ * @author Tobias Pietzsch
+ * @author Stephan Saalfeld
+ */
+public class SimpleVolatileProjector< A, B > implements VolatileProjector
+{
+	/**
+	 * A converter from the source pixel type to the target pixel type.
+	 */
+	private final Converter< ? super A, B > converter;
+
+	/**
+	 * The target interval. Pixels of the target interval should be set by
+	 * {@link #map}
+	 */
+	private final RandomAccessibleInterval< B > target;
+
+	private final RandomAccessible< A > source;
+
+	/**
+	 * Source interval which will be used for rendering. This is the 2D target
+	 * interval expanded to source dimensionality (usually 3D) with
+	 * {@code min=max=0} in the additional dimensions.
+	 */
+	private final FinalInterval sourceInterval;
+
+	/**
+	 * Number of threads to use for rendering
+	 */
+	private final int numThreads;
+
+	private final ExecutorService executorService;
+
+	/**
+	 * Time needed for rendering the last frame, in nano-seconds.
+	 */
+	private long lastFrameRenderNanoTime;
+
+	private AtomicBoolean canceled = new AtomicBoolean();
+
+	private boolean valid = false;
+
+	/**
+	 * Create new projector with the given source and a converter from source to
+	 * target pixel type.
+	 *
+	 * @param source
+	 *            source pixels.
+	 * @param converter
+	 *            converts from the source pixel type to the target pixel type.
+	 * @param target
+	 *            the target interval that this projector maps to
+	 * @param numThreads
+	 *            how many threads to use for rendering.
+	 * @param executorService
+	 */
+	public SimpleVolatileProjector(
+			final RandomAccessible< A > source,
+			final Converter< ? super A, B > converter,
+			final RandomAccessibleInterval< B > target,
+			final int numThreads,
+			final ExecutorService executorService )
+	{
+		this.converter = converter;
+		this.target = target;
+		this.source = source;
+
+		final int n = Math.max( 2, source.numDimensions() );
+		final long[] min = new long[ n ];
+		final long[] max = new long[ n ];
+		min[ 0 ] = target.min( 0 );
+		max[ 0 ] = target.max( 0 );
+		min[ 1 ] = target.min( 1 );
+		max[ 1 ] = target.max( 1 );
+		sourceInterval = new FinalInterval( min, max );
+
+		this.numThreads = numThreads;
+		this.executorService = executorService;
+		lastFrameRenderNanoTime = -1;
+	}
+
+	@Override
+	public void cancel()
+	{
+		canceled.set( true );
+	}
+
+	@Override
+	public long getLastFrameRenderNanoTime()
+	{
+		return lastFrameRenderNanoTime;
+	}
+
+	@Override
+	public boolean isValid()
+	{
+		return valid;
+	}
+
+	/**
+	 * Render the 2D target image by copying values from the source. Source can
+	 * have more dimensions than the target. Target coordinate <em>(x,y)</em> is
+	 * copied from source coordinate <em>(x,y,0,...,0)</em>.
+	 *
+	 * @return true if rendering was completed (all target pixels written).
+	 *         false if rendering was interrupted.
+	 */
+	@Override
+	public boolean map( final boolean clearUntouchedTargetPixels )
+	{
+		if ( canceled.get() )
+			return false;
+
+		final StopWatch stopWatch = StopWatch.createAndStart();
+
+		final int targetHeight = ( int ) target.dimension( 1 );
+		final int numTasks = numThreads <= 1 ? 1 : Math.min( numThreads * 10, targetHeight );
+		final double taskHeight = ( double ) targetHeight / numTasks;
+		final int[] taskStartHeights = new int[ numTasks + 1 ];
+		for ( int i = 0; i < numTasks; ++i )
+			taskStartHeights[ i ] = ( int ) ( i * taskHeight );
+		taskStartHeights[ numTasks ] = targetHeight;
+
+		final boolean createExecutor = ( executorService == null );
+		final ExecutorService ex = createExecutor ? Executors.newFixedThreadPool( numThreads ) : executorService;
+
+		final List< Callable< Void > > tasks = new ArrayList<>( numTasks );
+		for( int i = 0; i < numTasks; ++i )
+			tasks.add( createMapTask( taskStartHeights[ i ], taskStartHeights[ i + 1 ] ) );
+		try
+		{
+			ex.invokeAll( tasks );
+		}
+		catch ( final InterruptedException e )
+		{
+			Thread.currentThread().interrupt();
+		}
+
+		if ( createExecutor )
+			ex.shutdown();
+
+		lastFrameRenderNanoTime = stopWatch.nanoTime();
+
+		final boolean success = !canceled.get();
+		valid |= success;
+		return success;
+	}
+
+	/**
+	 * @return a {@code Callable} that runs {@code map(startHeight, endHeight)}
+	 */
+	private Callable< Void > createMapTask( final int startHeight, final int endHeight )
+	{
+		return Executors.callable( () -> map( startHeight, endHeight ), null );
+	}
+
+	/**
+	 * Copy lines from {@code y = startHeight} up to {@code endHeight}
+	 * (exclusive) from source to target. Check after
+	 * each line whether rendering was {@link #cancel() canceled}.
+	 *
+	 * @param startHeight
+	 *     start of line range to copy (relative to target min coordinate)
+	 * @param endHeight
+	 *     end (exclusive) of line range to copy (relative to target min
+	 *     coordinate)
+	 */
+	private void map( final int startHeight, final int endHeight )
+	{
+		if ( canceled.get() )
+			return;
+
+		final RandomAccess< B > targetRandomAccess = target.randomAccess( target );
+		final RandomAccess< A > sourceRandomAccess = source.randomAccess( sourceInterval );
+		final int width = ( int ) target.dimension( 0 );
+		final long[] smin = Intervals.minAsLongArray( sourceInterval );
+
+		final int targetMin = ( int ) target.min( 1 );
+		for ( int y = startHeight; y < endHeight; ++y )
+		{
+			if ( canceled.get() )
+				return;
+			smin[ 1 ] = y + targetMin;
+			sourceRandomAccess.setPosition( smin );
+			targetRandomAccess.setPosition( smin );
+			for ( int x = 0; x < width; ++x )
+			{
+				converter.convert( sourceRandomAccess.get(), targetRandomAccess.get() );
+				sourceRandomAccess.fwd( 0 );
+				targetRandomAccess.fwd( 0 );
+			}
+		}
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/TransformAwareBufferedImageOverlayRenderer.java b/src/main/java/bdv/viewer/render/TransformAwareBufferedImageOverlayRenderer.java
deleted file mode 100644
index 0296ce68ef4829e7d46182e5fb36ca34fe5512a5..0000000000000000000000000000000000000000
--- a/src/main/java/bdv/viewer/render/TransformAwareBufferedImageOverlayRenderer.java
+++ /dev/null
@@ -1,170 +0,0 @@
-/*
- * #%L
- * BigDataViewer core classes with minimal dependencies.
- * %%
- * Copyright (C) 2012 - 2020 BigDataViewer developers.
- * %%
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- * 
- * 1. Redistributions of source code must retain the above copyright notice,
- *    this list of conditions and the following disclaimer.
- * 2. Redistributions in binary form must reproduce the above copyright notice,
- *    this list of conditions and the following disclaimer in the documentation
- *    and/or other materials provided with the distribution.
- * 
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
- * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
- * POSSIBILITY OF SUCH DAMAGE.
- * #L%
- */
-package bdv.viewer.render;
-
-import java.awt.Graphics;
-import java.awt.Graphics2D;
-import java.awt.RenderingHints;
-import java.awt.image.BufferedImage;
-import java.util.concurrent.CopyOnWriteArrayList;
-
-import net.imglib2.realtransform.AffineTransform3D;
-import net.imglib2.ui.OverlayRenderer;
-import net.imglib2.ui.TransformListener;
-import net.imglib2.ui.overlay.BufferedImageOverlayRenderer;
-
-public class TransformAwareBufferedImageOverlayRenderer extends BufferedImageOverlayRenderer implements TransformAwareRenderTarget
-{
-	protected AffineTransform3D pendingTransform;
-
-	protected AffineTransform3D paintedTransform;
-
-	/**
-	 * These listeners will be notified about the transform that is associated
-	 * to the currently rendered image. This is intended for example for
-	 * {@link OverlayRenderer}s that need to exactly match the transform of
-	 * their overlaid content to the transform of the image.
-	 */
-	protected final CopyOnWriteArrayList< TransformListener< AffineTransform3D > > paintedTransformListeners;
-
-	public TransformAwareBufferedImageOverlayRenderer()
-	{
-		super();
-		pendingTransform = new AffineTransform3D();
-		paintedTransform = new AffineTransform3D();
-		paintedTransformListeners = new CopyOnWriteArrayList<>();
-	}
-
-	@Override
-	public synchronized BufferedImage setBufferedImageAndTransform( final BufferedImage img, final AffineTransform3D transform )
-	{
-		pendingTransform.set( transform );
-		return super.setBufferedImage( img );
-	}
-
-	@Override
-	public void drawOverlays( final Graphics g )
-	{
-		boolean notifyTransformListeners = false;
-		synchronized ( this )
-		{
-			if ( pending )
-			{
-				final BufferedImage tmp = bufferedImage;
-				bufferedImage = pendingImage;
-				paintedTransform.set( pendingTransform );
-				pendingImage = tmp;
-				pending = false;
-				notifyTransformListeners = true;
-			}
-		}
-		if ( bufferedImage != null )
-		{
-//			final StopWatch watch = new StopWatch();
-//			watch.start();
-//			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR );
-			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR );
-			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_SPEED );
-			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF );
-			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_SPEED );
-			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED );
-			g.drawImage( bufferedImage, 0, 0, getWidth(), getHeight(), null );
-			if ( notifyTransformListeners )
-				for ( final TransformListener< AffineTransform3D > listener : paintedTransformListeners )
-					listener.transformChanged( paintedTransform );
-//			System.out.println( String.format( "g.drawImage() :%4d ms", watch.nanoTime() / 1000000 ) );
-		}
-	}
-
-	/**
-	 * Add a {@link TransformListener} to notify about viewer transformation
-	 * changes. Listeners will be notified when a new image has been rendered
-	 * (immediately before that image is displayed) with the viewer transform
-	 * used to render that image.
-	 *
-	 * @param listener
-	 *            the transform listener to add.
-	 */
-	@Override
-	public void addTransformListener( final TransformListener< AffineTransform3D > listener )
-	{
-		addTransformListener( listener, Integer.MAX_VALUE );
-	}
-
-	/**
-	 * Add a {@link TransformListener} to notify about viewer transformation
-	 * changes. Listeners will be notified when a new image has been rendered
-	 * (immediately before that image is displayed) with the viewer transform
-	 * used to render that image.
-	 *
-	 * @param listener
-	 *            the transform listener to add.
-	 * @param index
-	 *            position in the list of listeners at which to insert this one.
-	 */
-	@Override
-	public void addTransformListener( final TransformListener< AffineTransform3D > listener, final int index )
-	{
-		synchronized ( paintedTransformListeners )
-		{
-			final int s = paintedTransformListeners.size();
-			paintedTransformListeners.add( index < 0 ? 0 : index > s ? s : index, listener );
-			listener.transformChanged( paintedTransform );
-		}
-	}
-
-	/**
-	 * Remove a {@link TransformListener}.
-	 *
-	 * @param listener
-	 *            the transform listener to remove.
-	 */
-	@Override
-	public void removeTransformListener( final TransformListener< AffineTransform3D > listener )
-	{
-		synchronized ( paintedTransformListeners )
-		{
-			paintedTransformListeners.remove( listener );
-		}
-	}
-
-	/**
-	 * DON'T USE THIS.
-	 * <p>
-	 * This is a work around for JDK bug
-	 * https://bugs.openjdk.java.net/browse/JDK-8029147 which leads to
-	 * ViewerPanel not being garbage-collected when ViewerFrame is closed. So
-	 * instead we need to manually let go of resources...
-	 */
-	void kill()
-	{
-		bufferedImage = null;
-		pendingImage = null;
-	}
-}
diff --git a/src/main/java/bdv/viewer/render/VisibilityUtils.java b/src/main/java/bdv/viewer/render/VisibilityUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..cb5b182c10d11187709b853657dda6156f3280bf
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/VisibilityUtils.java
@@ -0,0 +1,90 @@
+package bdv.viewer.render;
+
+import bdv.util.MipmapTransforms;
+import bdv.viewer.Interpolation;
+import bdv.viewer.Source;
+import bdv.viewer.SourceAndConverter;
+import bdv.viewer.ViewerState;
+import java.util.List;
+import java.util.Set;
+import net.imglib2.FinalRealInterval;
+import net.imglib2.Interval;
+import net.imglib2.realtransform.AffineTransform3D;
+
+class VisibilityUtils
+{
+	/**
+	 * Compute a list of sources are currently visible on screen.
+	 * <p>
+	 * This means that the sources
+	 * <ul>
+	 *     <li>are visible in the given {@code ViewerState}, and,</li>
+	 *     <li>when transformed to viewer coordinates, overlap the screen area.</li>
+	 * </ul>
+	 * The returned list of sources is sorted by {@code viewerState.sourceOrder()}.
+	 *
+	 * @param viewerState
+	 * 		specifies sources, transform, and current timepoint
+	 * @param screenScale
+	 * 		specifies screen size and scale transform
+	 * @param result
+	 * 		list of currently visible sources is stored here
+	 */
+	static void computeVisibleSourcesOnScreen(
+			final ViewerState viewerState,
+			final ScreenScales.ScreenScale screenScale,
+			final List< SourceAndConverter< ? > > result )
+	{
+		result.clear();
+
+		final int screenMinX = 0;
+		final int screenMinY = 0;
+		final int screenMaxX = screenScale.width() - 1;
+		final int screenMaxY = screenScale.height() - 1;
+
+		final AffineTransform3D screenTransform = viewerState.getViewerTransform();
+		screenTransform.preConcatenate( screenScale.scaleTransform() );
+
+		final AffineTransform3D sourceToScreen = new AffineTransform3D();
+		final double[] sourceMin = new double[ 3 ];
+		final double[] sourceMax = new double[ 3 ];
+
+		final Set< SourceAndConverter< ? > > sources = viewerState.getVisibleAndPresentSources();
+		final int t = viewerState.getCurrentTimepoint();
+		final double expand = viewerState.getInterpolation() == Interpolation.NEARESTNEIGHBOR ? 0.5 : 1.0;
+
+		for ( final SourceAndConverter< ? > source : sources )
+		{
+			final Source< ? > spimSource = source.getSpimSource();
+			final int level = MipmapTransforms.getBestMipMapLevel( screenTransform, spimSource, t );
+			spimSource.getSourceTransform( t, level, sourceToScreen );
+			sourceToScreen.preConcatenate( screenTransform );
+
+			final Interval interval = spimSource.getSource( t, level );
+			for ( int d = 0; d < 3; d++ )
+			{
+				sourceMin[ d ] = interval.realMin( d ) - expand;
+				sourceMax[ d ] = interval.realMax( d ) + expand;
+			}
+			final FinalRealInterval bb = sourceToScreen.estimateBounds( new FinalRealInterval( sourceMin, sourceMax ) );
+
+			if ( bb.realMax( 0 ) >= screenMinX
+					&& bb.realMin( 0 ) <= screenMaxX
+					&& bb.realMax( 1 ) >= screenMinY
+					&& bb.realMin( 1 ) <= screenMaxY
+					&& bb.realMax( 2 ) >= 0
+					&& bb.realMin( 2 ) <= 0 )
+			{
+				result.add( source );
+			}
+		}
+
+		result.sort( viewerState.sourceOrder() );
+	}
+	// TODO: Eventually, for thousands of sources, this could be moved to ViewerState,
+	//  in order to avoid creating a new intermediate HashSet for every
+	//  viewerState.getVisibleAndPresentSources().
+	//  However, other issues will become bottlenecks before that, e.g.,
+	//  copying source list when taking snapshots of ViewerState every frame,
+	//  painting MultiBoxOverlay, etc.
+}
diff --git a/src/main/java/bdv/viewer/render/VolatileHierarchyProjector.java b/src/main/java/bdv/viewer/render/VolatileHierarchyProjector.java
index 12c81e1ddf45cdc38dfa1b372befd3d9b3ead2c2..9d83f6b8fbd25d4f7e520f0cf20f0f5f475602f3 100644
--- a/src/main/java/bdv/viewer/render/VolatileHierarchyProjector.java
+++ b/src/main/java/bdv/viewer/render/VolatileHierarchyProjector.java
@@ -6,13 +6,13 @@
  * %%
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions are met:
- * 
+ *
  * 1. Redistributions of source code must retain the above copyright notice,
  *    this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
  *    this list of conditions and the following disclaimer in the documentation
  *    and/or other materials provided with the distribution.
- * 
+ *
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -36,10 +36,7 @@ import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
-
-import net.imglib2.Cursor;
 import net.imglib2.FinalInterval;
-import net.imglib2.IterableInterval;
 import net.imglib2.RandomAccess;
 import net.imglib2.RandomAccessible;
 import net.imglib2.RandomAccessibleInterval;
@@ -47,12 +44,9 @@ import net.imglib2.Volatile;
 import net.imglib2.cache.iotiming.CacheIoTiming;
 import net.imglib2.cache.iotiming.IoStatistics;
 import net.imglib2.converter.Converter;
-import net.imglib2.img.Img;
-import net.imglib2.img.array.ArrayImgs;
-import net.imglib2.type.numeric.NumericType;
-import net.imglib2.type.numeric.integer.ByteType;
-import net.imglib2.ui.AbstractInterruptibleProjector;
-import net.imglib2.ui.util.StopWatch;
+import net.imglib2.type.operators.SetZero;
+import net.imglib2.util.Intervals;
+import net.imglib2.util.StopWatch;
 import net.imglib2.view.Views;
 
 /**
@@ -60,75 +54,87 @@ import net.imglib2.view.Views;
  * {@link #map()} call, the projector has a {@link #isValid() state} that
  * signalizes whether all projected pixels were perfect.
  *
- * @author Stephan Saalfeld &lt;saalfeld@mpi-cbg.de&gt;
- * @author Tobias Pietzsch &lt;tobias.pietzsch@gmail.com&gt;
+ * @author Stephan Saalfeld
+ * @author Tobias Pietzsch
  */
-public class VolatileHierarchyProjector< A extends Volatile< ? >, B extends NumericType< B > > extends AbstractInterruptibleProjector< A, B > implements VolatileProjector
+public class VolatileHierarchyProjector< A extends Volatile< ? >, B extends SetZero > implements VolatileProjector
 {
-	protected final ArrayList< RandomAccessible< A > > sources = new ArrayList<>();
-
-	private final byte[] maskArray;
-
-	protected final Img< ByteType > mask;
-
-	protected volatile boolean valid = false;
+	/**
+	 * A converter from the source pixel type to the target pixel type.
+	 */
+	private final Converter< ? super A, B > converter;
 
-	protected int numInvalidLevels;
+	/**
+	 * The target interval. Pixels of the target interval should be set by
+	 * {@link #map}
+	 */
+	private final RandomAccessibleInterval< B > target;
 
 	/**
-	 * Extends of the source to be used for mapping.
+	 * List of source resolutions starting with the optimal resolution at index
+	 * 0. During each {@link #map(boolean)}, for every pixel, resolution levels
+	 * are successively queried until a valid pixel is found.
 	 */
-	protected final FinalInterval sourceInterval;
+	private final List< RandomAccessible< A > > sources;
 
 	/**
-	 * Target width
+	 * Records, for every target pixel, the best (smallest index) source
+	 * resolution level that has provided a valid value. Only better (lower
+	 * index) resolutions are re-tried in successive {@link #map(boolean)}
+	 * calls.
 	 */
-	protected final int width;
+	private final byte[] mask;
 
 	/**
-	 * Target height
+	 * {@code true} iff all target pixels were rendered with valid data from the
+	 * optimal resolution level (level {@code 0}).
 	 */
-	protected final int height;
+	private volatile boolean valid = false;
 
 	/**
-	 * Steps for carriage return. Typically -{@link #width}
+	 * How many levels (starting from level {@code 0}) have to be re-rendered in
+	 * the next rendering pass, i.e., {@code map()} call.
 	 */
-	protected final int cr;
+	private int numInvalidLevels;
 
 	/**
-	 * A reference to the target image as an iterable. Used for source-less
-	 * operations such as clearing its content.
+	 * Source interval which will be used for rendering. This is the 2D target
+	 * interval expanded to source dimensionality (usually 3D) with
+	 * {@code min=max=0} in the additional dimensions.
 	 */
-	protected final IterableInterval< B > iterableTarget;
+	private final FinalInterval sourceInterval;
 
 	/**
 	 * Number of threads to use for rendering
 	 */
-	protected final int numThreads;
+	private final int numThreads;
 
-	protected final ExecutorService executorService;
+	/**
+	 * Executor service to be used for rendering
+	 */
+	private final ExecutorService executorService;
 
 	/**
 	 * Time needed for rendering the last frame, in nano-seconds.
 	 * This does not include time spent in blocking IO.
 	 */
-	protected long lastFrameRenderNanoTime;
+	private long lastFrameRenderNanoTime;
 
 	/**
 	 * Time spent in blocking IO rendering the last frame, in nano-seconds.
 	 */
-	protected long lastFrameIoNanoTime; // TODO move to derived implementation for local sources only
+	private long lastFrameIoNanoTime; // TODO move to derived implementation for local sources only
 
 	/**
 	 * temporary variable to store the number of invalid pixels in the current
 	 * rendering pass.
 	 */
-	protected final AtomicInteger numInvalidPixels = new AtomicInteger();
+	private final AtomicInteger numInvalidPixels = new AtomicInteger();
 
 	/**
-	 * Flag to indicate that someone is trying to interrupt rendering.
+	 * Flag to indicate that someone is trying to {@link #cancel()} rendering.
 	 */
-	protected final AtomicBoolean interrupted = new AtomicBoolean();
+	private final AtomicBoolean canceled = new AtomicBoolean();
 
 	public VolatileHierarchyProjector(
 			final List< ? extends RandomAccessible< A > > sources,
@@ -148,27 +154,21 @@ public class VolatileHierarchyProjector< A extends Volatile< ? >, B extends Nume
 			final int numThreads,
 			final ExecutorService executorService )
 	{
-		super( Math.max( 2, sources.get( 0 ).numDimensions() ), converter, target );
-
-		this.sources.addAll( sources );
+		this.converter = converter;
+		this.target = target;
+		this.sources = new ArrayList<>( sources );
 		numInvalidLevels = sources.size();
+		mask = maskArray;
 
-		this.maskArray = maskArray;
-		mask = ArrayImgs.bytes( maskArray, target.dimension( 0 ), target.dimension( 1 ) );
-
-		iterableTarget = Views.iterable( target );
-
-		for ( int d = 2; d < min.length; ++d )
-			min[ d ] = max[ d ] = 0;
-
+		final int n = Math.max( 2, sources.get( 0 ).numDimensions() );
+		final long[] min = new long[ n ];
+		final long[] max = new long[ n ];
+		min[ 0 ] = target.min( 0 );
 		max[ 0 ] = target.max( 0 );
+		min[ 1 ] = target.min( 1 );
 		max[ 1 ] = target.max( 1 );
 		sourceInterval = new FinalInterval( min, max );
 
-		width = ( int )target.dimension( 0 );
-		height = ( int )target.dimension( 1 );
-		cr = -width;
-
 		this.numThreads = numThreads;
 		this.executorService = executorService;
 
@@ -179,7 +179,7 @@ public class VolatileHierarchyProjector< A extends Volatile< ? >, B extends Nume
 	@Override
 	public void cancel()
 	{
-		interrupted.set( true );
+		canceled.set( true );
 	}
 
 	@Override
@@ -205,164 +205,175 @@ public class VolatileHierarchyProjector< A extends Volatile< ? >, B extends Nume
 	 */
 	public void clearMask()
 	{
-		Arrays.fill( maskArray, 0, ( int ) mask.size(), Byte.MAX_VALUE );
+		final int size = ( int ) Intervals.numElements( target );
+		Arrays.fill( mask, 0, size, Byte.MAX_VALUE );
 		numInvalidLevels = sources.size();
 	}
 
 	/**
 	 * Clear target pixels that were never written.
 	 */
-	protected void clearUntouchedTargetPixels()
+	private void clearUntouchedTargetPixels()
 	{
-		final Cursor< ByteType > maskCursor = mask.cursor();
-		for ( final B t : iterableTarget )
-			if ( maskCursor.next().get() == Byte.MAX_VALUE )
-				t.setZero();
-	}
-
-	@Override
-	public boolean map()
-	{
-		return map( true );
+		final int[] data = ProjectorUtils.getARGBArrayImgData( target );
+		if ( data != null )
+		{
+			final int size = ( int ) Intervals.numElements( target );
+			for ( int i = 0; i < size; ++i )
+				if ( mask[ i ] == Byte.MAX_VALUE )
+					data[ i ] = 0;
+		}
+		else
+		{
+			int i = 0;
+			for ( final B t : Views.flatIterable( target ) )
+				if ( mask[ i++ ] == Byte.MAX_VALUE )
+					t.setZero();
+		}
 	}
 
 	@Override
 	public boolean map( final boolean clearUntouchedTargetPixels )
 	{
-		interrupted.set( false );
+		if ( canceled.get() )
+			return false;
 
-		final StopWatch stopWatch = new StopWatch();
-		stopWatch.start();
+		valid = false;
+
+		final StopWatch stopWatch = StopWatch.createAndStart();
 		final IoStatistics iostat = CacheIoTiming.getIoStatistics();
 		final long startTimeIo = iostat.getIoNanoTime();
 		final long startTimeIoCumulative = iostat.getCumulativeIoNanoTime();
-//		final long startIoBytes = iostat.getIoBytes();
-
-		final int numTasks;
-		if ( numThreads > 1 )
-		{
-			numTasks = Math.min( numThreads * 10, height );
-		}
-		else
-			numTasks = 1;
-		final double taskHeight = ( double )height / numTasks;
 
-		int i;
-
-		valid = false;
+		final int targetHeight = ( int ) target.dimension( 1 );
+		final int numTasks = numThreads <= 1 ? 1 : Math.min( numThreads * 10, targetHeight );
+		final double taskHeight = ( double ) targetHeight / numTasks;
+		final int[] taskStartHeights = new int[ numTasks + 1 ];
+		for ( int i = 0; i < numTasks; ++i )
+			taskStartHeights[ i ] = ( int ) ( i * taskHeight );
+		taskStartHeights[ numTasks ] = targetHeight;
 
 		final boolean createExecutor = ( executorService == null );
 		final ExecutorService ex = createExecutor ? Executors.newFixedThreadPool( numThreads ) : executorService;
-		for ( i = 0; i < numInvalidLevels && !valid; ++i )
+		try
 		{
-			final byte iFinal = ( byte ) i;
-
-			valid = true;
-			numInvalidPixels.set( 0 );
-
-			final ArrayList< Callable< Void > > tasks = new ArrayList<>( numTasks );
-			for ( int taskNum = 0; taskNum < numTasks; ++taskNum )
+			/*
+			 * After the for loop, resolutionLevel is the highest (coarsest)
+			 * resolution for which all pixels could be filled from valid data. This
+			 * means that in the next pass, i.e., map() call, levels up to
+			 * resolutionLevel have to be re-rendered.
+			 */
+			int resolutionLevel;
+			for ( resolutionLevel = 0; resolutionLevel < numInvalidLevels; ++resolutionLevel )
 			{
-				final int myOffset = width * ( int ) ( taskNum * taskHeight );
-				final long myMinY = min[ 1 ] + ( int ) ( taskNum * taskHeight );
-				final int myHeight = ( int ) ( ( (taskNum == numTasks - 1 ) ? height : ( int ) ( ( taskNum + 1 ) * taskHeight ) ) - myMinY - min[ 1 ] );
-
-				final Callable< Void > r = new Callable< Void >()
+				final List< Callable< Void > > tasks = new ArrayList<>( numTasks );
+				for ( int i = 0; i < numTasks; ++i )
+					tasks.add( createMapTask( ( byte ) resolutionLevel, taskStartHeights[ i ], taskStartHeights[ i + 1 ] ) );
+				numInvalidPixels.set( 0 );
+				try
 				{
-					@Override
-					public Void call()
-					{
-						if ( interrupted.get() )
-							return null;
-
-						final RandomAccess< B > targetRandomAccess = target.randomAccess( target );
-						final Cursor< ByteType > maskCursor = mask.cursor();
-						final RandomAccess< A > sourceRandomAccess = sources.get( iFinal ).randomAccess( sourceInterval );
-						int myNumInvalidPixels = 0;
-
-						final long[] smin = new long[ n ];
-						System.arraycopy( min, 0, smin, 0, n );
-						smin[ 1 ] = myMinY;
-						sourceRandomAccess.setPosition( smin );
-
-						targetRandomAccess.setPosition( min[ 0 ], 0 );
-						targetRandomAccess.setPosition( myMinY, 1 );
-
-						maskCursor.jumpFwd( myOffset );
-
-						for ( int y = 0; y < myHeight; ++y )
-						{
-							if ( interrupted.get() )
-								return null;
-
-							for ( int x = 0; x < width; ++x )
-							{
-								final ByteType m = maskCursor.next();
-								if ( m.get() > iFinal )
-								{
-									final A a = sourceRandomAccess.get();
-									final boolean v = a.isValid();
-									if ( v )
-									{
-										converter.convert( a, targetRandomAccess.get() );
-										m.set( iFinal );
-									}
-									else
-										++myNumInvalidPixels;
-								}
-								sourceRandomAccess.fwd( 0 );
-								targetRandomAccess.fwd( 0 );
-							}
-							++smin[ 1 ];
-							sourceRandomAccess.setPosition( smin );
-							targetRandomAccess.move( cr, 0 );
-							targetRandomAccess.fwd( 1 );
-						}
-						numInvalidPixels.addAndGet( myNumInvalidPixels );
-						if ( myNumInvalidPixels != 0 )
-							valid = false;
-						return null;
-					}
-				};
-				tasks.add( r );
-			}
-			try
-			{
-				ex.invokeAll( tasks );
-			}
-			catch ( final InterruptedException e )
-			{
-				Thread.currentThread().interrupt();
-			}
-			if ( interrupted.get() )
-			{
-//				System.out.println( "interrupted" );
-				if ( createExecutor )
-					ex.shutdown();
-				return false;
+					ex.invokeAll( tasks );
+				}
+				catch ( final InterruptedException e )
+				{
+					Thread.currentThread().interrupt();
+				}
+				if ( canceled.get() )
+					return false;
+				if ( numInvalidPixels.get() == 0 )
+					// if this pass was all valid
+					numInvalidLevels = resolutionLevel;
 			}
-//			System.out.println( "numInvalidPixels(" + i + ") = " + numInvalidPixels );
 		}
-		if ( createExecutor )
-			ex.shutdown();
+		finally
+		{
+			if ( createExecutor )
+				ex.shutdown();
+		}
 
-		if ( clearUntouchedTargetPixels && !interrupted.get() )
+		if ( clearUntouchedTargetPixels && !canceled.get() )
 			clearUntouchedTargetPixels();
 
 		final long lastFrameTime = stopWatch.nanoTime();
-//		final long numIoBytes = iostat.getIoBytes() - startIoBytes;
 		lastFrameIoNanoTime = iostat.getIoNanoTime() - startTimeIo;
 		lastFrameRenderNanoTime = lastFrameTime - ( iostat.getCumulativeIoNanoTime() - startTimeIoCumulative ) / numThreads;
-
 //		System.out.println( "lastFrameTime = " + lastFrameTime / 1000000 );
 //		System.out.println( "lastFrameRenderNanoTime = " + lastFrameRenderNanoTime / 1000000 );
 
-		if ( valid )
-			numInvalidLevels = i - 1;
 		valid = numInvalidLevels == 0;
 
-//		System.out.println( "Mapping complete after " + ( s + 1 ) + " levels." );
+		return !canceled.get();
+	}
+
+	/**
+	 * @return a {@code Callable} that runs
+	 * {@code map(resolutionIndex, startHeight, endHeight)}
+	 */
+	private Callable< Void > createMapTask( final byte resolutionIndex, final int startHeight, final int endHeight )
+	{
+		return Executors.callable( () -> map( resolutionIndex, startHeight, endHeight ), null );
+	}
+
+	/**
+	 * Copy lines from {@code y = startHeight} up to {@code endHeight}
+	 * (exclusive) from source {@code resolutionIndex} to target. Check after
+	 * each line whether rendering was {@link #cancel() canceled}.
+	 * <p>
+	 * Only valid source pixels with a current mask value
+	 * {@code mask>resolutionIndex} are copied to target, and their mask value
+	 * is set to {@code mask=resolutionIndex}. Invalid source pixels are
+	 * ignored. Pixels with {@code mask<=resolutionIndex} are ignored, because
+	 * they have already been written to target during a previous pass.
+	 * <p>
+	 *
+	 * @param resolutionIndex
+	 *     index of source resolution level
+	 * @param startHeight
+	 *     start of line range to copy (relative to target min coordinate)
+	 * @param endHeight
+	 *     end (exclusive) of line range to copy (relative to target min
+	 *     coordinate)
+	 */
+	private void map( final byte resolutionIndex, final int startHeight, final int endHeight )
+	{
+		if ( canceled.get() )
+			return;
+
+		final RandomAccess< B > targetRandomAccess = target.randomAccess( target );
+		final RandomAccess< A > sourceRandomAccess = sources.get( resolutionIndex ).randomAccess( sourceInterval );
+		final int width = ( int ) target.dimension( 0 );
+		final long[] smin = Intervals.minAsLongArray( sourceInterval );
+		int myNumInvalidPixels = 0;
+
+		final int targetMin = ( int ) target.min( 1 );
+		for ( int y = startHeight; y < endHeight; ++y )
+		{
+			if ( canceled.get() )
+				return;
+
+			smin[ 1 ] = y + targetMin;
+			sourceRandomAccess.setPosition( smin );
+			targetRandomAccess.setPosition( smin );
+			final int mi = y * width;
+			for ( int x = 0; x < width; ++x )
+			{
+				if ( mask[ mi + x ] > resolutionIndex )
+				{
+					final A a = sourceRandomAccess.get();
+					final boolean v = a.isValid();
+					if ( v )
+					{
+						converter.convert( a, targetRandomAccess.get() );
+						mask[ mi + x ] = resolutionIndex;
+					}
+					else
+						++myNumInvalidPixels;
+				}
+				sourceRandomAccess.fwd( 0 );
+				targetRandomAccess.fwd( 0 );
+			}
+		}
 
-		return !interrupted.get();
+		numInvalidPixels.addAndGet( myNumInvalidPixels );
 	}
 }
diff --git a/src/main/java/bdv/viewer/render/VolatileProjector.java b/src/main/java/bdv/viewer/render/VolatileProjector.java
index 0460c969e1ebd6ed88059bae908a4b8e2eea5069..4e94698a5148b3fc9067b770cf39686ad8ffdd15 100644
--- a/src/main/java/bdv/viewer/render/VolatileProjector.java
+++ b/src/main/java/bdv/viewer/render/VolatileProjector.java
@@ -28,10 +28,16 @@
  */
 package bdv.viewer.render;
 
-import net.imglib2.Volatile;
-import net.imglib2.ui.InterruptibleProjector;
-
-public interface VolatileProjector extends InterruptibleProjector
+/**
+ * Render a 2D target copying pixels from a nD source.
+ * <p>
+ * Rendering can be interrupted, in which case {@link #map} will return false.
+ * Also, the rendering time for the last {@link #map} can be queried.
+ *
+ * @author Tobias Pietzsch
+ * @author Stephan Saalfeld
+ */
+public interface VolatileProjector
 {
 	/**
 	 * Render the target image.
@@ -40,10 +46,27 @@ public interface VolatileProjector extends InterruptibleProjector
 	 * @return true if rendering was completed (all target pixels written).
 	 *         false if rendering was interrupted.
 	 */
-	public boolean map( boolean clearUntouchedTargetPixels );
+	boolean map( boolean clearUntouchedTargetPixels );
+
+	default boolean map()
+	{
+		return map( true );
+	}
+
+	/**
+	 * Abort {@link #map()} if it is currently running.
+	 */
+	void cancel();
+
+	/**
+	 * How many nano-seconds did the last {@link #map()} take.
+	 *
+	 * @return time needed for rendering the last frame, in nano-seconds.
+	 */
+	long getLastFrameRenderNanoTime();
 
 	/**
-	 * @return true if all mapped pixels were {@link Volatile#isValid() valid}.
+	 * @return true if all mapped pixels were valid.
 	 */
-	public boolean isValid();
+	boolean isValid();
 }
diff --git a/src/main/java/bdv/viewer/render/awt/BufferedImageOverlayRenderer.java b/src/main/java/bdv/viewer/render/awt/BufferedImageOverlayRenderer.java
new file mode 100644
index 0000000000000000000000000000000000000000..1ba2b08059cd768a7ae1e0641154649e13b55478
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/awt/BufferedImageOverlayRenderer.java
@@ -0,0 +1,182 @@
+/*
+ * #%L
+ * ImgLib2: a general-purpose, multidimensional image processing library.
+ * %%
+ * Copyright (C) 2009 - 2016 Tobias Pietzsch, Stephan Preibisch, Stephan Saalfeld,
+ * John Bogovic, Albert Cardona, Barry DeZonia, Christian Dietz, Jan Funke,
+ * Aivar Grislis, Jonathan Hale, Grant Harris, Stefan Helfrich, Mark Hiner,
+ * Martin Horn, Steffen Jaensch, Lee Kamentsky, Larry Lindsey, Melissa Linkert,
+ * Mark Longair, Brian Northan, Nick Perry, Curtis Rueden, Johannes Schindelin,
+ * Jean-Yves Tinevez and Michael Zinsmaier.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv.viewer.render.awt;
+
+import bdv.util.TripleBuffer;
+import bdv.util.TripleBuffer.ReadableBuffer;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import net.imglib2.realtransform.AffineTransform3D;
+import bdv.viewer.OverlayRenderer;
+import bdv.viewer.render.RenderTarget;
+import bdv.viewer.TransformListener;
+import org.scijava.listeners.Listeners;
+
+/**
+ * {@link OverlayRenderer} drawing a {@link BufferedImage}, scaled to fill the
+ * canvas. It can be used as a {@link RenderTarget}, such that the
+ * {@link BufferedImageRenderResult} to draw is set by a renderer.
+ *
+ * @author Tobias Pietzsch
+ */
+public class BufferedImageOverlayRenderer implements OverlayRenderer, RenderTarget< BufferedImageRenderResult >
+{
+	private final TripleBuffer< BufferedImageRenderResult > tripleBuffer;
+
+	/**
+	 * These listeners will be notified about the transform that is associated
+	 * to the currently rendered image. This is intended for example for
+	 * {@link OverlayRenderer}s that need to exactly match the transform of
+	 * their overlaid content to the transform of the image.
+	 */
+	private final Listeners.List< TransformListener< AffineTransform3D > > paintedTransformListeners;
+
+	private final AffineTransform3D paintedTransform;
+
+	/**
+	 * The current canvas width.
+	 */
+	private volatile int width;
+
+	/**
+	 * The current canvas height.
+	 */
+	private volatile int height;
+
+	public BufferedImageOverlayRenderer()
+	{
+		tripleBuffer = new TripleBuffer<>( BufferedImageRenderResult::new );
+		width = 0;
+		height = 0;
+		paintedTransform = new AffineTransform3D();
+		paintedTransformListeners = new Listeners.SynchronizedList<>( l -> l.transformChanged( paintedTransform ) );
+	}
+
+	@Override
+	public BufferedImageRenderResult getReusableRenderResult()
+	{
+		return tripleBuffer.getWritableBuffer();
+	}
+
+	@Override
+	public BufferedImageRenderResult createRenderResult()
+	{
+		return new BufferedImageRenderResult();
+	}
+
+	/**
+	 * Set the {@code RenderResult} that is to be drawn on the canvas.
+	 *
+	 * @param result
+	 *            image to draw (may be null).
+	 */
+	@Override
+	public synchronized void setRenderResult( final BufferedImageRenderResult result )
+	{
+		tripleBuffer.doneWriting( result );
+	}
+
+	@Override
+	public int getWidth()
+	{
+		return width;
+	}
+
+	@Override
+	public int getHeight()
+	{
+		return height;
+	}
+
+	@Override
+	public void drawOverlays( final Graphics g )
+	{
+		final ReadableBuffer< BufferedImageRenderResult > rb = tripleBuffer.getReadableBuffer();
+		final BufferedImageRenderResult result = rb.getBuffer();
+		final BufferedImage image = result != null ? result.getBufferedImage() : null;
+		if ( image != null )
+		{
+//			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR );
+			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR );
+			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_SPEED );
+			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF );
+			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_SPEED );
+			( ( Graphics2D ) g ).setRenderingHint( RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED );
+
+			final double scaleFactor = result.getScaleFactor();
+			final int w = Math.max( width, ( int ) ( image.getWidth() / scaleFactor + 0.5 ) );
+			final int h = Math.max( height, ( int ) ( image.getHeight() / scaleFactor + 0.5 ) );
+			g.drawImage( image, 0, 0, w, h, null );
+
+			if ( rb.isUpdated() )
+			{
+				paintedTransform.set( result.getViewerTransform() );
+				paintedTransformListeners.list.forEach( listener -> listener.transformChanged( paintedTransform ) );
+			}
+		}
+	}
+
+	@Override
+	public void setCanvasSize( final int width, final int height )
+	{
+		this.width = width;
+		this.height = height;
+	}
+
+	/**
+	 * Add/remove {@code TransformListener}s to notify about viewer transformation
+	 * changes. Listeners will be notified when a new image has been rendered
+	 * (immediately before that image is displayed) with the viewer transform
+	 * used to render that image.
+	 */
+	public Listeners< TransformListener< AffineTransform3D > > transformListeners()
+	{
+		return paintedTransformListeners;
+	}
+
+	/**
+	 * DON'T USE THIS.
+	 * <p>
+	 * This is a work around for JDK bug
+	 * https://bugs.openjdk.java.net/browse/JDK-8029147 which leads to
+	 * ViewerPanel not being garbage-collected when ViewerFrame is closed. So
+	 * instead we need to manually let go of resources...
+	 */
+	public void kill()
+	{
+		tripleBuffer.clear();
+	}
+}
diff --git a/src/main/java/bdv/viewer/render/awt/BufferedImageRenderResult.java b/src/main/java/bdv/viewer/render/awt/BufferedImageRenderResult.java
new file mode 100644
index 0000000000000000000000000000000000000000..2b9d882e9f4fccf6d514b9bb033611604cc51f3a
--- /dev/null
+++ b/src/main/java/bdv/viewer/render/awt/BufferedImageRenderResult.java
@@ -0,0 +1,101 @@
+package bdv.viewer.render.awt;
+
+import bdv.util.AWTUtils;
+import bdv.viewer.render.RenderResult;
+import java.awt.geom.AffineTransform;
+import java.awt.image.AffineTransformOp;
+import java.awt.image.BufferedImage;
+import net.imglib2.Interval;
+import net.imglib2.RandomAccessibleInterval;
+import net.imglib2.display.screenimage.awt.ARGBScreenImage;
+import net.imglib2.realtransform.AffineTransform3D;
+import net.imglib2.type.numeric.ARGBType;
+
+public class BufferedImageRenderResult implements RenderResult
+{
+	private final AffineTransform3D viewerTransform = new AffineTransform3D();
+
+	private int width;
+	private int height;
+
+	private int[] data = new int[ 0 ];
+
+	private ARGBScreenImage screenImage;
+
+	private BufferedImage bufferedImage;
+
+	private double scaleFactor;
+
+	@Override
+	public void init( final int width, final int height )
+	{
+		if ( this.width == width && this.height == height )
+			return;
+
+		this.width = width;
+		this.height = height;
+
+		if ( data.length < width * height )
+			data = new int[ width * height ];
+
+		screenImage = new ARGBScreenImage( width, height, data );
+		bufferedImage = AWTUtils.getBufferedImage( screenImage );;
+	}
+
+	@Override
+	public RandomAccessibleInterval< ARGBType > getTargetImage()
+	{
+		return screenImage;
+	}
+
+	public BufferedImage getBufferedImage()
+	{
+		return bufferedImage;
+	}
+
+	@Override
+	public AffineTransform3D getViewerTransform()
+	{
+		return viewerTransform;
+	}
+
+	@Override
+	public double getScaleFactor()
+	{
+		return scaleFactor;
+	}
+
+	@Override
+	public void setScaleFactor( final double scaleFactor )
+	{
+		this.scaleFactor = scaleFactor;
+	}
+
+	@Override
+	public void setUpdated()
+	{
+		// ignored. BufferedImage is always up-to-date
+	}
+
+	@Override
+	public void patch( final RenderResult patch, final Interval interval, final double ox, final double oy )
+	{
+		final BufferedImageRenderResult biresult = ( BufferedImageRenderResult ) patch;
+
+		final double s = scaleFactor / patch.getScaleFactor();
+		final double tx = ox - interval.min( 0 );
+		final double ty = oy - interval.min( 1 );
+		final AffineTransform transform = new AffineTransform( s, 0, 0, s, tx, ty );
+		final AffineTransformOp op = new AffineTransformOp( transform, AffineTransformOp.TYPE_NEAREST_NEIGHBOR );
+		op.filter( biresult.getBufferedImage(), subImage( interval ) );
+	}
+
+	private BufferedImage subImage( final Interval interval )
+	{
+		final int x = ( int ) interval.min( 0 );
+		final int y = ( int ) interval.min( 1 );
+		final int w = ( int ) interval.dimension( 0 );
+		final int h = ( int ) interval.dimension( 1 );
+		return bufferedImage.getSubimage( x, y, w, h );
+	}
+}
diff --git a/src/test/java/bdv/IntervalPaintingExample.java b/src/test/java/bdv/IntervalPaintingExample.java
new file mode 100644
index 0000000000000000000000000000000000000000..54cb871c475372dcf70271316bcb2ebea537c1a4
--- /dev/null
+++ b/src/test/java/bdv/IntervalPaintingExample.java
@@ -0,0 +1,147 @@
+/*-
+ * #%L
+ * BigDataViewer core classes with minimal dependencies
+ * %%
+ * Copyright (C) 2012 - 2016 Tobias Pietzsch, Stephan Saalfeld, Stephan Preibisch,
+ * Jean-Yves Tinevez, HongKee Moon, Johannes Schindelin, Curtis Rueden, John Bogovic
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package bdv;
+
+import bdv.export.ProgressWriterConsole;
+import bdv.viewer.ViewerFrame;
+import bdv.viewer.ViewerOptions;
+import bdv.viewer.ViewerPanel;
+import java.awt.Color;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.io.File;
+import mpicbg.spim.data.SpimDataException;
+import net.imglib2.Interval;
+import bdv.viewer.OverlayRenderer;
+import net.imglib2.util.Intervals;
+import org.scijava.ui.behaviour.DragBehaviour;
+import org.scijava.ui.behaviour.io.InputTriggerConfig;
+import org.scijava.ui.behaviour.util.Behaviours;
+
+public class IntervalPaintingExample
+{
+	public static void main( final String[] args ) throws SpimDataException
+	{
+		final String fn = "/Users/pietzsch/workspace/data/111010_weber_full.xml";
+		System.setProperty( "apple.laf.useScreenMenuBar", "true" );
+		final BigDataViewer bdv = BigDataViewer.open(
+				fn,
+				new File( fn ).getName(),
+				new ProgressWriterConsole(),
+				ViewerOptions.options() );
+		new IntervalPaintingExample( bdv.getViewerFrame() );
+	}
+
+	public IntervalPaintingExample( final ViewerFrame viewerFrame )
+	{
+		this.viewer = viewerFrame.getViewerPanel();
+		viewer.getDisplay().overlays().add( new Overlay() );
+
+		final Behaviours behaviours = new Behaviours( new InputTriggerConfig(), "bdv" );
+		behaviours.behaviour( new RepaintIntervalBehaviour(), "repaint interval", "SPACE" );
+		behaviours.install( viewerFrame.getTriggerbindings(), "repaint" );
+	}
+
+	private final ViewerPanel viewer;
+
+	private Interval repaintInterval;
+
+	private final int halfw = 30;
+
+	private final int halfh = 30;
+
+	private synchronized void repaint( final int x, final int y )
+	{
+		if ( x < 0 || y < 0 )
+			repaintInterval = null;
+		else
+			repaintInterval = Intervals.createMinMax( x - halfw, y - halfh, x + halfw, y + halfh );
+
+		if ( repaintInterval != null )
+			viewer.requestRepaint( repaintInterval );
+
+		viewer.getDisplay().repaint();
+	}
+
+	private synchronized void drawOverlay( final Graphics2D g )
+	{
+		if ( repaintInterval != null )
+		{
+			g.setColor( Color.green );
+			draw( g, Intervals.expand( repaintInterval, 10 ) );
+			g.setColor( Color.red );
+			draw( g, Intervals.expand( repaintInterval, -10 ) );
+		}
+	}
+
+	private static void draw( final Graphics2D g, final Interval interval )
+	{
+		final int x = ( int ) interval.min( 0 );
+		final int y = ( int ) interval.min( 1 );
+		final int w = ( int ) interval.dimension( 0 );
+		final int h = ( int ) interval.dimension( 1 );
+		g.drawRect( x, y, w, h );
+	}
+
+	private class RepaintIntervalBehaviour implements DragBehaviour
+	{
+		@Override
+		public void init( final int x, final int y )
+		{
+			repaint( x, y );
+		}
+
+		@Override
+		public void drag( final int x, final int y )
+		{
+			repaint( x, y );
+		}
+
+		@Override
+		public void end( final int x, final int y )
+		{
+			repaint( -1, -1 );
+		}
+	}
+
+	private class Overlay implements OverlayRenderer
+	{
+		@Override
+		public void drawOverlays( final Graphics g )
+		{
+			drawOverlay( ( Graphics2D ) g );
+		}
+
+		@Override
+		public void setCanvasSize( final int width, final int height )
+		{
+		}
+	}
+}
diff --git a/src/test/java/bdv/util/MovingAverageTest.java b/src/test/java/bdv/util/MovingAverageTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..5a2f851b05a7119c4ab9b82f68fbfac953f1b7a7
--- /dev/null
+++ b/src/test/java/bdv/util/MovingAverageTest.java
@@ -0,0 +1,36 @@
+package bdv.util;
+
+import java.util.Arrays;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class MovingAverageTest
+{
+	@Test
+	public void testAverage()
+	{
+		final double[] values = new double[ 1000 ];
+		Arrays.setAll( values, i -> Math.random() );
+
+		final int width = 3;
+		final MovingAverage avg = new MovingAverage( width );
+		avg.init( 0 );
+
+		for ( int i = 0; i < values.length; ++i )
+		{
+			avg.add( values[ i ] );
+			double expected = average( values, i - width + 1, i + 1 );
+			assertEquals( expected, avg.getAverage(), 1e-6 );
+		}
+	}
+
+	private static double average( final double[] values, final int fromIndex, final int toIndex )
+	{
+		final int numValues = toIndex - fromIndex;
+		double sum = 0;
+		for ( int i = fromIndex; i < toIndex; i++ )
+			sum += i < 0 ? 0 : values[ i ];
+		return sum / numValues;
+	}
+}
diff --git a/src/test/java/bdv/util/TripleBufferDemo.java b/src/test/java/bdv/util/TripleBufferDemo.java
new file mode 100644
index 0000000000000000000000000000000000000000..ed2f632f70236cb213496c61a18337472f28da63
--- /dev/null
+++ b/src/test/java/bdv/util/TripleBufferDemo.java
@@ -0,0 +1,83 @@
+package bdv.util;
+
+import java.awt.Graphics;
+
+import javax.swing.JComponent;
+import javax.swing.JFrame;
+
+import net.imglib2.display.screenimage.awt.ARGBScreenImage;
+import net.imglib2.img.array.ArrayCursor;
+import net.imglib2.type.numeric.ARGBType;
+
+/**
+ * Test {@link TripleBuffer}.
+ *
+ * @author Matthias Arzt
+ */
+public class TripleBufferDemo
+{
+	private static TripleBuffer< ARGBScreenImage > tripleBuffer = new TripleBuffer<>( () -> new ARGBScreenImage( 100, 100 ) );
+
+	private static ImageComponent imageComponent = new ImageComponent();
+
+	public static void main( final String... args ) throws InterruptedException
+	{
+		final JFrame frame = new JFrame();
+		frame.setSize( 100, 100 );
+		frame.add( imageComponent );
+		frame.setVisible( true );
+
+		new PainterThread().start();
+	}
+
+	private static class PainterThread extends Thread
+	{
+		@Override
+		public void run()
+		{
+			try
+			{
+				while ( true )
+				{
+					for ( double r = 0; r < 2 * Math.PI; r += 0.01 )
+					{
+						renderCircle( r );
+						Thread.sleep( 1 );
+						imageComponent.repaint();
+					}
+				}
+			}
+			catch ( final InterruptedException e )
+			{
+				e.printStackTrace();
+			}
+		}
+
+		private void renderCircle( final double r )
+		{
+			final ARGBScreenImage image = tripleBuffer.getWritableBuffer();
+			image.forEach( pixel -> pixel.set( 0xff00ff00 ) );
+			final ArrayCursor< ARGBType > cursor = image.cursor();
+			while ( cursor.hasNext() )
+			{
+				final ARGBType pixel = cursor.next();
+				final double x = cursor.getDoublePosition( 0 );
+				final double y = cursor.getDoublePosition( 1 );
+				pixel.set( ARGBType.rgba( Math.sin( x / 10 + r ) * 127 + 127, Math.cos( y / 10 + r ) * 127 + 127, 0.0, 255.0 ) );
+			}
+			tripleBuffer.doneWriting( image );
+		}
+	}
+
+	private static class ImageComponent extends JComponent
+	{
+		@Override
+		protected void paintComponent( final Graphics g )
+		{
+			final ARGBScreenImage image = tripleBuffer.getReadableBuffer().getBuffer();
+			if ( image != null )
+				g.drawImage( image.image(), 0, 0, getWidth(), getHeight(), null );
+		}
+	}
+}
+
diff --git a/src/test/java/bdv/util/TripleBufferTest.java b/src/test/java/bdv/util/TripleBufferTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..a2be68350877b4095478bdd490787e7ecc745d52
--- /dev/null
+++ b/src/test/java/bdv/util/TripleBufferTest.java
@@ -0,0 +1,74 @@
+package bdv.util;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+/**
+ * Test {@link TripleBuffer}.
+ *
+ * @author Matthias Arzt
+ */
+public class TripleBufferTest
+{
+	@Test
+	public void testSimple()
+	{
+		final TripleBuffer< ModifiableString > db = new TripleBuffer<>( ModifiableString::new );
+		db.getWritableBuffer().set( "Hello" );
+		db.doneWriting();
+		assertEquals( "Hello", db.getReadableBuffer().getBuffer().get() );
+	}
+
+	@Test
+	public void testWriteTwice()
+	{
+		final TripleBuffer< ModifiableString > db = new TripleBuffer<>( ModifiableString::new );
+		db.getWritableBuffer().set( "Hello" );
+		db.doneWriting();
+		db.getWritableBuffer().set( "World" );
+		db.doneWriting();
+		assertEquals( "World", db.getReadableBuffer().getBuffer().get() );
+	}
+
+	@Test
+	public void testReadTwice()
+	{
+		final TripleBuffer< ModifiableString > db = new TripleBuffer<>( ModifiableString::new );
+		db.getWritableBuffer().set( "Hello" );
+		db.doneWriting();
+		assertEquals( "Hello", db.getReadableBuffer().getBuffer().get() );
+		assertEquals( "Hello", db.getReadableBuffer().getBuffer().get() );
+	}
+
+	@Test
+	public void testThreeBuffers()
+	{
+		final TripleBuffer< ModifiableString > db = new TripleBuffer<>( ModifiableString::new );
+		db.getWritableBuffer().set( "1" );
+		db.doneWriting();
+		db.getReadableBuffer();
+		db.getWritableBuffer().set( "2" );
+		db.doneWriting();
+		db.getReadableBuffer();
+		db.getWritableBuffer().set( "3" );
+		db.doneWriting();
+		db.getReadableBuffer();
+		assertEquals( "1", db.getWritableBuffer().get() );
+	}
+
+	private static class ModifiableString
+	{
+		private String value = "";
+
+		public String get()
+		{
+			return value;
+		}
+
+		public void set( final String value )
+		{
+			this.value = value;
+		}
+	}
+}