diff --git a/src/main/java/bdv/viewer/render/MultiResolutionRenderer.java b/src/main/java/bdv/viewer/render/MultiResolutionRenderer.java
index d139c337a6fd4bee73988be895e3554e83dd4c14..7e114fbd540a243ee60a58e18dc3d2432184c091 100644
--- a/src/main/java/bdv/viewer/render/MultiResolutionRenderer.java
+++ b/src/main/java/bdv/viewer/render/MultiResolutionRenderer.java
@@ -28,7 +28,8 @@
  */
 package bdv.viewer.render;
 
-import bdv.viewer.RequestRepaint;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.concurrent.ExecutorService;
 
 import net.imglib2.Interval;
@@ -41,6 +42,8 @@ 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;
@@ -152,9 +155,14 @@ public class MultiResolutionRenderer
 	private ViewerState currentViewerState;
 
 	/**
-	 * How many sources are visible in {@link #currentViewerState}.
+	 * 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}).
 	 */
-	private int currentNumVisibleSources;
+	private final List< SourceAndConverter< ? > > currentVisibleSourcesOnScreen;
+
 
 	/**
 	 * The last successfully rendered (not cancelled) full frame result.
@@ -278,6 +286,7 @@ public class MultiResolutionRenderer
 		this.painterThread = painterThread;
 		projector = null;
 		currentScreenScaleIndex = -1;
+		currentVisibleSourcesOnScreen = new ArrayList<>();
 		screenScales = new ScreenScales( screenScaleFactors, targetRenderNanos );
 		renderStorage = new RenderStorage();
 
@@ -351,6 +360,7 @@ public class MultiResolutionRenderer
 		projector = null;
 		currentViewerState = null;
 		currentRenderResult = null;
+		currentVisibleSourcesOnScreen.clear();
 		renderStorage.clear();
 	}
 
@@ -384,7 +394,8 @@ public class MultiResolutionRenderer
 			if ( newInterval )
 			{
 				intervalMode = true;
-				final double renderNanosPerPixel = renderNanosPerPixelAndSource.getAverage() * currentNumVisibleSources;
+				final int numSources = currentVisibleSourcesOnScreen.size();
+				final double renderNanosPerPixel = renderNanosPerPixelAndSource.getAverage() * numSources;
 				requestedIntervalScaleIndex = screenScales.suggestIntervalScreenScale( renderNanosPerPixel, currentScreenScaleIndex );
 			}
 
@@ -410,8 +421,9 @@ public class MultiResolutionRenderer
 		if ( newFrame )
 		{
 			currentViewerState = viewerState.snapshot();
-			currentNumVisibleSources = currentViewerState.getVisibleAndPresentSources().size();
-			final double renderNanosPerPixel = renderNanosPerPixelAndSource.getAverage() * currentNumVisibleSources;
+			VisibilityUtils.computeVisibleSourcesOnScreen( currentViewerState, screenScales.get( 0 ), currentVisibleSourcesOnScreen );
+			final int numSources = currentVisibleSourcesOnScreen.size();
+			final double renderNanosPerPixel = renderNanosPerPixelAndSource.getAverage() * numSources;
 			requestedScreenScaleIndex = screenScales.suggestScreenScale( renderNanosPerPixel );
 		}
 
@@ -445,8 +457,8 @@ public class MultiResolutionRenderer
 				renderResult.setScaleFactor( screenScale.scale() );
 				currentViewerState.getViewerTransform( renderResult.getViewerTransform() );
 
-				renderStorage.checkRenewData( screenScales.get( 0 ).width(), screenScales.get( 0 ).height(), currentNumVisibleSources );
-				projector = createProjector( currentViewerState, requestedScreenScaleIndex, renderResult.getTargetImage(), 0, 0 );
+				renderStorage.checkRenewData( screenScales.get( 0 ).width(), screenScales.get( 0 ).height(), currentVisibleSourcesOnScreen.size() );
+				projector = createProjector( currentViewerState, currentVisibleSourcesOnScreen, requestedScreenScaleIndex, renderResult.getTargetImage(), 0, 0 );
 				requestNewFrameIfIncomplete = projectorFactory.requestNewFrameIfIncomplete();
 			}
 			p = projector;
@@ -496,7 +508,7 @@ public class MultiResolutionRenderer
 			{
 				intervalResult.init( intervalRenderData.width(), intervalRenderData.height() );
 				intervalResult.setScaleFactor( intervalRenderData.scale() );
-				projector = createProjector( currentViewerState, requestedIntervalScaleIndex, intervalResult.getTargetImage(), intervalRenderData.offsetX(), intervalRenderData.offsetY() );
+				projector = createProjector( currentViewerState, currentVisibleSourcesOnScreen, requestedIntervalScaleIndex, intervalResult.getTargetImage(), intervalRenderData.offsetX(), intervalRenderData.offsetY() );
 			}
 			p = projector;
 		}
@@ -543,7 +555,8 @@ public class MultiResolutionRenderer
 
 	private void recordRenderTime( final RenderResult result, final long renderNanos )
 	{
-		final int numRenderPixels = ( int ) Intervals.numElements( result.getTargetImage() ) * currentNumVisibleSources;
+		final int numSources = currentVisibleSourcesOnScreen.size();
+		final int numRenderPixels = ( int ) Intervals.numElements( result.getTargetImage() ) * numSources;
 		if ( numRenderPixels >= 4096 )
 			renderNanosPerPixelAndSource.add( renderNanos / ( double ) numRenderPixels );
 	}
@@ -597,6 +610,7 @@ public class MultiResolutionRenderer
 
 	private VolatileProjector createProjector(
 			final ViewerState viewerState,
+			final List< SourceAndConverter< ? > > visibleSourcesOnScreen,
 			final int screenScaleIndex,
 			final RandomAccessibleInterval< ARGBType > screenImage,
 			final int offsetX,
@@ -609,6 +623,7 @@ public class MultiResolutionRenderer
 
 		final VolatileProjector projector = projectorFactory.createProjector(
 				viewerState,
+				visibleSourcesOnScreen,
 				screenImage,
 				screenTransform,
 				renderStorage );
diff --git a/src/main/java/bdv/viewer/render/ProjectorFactory.java b/src/main/java/bdv/viewer/render/ProjectorFactory.java
index a6dc427965c2c15c8e8d5320ec44f3e028ce1e98..89e249e451855fb5c6c30bccf39c76f66a01c3e4 100644
--- a/src/main/java/bdv/viewer/render/ProjectorFactory.java
+++ b/src/main/java/bdv/viewer/render/ProjectorFactory.java
@@ -110,13 +110,15 @@ class ProjectorFactory
 
 	/**
 	 * Create a projector for rendering the specified {@code ViewerState} to the
-	 * specified {@code screenImage}, with the current visible sources and
+	 * 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 )
@@ -131,22 +133,20 @@ class ProjectorFactory
 		final int width = ( int ) screenImage.dimension( 0 );
 		final int height = ( int ) screenImage.dimension( 1 );
 
-		final ArrayList< SourceAndConverter< ? > > visibleSources = new ArrayList<>( viewerState.getVisibleAndPresentSources() );
-		visibleSources.sort( viewerState.sourceOrder() );
 		VolatileProjector projector;
-		if ( visibleSources.isEmpty() )
+		if ( visibleSourcesOnScreen.isEmpty() )
 			projector = new EmptyProjector<>( screenImage );
-		else if ( visibleSources.size() == 1 )
+		else if ( visibleSourcesOnScreen.size() == 1 )
 		{
 			final byte[] maskArray = renderStorage.getMaskArray( 0 );
-			projector = createSingleSourceProjector( viewerState, visibleSources.get( 0 ), screenImage, screenTransform, maskArray );
+			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 : visibleSources )
+			for ( final SourceAndConverter< ? > source : visibleSourcesOnScreen )
 			{
 				final RandomAccessibleInterval< ARGBType > renderImage = renderStorage.getRenderImage( width, height, j );
 				final byte[] maskArray = renderStorage.getMaskArray( j );
@@ -155,7 +155,7 @@ class ProjectorFactory
 				sourceProjectors.add( p );
 				sourceImages.add( renderImage );
 			}
-			projector = accumulateProjectorFactory.createProjector( sourceProjectors, visibleSources, sourceImages, screenImage, numRenderingThreads, renderingExecutorService );
+			projector = accumulateProjectorFactory.createProjector( sourceProjectors, visibleSourcesOnScreen, sourceImages, screenImage, numRenderingThreads, renderingExecutorService );
 		}
 		previousTimepoint = viewerState.getCurrentTimepoint();
 		return projector;
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.
+}